함수형 도구와 클로저·데코레이터
함수는 일급 객체다
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)라 한다. inner는 greeting 변수를 "기억"하는 클로저다.
클로저: 자유 변수를 포획하는 함수
클로저는 자신을 둘러싼 스코프의 변수(자유 변수, 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()) # 13nonlocal 없이 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_decorator는 say_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__를 이용하면 원래 함수에 접근할 수 있어 테스트가 쉬워진다.
인자를 받는 데코레이터
데코레이터 자체가 파라미터를 받아야 할 때는 한 단계를 더 감싼다 — 파라미터를 받는 "팩토리"가 실제 데코레이터를 반환한다.
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]partial은 lambda보다 명시적이고, 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) # 0Python에서 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]map과 filter는 제너레이터를 반환하므로 지연 평가된다. 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정리
- 클로저는 자유 변수를 cell 객체에 저장한다.
nonlocal로만 재바인딩 가능하다. - 데코레이터 = 고차 함수.
@는func = decorator(func)의 설탕 문법이다. functools.wraps는 필수다. 없으면__name__,__doc__이 wrapper로 덮힌다.- 인자 있는 데코레이터는 팩토리 → 데코레이터 → 래퍼 세 겹이다.
lru_cache/cache: 순수 함수 메모이제이션. 인스턴스 메서드에는 주의해서 적용한다.partial: lambda보다 명시적인 인자 고정.
References
- Python Docs,
functoolsmodule: 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/