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. 동작 원리
구조
- 공통 인터페이스(Component)를 정의한다.
- 원본 구현(ConcreteComponent)이 이를 구현한다.
- 데코레이터는 같은 인터페이스를 구현하면서 Component 참조를 필드로 가진다.
- 호출이 오면 전처리 → 원본 위임 → 후처리 순서로 실행한다.
데코레이터도 같은 인터페이스이므로 다른 데코레이터가 다시 감쌀 수 있다. 호출은 바깥 래퍼에서 안쪽으로 들어갔다가 반대 순서로 빠져나온다.
Python @ 문법의 의미
@deco
def f(): ...위 코드는 정의가 끝난 직후 f = deco(f)를 실행하는 것과 같다. 데코레이터가 여러 개이면 아래에서 위로 적용된다.
@a
@b
def f(): ...
# f = a(b(f))호출 시에는 가장 바깥인 a의 래퍼가 먼저 실행된다.
4. 코드 예제
4-1. 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 방식의 객체 데코레이터
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: 고차 함수 기반 데코레이터
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, 적용 순서, 비동기 처리, 캐시 메모리 관리를 항상 점검한다.