[TIL] 의존성 역전 원칙 (Dependency Inversion Principle)

곽태민·2025년 11월 4일

TIL

목록 보기
71/72

과제를 진행하다 레이어드 아키텍처를 적용하던 중, "Repository 인터페이스는 Domain에, 구현체는 Infrastructure에" 라는 요구사항을 보고, 보통 상위 계층이 하위 계층을 호출하는 거 아닌가?
라는 생각이 들었다.

이 의문을 해결하는 핵심이 의존성 역전 원칙이었다. SOLID 원칙 중 마지막 "D"에 해당하는 이 원칙은, 제대로 이해하면 아키텍처 설계의 핵심을 꿰뚫을 수 있다.


🤔 문제 상황

@Service
class OrderService(
    private val mySqlOrderRepository: MySqlOrderRepository  // 구체 클래스에 의존 💥
) {
    fun createOrder(order: Order): Order {
        return mySqlOrderRepository.save(order)
    }
}

@Repository
class MySqlOrderRepository {
    fun save(order: Order): Order {
        // MySQL에 저장하는 구체적인 로직
        val sql = "INSERT INTO orders ..."
        // ...
        return order
    }
}

문제점
1. MySQL에서 MongoDB로 변경하려면? -> OrderService 코드 수정이 필요 💥
2. 테스트하려면 -> 실제 MySQL이 필요 💥
3. Service가 저수준 모듈(DB)에 의존 -> 비즈니스 로직이 기술에 종속 💥


🎯 의존성 역전 원칙이란?

Robert C. Martin이 정의한 원칙!

A. 고수준 모듈은 저수준 모듈에 의존해서는 안된다. 둘 다 추상화에 의존

B. 주상화는 세부사항에 의존해서는 안되고, 세부사항이 추상화에 의존해야 한다.

용어 정리

용어의미예시
고수준 모듈비즈니스 규칙, 정책OrderService, Domain
저수준 모듈기술적 세부사항, 구현MySqlRepository, EmailService
추상화인터페이스, 추상 클래스OrderRepository 인터페이스
세부사항구체적인 구현MySqlOrderRepository 구현체

📊 전통적 의존성 vs 역전된 의존성

전통적 의존성 (나쁜 예)

┌─────────────────┐
│  OrderService   │  (고수준)
└────────┬────────┘
         ↓ 의존 (직접 참조)
┌─────────────────┐
│ MySqlRepository │  (저수준)
└─────────────────┘

문제: 고수준 모듈이 저수준 모듈에 의존 → 변경에 취약


역전된 의존성 (좋은 예)

┌─────────────────┐
│  OrderService   │  (고수준)
└────────┬────────┘
         ↓ 의존 (인터페이스)
┌─────────────────────┐
│ OrderRepository     │  (추상화 - Interface)
└─────────┬───────────┘
          ↑ 구현 (implements)
┌─────────────────────┐
│ MySqlRepository     │  (저수준)
└─────────────────────┘

해결: 고수준과 저수준 모두 추상화에 의존 -> 유연함!


🔑 코드로 이해하기

❌ 나쁜 예: DIP 위반

// Service가 구체 클래스에 직접 의존
@Service
class OrderService(
    private val mySqlOrderRepository: MySqlOrderRepository  // 💥 구체 클래스
) {
    fun createOrder(order: Order): Order {
        return mySqlOrderRepository.save(order)
    }
}

@Repository
class MySqlOrderRepository {
    fun save(order: Order): Order {
        // MySQL 저장 로직
    }
}

문제점:

// MongoDB로 변경하려면 Service 코드 수정 필요!
@Service
class OrderService(
    // private val mySqlOrderRepository: MySqlOrderRepository  // 삭제
    private val mongoOrderRepository: MongoOrderRepository     // 추가
) {
    fun createOrder(order: Order): Order {
        return mongoOrderRepository.save(order)  // 메서드 호출도 변경
    }
}

✅ 좋은 예: DIP 적용

1단계: 인터페이스 정의 (Domain Layer)

// domain/order/OrderRepository.kt
package com.hhplus.ecommerce.domain.order

// ✅ 고수준 모듈(Domain)이 정의하는 인터페이스
interface OrderRepository {
    fun save(order: Order): Order
    fun findById(id: Long): Order?
    fun findByUserId(userId: Long): List<Order>
}

2단계: Service는 인터페이스에 의존 (Application Layer)

// application/order/OrderService.kt
package com.hhplus.ecommerce.application.order

import com.hhplus.ecommerce.domain.order.OrderRepository  // 인터페이스
import com.hhplus.ecommerce.domain.order.Order

@Service
class OrderService(
    private val orderRepository: OrderRepository  // ✅ 추상화에 의존!
) {
    fun createOrder(order: Order): Order {
        return orderRepository.save(order)
    }
}

3단계: 구현체들 (Infrastructure Layer)

// infrastructure/order/MySqlOrderRepository.kt
package com.hhplus.ecommerce.infrastructure.order

import com.hhplus.ecommerce.domain.order.OrderRepository  // Domain 인터페이스
import com.hhplus.ecommerce.domain.order.Order

@Repository
@Primary  // 기본 구현체
class MySqlOrderRepository : OrderRepository {
    override fun save(order: Order): Order {
        // MySQL 저장 로직
        println("MySQL에 저장")
        return order
    }
    
    override fun findById(id: Long): Order? {
        // MySQL 조회 로직
    }
}
// infrastructure/order/MongoOrderRepository.kt
package com.hhplus.ecommerce.infrastructure.order

import com.hhplus.ecommerce.domain.order.OrderRepository
import com.hhplus.ecommerce.domain.order.Order

@Repository
class MongoOrderRepository : OrderRepository {
    override fun save(order: Order): Order {
        // MongoDB 저장 로직
        println("MongoDB에 저장")
        return order
    }
    
    override fun findById(id: Long): Order? {
        // MongoDB 조회 로직
    }
}

장점:

  • DB 변경 시 Service 코드는 전혀 수정할 필요없음.
  • application.yml 또는 @Primary만 변경하면 끝.

🔁 의존성 방향이 역전되는 이유

전통적 방식

// OrderService.kt (상위 모듈)
class OrderService(
    private val repository: MySqlOrderRepository  // 하위 모듈 직접 참조
)

// MySqlOrderRepository.kt (하위 모듈)
class MySqlOrderRepository {
    // 구현
}

의존성 방향: OrderService -> MySqlOrderRepository


DIP 적용

// Domain Layer
interface OrderRepository {  // 고수준 모듈이 정의
    fun save(order: Order): Order
}

// Application Layer
class OrderService(
    private val repository: OrderRepository  // 인터페이스 의존
)

// Infrastructure Layer
class MySqlOrderRepository : OrderRepository {  // 인터페이스 구현
    override fun save(order: Order): Order { ... }
}

의존성 방향:

  • OrderService -> OrderRepository (인터페이스)
  • MySqlOrderRepository -> OrderRepository (인터페이스 구현)

핵심: 구현체가 인터페이스에 의존하므로, 의존성 방향이 역전됨.

전통정: 상위 -> 하위
역전됨: 상위 -> 인터페이스 <- 하위


🎯 실제 적용 예시

예시1: 알림 시스템

// ❌ DIP 위반
@Service
class OrderService(
    private val smtpEmailService: SmtpEmailService  // SMTP에 강하게 결합
) {
    fun createOrder(order: Order) {
        // ...
        smtpEmailService.sendEmail(
            to = "user@example.com",
            subject = "주문 완료",
            body = "주문이 완료되었습니다"
        )
    }
}

class SmtpEmailService {
    fun sendEmail(to: String, subject: String, body: String) {
        // SMTP 프로토콜로 이메일 전송
    }
}

이 코드의 문제는 이메일을 slack으로 알림을 변경하려면 Service 전체 수정이 필요하다.

// ✅ DIP 적용
// domain/port/NotificationPort.kt (Domain이 정의)
interface NotificationPort {
    fun sendOrderConfirmation(order: Order)
}

// application/order/OrderService.kt
@Service
class OrderService(
    private val notificationPort: NotificationPort  // 추상화에 의존
) {
    fun createOrder(order: Order) {
        // ...
        notificationPort.sendOrderConfirmation(order)  // 구현 방법은 모름!
    }
}

// infrastructure/notification/EmailNotificationAdapter.kt
@Component
class EmailNotificationAdapter : NotificationPort {
    override fun sendOrderConfirmation(order: Order) {
        // SMTP로 이메일 전송
    }
}

// infrastructure/notification/SlackNotificationAdapter.kt
@Component
class SlackNotificationAdapter : NotificationPort {
    override fun sendOrderConfirmation(order: Order) {
        // Slack API로 메시지 전송
    }
}

이 코드의 장점은 이메일에서 slacke으로 변경을 할 때 Service 코드 수정이 불필요하다.


🧪 테스트 용이성

❌ DIP 미적용 (테스트 어려움)

class OrderServiceTest {
	@Test
    fun `주문 생성 테스트`() {
    	val service = OrderService(
        	MySqlOrderRepository()
        )
        
        // MySQL이 없으면 테스트 불가
    }
}

✅ DIP 적용 (테스트 쉬움)

// 테스트용 Fake Repository
class FakeOrderRepository : OrderRepository {
    private val storage = mutableMapOf<Long, Order>()
    
    override fun save(order: Order): Order {
        storage[order.id!!] = order
        return order
    }
    
    override fun findById(id: Long): Order? = storage[id]
}

class OrderServiceTest {
    @Test
    fun `주문 생성 테스트`() {
        // ✅ DB 없이도 테스트 가능!
        val fakeRepository = FakeOrderRepository()
        val service = OrderService(fakeRepository)
        
        val order = service.createOrder(
            Order.create(userId = 1L, items = listOf(...))
        )
        
        assertNotNull(order.id)
    }
    
    @Test
    fun `주문 생성 시 알림 전송 테스트`() {
        // ✅ Mock으로 쉽게 검증
        val mockNotification = mock<NotificationPort>()
        val service = OrderService(
            orderRepository = fakeRepository,
            notificationPort = mockNotification
        )
        
        service.createOrder(order)
        
        verify(mockNotification).sendOrderConfirmation(any())
    }
}

📁 레이어드 아키텍처에서 DIP

┌─────────────────────────────────────┐
│  Application Layer                  │
│  ┌────────────────┐                 │
│  │ OrderService   │                 │
│  └────────┬───────┘                 │
└───────────┼─────────────────────────┘
            ↓ 의존 (인터페이스)
┌───────────────────────────────────────┐
│  Domain Layer                         │
│  ┌────────────────────────────────┐   │
│  │ OrderRepository (interface)    │   │  ← 고수준이 정의!
│  └────────────────────────────────┘   │
└───────────────────────────────────────┘
            ↑ 구현 (implements)
┌───────────────────────────────────────┐
│  Infrastructure Layer                 │
│  ┌────────────────────────────────┐   │
│  │ MySqlOrderRepository           │   │  ← 저수준이 구현!
│  │ (implements OrderRepository)   │   │
│  └────────────────────────────────┘   │
└───────────────────────────────────────┘

핵심은 Domain이 인터페이스를 정의하고, infrastructure가 구현을 한다.


의존성 역전 원칙은 단순히 "인터페이스를 만들자"가 아니라 "누가 인터페이스를 정의하고 소유하는가" 의 문제다. 고수준 모듈이 필요로 하는 것을 인터페이스로 정의하고, 저수준 모듈이 그것을 구현하게 만드는 것. 이것이 의존성을 역전시키는 핵심이다!

profile
Node.js 백엔드 개발자입니다!

0개의 댓글