저번 편에서는 스코프 문제와 클로저의 개념, 그리고 nonlocal까지 정리했었다. 클로저 공부를 하면서 "클로저가 왜 필요한지 아직 모르겠다"고 했었는데, 오늘 데코레이터를 공부하면서 필요성을 알게되었다.
데코레이터를 사용하는 건 사실 엄청 간단하다.
@somedecorator
def some_function():
print("데코레이터 쓰고 있음!")
사용자 정의함수 위에 @ 기호 하나 붙이면 끝이다. 근데 이걸 직접 만드는 건 완전히 다른 얘기다. 제대로 만들려면 지금까지 공부한 개념들이 전부 필요하다.
*args): 어떤 함수든 감쌀 수 있어야 함데코레이터는 이 세 가지가 전부 합쳐진 결과물이다.
실습으로 함수 실행 시간을 측정하는 데코레이터를 만들었다.
import time
def performance_clock(func): # 함수를 매개변수로 받음 (자유변수)
def performance_clocked(*args): # 내부 함수 (실제 동작)
st = time.perf_counter()
result = func(*args) # 원래 함수 실행
et = time.perf_counter() - st
name = func.__name__
arg_str = ', '.join(repr(arg) for arg in args)
print(f"[{et}s] {name}({arg_str}) -> {result}")
return result
return performance_clocked # 함수 자체를 반환
구조를 뜯어보면 완전히 클로저다.
performance_clock이 외부 함수, performance_clocked가 내부 함수func가 자유변수(계속 유지되는 변수)로서 내부 함수에서 계속 기억됨performance_clocked 함수 자체를 반환 (일급 객체)performance_clocked가 실행되는 시점을 생각해보면, 이미 performance_clock은 실행이 끝난 상태다. 함수가 끝났으면 내부 변수인 func는 사라져야 정상이다.
근데 performance_clocked 입장에서 보면 func(*args)를 실행해야 한다. 이미 종료된 외부 함수의 변수 func가 필요한 것이다.
이게 클로저가 해결해주는 문제다. performance_clocked가 반환될 때 func를 자유변수로 묶어서 같이 들고 나온다. 그래서 performance_clock이 끝난 이후에도 func가 사라지지 않고 살아있는 것이다.
# 이렇게 적용하는 순간
@performance_clock
def int_to_eng_func(seconds):
...
# 내부적으로 이렇게 동작한다
# int_to_eng_func = performance_clock(int_to_eng_func)
# -> performance_clock은 이미 종료됐지만
# -> 반환된 performance_clocked 안에 int_to_eng_func(func)가 살아있다
클로저가 없었다면 데코레이터는 구조 자체가 불가능하다. 외부 함수가 종료되는 순간
func가 사라지기 때문에, 나중에 실제로 함수를 호출할 때func(*args)를 실행할 방법이 없어진다. 클로저가func를 붙잡아두기 때문에 데코레이터가 동작할 수 있는 거다.
@ vs 수동 적용데코레이터를 적용하는 방법이 두 가지인데, 동작은 완전히 동일하다.
# 수동 적용 -> 직접 실행
none_deco1 = performance_clock(int_to_eng_func)
none_deco1(1.5)
# 데코레이터 적용
@performance_clock
def int_to_eng_func(seconds):
...
int_to_eng_func(1.5) # 원함수 이름으로 바로 호출 가능
@performance_clock은 사실 int_to_eng_func = performance_clock(int_to_eng_func)를 축약한 것이다. @ 기호가 이걸 자동으로 처리해주는 것뿐이다.
처음에 "그냥 함수 만들어서 적용하면 되는 거 아닌가?"라는 생각이 들었다.
사실 쓰는 이유는 지금은 얼추 이해되는데 아직도 언제 쓰는게 가장 적합한 상황인지 약간은 모호하다.
찾아보니까 코드 재사용성, 유연한 기능 추가 같은 이유들이 나왔는데 솔직히 잘 와닿지 않았다.
나한테 제일 와닿은 이유는 관심사 분리였다.
# 비즈니스 로직 (함수의 본래 동작)
def int_to_eng_func(seconds):
response = llm.invoke([f'{seconds}이거 영어로 어떻게 읽어?']).content
print(response)
# 부가 기능 (시간 측정)
@performance_clock
def int_to_eng_func(seconds):
...
함수가 하는 일(비즈니스 로직)과 부가 기능(시간 측정, 로깅, 인증)의 관심사가 다르다. 데코레이터는 이 둘을 분리해준다. 원래 함수 코드를 건드리지 않고 기능을 붙이거나 뺄 수 있고, 여러 함수에 같은 부가 기능을 붙일 때 코드 중복도 없다.
데코레이터의 장단점을 정리하면 이렇다.
| 장점 | 단점 |
|---|---|
| 중복 제거, 코드 간결 | 가독성이 떨어질 수 있음 |
| 공통 기능(로깅, 인증, 시간 측정) 분리 | 디버깅이 불편함 |
| 조합해서 사용 용이 | 단일 함수로 쓰는 게 나은 경우도 있음 |
@tool과의 연결요즘 LangGraph를 공부하면서 @tool 데코레이터를 쓰고 있었는데, 이게 정확히 오늘 배운 원리다.
from langchain_core.tools import tool
@tool
def search_web(query: str) -> str:
"""웹에서 정보를 검색합니다."""
...
에이전트에 도구를 등록할 때 @tool을 붙여주면 그 함수가 LLM이 호출할 수 있는 도구로 변환된다. 이게 그냥 그냥 동작하는 게 아니라, 오늘 배운 데코레이터 구조 그대로다. 파이썬 문법 공부가 요즘 공부하고 있는 실제 AI 에이전트 개발이랑 이렇게 직접 연결될 줄은 몰랐다.
챕터05 전체를 돌아보면, 일급 객체 → 클로저 → 데코레이터 순서로 하나의 흐름으로 연결된다는 게 명확하게 보인다. 각각 따로 외우는 개념이 아니라, 앞 개념이 다음 개념의 기반이 되는 구조다.
클로저가 왜 필요한지 처음에는 모르겠고 이해도 안됐는데, 데코레이터를 만들어보니까 이해가 되었다.