Devin.KR
로그인

파이썬 클래스와 객체지향 - __init__ 상속 매직 메서드 설계 (파이썬 중급 9단원)

개발자 조회 1

이 단원에서 배우는 것

초급 과정 마지막에서 서점 프로그램을 패키지로 정리했다. 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}!rrepr()을 적용하라는 뜻이다. 이걸 빼면 문자열에 따옴표가 안 붙어서, 값이 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)이 그대로 유지되므로 호출하는 쪽 코드를 고칠 필요가 없다. 처음부터 게터를 깔아 두는 것은 파이썬에서는 값을 치르고 얻는 게 없는 선택이다.

스스로 확인하기

  1. OrderLine__eq__만 정의한 상태에서 set(lines)로 중복을 제거하려 하면 어떤 에러가 나며, 이때 값이 변하지 않는 객체라는 전제 아래 어떻게 고치는가?
  2. 아래 코드에서 b.lines가 비어 있지 않은 이유를 설명하고 고쳐라.
    class Order:
        def __init__(self, order_id, lines=[]):
            self.order_id = order_id
            self.lines = lines
    
  3. Order.total@property로 두는 대신 __init__에서 self.total = ...로 계산해 저장하면 어떤 상황에서 값이 틀어지는가?

정답

  1. TypeError: unhashable type이 난다. __eq__를 정의하면 __hash__None으로 덮이기 때문이다. 비교에 쓴 것과 같은 속성들로 def __hash__(self): return hash((self.sku, self.qty, self.unit_price))를 정의하거나, 클래스를 @dataclass(frozen=True)로 선언한다.
  2. 기본값 리스트는 함수 정의 시점에 한 번 만들어지고 모든 인스턴스가 그 하나를 공유한다. def __init__(self, order_id, lines=None):으로 받고 self.lines = list(lines) if lines else []로 매번 새 리스트를 만든다.
  3. order.lines.append(...)로 항목을 추가하거나 기존 항목의 qty를 바꾸면 저장된 total은 옛날 값 그대로 남는다. 계산 결과를 저장하는 순간 그 값을 무효화할 책임이 생기고, 그 책임을 빠뜨리는 곳이 반드시 생긴다.