Tech </> tech.log
· - ·
PythonNormal

[Python][중급] Decorator

우아하게 Decorator를 이해하고 사용하기

Python Decorator, 함수를 감싸는 우아한 기술

들어가며: 반복되는 코드의 고통

새벽 3시, 당신은 모니터를 응시하고 있다. 또 버그가 발생했다. 로그를 확인해보니 문제는 명확했다. 하지만 로그를 추가할 함수가 200개가 넘는다. 각 함수마다 logger.info("함수 시작..."), logger.info("함수 끝...")을 카피하기 시작한다.

3시간 후, 겨우 작업을 마쳤다. 그런데 다음 날 아침, 팀장이 말한다. "로그 포맷을 바꿔야 할 것 같은데요?"

당신의 하루가 다시 시작된다. 200개 함수를 다시 수정하면서.

이런 악몽 같은 상황을 방지하기 위해 Python은 Decorator라는 우아한 해결책을 제시한다.


Decorator의 탄생: 왜 필요했을까?

소프트웨어 개발의 영원한 딜레마

소프트웨어 개발에는 두 가지 서로 상충하는 요구사항이 있다:

  1. DRY (Don't Repeat Yourself): 같은 코드를 반복하지 말라
  2. 관심사의 분리 (Separation of Concerns): 각 코드는 하나의 명확한 목적만 가져야 한다

예를 들어, 당신이 API 서버를 만든다고 생각해보자. 각 API 함수는 "비즈니스 로직"을 처리해야 한다. 하지만 실제로는 이것만으로 끝나지 않는다:

  • 사용자가 로그인했는지 확인해야 한다 (인증)
  • 사용자가 이 작업을 할 권한이 있는지 확인해야 한다 (권한 검사)
  • 함수 실행 시간을 측정해야 한다 (성능 모니터링)
  • 에러가 발생하면 로그를 남겨야 한다 (로깅)
  • 데이터베이스 트랜잭션을 관리해야 한다

순수한 비즈니스 로직:

def transfer_money(from_account, to_account, amount):
    from_account.balance -= amount
    to_account.balance += amount

현실의 비즈니스 로직:

def transfer_money(from_account, to_account, amount):
    # 로그인 확인
    if not current_user.is_authenticated:
        raise PermissionError("로그인이 필요합니다")

    # 권한 확인
    if from_account.owner != current_user:
        raise PermissionError("권한이 없습니다")

    # 성능 측정 시작
    start_time = time.time()

    # 트랜잭션 시작
    transaction = db.begin_transaction()

    try:
        # 드디어 진짜 로직
        from_account.balance -= amount
        to_account.balance += amount

        # 트랜잭션 커밋
        transaction.commit()

        # 로그 기록
        logger.info(f"이체 완료: {amount}원")

        # 성능 측정 종료
        elapsed = time.time() - start_time
        logger.info(f"실행 시간: {elapsed}초")

    except Exception as e:
        transaction.rollback()
        logger.error(f"이체 실패: {e}")
        raise

핵심 로직 2줄이 30줄이 되었다. 이게 200개 함수에서 반복된다면? 악몽이 현실이 된다.

Decorator의 철학

Decorator는 이런 문제에 대한 Python의 답변이다. 핵심 아이디어는 간단하다:

"함수의 본질적인 기능은 그대로 두고, 그 주변에 부가 기능을 '감싸는' 방법이 있다면 어떨까?"

마치 선물을 포장하는 것처럼. 선물 자체는 변하지 않지만, 포장지를 덧대면서 더 멋져 보이게 만드는 것처럼 말이다.

@requires_authentication
@check_permissions
@measure_performance
@with_transaction
@log_execution
def transfer_money(from_account, to_account, amount):
    from_account.balance -= amount
    to_account.balance += amount

이제 우리의 함수는 다시 본질로 돌아왔다. 부가 기능들은 모두 "포장지"가 되었다.


Python 함수의 철학

Decorator를 이해하려면 먼저 Python이 함수를 어떻게 다루는지 알아야 한다.

First-Class Object: 함수도 객체다

대부분의 프로그래밍 언어에서 함수는 특별하다. 하지만 Python은 다르다. Python에서 함수는 그냥 객체일 뿐이다. 정수나 문자열과 다를 바 없는.

이게 무슨 의미일까?

def greet(name):
    return f"안녕, {name}!"

# 함수를 변수에 담을 수 있다
say_hello = greet

# 함수를 인자로 전달할 수 있다
def execute_twice(func, arg):
    func(arg)
    func(arg)

execute_twice(greet, "철수")

이건 마치 레고 블록 같다. 레고 블록을 다른 상자에 담을 수도 있고, 친구에게 줄 수도 있고, 다른 블록 위에 쌓을 수도 있다. 함수도 마찬가지다.

Closure: 함수가 환경을 기억한다

더 신기한 건, Python의 함수는 기억력이 있다는 것이다.

def make_multiplier(n):
    def multiplier(x):
        return x * n  # n을 "기억"한다
    return multiplier

times_3 = make_multiplier(3)
times_5 = make_multiplier(5)

print(times_3(10))  # 30
print(times_5(10))  # 50

make_multiplier 함수는 이미 종료되었다. 보통의 함수라면, 함수가 끝나면 모든 변수는 사라진다. 하지만 multiplier 함수는 여전히 n을 기억하고 있다.

이건 마치 타임캡슐 같다. 특정 시점의 상태를 "봉인"해서 나중에도 사용할 수 있게 하는 것.

이런 **일급 함수(First-Class Function)**와 **클로저(Closure)**의 조합이 Decorator를 가능하게 만든다.


Decorator의 본질: 함수를 감싸는 함수

Decorator의 핵심은 놀랍도록 단순하다:

함수를 받아서, 그 함수를 감싼 새로운 함수를 돌려준다.

이게 전부다. 정말로.

개념적 이해

생각해보자. 당신이 카페를 운영한다. 커피를 만드는 기본 레시피가 있다:

def make_coffee():
    return "에스프레소"

하지만 고객이 "얼음 추가"를 원한다면? 레시피 자체를 바꾸고 싶지 않다. 대신 레시피를 "감싸는" 새로운 레시피를 만든다:

def add_ice(coffee_maker):
    def wrapped_coffee():
        coffee = coffee_maker()  # 원래 레시피대로 커피 만들고
        return coffee + " + 얼음"  # 얼음 추가
    return wrapped_coffee

# 이제 얼음이 추가된 커피 제조법을 만든다
iced_coffee = add_ice(make_coffee)
print(iced_coffee())  # "에스프레소 + 얼음"

이게 바로 Decorator의 원리다. @ 기호는 단지 이 과정을 예쁘게 표현하는 **문법 설탕(Syntactic Sugar)**일 뿐이다:

@add_ice
def make_coffee():
    return "에스프레소"

# 이건 사실 이렇게 쓴 것과 같다:
# make_coffee = add_ice(make_coffee)

왜 functools.wraps를 써야 할까: 정체성의 문제

Decorator를 처음 배울 때 가장 혼란스러운 부분 중 하나가 @wraps다. 왜 필요한 걸까?

정체성을 잃어버린 함수

다시 카페 비유로 돌아가자. 당신이 "아메리카노" 레시피를 만들었는데, 여러 단계를 거쳐 포장되었다고 상상해보자:

  1. 얼음 추가 → "얼음 추가된 음료"
  2. 휘핑크림 추가 → "휘핑크림 추가된 음료"
  3. 캬라멜 시럽 추가 → "캬라멜 시럽 추가된 음료"

최종 결과물을 보면, "이게 원래 아메리카노였나?" 알 수가 없다. 레시피의 원래 이름이 사라져버렸기 때문이다.

Python 함수도 마찬가지다:

def log(func):
    def wrapper(*args, **kwargs):
        print(f"함수 실행")
        return func(*args, **kwargs)
    return wrapper

@log
def calculate(x, y):
    """두 수를 더합니다"""
    return x + y

print(calculate.__name__)  # "wrapper" (원래는 "calculate")
print(calculate.__doc__)   # None (원래는 "두 수를 더합니다")

함수가 자신의 정체성을 잃어버렸다. 디버깅할 때, 에러 메시지에 "wrapper"가 수십 개 나온다면? 악몽이다.

@wraps: 정체성을 보존하는 마법

@wraps는 이 문제를 해결한다. 원본 함수의 메타데이터(이름, 문서, 타입 힌트 등)를 래퍼 함수에 복사한다:

from functools import wraps

def log(func):
    @wraps(func)  # 이 한 줄이 정체성을 보존한다
    def wrapper(*args, **kwargs):
        print(f"함수 실행")
        return func(*args, **kwargs)
    return wrapper

이제 함수는 포장되었어도 여전히 자신이 누구인지 기억한다.


Decorator Factory: 설정 가능한 포장지

문제: Decorator에 옵션을 주고 싶다

로그 레벨을 지정하고 싶다고 해보자:

@log(level="DEBUG")  # 이렇게 쓰고 싶은데...
def my_function():
    pass

문제는 @log는 함수를 받아야 하는데, 여기서는 level="DEBUG"를 먼저 받아야 한다는 것.

해결책: Decorator를 반환하는 함수

한 겹 더 감싸면 된다:

def log(level="INFO"):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] {func.__name__} 실행")
            return func(*args, **kwargs)
        return wrapper
    return decorator

동작 순서:

  1. log(level="DEBUG") 호출 → decorator 함수 반환
  2. 반환된 decoratormy_function을 받음
  3. wrapper가 최종 결과물

괄호가 있으면 Decorator Factory, 없으면 Decorator라고 생각하면 쉽다.

@log              # Decorator - 함수를 직접 받음
@log()            # Decorator Factory - 괄호가 있으면 팩토리
@log(level="DEBUG")  # Decorator Factory - 인자와 함께

실제로 자주 보는 Decorator Factory

# Flask
@app.route("/users", methods=["GET"])
def get_users(): ...

# pytest
@pytest.mark.parametrize("x,y", [(1, 2), (3, 4)])
def test_add(x, y): ...

# dataclass
@dataclass(frozen=True)
class Point:
    x: int
    y: int

클래스 기반 Decorator: `__call__`의 마법

함수로만 Decorator를 만들 수 있는 건 아니다. 클래스도 Decorator가 될 수 있다.

왜 클래스를 쓸까?

클래스 기반 Decorator는 다음 상황에서 유용하다:

  • 상태를 유지해야 할 때 (호출 횟수, 캐시 등)
  • 복잡한 로직을 메서드로 분리하고 싶을 때
  • 인스턴스별로 독립적인 상태가 필요할 때

기본 구조

Python에서 객체를 함수처럼 호출하려면 __call__ 메서드를 구현하면 된다:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0
        # wraps와 동일한 효과
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__}{self.count}번 호출됨")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()  # say_hello이 1번 호출됨 \n Hello!
say_hello()  # say_hello이 2번 호출됨 \n Hello!
print(say_hello.count)  # 2

함수 기반 Decorator에서는 count를 유지하려면 클로저나 전역 변수를 써야 하지만, 클래스는 인스턴스 속성으로 자연스럽게 상태를 관리한다.

인자가 있는 클래스 Decorator

Decorator Factory처럼 인자를 받으려면 구조가 달라진다:

class Retry:
    def __init__(self, max_attempts=3, delay=1):
        self.max_attempts = max_attempts
        self.delay = delay

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(self.max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    print(f"시도 {attempt + 1} 실패, {self.delay}초 후 재시도")
                    time.sleep(self.delay)
            raise last_exception
        return wrapper

@Retry(max_attempts=5, delay=2)
def unstable_api_call():
    # 불안정한 외부 API 호출
    ...

구조의 차이:

유형 __init__이 받는 것 __call__이 받는 것
인자 없는 Decorator func *args, **kwargs
인자 있는 Decorator 설정값들 func

실전 예시: 메모이제이션

class Memoize:
    def __init__(self, func):
        self.func = func
        self.cache = {}
        functools.update_wrapper(self, func)

    def __call__(self, *args):
        if args not in self.cache:
            self.cache[args] = self.func(*args)
        return self.cache[args]

    def clear_cache(self):
        self.cache.clear()

@Memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 캐시 덕분에 빠르게 계산
fibonacci.clear_cache()  # 캐시 초기화 메서드도 사용 가능

Async Decorator: 비동기 세계의 포장지

Python 3.5 이후 async/await가 도입되면서, 비동기 함수를 위한 Decorator도 필요해졌다.

문제: 일반 Decorator는 async와 호환되지 않는다

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("시작")
        result = func(*args, **kwargs)  # 코루틴 객체가 반환됨, 실행 안 됨!
        print("끝")
        return result
    return wrapper

@log
async def fetch_data():
    await asyncio.sleep(1)
    return "data"

# 실행하면 "시작", "끝"이 먼저 출력되고, 실제 함수는 나중에 실행됨

async 함수를 호출하면 즉시 실행되는 게 아니라 코루틴 객체가 반환된다. await를 해야 실제로 실행된다.

해결책: async wrapper 사용

def log_async(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        print(f"[시작] {func.__name__}")
        result = await func(*args, **kwargs)  # await로 실제 실행
        print(f"[끝] {func.__name__}")
        return result
    return wrapper

@log_async
async def fetch_data():
    await asyncio.sleep(1)
    return "data"

동기/비동기 함수 모두 지원하기

실무에서는 동기, 비동기 함수 모두에 적용할 수 있는 Decorator가 필요할 때가 있다:

import asyncio
import inspect
from functools import wraps

def universal_log(func):
    @wraps(func)
    async def async_wrapper(*args, **kwargs):
        print(f"[시작] {func.__name__}")
        result = await func(*args, **kwargs)
        print(f"[끝] {func.__name__}")
        return result

    @wraps(func)
    def sync_wrapper(*args, **kwargs):
        print(f"[시작] {func.__name__}")
        result = func(*args, **kwargs)
        print(f"[끝] {func.__name__}")
        return result

    if asyncio.iscoroutinefunction(func):
        return async_wrapper
    return sync_wrapper

# 동기 함수에도 적용
@universal_log
def sync_task():
    return "sync result"

# 비동기 함수에도 적용
@universal_log
async def async_task():
    await asyncio.sleep(0.1)
    return "async result"

실전 예시: 비동기 재시도 Decorator

def async_retry(max_attempts=3, delay=1, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_attempts):
                try:
                    return await func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts - 1:
                        print(f"시도 {attempt + 1} 실패, {delay}초 후 재시도")
                        await asyncio.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@async_retry(max_attempts=3, delay=2, exceptions=(ConnectionError, TimeoutError))
async def fetch_from_api(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.json()

실전 사례: 트랜잭션 관리 Decorator

이론은 충분하다. 실제 프로덕션 코드에서 어떻게 사용되는지 보자.

문제 상황

당신은 백엔드 개발자다. 모든 데이터베이스 작업에는 다음이 필요하다:

  1. 커넥션 풀에서 커넥션 가져오기
  2. 트랜잭션 시작하기
  3. 비즈니스 로직 실행
  4. 성공하면 커밋, 실패하면 롤백
  5. 커넥션 반환하기

이 패턴이 수백 개 함수에서 반복된다. 카피 지옥이 시작된다.

Decorator로 해결

from functools import wraps

# pool은 애플리케이션 초기화 시 생성된 커넥션 풀
def with_transaction():
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            async with pool.connection() as conn:
                async with conn.transaction():
                    dao = DAO(conn)
                    return await func(dao, *args, **kwargs)
        return wrapper
    return decorator

이제 비즈니스 로직만 집중할 수 있다:

@with_transaction()
async def transfer_money(dao, from_id, to_id, amount):
    # 순수한 비즈니스 로직만!
    await dao.decrease_balance(from_id, amount)
    await dao.increase_balance(to_id, amount)
    await dao.log_transfer(from_id, to_id, amount)

트랜잭션 관리? 커넥션 관리? 에러 처리? 모두 Decorator가 알아서 한다.

왜 이게 중요한가

  1. 관심사의 분리: 비즈니스 로직과 인프라 관심사가 분리되었다
  2. 재사용성: 한 번 작성한 Decorator를 모든 곳에서 사용
  3. 유지보수성: 트랜잭션 로직 변경? Decorator 한 곳만 수정
  4. 가독성: 함수를 보면 무슨 일을 하는지 바로 이해됨

Decorator의 어두운 면: 주의사항

Decorator는 강력하지만, 잘못 사용하면 독이 된다.

1. 숨겨진 마법은 혼란을 부른다

@magic_decorator
def my_function(x):
    return x * 2

이 함수는 겉보기엔 단순해 보인다. 하지만 @magic_decorator가 실제로 뭘 하는지 모르면? 디버깅이 악몽이 된다.

원칙: Decorator는 명시적이고 예측 가능해야 한다. 이름만 보고 동작을 유추할 수 있게 @log_, @require_, @cache_ 같은 prefix를 사용하라.

2. 순환 임포트의 늪

Decorator는 임포트 타임에 실행된다. 순환 임포트에 매우 취약하다:

# users.py
from auth import requires_login

@requires_login
def get_user(): ...

# auth.py
from users import get_user  # 순환 임포트!

def requires_login(func): ...

원칙: Decorator는 독립적인 모듈에 분리하라.

3. 성능 오버헤드

모든 Decorator는 함수 호출을 감싸기 때문에 오버헤드가 있다. Hot path(자주 실행되는 코드)에서는 주의해야 한다.

@log
@check_permissions
@validate_input
@measure_performance
def critical_function():  # 초당 10,000번 호출됨
    return "result"

함수 하나 호출에 4개의 래퍼를 거쳐야 한다. 성능이 중요하다면 신중하게 사용하라.

4. 디버깅의 어려움

Decorator가 중첩되면 스택 트레이스가 복잡해진다. 문제가 발생했을 때 어느 레이어에서 문제가 생겼는지 파악하기 어려울 수 있다.

# 스택 트레이스에 wrapper가 여러 개 나타남
Traceback:
  File "...", in wrapper
  File "...", in wrapper
  File "...", in wrapper
  ...

@wraps를 사용하면 최소한 함수 이름은 보존되므로, 반드시 사용하자.


Decorator를 넘어서: 패러다임의 전환

Decorator를 마스터하면, 코드를 보는 관점이 바뀐다.

Before: 절차적 사고

def process_order():
    if not auth():
        return
    if not permitted():
        return
    log("시작")
    result = do_work()
    log("끝")
    return result

After: 선언적 사고

@requires_auth
@requires_permission
@log_execution
def process_order():
    return do_work()

이는 단순히 코드를 줄이는 것 이상의 의미가 있다. "어떻게(How)"에서 "무엇을(What)"로 사고방식이 전환된 것이다.

함수를 볼 때, 이제 우리는 묻는다:

  • "이 함수는 어떻게 동작하지?"
  • "이 함수는 무엇을 하지?"

Decorator는 철학이다

Decorator는 단순한 문법적 기능이 아니다. 이는 **"관심사를 어떻게 분리할 것인가"**에 대한 Python의 철학적 답변이다.

좋은 코드는 레이어 케이크와 같다. 각 층은 명확하게 분리되어 있지만, 함께 조화를 이룬다. Decorator는 이 레이어들을 우아하게 쌓는 방법을 제공한다.

핵심 교훈:

  1. 함수는 하나의 일만 잘해야 한다
  2. 부가 기능은 감싸서 추가한다
  3. 명시적이고 예측 가능하게 작성한다
  4. 재사용성을 최우선으로 한다
  5. @wraps는 항상 사용한다

다음에 200개 함수에 같은 코드를 카피하려는 순간이 온다면, 멈춰서 생각하라:

"이걸 Decorator로 만들 수 있지 않을까?"

그 한 줄의 질문이, 당신의 코드를 다음 레벨로 끌어올릴 것이다.


더 읽을거리