LLM WikiAccess-protected knowledge portal
← 스터디 홈
3편 · 약 20분

함수형 도구와 클로저·데코레이터

함수는 일급 객체다

Python에서 함수는 일급 객체(first-class object)다. 변수에 담고, 다른 함수의 인자로 넘기고, 반환값으로 돌려줄 수 있다. 이 특성이 클로저와 데코레이터의 기반이다.

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

# 함수를 변수에 대입
say_hello = greet
print(say_hello("Python"))  # 안녕, Python!

# 함수를 인자로 넘기기
def apply(fn, value):
    return fn(value)

print(apply(greet, "세상"))  # 안녕, 세상!

# 함수를 반환값으로 돌려주기
def make_greeter(greeting: str):
    def inner(name: str) -> str:
        return f"{greeting}, {name}!"
    return inner  # 함수 객체를 반환

hello = make_greeter("안녕")
print(hello("철수"))  # 안녕, 철수!

make_greeter처럼 함수를 인수로 받거나 반환하는 함수를 고차 함수(higher-order function)라 한다. innergreeting 변수를 "기억"하는 클로저다.

클로저: 자유 변수를 포획하는 함수

클로저는 자신을 둘러싼 스코프의 변수(자유 변수, free variable)를 캡처해 유지하는 함수다. 외부 함수가 반환되어 스택에서 사라진 뒤에도 내부 함수는 그 변수를 계속 참조할 수 있다.

def make_counter(start: int = 0):
    count = start              # 자유 변수

    def increment() -> int:
        nonlocal count         # 바깥 스코프의 count를 재바인딩
        count += 1
        return count

    return increment

counter = make_counter(10)
print(counter())  # 11
print(counter())  # 12
print(counter())  # 13

nonlocal 없이 count += 1을 쓰면 UnboundLocalError가 발생한다. Python은 대입이 있는 이름을 기본적으로 로컬 변수로 취급하기 때문이다. nonlocal은 컴파일러에게 "이 이름은 바깥 스코프에 있다"고 알린다.

LEGB 규칙과 클로저의 내부 구조

Python의 이름 탐색 순서는 LEGB: Local → Enclosing → Global → Built-in.

x = "global"

def outer():
    x = "enclosing"      # Enclosing 스코프

    def inner():
        # x = "local"   # 없으면 Enclosing의 x를 읽음
        print(x)         # "enclosing"

    inner()

outer()

자유 변수는 func.__code__.co_freevars에 이름이, func.__closure__cell 객체 배열로 저장된다.

def outer():
    secret = 42
    def inner():
        return secret
    return inner

fn = outer()
print(fn.__code__.co_freevars)  # ('secret',)
print(fn.__closure__[0].cell_contents)  # 42

클로저 생성 시 흔한 실수: 루프 변수 캡처

# 잘못된 코드 — 모두 9를 출력
fns = [lambda: i for i in range(10)]
print(fns[0]())  # 9 (!)

# 올바른 코드 — 기본값으로 값을 고정
fns = [lambda i=i: i for i in range(10)]
print(fns[0]())  # 0

루프 변수 i는 루프가 끝나면 마지막 값(9)을 갖는다. 클로저가 변수 자체를 참조하기 때문이다. 기본값 i=i로 그 시점의 값을 캡처하면 해결된다.

데코레이터: 클로저의 응용

데코레이터는 함수(또는 클래스)를 감싸서 행동을 추가하는 고차 함수다. 구문 설탕 @ 없이 쓰면 그 구조가 명확해진다.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("호출 전")
        result = func(*args, **kwargs)
        print("호출 후")
        return result
    return wrapper

def say_hi():
    print("안녕!")

say_hi = my_decorator(say_hi)  # @my_decorator와 같음
say_hi()
# 호출 전
# 안녕!
# 호출 후

@my_decoratorsay_hi = my_decorator(say_hi)와 정확히 동일하다.

functools.wraps: 메타데이터 보존

데코레이터를 쓰면 __name__, __doc__ 등이 wrapper로 덮어씌워진다. functools.wraps가 이를 방지한다.

import functools

def logging_decorator(func):
    @functools.wraps(func)          # __name__, __doc__, __annotations__ 등을 복사
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} 호출")
        return func(*args, **kwargs)
    return wrapper

@logging_decorator
def add(a: int, b: int) -> int:
    """두 수를 더한다."""
    return a + b

print(add.__name__)   # add  (wraps 없으면 wrapper)
print(add.__doc__)    # 두 수를 더한다.

functools.wraps는 내부적으로 functools.update_wrapper를 호출해 __module__, __name__, __qualname__, __doc__, __dict__, __annotations__, __wrapped__를 복사한다. __wrapped__를 이용하면 원래 함수에 접근할 수 있어 테스트가 쉬워진다.

outer 함수 스코프 cell 객체 count = 0 ← 자유 변수 inner 함수 __closure__[0] → cell 자유 변수 참조·수정 반환 데코레이터 패턴 원본 함수 func() 데코레이터 my_decorator(func) wrapper 클로저 반환 functools.wraps(func) __name__, __doc__, __annotations__, __wrapped__ 복사 → wrapper가 원본 함수처럼 보이게 만든다 @decorator 구문 = func = decorator(func) 의 설탕 문법 클로저가 outer 스코프를 살아있게 유지한다
클로저와 데코레이터 흐름

인자를 받는 데코레이터

데코레이터 자체가 파라미터를 받아야 할 때는 한 단계를 더 감싼다 — 파라미터를 받는 "팩토리"가 실제 데코레이터를 반환한다.

import functools
import time

def retry(max_attempts: int = 3, delay: float = 1.0):
    """max_attempts번 재시도, 실패 사이 delay초 대기."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"[retry] {attempt}/{max_attempts} 실패: {e}")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url: str):
    ...  # 네트워크 호출

@retry(max_attempts=3)fetch_data = retry(max_attempts=3)(fetch_data)와 같다. 호출 단계가 세 번(팩토리 → 데코레이터 → 래퍼 호출)임을 기억해 두면 인자 있는 데코레이터의 동작이 명확해진다.

Python 3.8+: functools.wraps + __wrapped__ 활용

functools.wraps를 쓰면 func.__wrapped__에 원본 함수가 저장된다. 테스트에서 데코레이터 효과를 건너뛰고 원본 로직만 검증할 수 있다.

original_fetch = fetch_data.__wrapped__
# original_fetch는 retry 로직 없이 원본 함수를 직접 호출

클래스 기반 데코레이터

__call__을 구현한 클래스도 데코레이터로 쓸 수 있다. 상태를 유지하거나 더 복잡한 동작을 구현할 때 유용하다.

import functools

class Timer:
    """함수 실행 시간을 측정하는 데코레이터."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.elapsed: list[float] = []

    def __call__(self, *args, **kwargs):
        start = time.perf_counter()
        result = self.func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        self.elapsed.append(elapsed)
        print(f"[{self.func.__name__}] {elapsed:.4f}s")
        return result

@Timer
def heavy_computation(n: int) -> int:
    return sum(range(n))

heavy_computation(1_000_000)
print(heavy_computation.elapsed)  # 클래스 인스턴스이므로 상태 접근 가능

데코레이터 쌓기

데코레이터를 여러 개 쌓을 때는 아래에서 위로 적용된다.

@decorator_a
@decorator_b
@decorator_c
def func():
    ...

# 아래와 동일
func = decorator_a(decorator_b(decorator_c(func)))

순서가 중요하다. 예를 들어 @retry 위에 @timer를 두면 재시도 전체 시간을 측정하고, 아래에 두면 한 번의 시도 시간을 측정한다.

functools 모듈 핵심 도구

functools.lru_cache / functools.cache

LRU(Least Recently Used) 캐싱으로 결과를 메모이제이션한다.

import functools

@functools.lru_cache(maxsize=128)  # None이면 무제한
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(50))              # 재계산 없이 빠르게
print(fib.cache_info())     # CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
fib.cache_clear()           # 캐시 비우기

# Python 3.9+: functools.cache = lru_cache(maxsize=None)
@functools.cache
def fib2(n: int) -> int:
    ...

인자는 반드시 해시 가능해야 한다. 인스턴스 메서드에 @lru_cache를 직접 붙이면 인스턴스가 캐시 키에 포함되어 메모리 누수가 발생한다 — @cached_property를 쓰거나 모듈 레벨 함수에만 적용하는 것이 안전하다.

functools.partial: 인자 고정

from functools import partial

def power(base: float, exp: float) -> float:
    return base ** exp

square = partial(power, exp=2)    # exp를 2로 고정
cube   = partial(power, exp=3)

print(square(5))   # 25.0
print(cube(3))     # 27.0

# map과 함께 쓸 때 자주 등장
results = list(map(partial(power, exp=2), [1, 2, 3, 4]))
# [1.0, 4.0, 9.0, 16.0]

partiallambda보다 명시적이고, functools.update_wrapper가 적용되어 있어 디버깅 시 이름이 보존된다.

functools.reduce

from functools import reduce

# 1 * 2 * 3 * 4 * 5
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product)  # 120

# 초기값 지정 (빈 시퀀스 안전)
total = reduce(lambda acc, x: acc + x, [], 0)
print(total)  # 0

Python에서 reduce는 내장 함수에서 functools로 옮겨졌다(Python 3). 루프로 더 명확하게 쓸 수 있는 경우가 많으므로 남용하지 않는다.

map, filter: 내장 고차 함수

nums = [1, 2, 3, 4, 5, 6]

# map: 각 원소에 함수 적용
squares = list(map(lambda x: x ** 2, nums))

# filter: 조건을 만족하는 원소만
evens = list(filter(lambda x: x % 2 == 0, nums))

# 대부분의 경우 컴프리헨션이 더 읽기 쉽다
squares2 = [x ** 2 for x in nums]
evens2   = [x for x in nums if x % 2 == 0]

mapfilter는 제너레이터를 반환하므로 지연 평가된다. Airflow 오퍼레이터 리스트, dbt 모델 목록 등을 변환할 때 파이프라인 중간 단계로 활용한다.

실전 패턴 정리

import functools, time, logging

# 1. 타이밍 데코레이터
def timeit(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.perf_counter() - t0:.3f}s")
        return result
    return wrapper

# 2. 캐싱 데코레이터 (커스텀 TTL)
def ttl_cache(ttl: float):
    def decorator(func):
        cache: dict = {}
        @functools.wraps(func)
        def wrapper(*args):
            key = args
            now = time.monotonic()
            if key in cache and now - cache[key][1] < ttl:
                return cache[key][0]
            result = func(*args)
            cache[key] = (result, now)
            return result
        return wrapper
    return decorator

# 3. 예외 로깅
def log_exceptions(logger: logging.Logger):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception:
                logger.exception(f"{func.__name__} failed")
                raise
        return wrapper
    return decorator

정리

  1. 클로저는 자유 변수를 cell 객체에 저장한다. nonlocal로만 재바인딩 가능하다.
  2. 데코레이터 = 고차 함수. @func = decorator(func)의 설탕 문법이다.
  3. functools.wraps는 필수다. 없으면 __name__, __doc__이 wrapper로 덮힌다.
  4. 인자 있는 데코레이터는 팩토리 → 데코레이터 → 래퍼 세 겹이다.
  5. lru_cache/cache: 순수 함수 메모이제이션. 인스턴스 메서드에는 주의해서 적용한다.
  6. partial: lambda보다 명시적인 인자 고정.

References

  • Python Docs, functools module: https://docs.python.org/3/library/functools.html
  • Python Docs, Free variables and closures (reference): https://docs.python.org/3/reference/executionmodel.html#resolution-of-names
  • PEP 318 – Decorators for Functions and Methods: https://peps.python.org/pep-0318/
  • freeCodeCamp, "First-Class Functions, Higher-Order Functions, and Closures in Python": https://www.freecodecamp.org/news/first-class-functions-and-closures-in-python/
  • calmops.com, "Python's functools Module: Mastering partial, wraps, and lru_cache": https://calmops.com/programming/python/python-functools-partial-wraps-lru_cache/
  • Redowan's Reflections, "Untangling Python decorators": https://rednafi.com/python/decorators/
  • Redowan's Reflections, "Don't wrap instance methods with lru_cache": https://rednafi.com/python/lru-cache-on-methods/