데코레이터와 클로저

src/content/documents/python/python-decorators-and-closures-in-practice.json

결론: 클로저로 상태를 가두고 데코레이터로 공통 관심사를 분리한다

클로저는 함수가 자신이 정의된 환경의 변수를 기억해 나중에 호출될 때도 그 값을 계속 참조하는 특성이다. 데코레이터는 함수를 인자로 받아 새 함수로 감싸는 함수이며, 대부분 클로저로 구현된다. 데이터 분석 파이프라인에서는 함수마다 실행 시간 측정·재시도·로깅 같은 코드를 반복해서 넣는 대신, 그 관심사를 데코레이터 하나로 분리해 재사용한다. 이때 원본 함수의 이름과 설명이 사라지지 않도록 functools.wraps를 함께 쓴다.

클로저: 함수가 자신의 환경을 기억하는 구조

함수 안에서 정의한 내부 함수가 바깥 함수의 지역 변수를 참조하면서 그 함수를 값으로 반환하면, 바깥 함수의 실행이 끝난 뒤에도 내부 함수는 그 변수에 계속 접근할 수 있다. 이 내부 함수를 클로저라고 부른다.

def multiplier(factor):
    def multiply(x):
        return x * factor  # 바깥 함수의 factor를 계속 기억한다
    return multiply

double = multiplier(2)
triple = multiplier(3)
print(double(10), triple(10))
20 30

multiplier(2)가 반환된 뒤에도 doublefactor=2라는 환경을 그대로 들고 있다. doubletriple은 같은 코드로 만들어졌지만 서로 다른 factor를 독립적으로 기억하므로 매번 새로운 클로저가 생긴다.

nonlocal로 바깥 변수 값 바꾸기

클로저는 기본적으로 바깥 변수를 읽기만 한다. 내부 함수에서 그 값을 다시 대입하려면 nonlocal로 지역 변수가 아니라 바깥 함수의 변수임을 명시해야 한다.

def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
print(counter(), counter(), counter())
1 2 3

nonlocal 없이 함수 안에서 count += 1을 그대로 실행하면 count를 새로운 지역 변수로 취급해 UnboundLocalError가 난다. 함수·클래스·모듈의 유효 범위 규칙은 Python 공식 문서의 실행 모델에서 확인할 수 있다 (2026-08-10 확인).

흔한 함정: 반복문에서 늦은 바인딩

클로저는 변수의 값이 아니라 변수 자체를 참조한다. 반복문 안에서 클로저를 여러 개 만들면 함수가 호출되는 시점의 최종 값을 모두 같이 바라본다.

funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])  # 기대와 다르게 모두 마지막 i를 가리킨다

# 해결: 기본 인자로 그 순간의 값을 즉시 바인딩한다
funcs_fixed = []
for i in range(3):
    funcs_fixed.append(lambda i=i: i)

print([f() for f in funcs_fixed])
[2, 2, 2]
[0, 1, 2]

반복문에서 만든 함수 목록이 모두 같은 값을 반환한다면 늦은 바인딩을 의심한다. lambda i=i: i처럼 기본 인자에 현재 값을 넘겨 그 시점의 값을 함수 안에 고정한다.

데코레이터: 함수를 감싸 동작을 추가하는 함수

데코레이터는 함수를 인자로 받아 새 함수(대개 클로저)를 반환한다. @decorator 문법은 func = decorator(func)를 줄여 쓴 것과 같다.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("함수 실행 전")
        result = func(*args, **kwargs)
        print("함수 실행 후")
        return result
    return wrapper

@my_decorator
def add(a, b):
    return a + b

print(add(3, 5))
함수 실행 전
함수 실행 후
8

*args, **kwargs로 내부 함수를 정의하면 원본 함수의 매개변수 개수와 이름이 무엇이든 그대로 전달할 수 있어, 데코레이터를 서로 다른 함수에 재사용할 수 있다.

실무 예제: 실행 시간 측정 데코레이터

데이터 분석 파이프라인에서는 어느 단계가 느린지 알아야 병목을 찾을 수 있다. 매 함수마다 시간 측정 코드를 반복하는 대신 데코레이터로 분리한다.

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} 실행 시간: {elapsed:.4f}초")
        return result
    return wrapper

@timer
def load_rows(n):
    """가짜 데이터를 n개 만든다."""
    return list(range(n))

rows = load_rows(1000)
print(load_rows.__name__)
print(load_rows.__doc__)
load_rows 실행 시간: 0.0001초
load_rows
가짜 데이터를 n개 만든다.

실행 시간 수치는 환경마다 다르지만, 중요한 것은 load_rows.__name__"wrapper"가 아니라 "load_rows"로 그대로 남는다는 점이다. 그 이유는 다음 절에서 다룬다.

functools.wraps가 필요한 이유

데코레이터가 함수를 감싸면 바깥에서 보이는 함수는 원본이 아니라 내부의 wrapper다. functools.wraps를 쓰지 않으면 __name__·__doc__ 같은 메타데이터가 wrapper의 것으로 바뀐다.

def bare_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bare_decorator
def sample():
    """샘플 함수."""
    return 1

print(sample.__name__)
print(sample.__doc__)
wrapper
None

원본 이름과 설명이 사라지면 디버깅 로그, 자동 문서화 도구, help()가 모두 엉뚱한 정보를 보여준다. wrapper 정의 바로 위에 @functools.wraps(func)를 붙이면 __name__·__doc__·__module__ 등을 원본 함수에서 복사해 온다. 위 타이머 예제가 load_rows라는 이름을 유지한 것도 이 데코레이터 안에 functools.wraps(func)가 있었기 때문이다. 자세한 동작은 Python 공식 문서의 functools.wraps를 참고한다 (2026-08-10 확인).

매개변수를 받는 데코레이터: 재시도 로직

외부 API·파일 시스템에서 데이터를 읽어 올 때는 일시적인 오류가 흔하다. 재시도 횟수를 데코레이터의 인자로 받으려면 데코레이터를 한 겹 더 감싼다: 바깥 함수는 설정을 받고, 그 안에서 실제 데코레이터를 반환한다.

import functools
import time

def retry(times=3, delay=0.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ValueError as exc:
                    last_error = exc
                    print(f"{func.__name__} 시도 {attempt}/{times} 실패: {exc}")
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

calls = {"n": 0}

@retry(times=3)
def flaky_fetch():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ValueError("일시적 오류")
    return "ok"

print(flaky_fetch())
flaky_fetch 시도 1/3 실패: 일시적 오류
flaky_fetch 시도 2/3 실패: 일시적 오류
ok

retry(times=3)는 먼저 decorator 함수를 클로저로 만들어 timesdelay를 기억하게 하고, 그 decorator가 실제 함수를 감싼다. 모든 시도가 실패하면 마지막 예외를 그대로 다시 던져 호출한 쪽이 실패를 알 수 있게 한다. 재시도 대상 예외를 넓게 잡으면 프로그램 로직 오류까지 감춰버리므로, 여기서는 ValueError처럼 실제로 재시도할 만한 예외만 잡는다.

데코레이터 여러 개 쌓기

데코레이터는 아래에서 위로 적용되고, 실행은 위에서 아래로 시작해 안쪽 함수를 거쳐 다시 바깥으로 나온다.

def decorator1(func):
    def wrapper(*args, **kwargs):
        print("[decorator1] 실행 전")
        result = func(*args, **kwargs)
        print("[decorator1] 실행 후")
        return result
    return wrapper

def decorator2(func):
    def wrapper(*args, **kwargs):
        print("[decorator2] 실행 전")
        result = func(*args, **kwargs)
        print("[decorator2] 실행 후")
        return result
    return wrapper

@decorator1
@decorator2
def say_hello():
    print("안녕하세요!")

say_hello()
[decorator1] 실행 전
[decorator2] 실행 전
안녕하세요!
[decorator2] 실행 후
[decorator1] 실행 후

say_hello는 먼저 decorator2로 감싸지고, 그 결과가 다시 decorator1로 감싸진다. 그래서 실행 순서는 decorator1의 시작 → decorator2의 시작 → 원본 함수 → decorator2의 종료 → decorator1의 종료 순이 된다. 로깅·인증·캐싱처럼 순서가 결과에 영향을 주는 데코레이터를 쌓을 때는 이 순서를 먼저 그려 보고 붙인다.

클로저·데코레이터 활용 정리

패턴

쓰는 이유

클로저

함수 호출 사이에 상태를 안전하게 가둔다

카운터, 배율 등 설정을 기억하는 함수 생성기

단순 데코레이터

함수 실행 앞뒤에 공통 동작을 끼운다

실행 시간 측정, 호출 로깅

파라미터화 데코레이터

데코레이터 동작 자체를 설정 가능하게 만든다

재시도 횟수, 캐시 TTL 지정

functools.wraps

감싸진 함수의 정체성을 보존한다

디버깅, 문서화, introspection

자주 발생하는 문제

  • nonlocal을 빠뜨리고 클로저 안에서 바깥 변수에 대입하면 그 변수를 새 지역 변수로 취급해 UnboundLocalError가 난다.

  • 반복문에서 만든 클로저·람다가 모두 같은 값을 반환하면 늦은 바인딩이다. 기본 인자로 그 순간의 값을 고정한다.

  • functools.wraps를 빠뜨리면 __name__·__doc__가 wrapper의 것으로 바뀌어 로그와 문서화가 엉뚱한 이름을 보여준다.

  • 데코레이터 안에서 예외를 무조건 삼키면 실패한 호출이 성공한 것처럼 보인다. 재시도처럼 의도한 경우가 아니면 예외를 그대로 다시 던진다.

  • 클로저가 큰 DataFrame이나 연결 객체를 계속 붙잡고 있으면 그 객체가 예상보다 오래 메모리에 남는다. 클로저에 넣는 값은 필요한 만큼만 좁힌다.

다음 글: 예외 처리와 사용자 정의 예외

다음 글에서는 try/except/else/finally의 역할을 정리하고, 이번 글의 재시도 데코레이터처럼 실패를 다루는 코드에 어떤 예외를 잡을지, 파이프라인 실패 경계를 어디에 둘지, 사용자 정의 예외를 언제 만들지를 다룬다.

참고 자료

댓글 0

댓글을 불러오는 중…