파이썬 클래스와 객체지향 - __init__ 상속 매직 메서드 설계 (파이썬 중급 9단원)
이 단원에서 배우는 것
초급 과정 마지막에서 서점 프로그램을 패키지로 정리했다. CSV에서 재고를 읽고, 발주 대상을 고르고, 영수증을 찍는다. 데이터는 전부 딕셔너리와 리스트에 담고 그것을 처리하는 함수를 모듈로 나눴다. 규모가 작을 때는 이 방식이 가장 빠르다. 문제는 데이터와 그 데이터를 다루는 규칙이 서로 다른 파일에 흩어지기 시작할 때 생긴다.
중급 과정에서는 그 서점에 주문이 붙는다. 이번 단원부터 16단원까지 하나의 소재를 이어서 쓴다. 주문 기록을 읽어 집계하는 주문 로그 분석 도구다. 08단원까지의 프로그램에서 if로 대충 막아 두던 오류 상황들을 이 과정에서 제대로 다루게 되는데, 그 출발점이 "데이터가 스스로 규칙을 아는 것"이다. 이번 단원에서는 주문 데이터를 클래스로 다시 세운다.
- 딕셔너리로 표현하던 데이터를 클래스로 옮겼을 때 실제로 무엇이 좋아지는지 설명할 수 있다
__init__,@property,__repr__,__eq__같은 특수 메서드를 목적에 맞게 골라 쓸 수 있다- 상속을 쓸 자리와 쓰면 안 되는 자리를 구분하고,
dataclass로 반복 코드를 줄일 수 있다
왜 필요한가
주문 한 건을 딕셔너리로 표현했다고 하자.
order = {
"id": "ORD-1001",
"lines": [
{"sku": "BOOK-01", "qty": 2, "unit_price": 12000},
{"sku": "PEN-07", "qty": 1, "unit_price": 3000},
],
}
def order_total(o):
return sum(line["qty"] * line["unit_price"] for line in o["lines"])
print(order_total(order)) # 27000
이 코드는 잘 돌아간다. 그런데 이런 일이 생긴다.
- 어디선가
line["unitprice"]라고 오타를 낸다. 편집기는 아무 말이 없고, 그 코드 경로를 실제로 태우는 순간에야KeyError가 난다. - 수량이 0이거나 음수인 주문이 섞여 들어와도 막을 자리가 없다. 검증 코드를 넣으려면 주문을 만드는 모든 자리에 똑같이 넣어야 한다.
- 총액 계산이 필요한 곳이 늘면서
order_total과 비슷한 함수가 여러 모듈에 하나씩 생긴다. 배송비 규칙이 바뀌면 그것들을 전부 찾아야 한다.
세 가지 모두 원인이 같다. 데이터의 모양을 강제하는 곳과 그 데이터의 규칙이 사는 곳이 없다. 클래스는 그 자리를 만들어 준다. "값을 담는 상자"가 아니라 "이 값에 대해 무엇이 참이어야 하는지 아는 상자"라고 보면 된다.
문법과 예제
__init__ 은 생성자가 아니라 초기화 메서드다
객체를 실제로 만드는 것은 __new__이고, __init__은 이미 만들어진 객체에 값을 채운다. 그래서 __init__은 아무것도 반환하지 않는다. 여기에 검증을 넣으면, 잘못된 주문 항목은 애초에 존재할 수 없게 된다.
class OrderLine:
def __init__(self, sku: str, qty: int, unit_price: int):
if qty <= 0:
raise ValueError(f"수량은 1 이상이어야 한다: {qty}")
if unit_price < 0:
raise ValueError(f"단가는 음수일 수 없다: {unit_price}")
self.sku = sku
self.qty = qty
self.unit_price = unit_price
@property
def amount(self) -> int:
return self.qty * self.unit_price
line = OrderLine("BOOK-01", 2, 12000)
print(line.amount) # 24000
amount는 __init__에서 계산해 저장하지 않았다. 저장해 두면 qty가 바뀌었을 때 amount가 옛날 값으로 남는다. @property로 매번 계산하면 그런 어긋남이 생기지 않는다. 계산이 무거워서 캐시해야 하는 경우에만 저장을 고민하면 된다.
__repr__ 은 개발자를 위한 것이다
위 객체를 그냥 출력하면 <__main__.OrderLine object at 0x104f2a3d0>가 나온다. 로그와 디버거에서 아무 정보도 주지 못한다. __repr__은 그 자리를 메운다.
class OrderLine:
def __init__(self, sku: str, qty: int, unit_price: int):
self.sku = sku
self.qty = qty
self.unit_price = unit_price
def __repr__(self) -> str:
return f"OrderLine(sku={self.sku!r}, qty={self.qty}, unit_price={self.unit_price})"
print([OrderLine("BOOK-01", 2, 12000)])
# [OrderLine(sku='BOOK-01', qty=2, unit_price=12000)]
{self.sku!r}의 !r은 repr()을 적용하라는 뜻이다. 이걸 빼면 문자열에 따옴표가 안 붙어서, 값이 BOOK 01인지 'BOOK 01'인지 구분이 안 된다. __repr__의 목표는 붙여넣으면 같은 객체가 만들어지는 문자열이다. 사용자에게 보여줄 문자열이 따로 필요할 때만 __str__을 추가한다. __str__이 없으면 print()는 __repr__을 쓴다.
__eq__ 와 __hash__ 는 한 쌍이다
주문 항목 두 개가 같은지 비교하려면 __eq__가 필요하다. 그런데 __eq__를 정의하는 순간 파이썬은 그 클래스의 __hash__를 None으로 만든다. 값이 같으면 해시도 같아야 한다는 규칙을 지킬 방법을 파이썬이 알 수 없기 때문이다. 결과적으로 그 객체는 집합에 넣거나 딕셔너리 키로 쓸 수 없게 된다.
class OrderLine:
def __init__(self, sku, qty, unit_price):
self.sku = sku
self.qty = qty
self.unit_price = unit_price
def __eq__(self, other) -> bool:
if not isinstance(other, OrderLine):
return NotImplemented
return (self.sku, self.qty, self.unit_price) == (other.sku, other.qty, other.unit_price)
def __hash__(self) -> int:
return hash((self.sku, self.qty, self.unit_price))
a = OrderLine("BOOK-01", 2, 12000)
b = OrderLine("BOOK-01", 2, 12000)
print(a == b) # True
print(len({a, b})) # 1
비교 대상이 다른 타입이면 False가 아니라 NotImplemented를 돌려주는 것이 규칙이다. 그러면 파이썬이 반대쪽 객체의 __eq__를 한 번 더 시도하고, 그것도 안 되면 그때 False가 된다. False를 바로 돌려주면 상대 타입이 스스로를 OrderLine과 같다고 판단할 기회를 뺏는다.
컨테이너처럼 행동하게 만들기
주문은 항목 여러 개를 담는다. __len__과 __iter__를 정의하면 주문 객체가 파이썬의 기본 문법과 그대로 맞물린다.
class Order:
def __init__(self, order_id: str, lines=None):
self.order_id = order_id
self.lines: list[OrderLine] = list(lines) if lines else []
def add_line(self, line: OrderLine) -> None:
self.lines.append(line)
@property
def total(self) -> int:
return sum(line.amount for line in self.lines)
def __len__(self) -> int:
return len(self.lines)
def __iter__(self):
return iter(self.lines)
def __repr__(self) -> str:
return f"Order(order_id={self.order_id!r}, lines={self.lines!r})"
order = Order("ORD-1001")
order.add_line(OrderLine("BOOK-01", 2, 12000))
order.add_line(OrderLine("PEN-07", 1, 3000))
print(len(order)) # 2
print(order.total) # 27000
for line in order: # __iter__ 덕분에 바로 순회된다
print(line.sku, line.amount)
lines=None으로 받아 안에서 리스트를 만든 이유는 뒤의 "자주 틀리는 것"에서 다룬다. 여기서 self.lines: list[OrderLine]처럼 타입을 적어 둔 것은 16단원에서 본격적으로 설명한다. 지금은 편집기에 힌트를 주는 주석 정도로 보면 된다.
classmethod 로 대체 생성자 만들기
실무에서 주문은 대부분 JSON이나 CSV로 들어온다. 변환 코드를 클래스 안에 두면 "이 클래스를 어떻게 만드는가"에 대한 답이 한곳에 모인다.
class Order:
def __init__(self, order_id: str, lines=None):
self.order_id = order_id
self.lines = list(lines) if lines else []
@property
def total(self) -> int:
return sum(line.qty * line.unit_price for line in self.lines)
@classmethod
def from_dict(cls, raw: dict) -> "Order":
lines = [
OrderLine(item["sku"], item["qty"], item["unit_price"])
for item in raw["lines"]
]
return cls(raw["id"], lines)
raw = {"id": "ORD-1001", "lines": [{"sku": "BOOK-01", "qty": 2, "unit_price": 12000}]}
print(Order.from_dict(raw).total) # 24000
첫 인자가 self가 아니라 cls다. 그래서 Order를 상속한 클래스가 from_dict를 호출하면 그 자식 클래스가 만들어진다. 여기에 Order(...)라고 하드코딩하면 상속했을 때 부모 객체가 튀어나온다.
상속은 "같은 자리에 대신 넣을 수 있을 때"만 쓴다
정기배송 주문을 추가한다고 하자. 정기배송 주문도 주문이고, 총액을 묻는 모든 코드에 그대로 들어갈 수 있다. 이럴 때가 상속이다.
class Order:
def __init__(self, order_id: str, lines=None):
self.order_id = order_id
self.lines = list(lines) if lines else []
@property
def subtotal(self) -> int:
return sum(line.qty * line.unit_price for line in self.lines)
@property
def total(self) -> int:
return self.subtotal
class SubscriptionOrder(Order):
def __init__(self, order_id: str, lines=None, discount_rate: float = 0.1):
super().__init__(order_id, lines)
self.discount_rate = discount_rate
@property
def total(self) -> int:
return round(self.subtotal * (1 - self.discount_rate))
lines = [OrderLine("BOOK-01", 2, 12000)]
print(Order("ORD-1001", lines).total) # 24000
print(SubscriptionOrder("ORD-1002", lines).total) # 21600
반대로 "주문에 결제 정보가 필요하다"는 이유로 class Order(Payment)를 만들면 안 된다. 주문은 결제의 한 종류가 아니다. 이건 가지고 있는 관계이므로 self.payment = payment로 담는다. 상속을 쓸지 망설여진다면 "이 객체를 부모 타입 자리에 넣어도 코드가 놀라지 않는가"를 물어보면 대부분 답이 나온다.
dataclass 로 반복 줄이기
위에서 __init__, __repr__, __eq__를 손으로 썼다. 값을 담는 것이 주 목적인 클래스라면 dataclasses가 그 셋을 만들어 준다.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class OrderLine:
sku: str
qty: int
unit_price: int
def __post_init__(self):
if self.qty <= 0:
raise ValueError(f"수량은 1 이상이어야 한다: {self.qty}")
@property
def amount(self) -> int:
return self.qty * self.unit_price
@dataclass
class Order:
order_id: str
lines: list[OrderLine] = field(default_factory=list)
@property
def total(self) -> int:
return sum(line.amount for line in self.lines)
order = Order("ORD-1001", [OrderLine("BOOK-01", 2, 12000)])
print(order)
# Order(order_id='ORD-1001', lines=[OrderLine(sku='BOOK-01', qty=2, unit_price=12000)])
print(order.total) # 24000
frozen=True는 생성 후 속성 변경을 막고, 대신 __hash__를 자동으로 만들어 준다. 집합이나 딕셔너리 키로 쓸 값 객체에 잘 맞는다. slots=True는 파이썬 3.10부터 쓸 수 있고, 인스턴스마다 딕셔너리를 만들지 않아 메모리를 줄인다. 주문 항목 수십만 건을 메모리에 올리는 배치라면 차이가 눈에 보인다. 검증은 __post_init__에 넣는다. 옵션이 여러 개라 헷갈리면 dataclasses 공식 문서에서 각 인자가 무엇을 생성하는지 표로 확인하는 편이 빠르다.
실무에서 자주 틀리는 것
1. 기본값으로 가변 객체를 쓴다
가장 많이 데는 지점이다. 기본값은 함수가 정의될 때 한 번만 만들어지고, 그 하나를 모든 호출이 공유한다.
class Order:
def __init__(self, order_id, lines=[]): # 위험
self.order_id = order_id
self.lines = lines
a = Order("ORD-1")
b = Order("ORD-2")
a.lines.append("BOOK-01")
print(b.lines) # ['BOOK-01'] <- b 는 건드린 적이 없다
해결은 lines=None으로 받고 안에서 새로 만드는 것이다. dataclass에서는 아예 이 실수를 막아 놓아서, 가변 기본값을 쓰면 정의 시점에 ValueError가 난다. field(default_factory=list)를 써야 한다. 같은 함정이 클래스 속성에도 있다. class Order: lines = []라고 쓰면 그 리스트는 모든 인스턴스가 공유한다.
2. super().__init__() 을 빼먹는다
자식 클래스에서 __init__을 새로 정의하면 부모의 __init__은 자동으로 불리지 않는다.
class SubscriptionOrder(Order):
def __init__(self, order_id, discount_rate=0.1):
self.discount_rate = discount_rate # super().__init__ 을 안 불렀다
o = SubscriptionOrder("ORD-1002")
print(o.total) # AttributeError: 'SubscriptionOrder' object has no attribute 'lines'
더 나쁜 경우는 에러가 안 나고 넘어가는 것이다. 부모가 설정하던 속성을 자식이 우연히 안 쓰다가, 몇 달 뒤 다른 메서드를 호출하는 순간 터진다. 또 super().__init__(...)은 인자를 직접 넘겨야 한다. super()는 부모 클래스가 아니라 MRO에서 다음 순서를 가리키므로, 다중 상속에서는 이 차이가 결과를 바꾼다.
3. __eq__ 만 정의하고 집합에 넣는다
앞에서 설명한 __hash__ = None 문제다. 증상이 헷갈리는 이유는 에러가 클래스 정의 자리가 아니라, 한참 떨어진 곳에서 나기 때문이다.
class Sku:
def __init__(self, code):
self.code = code
def __eq__(self, other):
return isinstance(other, Sku) and self.code == other.code
print(Sku("A") == Sku("A")) # True
seen = set()
seen.add(Sku("A")) # TypeError: unhashable type: 'Sku'
값이 바뀌지 않는 객체라면 __hash__를 같이 정의하거나 @dataclass(frozen=True)를 쓴다. 값이 바뀌는 객체는 애초에 해시 대상이 아니다. 속성이 바뀌면 해시도 바뀌어서, 딕셔너리에 넣어 둔 키를 다시는 찾지 못하게 된다.
4. 게터와 세터를 자바처럼 만든다
class OrderLine:
def __init__(self, qty):
self._qty = qty
def get_qty(self): # 파이썬에서는 불필요하다
return self._qty
def set_qty(self, value):
self._qty = value
파이썬에서는 그냥 self.qty = qty로 두고, 나중에 검증이 필요해지면 그때 @property로 바꾸면 된다. 속성 접근 문법(line.qty)이 그대로 유지되므로 호출하는 쪽 코드를 고칠 필요가 없다. 처음부터 게터를 깔아 두는 것은 파이썬에서는 값을 치르고 얻는 게 없는 선택이다.
스스로 확인하기
OrderLine에__eq__만 정의한 상태에서set(lines)로 중복을 제거하려 하면 어떤 에러가 나며, 이때 값이 변하지 않는 객체라는 전제 아래 어떻게 고치는가?- 아래 코드에서
b.lines가 비어 있지 않은 이유를 설명하고 고쳐라.class Order: def __init__(self, order_id, lines=[]): self.order_id = order_id self.lines = lines Order.total을@property로 두는 대신__init__에서self.total = ...로 계산해 저장하면 어떤 상황에서 값이 틀어지는가?
정답
TypeError: unhashable type이 난다.__eq__를 정의하면__hash__가None으로 덮이기 때문이다. 비교에 쓴 것과 같은 속성들로def __hash__(self): return hash((self.sku, self.qty, self.unit_price))를 정의하거나, 클래스를@dataclass(frozen=True)로 선언한다.- 기본값 리스트는 함수 정의 시점에 한 번 만들어지고 모든 인스턴스가 그 하나를 공유한다.
def __init__(self, order_id, lines=None):으로 받고self.lines = list(lines) if lines else []로 매번 새 리스트를 만든다. order.lines.append(...)로 항목을 추가하거나 기존 항목의qty를 바꾸면 저장된total은 옛날 값 그대로 남는다. 계산 결과를 저장하는 순간 그 값을 무효화할 책임이 생기고, 그 책임을 빠뜨리는 곳이 반드시 생긴다.