의존성 주입에 대한 고찰
안드로이드 앱 개발 프로젝트들을 살펴보면, 대부분 DI 관련 프레임워크로 Dagger 기반이면서 안드로이드 Jetpack 으로 제공되는 Hilt 나 Koin 등을 사용하는 것을 볼 수 있었습니다. 저의 프로젝트에 Hilt 를 적용하면서 DI 에 대해 자세히 알지 못했던 부분들을 새로 학습하고 포스팅에 남겨보려 합니다.
먼저, DI 가 뭘까? 에 대해서 정의해 보겠습니다.
DI (Dependency Injection)
많은 아티클, 블로그, 문서들을 살펴보면서 DI 에 대한 정의가 조금씩 다른 느낌이 들었습니다. 보통, 의존성 주입은 외부에서 클래스의 인스턴스를 생성하여 클래스 내부로 주입한다고 정의하곤 합니다. 저는 이렇게 한 문장으로 표현해보고 싶습니다.
의존성 주입은 클래스와 클래스의 관계가 아닌, 클래스와 객체 간의 관계를 만들고 외부에서 인스턴스를 생성하여 주입하는 패턴 이다.
클래스가 특정 클래스를 의존하지 않고, 객체를 의존하게 함으로써 추상화와 다형성을 이용하여 재사용성 과 테스트 가능성 을 높일 수 있다는 장점이 있습니다.
의존성 주입을 통한 추상화와 다형성의 이용
class A(b: B) { }
interface B {
val name: String
fun doSomething()
}
class C(override val name: String = "C"): B {
override fun doSomething() { println("I am C") }
}
class D(override val name: String = "D"): B {
override fun doSomething() { println("I am D") }
}
fun main() {
val b: B = C()
val a = A(b)
}
간단하게 작성한 위의 코드는 클래스 A에 B 인터페이스의 구현체를 주입하고, B 인터페이스의 구현체중 C 와 D중 누구든지 주입의 대상이 될 수 있다는 의존성 주입에 대한 예시 입니다.
뭔가 떠오르시지 않으신가요? 바로 추상화와 다형성을 이용한 예시 입니다.
추상화는 구현하고자 하는 기능과 행동의 중요한 것들만 추상적으로 표현하고, 나머지 세부적이고 구체적인 것들은 구현체에 의존 하게 하는 원리이고, 다형성은 상속 관계에 있는 클래스의 인스턴스는 그보다 상위 혹은 하위 타입으로 얼마든지 캐스팅 될 수 있다 는 것을 의미하는 객체지향 프로그래밍의 4대 원리중 하나입니다.
의존성 주입 패턴을 사용한다면, 외부에서 생성된 인스턴스를 주입받으면서 구체적인 특정 클래스와 관계를 맺지 않고, 주입되는 인스턴스와 관계를 맺기 때문에 구체적 인스턴스가 어떤 것인지 몰라도 됩니다. 또, 그 인스턴스가 어떤 구체적인 기능을 수행하는지에 대해서도 관심을 갖지 않습니다. 따라서, 결과적으로 재사용성을 높일 수 있습니다.
많은 아티클에서 이부분을 강조하고 있기도 합니다. 뿐만아니라, 이점을 이용하면 테스트 가능성 또한 높일 수 있습니다.
의존성 주입을 통한 테스트 가능성
@HiltViewModel
class MyViewModel @Inject constructor(usecase: DoSomethingUseCase) : ViewModel() {}
interface DoSomethingUseCase {}
class DoSomethingUseCaseImpl @Inject constructor(): DoSomethingUseCase {}
class DoSomethingUseCaseTestImpl: DoSomethingUseCase {}
internal class ViewModelTest {
private lateinit var viewModel: MyViewModel
private lateinit var usecase: DoSomethingUseCase
@Before
fun setUp() {
usecase = mockk()
viewModel = usecase
}
}
뷰모델에 대한 테스트를 진행한다고 가정해 보겠습니다.
물론, UseCase 는 클린 아키텍쳐에서 이야기 하는 UseCase 라고 했을 때, UseCase 의 목적은 비즈니스 룰인 Entity 에 대한 어떤 행동 또는 기능을 명확하게 정의하는 것 입니다.
즉, UseCase 의 존재는 명확하고, 구체적 이어야 합니다. UseCase 를 인터페이스로 만들고 인터페이스의 타입으로 주입하면서, 구체적인 것에 의해 관심사를 갖지 마! 라고 할 수 없습니다. 그렇게 하면 우리가 해결하고자 하는 문제가 뭔지, 또 이 UseCase 가 뭔지 어떤 기능을 하는지 명확히 알 수 없는 문제가 생기고 코드를 이해하는데 시간이 걸릴 수 있습니다.
하지만, 유즈케이스를 의존하는 특정 클래스(여기서는 뷰모델)를 테스트한다고 했을 때 우리는 유즈케이스가 어떤 기능을 수행하는지에 대해서는 관심이 없습니다. 단순히 뷰모델에서 동작하는 뷰로직이 정상적으로 동작하는지를 알고 싶을 뿐입니다.
이럴 때는 구체적인 유즈케이스의 인스턴스를 생성하여 어렵게 가져오기 보다는 단순하게 유즈케이스를 추상화하고 구현체를 Mocking 하여 간단하게 테스트 해볼 수 있습니다.
결론적으로 테스트 가능성을 만든다는 것 역시 재사용성의 이점을 이용하고 있을 뿐 입니다.
의존성 주입 프레임워크
@AndroidEntryPoint
class MyActivity : ComponentActivity() {
val viewModel: MyViewModel lazy by viewModels()
}
@HiltViewModel
class MyViewModel @Inject constructor() : ViewModel() {}
이번에는 Hilt 로 ViewModel 인스턴스를 주입하는 코드를 예시로 보겠습니다. 위 방식은 생성자로의 의존성 주입이 아닙니다. Activity 는 ActivityThread 에서 Dispatch 되면서 Instrumentation 에 의해 인스턴스화 된 후 콜백 함수들이 순서에 따라 호출되게 됩니다. 따라서, 프레임워크 에서 생성하고 생명주기를 제어하기 때문에 Activity의 생성자로 주입할 수 없고, 필드 주입을 통해 뷰모델 의존성을 주입합니다.
Activity 는 내부적으로 갖고 있는 추상클래스인 ViewModel 들을 생명주기에 따라 관리하기 위해 Map<> 형태로 들고 있는데, 이 또한 필드주입을 통해 MyViewModel 이 오던, MyViewModel2 가 오던 구현체에 상관없이 관리할 수 있게 됩니다.
또, 위와 같이 Hilt를 함께 사용하면, Hilt 에 의해 생성되는 코드로 Activity#getDefaultViewModelProviderFactory() 를 override 하면서, 매번 직접 ViewModel 을 생성하기 위한 ViewModelProvider.Factory 를 구현하지 않아도 되며, (자세한 설명은 브랜디 에서 작성한 글을 참고해 주세요.) 결과적으로 보일러 플레이트들을 Hilt 와 Dagger 컴파일러가 직접 생성후 삽입해주면서, Hilt 가 뷰모델 인스턴스를 외부에 생성하고 생명주기에 따라 관리해주어 개발자가 더 편리하게 코드를 작성해 줄 수 있도록 도와줍니다.
이제 정리해서 의존성 주입과 장점에 대해서 요약할 수 있을 것 같습니다.
의존성 주입의 장점
의존성 주입은 클래스와 클래스의 관계가 아닌, 클래스와 객체 간의 관계를 만들기 위해 외부에서 인스턴스를 생성하여 주입하는 패턴 이고, 이를 통해 객체지향 프로그래밍의 다형성과 추상화를 이용할 수 있어 확장성과 코드 재사용성을 높일 수 있고, 테스트 가능성을 만들어준다.
끝으로
프로젝트를 진행하면서 그 당시에는 이해하지 못했던 DI 에 관한 추상적인 개념과 패턴들에 대해 질문을 던지고 정의해 나가면서 저만의 정답을 도출하는 과정을 가지게 되었습니다. 물론 틀렸거나 조금 부족한 내용이 있을 수 있습니다. 만약 이 글을 보고 계신분들 중에 저에게 미숙한 점이 있다면 댓글을 남겨주시면 감사하겠습니다.
댓글남기기