파이썬 데이터 모델과 던더(Dunder) 메소드 이야기

오늘은 파이썬의 중요한 개념인 데이터 모델(Data Model)에 대해 정리해보겠습니다. "Fluent Python"이라는 책을 읽으면서 배운 내용인데, 개인적으로는 파이썬이 내부적으로 어떻게 동작하는지 이해하는 데 정말 도움이 되었습니다.
파이썬 데이터 모델? 그것이 무엇인가?
파이썬 데이터 모델은 쉽게 말해서 파이썬이라는 언어가 '동작하는 규칙' 같은 것입니다. 우리가 리스트 길이를 잴 때 len(my_list)
를 쓰거나, 특정 항목에 접근할 때 my_list[0]
처럼 쓰는 것이 매우 상식적으로 여겨집니다. 이런 기본적인 동작들이 가능하게끔 하는 약속, 일종의 프레임워크가 바로 데이터 모델입니다.
이 데이터 모델의 핵심에는 특별 메소드(Special Methods)가 있습니다. 이름 앞뒤에 더블 언더스코어(__
)가 붙어서 흔히 던더(dunder) 메소드라고 불리는 것들입니다. 예를 들면 __len__
, __getitem__
같은 것들입니다. 우리가 직접 이 메소드들을 호출하는 경우는 거의 없지만, 파이썬 인터프리터는 우리가 len()
함수를 쓰거나 []
문법을 사용할 때 내부적으로 이 던더 메소드들을 찾아서 호출합니다.
객체를 파이썬스럽게 만들기
말로만 하면 이해가 어려울 수 있으니, 카드 덱(Card Deck)을 만드는 예시를 한번 살펴보겠습니다.
import collections
# 카드를 표현하는 간단한 방법: namedtuple 사용
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self) -> None:
# 카드 덱 초기화: 모든 카드 조합 만들기
self._cards = [Card(rank, suit) for suit in self.suits for rank in self.ranks]
def __len__(self):
"""
덱에 있는 카드 개수를 반환 (len() 함수가 이것을 호출!)
"""
return len(self._cards)
def __getitem__(self, position):
"""
주어진 위치(position)의 카드를 반환 (deck[i] 같은 인덱싱과 슬라이싱이 이것을 호출!)
"""
return self._cards[position]
자, 이렇게 FrenchDeck
클래스를 만들었습니다. 특별한 것은 없으며 그냥 카드들을 리스트(self._cards
)에 저장하고, __len__
과 __getitem__
두 개의 던더 메소드를 정의했습니다.
deck = FrenchDeck()
# 1. 길이 확인 가능! (내부적으로 deck.__len__() 호출)
print(f"카드 덱 길이: {len(deck)}")
# 2. 인덱스로 카드 접근 가능! (내부적으로 deck.__getitem__(0) 호출)
print(f"첫 번째 카드: {deck[0]}")
# 3. 슬라이싱도 가능! (내부적으로 deck.__getitem__(slice(0, 3, None)) 비슷한 것을 호출)
print(f"처음 3개 카드: {deck[:3]}")
# 4. 심지어 반복문도 동작한다! (__iter__가 없는데도 가능)
print("처음 3개 카드 (반복문):")
for card in deck[:3]:
print(card)
# 5. 무작위 선택도 가능! (random.choice는 시퀀스를 기대하는데, 우리 덱이 시퀀스처럼 동작!)
from random import choice
print(f"무작위 카드: {choice(deck)}")
신기하게도 그냥 __len__
과 __getitem__
두 개만 정의했을 뿐인데도, FrenchDeck
객체가 파이썬의 기본 리스트나 튜플처럼 길이도 잴 수 있고, 인덱싱/슬라이싱도 되고, 심지어 for
루프나 random.choice
같은 기능까지 그냥 쓸 수 있게 되었습니다.
이것이 바로 파이썬 데이터 모델의 힘입니다. "어떤 객체가 특정 메소드를 가지고 있으면, 그 객체는 특정 프로토콜(규칙)을 지원하는 것으로 간주한다" 이것이 파이썬의 중요한 철학 중 하나인 덕 타이핑(Duck Typing)과 연결되는 지점입니다. __len__
과 __getitem__
을 구현했더니 파이썬이 "어, 너 시퀀스(sequence)처럼 행동할 수 있네?" 하고 시퀀스 관련 기능을 쓸 수 있게 해주는 것입니다. 굳이 복잡한 상속 없이도 가능합니다.
반복의 미학: __iter__
vs __getitem__
위에서 __iter__
메소드를 정의하지 않았는데도 for
루프가 동작했습니다. 그것은 파이썬이 __iter__
가 없으면 차선책으로 __getitem__
을 0부터 시작해서 1씩 증가시키면서 호출하기 때문입니다 (IndexError가 발생할 때까지).
그럼 만약 __iter__
를 직접 정의하면 어떻게 될까요? 어떤 것이 우선순위를 가질까요?
class FrenchDeck1:
# ... (ranks, suits, __init__, __len__은 위와 동일) ...
def __getitem__(self, position):
print('called __getitem__')
return self._cards[position]
def __iter__(self):
print('called __iter__') # 언제 호출되는지 확인용 로그
# 간단한 예시를 위해 __init__에서 초기화했다고 가정.
# Fluent Python 원서에서는 제너레이터로 구현하여 이 문제를 해결.
# 여기서는 간단히 cards 를 순회하는 제너레이터를 반환한다고 가정
for card in self._cards:
yield card # yield를 쓰면 이 함수는 제너레이터가 됩니다!
# FrenchDeck1 인스턴스 생성 (간단하게 카드 10개만)
deck1 = FrenchDeck1()
# deck1._cards = deck1._cards[:10] # __init__ 에서 처리했다고 가정
print("\n--- FrenchDeck1 반복 테스트 ---")
for card in deck1: # deck1 전체를 반복
pass # 카드 출력은 생략
print("\n--- FrenchDeck1 슬라이싱 후 반복 테스트 ---")
for card in deck1[:3]: # 슬라이싱 후 반복
pass # 카드 출력은 생략
실행 결과를 보면 재밌는 것을 알 수 있습니다.
for card in deck1:
처럼 객체 전체를 직접 반복하면__iter__
가 딱 한 번 호출됩니다. 그리고 그 뒤로는__getitem__
은 전혀 호출되지 않습니다. 즉,__iter__
가 있으면 파이썬은 반복을 위해 이것을 우선 사용한다는 것입니다. (called __iter__
가 한 번만 출력되는 이유는,yield
가 있는 함수는 호출 시 제너레이터 객체를 반환하고,for
루프가 이 객체의__next__
를 반복 호출하기 때문입니다.__iter__
함수 자체가 계속 호출되는 것이 아니라는 점입니다!)for card in deck1[:3]:
처럼 슬라이싱을 한 후에 반복하면, 이전처럼__getitem__
이 여러 번 호출됩니다. 이것은 슬라이싱(deck1[:3]
) 자체가__getitem__
을 이용해서 처리되기 때문에, 반복 메커니즘 이전에 슬라이싱 과정에서__getitem__
이 호출되는 것입니다.
결론: 직접 반복 시에는 __iter__
가 우선입니다! 하지만 __iter__
가 없거나, 슬라이싱 같은 다른 작업이 먼저 일어나면 __getitem__
이 활용될 수 있습니다!
수치형 데이터 처럼 행동하기
이번엔 우리가 직접 만든 객체가 숫자처럼 덧셈, 곱셈 같은 연산을 할 수 있게 만들어 보겠습니다. 아까 잠깐 언급했던 Vector
클래스를 예시로 들어보겠습니다.
import math
import doctest # doctest: 간단한 테스트를 주석에 넣을 수 있게 해줌
class Vector:
"""
간단한 2차원 벡터 클래스
# doctest 예시 시작 (아래 코드가 실제로 실행되고 결과가 맞는지 확인)
>>> v1 = Vector(1, 2)
>>> v2 = Vector(3, 4)
>>> v1 + v2 # __add__ 테스트
Vector(4, 6)
>>> v = Vector(3, 4)
>>> abs(v) # __abs__ 테스트 (벡터 크기)
5.0
>>> v * 3 # __mul__ 테스트 (스칼라 곱)
Vector(9, 12)
>>> abs(v * 3)
15.0
# doctest 예시 끝
"""
def __init__(self, x: int = 0, y: int = 0) -> None:
self.x = x
self.y = y
def __repr__(self):
"""
객체를 '개발자 친화적인' 문자열로 표현 (repr() 함수나 콘솔 출력이 이것을 사용)
이것이 잘 되어 있으면 디버깅이 편리해집니다!
>>> v = Vector(3, 4)
>>> v
Vector(3, 4)
"""
# f-string을 사용하면 깔끔하게 표현 가능
return f'Vector({self.x}, {self.y})'
def __abs__(self):
"""
벡터의 크기(magnitude)를 반환 (abs() 함수가 이것을 호출)
math.hypot(x, y)는 sqrt(x*x + y*y)와 같음
>>> v = Vector(3, 4)
>>> abs(v)
5.0
"""
return math.hypot(self.x, self.y) # 유클리드 노름(norm)
def __add__(self, other: "Vector"):
"""
벡터 덧셈 (v1 + v2 연산이 이것을 호출)
주의: 타입 힌트에서 클래스 자신을 참조할 때는 문자열로 감싸줌 ("Vector")
>>> v1 = Vector(1, 2)
>>> v2 = Vector(3, 4)
>>> v1 + v2
Vector(4, 6)
"""
# 타입 체크를 추가하면 더 견고해짐 (여기선 생략)
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
"""
벡터와 스칼라(숫자)의 곱셈 (v * 3 연산이 이것을 호출)
>>> v = Vector(3, 4)
>>> v * 3
Vector(9, 12)
"""
# scalar가 숫자인지 확인하는 로직을 추가하면 좋음 (여기선 생략)
return Vector(self.x * scalar, self.y * scalar)
# doctest 실행: 클래스나 함수 주석에 있는 테스트 코드를 실행
doctest.testmod()
위 Vector
클래스를 보면 __repr__
, __abs__
, __add__
, __mul__
같은 던더 메소드를 정의했습니다.
__repr__
:repr()
함수나 인터프리터에서 객체를 그냥 출력했을 때 보여줄 문자열을 정의합니다. 디버깅할 때 객체 내용을 명확하게 보여줘서 아주 유용합니다. (__str__
도 있는데, 이것은print()
함수처럼 사용자에게 보여줄 때 사용하고,__repr__
은 개발자를 위한 표현에 가깝습니다. 둘 다 정의할 수 있고,__str__
이 없으면__repr__
이 대신 사용되기도 합니다.)__abs__
:abs()
내장 함수가Vector
객체에 사용될 수 있게 해줍니다. 여기서는 벡터의 크기(norm)를 계산하도록 했습니다.__add__
,__mul__
: 각각+
와*
연산자가Vector
객체에 사용될 수 있게 해줍니다.
이렇게 던더 메소드를 정의하니까 우리가 만든 Vector
객체가 마치 파이썬의 기본 숫자 타입처럼 자연스럽게 연산이 가능해졌습니다!
(잠깐! doctest
것을 처음본 분들을 위한 설명: 주석 안에 >>>
로 예제 코드를 쓰고 바로 밑에 예상 결과를 적으면, doctest.testmod()
가 이것을 진짜 실행해서 결과가 맞는지 테스트해줍니다. 복잡한 테스트는 pytest
같은 전문 도구를 쓰는 것이 좋지만, 간단한 예제나 문서에서는 정말 편리한 기능입니다.)
중위 연산자와 불변성(Immutability)
위 Vector
예시에서 __add__
와 __mul__
을 보면 중요한 특징이 하나 있습니다. 바로 연산 결과로 새로운 Vector
객체를 생성해서 반환한다는 것입니다. 기존의 v1
이나 v2
객체를 직접 수정(inplace)하는 것이 아닙니다.
Python
v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2 # v3는 Vector(4, 6) 이라는 '새로운' 객체
print(v1) # 출력: Vector(1, 2) <- v1은 그대로!
print(v2) # 출력: Vector(3, 4) <- v2도 그대로!
print(v3) # 출력: Vector(4, 6)
이것은 파이썬에서 일반적인 연산자 오버로딩(operator overloading) 방식입니다. +
, *
같은 중위 연산자(infix operator) 들은 보통 피연산자(operand)를 변경하지 않고 새로운 값을 반환합니다. 우리가 a = b + c
라고 쓸 때 b
나 c
의 값이 바뀌길 기대하지 않는 것과 같습니다. 이런 방식은 코드를 예측 가능하게 만들고 의도치 않은 부수 효과(side effect)를 줄여줍니다. 리스트의 sort()
메소드처럼 객체 자체를 변경하는 in-place 연산과는 대비됩니다.
마치며
오늘은 "Fluent Python" 책을 길잡이 삼아 파이썬 데이터 모델과 던더 메소드의 세계를 살짝 엿보았습니다. 던더 메소드를 잘 활용하면 우리가 직접 만든 클래스도 파이썬 내장 타입처럼 아주 자연스럽고 '파이썬스럽게' 동작하게 만들 수 있다는 것을 알았습니다.
처음엔 __init__
말고는 던더 메소드를 쓸 일이 별로 없을 것으로 생각될 수 있지만, 내 객체가 len()
함수와 잘 동작하게 하고 싶거나, +
연산을 지원하게 하고 싶거나, for
루프에서 자연스럽게 사용되게 하고 싶을 때, 이 던더 메소드들을 떠올리면 됩니다. 파이썬의 강력함과 유연함이 바로 이런 데이터 모델 설계에서 나온다고 생각합니다.
Subscribe to my newsletter
Read articles from roach directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

roach
roach
https://www.linkedin.com/feed/update/urn:li:activity:7092144087058825216/