(Java/Kotlin) SOLID - DIP 의존성 역전 원칙 완전 정리
개요
- SOLID 원칙 중 다섯 번째 DIP(Dependency Inversion Principle) 를 다룹니다.
- DIP는 “고수준 모듈이 저수준 모듈에 직접 의존하지 말고, 둘 다 추상화에 의존해야 한다”는 원칙입니다.
- 이 글에서는 다음을 설명합니다.
- DIP가 정확히 무엇인지
- 왜 필요한지
- 위반 사례와 개선 방법
- 의존성 주입(DI)과의 관계
- Android / Kotlin 실전 예제
1. 정의
고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
— Robert C. Martin
❌ 전통적인 의존 방향
고수준 모듈(비즈니스 로직) → 저수준 모듈(DB, 네트워크, 파일)
✅ DIP 적용 후
고수준 모듈 → 추상화(인터페이스)
저수준 모듈 → 추상화(인터페이스) 구현
- 고수준 모듈 : 비즈니스 로직, 핵심 정책 (예: 주문 처리, 결제 검증)
- 저수준 모듈 : 세부 구현 (예: MySQL DB, Retrofit 네트워크, 로컬 파일)
- “의존성 역전”이란 고수준이 저수준을 바라보던 방향을 뒤집어, 저수준이 추상화를 바라보게 만드는 것입니다.
2. 왜 필요한가
고수준 모듈이 저수준 모듈을 직접 참조
→ 저수준 모듈(DB, 외부 API)이 바뀌면 고수준 모듈도 수정해야 함
→ 비즈니스 로직 테스트 시 실제 DB/네트워크가 필요해짐
→ 구현체 교체(MySQL → Room, Retrofit → Ktor)가 어려워짐
- DIP를 지키면 고수준 모듈은 추상화만 알고, 저수준 구현체는 언제든 교체할 수 있습니다.
- 테스트에서 가짜 구현체(Fake/Mock)를 주입해 단위 테스트가 쉬워집니다.
3. 위반 사례 — Before
❌ 고수준 모듈이 저수준 구현체를 직접 생성
// 저수준 모듈 — MySQL 데이터베이스 구현체
class MySqlUserDatabase {
fun getUser(id: Long): User {
println("MySQL에서 유저 조회: $id")
return User(id, "홍길동")
}
fun saveUser(user: User) {
println("MySQL에 유저 저장: ${user.name}")
}
}
// 저수준 모듈 — 이메일 발송 구현체
class SmtpEmailSender {
fun send(to: String, message: String) {
println("SMTP로 이메일 발송 → $to: $message")
}
}
// 고수준 모듈 — 비즈니스 로직
class UserService {
// ⚠️ 저수준 구현체를 직접 생성 — DIP 위반
private val database = MySqlUserDatabase()
private val emailSender = SmtpEmailSender()
fun registerUser(user: User) {
database.saveUser(user)
emailSender.send(user.email, "가입을 환영합니다!")
}
fun getUser(id: Long): User {
return database.getUser(id)
}
}
문제점:
UserService는MySqlUserDatabase와SmtpEmailSender를 직접 알고 있습니다.- MySQL을 Room으로 바꾸려면
UserService코드를 수정해야 합니다. - 테스트 시 실제 MySQL과 SMTP 서버가 있어야 합니다.
MySqlUserDatabase가 변경되면UserService도 영향을 받습니다.
4. 개선 — After
✅ 추상화를 도입하고 구현체를 외부에서 주입
// 추상화 — 고수준/저수준 모두 이것에 의존
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User)
}
interface EmailSender {
fun send(to: String, message: String)
}
// 저수준 모듈 — 추상화를 구현
class MySqlUserRepository : UserRepository {
override fun findById(id: Long): User? {
println("MySQL에서 유저 조회: $id")
return User(id, "홍길동")
}
override fun save(user: User) {
println("MySQL에 유저 저장: ${user.name}")
}
}
class RoomUserRepository : UserRepository {
override fun findById(id: Long): User? {
println("Room DB에서 유저 조회: $id")
return User(id, "홍길동")
}
override fun save(user: User) {
println("Room DB에 유저 저장: ${user.name}")
}
}
class SmtpEmailSender : EmailSender {
override fun send(to: String, message: String) {
println("SMTP 이메일 → $to: $message")
}
}
// 고수준 모듈 — 추상화에만 의존, 구현체는 외부에서 주입
class UserService(
private val userRepository: UserRepository, // 추상화에 의존 ✅
private val emailSender: EmailSender // 추상화에 의존 ✅
) {
fun registerUser(user: User) {
userRepository.save(user)
emailSender.send(user.email, "가입을 환영합니다!")
}
fun getUser(id: Long): User? {
return userRepository.findById(id)
}
}
// 구성 지점(Composition Root) — 구현체 결정은 여기서
val service = UserService(
userRepository = MySqlUserRepository(), // MySQL 사용
emailSender = SmtpEmailSender()
)
// MySQL → Room으로 교체 — UserService 수정 없음 ✅
val serviceWithRoom = UserService(
userRepository = RoomUserRepository(),
emailSender = SmtpEmailSender()
)
5. 테스트에서 DIP의 진가
DIP를 지키면 테스트용 가짜 구현체를 주입해 단위 테스트가 쉬워집니다.
// 테스트용 가짜 구현체
class FakeUserRepository : UserRepository {
private val store = mutableMapOf<Long, User>()
override fun findById(id: Long) = store[id]
override fun save(user: User) { store[user.id] = user }
fun size() = store.size
}
class FakeEmailSender : EmailSender {
val sentEmails = mutableListOf<String>()
override fun send(to: String, message: String) {
sentEmails.add(to)
}
}
// 테스트 — 실제 DB나 네트워크 없이 순수 로직만 검증
fun testRegisterUser() {
val fakeRepo = FakeUserRepository()
val fakeSender = FakeEmailSender()
val service = UserService(fakeRepo, fakeSender)
service.registerUser(User(1L, "홍길동", "hong@test.com"))
assert(fakeRepo.size() == 1) // 저장됐는가?
assert(fakeSender.sentEmails.contains("hong@test.com")) // 이메일 발송됐는가?
println("테스트 통과 ✅")
}
6. 의존성 주입(DI)과 DIP의 관계
DIP = 원칙 : "추상화에 의존하라"
DI = 구현 방법 : "의존 객체를 외부에서 주입하라"
- DIP를 실현하는 대표적인 방법이 의존성 주입(Dependency Injection) 입니다.
- DI에는 세 가지 방식이 있습니다.
// 1. 생성자 주입 (권장)
class UserService(
private val repository: UserRepository,
private val sender: EmailSender
)
// 2. 세터 주입
class UserService {
lateinit var repository: UserRepository
lateinit var sender: EmailSender
}
// 3. 메서드 주입
class UserService {
fun registerUser(user: User, repository: UserRepository, sender: EmailSender) {
repository.save(user)
sender.send(user.email, "환영합니다!")
}
}
- 생성자 주입이 가장 권장됩니다 — 의존성이 명확하고, 불변 객체를 만들 수 있습니다.
7. Android 실전 예제 — Hilt 없이 DIP 적용
// 추상화
interface ProductRepository {
suspend fun getProducts(): List<Product>
}
interface CartRepository {
suspend fun addToCart(product: Product)
suspend fun getCartItems(): List<Product>
}
// 저수준 모듈
class RemoteProductRepository(
private val api: ProductApi
) : ProductRepository {
override suspend fun getProducts(): List<Product> = api.fetchProducts()
}
class LocalCartRepository(
private val dao: CartDao
) : CartRepository {
override suspend fun addToCart(product: Product) = dao.insert(product)
override suspend fun getCartItems() = dao.getAll()
}
// 고수준 모듈 — ViewModel이 추상화에만 의존
class ShopViewModel(
private val productRepo: ProductRepository, // 추상화에 의존 ✅
private val cartRepo: CartRepository // 추상화에 의존 ✅
) : ViewModel() {
val products = MutableLiveData<List<Product>>()
fun loadProducts() {
viewModelScope.launch {
products.value = productRepo.getProducts()
}
}
fun addToCart(product: Product) {
viewModelScope.launch {
cartRepo.addToCart(product)
}
}
}
// 수동 DI — Application 또는 Factory에서 구성
class ShopViewModelFactory(
private val productRepo: ProductRepository,
private val cartRepo: CartRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ShopViewModel(productRepo, cartRepo) as T
}
}
// Activity에서
val factory = ShopViewModelFactory(
productRepo = RemoteProductRepository(RetrofitClient.productApi),
cartRepo = LocalCartRepository(AppDatabase.getInstance(this).cartDao())
)
val viewModel = ViewModelProvider(this, factory)[ShopViewModel::class.java]
8. Android 실전 예제 — Hilt로 DIP 적용
// 추상화
interface UserRepository {
suspend fun getUser(id: Long): User?
}
// 저수준 모듈
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override suspend fun getUser(id: Long): User? =
try { api.getUser(id) } catch (e: Exception) { dao.getUser(id) }
}
// Hilt 모듈 — 구현체 바인딩
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository // 인터페이스 ↔ 구현체 연결
}
// ViewModel — 추상화에만 의존, Hilt가 구현체를 주입
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository // 추상화에만 의존 ✅
) : ViewModel() {
val user = MutableLiveData<User?>()
fun loadUser(id: Long) {
viewModelScope.launch {
user.value = userRepository.getUser(id)
}
}
}
// 테스트 — 가짜 구현체로 교체
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
abstract class FakeRepositoryModule {
@Binds
abstract fun bindUserRepository(
impl: FakeUserRepository
): UserRepository
}
9. DIP와 Clean Architecture
DIP는 Clean Architecture의 의존성 규칙(Dependency Rule) 의 근간입니다.
[ Presentation Layer ] → [ Domain Layer ] ← [ Data Layer ]
ViewModel UseCase Repository 구현체
Activity Repository 인터페이스 API, DB
Entity
- Domain Layer는 어디에도 의존하지 않습니다 — 추상화(인터페이스)만 정의합니다.
- Data Layer가 Domain의 인터페이스를 구현합니다 — 화살표가 안쪽을 향합니다.
- Presentation Layer는 Domain의 UseCase를 호출합니다 — 구현체를 직접 모릅니다.
// Domain Layer — 추상화만 존재, 외부 의존 없음
interface OrderRepository {
suspend fun placeOrder(order: Order): Result<OrderId>
}
class PlaceOrderUseCase(
private val orderRepository: OrderRepository // 같은 레이어의 추상화에 의존
) {
suspend operator fun invoke(order: Order) = orderRepository.placeOrder(order)
}
// Data Layer — Domain의 추상화를 구현
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi
) : OrderRepository {
override suspend fun placeOrder(order: Order) =
runCatching { api.postOrder(order.toDto()).toOrderId() }
}
10. DIP 위반을 감지하는 신호
✔ 고수준 클래스 내부에서 구현체를 직접 생성한다 (val x = ConcreteClass())
✔ import 목록에 특정 프레임워크나 DB 클래스가 비즈니스 로직 파일에 등장한다
(예: import com.mysql.cj.jdbc.Driver 가 UseCase 파일에 있음)
✔ 구현체를 교체하려면 비즈니스 로직 파일을 수정해야 한다
✔ 단위 테스트에서 실제 DB나 네트워크 없이 테스트할 수 없다
✔ 생성자 파라미터가 구체 클래스 타입이다 (인터페이스가 아님)
11. SOLID 5원칙 최종 비교
| 원칙 | 대상 | 핵심 질문 | 해결 도구 |
|---|---|---|---|
| SRP | 클래스 | 변경 이유가 하나인가? | 클래스 분리 |
| OCP | 클래스 | 수정 없이 확장할 수 있는가? | 인터페이스·추상 클래스 |
| LSP | 상속 | 자식이 부모를 완전히 대체하는가? | 올바른 상속 / 인터페이스 분리 |
| ISP | 인터페이스 | 불필요한 메서드에 의존하지 않는가? | 인터페이스 분리 |
| DIP | 의존성 | 고수준이 저수준에 직접 의존하지 않는가? | 추상화 + 의존성 주입 |
12. 정리
| 항목 | 내용 |
|---|---|
| 정의 | 고수준·저수준 모두 추상화에 의존해야 한다 |
| 핵심 | 구현체를 직접 참조하지 말고 인터페이스를 통해 소통한다 |
| 위반 신호 | 내부에서 new/직접 생성, 구현체 타입 파라미터, 테스트 불가 |
| 해결 방법 | 인터페이스 추출 + 생성자 주입(DI) |
| 효과 | 구현체 교체 용이, 단위 테스트 가능, 변경 파급 최소화 |
| Android 활용 | Hilt, ViewModelFactory, Repository 패턴 |
- DIP는 “무엇을 하는지(추상화)와 어떻게 하는지(구현체)를 분리” 하는 것이 핵심입니다.
- SOLID 5원칙 중 가장 설계 전반에 영향을 미치며, Clean Architecture의 근간이 됩니다.
참고
- Clean Code — Robert C. Martin
- SOLID 전체 포스팅 보기
- SRP 포스팅 보기
- OCP 포스팅 보기
- LSP 포스팅 보기
- ISP 포스팅 보기