중첩 데코레이터와 적용 순서

@A @B @C로 여러 데코레이터를 쌓을 때 적용 순서와 실행 순서가 어떻게 다른지, 순서가 결과에 미치는 영향을 코드 예제로 설명합니다.

· 5 min read · PALDYN Team

지난 글에서 클래스 기반 데코레이터를 살펴봤다. 실무에서는 하나의 함수에 여러 데코레이터를 동시에 적용하는 경우가 많다. 이때 적용 순서실행 순서가 직관과 반대여서 혼란을 일으킨다. 이 글에서 그 규칙을 명확히 정리한다.

적용(wrapping) 순서: 아래에서 위로

@A
@B
@C
def func():
    pass

Python은 이 코드를 다음과 동일하게 처리한다.

func = A(B(C(func)))

함수에서 가장 가까운 @C가 먼저 적용되고, 그 다음 @B, 마지막으로 @A가 적용된다. 위에서 아래가 아니라 아래에서 위로 wrap된다.

실행 순서: 위에서 아래로

wrap 순서와 실행 순서는 반대다. func()를 호출하면 가장 바깥인 A wrapper가 먼저 실행된다.

# 실행 흐름
A_wrapper 전처리
  B_wrapper 전처리
    C_wrapper 전처리
      원본 func 실행
    C_wrapper 후처리
  B_wrapper 후처리
A_wrapper 후처리

쉽게 기억하는 방법: 겹겹이 쌓인 양파 껍질처럼, 벗길 때는 바깥 껍질부터(실행 순서), 입을 때는 안쪽부터(적용 순서).

중첩 데코레이터 적용 및 실행 순서

실험으로 확인하기

from functools import wraps

def make_tag(name):
    def decorator(func):
        @wraps(func)
        def wrapper(*a, **k):
            return f"<{name}>" + func(*a, **k) + f"</{name}>"
        return wrapper
    return decorator

@make_tag("b")   # 마지막 적용 (바깥)
@make_tag("i")   # 첫 번째 적용 (안쪽)
def greet():
    return "Hello"

print(greet())   # <b><i>Hello</i></b>

greet = make_tag("b")(make_tag("i")(greet))와 동일하다. 실행 시 <b> wrapper가 먼저 실행되고, 그 안에서 <i> wrapper, 마지막으로 원본 greet가 실행된다.

중첩 데코레이터 코드 예시

순서가 결과를 바꾸는 경우

데코레이터 순서는 기능적 차이를 만들 수 있다.

import time
from functools import wraps

def log(func):
    @wraps(func)
    def wrapper(*a, **k):
        print(f"[LOG] {func.__name__} 호출")
        return func(*a, **k)
    return wrapper

def timer(func):
    @wraps(func)
    def wrapper(*a, **k):
        start = time.perf_counter()
        result = func(*a, **k)
        print(f"[TIME] {time.perf_counter()-start:.4f}s")
        return result
    return wrapper

# 순서 A: log가 바깥, timer가 안쪽
@log
@timer
def process_a():
    time.sleep(0.01)

# 순서 B: timer가 바깥, log가 안쪽
@timer
@log
def process_b():
    time.sleep(0.01)

process_a()
# [LOG] process_a 호출   ← log가 먼저
# [TIME] 0.0101s          ← timer가 func 실행 시간만 측정

process_b()
# [LOG] process_b 호출   ← log 실행 시간도 포함됨
# [TIME] 0.0102s          ← timer가 log + func 시간 측정

timer가 바깥(@timer @log)이면 log 실행 시간까지 포함하여 측정한다. timer가 안쪽(@log @timer)이면 원본 함수 실행 시간만 측정한다. 어느 쪽이 맞는지는 의도에 따라 다르다.

__name__과 중첩 데코레이터

각 데코레이터에 @wraps를 올바르게 적용했다면, 아무리 많이 쌓아도 __name__은 원본을 가리킨다.

@log
@timer
def process():
    """처리 함수"""
    pass

print(process.__name__)   # process
print(process.__doc__)    # 처리 함수
print(process.__wrapped__)  # timer의 wrapper
# inspect.unwrap(process)로 원본까지 도달 가능

__wrapped__는 바로 안쪽 함수를 가리킨다. inspect.unwrap으로 체인 전체를 따라가면 원본 process에 도달할 수 있다.

실무 권장 순서

여러 데코레이터를 함께 쓸 때 보편적으로 권장하는 순서가 있다.

@login_required    # 가장 바깥: 진입 전 인증 체크
@cache(ttl=60)    # 그 다음: 캐시 hit이면 내부 실행 불필요
@log_call          # 마지막 바깥: 실제 처리 전 로깅
def get_user(uid):
    ...

외부 관심사(인증, 캐시)를 바깥에, 내부 관심사(로깅, 타이밍)를 안쪽에 두는 것이 일반적인 패턴이다. 팀마다 컨벤션이 다를 수 있으므로 일관성이 중요하다.


지난 글: 클래스로 만드는 데코레이터

다음 글: 상태를 가진 데코레이터


읽어주셔서 감사합니다. 😊