17. 데코레이터
- 함수가 "값처럼 다뤄지는" 일급 함수 개념을 이해한다.
- 클로저(함수를 반환하는 함수)를 안다.
@데코레이터로 함수를 감싸 기능을 더한다.*args·**kwargs와functools.wraps로 제대로 된 데코레이터를 만든다.
먼저: 함수는 값이다 (일급 함수)
데코레이터를 이해하려면 먼저 한 가지 사실에 익숙해져야 합니다. Python에서 함수는 "값"입니다. 숫자 42나 문자열 "hi"처럼, 함수도 하나의 값으로 다룰 수 있다는 뜻입니다. 이 성질을 일급 함수(first-class function)라고 부릅니다. ("일급"은 "특별 대우 없이 다른 값들과 똑같이 취급된다"는 뜻입니다.)
이게 왜 특별할까요? 많은 사람이 함수를 "실행하는 동작"으로만 생각합니다. 하지만 Python에서 함수는 레시피 카드 같은 물건이기도 합니다. 카드를 서랍에 넣을 수도(변수에 담기), 남에게 건넬 수도(인자로 넘기기), 카드를 만들어 돌려줄 수도(반환하기) 있습니다. 카드를 보고 실제로 요리하는 것(함수 호출)은 그다음 일이죠.
1) 함수를 변수에 담기
가장 먼저, 함수를 변수에 담아 봅시다. 핵심은 괄호의 유무입니다.
def greet(name): return f"안녕, {name}" say = greet # ← 괄호 없음! "함수 그 자체"를 say에 담음 print(say("민지")) # 안녕, 민지 ← say로도 똑같이 호출 가능
여기서 결정적인 구분을 확실히 합시다.
greet(괄호 없음) → 함수라는 물건 자체 (레시피 카드)greet("민지")(괄호 있음) → 함수를 실행한 결과 (요리 완성품)
print(greet) # <function greet at 0x...> ← 함수 객체 자체 print(greet("민지")) # 안녕, 민지 ← 실행한 결과
say = greet는 카드를 복사해 다른 서랍에 넣은 것과 같습니다. say와 greet은 같은 함수를 가리킵니다.
```python
say = greet() # ❌ greet를 즉시 실행하려다 에러 (name 인자 없음)
say = greet # ✅ 함수 자체를 담음
```
2) 함수를 자료구조에 담기
함수가 값이라면, 리스트나 딕셔너리에도 담을 수 있겠죠? 실제로 매우 유용합니다.
def add(a, b): return a + b def sub(a, b): return a - b def mul(a, b): return a * b # 연산 기호 → 함수 를 딕셔너리로! operations = {"+": add, "-": sub, "*": mul} print(operations["+"](3, 5)) # 8 (operations["+"]가 add 함수, 거기에 (3,5)) print(operations["*"](4, 6)) # 24
operations["+"]는 add 함수를 꺼내오고, 거기에 (3, 5)를 붙여 실행합니다. 이렇게 하면 길고 긴 if/elif로 연산을 고르는 대신, 딕셔너리로 깔끔하게 분기할 수 있습니다. 함수를 리스트에 담아 차례로 적용할 수도 있습니다.
funcs = [str.upper, str.lower, str.title] # 문자열 메서드도 함수다 for f in funcs: print(f("Hello World")) # HELLO WORLD # hello world # Hello World
3) 함수를 다른 함수의 인자로 넘기기
함수를 값처럼 인자로 넘길 수 있습니다. "이 동작을 대신 해줘"라고 함수를 건네는 것이죠. 이렇게 넘기는 함수를 흔히 콜백(callback)이라 부릅니다.
def apply(func, value): return func(value) # 받은 함수를 실행 print(apply(str.upper, "hello")) # HELLO (str.upper를 넘김) print(apply(len, "hello")) # 5 (len을 넘김)
조금 더 실용적인 예를 봅시다. "리스트의 각 항목에 어떤 변환을 적용"하는데, 그 변환을 인자로 받습니다.
def transform_all(items, func): return [func(x) for x in items] print(transform_all([1, 2, 3], lambda x: x * 10)) # [10, 20, 30] print(transform_all(["a", "b"], str.upper)) # ['A', 'B']
사실 우리는 이미 이 패턴을 써왔습니다. 초급편에서 sorted(words, key=len)을 본 적 있죠? 그 len이 바로 "정렬 기준을 정하는 함수를 인자로 넘긴" 일급 함수의 예입니다.
words = ["banana", "kiwi", "apple"] print(sorted(words, key=len)) # ['kiwi', 'apple', 'banana'] (길이순)
func는 함수 자체(레시피 카드), func()는 실행 결과(요리)다. 이 구분이 데코레이터의 출발점이다.클로저: 함수를 반환하는 함수
일급 함수의 마지막 능력은 함수가 함수를 만들어 반환하는 것입니다. 그리고 여기서 아주 흥미로운 일이 일어납니다.
함수 공장 만들기
먼저 "곱셈 함수를 찍어내는 공장"을 봅시다.
def make_multiplier(factor): def multiply(n): return n * factor # 바깥의 factor를 사용 return multiply # 만든 함수를 반환 (괄호 없음!) double = make_multiplier(2) # "2를 곱하는 함수"를 받음 triple = make_multiplier(3) # "3을 곱하는 함수"를 받음 print(double(5)) # 10 (5 * 2) print(triple(5)) # 15 (5 * 3) print(double(100)) # 200
make_multiplier(2)를 부르면, 안에서 multiply 함수를 새로 만들어 돌려줍니다. double은 그렇게 만들어진 "2배 함수"입니다.
신기한 점: 함수가 값을 기억한다
여기서 멈춰서 생각해 봅시다. double = make_multiplier(2)가 끝나는 순간, make_multiplier의 실행은 종료되고 그 안의 factor도 사라져야 할 것 같습니다. 그런데 나중에 double(5)를 부르면 factor가 2였다는 걸 여전히 기억합니다. 어떻게?
안쪽 함수 multiply가 자기를 만들어준 바깥 변수 factor를 함께 품고 떠나기 때문입니다. 이렇게 "함수 + 그 함수가 기억하는 바깥 변수"의 묶음을 클로저(closure)라고 합니다. (closure = "감싸 안다"는 뜻)
도시락에 비유하면, double은 단순한 함수가 아니라 "factor=2라는 반찬이 담긴 도시락을 들고 다니는 함수"입니다. triple은 "factor=3 도시락"을 들고 다니죠. 그래서 둘은 독립적으로 자기 값을 기억합니다.
flowchart TD
Call["make_multiplier(2) 호출"]:::user --> Inner["내부 함수 multiply 생성"]:::proc
Inner --> Box["multiply가 factor=2를<br/>도시락처럼 품음 (클로저)"]:::data
Box --> Ret["multiply 반환"]:::proc
Ret --> Double["double = 그 함수<br/>(factor=2를 기억)"]:::result
Double --> Use["double(5) → 5*2 → 10"]:::result
classDef user fill:#fff3b0,stroke:#e0a800,color:#5c4500
classDef proc fill:#7fd8d8,stroke:#2a9d8f,color:#14532d
classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
classDef data fill:#a8dadc,stroke:#457b9d,color:#1d3557
double은 2, triple은 3).클로저 예제 더 보기
클로저는 "설정을 기억한 맞춤 함수"를 만들 때 빛납니다. 몇 가지 패턴을 봅시다.
(예제 1) 인사말을 기억하는 함수
def make_greeter(greeting): def greet(name): return f"{greeting}, {name}!" # greeting을 기억 return greet hello = make_greeter("Hello") annyeong = make_greeter("안녕") print(hello("Mina")) # Hello, Mina! print(annyeong("민지")) # 안녕, 민지!
hello와 annyeong은 각자 다른 인사말을 품은 함수입니다. 매번 인사말을 넘길 필요 없이, 한 번 설정하면 계속 기억합니다.
(예제 2) 값을 누적하는 함수
클로저가 기억하는 변수를 안쪽에서 바꾸려면 nonlocal 키워드가 필요합니다. "바깥 함수의 변수를 수정하겠다"는 선언입니다(초급편의 스코프를 떠올리세요 — nonlocal 없이 할당하면 새 지역 변수가 만들어집니다).
def make_accumulator(): total = 0 def add(value): nonlocal total # 바깥 total을 수정하겠다는 선언 total += value return total return add acc = make_accumulator() print(acc(10)) # 10 print(acc(20)) # 30 ← 이전 값을 기억하고 누적! print(acc(5)) # 35
acc는 호출될 때마다 total을 기억하고 거기에 더합니다. 함수가 상태를 유지하는 것이죠.
(예제 3) 호출 횟수를 세는 함수
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c = make_counter() print(c(), c(), c()) # 1 2 3 c2 = make_counter() # 새 카운터는 독립적 print(c2()) # 1 (c와 별개로 0부터)
📎nonlocal은 클로저 안에서 바깥 변수를 수정할 때만 필요합니다. 단순히 읽기만 한다면(예제 1의greeting,make_multiplier의factor)nonlocal없이도 됩니다.
⭐ 데코레이터: 함수를 감싸 기능 더하기
이제 본론입니다. 데코레이터(decorator)는 기존 함수를 감싸서, 원래 코드를 건드리지 않고 기능을 더하는 도구입니다. "실행 전후에 로그 남기기", "실행 시간 재기", "권한 확인하기" 같은 공통 작업을 함수마다 반복하지 않고 한 번에 입힙니다.
데코레이터는 함수를 받아, 그 함수를 감싼 새 함수를 반환하는 함수입니다(클로저의 응용입니다).
def my_decorator(func): def wrapper(): print("─── 함수 실행 전 ───") func() # 원래 함수 실행 print("─── 함수 실행 후 ───") return wrapper def say_hello(): print("안녕하세요!") say_hello = my_decorator(say_hello) # 감싸기 say_hello() # ─── 함수 실행 전 ─── # 안녕하세요! # ─── 함수 실행 후 ───
my_decorator(say_hello)는 say_hello를 감싼 wrapper를 돌려줍니다. 이제 say_hello()를 부르면 앞뒤로 메시지가 붙죠.
@ 문법: 더 깔끔하게
say_hello = my_decorator(say_hello)를 매번 쓰는 건 번거롭습니다. Python은 이를 @데코레이터 한 줄로 줄여줍니다. 함수 정의 바로 위에 붙입니다.
@my_decorator # = say_hello = my_decorator(say_hello) def say_hello(): print("안녕하세요!") say_hello() # 똑같이 동작
@데코레이터는 "바로 아래 함수를 그 데코레이터로 감싸라"는 뜻이다. 함수 = 데코레이터(함수)의 간편 표기다. 원래 함수 코드는 전혀 바꾸지 않는다.인자가 있는 함수 감싸기: *args, **kwargs
위 wrapper()는 인자를 못 받습니다. 그래서 인자가 있는 함수를 감싸면 깨집니다. 해결책은 초급편에서 본 *args·**kwargs입니다. 어떤 인자든 그대로 받아 원래 함수에 넘깁니다.
def logger(func): def wrapper(*args, **kwargs): # 어떤 인자든 받기 print(f"호출: {func.__name__}{args}") 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
wrapper가 func(...)의 결과를 return하지 않으면, 데코레이트된 함수가 항상 None을 돌려줍니다. 원래 함수가 값을 반환한다면 wrapper도 반드시 그 결과를 return해야 합니다.functools.wraps: 정체성 보존
데코레이터에는 한 가지 부작용이 있습니다. 감싸고 나면 원래 함수의 이름(__name__)과 설명(__doc__)이 wrapper의 것으로 바뀝니다.
@logger def add(a, b): """두 수를 더한다""" return a + b print(add.__name__) # wrapper ← add가 아니라! print(add.__doc__) # None ← 원래 설명이 사라짐
이는 디버깅을 어렵게 합니다. functools.wraps를 wrapper에 붙이면 원래 함수의 정체성이 보존됩니다.
import functools def logger(func): @functools.wraps(func) # 이 한 줄 추가! def wrapper(*args, **kwargs): result = func(*args, **kwargs) return result return wrapper @logger def add(a, b): """두 수를 더한다""" return a + b print(add.__name__) # add ← 보존됨! print(add.__doc__) # 두 수를 더한다 ← 보존됨!
wrapper 위에 @functools.wraps(func)를 붙이는 것이 표준이다. 원래 함수의 이름·문서를 보존해준다.실용 예: 실행 시간 측정
데코레이터의 진가는 실용 예에서 드러납니다. 함수의 실행 시간을 재는 데코레이터를 만들면, 어떤 함수든 @measure 한 줄로 시간을 측정할 수 있습니다.
import functools import time def measure(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__}: {elapsed:.6f}초") return result return wrapper @measure def sum_to(n): return sum(range(n)) sum_to(1_000_000) # sum_to: 0.00xxxx초
원래 sum_to 코드는 한 줄도 바꾸지 않았는데 시간 측정 기능이 붙었습니다. 다른 함수에도 @measure만 붙이면 똑같이 적용됩니다 — 이것이 데코레이터의 힘입니다.
@app.route("/"), 캐싱의 @functools.cache, 클래스의 @property(다음 단계에서 배움) 등이 모두 데코레이터입니다. 직접 만들 일은 적어도, 읽고 이해하는 능력은 꼭 필요합니다.데코레이터 동작 한눈에 보기
flowchart TD
Def["@measure<br/>def sum_to(n): ..."]:::user --> Wrap["measure(sum_to) 실행<br/>→ wrapper 반환"]:::proc
Wrap --> Replace["sum_to 이름이<br/>wrapper를 가리킴"]:::data
Replace --> Call["sum_to(1000000) 호출"]:::user
Call --> Before["① 시작 시간 기록"]:::proc
Before --> Orig["② 원래 함수 실행"]:::result
Orig --> After["③ 경과 시간 출력"]:::proc
After --> RetR["④ 원래 결과 반환"]:::result
classDef user fill:#fff3b0,stroke:#e0a800,color:#5c4500
classDef proc fill:#7fd8d8,stroke:#2a9d8f,color:#14532d
classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
classDef data fill:#a8dadc,stroke:#457b9d,color:#1d3557
이 장에서 배운 것
- Python에서 함수는 값이다(일급 함수). 변수에 담고·인자로 넘기고·반환할 수 있다.
- 클로저는 안쪽 함수가 바깥 변수를 기억하는 현상이다. "함수를 반환하는 함수"가 데코레이터의 토대다.
- 데코레이터는 함수를 감싸 원래 코드를 건드리지 않고 기능을 더한다.
@데코레이터는함수 = 데코레이터(함수)의 간편 표기다. - 인자가 있는 함수를 감싸려면
wrapper(*args, **kwargs)로 받고, 결과를return해야 한다. wrapper위에@functools.wraps(func)를 붙여 원래 함수의 이름·문서를 보존한다.
🧪 실습 문제
문제 1. 다음 클로저의 출력은?
def adder(x): def add(y): return x + y return add add10 = adder(10) print(add10(5))
문제 1-2. 함수를 값으로 다루는 연습입니다. 딕셔너리 ops에 "double": lambda x: x*2와 "square": lambda x: x**2를 담고, ops["square"](5)의 결과를 출력하세요.
문제 1-3. make_power(exp) 클로저를 작성하세요. exp를 기억해, 받은 수를 exp 제곱하는 함수를 반환합니다. square = make_power(2), cube = make_power(3)을 만들어 square(5)와 cube(2)를 출력하세요.
문제 2. 함수 실행 전에 "시작", 후에 "종료"를 출력하는 데코레이터 announce를 만들고, 임의의 함수에 적용해 동작을 확인하세요. (인자 없는 함수면 됩니다.)
문제 3. 다음 데코레이터의 문제점은? multiply(3, 4)를 호출하면 결과가 어떻게 될까요?
def deco(func): def wrapper(*args, **kwargs): func(*args, **kwargs) # ? return wrapper @deco def multiply(a, b): return a * b print(multiply(3, 4))
문제 4. 함수의 결과를 두 번 출력하는 것이 아니라, 함수를 두 번 실행해 결과를 리스트로 돌려주는 데코레이터 twice를 만드세요. @twice를 붙인 def roll(): return random.randint(1,6)을 호출하면 [주사위1, 주사위2]가 나오게 하세요.
문제 5. 다음 코드에서 @functools.wraps를 빼면 info.__name__이 무엇이 될까요? 넣으면?
import functools def deco(func): @functools.wraps(func) def wrapper(*a, **k): return func(*a, **k) return wrapper @deco def info(): pass
<details>
<summary>✅ 정답·해설 보기</summary>
1. 15. adder(10)이 x=10을 기억하는 함수를 반환하고, add10(5)는 10 + 5를 계산합니다.
1-2.
ops = {"double": lambda x: x * 2, "square": lambda x: x ** 2}
print(ops["square"](5)) # 25
딕셔너리에서 함수를 꺼내(ops["square"]) 바로 호출((5))합니다.
1-3.
def make_power(exp): def power(n): return n ** exp # exp를 기억 return power square = make_power(2) cube = make_power(3) print(square(5)) # 25 print(cube(2)) # 8
square는 exp=2를, cube는 exp=3을 각각 품은 독립적인 클로저입니다.
2.
def announce(func): def wrapper(): print("시작") func() print("종료") return wrapper @announce def work(): print("작업 중...") work() # 시작 / 작업 중... / 종료
3. wrapper가 func(...)의 결과를 return하지 않습니다. 그래서 multiply(3, 4)는 12가 아니라 None을 반환합니다. return func(*args, **kwargs)로 고쳐야 합니다.
4.
import functools, random def twice(func): @functools.wraps(func) def wrapper(*args, **kwargs): return [func(*args, **kwargs), func(*args, **kwargs)] return wrapper @twice def roll(): return random.randint(1, 6) print(roll()) # 예: [3, 6]
5. 빼면 wrapper(감싸는 함수의 이름)가 되고, 넣으면 원래 이름 info가 보존됩니다.
</details>
◀️ 이전 장: 16. 파일 입출력과 데이터 형식 | ▶️ 다음 장: 18. 타입 힌트