SOLID · OCP · OOP

Open/Closed Principle

새 동작은 추가하되 기존 코드는 고치지 않는다. 타입이 늘 때마다 if-else를 여는 대신, 다형성으로 확장점을 두는 두 번째 SOLID 원칙.

·SOLID 원칙 2편
목차
  1. 타입이 늘 때마다 같은 코드를 고친다
  2. 개방·폐쇄 = 추가엔 열고 변경엔 닫는다
  3. 검증된 코드를 다시 건드린다
  4. 변경이 한 곳으로 몰린다
  5. 추상화로 확장점을 낸다
  6. 새 동작은 새 클래스로
  7. 무엇이 닫히고 무엇이 열리나
  8. 다 열어두지는 마라
  9. 실무: 스위치가 보이면 의심한다
  10. 정리

타입이 늘 때마다 같은 코드를 고친다

주문 금액에서 회원 등급별로 할인을 계산한다고 하자.

java
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를 넣으려고 이 메서드를 열면, 멀쩡하던 셋이 다시 회귀 위험에 노출된다. 조건 하나 잘못 건드리면 손대지도 않은 기존 할인이 깨진다. 잘 돌던 걸 건드리지 않고 새것만 더할 수 있다면, 이 위험은 아예 사라진다.

변경이 한 곳으로 몰린다

문제가 하나 더 있다. 이 메서드 하나가 모든 등급을 다 안다. 등급이 늘수록 여기가 비대해지고, 등급과 관련된 변경이 전부 이 한 곳으로 몰린다. 새 등급 추가, 기존 등급의 할인율 조정, 등급별 예외 처리 - 성격이 다른 변경들이 같은 메서드 안에서 부딪힌다. 한 줄을 고치다 옆줄을 건드리기 딱 좋은 구조다.

추상화로 확장점을 낸다

방법은 바뀌는 부분을 인터페이스 뒤로 빼는 것이다. 할인 정책을 인터페이스로 세운다.

java
public interface DiscountPolicy {
    int discount(Order order);
}

그리고 등급마다 구현을 하나씩 만든다.

java
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; }
}

부르는 쪽은 어느 등급인지 모른 채 인터페이스로만 부른다.

java
int amount = policy.discount(order);   // 어느 정책인지 몰라도 된다

새 동작은 새 클래스로

이제 PARTNER 등급이 생기면 어떻게 하나. 새 클래스를 하나 더 만든다.

java
public class PartnerDiscount implements DiscountPolicy {
    public int discount(Order order) { return order.total() * 15 / 100; }
}

끝이다. RegularDiscount·MemberDiscount·VipDiscount도, 그걸 부르는 코드도 한 줄도 안 바뀐다. 아까처럼 if-else를 열 일이 없다.

지금 - discount() 메서드 하나가 모든 등급을 안다
새 등급 PARTNER
REGULAR0%
MEMBER5%
VIP10%
PARTNERelse if 추가
이 덩어리를 열어야 한다 - 검증된 셋이 다시 노출
고침 - DiscountPolicy라는 확장점에 끼운다
새 등급 PARTNER
DiscountPolicy 인터페이스 - 구멍
RegularDiscount안 바뀜
MemberDiscount안 바뀜
VipDiscount안 바뀜
PartnerDiscount새 파일
여기만 새로 생긴다

같은 자리에 같은 요구가 왔는데, 위는 테두리를 열어 안에 밀어 넣고 아래는 빈자리에 붙인다. 다시 열리는 범위가 넷에서 하나로 줄었다.

참고

이렇게 “바뀌는 부분을 인터페이스로 빼고 구현을 갈아 끼우는” 구조를 **전략 패턴(Strategy)**이라고 부른다. OCP라는 원칙을 코드로 이룬 대표적인 모습이다.

무엇이 닫히고 무엇이 열리나

OCP의 이름이 헷갈리기 쉬우니 정확히 갈라 두자.

  • 변경에 닫혔다 - 기존 정책 클래스들과 그걸 부르는 코드는 새 등급이 와도 안 바뀐다. 손댈 일이 없으니 깨질 일도 없다.
  • 확장에 열렸다 - DiscountPolicy라는 인터페이스가 구멍이라, 새 구현을 얼마든지 끼울 수 있다.

한마디로 기존은 안 건드리고, 새것만 더한다. 이 두 성질을 동시에 주는 게 인터페이스라는 확장점이다.

다 열어두지는 마라

주의 하나. OCP를 “모든 걸 인터페이스로”로 밀면 추상화 지옥에 빠진다. 아직 종류가 하나뿐이고 늘어날 낌새도 없는데 인터페이스부터 파면, 얻는 건 없고 복잡함만 는다.

열어둘 곳은 실제로 바뀔 축이다. “할인 등급은 앞으로도 계속 늘겠다”가 보일 때 그 축을 연다. 예측되지 않는 축까지 미리 다 열어두는 건 과설계다. 바뀔 걸 아는 곳만 열고, 나머지는 단순하게 둔다.

실무: 스위치가 보이면 의심한다

실무에서 OCP 위반은 대개 **타입으로 분기하는 switch/if-else**로 나타난다. 결제 수단(카드·계좌이체·포인트), 알림 채널(메일·SMS·푸시), 파일 포맷처럼 **“종류가 계속 느는 축”**에서 이 스위치가 자란다.

Spring에서는 이걸 자연스럽게 받친다. 각 구현을 @Component로 빈 등록해 두면, 필요한 구현을 주입받아 쓴다. 나아가 같은 인터페이스의 모든 구현을 ListMap으로 한꺼번에 주입받을 수도 있어서, 새 구현을 추가하면 컨테이너가 알아서 그 목록에 포함시킨다. 등록만 하면 되고, 고를 코드조차 손댈 필요가 없어진다.

정리

OCP란확장엔 열리고 변경엔 닫힌다 - 새 동작을 더하되 기존 코드는 안 고친다
문제타입이 늘 때마다 if-else를 열면, 확장이 곧 변경이라 검증된 코드가 회귀 위험에 노출
고침바뀌는 부분을 인터페이스로 빼고, 새 타입은 새 구현 클래스로
닫힘/열림기존 코드·호출부는 안 바뀌고(닫힘), 인터페이스로 새 구현을 끼운다(열림)
경계실제로 바뀔 축만 연다. 다 추상화하면 과설계
신호종류가 느는 축의 switch/if-else

새 요구가 왔을 때 기존 파일을 열게 되는가, 새 파일만 더하게 되는가. 후자로 만들어 두는 것 - 그게 개방·폐쇄가 지키는 것이다.