저장소 추가 및 수동 DI

1. 시작하기 전에

소개

이전 Codelab에서는 ViewModel이 API 서비스를 사용하여 네트워크에서 화성 사진의 URL을 검색하도록 하여 웹 서비스에서 데이터를 가져오는 방법을 알아봤습니다. 이 접근 방식은 효과적이며 간단하게 구현되지만 앱이 커지고 둘 이상의 데이터 소스를 다루게 되면 확장성이 떨어집니다. 이 문제를 해결하려면 Android 아키텍처 권장사항에 따라 UI 레이어와 데이터 레이어를 분리하는 것이 좋습니다.

이 Codelab에서는 Mars Photos 앱을 별도의 UI 레이어 및 데이터 레이어로 리팩터링합니다. 저장소 패턴을 구현하고 종속 항목 삽입을 사용하는 방법을 알아봅니다. 종속 항목 삽입은 개발과 테스트에 유용한 더 유연한 코딩 구조를 만듭니다.

기본 요건

  • Retrofit 라이브러리와 Serialization(kotlinx.serialization) 라이브러리를 사용하여 REST 웹 서비스에서 JSON을 검색하고 이 데이터를 Kotlin 객체로 파싱하는 능력
  • REST 웹 서비스 사용 방법에 관한 지식
  • 앱에 코루틴을 구현하는 능력

학습할 내용

  • 저장소 패턴
  • 종속 항목 삽입

빌드할 항목

  • 앱이 UI 레이어와 데이터 레이어로 분리되도록 Mars Photos 앱을 수정합니다.
  • 데이터 레이어를 분리하는 동안 저장소 패턴을 구현합니다.
  • 종속 항목 삽입을 사용하여 느슨하게 결합된 코드베이스를 만듭니다.

필요한 항목

  • 최신 버전의 Chrome과 같은 최신 웹브라우저가 설치된 컴퓨터

시작 코드 가져오기

시작하려면 시작 코드를 다운로드하세요.

또는 코드에 관한 GitHub 저장소를 클론해도 됩니다.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout repo-starter

Mars Photos GitHub 저장소에서 코드를 찾아볼 수 있습니다.

2. UI 레이어와 데이터 레이어 분리

레이어를 분리하는 이유

코드를 여러 레이어로 분리하면 앱의 확장성이 높아지며 앱이 더 견고해지고 테스트하기 더 쉬워집니다. 또한 경계가 명확히 정의된 여러 레이어를 사용하면 여러 개발자가 서로에게 부정적인 영향을 주지 않고 동일한 앱을 더 쉽게 작업할 수 있습니다.

Android의 권장 앱 아키텍처에는 앱에 최소한 UI 레이어와 데이터 레이어가 반드시 있어야 한다고 명시되어 있습니다.

이 Codelab에서는 데이터 레이어를 집중적으로 살펴보고 앱을 권장사항에 따라 변경합니다.

데이터 레이어 정의

데이터 레이어는 앱의 비즈니스 로직과 앱 데이터 소싱 및 저장을 담당합니다. 데이터 레이어는 단방향 데이터 흐름 패턴을 사용하여 UI 레이어에 데이터를 노출합니다. 데이터는 네트워크 요청, 로컬 데이터베이스, 기기의 파일 등 여러 소스에서 가져올 수 있습니다.

앱에 데이터 소스가 두 개 이상 있을 수도 있습니다. 앱이 열리면 첫 번째 소스인 기기의 로컬 데이터베이스에서 데이터를 검색합니다. 앱은 실행되는 동안 두 번째 소스에 네트워크를 요청하여 최신 데이터를 가져옵니다.

UI 코드와 별도의 레이어에 데이터를 배치하면 코드의 한 부분에서 변경해도 다른 부분에 영향을 주지 않습니다. 이 접근 방식은 관심사 분리라는 디자인 원칙의 일부입니다. 코드의 한 섹션은 자체 관심사에 초점을 맞추며 내부 작동 정보를 다른 코드로부터 별도로 캡슐화합니다. 캡슐화는 내부적으로 코드가 작동하는 방식을 코드의 다른 섹션으로부터 숨기는 방식입니다. 코드의 한 섹션이 다른 섹션과 상호작용해야 하는 경우 인터페이스를 통해 처리합니다.

UI 레이어의 관심사는 제공된 데이터를 표시하는 것입니다. 데이터 검색은 데이터 레이어의 관심사이므로, UI는 더 이상 데이터를 검색하지 않습니다.

데이터 레이어는 하나 이상의 저장소로 구성됩니다. 저장소 자체에는 0개 이상의 데이터 소스가 포함됩니다.

dbf927072d3070f0.png

권장사항에 따르면 앱에 사용되는 데이터 소스 유형별로 저장소가 있어야 합니다.

이 Codelab에서는 앱의 데이터 소스가 1개이므로 코드를 리팩터링한 후에 앱은 저장소 하나를 가집니다. 이 앱의 경우 인터넷에서 데이터를 가져오는 저장소가 데이터 소스의 작업을 완료합니다. API에 네트워크 요청을 하는 방법으로 처리됩니다. 데이터 소스 코딩이 더 복잡하거나 데이터 소스가 추가되는 경우 데이터 소스 작업이 별도의 데이터 소스 클래스에 캡슐화되며 저장소가 모든 데이터 소스를 관리합니다.

저장소 정의

일반적으로 저장소 클래스는 다음을 실행합니다.

  • 앱의 나머지 부분에 데이터 노출
  • 데이터 변경사항을 한곳으로 일원화
  • 여러 데이터 소스 간의 충돌 해결
  • 앱의 나머지 부분에서 데이터 소스 추상화
  • 비즈니스 로직 포함

Mars Photos에는 단일 데이터 소스(네트워크 API 호출)가 있습니다. 데이터를 가져오기만 하므로 비즈니스 로직이 없습니다. 데이터는 데이터 소스를 추상화하는 저장소 클래스를 통해 앱에 노출됩니다.

ff7a7cd039402747.png

3. 데이터 레이어 만들기

먼저 저장소 클래스를 만들어야 합니다. Android 개발자 가이드에서는 저장소 클래스의 이름이 관련 데이터에 따라 지정된다고 설명합니다. 저장소 이름 지정 규칙데이터 유형 + 저장소입니다. 이 앱의 경우 MarsPhotosRepository입니다.

저장소 만들기

  1. com.example.marsphotos를 마우스 오른쪽 버튼으로 클릭하고 New > Package를 선택합니다.
  2. 대화상자에 data를 입력합니다.
  3. data 패키지를 마우스 오른쪽 버튼으로 클릭하고 New > Kotlin Class/File을 선택합니다.
  4. 대화상자에서 Interface를 선택하고 인터페이스 이름으로 MarsPhotosRepository를 입력합니다.
  5. MarsPhotosRepository 인터페이스 내부에서 getMarsPhotos()라는 추상 함수를 추가합니다. 이 함수는 MarsPhoto 객체 목록을 반환합니다. 코루틴에서 호출되므로 suspend로 선언합니다.
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. 인터페이스 선언 아래에서 MarsPhotosRepository 인터페이스를 구현하기 위한 NetworkMarsPhotosRepository라는 클래스를 만듭니다.
  2. MarsPhotosRepository 인터페이스를 클래스 선언에 추가합니다.

인터페이스의 추상 메서드를 재정의하지 않았기 때문에 오류 메시지가 표시됩니다. 이 오류는 다음 단계에서 해결합니다.

MarsPhotosRepository 인터페이스와 NetworkMarsPhotosRepository 클래스를 보여주는 Android 스튜디오 스크린샷

  1. NetworkMarsPhotosRepository 클래스 내부에서 추상 함수 getMarsPhotos()를 재정의합니다. 이 함수는 MarsApi.retrofitService.getPhotos() 호출을 통해 데이터를 반환합니다.
import com.example.marsphotos.network.MarsApi

class NetworkMarsPhotosRepository() : MarsPhotosRepository {
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return MarsApi.retrofitService.getPhotos()
   }
}

다음으로, Android 권장사항에 따라 저장소를 사용해 데이터를 가져오도록 ViewModel 코드를 업데이트해야 합니다.

  1. ui/screens/MarsViewModel.kt 파일을 엽니다.
  2. 아래로 스크롤하여 getMarsPhotos() 메서드를 찾습니다.
  3. 'val listResult = MarsApi.retrofitService.getPhotos()' 줄을 다음 코드로 바꿉니다.
import com.example.marsphotos.data.NetworkMarsPhotosRepository

val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()

5313985852c151aa.png

  1. 앱을 실행합니다. 표시된 결과가 이전 결과와 같은 것을 확인할 수 있습니다.

ViewModel이 데이터의 네트워크 요청을 직접 실행하지 않고, 저장소가 데이터를 제공합니다. ViewModel는 더 이상 MarsApi 코드를 직접 참조하지 않습니다. 이전에 Viewmodel에서 곧바로 데이터 영역에 액세스하던 방법을 보여주는 흐름 다이어그램. 우리에게는 이제 화성 사진 저장소가 있습니다.

이 접근 방식은 데이터를 가져오는 코드가 ViewModel에서 느슨하게 결합되도록 만드는 데 도움이 됩니다. 느슨하게 결합되면 저장소에 getMarsPhotos()라는 함수가 있는 한 다른 항목에 부정적인 영향을 미치지 않고 ViewModel 또는 저장소를 변경할 수 있습니다.

이제 호출자에 영향을 주지 않고 저장소 내부의 구현을 변경할 수 있습니다. 대규모 앱의 경우 이 변경으로 여러 호출자를 지원할 수 있습니다.

4. 종속 항목 삽입

클래스가 작동하려면 다른 클래스의 객체가 필요한 경우가 많습니다. 클래스에 다른 클래스가 필요한 경우 필요한 클래스를 종속 항목이라고 합니다.

다음 예에서 Car 객체는 Engine 객체에 종속됩니다.

필요한 이 객체를 클래스가 가져오는 방법에는 두 가지가 있습니다. 한 가지 방법은 클래스가 필요한 객체 자체를 인스턴스화하는 것입니다.

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car {

    private val engine = GasEngine()

    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

다른 방법은 필요한 객체를 인수로 전달하는 것입니다.

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = GasEngine()
    val car = Car(engine)
    car.start()
}

클래스가 필요한 객체를 인스턴스화하도록 하기는 쉽지만 이 접근 방식을 사용하면 클래스와 필요한 객체 간의 긴밀한 결합으로 인해 코드가 유연하지 않고 테스트하기 더 어려워집니다.

호출 클래스가 구현 세부정보인 객체의 생성자를 호출해야 합니다. 생성자가 변경되면 호출 코드도 변경해야 합니다.

코드의 유연성과 적응성을 높이려면 클래스가 종속되는 객체를 인스턴스화하면 안 됩니다. 종속되는 객체는 클래스 외부에서 인스턴스화한 후 전달해야 합니다. 이 접근 방식을 사용하면 클래스가 더 이상 하나의 특정 객체에 하드코딩되지 않으므로 더 유연한 코드가 생성됩니다. 호출 코드를 수정할 필요 없이 필요한 객체의 구현을 변경할 수 있습니다.

앞의 예에서 ElectricEngine이 필요한 경우 이를 생성하여 Car 클래스 에 전달할 수 있습니다. Car 클래스를 어떤 식으로든 수정할 필요가 없습니다.

interface Engine {
    fun start()
}

class ElectricEngine : Engine {
    override fun start() {
        println("ElectricEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = ElectricEngine()
    val car = Car(engine)
    car.start()
}

필요한 객체를 전달하는 것을 종속 항목 삽입(DI)이라고 합니다. 컨트롤 반전이라고도 합니다.

DI는 종속 항목이 호출 클래스에 하드코딩되는 대신 런타임에 제공되는 경우를 말합니다.

종속 항목 삽입을 구현하면 다음이 가능합니다.

  • 코드 재사용성 지원. 코드가 특정 객체에 종속되지 않으므로 유연성이 높습니다.
  • 리팩터링 편의성 향상. 코드가 느슨하게 연결되므로 코드의 한 섹션을 리팩터링해도 다른 섹션에 영향을 미치지 않습니다.
  • 테스트 지원. 테스트 중에 테스트 객체를 전달할 수 있습니다.

DI가 테스트에 도움이 되는 예로 네트워크 호출 코드 테스트를 들 수 있습니다. 이 테스트에서는 네트워크 호출이 이루어지는지, 그리고 호출 후 데이터가 반환되는지 실제로 테스트하려고 합니다. 테스트 중 네트워크 요청을 할 때마다 비용을 지불해야 하는 경우 비용이 많이 들 수 있으므로 이 코드 테스트를 건너뛰겠다고 생각할 수도 있습니다. 테스트용으로 가짜 네트워크 요청을 할 수 있다면 어떨까요? 얼마나 만족스럽고 비용도 절감할 수 있을까요? 테스트를 위해, 네트워크 호출을 실제로 실행하지 않고 호출될 때 모조 데이터를 반환하는 테스트 객체를 저장소에 전달할 수 있습니다. 1ea410d6670b7670.png

ViewModel을 테스트하기 쉽게 만들고 싶지만 현재 이 코드는 실제 네트워크 호출을 실행하는 저장소에 종속됩니다. 실제 프로덕션 저장소로 테스트할 때는 많은 네트워크 호출을 실행합니다. 이 문제를 해결하려면 ViewModel로 저장소를 만드는 대신 프로덕션에 사용하고 동적으로 테스트할 저장소 인스턴스를 결정하고 전달하는 방법이 필요합니다.

MarsViewModel에 저장소를 제공하는 애플리케이션 컨테이너를 구현하면 이 프로세스가 완료됩니다.

컨테이너는 앱에 필요한 종속 항목이 포함된 객체입니다. 이러한 종속 항목은 전체 애플리케이션에 걸쳐 사용되므로 모든 활동에서 사용할 수 있는 일반적인 위치에 배치해야 합니다. Application 클래스의 서브클래스를 만들고 컨테이너 참조를 저장할 수 있습니다.

애플리케이션 컨테이너 만들기

  1. data 패키지를 마우스 오른쪽 버튼으로 클릭하고 New > Kotlin Class/File을 선택합니다.
  2. 대화상자에서 Interface를 선택하고 인터페이스 이름으로 AppContainer를 입력합니다.
  3. AppContainer 인터페이스 내부에서 MarsPhotosRepository 유형의 marsPhotosRepository라는 추상 속성을 추가합니다. 7ed26c6dcf607a55.png
  4. 인터페이스 정의 아래에 AppContainer 인터페이스를 구현하는 DefaultAppContainer라는 클래스를 만듭니다.
  5. network/MarsApiService.kt에서 BASE_URL, retrofit, retrofitService 변수의 코드를 DefaultAppContainer 클래스로 이동하여 종속 항목을 유지하는 컨테이너 내에 모두 배치합니다.
import retrofit2.Retrofit
import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType

class DefaultAppContainer : AppContainer {

    private const val BASE_URL =
        "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(BASE_URL)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

}
  1. BASE_URL 변수의 경우 const 키워드를 삭제합니다. BASE_URL이 더 이상 최상위 변수가 아니며 이제 DefaultAppContainer 클래스의 속성이므로 const를 삭제해야 합니다. 카멜 표기법 baseUrl로 리팩터링합니다.
  2. retrofitService 변수에는 private 공개 상태 수정자를 추가합니다. retrofitService 변수는 클래스 내부의 marsPhotosRepository 속성에 의해서만 사용되므로 클래스 외부에서 액세스할 필요가 없기 때문에 private 수정자를 추가합니다.
  3. DefaultAppContainer 클래스가 AppContainer 인터페이스를 구현하므로 marsPhotosRepository 속성을 재정의해야 합니다. retrofitService 변수 뒤에 다음 코드를 추가합니다.
override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
}

완성된 DefaultAppContainer 클래스는 다음과 같습니다.

class DefaultAppContainer : AppContainer {

    private val baseUrl =
        "https://android-kotlin-fun-mars-server.appspot.com"

    /**
     * Use the Retrofit builder to build a retrofit object using a kotlinx.serialization converter
     */
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()
    
    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}
  1. data/MarsPhotosRepository.kt 파일을 엽니다. 이제 retrofitServiceNetworkMarsPhotosRepository에 전달하므로 NetworkMarsPhotosRepository 클래스를 수정해야 합니다.
  2. NetworkMarsPhotosRepository 클래스 선언에서 다음 코드와 같이 생성자 매개변수 marsApiService를 추가합니다.
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. NetworkMarsPhotosRepository 클래스의 getMarsPhotos() 함수에서 데이터를 marsApiService에서 가져오도록 return 문을 변경합니다.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. MarsPhotosRepository.kt 파일에서 다음과 같은 가져오기를 삭제합니다.
// Remove
import com.example.marsphotos.network.MarsApi

network/MarsApiService.kt 파일에서 모든 코드를 객체 밖으로 이동했습니다. 이제 나머지 객체 선언이 더 이상 필요하지 않으므로 이러한 객체 선언을 삭제할 수 있습니다.

  1. 다음 코드를 삭제합니다.
object MarsApi {

}

5. 앱에 애플리케이션 컨테이너 연결

이 섹션의 단계를 따라 다음 그림과 같이 애플리케이션 객체를 애플리케이션 컨테이너에 연결합니다.

92e7d7b79c4134f0.png

  1. com.example.marsphotos를 마우스 오른쪽 버튼으로 클릭하고 New > Kotlin Class/File을 선택합니다.
  2. 대화상자에 MarsPhotosApplication을 입력합니다. 이 클래스는 애플리케이션 객체에서 상속되므로 클래스 선언에 추가해야 합니다.
import android.app.Application

class MarsPhotosApplication : Application() {
}
  1. MarsPhotosApplication 클래스 내부에서 AppContainer 유형의 container라는 변수를 선언하여 DefaultAppContainer 객체를 저장합니다. 이 변수는 onCreate() 호출 중에 초기화되므로 lateinit 수정자로 이 변수를 표시해야 합니다.
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

lateinit var container: AppContainer
override fun onCreate() {
    super.onCreate()
    container = DefaultAppContainer()
}
  1. 전체 MarsPhotosApplication.kt 파일은 다음 코드와 같습니다.
package com.example.marsphotos

import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}
  1. 방금 정의한 애플리케이션 클래스를 앱에서 사용하도록 Android 매니페스트를 업데이트해야 합니다. manifests/AndroidManifest.xml 파일을 엽니다.

759144e4e0634ed8.png

  1. application 섹션에서 애플리케이션 클래스 이름 ".MarsPhotosApplication"의 값과 함께 android:name 속성을 추가합니다.
<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

6. ViewModel에 저장소 추가

아래 단계를 완료하면 ViewModel이 저장소 객체를 호출하여 화성 데이터를 검색할 수 있습니다.

7425864315cb5e6f.png

  1. ui/screens/MarsViewModel.kt 파일을 엽니다.
  2. MarsViewModel의 클래스 선언에서 MarsPhotosRepository 유형의 비공개 생성자 매개변수 marsPhotosRepository를 추가합니다. 이제 앱이 종속 항목 삽입을 사용하고 있으므로 생성자 매개변수의 값을 애플리케이션 컨테이너에서 가져옵니다.
import com.example.marsphotos.data.MarsPhotosRepository

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
  1. 이제 생성자 호출 시 marsPhotosRepository가 채워지므로 getMarsPhotos() 함수에서 다음 코드 줄을 삭제합니다.
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. Android 프레임워크는 생성 시 생성자의 ViewModel에서 값이 전달되는 것을 허용하지 않으므로 ViewModelProvider.Factory 객체를 구현하여 이 제한을 해결할 수 있습니다.

팩토리 패턴은 객체를 만드는 데 사용되는 생성 패턴입니다. MarsViewModel.Factory 객체는 애플리케이션 컨테이너를 사용하여 marsPhotosRepository를 검색한 후에 ViewModel 객체가 생성되면 이 저장소를 ViewModel에 전달합니다.

  1. getMarsPhotos() 함수 아래에 컴패니언 객체의 코드를 입력합니다.

컴패니언 객체를 사용하면 비용이 많이 드는 객체의 새 인스턴스를 만들 필요 없이 모두가 단일 객체 인스턴스를 사용할 수 있어 유용합니다. 구현 세부정보이며, 분리하면 앱 코드의 다른 부분에 영향을 주지 않고 변경할 수 있습니다.

APPLICATION_KEYViewModelProvider.AndroidViewModelFactory.Companion 객체의 일부이며 앱의 MarsPhotosApplication 객체를 찾는 데 사용됩니다. container 속성이 있어, 종속 항목 삽입에 사용된 저장소를 검색하는 데 사용할 수 있습니다.

import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication

companion object {
   val Factory: ViewModelProvider.Factory = viewModelFactory {
       initializer {
           val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
           val marsPhotosRepository = application.container.marsPhotosRepository
           MarsViewModel(marsPhotosRepository = marsPhotosRepository)
       }
   }
}
  1. theme/MarsPhotosApp.kt 파일을 열고 MarsPhotosApp() 함수 내에서 팩토리를 사용하도록 viewModel()을 업데이트합니다.
Surface(
            // ...
        ) {
            val marsViewModel: MarsViewModel =
   viewModel(factory = MarsViewModel.Factory)
            // ...
        }

marsViewModel 변수는 ViewModel 생성을 위한 인수로 컴패니언 객체에서 MarsViewModel.Factory에 전달되는 viewModel() 함수를 호출하여 채워집니다.

  1. 앱을 실행하여 앱이 이전처럼 작동하는지 확인합니다.

저장소 및 종속 항목 삽입을 사용하도록 Mars Photos 앱을 리팩터링했습니다. 저장소 하나가 포함된 데이터 레이어를 구현하여 Android 권장사항에 따라 UI와 데이터 소스 코드가 분리되었습니다.

종속 항목 삽입을 사용하여 ViewModel을 더 쉽게 테스트할 수 있습니다. 이제 앱이 더 유연하고 강력해졌으며 확장 가능합니다.

이렇게 개선을 마쳤으며 이제 테스트하는 방법을 알아보겠습니다. 테스트를 하면 코드가 예상대로 계속 작동하며 계속 코드 작업을 하는 동안 버그가 발생할 가능성이 줄어듭니다.

7. 로컬 테스트 설정

이전 섹션에서는 ViewModel에서 REST API 서비스와의 직접적인 상호작용을 추상화하는 저장소를 구현했습니다. 이렇게 하면 목적이 제한된 작은 코드 조각을 테스트할 수 있습니다. 기능이 제한된 작은 코드를 테스트하는 것은 여러 기능을 갖춘 대규모 코드용으로 작성한 테스트보다 빌드하고 구현하고 이해하기가 더 쉽습니다.

또한 인터페이스, 상속, 종속 항목 삽입을 활용하여 저장소를 구현했습니다. 다음 섹션에서는 이러한 아키텍처 권장사항을 따르면 더 쉽게 테스트할 수 있는 이유를 알아봅니다. 또한 Kotlin 코루틴을 사용하여 네트워크 요청을 했습니다. 코루틴을 사용하는 코드를 테스트하려면 코드의 비동기 실행을 처리하기 위한 추가 단계가 필요합니다. 이 단계는 이 Codelab의 후반부에서 다룹니다.

로컬 테스트 종속 항목 추가

app/build.gradle.kts에 다음 종속 항목을 추가합니다.

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")

로컬 테스트 디렉터리 만들기

  1. 프로젝트 뷰에서 src 디렉터리를 마우스 오른쪽 버튼으로 클릭하고 New > Directory > test/java를 선택하여 로컬 테스트 디렉터리를 만듭니다.
  2. 테스트 디렉터리에 com.example.marsphotos라는 새 패키지를 만듭니다.

8. 테스트용 모조 데이터 및 종속 항목 만들기

이 섹션에서는 종속 항목 삽입이 로컬 테스트 작성에 어떻게 도움이 되는지 알아봅니다. Codelab 앞부분에서는 API 서비스에 종속되는 저장소를 만들었습니다. 그런 다음 저장소에 종속되도록 ViewModel을 수정했습니다.

각 로컬 테스트는 한 가지만 테스트해야 합니다. 예를 들어 뷰 모델의 기능을 테스트할 때 저장소 또는 API 서비스의 기능은 테스트하고 싶지 않을 수 있습니다. 마찬가지로, 저장소를 테스트할 때 API 서비스는 테스트하고 싶지 않을 수 있습니다.

인터페이스를 사용하고 이후에 종속 항목 삽입을 사용하여 이러한 인터페이스에서 상속되는 클래스를 포함하면 테스트 목적으로만 만들어진 모조 클래스를 사용하여 이러한 종속 항목의 기능을 시뮬레이션할 수 있습니다. 테스트를 위해 모조 클래스와 데이터 소스를 삽입하면 반복성과 일관성을 유지하면서 코드를 개별적으로 테스트할 수 있습니다.

먼저 필요한 것은 나중에 만드는 모조 클래스에서 사용할 모조 데이터입니다.

  1. 테스트 디렉터리에서 com.example.marsphotos 아래에 fake라는 패키지를 만듭니다.
  2. fake 디렉터리에서 FakeDataSource라는 새 Kotlin 객체를 만듭니다.
  3. 이 객체에서 MarsPhoto 객체 목록으로 설정된 속성을 만듭니다. 목록은 길지 않아도 되지만 객체를 두 개 이상 포함해야 합니다.
object FakeDataSource {

   const val idOne = "img1"
   const val idTwo = "img2"
   const val imgOne = "url.1"
   const val imgTwo = "url.2"
   val photosList = listOf(
       MarsPhoto(
           id = idOne,
           imgSrc = imgOne
       ),
       MarsPhoto(
           id = idTwo,
           imgSrc = imgTwo
       )
   )
}

이 Codelab 앞부분에서 저장소가 API 서비스에 종속된다고 설명했습니다. 저장소 테스트를 만들려면 방금 만든 모조 데이터를 반환하는 모조 API 서비스가 있어야 합니다. 모조 API 서비스가 저장소에 전달되면 모조 API 서비스의 메서드가 호출될 때 저장소가 모조 데이터를 수신합니다.

  1. fake 패키지에서 FakeMarsApiService라는 새 클래스를 만듭니다.
  2. MarsApiService 인터페이스에서 상속하도록 FakeMarsApiService 클래스를 설정합니다.
class FakeMarsApiService : MarsApiService {
}
  1. getPhotos() 함수를 재정의합니다.
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. getPhotos() 메서드에서 가짜 사진의 목록을 반환합니다.
override suspend fun getPhotos(): List<MarsPhoto> {
   return FakeDataSource.photosList
}

이 클래스의 목적이 아직 불분명하더라도 괜찮습니다. 이 모조 클래스의 사용법은 다음 섹션에서 더 자세히 설명합니다.

9. 저장소 테스트 작성

이 섹션에서는 NetworkMarsPhotosRepository 클래스의 getMarsPhotos() 메서드를 테스트합니다. 이 섹션에서는 모조 클래스의 사용법을 명확히 설명하고 코루틴을 테스트하는 방법을 보여줍니다.

  1. 모조 디렉터리에 NetworkMarsRepositoryTest라는 새 클래스를 만듭니다.
  2. 방금 만든 클래스에 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()라는 새 메서드를 만들고 @Test 주석을 답니다.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}

저장소를 테스트하려면 NetworkMarsPhotosRepository 인스턴스가 필요합니다. 이 클래스는 MarsApiService 인터페이스에 종속된다는 점을 기억하세요. 여기에서 이전 섹션의 모조 API 서비스를 활용합니다.

  1. NetworkMarsPhotosRepository 인스턴스를 만들고 marsApiService 매개변수로 FakeMarsApiService를 전달합니다.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )
}

모조 API 서비스를 전달하면 저장소에서 marsApiService 속성을 호출할 때 FakeMarsApiService가 호출됩니다. 종속 항목용 모조 클래스를 전달하면 종속 항목에서 반환되는 항목을 정확하게 제어할 수 있습니다. 이 접근 방식을 사용하면 테스트 중인 코드가 테스트되지 않은 코드에 종속되지 않으며 변경 가능성이 있거나 예상치 못한 문제가 발생할 수 있는 API에 종속되지 않습니다. 이러한 상황에서는 작성한 코드에 문제가 없더라도 테스트가 실패할 수 있습니다. 가짜 구현은 더 일관된 테스트 환경을 만들고 테스트 결함을 줄이는 데 도움이 되며 단일 기능을 테스트하는 간결한 테스트를 용이하게 합니다.

  1. getMarsPhotos() 메서드에서 반환하는 데이터가 FakeDataSource.photosList와 같다고 어설션합니다.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}

IDE에서 getMarsPhotos() 메서드 호출에 빨간색 밑줄이 표시됩니다.

2bd5f8999e0f3ec2.png

메서드 위로 마우스를 가져가면 '정지 함수 'getMarsPhotos'의 호출이 코루틴이나 또 다른 정지 함수에서만 허용됩니다'라는 도움말이 표시됩니다.

d2d3b6d770677ef6.png

data/MarsPhotosRepository.kt에서 NetworkMarsPhotosRepositorygetMarsPhotos() 구현을 살펴보면 getMarsPhotos() 함수가 정지 함수임을 알 수 있습니다.

class NetworkMarsPhotosRepository(
   private val marsApiService: MarsApiService
) : MarsPhotosRepository {
   /** Fetches list of MarsPhoto from marsApi*/
   override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

MarsViewModel에서 이 함수를 호출할 때 viewModelScope.launch()에 전달된 람다에서 호출하여 코루틴에서 이 메서드를 호출했습니다. 테스트 시 코루틴에서 정지 함수(예: getMarsPhotos())도 호출해야 합니다. 그러나 접근 방식은 다릅니다. 다음 섹션에서는 이 문제를 해결하는 방법을 설명합니다.

테스트 코루틴

이 섹션에서는 테스트 메서드의 본문이 코루틴에서 실행되도록 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 테스트를 수정합니다.

  1. NetworkMarsRepositoryTest.kt에서 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 함수를 표현식이 되도록 수정합니다.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. 표현식을 runTest() 함수와 같게 설정합니다. 이 메서드에는 람다가 필요합니다.
...
import kotlinx.coroutines.test.runTest
...

@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
    runTest {}

코루틴 테스트 라이브러리는 runTest() 함수를 제공합니다. 이 함수는 람다에 전달한 메서드를 가져와 CoroutineScope에서 상속되는 TestScope에서 실행합니다.

  1. 테스트 함수의 콘텐츠를 람다 함수로 이동합니다.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
   runTest {
       val repository = NetworkMarsPhotosRepository(
           marsApiService = FakeMarsApiService()
       )
       assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
   }

이제 getMarsPhotos() 아래의 빨간색 줄이 사라지는 것을 볼 수 있습니다. 이 테스트를 실행하면 통과됩니다.

10. ViewModel 테스트 작성

이 섹션에서는 MarsViewModel에서 getMarsPhotos() 함수의 테스트를 작성합니다. MarsViewModelMarsPhotosRepository에 종속됩니다. 따라서 이 테스트를 작성하려면 모조 MarsPhotosRepository를 만들어야 합니다. 또한 runTest() 메서드를 사용하는 것 외에 코루틴을 고려할 때 추가 단계가 있습니다.

모조 저장소 만들기

이 단계의 목표는 MarsPhotosRepository 인터페이스에서 상속되고 모조 데이터를 반환하도록 getMarsPhotos() 함수를 재정의하는 모조 클래스를 만드는 것입니다. 이 접근 방식은 모조 API 서비스를 사용했을 때의 접근 방식과 유사하며 이 클래스가 MarsApiService 대신 MarsPhotosRepository 인터페이스를 확장한다는 점이 다릅니다.

  1. fake 디렉터리에 FakeNetworkMarsPhotosRepository라는 새 클래스를 만듭니다.
  2. MarsPhotosRepository 인터페이스로 이 클래스를 확장합니다.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. getMarsPhotos() 함수를 재정의합니다.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. getMarsPhotos() 함수에서 FakeDataSource.photosList를 반환합니다.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

ViewModel 테스트 작성

  1. MarsViewModelTest라는 새 클래스를 만듭니다.
  2. marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()라는 함수를 만들고 @Test 주석을 추가합니다.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. 이 함수를 runTest() 메서드의 결과로 설정된 표현식으로 만들어 이전 섹션의 저장소 테스트와 마찬가지로 코루틴에서 테스트가 실행되도록 합니다.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
    }
  1. runTest()의 람다 본문에서 MarsViewModel 인스턴스를 만들고 방금 만든 모조 저장소의 인스턴스를 전달합니다.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
        val marsViewModel = MarsViewModel(
            marsPhotosRepository = FakeNetworkMarsPhotosRepository()
         )
    }
  1. ViewModel 인스턴스의 marsUiState가 성공적인 MarsPhotosRepository.getMarsPhotos() 호출의 결과와 일치하는지 어설션합니다.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
   runTest {
       val marsViewModel = MarsViewModel(
           marsPhotosRepository = FakeNetworkMarsPhotosRepository()
       )
       assertEquals(
           MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
                   "photos retrieved"),
           marsViewModel.marsUiState
       )
   }

현재 상태로 이 테스트를 실행하려고 하면 실패합니다. 오류는 다음 예와 같습니다.

Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

MarsViewModelviewModelScope.launch()를 사용하여 저장소를 호출합니다. 이 명령은 Main 디스패처라고 하는 기본 코루틴 디스패처 아래에 새 코루틴을 실행합니다. Main 디스패처는 Android UI 스레드를 래핑합니다. 앞의 오류가 발생한 이유는 단위 테스트에서 Android UI 스레드를 사용할 수 없기 때문입니다. 단위 테스트는 Android 기기나 에뮬레이터가 아닌 워크스테이션에서 실행됩니다. 로컬 단위 테스트 아래의 코드가 Main 디스패처를 참조하는 경우 단위 테스트 실행 시 위의 예와 같은 예외가 발생합니다. 이 문제를 해결하려면 단위 테스트를 실행할 때 기본 디스패처를 명시적으로 정의해야 합니다. 다음 섹션으로 이동하여 방법을 알아보세요.

테스트 디스패처 만들기

Main 디스패처는 UI 컨텍스트에서만 사용할 수 있으므로 단위 테스트 친화적인 디스패처로 바꿔야 합니다. Kotlin 코루틴 라이브러리는 이를 위해 TestDispatcher라는 코루틴 디스패처를 제공합니다. 뷰 모델의 getMarsPhotos() 함수와 마찬가지로 새 코루틴이 생성되는 단위 테스트에는 Main 디스패처 대신 TestDispatcher를 사용해야 합니다.

모든 경우에 Main 디스패처를 TestDispatcher로 바꾸려면 Dispatchers.setMain() 함수를 사용합니다. Dispatchers.resetMain() 함수를 사용하여 스레드 디스패처를 다시 Main 디스패처로 재설정할 수 있습니다. 각 테스트에서 Main 디스패처를 대체하는 코드가 중복되지 않도록 JUnit 테스트 규칙으로 추출할 수 있습니다. TestRule은 테스트가 실행되는 환경을 제어하는 방법을 제공합니다. TestRule은 검사를 추가하거나, 테스트에 필요한 설정 또는 정리를 실행하거나, 테스트 실행을 관찰하여 다른 곳에 보고할 수 있습니다. 테스트 클래스 간에 쉽게 공유할 수 있습니다.

Main 디스패처를 대체하는 TestRule을 작성할 전용 클래스를 만듭니다. 맞춤 TestRule을 구현하려면 다음 단계를 완료하세요.

  1. 테스트 디렉터리에 rules라는 새 패키지를 만듭니다.
  2. 규칙 디렉터리에 TestDispatcherRule이라는 새 클래스를 만듭니다.
  3. TestWatcher를 사용하여 TestDispatcherRule을 확장합니다. TestWatcher 클래스를 사용하면 테스트의 다양한 실행 단계에서 작업을 실행할 수 있습니다.
class TestDispatcherRule(): TestWatcher(){

}
  1. TestDispatcherRuleTestDispatcher 생성자 매개변수를 만듭니다.

이 매개변수를 사용하면 StandardTestDispatcher 같은 다양한 디스패처를 사용할 수 있습니다. 이 생성자 매개변수에 UnconfinedTestDispatcher 객체의 인스턴스로 설정된 기본값이 있어야 합니다. UnconfinedTestDispatcher 클래스는 TestDispatcher 클래스에서 상속되며 태스크가 특정 순서로 실행되지 않도록 지정합니다. 이 실행 패턴은 코루틴이 자동으로 처리되므로 간단한 테스트에 적합합니다. UnconfinedTestDispatcher와 달리 StandardTestDispatcher 클래스를 사용하면 코루틴 실행을 완전히 제어할 수 있습니다. 이러한 방식은 수동 접근 방식이 필요한 복잡한 테스트에 선호되지만 이 Codelab의 테스트에는 필요하지 않습니다.

class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

}
  1. 이 테스트 규칙의 기본 목표는 테스트가 실행되기 전에 Main 디스패처를 테스트 디스패처로 바꾸는 것입니다. TestWatcher 클래스의 starting() 함수는 지정된 테스트가 실행되기 전에 실행됩니다. starting() 함수를 재정의합니다.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        
    }
}
  1. Dispatchers.setMain() 호출을 추가하고 testDispatcher를 인수로 전달합니다.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. 테스트 실행이 완료되면 finished() 메서드를 재정의하여 Main 디스패처를 재설정합니다. Dispatchers.resetMain() 함수를 호출합니다.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

TestDispatcherRule 규칙을 재사용할 준비가 됩니다.

  1. MarsViewModelTest.kt 파일을 엽니다.
  2. MarsViewModelTest 클래스에서 TestDispatcherRule 클래스를 인스턴스화하고 testDispatcher 읽기 전용 속성에 할당합니다.
class MarsViewModelTest {
    
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 이 규칙을 테스트에 적용하려면 testDispatcher 속성에 @get:Rule 주석을 추가합니다.
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 테스트를 다시 실행합니다. 이번에는 통과하는지 확인합니다.

11. 솔루션 코드 가져오기

완료된 Codelab의 코드를 다운로드하려면 다음 명령어를 사용하면 됩니다.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout coil-starter

또는 ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

이 Codelab의 솔루션 코드는 GitHub에서 확인하세요.

12. 결론

이 Codelab을 완료했습니다. Mars Photos 앱을 리팩터링하여 저장소 패턴과 종속 항목 삽입을 구현했습니다.

이제 앱 코드가 데이터 레이어와 관련된 Android 권장사항을 준수하므로 더 유연하고 강력하며 쉽게 확장 가능합니다.

이렇게 변경하여 앱을 더 쉽게 테스트할 수 있게 되었습니다. 코드가 예상대로 작동하는 동시에 계속해서 발전할 수 있기 때문에 이러한 이점이 매우 중요합니다.

#AndroidBasics를 사용해 작업한 결과물을 소셜 미디어로 공유해 보세요.

13. 자세히 알아보기

Android 개발자 문서:

기타: