PUBLISHED

class 심화

작성일: 2024.12.26

class 심화

이 문서는 클래스 문법을 넘어, 클래스가 어떻게 “설계 도구”로 작동하는지를 다룬다. 문법을 익힌 뒤 실무에서 마주하는 문제는 대부분 구조와 정책의 문제로 변한다. 그래서 여기서는 프로토콜, 상속/컴포지션, 메타프로그래밍처럼 클래스의 확장 규약을 중심으로 정리한다. 문단은 길게, 맥락은 촘촘하게, 트레이드오프는 명확하게 가져간다.

핵심은 “클래스가 무엇을 할 수 있는가”가 아니라 “클래스를 통해 어떤 제약과 구조를 설계할 것인가”다. 같은 기능을 구현해도 클래스의 설계 방식에 따라 유지보수성이 달라진다. 결국 심화는 문법이 아니라 선택의 문제다.

 

디스크립터: property의 뿌리

property는 사실 디스크립터 프로토콜(__get__, __set__, __delete__)을 이용한 얇은 문법이다. 디스크립터를 이해하면 ORM이나 프레임워크가 왜 특정 동작을 자동화할 수 있는지 보인다. 예를 들어 “필드 접근 시 자동으로 로딩되는 값”이나 “값 변경 시 자동으로 검증되는 속성”은 대부분 디스크립터로 구현된다.

디스크립터의 핵심은 “속성 접근 흐름을 가로채는 공식적인 훅”이라는 점이다. 이 메커니즘은 강력하지만, 남용하면 속성 접근의 비용이 커질 수 있다. 따라서 디스크립터는 공통된 규칙을 가진 필드에만 적용하고, 일회성 로직은 프로퍼티로 마무리하는 편이 실무적으로 안정적이다.

untitled
PY
class Positive:
    def __set_name__(self, owner, name):
        self.private_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError("음수 불가")
        setattr(obj, self.private_name, value)

class Item:
    price = Positive()

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

item = Item(10)
print(item.price)

__set_name__은 클래스가 만들어질 때 디스크립터에게 자신의 이름을 알려준다. 이 작은 메서드 덕분에 디스크립터는 스스로 저장 필드를 결정할 수 있고, 외부 API는 깔끔하게 유지된다. 결국 디스크립터는 “중복되는 속성 규칙을 하나로 모으는 도구”라는 점에서 가치가 크다.

 

컨테이너, 이터러블, 콜러블

클래스를 파이썬답게 만드는 가장 빠른 방법은 프로토콜을 구현하는 것이다. __iter__와 __len__을 구현하면 반복 가능해지고, __contains__를 구현하면 in 연산이 자연스러워진다. __call__을 구현하면 인스턴스를 함수처럼 쓸 수 있다. 이런 프로토콜은 “어떤 메서드를 구현하면 어떤 문법이 열리는가”라는 계약이다.

프로토콜은 작게 구현해도 큰 효과가 있다. 예를 들어 __iter__만 구현해도 for 루프, list 변환, sum 같은 함수가 바로 동작한다. 즉, 언어 기능과 라이브러리에 끼어드는 진입점이 되는 셈이다. 반대로 의미가 애매한 프로토콜 구현은 API를 혼란스럽게 만들 수 있으니, 메서드 하나를 추가할 때도 “사용자가 기대할 행동”을 먼저 상상해야 한다.

untitled
PY
class Roll:
    def __init__(self, n):
        self.n = n

    def __iter__(self):
        for i in range(self.n):
            yield i

    def __len__(self):
        return self.n

    def __contains__(self, item):
        return 0 <= item < self.n

    def __call__(self, x):
        return x * self.n

r = Roll(3)
print(list(r))
print(2 in r)
print(r(10))

프로토콜을 구현하면 표준 라이브러리와의 호환성이 올라가고, “이 객체를 어떻게 써야 하나?”가 코드만 봐도 직관적으로 드러난다. 프로토콜은 곧 사용자 경험이다.

 

컨텍스트 매니저

with 문은 __enter__와 __exit__를 호출한다. 파일 핸들링, 락, 트랜잭션처럼 “열고-닫는” 패턴을 안전하게 감싸는 데 쓰인다. 직접 구현하면 리소스 정리가 확실해지고, 예외 처리도 깔끔해진다. 특히 예외가 나더라도 __exit__가 보장된다는 점이 핵심이다.

실무에서는 DB 연결, 파일 스트림, 성능 측정 같은 반복 패턴에 컨텍스트 매니저를 붙이면 코드가 짧아지고 실수도 줄어든다. 또한 __exit__에서 예외를 흡수할지, 그대로 전파할지 결정할 수 있기 때문에 트랜잭션 롤백 같은 정책도 자연스럽게 녹아든다.

untitled
PY
class Timer:
    def __enter__(self):
        import time
        self.start = time.time()
        return self

    def __exit__(self, exc_type, exc, tb):
        import time
        self.end = time.time()
        print(f"elapsed: {self.end - self.start:.4f}s")

with Timer():
    sum(i * i for i in range(10000))

예외가 발생해도 __exit__가 실행되기 때문에, “닫는 코드”를 놓치는 경우가 줄어든다. 이 작은 차이가 운영 안정성과 직결되기도 한다.

 

대안 생성자와 팩토리

classmethod는 대안 생성자를 만들 때 특히 유용하다. 서로 다른 입력 형태를 하나의 인스턴스로 표준화해 주는 패턴이다. JS에서는 흔히 정적 팩토리로 하던 걸, 파이썬에서는 classmethod로 푸는 게 자연스럽다. 입력 데이터의 형태가 여러 개인 경우, 생성 규칙을 한 곳에 모을 수 있기 때문이다.

이 접근의 장점은 “객체 생성 정책”을 숨기면서도 인터페이스를 단순하게 유지한다는 데 있다. 반면 classmethod가 너무 많아지면 생성 경로가 분산되어 오히려 혼란스러울 수 있다. 그래서 생성자의 종류가 많아질수록 도메인 개념을 재정리해야 한다.

untitled
PY
class Color:
    def __init__(self, r, g, b):
        self.r = r
        self.g = g
        self.b = b

    @classmethod
    def from_hex(cls, hex_value):
        hex_value = hex_value.lstrip("#")
        r = int(hex_value[0:2], 16)
        g = int(hex_value[2:4], 16)
        b = int(hex_value[4:6], 16)
        return cls(r, g, b)

print(Color.from_hex("#ff8800").r)

이렇게 하면 “생성 규칙”이 한 곳에 모인다. 생성 규칙이 분산되지 않으니 유지보수성이 높아진다. 결국 classmethod는 단순한 문법이 아니라 생성 정책을 설계하는 도구다.

 

상속, 다중 상속, 그리고 MRO

상속은 기능 재사용을 위한 가장 고전적인 방법이다. 하지만 과도한 상속은 오히려 구조를 복잡하게 만들 수 있다. 파이썬은 다중 상속을 지원하고, 이를 안전하게 처리하기 위해 MRO(Method Resolution Order)를 사용한다. super()는 “부모 클래스”가 아니라 “MRO 상 다음 클래스”를 호출한다는 점이 핵심이다.

다중 상속이 작동하는 방식은 C3 linearization 규칙에 기반한다. 이 규칙을 알지 못하면 “왜 이 메서드가 호출되지?” 같은 상황이 생긴다. 그래서 다중 상속을 쓸 때는 항상 MRO를 확인하고, 각 클래스가 super()를 일관적으로 호출하는지 점검해야 한다. 그렇지 않으면 초기화가 누락되거나 중복 실행되는 버그가 생긴다.

untitled
PY
class Employee:
    def __init__(self, name):
        self.name = name

class Cashier(Employee):
    def __init__(self, name, number_sold=0):
        super().__init__(name)
        self.number_sold = number_sold

class DeliveryMan(Employee):
    def __init__(self, name, on_standby):
        super().__init__(name)
        self.on_standby = on_standby

class CashierDeliveryMan(DeliveryMan, Cashier):
    def __init__(self, name, on_standby, number_sold=0):
        super().__init__(name, on_standby)
        self.number_sold = number_sold

print(CashierDeliveryMan.mro())

다중 상속은 “Mixin”처럼 역할이 명확한 조각들을 합치는 용도로 쓰면 깔끔하지만, 복잡한 상태를 갖는 클래스를 억지로 겹치면 MRO가 예상과 달라질 수 있다. super()를 사용하고, 서로 간에 __init__ 시그니처를 최대한 일관되게 유지하는 게 중요하다. 결국 다중 상속은 기능 조합을 위해 쓰고, 상태 공유를 위해 쓰는 것은 피하는 편이 안전하다.

 

상속보다 컴포지션

상속이 항상 정답은 아니다. 실제 업무 코드에서는 컴포지션이 더 읽기 쉬울 때가 많다. 필요한 기능을 “포함”해서 쓰는 방식이다. 특히 상태를 갖는 클래스들이 많아질수록 상속은 관리하기 어렵다. 상속은 계층을 고정시키지만, 컴포지션은 조합을 유연하게 만든다.

컴포지션의 장점은 테스트와 교체가 쉽다는 점이다. 부품을 바꿔 끼우듯이 전략을 주입할 수 있고, 상속의 MRO 문제도 피할 수 있다. 반면 컴포지션이 지나치면 객체 간 연결이 복잡해지고, 책임이 분산되어 흐름을 추적하기 어려울 수 있다. 결국 상속과 컴포지션은 도구이며, “변경 가능성이 어디에 있는가”를 보고 선택해야 한다.

untitled
PY
class Logger:
    def log(self, msg):
        print(f"[LOG] {msg}")

class Service:
    def __init__(self, logger):
        self.logger = logger

    def run(self):
        self.logger.log("start")

service = Service(Logger())
service.run()

상속은 “나는 너다”라는 관계를 의미하지만, 컴포지션은 “나는 너를 가진다”는 관계다. 이 차이는 유지보수 단계에서 큰 차이를 만든다. 특히 장기적으로는 컴포지션이 더 안정적인 경로를 제공하는 경우가 많다.

 

추상 클래스와 인터페이스 유사 구조

파이썬에는 명시적인 interface 키워드가 없지만, abc 모듈로 추상 클래스를 만들 수 있다. 메서드의 “형태”를 강제하고, 구현 책임을 서브클래스에게 넘기는 방식이다. JS의 interface나 타입스크립트의 abstract class와 느낌이 비슷하다. 특히 런타임에서 강제가 필요할 때는 추상 클래스가 가장 직관적이다.

추상 클래스는 설계의 합의를 코드로 박아 넣는 역할을 한다. “이 메서드를 반드시 구현해야 한다”는 명시가 있기 때문에, 협업 시 실수를 줄일 수 있다. 다만 추상 클래스가 많아지면 계층이 복잡해지고, 상속 구조가 경직되는 문제가 생긴다. 그래서 추상 클래스는 핵심 규약에만 적용하는 것이 좋다.

untitled
PY
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

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

    def area(self):
        return 3.14159 * self.r * self.r

타입 힌트를 쓰는 환경이라면 typing.Protocol로 구조적 타이핑도 가능하다. 하지만 런타임 강제가 필요하다면 여전히 abc가 더 직관적이다. 결국 “검증을 언제 할 것인가”가 선택의 기준이 된다.

 

데이터 클래스와 __slots__

보일러플레이트를 줄이고 싶다면 dataclasses가 최고다. __init__, __repr__, __eq__를 자동 생성해 주고, 불변 객체도 쉽게 만들 수 있다. 대규모 데이터 객체를 많이 생성한다면 __slots__로 메모리 사용량까지 줄일 수 있다. 실제로 수십만 개의 인스턴스를 만들 때는 메모리 차이가 체감될 정도로 크다.

다만 __slots__는 동적 속성 추가를 막기 때문에 유연성이 줄어든다. 디버깅 시에도 __dict__가 없어서 내부 상태를 확인하기 어려울 수 있다. 따라서 __slots__는 “유연함보다 효율이 중요한 구간”에만 제한적으로 쓰는 편이 좋다. dataclass는 기본값 처리나 불변성 정책을 쉽게 제공하지만, 자동 생성된 코드가 프로젝트 스타일과 맞는지 검토해야 한다.

untitled
PY
from dataclasses import dataclass

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

p = Point(1, 2)
print(p)

frozen=True는 불변성을 강제한다. 값 객체를 만들거나, 해시 가능한 키로 쓰고 싶을 때 유용하다. 다만 변경이 필요하다면 replace를 통해 새 인스턴스를 만드는 방식이 자연스럽다. 불변성을 선택하는 순간, 상태 변경의 비용을 다른 형태로 치르게 된다는 점도 기억해야 한다.

 

타입 힌트와 클래스의 계약

파이썬은 동적 언어지만, 타입 힌트는 클래스의 역할을 명확히 해준다. ClassVar는 클래스 변수임을 명시하고, Self는 “자기 자신을 반환한다”는 의도를 표현한다. 이런 힌트는 동료 개발자와 타입 체커에게 중요한 신호가 된다. 특히 체이닝 API나 빌더 패턴을 구현할 때 Self는 가독성을 크게 높인다.

타입 힌트는 런타임 검증이 아니라 “약속의 문서화”에 가깝다. 그래서 잘 쓰면 더 안전해지고, 잘못 쓰면 거짓된 안정감을 준다. 클래스 설계 단계에서부터 힌트를 함께 설계하는 편이 깔끔하다. 또한 힌트가 많아질수록 변경 비용이 늘어나기 때문에, 핵심 경계에만 선택적으로 적용하는 것도 전략이다.

untitled
PY
from typing import ClassVar, Self

class Builder:
    version: ClassVar[str] = "1.0"

    def set_name(self, name: str) -> Self:
        self.name = name
        return self

정리하면 타입 힌트는 “정확성”보다 “의도 전달”에 더 가깝다. 그래서 클래스의 외부 계약을 선명하게 만드는 용도로 활용하는 것이 효과적이다.

 

클래스도 객체다: type과 메타클래스

파이썬에서 클래스는 type의 인스턴스다. 그래서 class 키워드를 쓰지 않고도 type()으로 클래스를 만들 수 있다. 메타클래스는 이런 “클래스를 만드는 과정”에 개입하는 기술이다. 즉, 클래스 자체를 설계 대상으로 삼는다는 점에서 매우 강력하다.

메타클래스는 자동 검증, 자동 등록, 규칙 강제 등 강력한 기능을 제공하지만, 과용하면 읽기 어려운 코드가 된다. 그래서 대부분의 실무에서는 메타클래스보다 __init_subclass__나 클래스 데코레이터 같은 가벼운 도구를 먼저 고려한다. 중요한 건 “메타를 쓴다”가 아니라 “왜 메타가 필요한가”다.

untitled
PY
User = type(
    "User",
    (),
    {"role": "member", "say": lambda self: "hi"}
)

u = User()
print(u.role)
print(u.say())

메타클래스는 강력하지만 과용하면 읽기 어려운 코드가 된다. 개인적으로는 __init_subclass__나 클래스 데코레이터 정도에서 대부분의 요구를 해결하는 편이 안전하다고 본다. “필요할 때만 쓰는 도구”라는 경계가 중요하다.

 

클래스 생성 훅: __init_subclass__

__init_subclass__는 “서브클래스가 정의되는 순간”에 호출된다. 메타클래스보다 가볍고, 공통 설정을 강제할 때 유용하다. 프레임워크들이 라우팅이나 모델 등록을 자동화하는 것도 이 원리를 이용하는 경우가 많다. 즉, “클래스가 정의될 때 자동으로 일어나는 일”을 만들 수 있다.

이 패턴은 “클래스를 만드는 순간 등록한다”는 강력한 아이디어를 제공하지만, 등록 기준이 불명확하면 디버깅이 어려워진다. 그래서 __init_subclass__는 규칙이 명확할 때만 쓰는 게 좋다. 또한 다중 상속 환경에서는 호출 순서를 주의해야 한다.

untitled
PY
class Plugin:
    registry = []

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Plugin.registry.append(cls)

class ImagePlugin(Plugin):
    pass

print(Plugin.registry)

결국 __init_subclass__는 메타프로그래밍을 안전한 수준으로 끌어내리는 장치다. “자동화는 좋지만, 투명해야 한다”는 원칙을 지키는 게 핵심이다.

 

__getattr__과 __getattribute__

속성을 찾지 못했을 때 호출되는 __getattr__과, 모든 속성 접근에 끼어드는 __getattribute__는 매우 강력하다. 하지만 강력함만큼 위험하다. 잘못 쓰면 무한 재귀나 성능 저하가 생긴다. 그래서 __getattribute__는 “마지막 카드”로 남겨두는 게 안전하다.

실무에서는 __getattr__만으로도 충분한 경우가 많다. 예를 들어 딕셔너리를 객체처럼 다루는 래퍼나, 동적으로 계산되는 속성을 구현할 때 유용하다. 반면 모든 속성을 가로채는 __getattribute__는 디버깅을 어렵게 만들 수 있으므로, 정말로 필요한 경우만 선택해야 한다. 동작을 숨기기보다 드러내는 설계가 장기적으로 더 안전하다.

untitled
PY
class SafeDict:
    def __init__(self, data):
        self.data = data

    def __getattr__(self, name):
        return self.data.get(name, None)

d = SafeDict({"title": "post"})
print(d.title)
print(d.missing)

__getattribute__는 “마지막 카드”다. 특별한 이유가 없다면 __getattr__만으로도 충분한 경우가 많다. 결국 속성 훅은 “편의”보다 “예측 가능성”이 중요하다.

 

직렬화와 저장

클래스를 다루다 보면 객체를 저장하거나 전송해야 하는 순간이 온다. 파이썬은 pickle로 객체를 직렬화할 수 있지만, 보안 위험이 있기 때문에 외부 입력에 쓰면 안 된다. 그래서 실제 서비스에서는 to_dict 같은 명시적 변환이 더 안정적이다. 직렬화는 편리하지만, 동시에 공개 범위를 강제하는 설계 결정이기도 하다.

실무에서는 “무엇을 저장하고 무엇을 숨길지”가 더 중요하다. 비밀번호, 토큰 같은 민감한 값은 기본적으로 제외되어야 하고, 표현 포맷도 소비자(예: API, 캐시, 로그)에 맞게 결정해야 한다. 결국 직렬화는 기술이 아니라 정책이다. 클래스를 설계할 때부터 “어떤 상태가 공개되는가”를 명확히 해두는 편이 안전하다.

untitled
PY
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def to_dict(self):
        return {"name": self.name, "email": self.email}

print(User("ayden", "me@me.com").to_dict())

직렬화는 편리하지만, 무엇을 저장하고 무엇을 숨길지 명확해야 한다. 클래스의 “public surface”가 어디까지인지 결정하는 과정이 결국 설계다. 규칙 없는 직렬화는 결국 보안 문제로 이어진다.

 

문서화와 인트로스펙션

클래스를 이해하는 가장 빠른 방법은 docstring, __dict__, help(), dir() 같은 인트로스펙션 도구를 쓰는 것이다. 특히 help()는 문서 작성 습관을 강제하기 때문에, 팀 코드베이스를 오래 유지할수록 그 가치가 커진다. 문서화는 단순한 설명이 아니라, “변경 가능한 부분과 불변의 약속”을 드러내는 과정이다.

문서가 빈약하면, 코드의 의도가 드러나지 않아 결국 사용자가 구현을 읽어야 한다. 반대로 docstring이 잘 작성되어 있으면 팀 전체가 같은 개념 모델을 공유할 수 있다. 이는 시간이 지날수록 큰 비용 절감으로 이어진다. 결국 문서화는 “코드가 아니라 의도를 관리하는 작업”이다.

untitled
PY
class User:
    """간단한 유저 모델

    Attributes:
        name (str): 유저 이름
    """
    def __init__(self, name):
        self.name = name

help(User)
print(User.__dict__)

문서화는 “지금 내가 이해하기 위한 기록”이기도 하다. 시간이 지난 뒤 다시 보면, docstring 하나가 코드 리딩 시간을 크게 줄여준다. 유지보수는 결국 “이해 비용”과 싸우는 일이기 때문이다.

 

정리: 설계는 선택이다

심화로 들어오면 클래스는 문법이 아니라 선택의 집합이 된다. 같은 기능도 프로토콜로 풀 것인지, 상속으로 풀 것인지, 메타프로그래밍을 허용할 것인지에 따라 구조가 완전히 달라진다. 그래서 중요한 건 “기능 구현”이 아니라 “규약 설계”다.

결국 심화는 더 많은 기능을 배우는 과정이 아니라, 덜 위험한 선택을 하는 과정이다. 클래스는 그 선택을 강제하고, 팀의 사고방식을 정돈한다. 이 관점을 잡으면 파이썬의 클래스는 단순한 문법을 넘어 설계 도구로 보이기 시작한다.