(Java/Kotlin) SOLID - DIP 의존성 역전 원칙 완전 정리

개요


1. 정의

고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
— Robert C. Martin

❌ 전통적인 의존 방향
고수준 모듈(비즈니스 로직) → 저수준 모듈(DB, 네트워크, 파일)

✅ DIP 적용 후
고수준 모듈 → 추상화(인터페이스)
저수준 모듈 → 추상화(인터페이스) 구현

2. 왜 필요한가

고수준 모듈이 저수준 모듈을 직접 참조
→ 저수준 모듈(DB, 외부 API)이 바뀌면 고수준 모듈도 수정해야 함
→ 비즈니스 로직 테스트 시 실제 DB/네트워크가 필요해짐
→ 구현체 교체(MySQL → Room, Retrofit → Ktor)가 어려워짐

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)
    }
}

문제점:


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  = 구현 방법 : "의존 객체를 외부에서 주입하라"
// 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 — 추상화만 존재, 외부 의존 없음
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 패턴

참고



Related Posts