한마디 요약
데코레이터(decorator, 장식자) 는 “함수를 인자로 받아, 원본 함수를 감싼 새 함수를 반환하는 고차 함수” 다. @이름 문법은 그 반환값으로 원본 이름을 재할당 하는 문법 설탕(syntactic sugar). LangChain @tool, FastAPI @app.get, LangGraph @task, Streamlit @st.cache_data 가 모두 같은 메커니즘. AI Agent 개발자가 가장 자주 만나는 고차 패턴.
1. 데코레이터란?
“함수를 감싸서 기능을 추가하는 함수”
# 데코레이터 없이
def hello():
print("안녕")
hello() # "안녕"
# 데코레이터 적용
@add_stars # ← 이게 데코레이터
def hello():
print("안녕")
hello() # "*** 안녕 ***"포함 관계
Python 함수 고차 패턴
├─ 클로저 [[클로저(Closure)란]]
├─ 고차 함수 (HOF) [[lambda와 순회함수]]
└─ 데코레이터 ← 이 문서
├─ 단순 데코레이터: @deco
├─ 인자 받는 데코레이터: @deco(max=3)
├─ 클래스 데코레이터: @MyClass
└─ 메서드 데코레이터: @classmethod, @staticmethod, @property
데코레이터는 클로저 + 고차 함수의 조합. 저변 개념 두 개를 먼저 이해해야 자연스럽게 읽힌다.
2. 작동 원리
2-1. 기본 구조
def add_stars(func): # func = 감쌀 원본 함수
def wrapper():
print("***")
func() # 원본 함수 실행
print("***")
return wrapper # 감싼 wrapper 를 반환
@add_stars # hello = add_stars(hello) 와 동일
def hello():
print("안녕")
hello()
# ***
# 안녕
# ***내부 흐름
# @add_stars 는 실제로는 이렇게 작동
hello = add_stars(hello)
# ↑ 데코레이터 함수가 원본 함수를 감싸서 새 함수 반환
# ↑ 그 새 함수를 원래 이름 hello 에 재할당단계 분해
1단계: def hello(): print("안녕") # 원본 함수 정의
2단계: @add_stars 가 hello 를 add_stars(hello) 로 대체
3단계: add_stars 내부에서 wrapper 정의 (hello 를 클로저로 기억)
4단계: wrapper 반환 → 이름 "hello" 에 바인딩
5단계: 이후 hello() 호출은 사실 wrapper() 호출
2-2. 인자 받는 함수에 적용
*args, **kwargs 로 모든 인자를 통과시키는 것이 관용구. 함수(별args, 별별kwargs)
def log_execution(func):
def wrapper(*args, **kwargs):
print(f"[실행 시작] {func.__name__}")
result = func(*args, **kwargs)
print(f"[실행 완료] 결과: {result}")
return result
return wrapper
@log_execution
def add(a, b):
return a + b
result = add(3, 5)
# [실행 시작] add
# [실행 완료] 결과: 82-3. 반드시 붙여야 하는 functools.wraps
데코레이터를 씌우면 함수의 이름·docstring·시그니처가 wrapper 의 것으로 바뀌어 버린다. 디버깅·자동 문서화·LangChain tool 스키마 추출 등에서 문제가 된다.
from functools import wraps
def log_execution(func):
@wraps(func) # ← 원본 메타데이터 복사
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_execution
def add(a, b):
"""두 수를 더한다."""
return a + b
print(add.__name__) # 'add' (없으면 'wrapper')
print(add.__doc__) # '두 수를 더한다.'
print(add.__wrapped__) # 원본 함수에 접근AI Agent 코드에서
@wraps누락 시@tool같은 프레임워크가 docstring/시그니처를 못 읽어 스키마 생성에 실패할 수 있다. 거의 습관처럼 붙인다.
3. 데코레이터 3종 변형
3-1. 인자 없는 데코레이터 (기본형)
@deco
def f(): ...3-2. 인자 받는 데코레이터 (데코레이터 팩토리)
def retry(max_attempts=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_attempts - 1:
raise
print(f"재시도 {attempt+1}/{max_attempts}")
return wrapper
return decorator
@retry(max_attempts=3)
def call_api():
return requests.get("https://api.example.com")3중 중첩 구조가 포인트:
retry(max_attempts=3) → decorator 반환
decorator(call_api) → wrapper 반환
wrapper(*a, **k) → 실제 호출 시 실행
이게 헷갈리면 클로저(Closure)란 의 “계산기 비유” 를 다시 보자.
3-3. 클래스 데코레이터 (인스턴스가 함수처럼 호출됨)
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"[호출 {self.count}회차]")
return self.func(*args, **kwargs)
@CountCalls
def greet():
print("hi")
greet() # [호출 1회차] hi
greet() # [호출 2회차] hi상태를 지속적으로 들고 다녀야 할 때 클래스 데코레이터가 편하다.
4. 여러 데코레이터 중첩
@measure_time # 3번째 적용 (가장 바깥)
@retry(max_attempts=3)
@log_execution # 1번째 적용 (가장 안쪽)
def important_function():
return "결과"
# 실제 변환:
# important_function = measure_time(retry(3)(log_execution(important_function)))실행 순서
호출 시
measure_time 의 wrapper 진입
retry 의 wrapper 진입
log_execution 의 wrapper 진입
원본 important_function 실행
log_execution 의 wrapper 종료
retry 의 wrapper 종료
measure_time 의 wrapper 종료
“감쌀 때는 아래→위, 실행될 때는 위→아래” 로 기억하면 쉽다.
5. AI Agent 프레임워크별 사용 예
5-1. LangChain @tool (함수 → Tool 객체)
from langchain_core.tools import tool
@tool
def get_real_estate_data(region: str, limit: int = 10) -> dict:
"""부동산 데이터를 가져옵니다.
Args:
region: 조회할 지역
limit: 가져올 개수
"""
return {"region": region, "items": [...]}@tool 이 내부에서 하는 일:
1. 함수 시그니처 분석 → args_schema (Pydantic 모델)
2. 타입 힌트 → JSON Schema
3. docstring → tool description (LLM 에게 안내할 문구)
4. 함수 자체 → Tool 객체의 .invoke() 에 래핑
5. 결과: LLM 이 function-calling 으로 호출 가능한 Tool 인스턴스
내부적으로:
get_real_estate_data = tool(get_real_estate_data)5-2. FastAPI @app.get (함수 → HTTP 엔드포인트)
app 은 FastAPI 클래스의 인스턴스이고, get() 은 그 클래스의 인스턴스 메서드. 그 메서드가 데코레이터를 반환한다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/apartments") # ← URL 라우트 등록
async def get_apartments():
return {"data": "아파트 목록"}@app.get() 이 하는 일:
1. 함수를 FastAPI 엔드포인트로 등록
2. URL path 매핑
3. 자동 OpenAPI 문서 생성
내부적으로:
app.get("/apartments")(get_apartments)
# = app.get("/apartments") 가 반환한 데코레이터에 get_apartments 를 전달추가 옵션 예:
@app.get(
"/apartments",
response_model=list[Apartment], # 응답 스키마
status_code=200, # 성공 상태 코드
tags=["Real Estate"], # Swagger 태그
)
async def get_apartments(): ...HTTP 메서드 버전:
@app.post(),@app.put(),@app.delete(),@app.patch(),@app.options()
5-3. Streamlit @st.cache_data (함수 → 캐싱 래퍼)
import streamlit as st
@st.cache_data
def load_data():
return expensive_computation()하는 일: 함수 인자를 해시해 key 로 삼고, 결과를 메모리에 저장. 같은 인자로 재호출 시 저장된 값 반환. LLM 호출 결과 캐싱에도 유용.
5-4. Python 내장 @abstractmethod, @property, @classmethod
from abc import ABC, abstractmethod
class BaseAgent(ABC):
@abstractmethod
def plan(self, state): ... # 하위 클래스가 반드시 구현해야 함
@property
def name(self) -> str: # 메서드를 속성처럼 사용
return self._name
@classmethod
def from_config(cls, cfg): # 클래스 메서드 (self 대신 cls)
return cls(**cfg)5-5. LangGraph @task (병렬 실행 단위 지정)
from langgraph.func import task
@task
def search_node(query: str) -> list:
return db.search(query)6. 비교: 같은 문법, 다른 목적
| 프레임워크 | 데코레이터 | 목적 | 변환 결과 |
|---|---|---|---|
| LangChain | @tool | 함수 → Tool 객체 | LLM function-calling 대상 |
| FastAPI | @app.get("/path") | 함수 → HTTP 엔드포인트 | URL 라우터 등록 |
| Streamlit | @st.cache_data | 함수 → 캐싱 래퍼 | 결과 저장 및 재사용 |
| Python | @abstractmethod | 메서드 → 추상 메서드 | 구현 강제 |
| Python | @property | 메서드 → 속성 | obj.x 로 접근 |
| functools | @lru_cache(maxsize=N) | 함수 → 메모이제이션 | 인자 해시 캐싱 |
| pytest | @pytest.fixture | 함수 → 픽스처 | 테스트 의존성 주입 |
문법은 동일하고 목적이 다름. 각 프레임워크가 “함수를 받아 뭘 하느냐” 만 다를 뿐.
7. 직접 만들어 보는 실용 데코레이터 모음
7-1. 실행 시간 측정 (로깅)
import time
import logging
from functools import wraps
def log_duration(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
logging.info(f"{func.__name__} 실행시간: {elapsed:.2f}초")
return result
return wrapper
@log_duration
def fetch_real_estate_data():
time.sleep(2)
return "데이터"
fetch_real_estate_data()
# INFO: fetch_real_estate_data 실행시간: 2.00초7-2. 재시도 데코레이터 (LLM/API 호출에 유용)
import time
from functools import wraps
def retry(max_attempts=3, backoff=2):
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
wait = backoff ** attempt
print(f"재시도 {attempt+1}/{max_attempts}, {wait}초 대기")
time.sleep(wait)
return wrapper
return decorator
@retry(max_attempts=3, backoff=2)
def call_llm(prompt):
return openai.chat.completions.create(...)7-3. 비동기 타임아웃
import asyncio
from functools import wraps
def async_timeout(seconds: float):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
return await asyncio.wait_for(
func(*args, **kwargs),
timeout=seconds,
)
return wrapper
return decorator
@async_timeout(10.0)
async def search(query):
...7-4. 입력 검증
from functools import wraps
def validate_non_empty(func):
@wraps(func)
def wrapper(query: str, *args, **kwargs):
if not query.strip():
raise ValueError("쿼리가 비어 있습니다.")
return func(query, *args, **kwargs)
return wrapper
@validate_non_empty
def search(query): ...8. 주의할 함정들
함정 1: @wraps 누락
시그니처·docstring 손실 → LangChain @tool 등에서 스키마 추출 실패. 항상 붙인다.
함정 2: 인자 받는 데코레이터에서 괄호 빠뜨림
@retry # ❌ retry 를 데코레이터로 사용 (func 인자만 받음)
def call_api(): ...
@retry() # ✅ retry() 호출 결과가 데코레이터
def call_api(): ...함정 3: 클래스 메서드에 일반 데코레이터 적용
class Agent:
@log_duration # OK, self 는 args[0] 로 들어감
def plan(self): ...
@classmethod
@log_duration # ✅ 데코레이터 순서 주의: 위가 바깥
def from_config(cls): ...@classmethod 와 일반 데코레이터를 같이 쓸 땐 @classmethod 를 가장 바깥(맨 위) 에 둔다.
함정 4: 동기/비동기 혼용
async def func(): ...
@log_duration # ❌ wrapper 가 sync 라 awaitable 반환 안 함
func()async 함수에는 async wrapper 를 만드는 별도 데코레이터를 쓴다.
def log_duration_async(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start = time.time()
result = await func(*args, **kwargs)
print(f"{func.__name__}: {time.time() - start:.2f}s")
return result
return wrapper9. raise 두 가지 사용법 (retry 데코레이터에서 필요)
# 1. 새로운 에러 발생
raise Exception("새 에러 메시지")
# 2. 잡은 에러 다시 던지기 (스택 트레이스 보존)
try:
...
except Exception as e:
raise # 방금 잡은 e 를 다시 던짐raise 없으면?
except Exception as e:
if attempt == max_attempts - 1:
pass # 에러를 삼킴
# 결과: 호출자가 None 을 받음 → 조용한 버그raise 있으면 “3번 시도 모두 실패” 가 호출자에게 예외로 전달된다. Agent 로직에서 특히 중요하다. 실패를 삼키면 디버깅 지옥이 된다.
10. 정리
핵심 규칙 5가지
- 데코레이터 = 함수를 인자로 받아 함수를 반환하는 고차 함수
@deco=func = deco(func)의 문법 설탕- wrapper 안은
*args, **kwargs+functools.wraps - 인자 있는 데코레이터는 3중 중첩
- 중첩 시 아래부터 감싸고, 위부터 실행
AI Agent 개발 체크리스트
-
@tool함수에 타입 힌트와 docstring 작성 (함수의 타입 힌트) - 직접 만든 데코레이터에
@wraps붙임 - LLM/API 호출에
@retry나tenacity같은 라이브러리 활용 - async 함수에는 async wrapper 사용
- FastAPI 엔드포인트에서
Depends로 의존성 주입 (함수의 반환값을 파라미터로 넣기)
관련 문서
- 클로저(Closure)란 (데코레이터의 밑바닥 원리)
- 함수(별args, 별별kwargs) (wrapper 가변 인자)
- 함수의 반환값을 파라미터로 넣기 (
Depends(f)같은 지연 실행) - 함수의 타입 힌트 (
@tool이 읽어가는 타입 정보) - 반환 타입 힌트(화살표➡️) (Tool 반환 스키마 생성)
- lambda와 순회함수 (데코레이터와 대비되는 간단 변환)
한마디 재요약
데코레이터는 “원본 코드를 건드리지 않고 함수 바깥에 기능을 덧칠하는 장치”. 로깅, 재시도, 캐싱, 등록(tool/route) 같은 횡단 관심사(cross-cutting concern) 를 깔끔히 분리해준다. AI Agent 코드에서 @tool, @app.get, @task 를 쓰는 순간 이미 데코레이터 패턴을 쓰고 있는 셈.