목차
타입이 늘 때마다 같은 코드를 고친다
주문 금액에서 회원 등급별로 할인을 계산한다고 하자.
public int discount(Order order, String type) {
if (type.equals("REGULAR")) {
return 0;
} else if (type.equals("MEMBER")) {
return order.total() * 5 / 100;
} else if (type.equals("VIP")) {
return order.total() * 10 / 100;
}
return 0;
}잘 도는 코드다. 그런데 “PARTNER 등급”이 새로 생기면? 이 메서드를 열어 else if를 하나 더 붙인다. 그다음 등급이 또 생기면 또 연다.
새 타입이 생길 때마다 같은 코드를 계속 고치는 것이 문제다.
개방·폐쇄 = 추가엔 열고 변경엔 닫는다
개방-폐쇄 원칙(OCP)은 이걸 겨눈다.
확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
풀어 쓰면 이렇다. 새 동작을 더할 수 있어야 하고(확장에 열림), 그때 기존 코드는 안 건드려야 한다(변경에 닫힘). 위 discount는 정확히 반대다. 새 등급을 더하려면 기존 메서드를 꼭 열어야 하니, 확장이 곧 변경이다.
검증된 코드를 다시 건드린다
“그냥 else if 한 줄 추가하는 건데 뭐가 문제인가” 싶을 수 있다. 위험은 이미 잘 돌던 코드를 다시 건드린다는 데 있다.
REGULAR·MEMBER·VIP는 검증됐고 잘 동작한다. PARTNER를 넣으려고 이 메서드를 열면, 멀쩡하던 셋이 다시 회귀 위험에 노출된다. 조건 하나 잘못 건드리면 손대지도 않은 기존 할인이 깨진다. 잘 돌던 걸 건드리지 않고 새것만 더할 수 있다면, 이 위험은 아예 사라진다.
변경이 한 곳으로 몰린다
문제가 하나 더 있다. 이 메서드 하나가 모든 등급을 다 안다. 등급이 늘수록 여기가 비대해지고, 등급과 관련된 변경이 전부 이 한 곳으로 몰린다. 새 등급 추가, 기존 등급의 할인율 조정, 등급별 예외 처리 - 성격이 다른 변경들이 같은 메서드 안에서 부딪힌다. 한 줄을 고치다 옆줄을 건드리기 딱 좋은 구조다.
추상화로 확장점을 낸다
방법은 바뀌는 부분을 인터페이스 뒤로 빼는 것이다. 할인 정책을 인터페이스로 세운다.
public interface DiscountPolicy {
int discount(Order order);
}그리고 등급마다 구현을 하나씩 만든다.
public class RegularDiscount implements DiscountPolicy {
public int discount(Order order) { return 0; }
}
public class MemberDiscount implements DiscountPolicy {
public int discount(Order order) { return order.total() * 5 / 100; }
}
public class VipDiscount implements DiscountPolicy {
public int discount(Order order) { return order.total() * 10 / 100; }
}부르는 쪽은 어느 등급인지 모른 채 인터페이스로만 부른다.
int amount = policy.discount(order); // 어느 정책인지 몰라도 된다새 동작은 새 클래스로
이제 PARTNER 등급이 생기면 어떻게 하나. 새 클래스를 하나 더 만든다.
public class PartnerDiscount implements DiscountPolicy {
public int discount(Order order) { return order.total() * 15 / 100; }
}끝이다. RegularDiscount·MemberDiscount·VipDiscount도, 그걸 부르는 코드도 한 줄도 안 바뀐다. 아까처럼 if-else를 열 일이 없다.
이렇게 “바뀌는 부분을 인터페이스로 빼고 구현을 갈아 끼우는” 구조를 **전략 패턴(Strategy)**이라고 부른다. OCP라는 원칙을 코드로 이룬 대표적인 모습이다.
무엇이 닫히고 무엇이 열리나
OCP의 이름이 헷갈리기 쉬우니 정확히 갈라 두자.
- 변경에 닫혔다 - 기존 정책 클래스들과 그걸 부르는 코드는 새 등급이 와도 안 바뀐다. 손댈 일이 없으니 깨질 일도 없다.
- 확장에 열렸다 -
DiscountPolicy라는 인터페이스가 구멍이라, 새 구현을 얼마든지 끼울 수 있다.
한마디로 기존은 안 건드리고, 새것만 더한다. 이 두 성질을 동시에 주는 게 인터페이스라는 확장점이다.
다 열어두지는 마라
주의 하나. OCP를 “모든 걸 인터페이스로”로 밀면 추상화 지옥에 빠진다. 아직 종류가 하나뿐이고 늘어날 낌새도 없는데 인터페이스부터 파면, 얻는 건 없고 복잡함만 는다.
열어둘 곳은 실제로 바뀔 축이다. “할인 등급은 앞으로도 계속 늘겠다”가 보일 때 그 축을 연다. 예측되지 않는 축까지 미리 다 열어두는 건 과설계다. 바뀔 걸 아는 곳만 열고, 나머지는 단순하게 둔다.
실무: 스위치가 보이면 의심한다
실무에서 OCP 위반은 대개 **타입으로 분기하는 switch/if-else**로 나타난다. 결제 수단(카드·계좌이체·포인트), 알림 채널(메일·SMS·푸시), 파일 포맷처럼 **“종류가 계속 느는 축”**에서 이 스위치가 자란다.
Spring에서는 이걸 자연스럽게 받친다. 각 구현을 @Component로 빈 등록해 두면, 필요한 구현을 주입받아 쓴다. 나아가 같은 인터페이스의 모든 구현을 List나 Map으로 한꺼번에 주입받을 수도 있어서, 새 구현을 추가하면 컨테이너가 알아서 그 목록에 포함시킨다. 등록만 하면 되고, 고를 코드조차 손댈 필요가 없어진다.
정리
| OCP란 | 확장엔 열리고 변경엔 닫힌다 - 새 동작을 더하되 기존 코드는 안 고친다 |
| 문제 | 타입이 늘 때마다 if-else를 열면, 확장이 곧 변경이라 검증된 코드가 회귀 위험에 노출 |
| 고침 | 바뀌는 부분을 인터페이스로 빼고, 새 타입은 새 구현 클래스로 |
| 닫힘/열림 | 기존 코드·호출부는 안 바뀌고(닫힘), 인터페이스로 새 구현을 끼운다(열림) |
| 경계 | 실제로 바뀔 축만 연다. 다 추상화하면 과설계 |
| 신호 | 종류가 느는 축의 switch/if-else |
새 요구가 왔을 때 기존 파일을 열게 되는가, 새 파일만 더하게 되는가. 후자로 만들어 두는 것 - 그게 개방·폐쇄가 지키는 것이다.