객체에 기능을 더하고 싶을 때 가장 쉬운 답은 상속입니다. 그러나 더할 기능이 늘어나면 상속 트리가 빠르게 자랍니다. Decorator는 그 자람에 대한 다른 답입니다.
같은 자리에 무엇을 더하는가
객체에 새 기능을 더하는 일을 생각합니다. HTTP 요청에 로깅을 더한다. 캐싱을 더한다. 인증 헤더를 더한다. 재시도를 더한다.
각 기능을 별도의 클래스로 만들고 상속으로 조합하려는 시도는 곧 막힙니다. 로깅과 캐싱을 동시에 더하려면 LoggingCachingRequest가 필요합니다. 거기에 인증을 더하면 LoggingCachingAuthRequest까지 필요합니다. 조합의 수만큼 클래스가 늘어납니다.
Decorator는 다른 답을 냅니다. 기능을 더하는 객체가 원본 객체와 같은 인터페이스를 가지도록 만듭니다. 같은 모양이므로 어디든 끼워 넣을 수 있습니다. 같은 모양이므로 또 다른 Decorator로 감쌀 수 있습니다.
핵심은 같은 모양 위에 책임을 한 겹씩 쌓는 것입니다. 양파 껍질처럼 겹이 쌓이고, 호출은 가장 바깥 껍질부터 안쪽으로 흘러갑니다.
Decorator의 정석
기본 구조
Request라는 인터페이스가 있고, 그것을 구현하는 기본 객체가 있습니다.
interface Request {
send(): Promise<Response>
}
class HttpRequest implements Request {
constructor(private url: string) {}
async send(): Promise<Response> {
return fetch(this.url)
}
}
이제 Decorator를 만듭니다. Decorator도 Request를 구현합니다. 내부에 또 다른 Request를 멤버로 가지고, 그것에 위임합니다.
class LoggingDecorator implements Request {
constructor(private inner: Request) {}
async send(): Promise<Response> {
console.log('→ request started')
const result = await this.inner.send()
console.log('← request completed')
return result
}
}
class CacheDecorator implements Request {
private cache = new Map<string, Response>()
constructor(private inner: Request, private key: string) {}
async send(): Promise<Response> {
const cached = this.cache.get(this.key)
if (cached) return cached
const result = await this.inner.send()
this.cache.set(this.key, result)
return result
}
}
class AuthDecorator implements Request {
constructor(private inner: Request, private token: string) {}
async send(): Promise<Response> {
// 인증 헤더를 부착하는 일은 별도로... (예시상 간단히)
return this.inner.send()
}
}
각 Decorator가 같은 인터페이스를 가지므로 자유롭게 조합할 수 있습니다.
const request: Request = new LoggingDecorator(
new CacheDecorator(
new AuthDecorator(
new HttpRequest('/api/users'),
'token-xyz',
),
'users',
),
)
await request.send()
// Logging이 가장 먼저 흐름을 받고, 가장 안쪽 HttpRequest까지 차례로 내려갑니다
LoggingDecorator(CacheDecorator(AuthDecorator(HttpRequest))). 호출은 바깥에서 안쪽으로, 응답은 안쪽에서 바깥으로 흐릅니다. 양파 껍질이라는 비유가 잘 맞습니다.
안티패턴
Decorator를 잘못 쓰면 다음 같은 문제가 생깁니다.
책임이 겹치는 Decorator. 두 Decorator가 비슷한 일을 한다면, 어느 것이 먼저 와야 하는지 정하기 어렵습니다. RetryDecorator와 TimeoutDecorator가 동시에 있을 때 순서가 결과를 바꿉니다. 순서가 의미를 가지는 Decorator는 명시적으로 그 순서를 문서화해야 합니다.
Decorator 안에서 inner를 우회. Decorator는 위임만 하는 객체여야 합니다. 내부 객체의 메서드가 아닌 다른 길로 비슷한 일을 수행하면, 체인의 의미가 깨집니다.
상태를 공유하는 Decorator. Decorator는 일반적으로 stateless이거나 자기만의 작은 상태를 가집니다. 외부와 공유하는 상태가 들어가면 Decorator 인스턴스가 더 이상 자유롭게 조합되지 않습니다.
TS 데코레이터 문법과는 다릅니다
TypeScript에는 @Logger처럼 사용하는 클래스/메서드 데코레이터 문법이 있습니다. 같은 이름이지만 다른 것입니다. GoF의 Decorator는 객체 단위의 합성 패턴이고, TS 데코레이터 문법은 메타프로그래밍 기능입니다. 형태가 비슷한 일을 할 수 있지만 같지는 않습니다.
같은 결정이 다시 나타나는 자리
Decorator가 답하는 질문은 같은 모양 위에 책임을 한 겹씩 쌓는 것입니다. 이 질문은 React 같은 컴포넌트 시스템에서 매일 반복됩니다.
Wrapper 컴포넌트가 가장 직접적인 사례입니다. 인증이 필요한 페이지를 AuthGate로 감쌉니다. 분석 이벤트가 필요한 영역을 TrackingProvider로 감쌉니다. 다크 모드 강제 영역을 ThemeOverride로 감쌉니다.
<AuthGate>
<TrackingProvider category="payment">
<ThemeOverride mode="dark">
<CheckoutPage />
</ThemeOverride>
</TrackingProvider>
</AuthGate>
각 Wrapper가 같은 자식 타입을 받고 같은 모양을 내보냅니다. 자유롭게 조합되고, 자유롭게 쌓입니다. 코드의 Decorator 체인과 같은 구조입니다.
컴포넌트를 받아 컴포넌트를 돌려주는 HOC(Higher-Order Component) 는 같은 결정에 대한 또 다른 답입니다. withAuth(withTracking(withTheme(CheckoutPage)))처럼 호출 순서는 Decorator와 같지만, 외부에서 보이는 것은 최종 컴포넌트 하나입니다. 현대 React는 Hook과 Wrapper로 거의 대체했지만, 같은 모양 위에 책임을 쌓는다는 같은 결정이 패턴의 이름만 바꿔 다시 나타난 것입니다.
Compound Component와의 차이
Decorator는 같은 모양 위에 외부에서 기능을 덧붙이는 패턴입니다. Compound Component는 한 컴포넌트 군의 내부 협업을 드러내는 패턴입니다. 둘은 답하는 질문이 다릅니다.
// Decorator (Wrapper)
<AuthGate>
<CheckoutPage />
</AuthGate>
// Compound Component
<Tabs>
<Tabs.List>
<Tabs.Trigger value="a">A</Tabs.Trigger>
<Tabs.Trigger value="b">B</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="a">...</Tabs.Panel>
</Tabs>
Wrapper는 외부에서 책임을 더합니다. Compound Component는 내부의 작은 부품들이 같은 컨텍스트 안에서 서로 인지하며 동작합니다. 둘을 구분하지 못하면, 디자인 시스템의 API가 일관성이 없어집니다..
Wrapper의 윤리
Decorator는 강력합니다. 그래서 책임이 따라옵니다.
-
투명성: Wrapper는 자식에게 보이지 않게 동작합니다. 자식 컴포넌트는 자기를 감싸는 Wrapper가 무엇을 하는지 모릅니다. 이 비대칭이 도움이 될 때도 있지만, 자식의 동작에 영향을 주는 Wrapper는 그 영향이 어떤 것인지 문서로 드러나야 합니다.
-
추적 가능성: 분석 이벤트를 자동으로 발사하는 Wrapper는 사용자에게는 보이지 않습니다. 디자이너와 개발자에게는 보여야 합니다. “이 컴포넌트가 어떤 이벤트를 보내는지” 묻는 일이 무겁지 않아야 합니다.
-
선택권: 다크 모드를 강제하는 Wrapper, 알림을 강제로 띄우는 Wrapper. 사용자의 선택을 우회하는 자리에 Wrapper가 놓인다면, 그것은 dark pattern과 가까워질 수 있습니다. 덧붙임이 가능하다는 것은 덧붙이지 않을 책임도 있다는 뜻입니다.
디자이너에게 이 패턴이 보이는 자리. 무엇을 별도의 컴포넌트 군으로 묶고(Compound), 무엇을 외부에서 덧입힐 것인가(Decorator). 이 결정은 디자인 시스템의 표현력과 깊이를 결정합니다.
다음 편 예고
Decorator가 같은 모양 위에 쌓는 패턴이라면, Facade는 안쪽의 복잡함을 한 겹의 단순함으로 가리는 패턴입니다. 디자인 시스템 컴포넌트의 API 표면을 어떻게 결정할 것인가? 그 질문이 곧 Facade의 질문입니다.