← SEARCHABLE ARCHIVE
Python · 2026-07-29

Python 기초부터 마스터까지 — 5화

클래스, dataclass, Protocol로 객체 설계하기

INTERACTIVE READING MODE →

본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.

서로 관련된 상태와 동작이 함께 변한다면 클래스로 하나의 규칙을 만들 수 있다. 하지만 모든 딕셔너리와 함수를 클래스로 바꾸는 것이 객체지향 설계는 아니다.

dataclass로 데이터의 모양 만들기

from dataclasses import dataclass, replace
from enum import Enum


class QuestStatus(Enum):
    READY = "ready"
    ACTIVE = "active"
    COMPLETED = "completed"


@dataclass(frozen=True, slots=True)
class Quest:
    quest_id: int
    title: str
    status: QuestStatus = QuestStatus.READY

    def __post_init__(self) -> None:
        if self.quest_id <= 0:
            raise ValueError("quest_id는 양수여야 합니다.")
        if not self.title.strip():
            raise ValueError("title은 비어 있을 수 없습니다.")

    def start(self) -> "Quest":
        if self.status is not QuestStatus.READY:
            raise RuntimeError("준비 상태의 퀘스트만 시작할 수 있습니다.")
        return replace(self, status=QuestStatus.ACTIVE)

dataclass는 생성자, 표현, 값 비교처럼 반복되는 코드를 만들어준다. frozen=True는 속성 재대입을 막아 상태를 제자리에서 바꾸는 대신 새 객체를 반환하도록 유도한다.

__post_init__에서 생성 직후 불변식을 검사하므로 Quest를 받은 내부 코드는 양수 ID와 비어 있지 않은 제목을 기대할 수 있다.

상태 변경은 메서드로 보호한다

ready = Quest(quest_id=1, title="첫 던전")
active = ready.start()

print(ready.status)   # QuestStatus.READY
print(active.status)  # QuestStatus.ACTIVE

상태를 문자열로 직접 대입하게 두면 존재하지 않는 상태와 잘못된 전이가 만들어질 수 있다. 허용 상태는 Enum으로 제한하고 start, complete 같은 메서드가 전이 규칙을 검사하게 한다.

상속보다 합성

class RewardPolicy:
    def calculate(self, level: int) -> int:
        return max(100, level * 25)


class QuestService:
    def __init__(self, reward_policy: RewardPolicy) -> None:
        self._reward_policy = reward_policy

    def reward_for(self, level: int) -> int:
        return self._reward_policy.calculate(level)

기능을 얻기 위해 깊은 상속 계층을 만드는 대신 필요한 객체를 전달받아 조합할 수 있다. 이 방식은 보상 정책을 바꾸거나 테스트용 정책으로 교체하기 쉽다.

Protocol로 필요한 동작만 표현하기

from typing import Protocol


class QuestRepository(Protocol):
    def save(self, quest: Quest) -> None: ...
    def find(self, quest_id: int) -> Quest | None: ...


class QuestService:
    def __init__(self, repository: QuestRepository) -> None:
        self._repository = repository

Protocol은 특정 부모 클래스를 상속하지 않아도 필요한 메서드 모양을 가진 객체를 받을 수 있게 설계를 표현한다. 런타임 검증을 자동으로 해주는 장치는 아니며 타입 검사와 의존성 경계를 명확하게 만드는 도구다.

클래스의 수가 많다고 좋은 설계는 아니다. 객체가 지켜야 할 상태 규칙이 있는지, 변경의 책임자가 누구인지, 외부 기술과 핵심 규칙을 분리할 필요가 있는지를 먼저 판단해야 한다.