본문 바로가기
개발/Android

[Android] DI(Dependency Injection)란?

by tempus 2022. 2. 24.
반응형

예전부터 객체지향 프로그래밍에서는 DI를 잘 사용해야 한다는 말을 들어서인지 이번에 진행하는 새로운 프로젝트에 적용해보고 싶어서 정리를 하게 되었다. 기본에 충실하고자 이번 포스팅은 안드로이드 공식 홈페이지의 내용 + α를 바탕으로 작성하였다.

 

DI(Dependency Injection)란?

DI는 Dependency Injection의 줄임말이다. 한국말로 하면 의존성 주입이라고 한다.

DI의 정의는 하나의 객체가 다른 객체의 의존성을 제공하는 기술

특정 객체가 다른 객체를 필요로 할 때 외부에서 해당 객체를 생성해서 필요한 객체에게 넘겨주는 것을 의미한다. 이를 통해 프로그램의 결합도를 낮출 수 있다.

 

DI를 통해 개발자가 얻는 이점은 다음과 같다고 한다.

  • 코드 재사용성 - 종속 항목 객체 변경이 쉬움
  • Refactoring의 편의성 - 종속 항목이 구현하는 세부정보로 숨겨져 있고 노출되어 있어서 리펙터링이 편함
  • Testing의 편의성 - 해당 클래스가 종속 항목을 관리하지 않으므로 다양한 사례 테스트가 가능

 

홈페이지에서는 하나의 예를 다음과 같이 들고 있다.

class Car {

    private val engine = Engine()

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

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

위와 같이 Car라는 클래스는 Engine이라는 클래스 참조가 필요할 때 Car클래스 내부에서 Engine 인스턴스를 생성하여 초기화를 해준다. 하지만 이렇게 코드를 작성할 경우 다음과 같은 문제가 발생할 수 있다.

  • Engine을 상속받아 구현한 여러 Engine클래스가 있다고 할 때 (GasEngine, EletricEngine) 다른 엔진으로 대체하기가 힘들다. 즉, Engine 을 수정하기가 힘들다.
  • Engine을 쉽게 수정할 수 없기 때문에 다양한 테스트를 진행하기가 어렵다.

 

그렇다면 Android에서 DI를 어떻게 적용할까?

 

Android에서 DI를 실행하는 2가지 방법

  • Constructor Injection(생성자 삽입) - 클래스의 종속 항목 생성자 전달
  • Field Injection(필드 삽입) - Activity나 Fragment 같은 클래스는 시스템에서 인스턴스화를 하기에 생성자 삽입이 불가능하다. 필드 삽입은 종속 항목을 클래스가 생성된 후 인스턴스화 된다.

✔ Constructor Injection

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

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}

이렇게 하면 다음과 같은 장점을 얻을 수 있다.

  • 다양한 EngineCar에 전달할 수 있어 재사용이 가능해진다.
  • Car에 여러 Engine 을 사용해서 테스트가 가능하다.

✔ Field Injection

class Car {
    lateinit var engine: Engine

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

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}

 

하지만 문제가 있다.

위의 경우는 종속되는 객체가 하나였지만 만약 여러 개라면 그만큼 더 복잡하고 지루해질 것이다. 또한 종속 항목을 전달하기 전에 구성할 수 없을 때(Activity나 Fragment)는 메모리에서 종속 항목의 전체 기관을 관리하는 맞춤 Container를 작성하고 유지해야 한다.

 

내가 생각해도 하나 정도는 괜찮은데 앱이 복잡해지고 고도화될수록 DI 작업을 일일이 하기란 매우 벅찰 것 같다는 생각이 들었다. 그래서 Android는 이를 쉽게 해 주기 위한 라이브러리를 제공한다.

 

Android DI 라이브러리

안드로이드에서는 DI 라이브러리로는 대표적으로 3개가 있다.

  • Hilt - Compile Time 에러 검출, Java, Kotlin에서 사용 가능
  • Dagger - Compile Time 에러 검출, Java, Kotlin에서 사용 가능
  • Koin - RunTime 에러 검출, Kotlin에서 사용 가능

 

Android 에서 DI 사용 (No 라이브러리)

일단 라이브러리를 사용하기 전에 안드로이드에서는 어떻게 기본적으로 DI를 사용할 수 있는지 알아보자!

예시는 공식 홈페이지에 있는 자료를 활용했다.

로그인 프로세스

그림은 하나의 로그인을 위한 프로세스이다. A -> B는 A는 B에 종속되어 있다는 의미다.

 

이를 위해 다음과 같이 코드를 작성했다.

    class UserRepository(
        private val localDataSource: UserLocalDataSource,
        private val remoteDataSource: UserRemoteDataSource
    ) { ... }

    class UserLocalDataSource { ... }
    class UserRemoteDataSource(
        private val loginService: LoginRetrofitService
    ) { ... }
    

 

✔ LoginActivity.kt

    class LoginActivity: Activity() {

        private lateinit var loginViewModel: LoginViewModel

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

            val retrofit = Retrofit.Builder()
                .baseUrl("https://example.com")
                .build()
                .create(LoginService::class.java)

            val remoteDataSource = UserRemoteDataSource(retrofit)
            val localDataSource = UserLocalDataSource()

            val userRepository = UserRepository(localDataSource, remoteDataSource)
            loginViewModel = LoginViewModel(userRepository)
        }
    }
    

기본적인 클래스들은 생성자 삽입으로 의존성을 주입하고 Activity는 필드 삽입으로 의존성을 주입했다. 일단 이 코드의 3가지 문제가 있다.

  1. 종속성을 순서대로 선언해야 한다.
  2. 객체를 재사용하기 어렵다. userRepository 를 다른 곳에서 재사용하기 어렵다.
  3. 보일러 플레이트 코드가 많다. 코드의 다른 부분에서 LoginViewModel의 다른 인스턴스를 만들려면 코드가 중복될 수 있다.

 

이러한 문제를 해결하기 위해 Container로 종속성을 관리하라고 한다. Container는 모든 종속 항목을 관리할 수 있다.

    class AppContainer {

        private val retrofit = Retrofit.Builder()
                                .baseUrl("https://example.com")
                                .build()
                                .create(LoginService::class.java)

        private val remoteDataSource = UserRemoteDataSource(retrofit)
        private val localDataSource = UserLocalDataSource()

        val userRepository = UserRepository(localDataSource, remoteDataSource)
    }

이렇게 AppContainer클래스를 통해 종속 항목을 생성해주고 전체 Application에서 사용하기 위해 Application()을 상속받은 클래스에 배치해준다.

    class MyApplication : Application() {

        val appContainer = AppContainer()
    }
    

이렇게 하면 보일러 플레이트 코드는 줄일 수 있다. 만약 LoginViewModel을 다른 곳에서 사용하고 싶다면 마찬가지로 LoginViewModel을 생성해주는 Factory클래스를 만드는 것이 좋다.

    interface Factory {
        fun create(): T
    }

    class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
        override fun create(): LoginViewModel {
            return LoginViewModel(userRepository)
        }
    }

그리고 AppContainer에 포함하여 사용한다.

    class AppContainer {
        ...
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        val loginViewModelFactory = LoginViewModelFactory(userRepository)
    }

    class LoginActivity: Activity() {

        private lateinit var loginViewModel: LoginViewModel

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

            val appContainer = (application as MyApplication).appContainer
            loginViewModel = appContainer.loginViewModelFactory.create()
        }
    }

그런데 생각해보면 Login 데이터 같은 경우 로그인이 끝나고 다시 로그인할 때 LoginUserData 같은 경우 새로운 인스턴스를 만들어야 한다. 이 또한 Container로 만들어서 로그인 프로세스가 끝날 때 메모리에서 삭제시킨다.

    class LoginContainer(val userRepository: UserRepository) {

        val loginData = LoginUserData()

        val loginViewModelFactory = LoginViewModelFactory(userRepository)
    }

    class AppContainer {
        ...
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        var loginContainer: LoginContainer? = null
    }
    

 

    class LoginActivity: Activity() {

        private lateinit var loginViewModel: LoginViewModel
        private lateinit var loginData: LoginUserData
        private lateinit var appContainer: AppContainer

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            appContainer = (application as MyApplication).appContainer

            appContainer.loginContainer = LoginContainer(appContainer.userRepository)

            loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
            loginData = appContainer.loginContainer.loginData
        }

        override fun onDestroy() {
            appContainer.loginContainer = null
            super.onDestroy()
        }
    }
    

 

정리해보자면 Container를 사용해 보일러 플레이트 코드를 제거하고 클래스 인스턴스들을 공유한다. 확실히 Container들을 이쁘게 잘 만들면 테스트하기도 편하고 관리하기도 용이할 것 같다.

 

하지만 생각해보면 앱이 복잡해지고 커지면 관리해야하는 Container들을 많아질 것이고 이는 곧 메모리 누수나 앱의 버그가 생길 수 있는 문제가 있을 것 같다. 그래서 라이브러리를 만들어서 쉽게 관리할 수 있게 하지 않았을까 생각한다.

 

나는 아직 라이브러리를 자세히 보지 않았지만 사용해보면 이러한 문제들이 많이 해소되지 않을까 생각한다. DI에 대해서는 이해를 했으니 실질적으로 사용해봐야겠다. 다음에는 Hilt를 사용해보고 정리하겠다.

반응형

댓글


loading