/home/caml-shaving

파이썬 데코레이터

2022-02-24

태그: dev python

요즘 파이썬으로 다양한 작업을 하고 있다. 덕분에 동적 타이핑 세계에서만 해볼 수 있는 다양한 짓(?)을 해보고 있는데, 그 중에서도 데코레이터를 유용하게 썼던 경험을 소개해본다.

파이썬 데코레이터

데코레이터의 생긴 모습 자체는 익숙하다. 자바에도 어노테이션이라는게 있긴 한데, 얘는 정적인 용도로 컴파일 타임에 주로 쓰이는 것으로 알고 있다. 반면 파이썬의 데코레이터는 동적인 환경에서 온갖 다양한 훅을 구사할 수 있다. 생긴 모습은 다음과 같다.

@decorator
def foo(x):
    return x

@로 시작하는 부분이 함수에 씌여진 데코레이터이다. 적용된 함수의 모든 것을 동적으로 후킹할 수 있는데, 예를 들면

그러니까 함수의 행동을 동적으로 조작하고 싶을 때 유용한 것으로 이해했다.

모든 것이 오브젝트로 관리되는 파이썬 세계에서 사실 데코레이터 역시 그냥 함수다. 그러니까 위의 코드는 사실 아래와 같다.

def decorator(func_to_wrap):
    def closure(*args, **kwargs):
        return func_to_wrap(*args, **kwargs)

    return closure

# apply decorator
foo = decorator(foo)

함수도 오브젝트이므로 파라미터로 넘길 수 있는데(functional?!), 이를 받아서 원래 함수와 원래 아규먼트를 가지고 다양한 짓을 하는 클로저(closure)를 만들어서 리턴하는 함수가 바로 데코레이터다. 여기서는 그냥 원래 함수를 원래 아규먼트로 호출했다. @decorator는 데코레이터를 감싸서 만든 클로저 foo = decorator(foo)를 좀더 읽기 좋게 만들어주는 Syntactic Sugar다.

코드를 보면 알겠지만 foo에 넘어가는 모든 파라미터들을 closure(*args, **kwargs)로 후킹하고 있기 때문에 우리는 이 모든 파라미터들에 접근할 수 있게 된다.

functools.wraps

다만 위와 같이 클로저를 만들어 버리면 한 가지 문제가 생긴다. 원본 함수 foo를 받아서 이걸 클로저로 덮어 쓴 함수 오브젝트를 리턴하기 때문에, 데코레이팅된 foo 함수 오브젝트를 print로 찍어보면 이름이 foo가 아니라 closure가 나온다.

<function decorator.<locals>.closure at ....>

생각해보면 당연하다. 우리는 원본 foo 함수를 돌려준게 아니기 때문이다. 앞에서 모든 것을 후킹할 수 있다고 했는데, 이러면 함수 이름이나 함수가 속한 클래스를 못찾게 된다. 여기서는 함수 이름을 예시로 들었지만, 실제로는 원본 함수 오브젝트의 모든 함수 관련 속성(function attribute), 즉 __name__, __dict__, __qualname__, __code__, __module__, __doc__ 등을 잃어버리게 된다.

이 문제를 해결해주는 아이가 바로 functools.wraps이다. 얘는 표준 라이브러리에 있으니까 맘 편히 쓰면 된다. 사용 방법은 위의 데코레이터 정의에 다음 한 줄을 추가해주면 된다.

import functools

def decorator(func_to_wrap):
    @functools.wraps(func_to_wrap)
    def closure(*args, **kwargs):
        return func_to_wrap(*args, **kwargs)

    return closure

한마디로 정확한 데코레이터를 만들기 위한 데코레이터다. 뭔가 점점 게슈탈트 붕괴가 일어나는 듯 하다. 아무튼 얘는 파라미터로 받은 원본 함수 오브젝트의 모든 메타데이터를 유지해준다. 구체적으로는 update_wrapperparital 등 깊은 내부 구현 사항이 있는데 거기까지 알아야 할 일은 없을 것 같아서 이쯤에서 멈추겠다.

아무튼 데코레이터를 만들 때는 functools.wraps로 원본 함수를 한번 감싸 줘야 함수 속성이 유지된다는 것만 기억하면 된다.

유용하게 썼던 데코레이터

타이머

먼저 타이머다. 이름 그대로 함수에 타이머를 달아서 수행 시간을 측정할 수 있다.

import time
import datetime
import functools

def timer(func):
    @functools.wraps(func)
    def closure(*args, **kwargs):
        started = time.time()
        res = func(*args, **kwargs)
        finished = time.time()
        time_spent = datetime.timedelta(seconds=(finished - started))
        print(f"{func.__qualname__} took {time_spent}")
        return res

    return closure

여기서 좀더 나가면 *args 또는 **kwargs에 특정 타입의 오브젝트가 넘어온다고 가정하고, 해당 오브젝트의 특정 필드에 함수 수행 시간을 기록할 수도 있다. 나는 보통 장고의 ORM 오브젝트를 넘겨서 time_spent를 저장하기도 했다.

예외 삼키기

많은 API를 호출해서 결과 페이로드를 파싱해야 할 때가 있다. 그런데 오래 서비스된 API라서 페이로드의 모양이 일정하지 않은 경우가 종종 있다. 즉, 서버의 버전이 업그레이드되면서 Json의 특정 필드가 null 인 경우가 생기는 것이다. 이런 코너 케이스를 모두 일일이 찾아서 그에 해당하는 디폴트 값을 줘도 되지만, 단순하게 특정 필드가 null인 경우를 아예 무시해도 좋은 경우라면 그냥 예외를 삼켜버리면 된다. 보통 다음 예외가 발생한다:

사실 모든 예외를 싸그리 Exception으로 잡아서 무시해도 되지만, 그러면 다른 오류가 난 경우까지 삼켜버리기 때문에 나중에 괴상한 오류를 만날 수도 있으니 주의해야 한다. 아무튼 이렇게 집어 삼킬 예외를 정의하고 나면 다음과 같은 데코레이터를 사용할 수 있다.

import functools

def swallow_exception(func):
    @functools.wraps(func)
    def closure(*args, **kwargs):
        try:
            res = func(*args, **kwargs)
        except (TypeError, KeyError, AttributeError):
            res = None
        return res

    return closure

별거 없이 try ... except로 한번 감싸서 원하는 예외만 삼키는 구조인데, 내가 겪은 것처럼 예외가 여기저기 발생할 수 있어서 여기저기 try를 삽입하기 귀찮을 때 유용하게 쓸 수 있다.

데코레이터 적용 순서

앞에서 데코레이터는 그냥 클로저를 리턴하는 함수라고 했는데, 따라서 당연히 하나의 함수에 여러 개의 데코레이터를 적용하는 것도 가능하다. 그리고 파이썬 표준에서는 이렇게 여러 개의 데코레이터가 적용됐을 때 어떤 순서로 적용되는지를 명시하고 있는데, 한마디로 함수에 가까운 것부터 먼저 적용된다. 코드를 위아래로 훑는다고 생각하면 일종의 스택이라고 생각해도 되겠다. 맨 마지막(아래) 데코레이터부터 적용되니까.

아래와 같이 위의 두 가지 데코레이터를 두 가지 순서로 적용한 경우를 모두 살펴보자.

# swallow_exception -> timer
@timer
@swallow_exception
def bar():
    raise TypeError


# timer -> swallow_exception
@swallow_exception
@timer
def baz():
    raise TypeError

정적 타입과 함수형 프로그래밍 지지자로서 파이썬 관련 글은 피하고 싶었지만, 파이썬으로 밥 벌어 먹고 살다 보니 이쪽의 경험이 유의미하게 늘어나고 있고 개중에는 또 재밌고 유용한 것도 있어서 이렇게 기록을 남기게 되었다. 기왕 이렇게 된 거 종종 파이썬 관련 글도 써봐야겠다.