OCP — 개방-폐쇄 원칙 (Open/Closed Principle)

“소프트웨어 엔티티는 확장에 열려 있어야 하고, 수정에는 닫혀 있어야 한다.” — Bertrand Meyer / Robert C. Martin


핵심 개념

  • 확장에 열림: 새로운 기능을 추가할 수 있다
  • 수정에 닫힘: 기존 코드를 변경하지 않고 추가할 수 있다
OCP 위반 신호:
- 새 요구사항마다 기존 if/when 블록에 조건을 추가한다
- 새 타입 추가 시 여러 파일을 동시에 수정해야 한다
- "기능 추가했는데 왜 기존 테스트가 깨지지?" 현상

위반 예제 — 결제 수단이 늘어날 때마다 코드 수정

// 나쁜 예: 새 결제 수단마다 PaymentService 수정 필요
class PaymentService {
    fun pay(request: PaymentRequest): PaymentResult {
        return when (request.method) {
            "CARD" -> {
                // 카드 결제 로직
                val response = cardClient.charge(request.amount, request.cardNumber)
                PaymentResult(transactionId = response.tid, status = SUCCESS)
            }
            "KAKAO_PAY" -> {
                // 카카오페이 로직
                val response = kakaoPayClient.pay(request.amount, request.userId)
                PaymentResult(transactionId = response.tid, status = SUCCESS)
            }
            "TOSS" -> {
                // 토스 로직 — 나중에 추가
                val response = tossClient.pay(request.amount)
                PaymentResult(transactionId = response.paymentKey, status = SUCCESS)
            }
            // 네이버페이 추가 시 여기를 또 수정해야 함
            else -> throw UnsupportedPaymentMethodException(request.method)
        }
    }
}

문제점:

  • 네이버페이, 페이팔 등이 추가될 때마다 PaymentService 수정
  • 기존 카드/카카오페이 로직이 깨질 위험
  • 테스트: 새 결제 수단 추가 시 전체 when 블록을 다시 테스트해야 함

개선 — 인터페이스로 확장점 정의

전략 패턴 (Strategy Pattern) 적용

// 확장 포인트: 인터페이스 정의
interface PaymentProcessor {
    fun supports(method: PaymentMethod): Boolean
    fun pay(request: PaymentRequest): PaymentResult
}
 
// 구현체 1: 카드 결제 (수정 없이 독립적으로 존재)
@Component
class CardPaymentProcessor(private val cardClient: CardClient) : PaymentProcessor {
    override fun supports(method: PaymentMethod) = method == PaymentMethod.CARD
 
    override fun pay(request: PaymentRequest): PaymentResult {
        val response = cardClient.charge(request.amount, request.cardNumber!!)
        return PaymentResult(transactionId = response.tid, status = SUCCESS)
    }
}
 
// 구현체 2: 카카오페이 (수정 없이 독립적으로 존재)
@Component
class KakaoPayProcessor(private val kakaoPayClient: KakaoPayClient) : PaymentProcessor {
    override fun supports(method: PaymentMethod) = method == PaymentMethod.KAKAO_PAY
 
    override fun pay(request: PaymentRequest): PaymentResult {
        val response = kakaoPayClient.pay(request.amount, request.userId!!)
        return PaymentResult(transactionId = response.tid, status = SUCCESS)
    }
}
 
// 구현체 3: 토스 — 기존 코드 수정 없이 추가
@Component
class TossPaymentProcessor(private val tossClient: TossClient) : PaymentProcessor {
    override fun supports(method: PaymentMethod) = method == PaymentMethod.TOSS
 
    override fun pay(request: PaymentRequest): PaymentResult {
        val response = tossClient.pay(request.amount)
        return PaymentResult(transactionId = response.paymentKey, status = SUCCESS)
    }
}

PaymentService — 이제 수정 없이 새 결제 수단 지원

// PaymentService는 더 이상 수정할 필요 없음
@Service
class PaymentService(
    private val processors: List<PaymentProcessor>  // Spring이 모든 구현체 주입
) {
    fun pay(request: PaymentRequest): PaymentResult {
        val processor = processors.find { it.supports(request.method) }
            ?: throw UnsupportedPaymentMethodException(request.method)
 
        return processor.pay(request)
    }
}

네이버페이 추가 시:

// NaverPayProcessor.kt 파일 하나만 추가 — 기존 코드 무수정
@Component
class NaverPayProcessor(private val naverPayClient: NaverPayClient) : PaymentProcessor {
    override fun supports(method: PaymentMethod) = method == PaymentMethod.NAVER_PAY
 
    override fun pay(request: PaymentRequest): PaymentResult {
        // ...
    }
}

실전 예제 2 — 할인 정책

// 할인 정책 인터페이스
interface DiscountPolicy {
    fun calculate(order: Order): Money
}
 
@Component
class FixedAmountDiscount : DiscountPolicy {
    override fun calculate(order: Order): Money =
        if (order.amount >= Money(50_000)) Money(3_000) else Money.ZERO
}
 
@Component
class RateDiscount : DiscountPolicy {
    override fun calculate(order: Order): Money =
        order.amount * 0.1  // 10% 할인
}
 
@Component
class MemberGradeDiscount(
    private val memberRepository: MemberRepository
) : DiscountPolicy {
    override fun calculate(order: Order): Money {
        val member = memberRepository.findById(order.memberId) ?: return Money.ZERO
        return when (member.grade) {
            BRONZE -> order.amount * 0.03
            SILVER -> order.amount * 0.05
            GOLD   -> order.amount * 0.10
            VIP    -> order.amount * 0.20
        }
    }
}
 
// OrderService — 새 할인 정책 추가해도 수정 불필요
@Service
class OrderService(
    private val discountPolicies: List<DiscountPolicy>
) {
    fun calculateDiscount(order: Order): Money =
        discountPolicies.sumOf { it.calculate(order) }
}

실전 예제 3 — 이벤트 핸들러

// Spring ApplicationEvent + OCP
abstract class OrderEvent(val orderId: Long)
class OrderCreatedEvent(orderId: Long) : OrderEvent(orderId)
class OrderCancelledEvent(orderId: Long, val reason: String) : OrderEvent(orderId)
 
// 각 핸들러는 독립적으로 추가 가능 — OrderService 수정 불필요
@Component
class OrderEmailNotifier {
    @EventListener
    fun on(event: OrderCreatedEvent) {
        // 주문 확인 이메일 발송
    }
}
 
@Component
class OrderInventoryUpdater {
    @EventListener
    fun on(event: OrderCreatedEvent) {
        // 재고 차감
    }
}
 
@Component
class OrderAnalyticsTracker {
    @EventListener
    fun on(event: OrderCreatedEvent) {
        // 주문 분석 데이터 적재 — 나중에 추가, 기존 코드 무수정
    }
}

Kotlin sealed class + OCP

sealed class는 OCP와 상충처럼 보이지만, 의도적으로 다르게 사용한다.

// sealed class: 타입 목록이 고정된 경우 (OCP보다 완전성 우선)
sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: DomainError) : Result<Nothing>()
}
 
// 인터페이스: 구현체가 계속 늘어날 경우 (OCP 우선)
interface NotificationChannel {
    fun send(message: Notification)
}
 
// sealed class로 OCP를 준수하는 패턴
// — 처리 로직은 when에서, 새 타입 추가는 컴파일 에러로 감지
fun handle(event: DomainEvent): Unit = when (event) {
    is OrderCreated   -> handleOrderCreated(event)
    is OrderCancelled -> handleOrderCancelled(event)
    is OrderDelivered -> handleOrderDelivered(event)
    // 새 sealed subclass 추가 시 컴파일 에러 → 강제로 처리 추가
}

테스트 관점

OCP를 지키면 새 기능의 테스트가 기존 테스트에 영향을 주지 않는다.

// 각 PaymentProcessor 독립 테스트
class CardPaymentProcessorTest {
    private val cardClient = mockk<CardClient>()
    private val processor = CardPaymentProcessor(cardClient)
 
    @Test
    fun `카드 결제 성공`() {
        every { cardClient.charge(any(), any()) } returns CardResponse("TID-001")
 
        val result = processor.pay(PaymentRequest(amount = 10_000, method = CARD))
 
        result.status shouldBe SUCCESS
        result.transactionId shouldBe "TID-001"
    }
}
 
// 새 구현체 추가 시 이 테스트만 새로 작성
class NaverPayProcessorTest {
    // 기존 CardPaymentProcessorTest와 완전히 독립
}
 
// PaymentService 테스트는 인터페이스 Mock으로 작성 — 구현체 독립적
class PaymentServiceTest {
    private val processors = listOf(
        mockk<PaymentProcessor>().also {
            every { it.supports(CARD) } returns true
            every { it.pay(any()) } returns PaymentResult("TX-001", SUCCESS)
        }
    )
    private val service = PaymentService(processors)
 
    @Test
    fun `지원하는 결제 수단으로 결제`() {
        val result = service.pay(PaymentRequest(amount = 10_000, method = CARD))
        result.status shouldBe SUCCESS
    }
 
    @Test
    fun `지원하지 않는 결제 수단 예외`() {
        shouldThrow<UnsupportedPaymentMethodException> {
            service.pay(PaymentRequest(amount = 10_000, method = BITCOIN))
        }
    }
}

정리

OCP 위반OCP 준수
기능 추가기존 클래스 수정새 클래스 추가
기존 테스트새 기능 추가 시 깨질 수 있음안전함
새 기능 테스트기존 로직과 얽힘독립적
배포 리스크기존 기능까지 재검증 필요새 기능만 검증

실천 팁:

  • when (type) 블록이 여러 곳에 반복된다면 인터페이스로 분리
  • Spring의 List<Interface> 주입 패턴을 적극 활용
  • 변경 이유가 다른 로직은 처음부터 인터페이스로 분리