python decorator 가 너무 헷갈려

좋아, 데코레이터를 “마법”이 아니라 “함수로 함수를 꾸미는 평범한 기술”로 풀어서 볼게. 차근차근 따라가면 헷갈림이 사라진다.

1) 한 문장 정의

데코레이터는 “함수를 받아서 새로운 함수를 반환하는 함수”다. @something은 단지 설탕 문법(syntax sugar)이고, 실제로는 func = something(func)과 같다.

2) 가장 작은 예제부터

먼저 아무 장식도 없는 함수.

def greet(name):
    return f"Hello, {name}"

이제 이 함수를 받아 살짝 바꾸는 “데코레이터 함수”를 만든다.

def shout_decorator(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)     # 원래 함수 실행
        return result.upper()              # 결과를 변형
    return wrapper

적용은 두 가지 방법이 있다.

# 방법 A: @ 문법
@shout_decorator
def greet(name):
    return f"Hello, {name}"

# 방법 B: 수동 적용 (동치)
def greet(name):
    return f"Hello, {name}"
greet = shout_decorator(greet)

이제 greet("world")는 "HELLO, WORLD"를 반환한다.

핵심 포인트는 wrapper가 원본 함수를 감싸고 있다는 것.

3) 꼭 쓰는 도구: functools.wraps

데코레이터를 쓰면 원래 함수의 이름, docstring이 wrapper로 바뀌어 버린다. 이를 바로잡는 게 functools.wraps.

from functools import wraps

def shout_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

이제 greet.__name__, help(greet) 등이 원본과 일치한다. 실무에선 wraps를 항상 쓴다고 생각하자.

4) 인자가 있는 데코레이터

가끔 데코레이터 자체에도 옵션이 필요하다. 이때는 “함수를 만드는 함수”를 한 번 더 감싼다.

from functools import wraps

def repeat(times=2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                last = func(*args, **kwargs)
            return last
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("ping")

정신승리 포인트: @repeat(3)는 즉시 repeat(3)을 호출해 “진짜 데코레이터(decorator)”를 만든 다음 그게 ping을 감싼다.

5) 적용 순서(스택)

여러 데코레이터를 쌓으면, 코드에선 위에서 아래로 읽히지만 적용은 아래에서 위로 된다.

@A
@B
def f(): ...

이는 f = A(B(f))와 같다. 즉 B가 먼저 감싸고, 그 다음 A가 감싼다.

6) 자주 쓰는 패턴들

로그/타이밍

import time
from functools import wraps

def timeit(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            dt = time.perf_counter() - t0
            print(f"{func.__name__} took {dt:.4f}s")
    return wrapper

메모이제이션(이미 표준 라이브러리에 있음)

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

권한 체크(개념 예시)

from functools import wraps

def require_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if role not in user.roles:
                raise PermissionError("nope")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

프로퍼티/정적/클래스 메서드(내장 데코레이터)

class Foo:
    @staticmethod
    def s(): ...
    @classmethod
    def c(cls): ...
    @property
    def name(self): ...

클래스 데코레이터(클래스를 받아 클래스/인스턴스를 돌려줌)

def add_repr(cls):
    def __repr__(self):
        fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({fields})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x, y): self.x, self.y = x, y

7) 비동기 함수 꾸미기(중요 포인트)

async def 함수를 감쌀 땐 wrapper도 async def여야 await를 안전하게 전달할 수 있다.

from functools import wraps
import asyncio

def async_timeit(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        t0 = asyncio.get_event_loop().time()
        try:
            return await func(*args, **kwargs)   # await 잊지 말기
        finally:
            dt = asyncio.get_event_loop().time() - t0
            print(f"{func.__name__} took {dt:.4f}s")
    return wrapper

동기/비동기 둘 다 지원하려면 오버로드하거나, 감싸기 전에 asyncio.iscoroutinefunction(func)로 분기한다.

8) 흔한 함정들 체크리스트

함수 시그니처 손실 문제: wraps로 메타데이터는 살리지만, 정교한 시그니처(IDE 인텔리센스)까지 유지하려면 inspect.signature를 쓰거나 wrapt 같은 라이브러리를 고려.

인자 바인딩: wrapper는 보통 *args, **kwargs로 받아 원본에 그대로 전달한다. 특정 인자를 바꾸면 서프라이즈 발생.

평가 시점: 데코레이터는 함수 정의 시점에 실행된다. 모듈 import만 해도 동작할 수 있다는 뜻. 사이드이펙트는 신중히.

상태 보관: 카운터 같은 상태가 필요하면 nonlocal 변수나 functools.partial, 또는 클래스형 데코레이터를 쓴다.

from functools import wraps

def counted(func):
    count = 0
    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"called {count} times")
        return func(*args, **kwargs)
    return wrapper

타입 힌트: 데코레이터는 타입 체커에 어려움을 준다. typing.Callable, TypeVar, ParamSpec, Concatenate를 적절히 쓰면 정적 분석 품질이 좋아진다.

from typing import Callable, TypeVar, ParamSpec
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper

9) 마음속 모델(헷갈림 방지 요약)

함수는 1급 객체다. 즉, 숫자처럼 전달·반환할 수 있다. 데코레이터는 “함수를 입력으로 받아 함수를 출력하는 공장”이다. @는 그 공장을 짧게 부르는 표기다. wrapper가 원본 함수를 실행하기 전후로 무언가를 덧붙이거나, 결과를 바꾸거나, 실행 자체를 막을 수도 있다.

10) 손에 익히는 미니 연습문제 5개

  1. time.sleep을 써서 어떤 함수의 실행 시간을 출력하는 timeit 데코레이터를 직접 작성.

  2. 실패하면 최대 3번까지 재시도하는 retry(times=3, exceptions=(Exception,)) 데코레이터 작성.

  3. 순수 함수에 한해 간단한 캐시를 붙이는 memoize 데코레이터 작성(딕셔너리 키는 인자 튜플).

  4. 비동기 함수용 async_retry 버전 구현(await 필수).

  5. 접근 제한: 특정 환경변수(APP_ENV=prod)일 때만 함수를 실행하고 아니면 경고만 출력하는 데코레이터 작성.

원하는 패턴이 있으면 그대로 구현 예시를 더 만들어 줄게.

Categories:

Updated: