포스트

Python 데코레이터와 매직 메서드 차이: 함수 실행과 연산자를 바꾸는 법

데코레이터는 함수를 호출하는 앞뒤 절차를 재사용하고, 매직 메서드는 객체가 연산자와 내장 문법에 반응하는 방식을 정한다. 둘 다 기존 코드를 감싸거나 바꿔 보이지만, 개입하는 지점은 완전히 다르다.

데코레이터의 안전성은 원래 함수의 인자, 반환값, 예외를 보존하는 데서 나오고, 매직 메서드의 안전성은 Python 사용자가 기대하는 연산 의미를 지키는 데서 나온다. 코드를 짧게 만드는 것보다 호출과 객체 계약을 더 분명하게 만드는지가 선택 기준이다.

@decorator는 실제로 무엇을 바꾸나

여러 함수가 실행되기 전에 함수 이름을 출력해야 한다고 해보자. 각 함수 본문에 같은 코드를 반복하면 요구사항이 바뀔 때 모두 수정해야 한다.

1
2
3
4
5
6
7
def func1():
    print(func1.__name__)
    print('run code 1')

def func2():
    print(func2.__name__)
    print('run code 2')

데코레이터는 함수를 받아 새 함수를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def my_deco(func):
    def get_func_name():
        print(func.__name__)
        func()
    return get_func_name

@my_deco
def func1():
    print('run code 1')

@my_deco
def func2():
    print('run code 2')

func1()
func2()

return get_func_name에서 괄호를 붙이지 않는 것이 핵심이다. 함수 객체를 반환해야 나중에 func1()을 호출할 때 wrapper가 실행된다. return get_func_name()으로 쓰면 장식하는 시점에 즉시 실행되고, 반환값이 원래 함수 이름에 들어간다.

@my_deco는 개념적으로 원래 함수를 func1 = my_deco(func1)로 다시 묶는 표기다. 따라서 호출 전에 로깅처럼 공통으로 들어갈 절차를 한곳에서 관리할 수 있다.

다만 위 예제의 wrapper는 인자를 받지 않는다. 인자가 있는 함수까지 처리하는 범용 구현이 아니라 데코레이터의 반환 구조를 확인하는 최소 예제다. 실제 프로젝트 코드를 이 조각으로 그대로 대체하면 인자 전달에서 문제가 생길 수 있다.

매직 메서드는 연산자의 의미를 어떻게 정하나

__init__처럼 이름 앞뒤에 이중 밑줄이 있는 메서드를 매직 메서드라고 부른다. 사용자가 a + b라고 쓸 때 객체의 __add__가 호출되는 식으로 Python 문법과 연결된다.

다음 클래스는 int를 상속하고 사칙연산 결과를 계산 과정이 포함된 문자열로 돌려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Calc(int):
    def __add__(self, num):
        return '{} + {} = {}'.format(
            self.real, num.real, self.real + num.real
        )

    def __sub__(self, num):
        return '{} - {} = {}'.format(
            self.real, num.real, self.real - num.real
        )

    def __mul__(self, num):
        return '{} x {} = {}'.format(
            self.real, num.real, self.real * num.real
        )

    def __truediv__(self, num):
        return '{} / {} = {}'.format(
            self.real, num.real, self.real / num.real
        )
1
2
3
4
5
6
value = Calc(5)

print(value + 6)
print(value - 6)
print(value * 6)
print(value / 6)

출력은 다음처럼 바뀐다.

1
2
3
4
5 + 6 = 11
5 - 6 = -1
5 x 6 = 30
5 / 6 = 0.8333333333333334

여기서 주의할 점은 연산 결과가 더 이상 숫자가 아니라 문자열이라는 사실이다. value + 6 + 7처럼 후속 산술 연산을 기대하면 자연스럽게 이어지지 않는다. 예제의 목적은 “계산 클래스를 잘 설계하는 법”이 아니라 연산자 문법이 어떤 메서드로 연결되는지 눈으로 확인하는 것이다.

둘 중 무엇을 써야 할까

판단 기준은 수정하려는 대상이다.

  • 여러 함수에 같은 전처리, 출력 절차를 적용하려면 데코레이터를 검토한다.
  • 객체가 +, -, *, / 같은 문법에 반응하는 방식을 정하려면 매직 메서드를 구현한다.
  • 함수 본문 자체의 핵심 로직이 다르다면 억지로 데코레이터에 숨기지 않는다.
  • 연산자 결과가 사용자의 일반적인 기대와 크게 다르면 편리함보다 오해가 커질 수 있다.

매직 메서드 종류를 더 찾아볼 때는 Python Magic Methods 번역 자료를 참고할 수 있다. 원문의 추가 참고 자료는 Python OOP Part 6다.

두 기능을 “코드를 짧게 만드는 마법”으로 외우기보다, 호출을 감쌀 것인지 객체의 프로토콜을 정의할 것인지로 구분하면 실제 코드를 읽을 때 훨씬 빠르게 이해할 수 있다.

데코레이터 실행 순서를 손으로 따라가는 법

@decorator 표기를 보면 먼저 아래 함수 정의가 decorator의 인자로 전달된다고 풀어 쓴다. 반환된 wrapper가 원래 함수 이름에 연결되고, 실제 호출 시 wrapper 안에서 원래 함수를 부른다. 정의 시점과 호출 시점을 구분하면 로그가 import 때 찍히는지 함수 실행 때 찍히는지 이해하기 쉽다.

Wrapper는 받은 positional, keyword 인자를 원래 함수에 전달하고 반환값을 다시 돌려줘야 한다. 단순 출력 예제에서는 반환을 빠뜨려도 눈에 띄지 않지만 계산 함수에서는 결과가 None으로 바뀔 수 있다. 원래 함수가 예외를 낼 때 wrapper가 삼킬지 그대로 전달할지도 의도적으로 정한다.

인자가 있는 decorator는 설정을 받는 바깥 함수, 실제 함수를 받는 함수, 호출을 받는 wrapper처럼 층이 늘어난다. 괄호 하나를 “마법”으로 넘기지 말고 각 단계가 무엇을 받아 무엇을 반환하는지 이름을 붙인다. Closure가 유지하는 설정값도 여러 함수가 공유하는지 함수별로 생성되는지 확인한다.

데코레이터가 맞는 문제와 아닌 문제

시간 측정, 권한 확인, 재시도처럼 여러 함수에 같은 호출 정책을 적용할 때 후보가 된다. 하지만 업무 로직의 핵심 분기를 decorator 안에 숨기면 호출부만 읽고 실제 동작을 알기 어렵다. 공통 절차인지 특정 함수의 책임인지 먼저 구분한다.

Decorator를 겹쳐 쓰면 아래쪽부터 감싸진 구조가 된다. 각 wrapper의 실행 전과 후 순서를 작은 로그로 확인하고, 예외와 반환값이 어느 층에서 바뀌는지 본다. 순서에 따라 인증 전에 cache를 보거나 그 반대가 될 수 있으므로 장식 순서도 API 계약의 일부다.

테스트에서는 원래 인자 전달, 반환값, 예외, decorator 전후 side effect를 각각 확인한다. 정상 입력 한 번만 통과시키면 wrapper가 optional 인자나 예외 경로를 깨뜨리는 문제를 놓칠 수 있다.

매직 메서드의 의미를 설계하는 법

__add__는 단순히 두 객체의 field를 더하는 코드를 넣는 자리가 아니다. 어떤 상대 타입을 허용하고 결과가 새 객체인지 기존 객체 변경인지, 단위와 범위가 무엇인지 정한다. 일반 사용자가 a + b를 보고 예상할 의미와 크게 다르면 명시적 method가 더 낫다.

__repr__이나 __len__처럼 다른 문법에 대응하는 메서드도 같은 원칙을 따른다. 보기 좋은 문자열과 디버깅 표현을 섞거나, 길이가 아닌 비용을 반환하면 Python 생태계의 기대와 충돌할 수 있다. 편리함보다 예측 가능성이 우선이다.

지원하지 않는 타입과 빈 값, 경계값을 시험한다. 같은 class끼리만 되는 연산인지 숫자와도 되는지 명시하고, 연산 순서를 바꿨을 때도 의미가 있는지 확인한다. 구현이 존재한다는 이유만으로 모든 조합을 억지로 허용하지 않는다.

두 기능이 함께 나타날 때 읽는 순서

매직 메서드 자체에도 decorator가 붙을 수 있지만 두 역할은 여전히 다르다. 먼저 매직 메서드가 객체 protocol에서 어떤 호출을 받는지 보고, 그 바깥 decorator가 호출 전후 무엇을 추가하는지 본다. 한 층씩 풀어 쓰면 오류가 wrapper인지 연산 의미인지 분리된다.

리뷰에서는 숨겨진 동작의 범위를 묻는다. 함수 이름만 보고 알 수 없는 I/O나 상태 변경을 decorator가 추가하는지, +가 예상 밖의 외부 작업을 하는지 확인한다. 짧은 문법이 많은 책임을 감출수록 문서와 테스트가 더 필요하다.

데코레이터를 겹쳐 쓸 때는 아래쪽 데코레이터가 먼저 원래 함수를 감싸고, 호출할 때는 바깥 wrapper부터 실행된다는 두 시점을 나눠야 합니다. 인증, 재시도, 로깅 순서를 바꾸면 실패 시 기록 여부와 재시도마다 인증을 반복하는지가 달라질 수 있습니다. Wrapper가 원래 함수의 인자와 반환값을 그대로 전달하는지, 예외를 숨기지 않는지, 함수 메타데이터를 보존하는지도 테스트합니다.

매직 메서드는 문법을 예쁘게 만드는 대신 사용자가 이미 기대하는 의미를 따라야 합니다. 두 객체의 +가 새 객체를 만드는지 원본을 바꾸는지, 다른 타입과 더할 때 무엇을 반환할지, ==와 hash의 관계를 어떻게 유지할지를 정해야 합니다. 지원하지 않는 피연산자를 임의로 변환하면 조용한 오류가 생길 수 있으므로 Python이 반대쪽 연산이나 오류 처리를 시도할 수 있는 반환 방식도 고려합니다.

둘을 함께 쓰는 코드에서는 먼저 클래스 문법이 어떤 매직 메서드 호출로 바뀌는지 확인한 뒤, 그 메서드가 데코레이터로 감싸졌는지 추적합니다. 측정 데코레이터가 __call__을 감싸면 객체 생성이 아니라 객체 호출 시간을 재고, __init__을 감싸면 반환값을 바꾸어서는 안 됩니다. 편리한 문법이 실제 실행 지점을 가리기 때문에 호출 경계를 작은 테스트로 고정하는 것이 안전합니다.

함께 읽으면 이해가 이어지는 글

자주 묻는 질문

데코레이터는 원래 함수 본문을 직접 바꾸나요?

원래 함수를 인자로 받아 새 wrapper 함수를 반환하고, 그 이름이 wrapper를 가리키게 합니다. 따라서 호출 전후 공통 절차를 넣되 원래 인자와 반환값을 올바르게 전달해야 합니다.

__add__를 구현하면 + 연산에서 무엇이 달라지나요?

해당 class 객체가 왼쪽 피연산자일 때 더하기가 어떤 결과를 반환할지 정의할 수 있습니다. 지원하지 않는 상대 타입과 결과 타입의 의미도 일관되게 정해야 합니다.

데코레이터와 매직 메서드를 고르는 가장 간단한 기준은 무엇인가요?

여러 함수의 호출 절차를 감싸려면 데코레이터를, 객체가 연산자, 길이, 표현 같은 Python 문법에 반응하는 방식을 정의하려면 매직 메서드를 검토합니다.

THE END / OPSOAI

여기까지 읽었습니다

핵심 장면을 한 번 더 떠올려 보세요. 이해가 남았다면 이 책은 제 역할을 다했습니다.

다른 책 고르기
표지 1

키와 좌우 스와이프를 지원합니다. 읽던 페이지는 이 기기에 저장됩니다.

CONTENTS

이 책의 목차

    9개 장 18 분읽는 시간