PHpullh

PYTHON · 심층 가이드

Python 객체지향 완전 정리

클래스와 프로퍼티, dataclass, ABC와 Protocol, MRO와 메타클래스까지 Python 객체 모델이 실제로 어떻게 조립되는지와 상속 대신 쓸 수 있는 선택지를 정리합니다.

주제 22개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Python의 클래스는 선언이 아니라 실행 결과입니다. class 블록이 평가되면 본문이 네임스페이스에서 실행되고 그 결과로 클래스 객체가 만들어집니다. 그래서 클래스 본문 안에 조건문을 넣을 수도, 생성 시점에 개입해 검증이나 자동 등록을 걸 수도 있습니다. 또 하나 다른 점은 인터페이스입니다. Java식 implements가 없어도 필요한 메서드만 있으면 통하는 덕 타이핑이 기본이고, 그 덕 타이핑에 정적 검사를 얹은 것이 Protocol입니다.

다형성을 구현하는 세 갈래를 나란히 두고 비교하면 선택이 쉬워집니다. 상속 & ABC & Protocol이 전체 지도를 그리고, 추상 클래스 (ABC)는 상속 계층을 강제하는 쪽, Protocol — 구조적 서브타이핑은 남의 코드까지 사후에 포섭하는 쪽입니다. 상태와 데이터 쪽은 dataclass & 매직 메서드에서 dataclass 심화 (field, post_init)로 깊어지고, 생성 제어는 메타클래스 & __init_subclass__다중 상속과 MRO가 맡습니다. 뒤쪽의 믹스인 패턴전략 패턴은 이 재료들의 조합 예제로 읽으면 됩니다.

dataclass에서 리스트나 딕셔너리를 기본값으로 그냥 적으면 아예 에러가 나고, field(default_factory=list)를 써야 합니다. 이건 친절한 쪽이고 조용한 쪽은 해시입니다. eq=True가 기본이라 __eq__가 생성되면 __hash__None으로 막혀 인스턴스를 set이나 딕셔너리 키로 쓸 수 없습니다. 해시가 필요하면 frozen=True로 불변으로 만드는 편이 정석입니다. 그리고 메타클래스를 꺼내기 전에 __init_subclass__로 충분한지 먼저 확인하세요. 대개 충분합니다.

01클래스 & __init__ & 프로퍼티

Python OOP의 핵심. __init__, @property, @classmethod, @staticmethod.

Python code

from datetime import date

class Person:
    # 클래스 변수 (모든 인스턴스 공유)
    species = "Homo sapiens"

    def __init__(self, name: str, birth_year: int):
        # 인스턴스 변수
        self.name = name
        self._birth_year = birth_year   # 관례적 protected
        self.__secret = "비밀"           # name mangling (private)

    # @property — getter
    @property
    def age(self) -> int:
        return date.today().year - self._birth_year

    # @property.setter
    @age.setter
    def age(self, value: int):
        if value < 0:
            raise ValueError("나이는 0 이상")
        self._birth_year = date.today().year - value

    # @classmethod — cls 인자, 클래스 메서드
    @classmethod
    def from_string(cls, data: str) -> "Person":
        name, year = data.split(",")
        return cls(name.strip(), int(year.strip()))

    # @staticmethod — self/cls 없음, 유틸리티
    @staticmethod
    def is_adult(age: int) -> bool:
        return age >= 18

    def __repr__(self) -> str:
        return f"Person(name={self.name!r}, age={self.age})"

p1 = Person("Alice", 1993)
p2 = Person.from_string("Bob, 1990")
print(p1.age)               # 계산된 나이
print(Person.is_adult(20))  # True
print(repr(p1))
알아두면 좋은 점

@property로 getter를 만들면 외부에서 obj.age처럼 속성처럼 접근하면서 내부적으로 계산할 수 있습니다.

자주 하는 실수

self.__attr(더블 언더스코어)는 외부에서 직접 접근을 막는 게 아니라 이름 맹글링(_ClassName__attr)으로 변환됩니다. 완전한 private가 아닙니다.

02dataclass &amp; 매직 메서드

@dataclass로 보일러플레이트를 제거하고, 매직 메서드로 연산자를 오버로드합니다.

Python code

from dataclasses import dataclass, field
from typing import List

@dataclass
class Point:
    x: float
    y: float

    def distance(self) -> float:
        return (self.x**2 + self.y**2) ** 0.5

    # 연산자 오버로딩
    def __add__(self, other: "Point") -> "Point":
        return Point(self.x + other.x, self.y + other.y)

    def __abs__(self) -> float:
        return self.distance()

@dataclass(frozen=True)   # immutable (hash 가능)
class Color:
    r: int; g: int; b: int

    def __post_init__(self):
        for val in (self.r, self.g, self.b):
            if not 0 <= val <= 255:
                raise ValueError(f"RGB 값은 0-255: {val}")

@dataclass
class Team:
    name: str
    members: List[str] = field(default_factory=list)

    def __len__(self):  return len(self.members)
    def __contains__(self, item): return item in self.members
    def __iter__(self): return iter(self.members)

p1 = Point(3, 4)
p2 = Point(1, 2)
print(p1 + p2)       # Point(x=4, y=6)
print(abs(p1))       # 5.0

team = Team("Dev", ["Alice", "Bob"])
print(len(team))            # 2
print("Alice" in team)      # True
for member in team:
    print(member)
알아두면 좋은 점

@dataclass(frozen=True)는 불변 객체를 만들고 __hash__를 자동 생성합니다. 딕셔너리 키나 세트 원소로 사용할 수 있습니다.

자주 하는 실수

@dataclass에서 가변 기본값(list, dict)은 직접 쓸 수 없습니다. field(default_factory=list)를 사용하세요.

03상속 &amp; ABC &amp; Protocol

다형성을 구현하는 세 가지 방법: 상속, 추상 기반 클래스(ABC), 구조적 서브타이핑(Protocol).

Python code

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable

# 추상 기반 클래스 (ABC)
class Animal(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def sound(self) -> str: ...

    def describe(self):   # 공통 구현
        print(f"{self.name}는 {self.sound()} 합니다")

class Dog(Animal):
    def sound(self) -> str: return "멍멍"

class Cat(Animal):
    def sound(self) -> str: return "야옹"

# Protocol — 구조적 서브타이핑 (duck typing 공식화)
@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:   # Drawable을 명시적으로 상속하지 않음
    def draw(self): print("⭕ 원 그리기")

class Square:
    def draw(self): print("⬜ 사각형 그리기")

def render(shape: Drawable):
    shape.draw()

# super() 활용
class Vehicle:
    def __init__(self, brand: str):
        self.brand = brand

class ElectricCar(Vehicle):
    def __init__(self, brand: str, battery: int):
        super().__init__(brand)
        self.battery = battery

Dog("Rex").describe()          # Rex는 멍멍 합니다
render(Circle())               # ⭕ 원 그리기
print(isinstance(Circle(), Drawable))  # True
알아두면 좋은 점

Protocol은 명시적 상속 없이도 "그 메서드를 구현하면 그 타입"으로 취급합니다. Go의 interface와 유사한 구조적 타이핑입니다.

자주 하는 실수

ABC를 상속하고 @abstractmethod를 구현하지 않으면 인스턴스 생성 시 TypeError가 발생합니다.

04메타클래스 &amp; __init_subclass__

클래스 생성 과정을 제어하는 메타클래스와 더 간단한 대안을 배웁니다.

Python code

# __init_subclass__ — 메타클래스보다 간단
class Plugin:
    _registry: dict = {}

    def __init_subclass__(cls, name: str = "", **kwargs):
        super().__init_subclass__(**kwargs)
        if name:
            Plugin._registry[name] = cls

class MarkdownPlugin(Plugin, name="markdown"):
    def render(self, text): return f"<p>{text}</p>"

class HtmlPlugin(Plugin, name="html"):
    def render(self, text): return text

print(Plugin._registry)
# {'markdown': MarkdownPlugin, 'html': HtmlPlugin}

plugin = Plugin._registry["markdown"]()
print(plugin.render("Hello"))

# 간단한 메타클래스
class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class AppConfig(metaclass=SingletonMeta):
    def __init__(self):
        self.debug = False

c1 = AppConfig()
c2 = AppConfig()
print(c1 is c2)   # True — 같은 인스턴스
알아두면 좋은 점

메타클래스는 강력하지만 복잡합니다. 대부분의 경우 __init_subclass__, @classmethod, dataclass로 해결 가능합니다.

자주 하는 실수

메타클래스 충돌: 두 부모 클래스의 메타클래스가 다르면 TypeError가 발생합니다. 두 메타클래스를 모두 상속하는 새 메타클래스를 만들어야 합니다.

05Pydantic v2 데이터 검증

@model_validator, @field_validator로 강력한 데이터 검증

Python code

<span class="cm">// Pydantic v2 데이터 검증 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Pydantic v2 데이터 검증") }
알아두면 좋은 점

PYTHON 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

06metaclass 기초

메타클래스는 "클래스의 클래스"로, 클래스 생성 과정을 커스터마이즈합니다. type을 상속하여 __new____init_subclass__를 오버라이드하면 클래스 생성 시 자동 검증, 등록, 변형이 가능합니다.

Python code

# 1. 기본 메타클래스: 클래스 생성 시 자동 검증
class ValidatedMeta(type):
    def __new__(mcs, name, bases, namespace):
        # 추상 메서드 검증
        if name != 'Base':
            required = getattr(namespace.get('__init__'), '_required_fields', [])
            for field in required:
                if field not in namespace:
                    raise TypeError(f"{name}에 '{field}' 메서드가 필요합니다")
        return super().__new__(mcs, name, bases, namespace)

# 2. 싱글톤 메타클래스
class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=SingletonMeta):
    def __init__(self):
        self.connection = "PostgreSQL 연결"
        print("DB 인스턴스 생성!")

# 테스트
db1 = Database()  # "DB 인스턴스 생성!" 출력
db2 = Database()  # 출력 없음 (같은 인스턴스 반환)
print(f"같은 객체? {db1 is db2}")  # True

# 3. __init_subclass__ (Python 3.6+, 메타클래스 대안)
class Plugin:
    _registry = {}

    def __init_subclass__(cls, plugin_name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        name = plugin_name or cls.__name__
        Plugin._registry[name] = cls
        print(f"플러그인 등록: {name}")

class AudioPlugin(Plugin, plugin_name="audio"):
    pass

class VideoPlugin(Plugin, plugin_name="video"):
    pass

print(f"등록된 플러그인: {list(Plugin._registry.keys())}")
알아두면 좋은 점

Python 3.6+의 __init_subclass__는 대부분의 메타클래스 사용 사례를 더 간단하게 대체합니다. 메타클래스가 정말 필요한지 먼저 검토하세요.

자주 하는 실수

메타클래스 충돌: 두 부모 클래스가 다른 메타클래스를 사용하면 TypeError가 발생합니다. 이 경우 공통 메타클래스를 만들어야 합니다.

07Protocol — 구조적 서브타이핑

typing.Protocol은 Go의 인터페이스처럼 구조적 서브타이핑(덕 타이핑의 정적 버전)을 지원합니다. 클래스가 명시적으로 상속하지 않아도, 필요한 메서드와 속성만 가지면 해당 프로토콜을 만족합니다.

Python code

from typing import Protocol, runtime_checkable, Iterable

# 1. 기본 Protocol 정의
@runtime_checkable  # isinstance() 체크 가능
class Drawable(Protocol):
    def draw(self) -> str: ...

class Resizable(Protocol):
    width: float
    height: float
    def resize(self, factor: float) -> None: ...

# Protocol을 상속하지 않아도 구조만 맞으면 OK
class Circle:
    def __init__(self, radius: float):
        self.radius = radius
        self.width = radius * 2
        self.height = radius * 2

    def draw(self) -> str:
        return f"○ (r={self.radius})"

    def resize(self, factor: float) -> None:
        self.radius *= factor
        self.width = self.radius * 2
        self.height = self.radius * 2

class Rectangle:
    def __init__(self, w: float, h: float):
        self.width = w
        self.height = h

    def draw(self) -> str:
        return f"□ ({self.width}x{self.height})"

    def resize(self, factor: float) -> None:
        self.width *= factor
        self.height *= factor

# 2. Protocol을 타입 힌트로 사용
def render_all(shapes: Iterable[Drawable]) -> None:
    for shape in shapes:
        print(f"  렌더링: {shape.draw()}")

def scale_all(shapes: Iterable[Resizable], factor: float) -> None:
    for shape in shapes:
        shape.resize(factor)

# 3. runtime_checkable 활용
shapes = [Circle(5), Rectangle(10, 20)]

for s in shapes:
    print(f"Drawable? {isinstance(s, Drawable)}")
    print(f"Resizable? {isinstance(s, Resizable)}")

print("\n원본:")
render_all(shapes)
scale_all(shapes, 0.5)
print("축소 후:")
render_all(shapes)
알아두면 좋은 점

@runtime_checkable을 붙여야 isinstance() 체크가 가능합니다. 하지만 런타임 검사는 메서드 존재 여부만 확인하고 시그니처는 검증하지 않으므로 mypy 같은 정적 타입 체커와 함께 사용하세요.

자주 하는 실수

Protocol은 isinstance()로 속성(attribute) 존재를 검사하지 못합니다. 메서드만 확인되므로 속성 기반 프로토콜은 정적 타입 체커에서만 완전히 검증됩니다.

08다중 상속과 MRO

Python의 다중 상속에서 메서드 호출 순서(MRO)는 C3 선형화 알고리즘으로 결정됩니다.

Python code

class A:
    def greet(self):
        return "A"

class B(A):
    def greet(self):
        return "B → " + super().greet()

class C(A):
    def greet(self):
        return "C → " + super().greet()

class D(B, C):
    def greet(self):
        return "D → " + super().greet()

d = D()
print(d.greet())  # D → B → C → A

# MRO 확인
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

# super()는 MRO를 따라 다음 클래스를 호출
for cls in D.__mro__:
    print(f"  {cls.__name__}", end="")
print()

# 다이아몬드 문제 해결
class Base:
    def __init__(self):
        print("Base.__init__")

class Left(Base):
    def __init__(self):
        super().__init__()
        print("Left.__init__")

class Right(Base):
    def __init__(self):
        super().__init__()
        print("Right.__init__")

class Child(Left, Right):
    def __init__(self):
        super().__init__()
        print("Child.__init__")

Child()  # Base, Right, Left, Child (각 1회만)
알아두면 좋은 점

super()는 현재 클래스가 아닌 MRO에서 다음 클래스를 호출합니다. 다중 상속에서 super()를 일관되게 사용하세요.

자주 하는 실수

다중 상속 시 super().__init__()을 빠뜨리면 MRO 체인이 끊어져 일부 부모 클래스가 초기화되지 않습니다.

09추상 클래스 (ABC)

abc 모듈로 추상 클래스를 정의하여 하위 클래스에 메서드 구현을 강제합니다.

Python code

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        """면적 계산"""
        ...

    @abstractmethod
    def perimeter(self) -> float:
        """둘레 계산"""
        ...

    def describe(self) -> str:
        return f"{self.__class__.__name__}: 면적={self.area():.2f}"

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2

    def perimeter(self) -> float:
        return 2 * 3.14159 * self.radius

class Rectangle(Shape):
    def __init__(self, w: float, h: float):
        self.w, self.h = w, h

    def area(self) -> float:
        return self.w * self.h

    def perimeter(self) -> float:
        return 2 * (self.w + self.h)

# Shape()  # TypeError: 추상 클래스 인스턴스 생성 불가
shapes = [Circle(5), Rectangle(3, 4)]
for s in shapes:
    print(s.describe())
알아두면 좋은 점

추상 클래스에 구현이 있는 메서드(템플릿 메서드)를 함께 정의하면 공통 로직을 재사용할 수 있습니다.

자주 하는 실수

추상 메서드를 하나라도 구현하지 않으면 하위 클래스도 추상 클래스가 되어 인스턴스를 생성할 수 없습니다.

10프로퍼티 (getter/setter)

@property로 속성 접근을 메서드로 제어하여 유효성 검사, 계산된 속성 등을 구현합니다.

Python code

class Temperature:
    def __init__(self, celsius: float = 0):
        self.celsius = celsius  # setter 호출

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float):
        if value < -273.15:
            raise ValueError("절대영도 이하 불가")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value: float):
        self.celsius = (value - 32) * 5/9

    def __repr__(self):
        return f"Temperature({self._celsius}°C / {self.fahrenheit}°F)"

t = Temperature(100)
print(t)              # Temperature(100°C / 212.0°F)
t.fahrenheit = 72
print(f"{t.celsius:.1f}°C")  # 22.2°C

# 읽기 전용 프로퍼티
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

c = Circle(5)
print(f"면적: {c.area:.2f}")
# c.area = 100  # AttributeError
알아두면 좋은 점

@property는 API를 변경하지 않고 단순 속성을 계산된 속성으로 전환할 수 있어 하위 호환성을 유지합니다.

자주 하는 실수

__init__에서 self.celsius = value는 setter를 호출하지만, self._celsius = value는 직접 할당입니다. 의도를 명확히 하세요.

11descriptor 프로토콜

디스크립터 프로토콜(__get__, __set__, __delete__)로 속성 접근을 완전히 커스터마이징합니다.

Python code

class Validated:
    """재사용 가능한 검증 디스크립터"""
    def __init__(self, min_val=None, max_val=None):
        self.min_val = min_val
        self.max_val = max_val

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_desc_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

    def __set__(self, obj, value):
        if self.min_val is not None and value < self.min_val:
            raise ValueError(f"{self.name}: {value} < 최소값 {self.min_val}")
        if self.max_val is not None and value > self.max_val:
            raise ValueError(f"{self.name}: {value} > 최대값 {self.max_val}")
        setattr(obj, self.storage_name, value)

class Product:
    price = Validated(min_val=0)
    quantity = Validated(min_val=0, max_val=10000)

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity

p = Product("노트북", 1500000, 50)
print(f"{p.name}: {p.price:,}원 x {p.quantity}")
# p.price = -100  # ValueError!
알아두면 좋은 점

__set_name__(Python 3.6+)으로 디스크립터가 속성명을 자동으로 알 수 있어 코드가 간결해집니다.

자주 하는 실수

디스크립터를 인스턴스 변수로 선언하면 동작하지 않습니다. 반드시 클래스 변수로 선언해야 합니다.

12__init_subclass__

__init_subclass__로 클래스가 상속될 때 자동으로 실행되는 로직을 정의합니다. 메타클래스보다 간단합니다.

Python code

class Plugin:
    """플러그인 자동 등록 시스템"""
    _registry: dict[str, type] = {}

    def __init_subclass__(cls, *, name: str = "", **kwargs):
        super().__init_subclass__(**kwargs)
        reg_name = name or cls.__name__.lower()
        Plugin._registry[reg_name] = cls
        print(f"플러그인 등록: {reg_name}")

    @classmethod
    def get_plugin(cls, name: str):
        return cls._registry.get(name)

    @classmethod
    def list_plugins(cls):
        return list(cls._registry.keys())

class JSONPlugin(Plugin, name="json"):
    def process(self, data):
        return f"JSON: {data}"

class XMLPlugin(Plugin, name="xml"):
    def process(self, data):
        return f"XML: {data}"

class CSVPlugin(Plugin):  # name 생략 → 클래스명 사용
    def process(self, data):
        return f"CSV: {data}"

print(Plugin.list_plugins())  # ['json', 'xml', 'csvplugin']
plugin = Plugin.get_plugin("json")()
print(plugin.process({"key": "value"}))
알아두면 좋은 점

__init_subclass__는 메타클래스 없이 서브클래스 생성을 가로채는 가장 간단한 방법입니다.

자주 하는 실수

__init_subclass__에서 super().__init_subclass__(**kwargs)를 호출하지 않으면 다중 상속 시 체인이 끊어집니다.

13클래스 데코레이터

클래스에 데코레이터를 적용하여 메서드 추가, 유효성 검사, 싱글톤 등 횡단 관심사를 처리합니다.

Python code

import functools
from typing import Any

# 자동 repr 추가 데코레이터
def auto_repr(cls):
    def __repr__(self):
        attrs = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls

# 불변 클래스 데코레이터
def frozen(cls):
    original_init = cls.__init__
    @functools.wraps(original_init)
    def new_init(self, *args, **kwargs):
        original_init(self, *args, **kwargs)
        object.__setattr__(self, '_frozen', True)
    def __setattr__(self, name, value):
        if getattr(self, '_frozen', False):
            raise AttributeError(f"{cls.__name__}는 불변입니다")
        object.__setattr__(self, name, value)
    cls.__init__ = new_init
    cls.__setattr__ = __setattr__
    return cls

@auto_repr
@frozen
class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

p = Point(3, 4)
print(p)  # Point(x=3, y=4)
# p.x = 5  # AttributeError: Point는 불변입니다
알아두면 좋은 점

Python 3.10+의 @dataclass(frozen=True)로 불변 클래스를 더 쉽게 만들 수 있습니다.

자주 하는 실수

클래스 데코레이터에서 원본 클래스를 반환하지 않으면 클래스가 None으로 대체됩니다.

14dataclass 심화 (field, post_init)

dataclassfield(), __post_init__, 상속 등 고급 기능을 다룹니다.

Python code

from dataclasses import dataclass, field, asdict
from typing import ClassVar

@dataclass
class User:
    name: str
    age: int
    tags: list[str] = field(default_factory=list)
    _id: int = field(init=False, repr=False)
    count: ClassVar[int] = 0  # 클래스 변수 (인스턴스 필드 아님)

    def __post_init__(self):
        User.count += 1
        self._id = User.count
        if self.age < 0:
            raise ValueError("나이는 0 이상이어야 합니다")

@dataclass(frozen=True)
class Point:
    x: float
    y: float

    @property
    def distance(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

# 사용
u1 = User("Alice", 30, ["admin"])
u2 = User("Bob", 25)
print(u1)   # User(name='Alice', age=30, tags=['admin'])
print(asdict(u2))  # {'name': 'Bob', 'age': 25, 'tags': []}

p = Point(3, 4)
print(f"거리: {p.distance}")  # 5.0
# p.x = 10  # FrozenInstanceError
알아두면 좋은 점

mutable 기본값(리스트, 딕셔너리)은 반드시 field(default_factory=list)로 선언하세요.

자주 하는 실수

dataclass에서 기본값이 없는 필드 뒤에 기본값이 있는 필드를 선언하면 TypeError가 발생합니다.

15열거형 심화

Enum의 고급 기능: 메서드 추가, Flag, 커스텀 값, 멤버 검증 등을 다룹니다.

Python code

from enum import Enum, Flag, auto, unique

@unique  # 중복 값 금지
class Status(Enum):
    PENDING = "pending"
    ACTIVE = "active"
    INACTIVE = "inactive"

    @classmethod
    def from_string(cls, s: str) -> "Status":
        try:
            return cls(s.lower())
        except ValueError:
            return cls.PENDING

    def is_active(self) -> bool:
        return self == Status.ACTIVE

# Flag: 비트 연산 가능
class Permission(Flag):
    READ = auto()
    WRITE = auto()
    EXECUTE = auto()
    ADMIN = READ | WRITE | EXECUTE

# 권한 조합
user_perm = Permission.READ | Permission.WRITE
print(user_perm)                        # Permission.READ|WRITE
print(Permission.READ in user_perm)     # True
print(Permission.EXECUTE in user_perm)  # False

# Status 사용
s = Status.from_string("active")
print(f"{s.name}: active={s.is_active()}")

for status in Status:
    print(f"  {status.value}")
알아두면 좋은 점

Flag는 비트 연산으로 권한이나 옵션을 조합할 때 유용합니다.

자주 하는 실수

@unique를 사용하지 않으면 같은 값을 가진 여러 멤버가 별칭(alias)으로 처리됩니다.

16믹스인 패턴

믹스인은 다중 상속으로 클래스에 특정 기능을 추가하는 패턴입니다. 단일 책임 원칙을 유지하면서 기능을 조합합니다.

Python code

import json

class JsonMixin:
    def to_json(self) -> str:
        return json.dumps(vars(self), ensure_ascii=False, indent=2)

    @classmethod
    def from_json(cls, data: str):
        return cls(**json.loads(data))

class LogMixin:
    def log(self, message: str):
        print(f"[{self.__class__.__name__}] {message}")

class ValidateMixin:
    def validate(self) -> bool:
        for name, value in vars(self).items():
            if value is None:
                raise ValueError(f"{name}은 None일 수 없습니다")
        return True

# 믹스인 조합
class User(JsonMixin, LogMixin, ValidateMixin):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

u = User("Alice", "alice@example.com")
u.log("생성됨")
u.validate()
print(u.to_json())

# JSON에서 복원
u2 = User.from_json('{"name": "Bob", "email": "bob@test.com"}')
u2.log(f"복원: {u2.name}")
알아두면 좋은 점

믹스인 클래스명에 Mixin 접미사를 붙여 의도를 명확히 하세요.

자주 하는 실수

믹스인에 __init__을 정의하면 다중 상속 시 초기화 순서 문제가 발생합니다. 믹스인은 메서드만 제공하세요.

17팩토리 메서드

클래스 메서드를 활용한 팩토리 패턴으로 다양한 방법으로 객체를 생성합니다.

Python code

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime

@dataclass
class User:
    name: str
    email: str
    created_at: str

    @classmethod
    def create(cls, name: str, email: str) -> User:
        return cls(name, email, datetime.now().isoformat())

    @classmethod
    def from_dict(cls, data: dict) -> User:
        return cls(**data)

    @classmethod
    def guest(cls) -> User:
        return cls("Guest", "guest@example.com",
                    datetime.now().isoformat())

# 다양한 생성 방법
u1 = User.create("Alice", "alice@test.com")
u2 = User.from_dict({"name": "Bob", "email": "bob@test.com",
                       "created_at": "2024-01-01"})
u3 = User.guest()

for u in [u1, u2, u3]:
    print(f"{u.name} ({u.email})")

# 상속에서도 올바르게 동작
class AdminUser(User):
    pass

admin = AdminUser.guest()
print(type(admin))  # <class 'AdminUser'>
알아두면 좋은 점

@classmethod 팩토리는 cls를 사용하므로 서브클래스에서도 올바른 타입의 인스턴스를 생성합니다.

자주 하는 실수

@staticmethod로 팩토리를 만들면 클래스명이 하드코딩되어 상속 시 부모 클래스 인스턴스가 생성됩니다.

18싱글톤 패턴

싱글톤 패턴으로 클래스의 인스턴스를 하나만 존재하게 보장합니다. Python에서의 다양한 구현 방법을 비교합니다.

Python code

# 방법 1: __new__ 오버라이드
class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

# 방법 2: 데코레이터
def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Database:
    def __init__(self, url="localhost"):
        self.url = url
        print(f"DB 연결: {url}")

db1 = Database("server1")
db2 = Database("server2")  # __init__ 호출 안 됨
print(db1 is db2)  # True
print(db1.url)     # server1

# 방법 3: 모듈 수준 인스턴스 (가장 Pythonic)
# config.py에 _config = Config() 하나만 생성
# 다른 모듈에서 from config import _config
알아두면 좋은 점

Python에서 가장 간단한 싱글톤은 모듈 수준 변수입니다. 모듈은 한 번만 import됩니다.

자주 하는 실수

싱글톤은 전역 상태를 만들어 테스트를 어렵게 합니다. 의존성 주입을 먼저 고려하세요.

19옵저버 패턴

옵저버 패턴으로 객체 상태 변경 시 관련 객체들에게 자동으로 알림을 보냅니다.

Python code

from typing import Callable, Any

class EventEmitter:
    def __init__(self):
        self._listeners: dict[str, list[Callable]] = {}

    def on(self, event: str, callback: Callable):
        self._listeners.setdefault(event, []).append(callback)

    def off(self, event: str, callback: Callable):
        if event in self._listeners:
            self._listeners[event].remove(callback)

    def emit(self, event: str, *args: Any, **kwargs: Any):
        for cb in self._listeners.get(event, []):
            cb(*args, **kwargs)

# 사용 예: 주문 시스템
class OrderSystem(EventEmitter):
    def place_order(self, item: str, qty: int):
        print(f"주문 접수: {item} x {qty}")
        self.emit("order_placed", item=item, qty=qty)

# 옵저버(리스너) 등록
shop = OrderSystem()
shop.on("order_placed",
    lambda **kw: print(f"  [재고] {kw['item']} {kw['qty']}개 차감"))
shop.on("order_placed",
    lambda **kw: print(f"  [알림] {kw['item']} 주문 알림 발송"))
shop.on("order_placed",
    lambda **kw: print(f"  [로그] 주문 기록 저장"))

shop.place_order("노트북", 2)
알아두면 좋은 점

약한 참조(weakref)를 사용하면 옵저버가 가비지 컬렉션되었을 때 자동으로 해제됩니다.

자주 하는 실수

옵저버 콜백에서 예외가 발생하면 나머지 옵저버가 호출되지 않습니다. try/except로 각 콜백을 보호하세요.

20전략 패턴

전략 패턴으로 알고리즘을 캡슐화하고 런타임에 교체할 수 있게 합니다. Python에서는 함수를 전략으로 사용할 수 있습니다.

Python code

from typing import Callable

# 함수 기반 전략
def bubble_sort(data: list) -> list:
    arr = data[:]
    for i in range(len(arr)):
        for j in range(len(arr) - 1 - i):
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
    return arr

def quick_sort(data: list) -> list:
    if len(data) <= 1:
        return data
    pivot = data[0]
    left = [x for x in data[1:] if x <= pivot]
    right = [x for x in data[1:] if x > pivot]
    return quick_sort(left) + [pivot] + quick_sort(right)

class Sorter:
    def __init__(self, strategy: Callable = sorted):
        self._strategy = strategy

    def sort(self, data: list) -> list:
        print(f"전략: {self._strategy.__name__}")
        return self._strategy(data)

    def set_strategy(self, strategy: Callable):
        self._strategy = strategy

sorter = Sorter(bubble_sort)
print(sorter.sort([3, 1, 4, 1, 5]))

sorter.set_strategy(quick_sort)
print(sorter.sort([9, 2, 6, 5, 3]))
알아두면 좋은 점

Python에서는 클래스 대신 함수를 전략으로 사용하면 더 간결합니다.

자주 하는 실수

전략 함수의 시그니처가 일치하지 않으면 런타임 에러가 발생합니다. Protocol이나 타입 힌트로 인터페이스를 명시하세요.

21상태 패턴

상태 패턴으로 객체의 내부 상태에 따라 행동을 변경합니다. 복잡한 조건문을 상태 객체로 대체합니다.

Python code

from abc import ABC, abstractmethod

class State(ABC):
    @abstractmethod
    def handle(self, context: "TrafficLight") -> None: ...

class RedState(State):
    def handle(self, context):
        print("🔴 빨간불: 정지")
        context.state = GreenState()

class GreenState(State):
    def handle(self, context):
        print("🟢 초록불: 통행")
        context.state = YellowState()

class YellowState(State):
    def handle(self, context):
        print("🟡 노란불: 주의")
        context.state = RedState()

class TrafficLight:
    def __init__(self):
        self.state: State = RedState()

    def change(self):
        self.state.handle(self)

    def current(self) -> str:
        return self.state.__class__.__name__

# 신호등 시뮬레이션
light = TrafficLight()
for _ in range(6):
    print(f"  현재 상태: {light.current()}")
    light.change()
알아두면 좋은 점

상태 전이 로직을 각 상태 클래스에 캡슐화하면 새로운 상태 추가가 기존 코드를 변경하지 않습니다.

자주 하는 실수

상태 전이 시 이전 상태의 리소스를 해제하지 않으면 메모리 누수가 발생할 수 있습니다.

22프로토콜 클래스 활용

Protocol로 구조적 서브타이핑(덕 타이핑)을 타입 시스템에서 지원합니다. 상속 없이 인터페이스를 정의합니다.

Python code

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...

@runtime_checkable
class Resizable(Protocol):
    def resize(self, factor: float) -> None: ...

# Protocol을 상속하지 않아도 호환
class Circle:
    def __init__(self, r: float):
        self.r = r
    def draw(self) -> str:
        return f"Circle(r={self.r})"
    def resize(self, factor: float):
        self.r *= factor

class Text:
    def __init__(self, content: str):
        self.content = content
    def draw(self) -> str:
        return f"Text({self.content})"

# 타입 체크 (구조적)
def render(item: Drawable) -> None:
    print(f"렌더링: {item.draw()}")

render(Circle(5))   # OK
render(Text("안녕")) # OK

# 런타임 체크
print(isinstance(Circle(1), Drawable))   # True
print(isinstance(Circle(1), Resizable))  # True
print(isinstance(Text("x"), Resizable))  # False
알아두면 좋은 점

@runtime_checkable을 추가하면 isinstance()로 런타임에 프로토콜 호환성을 검사할 수 있습니다.

자주 하는 실수

runtime_checkable Protocol의 isinstance 체크는 메서드 존재만 확인하고, 시그니처는 검사하지 않습니다.

정리하며

  • 인터페이스가 필요하면 상속 강제(ABC)와 구조적 검사(Protocol) 중 목적에 맞는 쪽을 고릅니다.
  • dataclass의 가변 기본값은 field(default_factory=...)로만 지정합니다.
  • eq=True인 dataclass는 해시가 막힙니다. 키로 쓰려면 frozen=True를 켭니다.
  • 클래스 생성 시점 개입은 메타클래스보다 __init_subclass__를 먼저 시도합니다.

더 깊이 들어가고 싶다면 Python 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.