매직 메서드(magic method, 밑줄 두 개로 둘러싼 특별한 메서드)는 파이썬 문법과 내가 만든 객체를 연결하는 다리다. 던더 메서드(dunder method, double underscore의 줄임말)라고도 부른다. print, ==, len, +, 정렬 같은 파이썬의 기본 기능들은 사실 객체 안의 약속된 이름의 메서드를 부르는 것뿐이다. 그 메서드를 내 클래스에 정의하면, 내 객체도 파이썬 문법에 자연스럽게 올라탄다. 매직 메서드를 안 쓴 객체와 쓴 객체를 나란히 놓고 차이를 본다.

10.1 print가 주소를 뱉을 때

매직 메서드 없이 만든 객체를 print하면 사람이 알아볼 수 없는 메모리 주소가 나온다. before 코드다.


class BookBad:
def __init__(self, title):
self.title = title
print("기본 표현:", str(BookBad("파이썬"))) # 주소가 그대로 노출


__str__과 __repr__을 정의하면 원하는 표현이 나온다. after 코드다.


class Book:
def __init__(self, title):
self.title = title
def __str__(self): # 사람이 읽기 좋은 표현
return f"책<{self.title}>"
def __repr__(self): # 개발자용, 디버깅 표현
return f"Book({self.title!r})"

b = Book("파이썬")
print("__str__:", str(b))
print("__repr__:", repr(b))


실행 결과다. 주소값은 실행할 때마다 달라지고, 나머지는 동일하다.


기본 표현: <__main__.BookBad object at 0x00000215F480A660>
__str__: 책<파이썬>
__repr__: Book('파이썬')


__str__은 print나 str이 쓰는 사용자용 표현이고, __repr__은 개발자가 디버깅할 때 보는 표현이다. 리스트 안에 객체를 넣고 출력할 때도 __repr__이 쓰인다. 무엇이 좋아졌나. 로그나 디버깅에서 객체가 무엇인지 바로 보인다. BookBad는 주소만 나와 어떤 책인지 알 길이 없다.

10.2 값이 같다는 것

금액이 같으면 같은 것으로 취급하고 싶다. 그런데 아무것도 정의하지 않으면 파이썬은 두 객체를 "같은 메모리 주소인가"로만 비교해서, 값이 같아도 다르다고 답한다. __eq__를 정의해 값 기준 비교를 만든다. set에 넣으려면 __hash__도 함께 정의한다.


class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
return isinstance(other, Money) and self.amount == other.amount
def __hash__(self):
return hash(self.amount)

print("같은 값:", Money(100) == Money(100))
print("다른 값:", Money(100) == Money(200))
print("set에서 중복 제거:", len({Money(100), Money(100), Money(200)}))


실행 결과다.


같은 값: True
다른 값: False
set에서 중복 제거: 2


Money(100)과 Money(100)은 서로 다른 객체지만 amount가 같으므로 같다고 판단한다. 무엇이 좋아졌나. 값 기준으로 같음을 정의하니 중복 제거, 검색, 비교가 모두 자연스러워진다. 한 가지 규칙이 있다. __eq__를 만들면 __hash__도 같이 정의해야 set이나 dict의 키로 안전하게 쓸 수 있다. 둘은 짝으로 움직인다.

10.3 리스트처럼 다루기

팀 객체를 len으로 인원수를 세고, 대괄호로 멤버를 꺼내고, for로 순회하고 싶다. __len__과 __getitem__ 두 개면 된다.


class Team:
def __init__(self, members):
self.members = members
def __len__(self):
return len(self.members)
def __getitem__(self, i):
return self.members[i]

t = Team(["철수", "영희", "민수"])
print("len:", len(t))
print("인덱스:", t[1])
print("반복:", [m for m in t])


실행 결과다.


len: 3
인덱스: 영희
반복: ['철수', '영희', '민수']


주목할 점이 있다. __getitem__ 하나만 정의해도 t[1] 인덱싱과 for 순회가 둘 다 된다. 파이썬이 0, 1, 2 순서로 __getitem__을 부르며 순회하기 때문이다. 무엇이 좋아졌나. 내가 만든 객체를 파이썬 기본 리스트처럼 자연스럽게 다룰 수 있다. len도 대괄호도 for도 전부 통한다.

10.4 더하기 기호 붙이기

2차원 벡터를 + 기호로 더하고 싶다. __add__를 정의하면 + 가 내 객체에도 통한다. 이렇게 연산자에 새 동작을 붙이는 것을 연산자 오버로딩(operator overloading, 연산자에 의미 부여하기)이라 한다.


class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self):
return f"Vector({self.x}, {self.y})"

print(Vector(1, 2) + Vector(3, 4))


실행 결과다.


Vector(4, 6)


a + b는 내부적으로 a.__add__(b)를 부른다. 여기서는 새 Vector를 만들어 돌려주므로 원본 두 벡터는 바뀌지 않는다. 무엇이 좋아졌나. 좌표 하나하나를 손으로 더하는 대신 + 한 번으로 벡터를 합친다. 수학적 객체를 사람이 종이에 쓰는 방식 그대로 코드로 옮길 수 있다.

10.5 정렬 가능하게

학생 객체 리스트를 점수 순으로 정렬하고 싶다. sorted는 원소끼리 < 비교를 하는데, 이때 __lt__(less than, 작다)가 불린다. 점수 기준 < 를 정의하면 정렬이 된다.


class Student:
def __init__(self, name, score):
self.name, self.score = name, score
def __lt__(self, other):
return self.score < other.score
def __repr__(self):
return f"{self.name}({self.score})"

students = [Student("A", 80), Student("B", 95), Student("C", 70)]
print("정렬:", sorted(students))


실행 결과다.


정렬: [C(70), A(80), B(95)]


점수 기준 < 를 정의했으니 점수 오름차순으로 줄을 선다. 무엇이 좋아졌나. sorted에 정렬 기준을 매번 넘기지 않아도, 객체가 자기들끼리 크기를 아는 상태가 된다.

10.6 매직 메서드가 하는 일

정리한다. 매직 메서드는 파이썬 문법이 부르는 약속된 이름의 메서드다. __str__은 print를, __eq__는 ==를, __len__과 __getitem__은 len과 대괄호와 for를, __add__는 +를, __lt__는 정렬을 내 객체에 열어 준다. 공통 원리는 하나다. 파이썬의 내장 기능들은 특정 타입을 검사하지 않고, "약속된 이름의 메서드가 있으면 그것을 부른다"는 규칙으로 움직인다.


그래서 매직 메서드를 정의한다는 것은 내 객체를 파이썬 세계의 일급 시민으로 만드는 일이다. 억지로 외부 함수를 만들지 않아도, 내 객체가 print되고 비교되고 더해지고 정렬된다. 다만 남용은 경계한다. 의미가 분명한 곳에만 붙인다. 벡터의 + 는 직관적이지만, 관계없는 두 객체에 억지로 + 를 붙이면 읽는 사람을 혼란스럽게 한다. 문법이 뜻하는 바와 실제 동작이 일치할 때만 매직 메서드는 코드를 읽기 쉽게 만든다.