안녕하세요! 스포카 프로그래머 김수현입니다.

최근 매장 점주분들을 위한 식자재 비용 관리와 분석을 제공하는 도도 카트 안드로이드 앱DI(Dependency Injection; 의존성 주입)를 적용하게 되었습니다. 안드로이드에 DI를 적용하면 훌륭한 앱 아키텍처를 위한 토대를 마련할 수 있고 많은 이점을 누릴 수 있습니다.

이 글을 통해 DI가 무엇인지, 왜 적용해야 하는지 간단하게 알아보고 Koin을 이용해서 DI를 적용하게 된 사례를 공유합니다!

문제 상황

도도 카트 안드로이드 앱은 대부분의 코드가 Kotlin으로 작성되어 있습니다. Kotlin에서는 object 키워드로 싱글톤 객체를 쉽게 생성할 수 있는데요, 초기 도도 카트 앱에서는 CartRepository라는 싱글톤 객체 내부에 GraphQL 기반 Apollo Client 및 현재 로그인된 세션 정보를 들고 있도록 구현되어 있었습니다.

object CartRepository {
    private lateinit var apolloClient: ApolloClient

    private var accessToken: String? = null
    var currentStore: Store? = null
    ...
}

하지만 프로젝트가 점점 커지고 객체 내부의 코드가 늘어남에 따라 몇 가지 문제가 생겼습니다.

한 객체 안에 서버 Client 코드와 세션 정보를 모두 들고 있으니 객체 자체의 의존도가 높아졌고, 점점 커지면서 가독성도 떨어졌습니다. 생성자가 따로 없다 보니 초기화를 별도로 구현해서 호출해줘야 했고 의존 관계가 점점 복잡해졌습니다. 당장은 괜찮을지 몰라도 대규모 프로젝트로 성장하게 되면 구조 파악이 더 어려워질 것입니다.

이를 해결하기 위해 DI를 도입하여 CartRepository 내부의 코드를 각각의 모듈로 분리하고 이를 주입받는 방향으로 리팩토링하게 되었습니다.

DI가 뭔가요?

일반적인 객체 생성과 DI를 적용한 객체 생성 비교

DI는 일반적으로 클래스 내부에서 객체를 생성하지 않고 외부에서 객체를 생성해 주입 받는 방식입니다.

왜 DI를 적용해야 하죠?

class Manager { ... }

class DodoCart {
    val manager = Manager()
    ...
}

class DodoPoint {
    val manager = Manager()
    ...
}

fun main() {
    val dodoCart = DodoCart()
    val dodoPoint = DodoPoint()
}

위 예시 코드에서 DodoCartDodoPoint 클래스는 모두 Manager 의존성을 가지고 있습니다. 하지만 이 코드로 인해 몇 가지 문제가 발생할 수 있습니다.

먼저 Manager 클래스에 변화가 생기면 해당 의존성을 갖는 모든 클래스가 변경되어야 합니다. 여기서는 두 클래스만 변경하면 되지만 프로젝트가 커지면 커질수록 변경해야 할 클래스도 많아지게 됩니다.

또 테스트하기 어려워집니다. DodoCartDodoPoint 클래스는 실제 Manager 객체를 사용하므로 모의 객체로 분리하여 단위 테스트하기 까다롭습니다.

이제 여기에 DI를 적용해보겠습니다.

class Manager { ... }

class DodoCart(val manager: Manager) { ... }

class DodoPoint(val manager: Manager) { ... }

fun main() {
    val manager = Manager()
    val dodoCart = DodoCart(manager)
    val dodoPoint = DodoPoint(manager)
}

무엇이 변경되었는지 보이시나요? main 함수에서 Manager 객체를 생성하고 생성자를 통해 각 클래스에 의존성을 주입해줬습니다. 이제 Manager 객체는 재사용이 가능해지고, FakeManager와 같은 모의 객체를 만들고 단위 테스트로 분리하기 쉬워졌습니다.

이처럼 DI를 적용하면 다음과 같은 이점들이 있습니다.

  • 코드의 가독성과 재사용성을 높여줍니다.
  • 단위 테스트하기 편합니다.
  • 객체 간의 의존(종속) 관계를 직접 설정할 수 있습니다.
  • 객체 간의 결합도를 낮출 수 있습니다.

안드로이드에서의 DI

특히 안드로이드에서는 어떤 Activity나 Fragment에서 객체를 생성하는지에 따라 context가 계속 바뀌기 때문에 같은 클래스 타입 객체임에도 다르게 동작할 수 있습니다. 하지만 범용된 환경에서 객체를 생성하고 이렇게 생성된 객체를 Activity나 Fragment에서 주입 받아 사용하는 식으로 구현하면 context의 영향을 받지 않고도 공통으로 재사용할 수 있는 객체를 구현하게 됩니다.

참고로 안드로이드에서는 다음 두 가지 방식의 의존성 주입이 가능합니다.

  • 생성자 주입 (Constructor Injection) : 생성자를 통해 의존하는 객체 전달
  • 필드 주입 (Field Injection) : 객체가 초기화된 후에 의존하는 객체 전달

Koin

위에서처럼 직접 구현해도 되지만 다양한 라이브러리의 도움을 받을 수 있습니다. 대표적으로 Koin, Dagger, Hilt 등이 있는데요, 그중에서도 Koin은 Kotlin 개발 환경에 쉽게 적용할 수 있는 경량화된 DI 프레임워크입니다. 1 스포카의 일부 안드로이드 프로젝트에서는 이미 Koin을 사용하고 있습니다.

공식 웹사이트에서는 Koin을 다음과 같이 소개합니다.

A pragmatic lightweight dependency injection framework for Kotlin developers: no proxy, no code generation, no reflection.

사용하기

간단한 예제 구현을 통해 Koin의 주요 기능들을 알아봅시다.

설치

먼저 build.gradle(Project) 파일에 jcenter 레포지토리가 등록되어 있는지 확인하고, koin_version 변수를 선언합니다.

buildscript {
    ext.koin_version = '버전 코드'

    repositories { 
        jcenter()
    }
}

그리고 build.gradle(Module) 파일에 Koin 의존성을 추가해줍니다. 2 이 예제에서는 Koin AndroidX ViewModel과 Koin Test 의존성을 추가해서 사용하겠습니다.

dependencies {
    implementation "org.koin:koin-androidx-viewmodel:$koin_version"
    testImplementation "org.koin:koin-test:$koin_version"
}

모듈 선언

설치가 끝났다면 본격적으로 Koin을 사용해보겠습니다.

module 키워드로 주입 받고자 하는 객체를 모듈로 만들어 선언할 수 있습니다. 간단한 동작을 하는 샘플 클래스를 만들어 봅시다. SampleRepository 클래스와 이 클래스 객체를 파라미터로 받는 SampleController, 그리고 ViewModel 클래스인 SampleViewModel을 만들어줍니다.

class SampleRepository() {
    val sampleData = "Sample Data!"
}

class SampleController(val repository: SampleRepository) {
    fun printSampleData() {
        Log.d("Print sample data", repository.sampleData)
    }
}

class SampleViewModel : ViewModel() {
    private var _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading
}

그리고 module 키워드를 이용해 모듈로 선언하고 변수에 저장합니다.

val appModule = module {
    single { SampleRepository() }
    factory { SampleController(get()) }
}
  • single : 앱이 실행되는 동안 계속 유지되는 싱글톤 객체를 생성합니다.
  • factory : 요청할 때마다 매번 새로운 객체를 생성합니다.
  • get() : 컴포넌트 내에서 알맞은 의존성을 주입 받습니다.
    • 위 예제에서는 SampleRepository 타입으로 선언되어 있으므로 이미 생성된 객체 중 SampleRepository 타입에 알맞은 객체를 Koin이 주입해줍니다.

ViewModel의 경우 viewModel 키워드로 선언해야 합니다. 3

val viewModelModule = module {
    viewModel { SampleViewModel() }
}

모듈 등록

이제 Koin에 해당 모듈을 등록할 차례입니다.

Application 클래스의(프로젝트에 Application 클래스가 따로 없다면 새로 생성합니다) onCreate LifeCycle에서 startKoin을 호출하고 위에서 선언한 모듈 변수를 넘겨줍니다.

class SampleApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@SampleApplication)
            modules(appModule)
            modules(viewModelModule)
        }
    }
}
  • androidLogger() : AndroidLogger를 Koin logger로 사용합니다.
  • androidContext(...) : 해당 안드로이드 context를 사용합니다.
  • modules(...) : 사용할 모듈을 등록합니다.

참고로 Application 클래스를 새로 생성하셨다면 manifest.xml에 등록해야 합니다.

<application
    android:name=".SampleApplication"
    ...

의존성 주입 받기

여기까지 잘 따라오셨다면 이제 위에서 등록한 모듈을 주입 받아 사용할 수 있습니다.

by inject()로 Koin에 등록된 객체를 lazy 하게 주입 받을 수 있습니다. 4 사용하고자 하는 Activity 클래스에서 SampleController를 주입 받아 봅시다.

class SampleActivity : AppCompatActivity() {

    val controller: SampleController by inject()

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

한 줄이면 됩니다. 아주 간단하죠?

ViewModel의 경우 by viewModel() 키워드를 사용합니다. 5

val viewModel: SampleViewModel by viewModel()

테스트하기

마지막으로 테스트에서 모듈을 주입 받아 사용하는 방법을 알아보겠습니다.

JUnit 테스트 클래스에서 KoinTest를 상속받아 Koin 모듈을 주입 받을 수 있습니다. KoinTestRule을 통해 Koin context를 시작/중단할 수 있습니다.

class SampleTest : KoinTest {

    @get:Rule
    val koinTestRule = KoinTestRule.create {
        modules(appModule)
    }

    val controller : SampleController by inject()

    @Test
    fun sampleTest() {
        ...
    }
}

Koin의 장단점

이렇게 Koin의 기능들을 예제 구현을 통해 간단히 알아봤는데요, 프로젝트에 도입했을 때 다음과 같은 장단점을 찾을 수 있었습니다.

장점

  • 러닝커브가 낮아 쉽고 빠르게 DI를 적용할 수 있습니다.
  • Kotlin 개발 환경에 도입하기 쉽습니다.
  • 별도의 어노테이션을 사용하지 않기 때문에 컴파일 시간이 단축됩니다.
  • ViewModel 주입을 쉽게 할 수 있는 별도의 라이브러리를 제공합니다.

단점

  • Dagger2와 달리 런타임에서 에러가 날 수도 있습니다(단위 테스트를 통해 방지할 수는 있습니다).
  • Activity나 Fragment, Service 등이 아닌 곳에서 사용하기 위해선 생성자로 넘기거나 별도의 구현을 해야 합니다.

마치며

지금까지 DI에 대해 간단하게 알아보고 Koin을 이용해서 안드로이드 프로젝트에 적용한 예제를 구현해봤습니다.

여러분의 프로젝트에는 DI가 적용되어 있나요? 소규모 프로젝트의 경우 굳이 적용할 필요성을 못 느끼셨을 수도 있습니다. 객체 간의 의존 관계가 그리 복잡하지 않을 테니까요. 하지만 프로젝트가 점차 커질수록 DI를 적용했을 때와 하지 않았을 때의 차이가 커집니다(아이러니하게도 적용하기 가장 좋은 시기는 아직 프로젝트가 소규모일 때입니다). DI를 쉽고 빠르게 프로젝트에 적용할 수 있는 라이브러리로 Koin을 추천해 드립니다.

Insert Koin in your project!

참고 문헌

  1. Dagger2는 안정적이고 구글에서 관리하고 있다는 장점이 있지만 러닝커브가 높다는 단점이 있습니다. Jetpack에서는 이를 보완한 Hilt를 권장하고 있지만, 아직 정식 버전이 출시되지 않아 프로덕션에 적용하기엔 무리가 있어 Koin을 선택하게 되었습니다. 

  2. 프로젝트에 필요한 알맞은 Koin 의존성을 선택하여 추가하면 됩니다. 자세한 의존성 목록은 여기서 확인 가능합니다. 

  3. ViewModel의 경우 선언하고 주입 받는 방법이 일반적인 클래스와 조금 다릅니다. viewModel 키워드로 모듈을 등록하면 Koin이 해당 ViewModel을 ViewModelFactory에 등록하고 현재 컴포넌트와 바인딩합니다. 주입 받을 때도 ViewModelFactory에서 해당 ViewModel 객체를 불러옵니다. 

  4. get() 키워드를 이용하면 non-lazy 하게 주입 받을 수 있습니다. 

  5. getViewModel() 키워드를 이용하면 non-lazy 하게 주입 받을 수 있습니다. 

스포카에서는 프론트엔드 프로그래머, 시니어 풀스택 프로그래머를 채용 중입니다! 웹 프론트엔드에 관심을 가지고 공부하는 디자이너, 뛰어난 서버 개발자 등 각자의 분야에서 전문적인 사람들이 능력있는 분들과 함께 일하기를 기대하고 있습니다. 채용 정보 페이지를 확인해주세요!