코딩 · 디자인 패턴
예제로 이해하는 데코레이터 패턴
핵심 답변
데코레이터 패턴은 같은 인터페이스를 가진 다른 객체로 감싸 객체에 책임을 동적으로 추가하는 구조 패턴입니다. 각 데코레이터는 호출을 감싼 객체에 전달하면서 그 전이나 후에 로깅, 캐싱, 압축 같은 자신의 동작을 더합니다. 데코레이터는 순서를 마음대로 쌓을 수 있으므로, 기능의 조합마다 서브클래스를 만드는 것보다 유연한 대안입니다.
의도
객체에 추가 책임을 동적으로 붙입니다. 데코레이터는 기능을 확장하는 데 서브클래싱보다 유연한 대안을 제공합니다. 데코레이터 패턴은 Gang of Four(GoF) 책의 일곱 가지 구조 패턴 중 하나이며, 책에서는 Wrapper라고도 부릅니다.
해결하는 문제
get(key)를 가진 데이터 저장소를 떠올려 보세요. 어떤 호출자는 로깅을, 어떤 호출자는 메모리 캐시를, 어떤 호출자는 둘 다를, 또 어떤 호출자는 느린 네트워크 백엔드를 위한 재시도를 원합니다. 상속으로 하면 LoggingStore, CachingStore, LoggingCachingStore, RetryingCachingStore 등 조합마다 서브클래스를 하나씩 작성해야 하고, 조합은 컴파일 시점에 고정됩니다.
데코레이터 패턴은 각 기능을 작은 래퍼 클래스로 만듭니다. 한 객체를 다른 객체로 감싸는 방식으로 필요한 조합을 런타임에 구성하며, 저장소를 사용하는 코드는 계층이 몇 겹이든 같은 인터페이스를 봅니다.
구조와 참여자
| 참여자 | 역할 | 예제에서 |
|---|---|---|
| Component | 실제 객체와 데코레이터가 공유하는 인터페이스. | Store |
| ConcreteComponent | 핵심 작업을 하는 원래 객체. | SlowStore |
| Decorator | Component에 대한 참조를 가지고 전달하는 방식으로 같은 인터페이스를 구현. | StoreDecorator |
| ConcreteDecorator | 전달하기 전이나 후에 동작을 추가. | LoggingStore, CachingStore |
TypeScript 구현
interface Store {
get(key: string): string;
}
class SlowStore implements Store {
get(key: string): string { console.log(` (database read: ${key})`); return key.toUpperCase(); }
}
// Base decorator: forwards everything to the wrapped store.
abstract class StoreDecorator implements Store {
constructor(protected inner: Store) {}
get(key: string): string { return this.inner.get(key); }
}
class LoggingStore extends StoreDecorator {
get(key: string): string {
console.log(`get ${key}`);
return super.get(key);
}
}
class CachingStore extends StoreDecorator {
private cache = new Map<string, string>();
get(key: string): string {
if (!this.cache.has(key)) this.cache.set(key, super.get(key));
return this.cache.get(key)!;
}
}
// Stack features in any order at run time.
const store: Store = new LoggingStore(new CachingStore(new SlowStore()));
console.log(store.get("ada"));
console.log(store.get("ada"));
// get ada
// (database read: ada)
// ADA
// get ada
// ADA두 번째 호출은 로그가 남지만 캐시에서 응답하므로 데이터베이스를 다시 읽지 않습니다. 순서를 바꿔 new CachingStore(new LoggingStore(...))로 하면 캐시 미스만 로그에 남습니다. 데코레이터의 순서가 동작을 바꾸며, 이는 장점이자 흔한 혼란의 원인입니다.
TypeScript의 함수 데코레이터
컴포넌트가 함수 하나라면 데코레이터는 고차 함수가 될 수 있습니다. 함수를 받아서 같은 시그니처에 동작을 더한 새 함수를 반환합니다.
function withTiming<A extends unknown[], R>(name: string, fn: (...args: A) => R) {
return (...args: A): R => {
const start = performance.now();
try {
return fn(...args);
} finally {
console.log(`${name} took ${(performance.now() - start).toFixed(1)} ms`);
}
};
}
const slowSquare = (n: number) => { for (let i = 0; i < 1e6; i++); return n * n; };
const timedSquare = withTiming("square", slowSquare);
console.log(timedSquare(12)); // square took ... ms, then 144언제 쓰고 언제 쓰지 말아야 하나
다음과 같을 때 데코레이터 패턴을 사용합니다.
- 같은 클래스의 다른 객체에는 영향을 주지 않고, 개별 객체에 로깅, 캐싱, 재시도, 검증, 압축, 접근 검사 같은 기능을 추가하고 싶을 때.
- 기능을 자유롭게 조합해야 하는데 서브클래싱을 하면 조합마다 클래스가 필요할 때.
- 라이브러리에서 온 클래스처럼 원래 클래스를 바꿀 수 없거나 바꾸지 말아야 할 때.
다음 경우에는 피하세요.
- 고정된 추가 동작이 하나뿐일 때. 직접 추가하거나 서브클래스 하나를 쓰는 편이 더 단순합니다.
- 코드가 객체의 동일성이나 구체 클래스에 의존할 때. 데코레이트된 객체는 다른 객체이므로
instanceof SlowStore나===비교 같은 검사가 실패합니다. - 작은 계층이 많으면 디버깅이 어려워질 때. 래퍼가 깊게 쌓이면 호출 스택이 길어지고 어느 계층이 무엇을 했는지 불분명해집니다.
Express나 Koa 같은 웹 프레임워크의 미들웨어도 관련된 발상입니다. 각 계층이 요청을 받아 처리할 수 있고 다음으로 넘깁니다.
실제 사용 사례
- Java I/O:
new BufferedInputStream(new FileInputStream("data.bin"))는 스트림을 버퍼링을 추가하는 다른 스트림으로 감싸며, 둘 다InputStream인터페이스를 가집니다. GoF 책도 스트림을 대표적인 예로 논의합니다. - Java 컬렉션:
Collections.unmodifiableList(list)와Collections.synchronizedList(list)는 기존 리스트를 읽기 전용으로 만들거나 락을 거는 래퍼를 반환합니다. - Python:
functools.lru_cache같은 함수 데코레이터는 함수를 감싸 캐싱을 추가하고,functools.wraps는 래퍼가 감싼 함수의 이름과 docstring을 유지하게 도와줍니다.
관련 패턴
| 패턴 | 데코레이터 패턴과의 차이 |
|---|---|
| 어댑터 패턴 | 객체의 인터페이스를 바꿉니다. 데코레이터는 인터페이스를 유지하며 동작을 추가합니다. |
| 프록시 패턴 | 구조는 같지만 목적이 다릅니다. 접근(지연 생성, 원격 접근, 권한)을 제어하며 보통 실제 객체의 생명주기를 직접 관리합니다. |
| 컴포지트 패턴 | 이것도 재귀적 합성을 쓰지만, 컴포지트는 자식이 여럿이고 데코레이터는 정확히 하나를 감쌉니다. |
| 전략 패턴 | 알고리즘을 교체해 객체의 내부를 바꿉니다. 데코레이터는 객체를 감싸 외부를 바꿉니다. |
| 책임 연쇄 패턴 | 이것도 요청을 일련의 객체에 통과시키지만, 각 핸들러가 요청을 멈출 수 있습니다. 데코레이터는 보통 항상 전달합니다. |
자주 묻는 질문
데코레이터 패턴과 상속은 무엇이 다른가요?
상속은 컴파일 시점에 클래스 전체에 동작을 추가합니다. 데코레이터는 런타임에 객체 하나에 동작을 추가하며, 여러 데코레이터를 어떤 순서로든 조합할 수 있습니다.
데코레이터 패턴과 프록시 패턴은 무엇이 다른가요?
모양은 같습니다. 데코레이터는 클라이언트가 쌓기로 선택한 기능을 추가하고, 프록시는 실제 객체에 대한 접근을 제어하며 객체를 만들거나 호출할지 말지를 결정하는 경우가 많습니다.
TypeScript의 @데코레이터가 데코레이터 패턴인가요?
정확히는 아닙니다. @ 데코레이터는 클래스와 그 멤버가 정의될 때 수정하는 언어 기능입니다. 감싸기를 구현하는 데 쓸 수는 있지만 GoF 패턴은 런타임에 객체를 감싸는 것입니다.
데코레이터의 순서가 중요한가요?
네. 각 데코레이터는 자신이 감싼 객체보다 먼저 호출을 보게 됩니다. 로거 바깥에 둔 캐시는 반복 호출을 로거에게 숨기지만, 캐시 바깥에 둔 로거는 모든 호출을 봅니다.