의존성 주입(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)
}
첫 번째. 보일러플레이트 코드가 많아진다.
두 번째. 객체 생성 순서에 영향을 받는다.
세 번째. 객체를 재사용하기 어렵다.
서비스 로케이터란?
서비스 로케이터란 마틴 파울러가 제시한 디자인 패턴으로, 복잡성 및 객체 생성을 추상화하고 클라이언트에게 간단한 인터페이스를 제공하여 인스턴스를 반환한다.
이에 따라 클라이언트의 복잡성이 줄어들고 재사용할 수 있다는 장점이 있다.
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)
Hilt(2020)
Dagger와 Hilt를 번역하면 단검과 손잡이다.
Hilt(손잡이)는 Dagger(단검)를 쉽게 사용하기 위해 만들어진 것이다.
의존성 주입을 잘 활용하면 느슨한 결합을 촉진하여 객체간 결합도를 줄이는데 도움이 되며 모듈성, 확장성, 가독성, 유지보수성을 향상시키는데 도움을 주는 등 여러 이점을 얻을 수 있다.
하지만 무조건적인 사용은 오버엔지니어링이 될 수 있으므로, 프로젝트 규모와 상황의 적합성을 잘 따져 배보다 배꼽이 더 큰 상황을 피하도록 하자.
참고자료