왜 공부하게 되었나
Django로 웹 개발하다가 이런 코드를 봤습니다:
@login_required
def view_profile(request):
return render(request, 'profile.html')
"@login_required가 뭐지? 저 골뱅이 표시는 대체 뭐하는 거야?" 선배에게 물었더니 "데코레이터야. 함수를 감싸서 기능을 추가하는 거지"라고 했습니다.
그런데 React 코드에서도 비슷한 걸 봤습니다:
export default withAuth(ProfileComponent);
그리고 Express로 API 만들 때도:
app.use(express.json());
app.use(authMiddleware);
"이게 다 연관이 있는 건가?" 궁금해졌습니다.
처음엔 뭐가 이해가 안 갔나
여러 가지가 헷갈렸습니다:
-
함수를 "감싼다"는 게 정확히 무슨 뜻인가? - 선물 포장처럼 뭔가 겉에 씌우는 건가? 그럼 원본 함수는 어떻게 되는 거지?
-
왜 굳이 데코레이터를 쓰나? - 그냥 상속으로 클래스를 확장하면 안 되나? 상속이 더 직관적인데?
-
Python의
@와 디자인 패턴의 데코레이터가 같은 건가? - 이름은 같은데 개념적으로 어떤 관계인 거지? -
Express 미들웨어도 데코레이터인가? - 체이닝처럼 보이는데 이것도 데코레이터 패턴인가?
-
래퍼(Wrapper)와 데코레이터의 차이는 뭔가? - 둘 다 "감싸기"인 것 같은데 정확히 뭐가 다른 거지?
깨달음의 순간 - "스타벅스에서 커피 주문하기"
스타벅스에서 커피를 주문하는 상황을 떠올렸을 때 모든 게 명확해졌습니다. 메뉴판을 보니:
아메리카노: 4,500원
- 휘핑크림 추가: +500원
- 에스프레소 샷 추가: +600원
- 바닐라 시럽 추가: +500원
- 두유로 변경: +600원
만약 상속으로 이걸 구현한다면:
- Americano
- AmericanoWithWhippedCream
- AmericanoWithShot
- AmericanoWithVanilla
- AmericanoWithWhippedCreamAndShot
- AmericanoWithWhippedCreamAndVanilla
- AmericanoWithShotAndVanilla
- AmericanoWithWhippedCreamAndShotAndVanilla
- AmericanoWithWhippedCreamAndShotAndVanillaAndSoyMilk
- ...
4가지 옵션만으로도 2^4 = 16가지 조합이 나옵니다. 각각을 클래스로 만들어야 한다면? 지옥이죠. 게다가 "헤이즐넛 시럽"이라는 새 옵션이 추가되면? 모든 조합을 다시 만들어야 합니다.
데코레이터로 하면:
let coffee = new Americano(); // 기본: 4,500원
coffee = new WhippedCream(coffee); // 포장: +500원
coffee = new Shot(coffee); // 한 번 더 포장: +600원
coffee = new Vanilla(coffee); // 또 포장: +500원
// 총 6,100원
4개 데코레이터 클래스로 모든 조합을 만들 수 있습니다. 새 옵션 추가? 데코레이터 하나만 추가하면 됩니다.
이때 깨달았습니다: "상속은 컴파일 타임에 정적으로 결정되지만, 데코레이터는 런타임에 동적으로 조합할 수 있구나!"
데코레이터 패턴이 뭔지 내 방식으로 정리해본다
데코레이터 패턴은 객체에 동적으로 새로운 책임(기능)을 추가하는 패턴입니다. 핵심은 "원본 객체를 수정하지 않고, 래퍼로 감싸서 기능을 확장한다"는 점입니다.
마치 선물을 포장하는 것처럼:
- 원본 선물(객체)이 있습니다
- 포장지(데코레이터)로 감쌉니다
- 포장된 것도 여전히 "선물"이지만 추가 기능(예쁜 외관)이 있습니다
- 그 위에 또 리본(또 다른 데코레이터)을 추가할 수 있습니다
나는 이렇게 이해했습니다: "데코레이터는 원본 객체와 동일한 인터페이스를 유지하면서, 내부적으로 원본 객체를 참조해서 기능을 추가하는 래퍼다."
왜 상속이 아니라 데코레이터를 써야 하나
이 부분이 처음엔 이해가 안 갔습니다. 상속도 기능을 추가하는 거 아닌가? 근데 직접 비교해보니 차이가 확 와닿았습니다.
상속의 문제점
상속은 컴파일 타임에 정적으로 결정됩니다:
class Coffee {
cost() { return 4500; }
}
class CoffeeWithWhippedCream extends Coffee {
cost() { return 5000; }
}
class CoffeeWithShot extends Coffee {
cost() { return 5100; }
}
// 둘 다 원하면?
class CoffeeWithWhippedCreamAndShot extends Coffee {
cost() { return 5600; }
}
문제가 뭔가 하면:
- 조합 폭발(Combinatorial Explosion): n개 옵션이면 2^n개 클래스 필요
- 유연성 부족: 런타임에 동적으로 기능을 추가/제거할 수 없음
- 코드 중복: 각 조합마다 비슷한 코드 반복
- 확장성 문제: 새 옵션 하나 추가하면 기존 모든 조합 클래스에 영향
데코레이터의 장점
데코레이터는 런타임에 동적으로 조합합니다:
// Component (기본 인터페이스)
class Coffee {
cost() {
return 4500;
}
description() {
return "아메리카노";
}
}
// Decorator 베이스
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee; // 원본을 내부에 보관
}
cost() {
return this.coffee.cost(); // 원본에 위임
}
description() {
return this.coffee.description();
}
}
// Concrete Decorators
class WhippedCream extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 500; // 원본 + 추가 비용
}
description() {
return this.coffee.description() + ", 휘핑크림";
}
}
class Shot extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 600;
}
description() {
return this.coffee.description() + ", 샷 추가";
}
}
class Vanilla extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 500;
}
description() {
return this.coffee.description() + ", 바닐라 시럽";
}
}
// 사용 예시
let myCoffee = new Coffee();
console.log(myCoffee.cost()); // 4500
console.log(myCoffee.description()); // "아메리카노"
// 런타임에 동적으로 조합
myCoffee = new WhippedCream(myCoffee);
console.log(myCoffee.cost()); // 5000
myCoffee = new Shot(myCoffee);
console.log(myCoffee.cost()); // 5600
myCoffee = new Vanilla(myCoffee);
console.log(myCoffee.cost()); // 6100
console.log(myCoffee.description());
// "아메리카노, 휘핑크림, 샷 추가, 바닐라 시럽"
이렇게 하면:
- 3개 데코레이터 클래스로 모든 조합 가능
- 고객의 선택에 따라 런타임에 동적으로 조합
- 새 옵션 추가? 데코레이터 하나만 만들면 됨
- 기존 코드는 전혀 수정 안 함
나는 이 예시를 보고 "결국 이거였다. 상속은 '이다(is-a)' 관계고 정적이지만, 데코레이터는 '가진다(has-a)' 관계고 동적이다"라고 받아들였습니다.
Composition over Inheritance (조합이 상속보다 낫다)
데코레이터 패턴을 공부하면서 이 원칙이 확 와닿았습니다. GoF 디자인 패턴 책에서 강조하는 핵심 원칙 중 하나인데, "클래스 상속보다 객체 조합을 선호하라"는 뜻입니다.
상속의 한계:
- 부모 클래스에 강하게 결합됨
- 부모가 바뀌면 자식도 영향받음
- 다중 상속 문제
- 런타임에 행동 변경 불가
조합의 장점:
- 느슨한 결합
- 런타임에 행동 변경 가능
- 더 유연한 조합
- 테스트하기 쉬움
나는 이렇게 이해했습니다: "상속은 강력하지만 유연하지 않고, 조합은 초기 설정은 복잡하지만 나중에 훨씬 유연하다." 데코레이터 패턴은 조합의 대표적인 예시입니다.
Open-Closed Principle과의 연결
SOLID 원칙 중 하나인 개방-폐쇄 원칙(Open-Closed Principle)이 데코레이터 패턴에서 정말 명확하게 드러납니다.
"소프트웨어 엔티티(클래스, 모듈, 함수)는 확장에는 열려있고 수정에는 닫혀있어야 한다."
데코레이터 패턴을 쓰면:
- 확장에 열려있음: 새 데코레이터 클래스를 추가해서 기능 확장 가능
- 수정에 닫혀있음: 기존 Coffee 클래스나 다른 데코레이터를 전혀 수정 안 함
예를 들어 "시나몬 파우더" 옵션을 추가한다면:
// 기존 코드는 전혀 건드리지 않음
class CinnamonPowder extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 300;
}
description() {
return this.coffee.description() + ", 시나몬 파우더";
}
}
// 바로 사용 가능
let coffee = new Coffee();
coffee = new CinnamonPowder(coffee);
나는 이걸 보고 "OCP가 추상적으로만 느껴졌는데, 데코레이터 패턴에서는 구체적으로 와닿는다"라고 생각했습니다.
Python 데코레이터 - 함수를 감싸는 마법
Python의 @ 문법은 처음 봤을 때 정말 신기했습니다. 디자인 패턴의 데코레이터와 개념은 같은데, 문법이 훨씬 간결합니다.
실행 시간 측정 데코레이터
import time
def timer(func):
"""함수 실행 시간을 측정하는 데코레이터"""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs) # 원본 함수 실행
end = time.time()
print(f"{func.__name__} 실행 시간: {end - start:.2f}초")
return result
return wrapper
@timer # slow_function = timer(slow_function)
def slow_function():
time.sleep(2)
return "완료"
result = slow_function()
# 출력 - slow_function 실행 시간: 2.00초
@timer는 syntactic sugar입니다. 실제로는 slow_function = timer(slow_function)과 같은 의미죠.
나는 이렇게 이해했습니다: "@ 문법은 함수를 인자로 받아서 새 함수를 반환하는 higher-order function이다."
로깅 데코레이터
def log(func):
"""함수 호출을 로깅하는 데코레이터"""
def wrapper(*args, **kwargs):
print(f"[LOG] {func.__name__} 호출됨")
print(f" 인자: args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f" 반환값: {result}")
return result
return wrapper
@log
def add(a, b):
return a + b
@log
def multiply(x, y):
return x * y
add(3, 5)
# [LOG] add 호출됨
# 인자: args=(3, 5), kwargs={}
# 반환값: 8
multiply(4, 7)
# [LOG] multiply 호출됨
# 인자: args=(4, 7), kwargs={}
# 반환값: 28
인증 데코레이터 (Django 스타일)
def login_required(func):
"""사용자 인증을 확인하는 데코레이터"""
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect('/login')
return func(request, *args, **kwargs)
return wrapper
@login_required
def view_profile(request):
return render(request, 'profile.html')
@login_required
def edit_profile(request):
if request.method == 'POST':
# 프로필 수정 로직
pass
return render(request, 'edit_profile.html')
이제 view_profile과 edit_profile 함수는 자동으로 인증 체크를 합니다. 인증 안 된 사용자는 로그인 페이지로 리다이렉트되죠.
나는 이걸 보고 "횡단 관심사(cross-cutting concerns)를 분리하는 완벽한 방법이다"라고 생각했습니다. 로깅, 인증, 캐싱, 성능 측정 같은 기능을 핵심 비즈니스 로직과 분리할 수 있습니다.
여러 데코레이터 중첩하기
@login_required
@timer
@log
def complex_operation(request):
# 복잡한 작업
time.sleep(1)
return "작업 완료"
# 실행 순서: login_required(timer(log(complex_operation)))
# 안쪽부터 바깥쪽으로 감싸짐
순서가 중요합니다:
log가 먼저 감쌈timer가 log를 감쌈login_required가 timer를 감쌈
실행은 반대 순서: login_required → timer → log → 원본 함수
TypeScript 데코레이터 - 클래스와 메서드 확장
TypeScript(실험적 기능)에서도 데코레이터를 지원합니다. 클래스, 메서드, 프로퍼티, 파라미터에 적용 가능합니다.
// 메서드 데코레이터
function readonly(target: any, key: string, descriptor: PropertyDescriptor) {
descriptor.writable = false;
return descriptor;
}
function log(target: any, key: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`${key} 호출됨, 인자:`, args);
const result = originalMethod.apply(this, args);
console.log(`${key} 반환값:`, result);
return result;
};
return descriptor;
}
class Calculator {
@readonly
version = "1.0.0";
@log
add(a: number, b: number): number {
return a + b;
}
@log
multiply(a: number, b: number): number {
return a * b;
}
}
const calc = new Calculator();
calc.version = "2.0.0"; // 에러: Cannot assign to read only property
calc.add(3, 5);
// add 호출됨, 인자: [3, 5]
// add 반환값: 8
TypeScript 데코레이터를 보면서 "메타프로그래밍의 세계가 열린다"는 느낌을 받았습니다. 코드의 구조 자체를 코드로 제어할 수 있다니.
실제 사례 - Express 미들웨어 패턴
Node.js Express를 쓰다 보면 미들웨어를 엄청 많이 씁니다. 근데 이것도 데코레이터 패턴이었습니다.
const express = require('express');
const app = express();
// 미들웨어 = 데코레이터
app.use(express.json()); // JSON 파싱 기능 추가
app.use((req, res, next) => {
console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
next(); // 다음 미들웨어로 넘김
});
// 인증 미들웨어
app.use((req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 토큰 검증 로직
req.user = verifyToken(token);
next();
});
// CORS 미들웨어
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
// 실제 라우트 핸들러
app.get('/api/users', (req, res) => {
// 여기 도착하기 전에 위의 모든 미들웨어를 거침
res.json({ users: [] });
});
요청이 들어오면:
express.json()미들웨어가 body를 파싱- 로깅 미들웨어가 요청 정보를 기록
- 인증 미들웨어가 토큰을 검증
- CORS 미들웨어가 헤더를 설정
- 최종적으로 라우트 핸들러가 실행
각 미들웨어가 요청 객체를 "감싸서" 기능을 추가하는 데코레이터입니다.
나는 이걸 이해하고 "Express 미들웨어는 데코레이터 패턴의 체인(chain of responsibility)이구나"라고 받아들였습니다.
실제 사례: React Higher-Order Component (HOC)
React에서도 데코레이터 패턴을 자주 씁니다. Higher-Order Component(HOC)가 바로 그겁니다.
import React from 'react';
import { Redirect } from 'react-router-dom';
// HOC (데코레이터)
function withAuth(Component) {
return function AuthenticatedComponent(props) {
const user = useAuth(); // 커스텀 훅으로 인증 상태 확인
if (!user) {
return <Redirect to="/login" />;
}
return <Component {...props} user={user} />;
};
}
// 원본 컴포넌트
const Profile = ({ user }) => {
return (
<div>
<h1>프로필</h1>
<p>안녕하세요, {user.name}님</p>
</div>
);
};
// 데코레이터로 감싸서 export
export default withAuth(Profile);
withAuth는 컴포넌트를 받아서 인증 기능이 추가된 새 컴포넌트를 반환합니다. 원본 Profile 컴포넌트는 전혀 수정하지 않았습니다.
여러 HOC를 조합할 수도 있습니다:
function withLoading(Component) {
return function LoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <div>로딩 중...</div>;
}
return <Component {...props} />;
};
}
function withErrorBoundary(Component) {
return class ErrorBoundaryComponent extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>에러가 발생했습니다</div>;
}
return <Component {...this.props} />;
}
};
}
// 여러 데코레이터 조합
const EnhancedProfile = withAuth(withLoading(withErrorBoundary(Profile)));
// 또는 함수 조합 라이브러리 사용
import { compose } from 'redux';
const EnhancedProfile = compose(
withAuth,
withLoading,
withErrorBoundary
)(Profile);
나는 이걸 보고 "HOC는 컴포넌트 재사용의 핵심 패턴이다"라고 이해했습니다. 요즘은 Hooks로 많이 대체되긴 했지만, 개념은 여전히 중요합니다.
실제 사례 - 캐싱, Rate Limiting
데코레이터 패턴의 활용 예시를 더 들어봅니다.
메모이제이션(캐싱) 데코레이터
def memoize(func):
"""함수 결과를 캐싱하는 데코레이터"""
cache = {}
def wrapper(*args):
if args in cache:
print(f"캐시에서 반환: {args}")
return cache[args]
print(f"계산 중: {args}")
result = func(*args)
cache[args] = result
return result
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(10)) # 처음 계산
print(fibonacci(10)) # 캐시에서 반환
Rate Limiting 데코레이터
import time
from functools import wraps
def rate_limit(max_calls, period):
"""API 호출 횟수를 제한하는 데코레이터"""
calls = []
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# period 이전 호출 기록 제거
calls[:] = [call for call in calls if call > now - period]
if len(calls) >= max_calls:
raise Exception(f"Rate limit exceeded: {max_calls} calls per {period} seconds")
calls.append(now)
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(max_calls=3, period=10) # 10초에 3번까지만 호출 가능
def call_api():
print("API 호출!")
return "응답 데이터"
# 3번까지는 성공
call_api() # OK
call_api() # OK
call_api() # OK
call_api() # Exception: Rate limit exceeded
나는 이런 실제 예시들을 보면서 "데코레이터는 횡단 관심사를 다루는 최고의 도구다"라고 받아들였습니다.
언제 데코레이터를 쓰지 말아야 하나
데코레이터가 만능은 아닙니다. 과도하게 쓰면 오히려 복잡도만 증가합니다.
Over-engineering 경고
# 나쁜 예 - 너무 많은 데코레이터
@login_required
@admin_required
@rate_limit(100, 60)
@cache(timeout=300)
@log
@timer
@retry(max_attempts=3)
@validate_input
@sanitize_output
@deprecated
def simple_function():
return "Hello"
# 실행 흐름 추적이 거의 불가능
이렇게 데코레이터를 10개 중첩하면:
- 디버깅이 지옥
- 실행 순서 파악 어려움
- 성능 오버헤드
- 코드 가독성 저하
데코레이터를 피해야 할 때
- 단순한 로직: 한 번만 쓰는 기능을 굳이 데코레이터로 만들 필요 없음
- 상태가 복잡할 때: 데코레이터가 상태를 가지면 복잡도 급상승
- 디버깅이 중요할 때: 프로덕션 크리티컬한 코드는 명시적이 낫다
- 성능이 중요할 때: 래퍼 레이어가 많으면 오버헤드 발생
나는 이렇게 정리했습니다: "데코레이터는 강력하지만 남용하면 독이 된다. 횡단 관심사에만 쓰고, 핵심 로직은 명시적으로 작성하자."
Java Stream API도 데코레이터였다
Java의 Stream API를 쓰면서 "이거 데코레이터 패턴 아닌가?"라고 생각했는데, 맞았습니다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");
List<String> result = names.stream() // Stream 생성
.filter(name -> name.length() > 3) // 데코레이터: 필터링 기능 추가
.map(String::toUpperCase) // 데코레이터: 대문자 변환 기능 추가
.sorted() // 데코레이터: 정렬 기능 추가
.collect(Collectors.toList()); // 최종 연산
// 결과: [ALICE, CHARLIE, DAVID]
각 메서드(filter, map, sorted)가 Stream을 감싸서 새로운 Stream을 반환합니다. 원본 Stream은 수정되지 않고(불변), 각 단계마다 기능이 추가된 새 Stream이 생성됩니다.
나는 이걸 보고 "함수형 프로그래밍의 파이프라인이 데코레이터 패턴이구나"라고 이해했습니다.
장단점 정리
장점
- 개방-폐쇄 원칙(OCP): 기존 코드 수정 없이 확장 가능
- 런타임 조합: 실행 중에 동적으로 기능 추가/제거 가능
- 단일 책임 원칙(SRP): 각 데코레이터가 하나의 기능만 담당
- 유연한 조합: 여러 데코레이터를 조합해서 다양한 변형 가능
- 횡단 관심사 분리: 로깅, 인증, 캐싱 등을 비즈니스 로직과 분리
단점
- 복잡도 증가: 래퍼가 많아지면 구조 파악이 어려움
- 순서 의존성: 데코레이터 적용 순서가 중요함
- 디버깅 어려움: 스택 트레이스가 복잡해짐
- 성능 오버헤드: 레이어가 많으면 함수 호출 비용 증가
- 객체 정체성 문제: 원본 객체와 데코레이트된 객체가 다름
# 순서가 중요한 예시
@decorator_a
@decorator_b
def func():
pass
# 실행 순서: decorator_a(decorator_b(func))
# b가 먼저 감싸고, a가 그걸 또 감쌈
# 실행은 a → b → func 순서
나는 이걸 정리하면서 "데코레이터는 양날의 검이다. 적절히 쓰면 코드가 우아해지지만, 과하면 복잡도 폭발"이라고 받아들였습니다.
정리하면
데코레이터 패턴을 공부하면서 깨달은 핵심:
- 포장(Wrapping)의 힘: 원본을 수정하지 않고 기능을 추가할 수 있다
- 동적 조합: 상속의 정적 결정과 달리, 런타임에 유연하게 조합 가능
- 조합 > 상속: Composition over Inheritance의 적용
- 개방-폐쇄 원칙: OCP를 구체적으로 구현하는 방법
- 횡단 관심사: 로깅, 인증, 캐싱, 성능 측정 등에 최적
- 다양한 변형: Python
@, Express 미들웨어, React HOC, Java Stream 등 - 적절한 사용: 과도하면 복잡도 증가, 필요한 곳에만 적용
나는 데코레이터 패턴을 이렇게 한 문장으로 정리했습니다:
"상속 대신 조립으로, 컴파일 타임 대신 런타임으로, 수정 대신 확장으로 유연성을 확보하는 패턴"
결국 이거였다. Python의 @login_required, Express의 app.use(), React의 withAuth() 모두 같은 개념이었습니다. 기존 객체/함수를 감싸서 기능을 추가하는 데코레이터 패턴.