Python 데코레이터: 동작 원리, functools.wraps, 인자 있는 데코레이터
이 글의 핵심
데코레이터를 붙였더니 함수 이름이 wrapper로 바뀌어 로그와 디버깅이 헷갈리는 경험은 흔합니다. @ 문법이 실제로는 함수를 인자로 받아 새 함수를 돌려주는 호출이라는 점을 이해하면, 인자 있는 데코레이터가 왜 세 겹의 함수가 되는지와 겹쳐 쓸 때 아래쪽부터 감싸는 순서가 자연스럽게 보입니다.
들어가며
데코레이터는 함수 본문을 고치지 않고 기능을 덧붙이는 Python 문법입니다. 실행 시간 측정, 로그, 권한 검사, 재시도처럼 여러 함수에 똑같이 들어가는 코드를 각 함수에 복사하면, 규칙이 바뀔 때마다 모든 곳을 찾아 고쳐야 합니다. 데코레이터는 이 공통 코드를 함수 하나로 모아 두고 @이름 한 줄로 붙이게 해 줍니다.
마법처럼 보이지만 원리는 단순합니다. Python에서 함수는 변수에 담고 인자로 넘길 수 있는 객체이고, 함수 안에서 정의한 함수는 바깥 함수의 변수를 기억합니다(클로저). 데코레이터는 이 두 성질을 이용해 “함수를 받아 새 함수를 돌려주는 함수”일 뿐이며, @ 기호는 그 호출을 짧게 쓰는 문법입니다. 함수와 클로저 기초는 Python 함수에서 다뤘습니다.
함수 데코레이터와 로깅 데코레이터
함수 데코레이터
@timer는 원래 함수를 감싸는 새 함수(wrapper)로 바꿔 넣는 간편 표기입니다. 호출 시점에 앞뒤로 로깅·시간 측정 같은 공통 장식을 붙일 수 있어, 본문 함수는 핵심 로직만 남기기 좋습니다.
def timer(func):
"""함수 실행 시간 측정"""
import time
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} 실행 시간: {end - start:.4f}초")
return result
return wrapper
@timer
def slow_function():
import time
time.sleep(1)
return "완료"
result = slow_function()
# slow_function 실행 시간: 1.0012초
@timer를 def slow_function 위에 쓰는 것은 함수 정의 직후에 slow_function = timer(slow_function)을 실행하는 것과 똑같습니다. 이 대입은 함수를 정의하는 시점에 한 번 일어나고, 이후 slow_function()을 호출하면 실제로는 wrapper가 실행됩니다. wrapper는 바깥 timer의 매개변수 func를 기억하고 있어서 원래 함수를 안에서 부를 수 있습니다.
wrapper(*args, **kwargs)로 받는 이유는 어떤 시그니처의 함수에도 붙일 수 있게 하기 위해서입니다. 인자를 wrapper()처럼 비워 두면 인자가 있는 함수에 붙이는 순간 TypeError: wrapper() takes 0 positional arguments but 1 was given이 납니다. 또 return result를 빠뜨리면 원래 함수의 반환값이 사라지고 호출 결과가 항상 None이 되는데, 오류가 나지 않아서 데코레이터를 처음 만들 때 가장 흔히 하는 실수입니다.
시간 측정에는 time.time()보다 time.perf_counter()가 적합합니다. time.time()은 시스템 시계라 NTP 동기화 등으로 값이 뒤로 갈 수 있고 해상도도 플랫폼마다 다르지만, perf_counter()는 경과 시간 측정용 단조 증가 시계입니다.
로깅 데코레이터
def logger(func):
"""함수 호출 로깅"""
def wrapper(*args, **kwargs):
print(f"[호출] {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"[반환] {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
add(3, 5)
# [호출] add((3, 5), {})
# [반환] 8
로깅 데코레이터는 호출 인자와 반환값을 한곳에서 기록할 수 있어 디버깅에 편합니다. 실무에서는 print 대신 logging 모듈을 쓰고, 인자에 비밀번호나 토큰이 들어가는 함수에는 붙이지 않거나 마스킹해야 합니다. 로그에 민감 정보가 남는 사고는 이렇게 “모든 인자를 찍는” 범용 로거에서 자주 생깁니다. 또 이 wrapper는 예외가 나면 [반환] 줄을 건너뛰고 예외를 그대로 올려 보내는데, 예외도 기록하고 싶다면 try/except로 감싸 로그를 남긴 뒤 raise로 다시 던집니다.
인자를 받는 데코레이터 팩토리
데코레이터 팩토리
def repeat(times):
"""함수를 여러 번 실행"""
def decorator(func):
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
result = func(*args, **kwargs)
results.append(result)
return results
return wrapper
return decorator
@repeat(3)
def greet(name):
return f"안녕, {name}!"
print(greet("철수"))
# ['안녕, 철수!', '안녕, 철수!', '안녕, 철수!']
함수가 세 겹이 되는 이유는 @repeat(3)이 두 번의 호출로 풀리기 때문입니다. 먼저 repeat(3)이 호출되어 decorator 함수를 돌려주고, 그 decorator가 greet를 받아 wrapper를 돌려줍니다. 즉 greet = repeat(3)(greet)입니다. 가장 바깥 함수는 설정값(times)을, 가운데는 대상 함수를, 가장 안쪽은 실제 호출 인자를 받는다고 보면 구조가 한눈에 들어옵니다.
자주 하는 실수는 인자 있는 데코레이터를 괄호 없이 @repeat로 쓰는 것입니다. 이러면 greet 함수 자체가 times로 들어가고 greet는 decorator로 바뀌어, 호출할 때 TypeError: decorator() takes 1 positional argument but ... 같은 알아보기 어려운 오류가 납니다. 반대로 인자 없는 데코레이터를 @timer()로 쓰면 timer()가 func 없이 호출되어 곧바로 오류가 납니다. 두 방식을 모두 지원하려면 첫 인자가 함수인지 검사하는 방법을 쓰기도 하지만, 사용법을 하나로 정해 두는 편이 읽기 쉽습니다.
또 repeat는 원래 함수의 반환값 대신 리스트를 돌려주도록 반환 타입을 바꾸는 데코레이터입니다. 이런 데코레이터는 호출하는 쪽의 기대를 바꾸기 때문에, 붙이기만 하면 되는 로깅·시간 측정류와 달리 이름과 문서로 동작을 분명히 알려야 합니다.
캐싱과 인증 데코레이터
캐싱 데코레이터
def memoize(func):
"""함수 결과 캐싱"""
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100)) # 매우 빠름!
캐시 없이 재귀 피보나치를 계산하면 같은 값을 지수적으로 반복 계산해 fibonacci(40)만 돼도 수십 초가 걸리지만, 결과를 딕셔너리에 저장하면 각 n을 한 번씩만 계산합니다. 여기서 핵심은 함수 본문의 fibonacci(n-1)이 호출하는 대상입니다. 데코레이터 적용 후 전역 이름 fibonacci는 wrapper를 가리키므로 재귀 호출도 캐시를 거칩니다. 데코레이터를 붙이지 않고 fast = memoize(fibonacci)처럼 다른 이름에 담으면 첫 호출만 캐시되고 재귀는 원래 함수로 가서 여전히 느립니다.
cache[args]처럼 인자 튜플을 딕셔너리 키로 쓰므로 인자가 해시 가능해야 합니다. 리스트나 딕셔너리를 넘기면 TypeError: unhashable type: 'list'가 납니다. 이 캐시는 크기 제한이 없어 서로 다른 인자로 오래 호출되는 함수에 붙이면 메모리가 계속 늘어납니다. 표준 라이브러리의 functools.lru_cache(maxsize=128)나 functools.cache(Python 3.9+)는 키워드 인자 처리, 크기 제한, cache_info() 통계까지 제공하므로 실무에서는 이쪽을 씁니다. 캐시는 같은 입력에 항상 같은 결과를 내는 순수 함수에만 붙여야 하고, DB 조회처럼 결과가 바뀌는 함수에 붙이면 오래된 값이 계속 반환됩니다.
인증 데코레이터
def require_auth(func):
"""인증 확인"""
def wrapper(user, *args, **kwargs):
if not user.get('is_authenticated'):
raise PermissionError("로그인이 필요합니다")
return func(user, *args, **kwargs)
return wrapper
@require_auth
def delete_post(user, post_id):
return f"포스트 {post_id} 삭제됨"
# 사용
user = {'name': '철수', 'is_authenticated': True}
print(delete_post(user, 123)) # 포스트 123 삭제됨
guest = {'name': '손님', 'is_authenticated': False}
# delete_post(guest, 123) # PermissionError!
인증 데코레이터는 본문이 실행되기 전에 조건을 검사하고, 통과하지 못하면 원래 함수를 아예 호출하지 않습니다. 권한 검사를 함수마다 if 문으로 넣으면 한 곳에서 빠뜨리는 순간 보안 구멍이 되지만, 데코레이터로 모으면 검사 로직이 한 곳에 있고 어떤 함수가 보호되는지 정의부만 봐도 보입니다. Flask의 @login_required, Django의 @permission_required가 같은 방식입니다.
이 wrapper는 첫 번째 인자가 user라고 가정합니다. 사용자 정보를 키워드 인자로 넘기거나(delete_post(post_id=1, user=u)) 첫 인자가 self인 메서드에 붙이면 엉뚱한 값을 검사하게 됩니다. 웹 프레임워크가 요청 객체나 현재 사용자를 전역 컨텍스트(flask.g, request.user)로 제공하는 이유 중 하나가 이런 인자 위치 의존성을 없애기 위해서입니다.
클래스 데코레이터
def singleton(cls):
"""싱글톤 패턴"""
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Database:
def __init__(self):
print("데이터베이스 연결")
self.connection = "Connected"
# 사용
db1 = Database() # 데이터베이스 연결
db2 = Database() # 출력 없음 (같은 인스턴스)
print(db1 is db2) # True
데코레이터는 함수뿐 아니라 클래스에도 붙일 수 있고, 원리는 같습니다. @singleton은 Database = singleton(Database)이므로 이후 Database() 호출은 get_instance를 실행합니다. 첫 호출에서만 실제 생성자가 돌고, 이후에는 저장해 둔 인스턴스를 돌려줍니다.
이 구현에는 알아 둘 부작용이 있습니다. 데코레이터가 클래스 대신 함수를 돌려주기 때문에 Database라는 이름이 더 이상 클래스가 아닙니다. isinstance(db1, Database)는 TypeError: isinstance() arg 2 must be a type...을 내고, class TestDB(Database):처럼 상속하려 해도 실패합니다. 또 두 번째 호출에 다른 인자를 넘겨도 조용히 무시되며, 여러 스레드가 동시에 첫 호출을 하면 인스턴스가 두 번 만들어질 수 있습니다. 클래스 데코레이터는 클래스를 받아 속성을 추가한 뒤 같은 클래스를 돌려주는 방식(@dataclass가 대표적)이 부작용이 적고, Python에서 싱글톤이 필요하면 모듈 수준 변수 하나로 충분한 경우가 많습니다.
functools.wraps로 메타데이터 보존
메타데이터 보존
from functools import wraps
def my_decorator(func):
@wraps(func) # 원본 함수 정보 보존
def wrapper(*args, **kwargs):
"""래퍼 함수"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""인사 함수"""
return f"안녕, {name}!"
print(greet.__name__) # greet (wraps 없으면 wrapper)
print(greet.__doc__) # 인사 함수
@wraps(func)는 원래 함수의 __name__, __doc__, __module__, __qualname__, __dict__를 wrapper에 복사하고, 원본을 __wrapped__ 속성에 저장합니다. 이것이 빠지면 로그와 스택 트레이스에 모든 함수가 wrapper로 찍히고, help(greet)는 래퍼의 docstring을 보여 줍니다. 앞의 예제들은 원리를 보여 주려고 wraps를 생략했지만, 실제 코드에서는 모든 래퍼에 붙이는 것이 기본입니다.
wraps가 실제 동작 오류를 막는 경우도 있습니다. Flask는 뷰 함수 이름을 엔드포인트 이름으로 쓰기 때문에, wraps 없는 데코레이터를 두 뷰에 붙이면 둘 다 wrapper가 되어 “View function mapping is overwriting an existing endpoint function: wrapper” 오류가 납니다. 처음 Flask에 인증 데코레이터를 직접 만들어 붙일 때 이 오류를 만나는 경우가 많은데, 원인이 데코레이터라는 것을 떠올리기 어렵습니다. inspect.signature(greet)도 __wrapped__를 따라가 원래 시그니처를 보여 주므로, pytest 픽스처나 FastAPI처럼 시그니처를 읽는 도구에서도 wraps가 필요합니다.
API 재시도 데코레이터
import time
from functools import wraps
def retry(max_attempts=3, delay=1):
"""실패 시 재시도"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"시도 {attempt + 1} 실패: {e}")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
import random
if random.random() < 0.7:
raise ConnectionError("연결 실패")
return f"{url} 데이터"
재시도는 일시적인 네트워크 오류처럼 다시 하면 성공할 수 있는 실패를 흡수하는 데 씁니다. 마지막 시도에서는 raise로 원래 예외를 그대로 올려 보내 호출한 쪽이 실패를 알 수 있게 한 점이 중요합니다. 예외를 삼키고 None을 반환하면 실패가 조용히 묻힙니다.
실제로 쓸 때는 몇 가지를 더 고려해야 합니다. except Exception은 오타로 생긴 TypeError나 잘못된 인자로 인한 ValueError까지 재시도해서, 절대 성공할 수 없는 호출을 몇 번씩 기다리게 만듭니다. 재시도할 예외 타입을 exceptions=(ConnectionError, TimeoutError)처럼 인자로 받는 편이 좋습니다. 대기 시간도 고정값보다 시도마다 두 배로 늘리는 지수 백오프에 약간의 무작위 지연을 섞어야, 장애가 난 서버에 모든 클라이언트가 같은 간격으로 몰려드는 것을 피할 수 있습니다. 그리고 결제나 주문 생성처럼 두 번 실행되면 안 되는 작업에는 서버 쪽 멱등성 보장 없이 재시도를 붙이면 안 됩니다. 요청은 성공했는데 응답만 유실된 경우 재시도가 중복 실행을 만들기 때문입니다. 이런 요구가 많다면 tenacity 같은 라이브러리가 이 옵션들을 제공합니다.
데코레이터를 겹쳐 쓸 때의 순서와 wraps
데코레이터는 원래 함수 앞뒤에 공통 장식을 덧붙이는 틀이라고 보면 됩니다. 여러 개를 쌓을 때는 감싸는 순서와 호출 시 실행 순서가 반대라는 점을 구분해야 디버깅이 수월합니다.
# ✅ 여러 데코레이터 조합
@timer
@logger
@retry(3)
def important_function():
pass
# 감싸는 순서: retry → logger → timer (아래부터)
# 호출 시 진입 순서: timer → logger → retry → 함수 (위부터)
# ✅ functools.wraps 사용
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
위 코드는 important_function = timer(logger(retry(3)(important_function)))와 같습니다. 가장 안쪽의 retry가 원래 함수를 먼저 감싸고, 그 결과를 logger가, 다시 그 결과를 timer가 감쌉니다. 호출하면 양파 껍질을 벗기듯 가장 바깥의 timer 래퍼가 먼저 실행되고 안쪽으로 들어갑니다.
이 순서는 결과에 직접 영향을 줍니다. 위 배치에서 timer는 재시도 대기 시간까지 포함한 전체 시간을 재고, logger는 재시도가 몇 번 일어나든 호출을 한 번만 기록합니다. @retry를 가장 위로 올리면 반대로 시도마다 로그가 찍히고 시간도 시도별로 측정됩니다. 인증과 캐시를 함께 쓸 때는 더 중요해서, 캐시가 인증보다 바깥에 있으면 권한이 있는 사용자가 한 번 호출해 캐시된 결과를 권한 없는 사용자도 받게 될 수 있습니다. 검사하는 데코레이터를 바깥(위)에 두는 것이 안전한 기본값입니다.
데코레이터 요약
- 데코레이터: 함수에 기능 추가
- 문법: @decorator_name
- 인자: 데코레이터 팩토리 사용
- wraps: 메타데이터 보존
- 활용: 로깅, 캐싱, 인증, 재시도
다음 단계
- 제너레이터
- Flask 웹 개발
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 데코레이터를 적용했더니 함수의 __name__이 wrapper로 바뀌는 이유는 무엇인가요?
A. 데코레이터는 원래 함수를 내부의 wrapper 함수로 바꿔서 반환하기 때문에, 이름과 독스트링도 wrapper의 것이 됩니다. 이렇게 되면 로그나 디버깅에서 어떤 함수가 호출됐는지 알기 어렵고, 함수 이름에 의존하는 도구도 제대로 동작하지 않습니다. wrapper 위에 @functools.wraps(func)를 붙이면 원본 함수의 __name__과 __doc__이 보존됩니다.