Nuke Olaf - Log Store

[Android] 안드로이드 - DI (Dependency Injection) 본문

Android

[Android] 안드로이드 - DI (Dependency Injection)

NukeOlaf 2020. 6. 1. 01:50

구글 공식문서에서는 Dependency Injection (줄여서 DI)에 관한 내용이 Best Practice 에 분류되어 있다. 또한, 현재 구글에서는 DI 프레임워크인 Dagger 를 공식적으로 지원하고 있다. 그 정도로 DI 가 구글에서 매우 중요하게 생각하고, 권장하는 기술이라는 것을 알 수 있다.

그렇다면, Dependency Injection 이란 무엇이고 왜 사용하는 것일까?

1.  Dependency 란?

Dependency Injection 가 무엇인지 알기 위해서는 먼저 Dependency 가 무엇인지 짚고 넘어가야겠다.

Dependency 는 "의존성" 이라는 뜻이다. 일반적으로 둘 중 하나가 다른 하나를 어떤 용도를 위해 사용하는 경우를 Dependency 라고 하는데, 여기서는 객체지향 개념의 중요한 요소중 하나인  "Object Dependency" 즉 객체끼리의 의존성을 의미한다고 볼 수 있다.

갑자기 와퍼가 먹고 싶으므로, 버거킹 알바생이 와퍼를 만드는 코드를 작성하며 예를 들어보자.

class BurgerKingTask {
    fun makeWhopper()
}
class PartTimer {
    val burgerKingTask = BurgerKingTask()
    fun work() {
        burgerKingTask.makeWhopper()
    }
}

위의 버거킹 알바생 클래스(PartTimer) 와 버거킹 업무 클래스(BurgerKingTask) 는 의존관계이다.

좀 더 정확하게 말하면, PartTimer 객체가 BurgerKingTask 객체에 의존하는 것이라고 할 수 있다.

이 말은 곧, PartTimer 클래스의 코드 상에서 BurgerKingTask 클래스의 요소가 나타난다는 뜻이다. 일단 이렇게 한 객체의 요소(PartTimer)에서 다른 객체의 요소(BurgerKingTask)가 한 번 등장하면, 그 뒤로 그 객체(BurgerKingTask)는 없어지면 안된다. 예를 들어서, 의존 대상인 BurgerKingTask 클래스가 사라지면, PartTimer 객체는 바로 컴파일이 불가능해지고 동작할 수 없는 상태가 된다. 그래서 이런 관계를 "의존"이라고 부른다.

그런데, 이러한 의존관계에는 문제점이 있다

예를 들어, 버거킹 알바생이 일하던 버거킹 매장주인이 버거킹매장을 맥도날드로 바꿨다고 생각해보자. 버거킹에서 일하던 알바생은 맥도날드에서 계속해서 일하기로 했다.

이제 위의 코드는 아래와 같이 바뀌어야한다.

class McDonaldsTask {
    fun makeBigMac()
}
class PartTimer {
    val mcDonaldsTask = McDonaldsTask()
    fun work() {
        mcDonaldsTask.makeBigMac()
    }
}

기존의 BurgerKingTask 는 McDonaldsTask 로 이름이 바뀌었다. 그러자 BurgerKingTask 객체를 사용하던 PartTimer 의 코드에서 변경된 McDonaldsTask 명칭을 사용하도록 수정해 주어야 했다.

또한, 와퍼를 만들던 makeWhopper() 메서드도 맥도날드에서 파는 빅맥을 만드는 makeBigMac() 메서드로 이름이 바뀌었다. 그래서 makeWhopper() 메서드를 호출하던 코드 역시 makeBigMac() 메서드를 호출하도록 변경해 주어야 했다. 

정말 번거로운 상황이다. 위의 예시에서는 알바생 클래스가 업무 클래스에 의존하는 곳이 두 곳 뿐이었지만(업무 객체 초기화, 햄버거 만드는 메서드 호출) 상황에 따라서 코드를 이렇게 하나하나 변경하기 어려울 정도로 많은 곳에서 의존이 발생할 수 있다.

이는 곧 코드의 재사용성과 유연성을 떨어트리게 되어 코드의 유지보수를 어렵게 하는 원인이 된다.

객체지향에서의 의존성에 대한 참고 링크>>
- 객체지향의 올바른 이해 : 7. 의존(Dependency)과 책임(Responsibility)
- [Objects] 객체와 의존성

 

2. Dependency injection 이란?

Dependency injection (의존성 주입)은 위에서 설명한 "객체끼리의 의존성"을 줄이거나 없앨 수 있는 디자인 패턴이다.

여기서 말하는 injection 은 내부가 아닌 외부에서 객체를 생성해서 넣어준다는 뜻이다.

위의 버거킹 알바생 예시를 아래와 같이 의존성 주입을 이용하여 작성할 수 있다.

interface FastFoodStoreTask {
    fun makeHamburger()
}

class BurgerKingTask : FastFoodStoreTask {
    override fun makeHamburger() { // 와퍼 만드는 코드... }
}

class McDonaldsTask : FastFoodStoreTask {
    override fun makeHamburger() { // 빅맥 만드는 코드... }
}

class PartTimer(task: FastFoodStoreTask) {
    private var task: FastFoodStoreTask
    
    init {
        this.task = task
    }

    fun changeTask(newTask: FastFoodStoreTask) {
        this.task = newTask
    }

    fun work() {
        task.makeHamburger()
    }
}

fun main(args: Array<String>) {
    // 버거킹 알바생 객체를 만드는 코드
    val partTimer = PartTimer(BurgerKingTask())

    // 맥도날드 알바생으로 직종 변경한 알바생
    partTimer.changeTask(McDonaldsTask())
}

위와 같이 의존하는(필요한) 클래스를 직접 생성하는 것이 아니라, 생성자와 메서드를 통해 외부에서 주입해줌으로서 객체간의 결합도를 줄이고 좀 더 유연한 코드를 작성할 수 있게 되었다. 이제 알바생이 롯데리아나 맘스터치로 알바를 옮겨도, FastFoodStoreTask 인터페이스를 구현한 롯데리아 업무 클래스나 맘스터치 업무 클래스를 만들어 사용하기만 하면 된다.

이렇게 의존 관계 주입을 활용한 프로그래밍에서는 객체가 자신이 사용할 객체를 스스로 선택하지 않고, 제3의 객체가 사용할 객체를 주입한다. 의존 관계 역전(Inversion of control)이라고 불리는 이 과정에서 반드시 프레임워크가 필요하지는 않다. 객체의 관계를 조립하는 팩토리 클래스를 만들어서 사용할 수도 있다. 하지만, Dagger2, Koin 과 같은 DI 프레임워크를 사용하는 것은 객체 조립 과정을 더 편리하게 한다. 참고 : Android에서 @Inject, @Test

 

3. 안드로이드에서의 Dependency Injection

안드로이드에서는 크게 두 가지 방식의 의존성 주입이 가능하다.

  • 생성자 주입 (Constructor Injection) : 생성자를 통해 의존하는 객체를 전달
  • 필드 주입 또는 세터 주입 (Field Injection or Setter Injection) : 객체가 초기화된 후, 메서드를 통해 의존하는 객체를 전달

위의 버거킹 알바생 예시에서 처럼, 이 방식들을 사용하여 의존성을 내가 직접 만들고, 제공하고 관리할 수 있다. 이를 dependency injection by hand, 또는 manual dependency injection 이라 부른다. 

위의 버거킹 알바생 예시에서는 의존성이 두 곳 밖에 존재하지 않았기 때문에 이렇게 직접 의존성을 관리하는 것이 어렵지 않았다. 그러나 더 많은 클래스와 의존성들이 생긴다면, 기존의 manual dependency injection 방식은 좀 더 복잡하고 까다로워지며 아래와 같은 몇가지 문제점을 야기할 수 있다. 

(1) 커다란 규모의 앱에서, 모든 종속성을 가져와 올바르게 연결하기 위해 수많은 boilerplate code 가 필요할 수 있다. 특히, 다중 계층 아키텍처에서는 최상위 계층에 대한 객체를 만들기 위해 그 아래 계층의 모든 종속성을 제공해야 한다. 예를 들어서, 실제 패스트 푸드점에서는 햄버거 만들기뿐만 아니라, 감자튀김 튀기기, 카운터 보기, 매장 청소, 매대 채우기 등의 업무가 필요할 수 있다. 그리고 햄버거를 만들때도, 어떤 빵을 사용해야하는지, 어떤 패티를 사용해야하는지 등 매우 다양한 변수가 존재할 수 있다. 이를 코드로 구현한다면, 매번 필요한 객체를 초기화하여 넣어주는 과정이 불필요하게 길어질 수 있다.

(2)  의존성을 전달하기 전에는 의존성을 구성할 수 없는 경우가 생길 수 있다. 예를 들어 lazy initializations 또는 앱의 흐름 내에서 객체를 scoping 하는 경우를 들 수 있다. 이러한 경우, 메모리에서 의존성의 lifetime 을 관리할 수 있게 하는 커스텀 컨테이너를 작성하여 유지해야 한다.

이러한 문제점을 해결하기 위해 의존성을 만들고 제공하는 과정을 자동화한 라이브러리 (DI 프레임워크라고도 부른다)가 등장하였다. 그 중 가장 대표적인 것이 Dagger2 이다.

 

4. 안드로이드에서의 Manual Dependency Injection 예시

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

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

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

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

 

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

 

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    
    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

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

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

 

// Definition of a Factory interface with a function to create objects of a type
interface Factory {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

 

안드로이드 공식 문서 참고 : https://developer.android.com/training/dependency-injection/manual

 

 

 

 

 

참고 사이트 >>>

android 공식문서 : https://developer.android.com/training/dependency-injection

https://woovictory.github.io/2019/07/08/DI/

https://woovictory.github.io/2019/05/04/What-is-DI/

https://imcreator.tistory.com/106

Comments