개방 폐쇄 원칙(OCP, Open Closed Principle)은 "확장에는 열려 있고, 수정에는 닫혀 있어야 한다"는 규칙이다. 말이 어렵게 들리지만 뜻은 단순하다. 새 기능을 추가할 때 기존 코드를 뜯어고치는 것이 아니라, 새 클래스를 더하는 것만으로 끝나야 한다는 것이다. 잘 돌아가던 코드를 자꾸 열어 고치면, 멀쩡하던 기능이 망가지는 회귀 버그(regression, 고치다 생긴 원상 복귀 오류)가 스며든다. OCP는 기존 코드를 건드리지 않는 구조를 만들어 이 위험을 없앤다.


위반 신호는 눈에 잘 띈다. 타입마다 늘어선 if나 elif 분기가 보이면 십중팔구 OCP 위반이다. 종류가 하나 늘 때마다 그 분기 사슬을 또 열어 가지를 더해야 하기 때문이다. 해법은 다형성이다. 분기로 갈라지던 지식을 각 객체 안으로 옮기면, 확장이 순수한 추가가 된다. before와 after로 확인한다.

12.1 도형이 늘 때마다 함수를 고친다

도형의 넓이를 종류 문자열로 if 분기해 계산한다. before 코드다.


def area_bad(shape):
if shape["type"] == "circle":
return 3.14159 * shape["r"] ** 2
elif shape["type"] == "rect":
return shape["w"] * shape["h"]
# 삼각형을 추가하려면 여기 또 손대야 함(수정에 닫혀있지 않음)

print("before:", round(area_bad({"type": "circle", "r": 2}), 2))


실행 결과다.


before: 12.57


지금은 원과 사각형뿐이라 짧아 보인다. 그러나 삼각형을 넣으려면 area_bad를 열어 elif를 하나 더 붙여야 한다. 오각형, 사다리꼴이 이어지면 이 함수는 계속 부풀고, 이미 잘 돌던 원과 사각형 계산까지 매번 위험에 노출된다. 확장할 때마다 기존 코드를 수정해야 하는 이 구조가 바로 수정에 닫혀 있지 않은 상태다.

12.2 각 도형이 자기 넓이를 안다

넓이 계산 지식을 함수의 if가 아니라 각 도형 클래스 안으로 옮긴다. 도형마다 area 메서드를 갖게 하고, 공통 약속은 추상 클래스(abstract class, 반드시 구현하라고 강제하는 설계도)로 묶는다. after 코드다.


from abc import ABC, abstractmethod

class ShapeOCP(ABC):
@abstractmethod
def area(self): ...
class CircleOCP(ShapeOCP):
def __init__(self, r): self.r = r
def area(self): return 3.14159 * self.r ** 2
class RectOCP(ShapeOCP):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
# 새 도형 추가: 기존 코드 수정 없이 클래스만 추가
class TriangleOCP(ShapeOCP):
def __init__(self, b, h): self.b, self.h = b, h
def area(self): return self.b * self.h / 2

def total_area(shapes):
return sum(s.area() for s in shapes)

print("after 총합:", round(total_area([CircleOCP(2), RectOCP(2, 3), TriangleOCP(4, 5)]), 2))


실행 결과다.


after 총합: 28.57


무엇이 좋아졌나. total_area는 도형이 원인지 사각형인지 삼각형인지 묻지 않는다. 그저 area만 부른다. 삼각형을 추가할 때 total_area는 한 글자도 바뀌지 않았다. TriangleOCP 클래스를 새로 더한 것이 전부다. 새 도형은 "추가"만 하고 기존 코드는 "그대로 닫힌다". 이것이 확장에는 열려 있고 수정에는 닫혀 있는 상태다. 게다가 추상 클래스가 area 구현을 강제하므로, area를 빠뜨린 도형은 객체를 만드는 순간 바로 걸러진다.

12.3 할인 정책을 갈아 끼우기

같은 원리가 도형 밖에서도 통한다. 회원 등급별 할인을 if로 나열하면 등급이 늘 때마다 함수를 고쳐야 한다. 할인 하나하나를 객체로 만들어 확장에 연다. before와 after를 한 번에 본다.


# before: 등급마다 if
def discount_bad(grade, price):
if grade == "vip":
return price * 0.8
elif grade == "gold":
return price * 0.9
return price
print("before vip:", discount_bad("vip", 10000))

# after: 할인을 전략 객체로
class DiscountPolicy(ABC):
@abstractmethod
def apply(self, price): ...
class VipDiscount(DiscountPolicy):
def apply(self, price): return price * 0.8
class GoldDiscount(DiscountPolicy):
def apply(self, price): return price * 0.9
class NoDiscount(DiscountPolicy):
def apply(self, price): return price

def checkout(policy, price):
return policy.apply(price)

print("after vip:", checkout(VipDiscount(), 10000))
print("after gold:", checkout(GoldDiscount(), 10000))


실행 결과다.


before vip: 8000.0
after vip: 8000.0
after gold: 9000.0


무엇이 좋아졌나. 새 등급, 예를 들어 실버 20퍼센트 할인을 넣고 싶으면 SilverDiscount 클래스 하나만 추가하면 된다. checkout 함수는 그대로다. 이렇게 알고리즘 덩어리를 객체로 만들어 갈아 끼우는 방식을 전략 패턴(strategy pattern, 정책을 객체로 만들어 교체하기)이라 부른다. 할인 규칙 하나하나가 독립된 클래스라 따로 테스트하고 따로 교체한다. discount_bad처럼 하나의 함수가 모든 등급을 떠안지 않는다.

12.4 확장을 추가로 바꾸는 법

정리한다. OCP의 실천은 한 가지 습관으로 요약된다. 타입에 따라 갈라지는 if 사슬이 보이면, 그 분기를 다형성으로 바꾼다. 각 종류가 자기 동작을 스스로 알게 하고, 사용하는 쪽은 공통 약속만 부른다. 그러면 새 종류를 더하는 일이 기존 코드 수정이 아니라 새 클래스 추가로 바뀐다.


이 원칙이 값어치를 하는 자리는 분명하다. 종류가 앞으로 늘어날 가능성이 있고, 그 종류에 따라 동작이 갈라질 때다. 도형, 회원 등급, 결제 수단, 알림 방식처럼 계속 늘어나는 개념이 그렇다. 반대로 종류가 둘로 고정되어 더 늘지 않는다면, 소박한 if가 오히려 읽기 쉽다. 늘어나지도 않을 분기를 미리 클래스 계층으로 만드는 것은 과설계다. 잘 돌던 코드를 열지 않고도 기능을 넓히고 싶을 때, 그때가 OCP를 꺼낼 순간이다.