한마디 요약

데코레이터(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
# [실행 완료] 결과: 8

2-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 엔드포인트)

appFastAPI 클래스의 인스턴스이고, 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 wrapper

9. 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가지

  1. 데코레이터 = 함수를 인자로 받아 함수를 반환하는 고차 함수
  2. @deco = func = deco(func) 의 문법 설탕
  3. wrapper 안은 *args, **kwargs + functools.wraps
  4. 인자 있는 데코레이터는 3중 중첩
  5. 중첩 시 아래부터 감싸고, 위부터 실행

AI Agent 개발 체크리스트


관련 문서


한마디 재요약

데코레이터는 “원본 코드를 건드리지 않고 함수 바깥에 기능을 덧칠하는 장치”. 로깅, 재시도, 캐싱, 등록(tool/route) 같은 횡단 관심사(cross-cutting concern) 를 깔끔히 분리해준다. AI Agent 코드에서 @tool, @app.get, @task 를 쓰는 순간 이미 데코레이터 패턴을 쓰고 있는 셈.