DIP(Dependency Inversion Principle) — 모듈 간 의존성 구축 규칙을 정의하는 SOLID의 다섯 번째 원칙입니다: 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항은 추상화에 의존해야 합니다. 로버트 마틴이 Clean Architecture (2017)에서 설명한 이 원칙은 느슨하게 결합된 아키텍처의 기초입니다. 이 책에 따르면, 의존성 역전 원칙은 애플리케이션 계층 간의 단단한 결합을 제거합니다.
주요 내용
DIP(Dependency Inversion Principle)는 모듈 간 의존성 방향에 대한 전통적인 관점을 뒤집는 의존성 역전 원칙입니다. 상위 수준 모듈(비즈니스 로직)은 하위 수준 모듈(데이터베이스, 네트워크, UI)에 직접 의존해서는 안 됩니다. 대신, 두 수준 모두 상위 수준 모듈에서 정의된 추상화에 의존합니다.
DIP의 공식적인 공식화는 두 가지 규칙을 포함합니다: A — 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다. B — 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항은 추상화에 의존해야 합니다. 두 번째 규칙은 첫 번째 규칙에서 비롯됩니다: 추상화가 세부 사항에 의존한다면, 상위 수준 모듈을 위한 안정적인 기반이 될 수 없습니다.
DIP가 없는 경우, 일반적인 아키텍처는 다음과 같습니다: BusinessLogic → DatabaseRepository — 비즈니스 로직이 구체적인 리포지토리에 직접 의존합니다. DIP가 있는 경우: BusinessLogic → DatabaseServiceInterface ← DatabaseRepository. BusinessLogic은 DatabaseRepository의 존재를 알지 못합니다, 비즈니스 로직 외부에서 구현되는 DatabaseService 인터페이스만 알고 있습니다.
역전은 제어 흐름과 의존성 흐름이 반대 방향으로 간다는 것을 의미합니다. 제어 흐름은 위에서 아래로 갑니다: UI → ViewModel → UseCase → Repository. 의존성 흐름은 아래에서 위로 갑니다: Repository는 UseCase에서 정의된 인터페이스를 구현합니다. Repository(하위 수준)는 UseCase(상위 수준)에 의존합니다.
이 역전이 DIP와 일반적인 계층 분리의 주요 차이점입니다. 전통적인 계층형 아키텍처에서는 각 계층이 아래 계층에 의존합니다. DIP 기반 아키텍처에서는 모든 계층이 추상화에 의존하며, 이러한 추상화의 구현은 인프라 계층에 있으며 DI 메커니즘을 통해 상위 계층에 “연결”됩니다.
DIP 메커니즘은 상위 수준 모듈에서 추상화를 정의하고 하위 수준 모듈에서 구현함으로써 실행됩니다. 상위 수준 모듈은 필요한 기능에 대한 인터페이스를 선언합니다. 하위 수준 모듈은 이 인터페이스를 구현합니다. 와이어링은 애플리케이션의 컴포지션 루트에서 이루어집니다.
기존 코드에 DIP를 도입하는 과정: 하위 수준 모듈의 인터페이스를 추출하고, 이 인터페이스를 상위 수준 모듈(또는 별도의 추상화 계층)로 이동하고, 상위 모듈의 의존성을 인터페이스를 사용하도록 다시 작성하고, 하위 수준 모듈이 이 인터페이스를 구현하도록 합니다. 이러한 단계 후에, 의존성 방향이 역전되었습니다.
DIP에는 컴포지션 루트 메커니즘이 필요합니다 — 모든 의존성이 생성되고 서로 연결되는 애플리케이션의 지점입니다. Android에서는 Application.get() 또는 Hilt 컴포넌트, iOS에서는 AppDelegate 또는 SceneDelegate입니다. 컴포지션 루트는 코드가 구체적인 구현에 대해 아는 유일한 장소입니다.
DIP는 애플리케이션 계층 간 아키텍처 경계를 만듭니다. ViewModel이 UserRepository 인터페이스에 의존할 때, 프레젠테이션 계층과 도메인 계층 사이에 경계가 형성됩니다: ViewModel(프레젠테이션)은 데이터가 어디서 오는지 알지 못합니다. 이 경계는 ViewModel에 영향을 주지 않고 UserRepository 구현(Room → REST → Mock)을 변경할 수 있게 합니다. 이러한 경계가 많을수록, 애플리케이션은 프레임워크 및 라이브러리 변경에 더 탄력적입니다.
Google이 권장하는 Android 아키텍처에서 DIP는 도메인 계층에 있으며 Repository 인터페이스에 의존하는 UseCase를 통해 구현됩니다. RepositoryImpl은 데이터 계층에 있으며 이러한 인터페이스를 구현합니다. 프레젠테이션 계층(ViewModel)은 UseCase에 의존합니다. 의존성 방향은 프레젠테이션에서 도메인으로, 도메인에서 데이터로 갑니다 — 그러나 어떤 계층도 다른 계층의 구체적인 구현에 대해 알지 못합니다.
DIP와 DI는 종종 혼동되지만, 다른 개념입니다. DIP는 아키텍처 원칙입니다(무엇을 할 것인가: 추상화에 의존). DI는 구현 패턴입니다(어떻게 할 것인가: 생성자를 통해 의존성 전달). DIP는 “모듈은 무엇에 기반해야 하는가?”라는 질문에 답하고, DI는 “객체는 어떻게 의존성을 얻는가?”에 답합니다.
의존성 주입(Dependency Injection)은 생성자, 메서드 또는 속성을 통해 객체에 의존성을 주입하는 방법입니다. Kotlin 클래스가 생성자를 통해 Repository 인터페이스를 받을 때 — 이것이 DI입니다. ViewModel 클래스가 구체적인 RoomRepository 구현 대신 Repository 인터페이스에 의존한다는 사실 — 이것이 DIP입니다. DI는 도구이고, DIP는 목표입니다.
DI 프레임워크 없이도 DIP를 따를 수 있습니다: 컴포지션 루트에서 수동으로 의존성을 연결하는 것도 DI(수동 DI)입니다. DIP를 위반하면서 DI 프레임워크(Dagger, Hilt, Koin)를 사용할 수 있습니다: ViewModel이 new()를 통해 직접 Repository 객체를 생성하는 경우 — 프레임워크가 설치되어 있어도 DIP가 위반됩니다. DIP는 아키텍처 결정이고, DI는 기술적 세부 사항입니다.
데이터 계층에 DIP를 적용하는 Android 예시를 살펴보겠습니다. DIP가 없는 경우, ViewModel이 직접 RoomDatabase와 DAO를 생성합니다. DIP가 있는 경우 — ViewModel은 UserRepository 인터페이스에 의존하고, 구체적인 RoomUserRepository 구현은 외부에서 제공됩니다.
// 추상화는 도메인 계층(상위 수준)에 속한다
interface UserRepository {
fun getUser(id: Int): User
}
// 도메인 계층은 추상화에만 의존한다
class GetUserUseCase(
private val repo: UserRepository
) {
fun execute(id: Int): User = repo.getUser(id)
}
// 데이터 계층의 구현은 도메인 계층 추상화에 의존한다
class RoomUserRepository(
private val dao: UserDao
) : UserRepository {
override fun getUser(id: Int): User {
return dao.getById(id)
}
}
// 컴포지션 루트
class AppModule {
fun provideUserRepository(dao: UserDao): UserRepository {
return RoomUserRepository(dao)
}
}
Application Coordinator와 네비게이션 프로토콜을 사용한 iOS 예시:
// 도메인 계층의 네비게이션 추상화
protocol AuthNavigation {
func navigateToHome()
func navigateToLogin()
}
// ViewModel은 추상화에 의존하고 UIKit에 의존하지 않는다
final class AuthViewModel {
private let navigation: AuthNavigation
init(navigation: AuthNavigation) {
self.navigation = navigation
}
func onLoginSuccess() {
navigation.navigateToHome()
}
}
// Coordinator(UIKit 계층)는 도메인 계층의 프로토콜을 구현한다
final class AppCoordinator: AuthNavigation {
func navigateToHome() {
// UIKit 네비게이션 코드
}
func navigateToLogin() {
// UIKit 네비게이션 코드
}
}
핵심 포인트: AuthViewModel(도메인)은 AppCoordinator(UIKit)의 존재를 알지 못합니다. AuthNavigation 프로토콜만 알고 있습니다. 내일 UIKit이 SwiftUI로 대체되어도 — AuthViewModel은 변경이 필요하지 않습니다. DIP는 도메인 계층을 UI 프레임워크 및 라이브러리로부터 독립적으로 만듭니다.
Hilt는 Android용 표준 DI 도구로, Google이 권장합니다. Jetpack에 내장되어 있으며 ViewModel, Fragment, Service 및 기타 Android 구성 요소를 지원합니다. Hilt는 @Module, @Provides, @Inject 애노테이션을 통해 컴포지션 루트 생성을 자동화합니다. Hilt를 사용한다고 DIP 준수가 보장되지는 않습니다 — UserRepository 인터페이스는 데이터 계층이 아닌 도메인 계층에서 정의되어야 합니다.
Koin은 코드 생성이나 애노테이션 처리 없이 Kotlin을 위한 경량 DI 프레임워크입니다. Koin의 DSL(module, single, factory)은 배우기 쉽지만, 의존성 검사는 컴파일 타임이 아닌 런타임에 이루어집니다. Koin은 iOS 지원 덕분에 멀티플랫폼 프로젝트(KMP)에서 인기가 있습니다.
Dagger 2는 Hilt의 전신으로, 여전히 대규모 프로젝트에서 사용됩니다. Dagger는 컴파일 타임에 DI 코드를 생성하여 최대 성능과 빌드 타임 오류 진단을 제공합니다. Hilt는 Dagger 위에 구축되었으며 단순화된 API를 제공합니다. 새로운 프로젝트의 경우 Google은 Hilt를 기본 DI 프레임워크로 권장합니다.
DI 모듈은 아키텍처 계층에 대응해야 하며 DomainModule, DataModule, PresentationModule로 분리되어야 합니다. DomainModule은 추상화와 UseCase만 제공합니다. DataModule은 추상화에 대한 구현을 제공합니다. PresentationModule은 ViewModel과 UseCase를 연결합니다. 이 구성은 도메인 계층이 인프라 라이브러리로부터 독립적으로 유지되도록 보장합니다.
DI 프레임워크 간 마이그레이션 시(예: Koin에서 Hilt로), DomainModule의 구조는 변경되지 않습니다 — DataModule과 PresentationModule의 연결 방식만 변경됩니다. DIP는 도메인 로직의 분리를 보장하고, DI 프레임워크는 기술적 연결 메커니즘입니다.
자주 묻는 질문
DIP는 아키텍처 경계에서 필요합니다 — 애플리케이션 계층 간(도메인 → 데이터, 프레젠테이션 → 도메인). 단일 계층 내에서는 DIP가 과도할 수 있습니다. 예를 들어, 도메인 계층 내의 유틸리티 클래스 StringFormatter는 교체할 이유가 없다면 인터페이스가 필요하지 않습니다.
아니요. DIP는 원칙입니다: 모듈은 추상화에 의존해야 합니다. DI는 패턴입니다: 객체는 의존성을 스스로 생성하는 대신 외부에서 받습니다. DI는 DIP를 구현하는 방법이지만, DIP는 DI 없이도(팩토리나 서비스 로케이터를 통해) 따를 수 있습니다. DI 없는 DIP는 가능하지만 아키텍처적 가치가 없습니다.
인터페이스는 그것을 사용하는 모듈에 속합니다, 구현하는 모듈이 아닙니다. UserRepository는 도메인 계층에서 선언되고 데이터 계층에서 구현됩니다. 이것이 DIP의 핵심 규칙입니다: 추상화의 소유자는 소비자이며, 구현 제공자가 아닙니다.
DIP는 분리된 계층에서 테스트를 가능하게 합니다. UserRepository(인터페이스)에 의존하는 ViewModel은 데이터베이스 없이 목 구현으로 테스트할 수 있습니다. DIP가 없으면 ViewModel은 RoomUserRepository에 의존하여 각 테스트마다 데이터베이스 설정이 필요합니다. DIP + DI는 테스트 중 완전한 모듈 분리를 제공합니다.
Hilt는 Android 프로젝트의 표준 선택이며, Google이 권장합니다. Koin은 Kotlin Multiplatform 프로젝트의 대안입니다. Dagger 2는 Hilt로의 마이그레이션이 정당화되지 않는 기존 프로젝트용입니다. 프레임워크 선택이 아키텍처 수준에서 DIP를 따라야 할 필요성을 없애지는 않습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.