디자인 패턴은 코드를 자동으로 좋은 구조로 만드는 템플릿이 아니다. 반복해서 나타나는 설계 문제와 그 해결책, 함께 생기는 trade-off에 붙인 이름이다. 패턴 이름보다 무엇이 자주 바뀌고 어떤 의존성을 격리하려는지 먼저 설명할 수 있어야 한다.
원문은 Singleton, Factory Method, Observer, Strategy, Decorator 예제를 한 번에 나열했지만 각 패턴을 선택해야 할 조건과 비용이 빠져 있었다. 이번에는 “변화 지점”을 기준으로 다시 묶는다.
먼저 묻는 네 가지 질문
| 변화하는 것 | 후보 패턴 | 분리하려는 책임 | 주의할 비용 |
|---|---|---|---|
| 계산·정책 알고리즘 | Strategy | 사용하는 곳과 알고리즘 구현 | 작은 함수에도 class가 늘 수 있음 |
| 생성할 구체 타입 | Factory Method | 객체 사용과 생성 결정 | subclass와 간접 계층 증가 |
| 선택적으로 겹쳐 붙일 기능 | Decorator | 핵심 기능과 부가 기능 | wrapper 순서와 identity 추적 |
| 한 상태 변화를 받을 여러 대상 | Observer | 발행자와 구독자 | 순서, 실패 전파, 해제와 memory leak |
요구사항이 한 번만 등장하고 변경 가능성도 낮다면 조건문과 함수 하나가 더 읽기 쉬울 수 있다. 두 번째 구현이나 실제 변경 축이 생기기 전에 추상 계층부터 만들 필요는 없다.
Strategy: 같은 입력에 적용할 정책이 바뀐다
Strategy는 바뀌는 알고리즘을 같은 interface 뒤에 둔다. 예를 들어 주문 금액 계산 흐름은 같지만 할인 정책이 고객 유형에 따라 달라질 수 있다.
Python의 Protocol로 필요한 동작만 표현할 수 있다.
from typing import Protocol
class DiscountPolicy(Protocol):
def discount(self, subtotal: int) -> int:
...
class NoDiscount:
def discount(self, subtotal: int) -> int:
return 0
class FixedDiscount:
def __init__(self, amount: int) -> None:
self.amount = amount
def discount(self, subtotal: int) -> int:
return min(self.amount, subtotal)
def final_price(subtotal: int, policy: DiscountPolicy) -> int:
return subtotal - policy.discount(subtotal)
final_price는 구체 정책을 알지 않는다. 새 정책을 추가해도 계산 흐름을 수정하지 않고 주입할 수 있다. 반대로 정책이 두 줄짜리 함수이고 상태가 없다면 callable을 전달하는 편이 더 단순할 수 있다. Strategy의 목적은 class를 만드는 것이 아니라 선택 가능한 정책을 호출자와 분리하는 데 있다.
Factory Method: 생성 결정을 subclass에 맡긴다
GoF의 Factory Method는 상위 class가 처리 흐름을 정의하고, 필요한 product 생성은 subclass가 결정하게 한다. 단순히 if type == ...로 객체를 반환하는 함수는 흔히 simple factory라고 부르며 Factory Method와 구조가 다르다.
from abc import ABC, abstractmethod
class Writer(ABC):
@abstractmethod
def write(self, rows: list[dict]) -> bytes:
raise NotImplementedError
class ReportJob(ABC):
@abstractmethod
def create_writer(self) -> Writer:
raise NotImplementedError
def run(self, rows: list[dict]) -> bytes:
writer = self.create_writer()
return writer.write(rows)
ReportJob.run()은 공통 처리 순서를 유지하고 subclass가 CSV·JSON 같은 writer를 만든다. 생성 조합이 configuration 몇 개로 끝난다면 dependency injection container나 명시적인 factory function이 더 적합할 수 있다. inheritance가 필요한 이유가 없다면 Factory Method를 위해 억지로 subclass를 만들지 않는다.
Decorator: 같은 interface를 유지하며 기능을 감싼다
Decorator는 대상과 같은 interface를 구현한 wrapper가 호출 전후에 기능을 더한다.
from typing import Protocol
class Notifier(Protocol):
def send(self, message: str) -> None:
...
class LoggingNotifier:
def __init__(self, inner: Notifier) -> None:
self.inner = inner
def send(self, message: str) -> None:
print("send started")
self.inner.send(message)
print("send finished")
상속으로 LoggingEmailNotifier, RetryingEmailNotifier, RetryingLoggingEmailNotifier를 계속 만드는 대신 wrapper를 조합할 수 있다. 그러나 retry와 logging의 순서를 바꾸면 log 횟수나 실패 처리 의미도 달라질 수 있다.
Logging(Retry(Email)) ≠ Retry(Logging(Email))
Python의 @decorator 문법과 GoF Decorator 패턴은 이름이 같지만 같은 범위의 개념은 아니다. 함수 decorator도 호출을 감쌀 수 있지만, GoF 패턴은 객체 composition과 동일 interface에 초점을 둔다. Python closure와 함수 decorator는 파이썬 클로저와 데코레이터에서 별도로 볼 수 있다.
Observer: 한 event를 여러 구독자에게 알린다
Observer는 subject가 구체 observer를 직접 호출하는 대신 구독 목록에 event를 전달한다.
from collections.abc import Callable
class OrderEvents:
def __init__(self) -> None:
self._listeners: list[Callable[[str], None]] = []
def subscribe(self, listener: Callable[[str], None]) -> Callable[[], None]:
self._listeners.append(listener)
def unsubscribe() -> None:
self._listeners.remove(listener)
return unsubscribe
def publish(self, order_id: str) -> None:
for listener in tuple(self._listeners):
listener(order_id)
이 예제는 동기식 in-process observer다. listener 하나가 오래 걸리거나 예외를 던지면 발행 흐름에 영향을 준다. 실제 설계에서는 다음 계약이 먼저 필요하다.
- 호출 순서를 보장하는가
- listener 실패가 다른 listener를 막는가
- 중복 등록과 구독 해제를 어떻게 처리하는가
- event 전달 중 목록이 바뀌어도 되는가
- process 밖으로 전달한다면 재시도·중복·순서를 누가 책임지는가
Observer를 message broker와 같은 것으로 보면 안 된다. in-memory callback에는 durability와 재전달이 없다.
Singleton은 기본 선택이 아니다
Singleton은 instance 하나만 만들도록 제한하지만 “application 전체에 하나”의 범위가 모호하다.
- process 하나인지 여러 worker 전체인지
- thread·async task에서 안전한지
- test 사이에서 state가 격리되는지
- lifecycle과 cleanup을 누가 관리하는지
Python module import cache는 process 안에서 module object를 재사용하지만 여러 process에 걸친 단일 instance를 만들지는 않는다. database connection pool, cache client처럼 lifecycle이 있는 자원은 composition root에서 명시적으로 생성하고 주입하는 편이 test와 종료 처리를 이해하기 쉽다.
전역 접근이 정말 요구사항이라면 생성 동시성, fork 이후 상태와 reset 방법까지 계약에 포함한다.
패턴을 적용하기 전 체크리스트
- 현재 코드에서 실제로 함께 바뀌는 부분을 찾는다.
- 그 변화가 조건문 하나보다 독립 interface를 가질 만큼 반복되는지 본다.
- pattern을 쓰지 않은 가장 단순한 구현과 비교한다.
- 생성·호출·오류·lifecycle 책임이 어느 객체로 이동하는지 적는다.
- pattern 이름을 모르는 동료도 흐름을 읽을 수 있는지 확인한다.
- 새 구현 추가와 기존 구현 제거를 test로 검증한다.
복잡한 subsystem 앞에 단순한 interface를 두는 경우는 Facade 패턴, 객체 생성 인자가 많아 단계적 조립이 필요한 경우는 생성자와 Builder 패턴에서 이어진다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| 비트 연산과 2의 보수: 고정 비트폭·마스크·Python 음수 표현 (0) | 2024.08.16 |
|---|---|
| Fetch API로 AJAX 구현하기: JSON 응답·PHP 연동·오류 처리 (0) | 2024.08.15 |
| 응집도와 결합도: 변경 비용으로 판단하는 모듈 설계 (0) | 2024.08.13 |
| 동기·비동기와 블로킹·논블로킹 차이: 통신과 코드에서 헷갈리지 않기 (0) | 2024.08.11 |
| Python nonlocal은 언제 필요한가: 중첩 함수의 변수 재바인딩 (0) | 2024.08.09 |
댓글