안드로이드 연구소

[안드로이드 클린아키텍처] 안드로이드 아키텍처 가이드라인 만드는 법2 (데이터 레이어/Retrofit연결/Room연결) 본문

안드로이드 연구소/클린아키텍처

[안드로이드 클린아키텍처] 안드로이드 아키텍처 가이드라인 만드는 법2 (데이터 레이어/Retrofit연결/Room연결)

안드로이드 연구원 2023. 8. 16. 00:30

안녕하세요 안드로이드 개발자입니다.

오늘부터는 실제로 안드로이드 클린 아키텍처인

안드로이드 앱 아키텍처 가이드라인대로 샘플 프로젝트를 만들어볼텐데요.

 

이런 분들이 이 포스터를 봐주셨으면 좋겠습니다.

- 클린 아키텍처 프로젝트를 만드시려는 분

- 연결할 API가 없어서 토이 프로젝트가 만드시기 힘드신 분

- 만들어둔 프로젝트를 클린 아키텍처로 프로젝트 구조를 변경하시려는 분

 

 

1. 앞으로 만들어볼 프로젝트 

가장 단순하게 만들어볼 프로젝트의 기능은 아래와 같습니다.

1. Retrofit2을 이용해여 서버로부터 이미지와 텍스트를 리스트로 [갤러리 탭]에서 불러온 뒤

2. Room을 이용하여 클릭한 아이템은 내부 데이터베이스에 저장됩니다.

3. 데이터베이스에서 저장된 아이템의 리스트를 [저장 탭]에서 불러오는 기능입니다.

 

갤러리 탭

 

저장 탭

절대 허접한게 아니고

최소한의 기능으로 단순하게 설명하기위해서

허접하게 하였습니다(?)

 

추후에 다양한 기술들로 기능들을 추가해보도록 하겠습니다.

 

 

2. 바텀 네비게이션 만들기

메인엑티비티에서 갤러리탭과 저장탭을 나눠서 사용할 수 있도록 하는 기능입니다. 

 

build.gradle에서 dataBiding과 viewBinding을 사용할 수 있도록 합니다.

    buildFeatures {
        dataBinding = true
        viewBinding = true
    }
    viewBinding{
        enable = true
    }

 

그 후 res폴더에 [menu] 디렉토리를 생성합니다.

그리고 bottom_navigation_menu.xml을 생성합니다.

그리고 원하는 탭의 갯수만큼 아이템을 만들어줍니다.(저희는 갤러리와 저장 2개)

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/page_gallery"
        android:enabled="true"
        android:icon="@drawable/baseline_format_list_bulleted_24"
        android:title="갤러리"/>

    <item
        android:id="@+id/page_favorite"
        android:enabled="true"
        android:icon="@drawable/baseline_favorite_border_24"
        android:title="저장"/>
</menu>

 

그 후 activity_main.xmd에서

BottomNavigationView를 만들어줍니다.

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:orientation="vertical"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"/>

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottomNavigationView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:menu="@menu/bottom_navigation_menu" />

    </androidx.constraintlayout.widget.ConstraintLayout>

 

 

그 후 MainAcitvity에서 

BottomNavigationView.OnNavigationItemSelectedListener 상속받습니다.

class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {

 

onNavigationItemSelected(item: MenuItem)을 상속받아 클릭 이벤트를 처리합니다.

그 후 빈 프레그먼트로 GalleryFragment와 SaveFragment를 생성하여 연결합니다.

override fun onNavigationItemSelected(item: MenuItem): Boolean {
    when(item.itemId) {
        R.id.page_gallery -> {
            supportFragmentManager.beginTransaction().replace(R.id.linearLayout , GalleryFragment()).commitAllowingStateLoss()
            return true
        }
        R.id.page_favorite -> {
            supportFragmentManager.beginTransaction().replace(R.id.linearLayout, SaveFragment()).commitAllowingStateLoss()
            return true
        }
    }
    return false
}

 

그리고 setOnNavigationItemSelectedListener(this)를 이용하여

xml에 만들어둔 bottomNavigationView와 연결합니다.

private lateinit var binding : ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    // navigationView
    binding.bottomNavigationView.setOnNavigationItemSelectedListener(this)
    supportFragmentManager.beginTransaction().add(R.id.linearLayout, GalleryFragment()).commit()
}

 

 

3. 디렉토리 생성

클린 아키텍처의 필수는 Data레이어, Domain레이어, Presentation레이어의 경계를 나누고

그 외에도 DI 모듈들을 관리하는 디렉토리와 그 외에 파일을 관리하는 Util디렉토리를 나눠줍니다. 

(이때 디렉토리명은 대문자로 하면 databinding에서 에러가 납니다. **소문자 필수**)

 

 

4. 그럼 가장 먼저 DI세팅

저번 포스트에서 클린 아키텍처에서 가장 중요한 요소는 DI 라이브러리라고 얘기를 했었는데요.

이번에도 Hilt를 사용할 수 있도록 세팅을 해보도록 하겠습니다.

 

https://android-lab.tistory.com/42

 

[안드로이드 클린아키텍처] 안드로이드 아키텍처 가이드라인 만드는 법1(Hilt Retrofit 연결하는법/H

안녕하세요 안드로이드 개발자입니다. 저는 최근 한주간 강의를 들으면서 클린아키텍처에 대해서 더 배우고 제 간단한 샘플 프로젝트를 만들면서 어떻게 강의를 해야할지 고민하던 와중에...

android-lab.tistory.com

 

첫번째, build.gradle(app)

plugins {
    id("com.google.dagger.hilt.android") version "2.44" apply false
}

 

두번째, build.gradle(android)

plugins {
    id("dagger.hilt.android.plugin")
}

dependencies {
    implementation 'com.google.dagger:hilt-android:$version'
    kapt 'com.google.dagger:hilt-android-compiler:$version'
}

최신버전: https://developer.android.com/jetpack/androidx/releases/hilt?hl=ko

 

세번쨰, HiltApplication.kt 파일 생성

@HiltAndroidApp으로 주석을 달아 Hilt를 활성화합니다.

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class HiltApplication : Application()

 

마지막으로 AndroidManifest에서

아래처럼 이름을 변경해주면 기본 세팅 끝!

    <application
        android:name=".HiltApplication"

 

 

5. Data레이어 만들기(DataSource, Repository)

클린 아키텍처에서 데이터 레이어는 애플리케이션을 만드는데 필요한 데이터를

생성하거나 저장 또는 변경하는데 필요한 로직들이 있는 계층입니다.

간단히 얘기하면 레파지토리와 데이터 소스가 이 레이어에 해당합니다.

 

[안드로이드 클린 아키텍처] 안드로이드 앱 아키텍처 가이드라인(Data레이어)

Q1. Data 레이어에 대해 설명해줘 https://developer.android.com/topic/architecture/data-layer Data 레이어에서는 비즈니스 로직이 포함되어 있습니다. 비즈니스 로직은 앱이 데이터를 생성, 저장 및 변경하는 방

android-lab.tistory.com

 

buld.gradle에서 추가 후

plugins {
    id("kotlin-kapt")
}

레트로핏과 Room도 불러옵니다.

dependencies {
    //Retrofit
    implementation ("com.squareup.okhttp3:okhttp:4.10.0")
    implementation ("com.squareup.retrofit2:retrofit:2.9.0")
    implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation ("com.squareup.okhttp3:logging-interceptor:4.10.0")

    //room
    val room_version = "2.5.0"
    implementation ("androidx.room:room-ktx:$room_version")
    implementation ("androidx.room:room-runtime:$room_version")
    annotationProcessor ("androidx.room:room-compiler:$room_version")
    kapt ("androidx.room:room-compiler:$room_version")
}

 

데이터 레이어안에서는 3가지 디렉토리를 나눠줍니다.

폴더 안에 폴더;;

 

5-1.  Model

가장 먼저 다룰 데이터의 타입을 설정하는 model부터 보겠습니다.

 

model을 설정하기 위해서는 저희가 받을 데이터가 어떤지부터 알아야하는데요.

따로 가지고 있는 API가 없기 때문에 공공api를 사용하였는데요.

저는 한국관광공사의 관광사진 정보를 이용하였습니다.

https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15101914#/API%20%EB%AA%A9%EB%A1%9D/gallerySearchList1 

(로그인하셔서 실시간으로 신청하시면 serviceKey를 받으셔서 이용하실 수 있습니다.)

 

아래 필수값 파라미터를 넣어 요청하면

 

이렇게 요청값을 리스트 형태로 얻을 수 있습니다.

"response": {
    "header": {
        "resultCode": "0000",
        "resultMsg": "OK"
    },
    "body": {
        "items": {
            "item": [
                {
                    "galContentId": "2859292",
                    "galContentTypeId": "17",
                    "galTitle": "1100고지습지",
                    "galWebImageUrl": "http://tong.visitkorea.or.kr/cms2/website/92/2859292.jpg",
                    "galCreatedtime": "20220926105242",
                    "galModifiedtime": "20220926105253",
                    "galPhotographyMonth": "202207",
                    "galPhotographyLocation": "제주특별자치도 서귀포시",
                    "galPhotographer": "한국관광공사 이범수",
                    "galSearchKeyword": "1100고지습지, 제주특별자치도 서귀포시, 제주도 오름, 제주오름, 1100고지 탐방로"
                },
                {
                    "galContentId": "1849005",
                    "galContentTypeId": "17",
                    "galTitle": "1100고지휴게소",
                    "galWebImageUrl": "http://tong.visitkorea.or.kr/cms2/website/05/1849005.jpg",
                    "galCreatedtime": "20131004171049",
                    "galModifiedtime": "20141103151501",
                    "galPhotographyMonth": "201308",
                    "galPhotographyLocation": "제주도",
                    "galPhotographer": "한국관광공사 김지호",
                    "galSearchKeyword": "제주도, 1100고지휴게소, 천백 고지 휴게소"
                },
                {

 

위에 응답 데이터를 바탕으로 데이터 클래스를 생성합니다.(굳이 파일을 두개로 분리안해도 됨)

 

NetworkDto.kt

data class GalleryListResponse(
    var response : GalleryListObject
)

data class GalleryListObject(
    var body: GalleryListBody
)

data class GalleryListBody(
    var items: GalleryListItems,
    var numOfRows: Int,
    var pageNo: Int,
    var totalCount: Int
)

data class GalleryListItems(
    var item: List<GalleryDto>
)

 

GalleryDto.kt

data class GalleryDto(
    var galContentId: String,
    var galContentTypeId: String,
    var galTitle: String,
    var galWebImageUrl: String,
    var galCreatedtime: String,
    var galModifiedtime: String,
    var galPhotographyMonth: String,
    var galPhotographyLocation: String,
    var galPhotographer: String,
    var galSearchKeyword: String
)

 

GalleryEntity.kt는 위의 서버용 데이터 클래스가 아닌

데이터베이스 room에서 필요한 데이터 클래스입니다.(미리 만들어두기)

@Entity(tableName = "Gallery")
data class GalleryEntity (
    @PrimaryKey(false)
    var galContentId: String,
    @ColumnInfo
    var galContentTypeId: String,
    @ColumnInfo
    var galTitle: String,
    @ColumnInfo
    var galWebImageUrl: String,
    @ColumnInfo
    var galCreatedtime: String,
    @ColumnInfo
    var galModifiedtime: String,
    @ColumnInfo
    var galPhotographyMonth: String,
    @ColumnInfo
    var galPhotographyLocation: String,
    @ColumnInfo
    var galPhotographer: String,
    @ColumnInfo
    var galSearchKeyword: String
)

5-2. DataSource

두번째로 source는 "데이터 소스"를 보겠습니다.

말 그대로 데이터를 가져오는 원천같은 곳이고 저희에게는 서버 데이터베이스가 되겠습니다.

local 데이터베이스로부터 받은 데이터, remote 서버로부터 받은 데이터입니다.

클린 아키텍처 특: 안 클린함

 

5-2 (A). DataSource의 remote

GalleryServiceApi 데이터 소스 인터페이스를 만들어줍니다. (서버로부터 받는 데이터는 suspend fun 비동기로 !)

interface GalleryServiceApi {

    @GET("galleryList1")
    suspend fun getGlleryList1(
        @Query("MobileOS") MobileOS: String,
        @Query("MobileApp") MobileApp: String,
        @Query("serviceKey", encoded = true) serviceKey: String, // encoded = true; 공공데이터 SERVICE_KEY_IS_NOT_REGISTERED_ERROR 해결
        @Query("_type") _type: String
    ) : GalleryListResponse
}

 

그리고 Retrofit2을 불러오는 클래스가 필요한데

이 클래스는 레파지토리에서 의존성 주입을 해서 받아야하기 때문에 

Hilt 모듈로 생성합니다.

 

RetrofitModule.kt을 통해서 retrofit 인스턴스를 받을 수 있게 해줍니다.

@Module
@InstallIn(SingletonComponent::class)
object RetrofitModule {

    @Singleton
    @Provides
    fun provideConverterFactory(): GsonConverterFactory {
        return GsonConverterFactory.create(
            GsonBuilder()
                .setLenient() // 규칙 완화 Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 1 path $ error
                .create()
        )
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(application: Application): OkHttpClient.Builder {
        return OkHttpClient.Builder().apply {
            connectTimeout(5, TimeUnit.SECONDS)
            readTimeout(5, TimeUnit.SECONDS)
            writeTimeout(5, TimeUnit.SECONDS)
            addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
        }
    }

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient.Builder, gsonConverterFactory: GsonConverterFactory): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://apis.data.go.kr/B551011/PhotoGalleryService1/")
            .addConverterFactory(gsonConverterFactory)
            .client(client.build())
            .build()
    }
}

 

ServiceModule.kt에서 위에서 사용할 수 있게 만든 retrofit 인스턴스로

GalleryServiceApi 인터페이스를 사용할 수 있도록 연결합니다.

@Module // 특정 유형의 인스턴스를 제공하는 방법을 Hilt에 알려줍니다
@InstallIn(SingletonComponent::class) // SingletonComponent은 전체 Application에 삽입할 것 임을 선언
object ServiceModule {

    @Singleton // SingletonComponent와 세트
    @Provides // 인스턴스 삽입할 내용 (Provides와 Bind중에 사용)
    fun provideGalleryServiceApi(retrofit: Retrofit): GalleryServiceApi {
        return retrofit.create(GalleryServiceApi::class.java)
    }
}

 

이제 레포지토리에서 사용할 수 있도록 함수화해놓으면 되는데

그전에 먼저 데이터베이스 데이터 소스를 연결하는 법을 먼저 해보도록 하겠습니다.

 

5-2 (B). DataSource의 local

이제 room라이브러리를 이용해서 데이터베이스와 연결해보게 될 예정입니다.

 

source > local 디렉토리에서 GalleryDao를 생성하여서 자신에게 필요한 쿼리문을 만들어줍니다.

저희는 저장할 사진 정보들을 읽어올 selectAll과 insert메서드 두개만 만들어보겠습니다.

@Dao
interface GalleryDao {
    @Query("SELECT * FROM Gallery")
    fun selectAll(): Flow<List<GalleryEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: GalleryEntity)
}

 

그 다음으로 room에 필요한 AppDatabase를 생성해줍니다.

@Database(entities = [GalleryEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun galleryDao() : GalleryDao
}

 

데이터 베이스도 레파지토리에서 사용할 수 있도록 Hilt모듈로 생성합니다.

먼저, di 디렉토리에서 DatabaseModule로 만들어줍니다.

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Singleton
    @Provides
    fun providesDataBase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(appContext, AppDatabase::class.java, "gallery.db")
            .fallbackToDestructiveMigration()
            .build()
    }
}

 

그리고 위에서 만든 AppData를 인스턴스로 받아

GalleryDao와 데이터베이스가 연결할 수 있도록 모듈을 세팅합니다.

@Module
@InstallIn(SingletonComponent::class)
object DaoModule {

    @Singleton
    @Provides
    fun providesGalleryDao(appDatabase: AppDatabase) : GalleryDao {
        return appDatabase.galleryDao()
    }
}

 

 

5-3. Repository

이제 마지막으로 위에서 만들어놓은 데이터소스를 레파지토리에서 호출할 수 있도록 함수화해놓는데요.

위에서 만들어놓은 ServiceModule과 DaoModule 덕분에

GalleryServiceApi와 GalleryDao 인스턴스를 의존성 주입 받아 사용할 수 있도록 하였습니다.

class GalleryRepository @Inject constructor(private val galleryServiceApi: GalleryServiceApi, private val galleryDao: GalleryDao) {
    // 서버로부터 불러오기
    suspend fun getGalleryList(): List<GalleryModel> {
        return galleryServiceApi.getGlleryList1("AND", "photoGallery", SOURCE_KEY, "json")
            .response.body.items.item
            .map { it.toGallery() }
    }

    // 데이터베이스로부터 불러오기
    suspend fun getGallerySavedList(): Flow<List<GalleryModel>> {
        return flow {
            Log.d("getGallerySavedList", "getGallerySavedList")
            galleryDao.selectAll().collect { list ->
                emit(list.map { it.toModel() })
            }
        }
    }

    // 데이터베이스에 삽입
    suspend fun insert(item: GalleryModel): Boolean{
        return try {
            galleryDao.insert(item.toEntity())
            true
        } catch (e: IOException){
            false
        }
    }
}

 

그리고 또 여기서 주목할점은 공통으로 List<GalleryModel>로 데이터 형을 받기위해서

GalleryDto를 .toGallery() 사용하여 형 변환을 하고 (Flow 사용 안한 예시)

GalleryEntity를 .toModel()을 사용하여 형 변환을 해주었습니다. (Flow 사용한 예시)

// 서버로부터 불러오기
suspend fun getGalleryList(): List<GalleryModel> {
    return galleryServiceApi.getGlleryList1("AND", "photoGallery", SOURCE_KEY, "json")
        .response.body.items.item
        .map { it.toGallery() }
}

// 데이터베이스로부터 불러오기
suspend fun getGallerySavedList(): Flow<List<GalleryModel>> {
    return flow {
        Log.d("getGallerySavedList", "getGallerySavedList")
        galleryDao.selectAll().collect { list ->
            emit(list.map { it.toModel() })
        }
    }
}

 

이는 Data 디렉토리에 GalleryMapper.Object 클래스를 생성해준뒤에 아래와 같이 만들어줍니다.

(toEntity()도 나중에 써서 미리 만들기)

package com.example.photogallery.data

import com.example.photogallery.data.model.dto.GalleryDto
import com.example.photogallery.data.model.entity.GalleryEntity
import com.example.photogallery.domain.model.GalleryModel

// 데이터레이어의 dto를 도메인레이어의 model로 형변환
object GalleryMapper {
    // Dto -> Model
    fun GalleryDto.toGallery() = GalleryModel(
        galContentId = galContentId,
        galContentTypeId = galContentTypeId,
        galTitle = galTitle,
        galWebImageUrl = galWebImageUrl,
        galCreatedtime = galCreatedtime,
        galModifiedtime = galModifiedtime,
        galPhotographyMonth = galPhotographyMonth,
        galPhotographyLocation = galPhotographyLocation,
        galPhotographer = galPhotographer,
        galSearchKeyword = galSearchKeyword
    )

    // Entity -> Model
    fun GalleryEntity.toModel() = GalleryModel(
        galContentId = galContentId,
        galContentTypeId = galContentTypeId,
        galTitle = galTitle,
        galWebImageUrl = galWebImageUrl,
        galCreatedtime = galCreatedtime,
        galModifiedtime = galModifiedtime,
        galPhotographyMonth = galPhotographyMonth,
        galPhotographyLocation = galPhotographyLocation,
        galPhotographer = galPhotographer,
        galSearchKeyword = galSearchKeyword
    )
    
    // Model -> Entity
    fun GalleryModel.toEntity() = GalleryEntity(
        galContentId = galContentId,
        galContentTypeId = galContentTypeId,
        galTitle = galTitle,
        galWebImageUrl = galWebImageUrl,
        galCreatedtime = galCreatedtime,
        galModifiedtime = galModifiedtime,
        galPhotographyMonth = galPhotographyMonth,
        galPhotographyLocation = galPhotographyLocation,
        galPhotographer = galPhotographer,
        galSearchKeyword = galSearchKeyword
    )
}

여기까지 하면 데이터레이어이 완성이 되었는데요.

저는 클린아키텍처 세팅하는 부분에서 데이터 레이어가 제일 복잡한 것 같습니다.(이제 70프로 해결! 30프로 남음)

 

이제 데이터소스를 연결한 레파지토리를 사용해서

도메인 레이어와 프레젠테이션 레이어에서 사용하기만 하면 됩니다!!

 

다음 내용들은 다음 포스트에서 올려보도록 하겠습니다!

감사합니다.

 

Comments