의존성 주입과 제어의 역전 (Dependency Injection and Inversion of Control)
의존성 주입과 제어의 역전 (Dependency Injection and Inversion of Control)
의존성 주입(Dependency Injection)은 객체가 필요한 의존성을 내부에서 직접 생성하지 않고, 외부로부터 전달받는 디자인 패턴입니다. 이 패턴을 통해 객체 생성에 대한 제어권이 소비 클래스에서 의존성 그래프(dependency graph)를 조립하는 별도 컴포넌트(인젝터)로 이동하게 되며, 이를 제어의 역전(Inversion of Control)이라 부릅니다. 안드로이드 개발에서는 Dagger와 Hilt 같은 DI 프레임워크가 컴파일 타임에 의존성 조립을 자동화하여, 런타임 리플렉션 오버헤드 없이 효율적인 코드를 생성해 줍니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 의존성 주입의 핵심 원리와 느슨한 결합(loose coupling)을 달성하는 방법을 설명할 수 있습니다.
- DI와 팩토리 패턴(Factory Pattern), 서비스 로케이터 패턴(Service Locator Pattern)의 차이를 구분할 수 있습니다.
- 생성자 주입(constructor injection)을 활용한 수동 DI를 구현하고, 대규모 프로젝트에서 발생하는 한계점을 이해할 수 있습니다.
- Dagger가 컴파일 타임에 의존성 그래프를 생성하는 과정을 설명할 수 있습니다.
- Hilt가 사전 정의된 컴포넌트와 진입점(entry point)을 제공하여 Dagger를 어떻게 간소화하는지 설명할 수 있습니다.
핵심 원리
의존성 주입을 사용하지 않으면, 클래스가 필요한 의존성을 직접 생성하게 됩니다.
class UserRepository {
private val api = RetrofitClient.create()
private val db = AppDatabase.getInstance()
}
위 코드는 테스트하기 매우 어렵습니다. 실제 API 클라이언트나 데이터베이스를 테스트용 대체 객체(test double)로 교체할 방법이 없기 때문입니다. UserRepository가 특정 구현체에 강하게 결합(tightly coupled)되어 있으므로, 네트워킹 라이브러리를 변경하려면 자체적으로 클라이언트를 생성하는 모든 클래스를 수정해야 합니다. 실무에서 이런 구조는 유지보수 비용을 크게 높이는 원인이 됩니다.
의존성 주입을 적용하면, 의존성을 생성자를 통해 외부에서 전달받습니다.
class UserRepository(
private val api: ApiService,
private val db: UserDao
) {
fun getUser(id: String): User = ...
}
이제 호출하는 쪽에서 어떤 ApiService와 UserDao 구현체를 전달할지 결정합니다. 프로덕션 환경에서는 실제 구현체를, 테스트 환경에서는 페이크(fake)나 목(mock) 객체를 주입할 수 있습니다. UserRepository 입장에서는 의존성이 어디서 오는지 알 필요도 없고, 알아야 할 이유도 없습니다.
DI와 팩토리 패턴의 비교
팩토리 패턴(Factory Pattern)은 객체 생성을 팩토리 클래스에 중앙화하지만, 클라이언트가 여전히 의존성을 명시적으로 요청한다는 점에서 DI와 다릅니다.
class UserRepository {
private val api = ApiServiceFactory.create()
}
클라이언트가 팩토리의 존재를 알고 직접 호출합니다. 직접 인스턴스를 생성하는 것보다는 낫습니다. 팩토리가 설정에 따라 다른 구현체를 반환할 수 있기 때문입니다. 하지만 클라이언트가 여전히 팩토리 클래스에 결합되어 있으므로, 팩토리 자체를 변경하거나 팩토리 인터페이스를 도입하지 않고서는 의존성을 대체하기 어렵습니다.
DI를 사용하면 클라이언트는 의존성이 어떻게 생성되는지 전혀 알지 못합니다. 의존성은 단순히 생성자에 나타날 뿐이며, 인젝터(수동 코드든 프레임워크든)가 의존성 그래프를 구성하는 책임을 집니다. 클라이언트는 이 과정에서 완전히 수동적인 역할만 합니다.