본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

Decorator: 기존 코드를 수정하지 않고 동작을 감싸서 확장하는 패턴

데코레이터(Decorator)는 객체나 함수를 같은 인터페이스로 감싸 부가 기능을 덧붙이는 구조 패턴이다. 상속 대신 합성(composition)을 사용하므로 기능 조합을 런타임에 유연하게 바꿀 수 있다. Python의 `@` 문법과 TypeScript의 함수 래핑으로 로깅, 캐싱, 재시도, 권한 검사 같은 횡단 관심사(cross-cutting concer

By 송민성

1. 개념

데코레이터(Decorator)는 원래 객체와 같은 인터페이스를 유지한 채 그 객체를 감싸서 동작을 추가하는 구조 패턴(structural pattern)이다. 감싸는 쪽(래퍼, wrapper)은 내부에 원본 객체를 참조로 들고 있다가, 호출을 위임(delegation)하면서 그 전후에 부가 로직을 실행한다.

GoF 디자인 패턴의 Decorator는 객체 단위로 동작하는 패턴이다. 반면 Python의 @decorator 문법은 함수나 클래스를 받아 새로운 함수나 클래스로 바꿔치기하는 언어 기능이다. 두 개념은 이름과 의도(기능을 감싸서 확장)가 같지만 엄밀히는 다르다. 이 글에서는 둘을 구분해서 다룬다.

2. 왜 사용하는가

기능을 확장하는 가장 단순한 방법은 상속(inheritance)이다. 하지만 기능이 늘어나면 문제가 생긴다.

  • 로깅, 캐싱, 재시도 3가지 기능을 선택적으로 조합하려면 서브클래스가 최대 2³ = 8가지로 늘어난다(클래스 폭발).
  • 상속은 컴파일 또는 정의 시점에 고정되므로 런타임에 기능을 켜고 끌 수 없다.
  • 핵심 로직 안에 로깅이나 인증 코드가 섞여 단일 책임 원칙(SRP)이 깨진다.

데코레이터는 각 부가 기능을 독립된 래퍼로 만들고 필요한 것만 겹쳐 쓰게 해서 이 문제를 해결한다. 개방-폐쇄 원칙(OCP), 즉 확장에는 열려 있고 수정에는 닫혀 있는 구조에도 부합한다.

3. 동작 원리

구조

  1. 공통 인터페이스(Component)를 정의한다.
  2. 원본 구현(ConcreteComponent)이 이를 구현한다.
  3. 데코레이터는 같은 인터페이스를 구현하면서 Component 참조를 필드로 가진다.
  4. 호출이 오면 전처리 → 원본 위임 → 후처리 순서로 실행한다.

데코레이터도 같은 인터페이스이므로 다른 데코레이터가 다시 감쌀 수 있다. 호출은 바깥 래퍼에서 안쪽으로 들어갔다가 반대 순서로 빠져나온다.

Python @ 문법의 의미

python
@deco def f(): ...

위 코드는 정의가 끝난 직후 f = deco(f)를 실행하는 것과 같다. 데코레이터가 여러 개이면 아래에서 위로 적용된다.

python
@a @b def f(): ... # f = a(b(f))

호출 시에는 가장 바깥인 a의 래퍼가 먼저 실행된다.

4. 코드 예제

4-1. Python: 함수 데코레이터

python
import functools import time def timed(func): @functools.wraps(func) # 원본의 __name__, __doc__ 등을 보존 def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed * 1000:.2f} ms") return wrapper def retry(times=3, exceptions=(Exception,)): """인자를 받는 데코레이터: 데코레이터를 반환하는 함수(3중 중첩).""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_exc = None for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as e: last_exc = e print(f"[retry] {func.__name__} 실패 ({attempt}/{times}): {e}") raise last_exc return wrapper return decorator _calls = {"n": 0} @timed @retry(times=3, exceptions=(ConnectionError,)) def fetch(): _calls["n"] += 1 if _calls["n"] < 3: raise ConnectionError("일시적 오류") return "ok" if __name__ == "__main__": print(fetch()) print(fetch.__name__) # functools.wraps 덕분에 'fetch'

timed가 가장 바깥이므로 재시도 전체에 걸린 시간을 측정한다.

4-2. Python: GoF 방식의 객체 데코레이터

python
from abc import ABC, abstractmethod class Notifier(ABC): @abstractmethod def send(self, message: str) -> None: ... class EmailNotifier(Notifier): def send(self, message: str) -> None: print(f"[Email] {message}") class NotifierDecorator(Notifier): def __init__(self, inner: Notifier): self._inner = inner def send(self, message: str) -> None: self._inner.send(message) class SlackNotifier(NotifierDecorator): def send(self, message: str) -> None: super().send(message) print(f"[Slack] {message}") class SmsNotifier(NotifierDecorator): def send(self, message: str) -> None: super().send(message) print(f"[SMS] {message}") if __name__ == "__main__": notifier = SmsNotifier(SlackNotifier(EmailNotifier())) notifier.send("배포 완료")

조합은 생성 시점에 결정되며, 클래스를 새로 만들지 않고 순서와 구성을 바꿀 수 있다.

4-3. TypeScript: 고차 함수 기반 데코레이터

typescript
type AsyncFn<A extends unknown[], R> = (...args: A) => Promise<R>; function withLogging<A extends unknown[], R>( fn: AsyncFn<A, R>, name = fn.name || "anonymous", ): AsyncFn<A, R> { return async (...args: A) => { console.log(`[start] ${name}`, args); try { const result = await fn(...args); console.log(`[done] ${name}`); return result; } catch (e) { console.error(`[fail] ${name}`, e); throw e; } }; } function withCache<A extends unknown[], R>( fn: AsyncFn<A, R>, keyOf: (...args: A) => string = (...args) => JSON.stringify(args), ): AsyncFn<A, R> { const cache = new Map<string, Promise<R>>(); return (...args: A) => { const key = keyOf(...args); let hit = cache.get(key); if (!hit) { hit = fn(...args); cache.set(key, hit); hit.catch(() => cache.delete(key)); // 실패는 캐시하지 않는다 } return hit; }; } async function getUser(id: number): Promise<{ id: number; name: string }> { await new Promise((r) => setTimeout(r, 100)); return { id, name: `user-${id}` }; } const decorated = withLogging(withCache(getUser), "getUser"); (async () => { await decorated(1); // 실제 호출 await decorated(1); // 캐시 적중 })();

Promise 자체를 캐시하므로 같은 키로 동시에 여러 번 호출해도 원본은 한 번만 실행된다.

5. 시간 복잡도 또는 성능 특성

  • 래퍼 한 겹은 호출당 함수 호출 한 번과 인자 전달 비용을 추가한다. 겹친 개수를 k라 하면 호출 오버헤드는 O(k)이다.
  • 데코레이터의 실제 비용은 부가 로직이 좌우한다. 로깅의 I/O, 직렬화(JSON.stringify) 등이 래핑 자체보다 훨씬 클 때가 많다.
  • Python의 @ 데코레이터는 정의 시점에 한 번 적용되고, 이후 호출마다 래퍼를 거친다.
  • 캐싱 데코레이터는 시간을 줄이는 대신 메모리를 쓴다. 위 withCache는 만료나 크기 제한이 없어 키 수만큼 메모리가 늘어난다.

6. 실무 사용 사례

  • Python 표준 라이브러리: functools.lru_cache(크기 제한이 있는 LRU 캐시), functools.wraps, @property, @staticmethod, @classmethod, @dataclass(클래스 데코레이터).
  • 웹 프레임워크: Flask의 @app.route, FastAPI의 @app.get으로 경로를 등록하고, 인증 검사에는 login_required 같은 데코레이터를 쓴다.
  • Java I/O: BufferedInputStream이 InputStream을 감싸는 전형적인 GoF 데코레이터이다.
  • Express/Koa 미들웨어: 요청 처리 함수를 겹겹이 감싸는 구조로, 개념적으로 데코레이터 체인과 유사하다.
  • HTTP 클라이언트: 재시도, 타임아웃, 토큰 주입, 메트릭 수집을 래퍼로 분리한다.
  • TypeScript/NestJS: @Controller, @Injectable 같은 클래스·메서드 데코레이터로 메타데이터를 선언한다. 이는 언어 기능으로서의 데코레이터이며 GoF 패턴과는 구분해서 이해해야 한다.

7. 주의할 점

  • 메타데이터 손실: Python에서 functools.wraps를 빼면 __name__, __doc__, 시그니처 정보가 래퍼 것으로 바뀌어 디버깅과 문서화가 어려워진다.
  • 적용 순서: 데코레이터는 순서에 따라 의미가 달라진다. 캐시가 재시도 바깥인지 안쪽인지, 인증 검사가 로깅 앞인지 뒤인지를 의도적으로 정해야 한다.
  • 상태 공유: 캐시 딕셔너리 같은 클로저 상태는 데코레이터 적용 시점에 한 번 만들어진다. 스레드 환경에서는 동기화가 필요하다.
  • 과도한 중첩: 래퍼가 5~6겹 쌓이면 스택 트레이스가 길어지고 동작 추적이 어려워진다.
  • 비동기 혼용: Python에서 async def 함수를 동기 래퍼로 감싸면 코루틴 객체만 반환될 뿐 의도한 전후 처리가 올바른 시점에 실행되지 않는다. 비동기 함수는 async def 래퍼와 await로 감싸야 한다.
  • 인터페이스 불일치: GoF 방식에서 데코레이터가 원본에 없는 메서드를 추가하면 같은 인터페이스라는 전제가 깨지고, 겹쳐 쓸 때 추가 메서드에 접근하기 어려워진다.
  • TypeScript 데코레이터 버전: 실험적 데코레이터(experimentalDecorators)와 TC39 표준 데코레이터는 시그니처가 다르므로, 프레임워크가 요구하는 설정을 확인해야 한다.

8. 핵심 정리

  • 데코레이터는 같은 인터페이스를 유지하며 객체나 함수를 감싸 기능을 덧붙이는 합성 기반 패턴이다.
  • 상속보다 조합이 자유롭고, 클래스 폭발을 피하며, 런타임에 구성을 바꿀 수 있다.
  • Python @deco는 f = deco(f)의 문법적 편의이며, 여러 개일 때 아래에서 위로 적용된다.
  • 호출 오버헤드는 겹 수에 비례(O(k))하지만, 대부분 부가 로직의 비용이 더 크다.
  • functools.wraps, 적용 순서, 비동기 처리, 캐시 메모리 관리를 항상 점검한다.