(Java/Kotlin) SOLID - OCP 개방 폐쇄 원칙 완전 정리

개요


1. 정의

소프트웨어 요소는 확장에는 열려(Open) 있고, 수정에는 닫혀(Closed) 있어야 한다.
— Bertrand Meyer

Open   for Extension  → 새로운 기능을 추가할 수 있어야 한다
Closed for Modification → 기존 코드를 수정하지 않아야 한다

2. 왜 필요한가

기능 추가 요청 → 기존 코드 수정 → 테스트 재수행 → 예상치 못한 버그 발생

3. 위반 사례 — Before

❌ 할인 정책마다 기존 코드를 수정해야 하는 경우

class DiscountCalculator {

    fun calculate(price: Int, discountType: String): Int {
        return when (discountType) {
            "NONE"       -> price
            "FIXED_1000" -> price - 1000
            "RATE_10"    -> (price * 0.9).toInt()
            // ⚠️ 새 할인 정책이 생길 때마다 이 when 블록을 수정해야 함
            else -> throw IllegalArgumentException("알 수 없는 할인 타입: $discountType")
        }
    }
}

문제점:


4. 개선 — After

✅ 인터페이스로 확장 지점을 만든다

// 할인 정책 인터페이스 (확장 지점)
interface DiscountPolicy {
    fun calculate(price: Int): Int
}

// 할인 없음
class NoDiscount : DiscountPolicy {
    override fun calculate(price: Int) = price
}

// 고정 금액 할인
class FixedDiscount(private val amount: Int) : DiscountPolicy {
    override fun calculate(price: Int) = (price - amount).coerceAtLeast(0)
}

// 비율 할인
class RateDiscount(private val rate: Double) : DiscountPolicy {
    override fun calculate(price: Int) = (price * (1 - rate)).toInt()
}

// 새 정책 추가 — 기존 코드 수정 없이 새 클래스만 추가 ✅
class VipDiscount : DiscountPolicy {
    override fun calculate(price: Int) = (price * 0.7).toInt()
}

class SeasonDiscount(private val flatAmount: Int, private val rate: Double) : DiscountPolicy {
    override fun calculate(price: Int) = ((price - flatAmount) * (1 - rate)).toInt()
}
// 계산기는 인터페이스에만 의존 — 정책이 늘어나도 수정 불필요
class DiscountCalculator(private val policy: DiscountPolicy) {
    fun calculate(price: Int): Int = policy.calculate(price)
}

// 사용
val calculator = DiscountCalculator(RateDiscount(0.1))
println(calculator.calculate(10000)) // 9000

val vipCalc = DiscountCalculator(VipDiscount())
println(vipCalc.calculate(10000))    // 7000

개선 효과:


5. 전략 패턴(Strategy Pattern)과 OCP

OCP를 구현하는 대표적인 방법이 전략 패턴입니다.

전략 패턴 = 알고리즘(전략)을 인터페이스로 추상화 → 런타임에 교체 가능
// 정렬 전략 인터페이스
interface SortStrategy<T : Comparable<T>> {
    fun sort(list: MutableList<T>): List<T>
}

// 버블 정렬 전략
class BubbleSort<T : Comparable<T>> : SortStrategy<T> {
    override fun sort(list: MutableList<T>): List<T> {
        for (i in list.indices) {
            for (j in 0 until list.size - i - 1) {
                if (list[j] > list[j + 1]) {
                    val tmp = list[j]; list[j] = list[j + 1]; list[j + 1] = tmp
                }
            }
        }
        return list
    }
}

// 퀵 정렬 전략
class QuickSort<T : Comparable<T>> : SortStrategy<T> {
    override fun sort(list: MutableList<T>): List<T> = list.sorted()
}

// 새 전략 추가 — 기존 코드 수정 없음 ✅
class MergeSort<T : Comparable<T>> : SortStrategy<T> {
    override fun sort(list: MutableList<T>): List<T> = list.sortedWith(naturalOrder())
}

// 컨텍스트 — 전략만 교체하면 됨
class Sorter<T : Comparable<T>>(private var strategy: SortStrategy<T>) {
    fun changeStrategy(strategy: SortStrategy<T>) { this.strategy = strategy }
    fun sort(list: MutableList<T>): List<T> = strategy.sort(list)
}

// 사용
val sorter = Sorter(BubbleSort<Int>())
println(sorter.sort(mutableListOf(3, 1, 4, 1, 5)))  // [1, 1, 3, 4, 5]

sorter.changeStrategy(QuickSort())
println(sorter.sort(mutableListOf(9, 2, 7, 3)))      // [2, 3, 7, 9]

6. Android 실전 예제

❌ SRP 위반 — 알림 타입마다 기존 코드를 수정해야 함

class NotificationSender {

    fun send(type: String, message: String) {
        when (type) {
            "PUSH"  -> sendPushNotification(message)
            "EMAIL" -> sendEmail(message)
            "SMS"   -> sendSms(message)
            // ⚠️ 카카오 알림이 생기면 여기를 수정해야 함
        }
    }

    private fun sendPushNotification(message: String) {
        // FCM 푸시 발송
        println("[PUSH] $message")
    }

    private fun sendEmail(message: String) {
        // 이메일 발송
        println("[EMAIL] $message")
    }

    private fun sendSms(message: String) {
        // SMS 발송
        println("[SMS] $message")
    }
}

✅ OCP 적용 — 새 알림 채널을 추가해도 기존 코드 수정 없음

// 알림 채널 인터페이스
interface NotificationChannel {
    fun send(message: String)
}

// FCM 푸시
class PushNotificationChannel : NotificationChannel {
    override fun send(message: String) {
        println("[FCM PUSH] $message")
        // FcmManager.send(message)
    }
}

// 이메일
class EmailNotificationChannel(private val email: String) : NotificationChannel {
    override fun send(message: String) {
        println("[EMAIL → $email] $message")
        // EmailClient.send(email, message)
    }
}

// SMS
class SmsNotificationChannel(private val phoneNumber: String) : NotificationChannel {
    override fun send(message: String) {
        println("[SMS → $phoneNumber] $message")
        // SmsClient.send(phoneNumber, message)
    }
}

// 카카오 알림 추가 → 기존 코드 수정 없이 새 클래스만 추가 ✅
class KakaoNotificationChannel : NotificationChannel {
    override fun send(message: String) {
        println("[KAKAO] $message")
        // KakaoClient.send(message)
    }
}

// 발송기 — 채널 목록만 관리
class NotificationSender(private val channels: List<NotificationChannel>) {
    fun send(message: String) {
        channels.forEach { it.send(message) }
    }
}

// 사용
val sender = NotificationSender(
    listOf(
        PushNotificationChannel(),
        EmailNotificationChannel("user@example.com"),
        KakaoNotificationChannel()
    )
)
sender.send("주문이 완료되었습니다")
// [FCM PUSH] 주문이 완료되었습니다
// [EMAIL → user@example.com] 주문이 완료되었습니다
// [KAKAO] 주문이 완료되었습니다

7. RecyclerView ItemDecoration 예시

Android의 RecyclerView.ItemDecoration도 OCP 설계의 좋은 예입니다.

// Android SDK의 ItemDecoration — 확장 지점을 열어둠
abstract class RecyclerView.ItemDecoration {
    open fun onDraw(c: Canvas, parent: RecyclerView, state: State) {}
    open fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {}
}

// 구분선 — 기존 RecyclerView 수정 없이 새 Decoration 추가
class DividerDecoration(private val height: Int) : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        outRect.bottom = height
    }
}

// 그리드 간격 — 기존 코드 수정 없이 추가 ✅
class GridSpacingDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        outRect.set(spacing, spacing, spacing, spacing)
    }
}

// 사용
recyclerView.addItemDecoration(DividerDecoration(8))
recyclerView.addItemDecoration(GridSpacingDecoration(16))

8. Kotlin sealed class로 OCP 적용

Kotlin의 sealed class도 OCP를 지원합니다.
단, when을 exhaustive하게 처리하면 새 타입 추가 시 컴파일 에러로 안전하게 알 수 있습니다.

sealed class PaymentMethod {
    data class Card(val cardNumber: String) : PaymentMethod()
    data class Cash(val amount: Int) : PaymentMethod()
    data class Kakao(val userId: String) : PaymentMethod()
    // 새 결제 수단 추가 시 → when 분기에서 컴파일 에러로 처리 강제
}

fun processPayment(method: PaymentMethod): String = when (method) {
    is PaymentMethod.Card  -> "카드 결제: ${method.cardNumber}"
    is PaymentMethod.Cash  -> "현금 결제: ${method.amount}원"
    is PaymentMethod.Kakao -> "카카오페이: ${method.userId}"
    // 새 타입을 추가하고 여기 분기를 추가하지 않으면 → 컴파일 에러 ✅
}

인터페이스 방식 — 완전한 OCP (기존 코드 무수정)
sealed class 방식 — 준-OCP (when 분기 추가 필요하지만 컴파일 타임에 강제됨)


9. OCP 위반을 감지하는 신호

✔ 새 기능을 추가할 때마다 기존 클래스의 when/if-else 블록을 열어 수정한다

✔ 기능 추가 후 기존 테스트가 깨진다

✔ "타입 문자열"(String)이나 "타입 enum"으로 분기하는 코드가 여러 곳에 퍼져 있다
   예) if (type == "KAKAO") { ... } else if (type == "CARD") { ... }

✔ 새 기능 추가 시 수정해야 할 파일이 2개 이상이다

✔ 클래스 안에 instanceof / is 타입 검사가 많이 등장한다

10. 정리

항목 내용
정의 확장에는 열리고, 수정에는 닫혀 있어야 한다
핵심 전략 인터페이스/추상 클래스로 확장 지점 설계
대표 패턴 전략 패턴(Strategy Pattern)
위반 신호 기능 추가 시 기존 when/if-else 수정 필요
효과 기존 코드 안정성 유지, 새 기능 추가 용이
Kotlin 활용 interface, sealed class, abstract class

참고



Related Posts