(Java/Kotlin) SOLID - OCP 개방 폐쇄 원칙 완전 정리
개요
- SOLID 원칙 중 두 번째 OCP(Open/Closed Principle) 를 다룹니다.
- OCP는 “기존 코드를 건드리지 않고 기능을 확장할 수 있도록 설계하라”는 원칙입니다.
- 이 글에서는 다음을 설명합니다.
- OCP가 정확히 무엇인지
- 왜 필요한지
- 위반 사례와 개선 방법
- 전략 패턴·인터페이스로 적용하는 법
- Android / Kotlin 실전 예제
1. 정의
소프트웨어 요소는 확장에는 열려(Open) 있고, 수정에는 닫혀(Closed) 있어야 한다.
— Bertrand Meyer
Open for Extension → 새로운 기능을 추가할 수 있어야 한다
Closed for Modification → 기존 코드를 수정하지 않아야 한다
- 새 기능이 필요할 때마다 기존 클래스를 수정하면 → 기존 동작이 깨질 위험
- 대신 새 클래스를 추가하는 방식으로 확장 → 기존 코드는 안전하게 유지
2. 왜 필요한가
기능 추가 요청 → 기존 코드 수정 → 테스트 재수행 → 예상치 못한 버그 발생
- 코드를 수정할수록 기존 로직에 영향을 줄 가능성이 높아집니다.
- OCP를 지키면 새 기능은 새 코드를 추가하는 것으로만 끝납니다.
- 기존 테스트는 그대로 통과하고, 새 기능에 대한 테스트만 추가하면 됩니다.
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")
}
}
}
문제점:
VIP 30% 할인정책이 추가되면 →calculate()메서드 수정시즌 할인정책이 추가되면 →calculate()메서드 수정- 매번 기존 코드를 열어
when분기를 추가해야 함 - 실수로 기존 분기를 망가뜨릴 위험이 있음
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
개선 효과:
- 새 할인 정책 추가 → 새 클래스만 추가, 기존 코드 수정 없음
DiscountCalculator는 건드리지 않아도 됨- 기존 테스트 그대로 유지
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 |
- OCP는 “미래의 변경을 예측하고 인터페이스로 추상화” 하는 것이 핵심입니다.
- 처음부터 모든 것을 추상화할 필요는 없습니다. 변경이 2번 이상 반복된다면 그때 OCP를 적용하세요.
참고
- Clean Code — Robert C. Martin
- SOLID 전체 포스팅 보기
- SRP 포스팅 보기