의존성 주입(DI) = Dependency Injection


라이브러리를 사용하지 않고 의존성을 수동으로 주입하는 것을 "수동 의존성 주입"으로 명시하였으며,
의존성 주입 라이브러리를 통해 의존성을 자동으로 주입하는 것을 "자동 의존성 주입"으로 명시하였다.

[의존관계]


class Car {
    private val engine = Engine()

	fun wearSeatBelt() {
    	println("안전벨트 착용")
    }
    
    fun start() {
        println("시동걸기")
    	engine.start()
    }
}

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

위 코드는 의존성 주입 없이 Car 내부에서 직접 Engine을 생성한다.
Car는 Egine을 사용하며, Car는 Egine에 의존한다. 라고 표현한다.


[의존성 주입이란?]

프로그램은 객체 간의 의존관계에 의해 동작하게 된다.
개발하면서 의존성은 필수적으로 작용하며 의존성 그 자체가 나쁜 것이 아니다.
다만 의존성이 높아지면 가독성, 재사용성, 확장성 등 유지보수함에 있어 어려움을 겪게 된다.
우리의 목표는 코드를 관리하기 쉽도록 객체 간의 의존성을 낮추고 최소화하는 것이다.
이를 돕기 위해 의존성 주입이라는 개념이 등장한다.

Car 클래스가 실행되기 위해서는 Engine 클래스의 인스턴스가 있어야 한다.
Car 내부에서 직접 Engine을 생성하지 않고 외부에서 Engine을 생성하여 Car로 주입하는 것.

의존성 주입이란 이와같이 외부에서 인스턴스를 생성하여 주입하는 것이다.

[의존성 주입의 필요성]

프로젝트 규모가 커지게 되면, 위의 코드에서 세 가지 문제가 발생한다.

첫 번째. 본연의 역할 이상을 수행한다.
사실 Car는 부품을 사용하여 동작하는 역할만 하면 된다.
하지만 위 코드에서는 불필요하게 부품을 생성하고 관리하는 역할까지 맡고 있다.
자동차에는 엔진뿐만 아니라 여러 부품이 존재하며 부품들이 추가되면서 Car 클래스 내의 코드는 점점 거대해진다.
SRP(Single Responsibility Principle)를 위반했기 때문이다.

두 번째. 변경에 민감하다
휘발유를 연료로 하는 가솔린 엔진 기준으로 엔진을 사용하였지만, 가스와 전기를 연료로 하는 엔진이 추가되었다고 하자.
GasEngine, ElectricEngin만 추가하면 될 거로 생각하였지만, Car는 Engine에 대한 종속성이 높아 Car를 재사용할 수 없고 각 유형에 맞게 추가되고 변경되어야 한다.(GasCar, ElectricCar)
OCP(Open Closed Principle)를 위반한 대가이다.

세 번째. 테스트가 어려워진다.
안전벨트 착용에 대한 테스트 코드를 작성해야 하는 상황에서 안전벨트는 엔진과 무관하다.
하지만 위 코드 구조에 의해 안전벨트 테스트를 위해 엔진을 신경 써야 하는 투머치한 상황이 발생한다. (실제 상황에서는 엔진을 생성하기 위해선 많은 과정이 필요할 것이다)
테스트 더블을 사용하여 Dummy 테스트가 불가한 것이다.

테스트 더블이란?
영화나 드라마에서 실제 배우가 연출하기 힘든 위험한 역할을 하는 'Stunt double'에서 유래된 말로, 실제 객체의 의도와 비슷해 보이게 동작하지만, 복잡성을 줄여 특정 상황에 대해 필요로 하는 부분에만 초점을 맞춘 단순화된 버전이며 다섯 가지 유형으로 나뉜다.

  • Dummy object
  • Test stub
  • Test spy
  • Mock object
  • Fake object

[수동 의존성 주입 방법]

아래 주요 두 가지 방법을 통해 간단하게 의존성 주입이 가능하다.


1. 생성자를 통한 의존성 주입
Car 객체 생성 시 생성자를 통해 Engine 객체를 전달받는다.

class Car(private val engine: Engine) {    
	fun wearSeatBelt() {
    	println("안전벨트 착용")
    }
    
    fun start() {
        println("시동걸기")
        engine.start()
    }
}

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

2. 필드 혹은 setter를 통한 의존성 주입
Car 객체 생성 후 필드나 Setter로 바로 접근하여 Engine을 주입한다.

class Car {
    lateinit var engine: Engine

	fun wearSeatBelt() {
    	println("안전벨트 착용")
    }
    
    fun start() {
        println("시동걸기")
        engine.start()
    }
}

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

이제 가스와 전기를 연료로 하는 엔진이 추가되어도 GasEngine, ElectricEngin 인스턴스를 생성하여 Car에 전달하여 Car를 재사용 할 수 있게 되었다.
또한 DummyEngine을 생성하여 안전벨트 테스트의 사전작업 환경을 손쉽게 만들 수 있다.

하지만 여전히 문제가 남아있다.

[수동 의존성 주입의 문제점]

무기를 사용하는 군인을 예시로 살펴보자.


fun main() {
	val bullet = Bullet()
    val gun = Gun(bullet)
    val knife = Knife()
    
    val weapon = Weapon(gun, knife)
    
    val soldier = Soldier(weapon)
}

첫 번째. 보일러플레이트 코드가 많아진다.

  • Soldier 객체를 생성하기 위해 무기 세팅하여 Soldier 객체를 생성하고 있다.
    다른 곳에서 Soldier 객체를 생성하기 위해서 위와 같은 코드들을 중복으로 생성해야 한다.

두 번째. 객체 생성 순서에 영향을 받는다.

  • Weapon은 Soldier 객체를 생성하기 전에 생성해야 한다.
  • Gun, Knife는 Weapon 객체를 생성하기 전에 생성해야 한다.
  • Bullet은 Gun 객체를 생성하기 전에 생성해야 한다.

세 번째. 객체를 재사용하기 어렵다.

  • 여러 곳에서 Soldier 객체를 재사용하려면 싱글톤 패턴을 따르게 해야 한다.
    그렇게 되면 모든 테스트가 동일한 싱글톤 인스턴스를 공유하므로 테스트가 더 어려워진다.

수동 의존성 주입의 대안으로 서비스 로케이터패턴이 존재하지만, 테스트를 더 어렵게 만들고, 사용하는 서비스의 모든 사용자가 서비스 로케이터에 종속되어 의존관계를 파악하기 힘들게 된다.

서비스 로케이터란?
서비스 로케이터란 마틴 파울러가 제시한 디자인 패턴으로, 복잡성 및 객체 생성을 추상화하고 클라이언트에게 간단한 인터페이스를 제공하여 인스턴스를 반환한다.
이에 따라 클라이언트의 복잡성이 줄어들고 재사용할 수 있다는 장점이 있다.
Mark Seemann 블로그에서는 Anti-Pattern이라는 비판을 받는 패턴이기도 하다.


[실제 앱 시나리오에 수동 의존성 주입]



실제 안드로이드 프로젝트는 일반적으로 구글에서 권장하는 위 아키텍처 그래프 모델과 비슷한 형태로 설계하게 된다.
실제와 비슷한 환경을 가정하여 수동 의존성 주입을 적용해보자.



위와 같은 로그인 비즈니스 플로우를 예시로 알아보자.


1. Repository 및 DataSource
UserLocalDataSource와 UserRemoteDataSource에 의존하는 UserRepository 구현


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

class UserLocalDataSource { 
	...
}

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

2. LoginViewModel 및 LoginData
로그인 시 사용되는 로그인 관련 Data와 로그인에 대한 비즈니스 로직을 담당하는 ViewModel 구현


class LoginData(private val userId: String,) {
	...
}

class LoginViewModel(private val loginRepository: LoginRepository) {
	...
}

3. Factory
LoginViewModel이 많은 위치에 필요할 수 있으며, 다른 유형의 ViewModel이 생성될 것을 고려하여 추상 팩토리 디자인 패턴을 적용하여 ViewModel을 생성하는 Factory 구현


interface Factory<T> {
    fun create(): T
}

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

4. LoginContainer

  • 여러 곳에 파편화된 로그인 관련된 인스턴스 생성을 이 Container에 담아두고 관리
  • 작업 유형별 Container의 수명관리를 위해 로그인 관련된 Container 구현

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

5. AppContainer
여러 곳에 파편화된 앱 전반적인 인스턴스 생성을 이 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)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
    
    var loginContainer: LoginContainer? = null

}

6. Application
AppContainer가 모든 Activity, Fragment 등 앱 전반에 걸쳐 사용할 수 있도록 Application 클래스에서 AppContainer 인스턴스를 생성한다.


class MyApplication : Application() {
    val appContainer = AppContainer()
}

7. LoginActivity

  • Application 인스턴스를 통해 AppContainer를 가져와 LoginViewModel 인스턴스 생성한다.
  • 로그인 흐름이 시작될 때 LoginContainer를 생성하고, 흐름이 종료될 때 소멸시킨다.

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()
    }
}


수동 의존성 주입에 대해 알아보았다.
위에서 언급한 문제점들과 인스턴스에 대한 수명주기를 수동으로 관리해야 하는 점으로 인해 다소 복잡해
지는 경향이 있다.
의존성 주입 라이브러리를 사용함으로써 문제점을 해결할 수 있다.



[자동 의존성 주입]

라이브러리를 통한 자동 의존성 주입은 적은 양의 코드로 클래스 간의 관계를 선언하고 의존관계와 수명주기를 설정하는 방법을 제공하여 의존성을 쉽게 관리할 수 있도록 도와준다.

아래 앱 크기에 따라 증가하는 비용 그래프를 통해 의존성 주입의 유형별 특성을 알아보자.

의존성 주입 유형 별 비교


수동 의존성 주입

  • 수동 의존성 주입은 러닝 커브가 낮은 장점이 있다.
  • 초기 비용이 들어가지 않지만, 앱 크기가 커질수록 수많은 보일러 플레이트 코드와 직접 관리 해야 할 작업이 늘어나 비용이 기하급수적으로 증가한다.
  • 앱 규모가 작다면 서비스 로케이터나 자동 의존성 주입에 들이는 비용이 적기 때문에 오히려 적합한 방법이 될 수 있다.

서비스 로케이터

  • 서비스 로케이터는 자동 의존성 주입에 비해 초기 비용이 적지만 앱이 커질수록 비용 곡선이 가파르게 상승하고 결국 수동 의존성 주입과 같은 문제에 직면하게 된다.

자동 의존성 주입 (Library)

  • 자동 의존성 주입은 셋업시간이 꽤 걸려 초기 비용이 많이 든다.
  • 하지만 앱이 커질수록 비용 곡선이 어느 정도의 선까지만 상승한다.
  • 앱이 중간 정도의 크기로 갈수록 유지관리 비용이 다른 방법들과 큰 차이가 나지 않으며 일정 수준을 넘어설 경우 오히려 비용이 적게 드는 모습을 볼 수 있다.


의존성 주입 라이브러리 비교

안드로이드에서 의존성 주입의 인기가 증가함에 따라 의존성 주입 프로세스를 단순화하는 것을 목표로 하는 여러 라이브러리가 등장했다.

의존성 주입 라이브러리들 중 Koin, Hilt, Dagger에 대해 알아보자.

Dagger (2012)

  • 컴파일 타임에 의존성을 관리해줄 코드를 검사하고 생성하여 런타임 예외가 발생하지 않아 정확성을 제공한다.
  • 컴파일 타임에 오버헤드가 발생하나 런타임 시 빠르고 안정적으로 작동한다.
  • 러닝 커브가 높다.

Dagger2의 등장 (2016)
Dagger1은 부분적으로 리플렉션을 사용해 런타임 시 오버헤드가 발생하고 디버깅이 어려운 단점이 있었으며, 이러한 문제점을 보완하기 위해 4년 뒤 Dagger2가 공개되었다.
Dagger2는 코드에 대한 추적을 쉽게 만들고 리플렉션을 사용하지 않고 어노테이션 프로세스를 적용하여 오버헤드를 런타임이 아닌 컴파일 타임으로 옮겨 런타임의 포퍼먼스를 향상 시켰다.

Koin(2017)

  • Kotlin DSL(Domain Specific Language)을 지원하여 직관적인 사용이 가능하다.
  • 서비스 로케이터 패턴 기반으로 만들어져 요청이 들어왔을 때 인스턴스를 동적으로 반환하여 런타임에 의존성을 해결하며 컴파일 타임에 코드가 생성되지 않는다.
  • 런타임에서 의존성을 해결하기 때문에 런타임 시 에러가 발생할 수 있으며 런타임 성능이 떨어진다.
  • 다른 의존성 주입 라이브러리들에 비해 가볍고 가장 러닝 커브가 낮다.

Hilt(2020)

  • 2019년 Google I/O에서 2020년부터 Android용 Dagger를 더 잘 만들 계획이라는 입장을 내놓았고, 2020년 Hilt를 공식적으로 발표하였다.
  • Dagger2를 기반으로 만들어졌기 때문에 Dagger의 장점을 모두 가지고 있다.
  • Dagger에서의 중복코드 발생과 복잡성을 개선하였으며, 최대 단점인 초기 셋업 비용을 감소시켰다.
  • Koin에 비해 러닝 커브가 높으나 Dagger에 비해 러닝 커브가 낮다.

    Dagger와 Hilt를 번역하면 단검과 손잡이다.
    Hilt(손잡이)는 Dagger(단검)를 쉽게 사용하기 위해 만들어진 것이다.




의존성 주입을 잘 활용하면 느슨한 결합을 촉진하여 객체간 결합도를 줄이는데 도움이 되며 모듈성, 확장성, 가독성, 유지보수성을 향상시키는데 도움을 주는 등 여러 이점을 얻을 수 있다.

하지만 무조건적인 사용은 오버엔지니어링이 될 수 있으므로, 프로젝트 규모와 상황의 적합성을 잘 따져 배보다 배꼽이 더 큰 상황을 피하도록 하자.



참고자료

profile
Android Developer

0개의 댓글