의존성 주입에 대한 고찰
안드로이드 앱 개발 프로젝트들을 살펴보면, 대부분 DI 관련 라이브러리인 Hilt, Koin, Dagger2 등을 사용한 것을 볼 수 있습니다. 프로젝트들을 되돌아보면서 내가 정말 알고 사용하는 것이 맞나? 하고 점검하는 시간을 가지는 중에 DI 에 대한 의문? 이 생겨서 글로 남겨보고자 합니다.
먼저, DI 가 뭘까? 에 대해서 정의해 보겠습니다.
DI
많은 아티클, 블로그, 문서들을 살펴보면서 DI 에 대한 정의가 조금씩 다른 느낌이 들었습니다. 보통, 의존성 주입은 외부에서 클래스의 인스턴스를 생성하여 클래스 내부로 주입한다고 정의하곤 합니다. 저는 이렇게 한 문장으로 표현해보고 싶습니다.
의존성 주입은 클래스와 클래스의 관계가 아닌, 클래스와 객체 간의 관계를 만들고 외부에서 인스턴스를 생성하여 주입하는 패턴 이다.
보통 DI 관련 글을 검색하면, DI 와 추상화를 연결지어 설명하고는 합니다. 인터페이스를 만들고 인터페이스의 타입을 생성자로 주입하는 형태를 DI 라고 말하는 글들을 많이 보았습니다. 하지만 그 문장에 추상화가 없으면 의존성 주입이 아닌건가? 또는 추상화가 없으면 DI 를 할 수 없는건가? 라는 의구심이 들었습니다.
추상화 와 의존성 주입
class A(b: B) { }
open class B(open val name: String) {}
class C(override val name: String = "C"): B(name) {}
class D(override val name: String = "D"): B(name) {}
fun main() {
val b: B = C()
val a = A(b)
}
정말 간단한 DI 예시 입니다. 뭔가 떠오르시지 않으신가요? 저는 추상화 보다는 다형성이 가장 먼저 떠올랐습니다. 다형성은 상속 관계에 있는 클래스의 인스턴스는 그보다 상위 혹은 하위 타입으로 얼마든지 캐스팅 될 수 있다. 는 것을 의미하는 객체지향 프로그래밍의 4대 원리중 하나입니다.
다형성을 기반으로 생각하면, 의존성 주입을 통해 확장성과 코드의 재사용성을 높이도록 클래스를 설계할 수 있습니다. 하지만, 이것은 의존성 주입을 했을 때 일어나는 효과 라고 생각합니다. 즉, 다형성을 목적으로 의존성을 주입해야 한다? 라기 보다는 의존성 주입을 했을 때 “다형성을 이용하여 확장성과 코드 재사용성을 늘릴 여지를 만들 수 있다” 라고 생각합니다. 간단하게 생각하면 다형성을 이용하는 방법 중 하나인 것이죠.
따라서, 다형성 보다는 저는 많은 아티클에서 의존성 주입의 장점에 대해 강조하는 것 중 하나인 테스트 가능성 을 더 집중해보려 합니다.
다형성을 위해 의존성 주입을 하는건 아니다.
먼저, 다형성을 위해서 의존성을 주입하는 것은 아니다는 관점을 위해 한가지 예시를 들어볼까요?
@HiltViewModel
class MyViewModel @Inject constructor(usecase: DoSomethingUseCase) : ViewModel() {}
class DoSomethingUseCase @Inject constructor() {}
뷰모델에 DoSomethingUseCase 인스턴스를 주입한다고 가정해보겠습니다. UseCase 는 클린 아키텍쳐에서 이야기 하는 UseCase 로 가정해 보겠습니다. 애초에 UseCase 의 목적은 비즈니스 룰인 Entity 에 대한 어떤 행동 또는 기능을 명확하게 정의하는 것 입니다. 즉, UseCase 의 존재는 명확하고, 구체적이어야 합니다. UseCase 를 인터페이스로 만들고 인터페이스의 타입으로 주입하면서, 구체적인 것에 의해 관심사를 갖지 마! 라고 할 수 없습니다. 그렇게 하면 우리가 해결하고자 하는 문제가 뭔지, 또 이 UseCase 가 뭔지 어떤 기능을 하는지 명확히 알 수 없는 문제가 생기고 코드를 이해하는데 시간이 걸리고 가독성이 떨어질 겁니다.
또다른 예시를 살펴볼까요?
@AndroidEntryPoint
class MyActivity : ComponentActivity() {
val viewModel: MyViewModel lazy by viewModels()
}
@HiltViewModel
class MyViewModel @Inject constructor() : ViewModel() {}
가장 흔히 사용하는 Hilt 라이브러리를 이용하는 의존성 주입 코드를 예시로 들었습니다. 해당 뷰모델은 @Inject 어노테이션으로 Activity 에서 생성하지 않아도 내부적으로 Activity#getDefaultViewModelProviderFactory() 를 override 로 생성하여 사용하는 코드 덕분에 by ViewModels() 델리게이트 함수를 그대로 이용하여 주입할 수 있습니다. (자세한 설명은 브랜디 에서 작성한 글을 참고해 주세요.)
물론, 이 코드는 의존성 주입에서 강조하는 생성자 주입이 아닙니다. Activity 는 ActivityThread 에서 Dispatch 되면서 Instrumentation 에 의해 인스턴스화 된 후 콜백 함수들이 순서에 따라 호출되게 됩니다. 따라서, 프레임워크 에서 생성하고 생명주기를 제어하기 때문에 Activity의 생성자로 주입할 수 없고, 필드 주입을 통해 뷰모델 의존성을 주입합니다.
Hilt 가 뷰모델 인스턴스를 외부에 생성하고 주입하게 되므로 의존성 주입에 해당합니다. 하지만 보통 뷰모델 인스턴스를 생성하는데 다형성을 위해서 Hilt 를 활용하여 의존성을 주입하는 코드를 작성하지는 않습니다. DI container 를 이유로 사용한다고 생각하시나요? Hilt 를 사용하지 않아도 뷰모델은 액티비티 내에서의 수명주기를 갖기 때문에 @HiltViewModel 어노테이션을 작성하지 않고도 생성하여 사용하는데 문제가 없습니다.
이러한 설계 원칙들이나 또는 그 클래스의 특성과 존재를 이유로 다형성을 이용할 이유가 없는데도 의존성 주입을 우리는 계속 사용하고 있습니다. “다형성을 이용하여 확장성과 코드 재사용성을 늘릴 여지를 만들 수 있다” 라고 앞서 얘기한 이유가 이것 입니다. 다형성은 의존성 주입을 해줌으로써 얻어질 수 있는 장점에 불과하고 오히려 그보다 테스트 가능성을 만들어준다는 점이 주요한 목적이라고 생각합니다.
테스트 가능성을 위한 의존성 주입
만약, 뷰모델을 테스트 하고 싶다면 어떨까요? 우리는 뷰에 대한 로직을 검증하기 위해 뷰모델을 테스트 하곤 합니다. 이때 의존성 주입을 했던 진가가 발휘됩니다.
internal class ViewModelTest {
private lateinit var viewModel: MyViewModel
private lateinit var usecase: DoSomethingUseCase
@Before
fun setUp() {
usecase = mockk()
viewModel = usecase
}
}
만약, 뷰모델 내부에서 usecase 를 생성했다면, 우리는 usecase 의 동작에 대해 예측할 수 없고, 테스트하기 어려워집니다. 하지만, 의존성 주입 덕분에 모킹이나 Fake 객체를 생성하여 단위테스트 코드에서 주입한 뒤 예측 가능한 형태의 뷰 로직에 대한 테스트를 진행할 수 있습니다.
이제 정리해서 의존성 주입과 장점에 대해서 요약할 수 있을 것 같습니다.
내가 생각하는 의존성 주입 과 장점
의존성 주입은 클래스와 클래스의 관계가 아닌, 클래스와 객체 간의 관계를 만들기 위해 외부에서 인스턴스를 생성하여 주입하는 패턴 이고, 이를 통해 다형성의 원리를 이용할 여지가 생겨 확장성과 코드 재사용성을 높일 수 있고, 테스트 가능성을 만들어준다.
끝으로
프로젝트를 진행하면서 그 당시에는 이해하지 못했던 DI 에 관한 추상적인 개념과 패턴들에 대해 질문을 던지고 정의해 나가면서 저만의 정답을 도출하는 과정을 가지게 되었습니다. 물론 틀렸거나 조금 부족한 내용이 있을 수 있습니다. 만약 이 글을 보고 계신분들 중에 저에게 미숙한 점이 있다면 댓글을 남겨주시면 감사하겠습니다.
댓글남기기