17. 데코레이터

🎯 이 장의 목표
  • 함수가 "값처럼 다뤄지는" 일급 함수 개념을 이해한다.
  • 클로저(함수를 반환하는 함수)를 안다.
  • @데코레이터로 함수를 감싸 기능을 더한다.
  • *args·**kwargsfunctools.wraps로 제대로 된 데코레이터를 만든다.

먼저: 함수는 값이다 (일급 함수)

데코레이터를 이해하려면 먼저 한 가지 사실에 익숙해져야 합니다. Python에서 함수는 "값"입니다. 숫자 42나 문자열 "hi"처럼, 함수도 하나의 값으로 다룰 수 있다는 뜻입니다. 이 성질을 일급 함수(first-class function)라고 부릅니다. ("일급"은 "특별 대우 없이 다른 값들과 똑같이 취급된다"는 뜻입니다.)

이게 왜 특별할까요? 많은 사람이 함수를 "실행하는 동작"으로만 생각합니다. 하지만 Python에서 함수는 레시피 카드 같은 물건이기도 합니다. 카드를 서랍에 넣을 수도(변수에 담기), 남에게 건넬 수도(인자로 넘기기), 카드를 만들어 돌려줄 수도(반환하기) 있습니다. 카드를 보고 실제로 요리하는 것(함수 호출)은 그다음 일이죠.

1) 함수를 변수에 담기

가장 먼저, 함수를 변수에 담아 봅시다. 핵심은 괄호의 유무입니다.

PYTHON
def greet(name):
    return f"안녕, {name}"

say = greet              # ← 괄호 없음! "함수 그 자체"를 say에 담음
print(say("민지"))        # 안녕, 민지   ← say로도 똑같이 호출 가능

여기서 결정적인 구분을 확실히 합시다.

  • greet (괄호 없음) → 함수라는 물건 자체 (레시피 카드)
  • greet("민지") (괄호 있음) → 함수를 실행한 결과 (요리 완성품)
PYTHON
print(greet)            # <function greet at 0x...>   ← 함수 객체 자체
print(greet("민지"))     # 안녕, 민지                   ← 실행한 결과

say = greet는 카드를 복사해 다른 서랍에 넣은 것과 같습니다. saygreet은 같은 함수를 가리킵니다.

⚠️ 흔한 실수
흔한 함정 — 괄호 실수: 함수를 넘기려다 실수로 괄호를 붙이면, 함수가 아니라 실행 결과가 넘어갑니다.
```python
say = greet() # ❌ greet를 즉시 실행하려다 에러 (name 인자 없음)
say = greet # ✅ 함수 자체를 담음
```

2) 함수를 자료구조에 담기

함수가 값이라면, 리스트나 딕셔너리에도 담을 수 있겠죠? 실제로 매우 유용합니다.

PYTHON
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로 연산을 고르는 대신, 딕셔너리로 깔끔하게 분기할 수 있습니다. 함수를 리스트에 담아 차례로 적용할 수도 있습니다.

PYTHON
funcs = [str.upper, str.lower, str.title]    # 문자열 메서드도 함수다
for f in funcs:
    print(f("Hello World"))
# HELLO WORLD
# hello world
# Hello World

3) 함수를 다른 함수의 인자로 넘기기

함수를 값처럼 인자로 넘길 수 있습니다. "이 동작을 대신 해줘"라고 함수를 건네는 것이죠. 이렇게 넘기는 함수를 흔히 콜백(callback)이라 부릅니다.

PYTHON
def apply(func, value):
    return func(value)           # 받은 함수를 실행

print(apply(str.upper, "hello"))      # HELLO   (str.upper를 넘김)
print(apply(len, "hello"))            # 5       (len을 넘김)

조금 더 실용적인 예를 봅시다. "리스트의 각 항목에 어떤 변환을 적용"하는데, 그 변환을 인자로 받습니다.

PYTHON
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이 바로 "정렬 기준을 정하는 함수를 인자로 넘긴" 일급 함수의 예입니다.

PYTHON
words = ["banana", "kiwi", "apple"]
print(sorted(words, key=len))    # ['kiwi', 'apple', 'banana']  (길이순)
📌 핵심
핵심: Python에서 함수는 값이다. 변수·리스트·딕셔너리에 담고, 인자로 넘기고(콜백), 반환할 수 있다. func는 함수 자체(레시피 카드), func()는 실행 결과(요리)다. 이 구분이 데코레이터의 출발점이다.

클로저: 함수를 반환하는 함수

일급 함수의 마지막 능력은 함수가 함수를 만들어 반환하는 것입니다. 그리고 여기서 아주 흥미로운 일이 일어납니다.

함수 공장 만들기

먼저 "곱셈 함수를 찍어내는 공장"을 봅시다.

PYTHON
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)를 부르면 factor2였다는 걸 여전히 기억합니다. 어떻게?

안쪽 함수 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) 인사말을 기억하는 함수

PYTHON
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("민지"))     # 안녕, 민지!

helloannyeong은 각자 다른 인사말을 품은 함수입니다. 매번 인사말을 넘길 필요 없이, 한 번 설정하면 계속 기억합니다.

(예제 2) 값을 누적하는 함수

클로저가 기억하는 변수를 안쪽에서 바꾸려면 nonlocal 키워드가 필요합니다. "바깥 함수의 변수를 수정하겠다"는 선언입니다(초급편의 스코프를 떠올리세요 — nonlocal 없이 할당하면 새 지역 변수가 만들어집니다).

PYTHON
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) 호출 횟수를 세는 함수

PYTHON
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_multiplierfactor) nonlocal 없이도 됩니다.

⭐ 데코레이터: 함수를 감싸 기능 더하기

이제 본론입니다. 데코레이터(decorator)기존 함수를 감싸서, 원래 코드를 건드리지 않고 기능을 더하는 도구입니다. "실행 전후에 로그 남기기", "실행 시간 재기", "권한 확인하기" 같은 공통 작업을 함수마다 반복하지 않고 한 번에 입힙니다.

데코레이터는 함수를 받아, 그 함수를 감싼 새 함수를 반환하는 함수입니다(클로저의 응용입니다).

PYTHON
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은 이를 @데코레이터 한 줄로 줄여줍니다. 함수 정의 바로 위에 붙입니다.

PYTHON
@my_decorator              # = say_hello = my_decorator(say_hello)
def say_hello():
    print("안녕하세요!")

say_hello()                # 똑같이 동작
📌 핵심
핵심: @데코레이터는 "바로 아래 함수를 그 데코레이터로 감싸라"는 뜻이다. 함수 = 데코레이터(함수)의 간편 표기다. 원래 함수 코드는 전혀 바꾸지 않는다.

인자가 있는 함수 감싸기: *args, **kwargs

wrapper()는 인자를 못 받습니다. 그래서 인자가 있는 함수를 감싸면 깨집니다. 해결책은 초급편에서 본 *args·**kwargs입니다. 어떤 인자든 그대로 받아 원래 함수에 넘깁니다.

PYTHON
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
⚠️ 흔한 실수
흔한 함정 1 — 결과 반환 잊기: wrapperfunc(...)의 결과를 return하지 않으면, 데코레이트된 함수가 항상 None을 돌려줍니다. 원래 함수가 값을 반환한다면 wrapper도 반드시 그 결과를 return해야 합니다.

functools.wraps: 정체성 보존

데코레이터에는 한 가지 부작용이 있습니다. 감싸고 나면 원래 함수의 이름(__name__)과 설명(__doc__)이 wrapper의 것으로 바뀝니다.

PYTHON
@logger
def add(a, b):
    """두 수를 더한다"""
    return a + b

print(add.__name__)    # wrapper   ← add가 아니라!
print(add.__doc__)     # None      ← 원래 설명이 사라짐

이는 디버깅을 어렵게 합니다. functools.wrapswrapper에 붙이면 원래 함수의 정체성이 보존됩니다.

PYTHON
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 한 줄로 시간을 측정할 수 있습니다.

PYTHON
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. 다음 클로저의 출력은?

PYTHON
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)를 호출하면 결과가 어떻게 될까요?

PYTHON
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__이 무엇이 될까요? 넣으면?

PYTHON
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.

PYTHON
ops = {"double": lambda x: x * 2, "square": lambda x: x ** 2}
print(ops["square"](5))   # 25

딕셔너리에서 함수를 꺼내(ops["square"]) 바로 호출((5))합니다.

1-3.

PYTHON
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

squareexp=2를, cubeexp=3을 각각 품은 독립적인 클로저입니다.

2.

PYTHON
def announce(func):
    def wrapper():
        print("시작")
        func()
        print("종료")
    return wrapper

@announce
def work():
    print("작업 중...")

work()   # 시작 / 작업 중... / 종료

3. wrapperfunc(...)의 결과를 return하지 않습니다. 그래서 multiply(3, 4)는 12가 아니라 None을 반환합니다. return func(*args, **kwargs)로 고쳐야 합니다.

4.

PYTHON
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. 타입 힌트