[Python 시리즈 14편] 데코레이터와 고차 함수 이해하기

English version

컴프리헨션으로 표현을 압축했다면, 이번에는 함수를 다루는 함수에 집중합니다. 새 용어가 많아 부담될 수 있으니, "함수를 변수처럼 넘길 수 있다"는 단순한 감각부터 잡고 출발합시다. 예를 들어 "API 호출하기 전에 공통 로그 남기기" 같은 일을 함수 하나로 감싸는 순간 이미 고차 함수를 사용한 것입니다. 이 개념을 토대로 Python 특유의 데코레이터(decorator) 문법이 만들어졌습니다.

이번 글에서 새로 나오는 용어

  1. 고차 함수: 함수를 값처럼 받아서 쓰거나 새 함수를 돌려주는 함수
  2. 클로저: 바깥 스코프 값을 기억한 채 실행되는 내부 함수 구조
  3. 데코레이터: @ 문법으로 함수에 공통 전·후처리를 덧씌우는 패턴
  4. functools.wraps: 데코레이터를 만들 때 원본 함수 이름과 설명을 보존해 주는 도구

개념 정리

학습 메모

  • 소요 시간: 60분
  • 준비물: 함수 정의·호출, 클로저 감각, pytest로 간단한 테스트 작성
  • 학습 목표: 직접 데코레이터를 구현하고 functools.wraps까지 적용해 재시도·타이머 패턴 만들기
  • 고차 함수는 함수를 값처럼 다루는 구조입니다.
  • 클로저(closure)는 바깥 변수를 기억한 채 실행되는 내부 함수입니다.
  • 데코레이터는 함수에 공통 전·후처리를 덧씌우는 문법입니다.
  • functools.wraps는 포장 함수가 원본 함수의 이름과 설명을 유지하게 도와줍니다.
  • "Core"로 표시된 섹션만 익혀도 실무에 바로 쓰고, "Optional"은 여유가 있을 때 천천히 살펴보세요.

코드로 이해하기

고차 함수 기초 (Core)

def apply_twice(func, value):
    return func(func(value))

def increment(x):
    return x + 1

result = apply_twice(increment, 3)  # 5
  • func 인자에는 호출 가능한(callable) 객체를 전달합니다.
  • 함수도 다른 값처럼 변수에 담거나 전달할 수 있다는 점이 파이썬의 유연함입니다.
  • 이렇게 간단한 예시를 충분히 납득한 뒤에만 다음 섹션으로 넘어가세요.

내부 함수와 클로저 (Core)

def make_multiplier(factor):
    def multiply(value):
        return value * factor

    return multiply

double = make_multiplier(2)
double(5)  # 10

multiply는 바깥 스코프의 factor를 기억합니다. 이렇게 바깥 변수 상태를 잡아둔 함수클로저(closure)라고 합니다. 데코레이터는 주로 내부 함수와 클로저 구조로 구현됩니다.

데코레이터 기본형 (Core)


def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} 실행 {elapsed:.3f}s")
        return result

    return wrapper


@timer
def fetch_data():
    time.sleep(0.2)
    return {"status": "ok"}
  • @timer 구문은 fetch_data = timer(fetch_data)와 동일합니다.
  • wrapper 내부에서 원본 함수를 호출하며 앞·뒤에 공통 동작을 삽입합니다.
  • *args, **kwargs는 위치·키워드 인자를 그대로 넘겨주는 패턴입니다.
direction: right

caller: "mealbot.fetcher
decorator: "@timer/@retry
wrapper: "wrapper()
target: "원본 함수 호출"

caller -> decorator: "데코레이터를 붙임"
decorator -> wrapper: "wrapper 생성"
wrapper -> target: "*args/**kwargs 전달"
target -> wrapper: "결과 반환"
wrapper -> caller: "결과 다시 전달"

시각적으로 보면 데코레이터는 단순히 함수를 한 겹 더 감싸 공통 코드를 재사용하는 도구라는 점이 명확해집니다. "인자 받아서 실행한다"는 기존 패턴과 크게 다르지 않으니 겁먹지 마세요.

functools.wraps로 메타데이터 유지 (Core)


def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        ...

    return wrapper

functools.wraps 데코레이터는 원본 함수의 이름과 문서 문자열(docstring)을 포장 함수에 복사합니다. 디버깅과 자동 문서화 도구에서 특히 중요합니다.

매개변수를 받는 데코레이터 (Core → Plus)

이제 함수에 붙이는 옵션을 직접 받는 단계입니다. 함수가 겹겹이 싸여 난도가 갑자기 올라가 보일 수 있지만, "함수를 반환한다"는 한 가지 규칙만 지키면 됩니다.

def retry(max_attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    print(f"{attempt}회 실패: {exc}")
            raise RuntimeError("재시도 초과")

        return wrapper

    return decorator


@retry(max_attempts=5)
def unstable_call():
    ...

데코레이터 자체가 인자를 필요로 할 때는 "데코레이터를 만드는 함수"를 한 번 더 감쌉니다. 함수 레이어가 많아지므로 변수명과 반환 구조를 명확히 유지하세요. 필요하면 손으로 호출 순서를 적어 보며 천천히 따라가도 좋습니다.

실전 예: Slack 알림 재시도 (Core → Plus)

Typer CLI에서 종종 Slack 웹훅을 호출해야 했습니다. API가 가끔 500을 반환해도 바로 포기하고 싶지 않다면 retry 패턴이 딱 맞습니다.



def retry_webhook(max_attempts=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except httpx.HTTPError as exc:
                    print(f"#{attempt} Slack 전송 실패", exc)
                    time.sleep(0.5)
            raise RuntimeError("웹훅 재시도 초과")

        return wrapper

    return decorator


@retry_webhook(max_attempts=5)
def send_slack(payload: dict):
    httpx.post(WEBHOOK_URL, json=payload, timeout=3)
  • 언제 사용하나? 반복되는 API 실패 처리, 공통 캐싱, 공통 인증 등 "모든 함수 호출 전에 같은 준비가 필요한" 시점입니다.
  • 무엇을 얻나? 재시도 로직을 한 곳에 두어 CLI·웹앱 어디서든 재사용 가능합니다.

표준 라이브러리의 고차 함수 (Optional)

  • map(func, iterable): 각 요소에 함수를 적용합니다.
  • filter(func, iterable): 조건을 통과하는 요소만 남깁니다.
  • functools.partial(func, **fixed_args): 일부 인자를 미리 채운 새 함수를 만듭니다.
  • functools.lru_cache: 결과를 메모이즈(캐싱)하는 데코레이터입니다.

고차 함수를 과용하면 오히려 읽기 어려워집니다. 반복 패턴을 줄이고 부수효과를 통제할 때만 선택적으로 사용하세요. 가장 좋은 신호는 "이 코드 복붙을 줄이고 싶다"는 생각이 들 때입니다. 당장 필요하지 않다면 이 구역은 가볍게 훑고 넘어가도 괜찮습니다.

왜 중요할까

  • 고차 함수는 함수를 값처럼 다룹니다.
  • 데코레이터는 공통 전·후처리를 함수에 주입하는 문법 설탕입니다.
  • functools 모듈을 활용하면 성능과 가독성을 동시에 챙길 수 있습니다.

실습

  • 따라 하기: timer 데코레이터를 구현하고 @timer를 붙인 함수에서 실행 시간을 출력합니다.
  • 확장하기: retry 데코레이터를 응용해 HTTP 요청 함수에 적용하고 실패 메시지를 로거에 남기도록 확장합니다.
  • 디버깅: functools.wraps를 제거해 함수 이름과 도큐스트링이 어떻게 바뀌는지 확인하고 다시 적용해 IDE 자동완성 문제를 해결합니다.
  • 완료 기준: 매개변수를 받지 않는 데코레이터와 받는 데코레이터를 각각 만들어 실제 코드에 적용해 봤을 때입니다.

마무리

다음 편에서는 이터레이터·제너레이터를 통해 "필요할 때만 값을 생산"하는 구조를 직접 구현해 보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요