PUBLISHED

class 기초

작성일: 2024.12.23

class 기초

Class

파이썬에서 클래스는 JS의 class와 비슷해 보이지만, 실제로는 훨씬 더 원초적인 개념이다. 클래스도 객체고, 함수도 객체고, 심지어 타입도 객체다. 그래서 파이썬 클래스는 “정의”라기보다 “객체를 만드는 객체”에 가깝다. 이 관점을 잡아두면, 클래스/인스턴스/메타클래스 사이의 관계가 훨씬 선명해진다. 결국 파이썬의 클래스는 문법적 장치가 아니라 런타임에서 움직이는 객체이며, 이 사실이 나중에 디스크립터나 메타프로그래밍을 이해할 때 중요한 기반이 된다.

파이썬 메서드는 항상 첫 번째 인자로 self를 받는다. JS에서 this가 암묵적으로 바인딩되는 것과 다르게, 파이썬은 명시적으로 전달한다. 클래스 안에 선언된 함수가 인스턴스에 바인딩되면 메서드가 되고, 이때 self가 자동으로 전달된다. 그래서 “self는 그냥 관례적인 이름”이라는 말은 절반만 맞다. 이름은 관례지만, 첫 번째 인자를 받는다는 규칙은 엄격하다. 이 규칙을 이해해야 메서드를 콜백으로 넘겼을 때 왜 인자가 하나 부족하다는 에러가 나는지, 혹은 왜 바운드 메서드가 안전한지를 자연스럽게 받아들일 수 있다.

untitled
PY
class User:
    count = 0  # 클래스 변수 (JS에서는 static 프로퍼티)

    def __init__(self, name, email, password):
        self.name = name
        self.email = email
        self._password = password
        User.count += 1

    def say_hello(self):
        print(f"안녕하세요. 저는 {self.name}입니다.")

    @classmethod
    def number_of_users(cls):
        return cls.count

    @staticmethod
    def valid_email(email):
        return "@" in email

user = User("ayden", "me@me.com", "password1234")
user.say_hello()
print(User.number_of_users())

인스턴스 변수는 각 객체가 따로 들고 있는 상태이고, 클래스 변수는 모든 인스턴스가 공유하는 상태다. 파이썬은 속성을 찾을 때 인스턴스 → 클래스 → 부모 클래스 순으로 탐색한다. 그래서 같은 이름의 인스턴스 변수를 만들면 클래스 변수를 가린다. 이 점을 이해하고 있어야 “왜 count가 이상하게 늘어나지?” 같은 버그를 피할 수 있다. 특히 가변 객체를 클래스 변수로 둔 뒤 인스턴스에서 수정하는 패턴은 자주 사고가 난다. 설계 단계에서 “이 값은 공유 상태인가, 개별 상태인가”를 먼저 분리하는 습관이 필요하다.

JS에서 static 메서드는 클래스에 붙는 “정적 함수”라는 느낌이 강한데, 파이썬은 classmethod와 staticmethod를 구분한다. classmethod는 첫 인자로 cls를 받기 때문에, 대안 생성자나 클래스 단위 연산에 적합하다. staticmethod는 클래스와도 인스턴스와도 관계없는 헬퍼 함수에 가깝다. 이름만 보면 staticmethod가 “더 클래스 같아” 보이지만, 실제로는 classmethod가 클래스의 책임을 더 잘 표현한다. 즉, “클래스의 상태에 관여할 수 있는가”가 핵심 구분이다.

 

클래스의 네임스페이스와 속성 탐색

클래스는 결국 이름과 값을 연결하는 네임스페이스다. 클래스 본문에서 정의된 함수와 변수는 내부적으로 __dict__에 저장된다. 인스턴스도 __dict__를 갖고, 속성 탐색 순서는 인스턴스 → 클래스 → 부모 클래스다. 이 순서를 이해하면 “왜 인스턴스에서 바꾼 값이 클래스에는 안 반영되지?” 같은 혼란이 줄어든다. 반대로 클래스 변수 값을 바꾸면 모든 인스턴스가 영향을 받기 때문에, 의도하지 않은 전역 상태가 만들어질 수 있다.

속성 탐색은 “없으면 예외”라고 끝나는 게 아니라, __getattr__ 같은 후속 훅으로 이어질 수 있다. 그래서 네임스페이스를 잘 이해하면 동적 속성이나 프록시 패턴을 만들 때 훨씬 안정적으로 설계할 수 있다. 디버깅할 때는 __dict__를 직접 들여다보는 것만으로도 많은 실마리가 보이지만, 실무에서는 외부 노출을 줄이기 위해 프로퍼티나 메서드로 감추는 것이 일반적이다.

untitled
PY
class Product:
    tax = 0.1

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

coffee = Product("coffee", 5)
print(coffee.__dict__)   # {'name': 'coffee', 'price': 5}
print(Product.__dict__)  # 클래스 네임스페이스 전체

__dict__는 “모든 것을 보여주는 창”이지만, 언제나 공개하고 싶지는 않다. 그렇기 때문에 실제 프로젝트에서는 __dict__를 직접 만지는 대신, 필요한 정보만 드러내는 프로퍼티나 메서드를 제공하는 편이 안전하다. 또한 __slots__를 도입하면 __dict__ 자체가 사라지는 경우도 있기 때문에, 디버깅 전략도 같이 조정해야 한다.

 

바운드 메서드와 디스크립터 감각

파이썬에서 함수는 클래스에 들어가면 “디스크립터”가 된다. 인스턴스에서 메서드를 꺼내는 순간, 그 함수는 self가 고정된 바운드 메서드가 된다. 그래서 user.say_hello는 사실 “user를 이미 가진 함수”다. 이 메커니즘이 클래스/인스턴스 간 동작을 자연스럽게 만든다. JS에서는 this 바인딩이 실행 시점에 흔들리지만, 파이썬은 접근 시점에 안정적으로 고정된다.

이 차이를 모르면 콜백으로 메서드를 넘길 때 “왜 인자가 모자라지?” 같은 문제가 생긴다. 바운드 메서드는 이미 self를 가진 상태이기 때문에, 외부에서 추가로 self를 넘기면 인자 수가 맞지 않는다. 반대로 클래스에서 직접 함수를 꺼내면 바인딩이 없기 때문에 self를 직접 넣어야 한다. 결국 이 메커니즘을 이해하면 classmethod와 staticmethod가 왜 필요했는지가 명확해진다.

untitled
PY
class Greeter:
    def hello(self):
        return "hi"

g = Greeter()
print(g.hello)        # <bound method Greeter.hello of ...>
print(Greeter.hello)  # <function Greeter.hello at ...>
print(Greeter.hello(g))

classmethod는 cls가 바인딩된 메서드, staticmethod는 바인딩 없이 그대로 함수로 꺼내는 방식이다. “어디에 책임을 둘 것인가”를 먼저 생각하면 어떤 데코레이터를 붙일지 판단이 쉬워진다.

 

생성과 초기화: __new__와 __init__

__init__은 익숙한 생성자 역할을 한다. 하지만 정확히 말하면 “생성”이 아니라 “초기화”다. 객체를 실제로 만드는 건 __new__이고, __init__은 만들어진 객체에 속성을 채우는 단계다. 보통은 __init__만 구현하지만, 불변 객체를 만들거나 인스턴스 생성 과정을 통제하고 싶다면 __new__를 건드린다. 예를 들어 캐싱된 인스턴스를 반환하거나, 입력을 강제 변환하는 경우 __new__가 더 자연스럽다.

__new__는 클래스 메서드처럼 동작하고, 실제 인스턴스를 반환해야 한다. 이 반환값이 __init__의 self가 된다. 따라서 __new__에서 super().__new__를 호출하지 않거나, 다른 타입의 객체를 반환하면 예상치 못한 동작이 생긴다. 결국 __new__는 “생성 정책”을 정의하는 곳이고, __init__은 “초기 상태”를 정의하는 곳이라는 구분이 설계에 도움이 된다.

untitled
PY
class PositiveInt(int):
    def __new__(cls, value):
        if value < 0:
            raise ValueError("음수는 허용하지 않습니다")
        return super().__new__(cls, value)

    def __init__(self, value):
        # int는 이미 생성이 끝난 상태라 여기서는 부가 로깅 정도만 한다
        print(f"생성됨: {value}")

print(PositiveInt(10))

실무에서는 __new__를 남용하기보다는 “정말 생성 단계에서만 가능한 일인지”를 먼저 따져보는 게 좋다. 대부분의 경우는 classmethod 대안 생성자로도 해결되기 때문이다.

 

속성 접근과 프로퍼티

파이썬은 getter/setter 키워드가 없고, @property 데코레이터로 이를 구현한다. 덕분에 외부에서는 평범한 속성처럼 보이지만, 내부에서는 검증 로직을 붙일 수 있다. 이런 방식은 캡슐화를 지키면서도 API를 단순하게 유지한다. 즉, 처음에는 단순 속성으로 시작해도, 나중에 유효성 검사가 필요해졌을 때 인터페이스를 깨지 않고 확장할 수 있다.

프로퍼티는 단순히 “편리한 문법”이 아니라 설계 도구다. 내부 표현을 숨기고 외부 계약을 고정한다는 점에서, 클래스의 공개 경계를 명확히 해준다. 또한 문서화 측면에서도 getter/setter 메서드보다 읽기가 훨씬 직관적이다. 반면 과도한 프로퍼티는 디버깅 시 흐름을 숨길 수 있으니, 어디까지가 속성이고 어디부터가 연산인지 구분하는 감각이 필요하다.

untitled
PY
class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("잔고는 음수가 될 수 없습니다")
        self._balance = value

@property는 단순히 편의 기능이 아니라, 클래스의 공개 인터페이스를 고정시키는 장치다. 초기에는 단순 변수로 시작했다가, 이후 검증 로직이 필요해졌을 때도 외부 사용 코드를 바꾸지 않아도 된다. 이 점을 이해하면 캡슐화가 단순한 은닉이 아니라 “변경에 강한 API 설계”라는 사실이 보인다.

 

매직 메서드와 연산자 오버로딩

파이썬은 연산과 언어 기능을 매직 메서드로 확장한다. +는 __add__, len()은 __len__, for 루프는 __iter__를 호출한다. “파이썬은 모든 것이 객체다”라는 말이 실제로는 이런 메서드 규약으로 구현되어 있다는 뜻이다. 즉, 언어의 문법이 결국 메서드 호출로 치환되는 셈이다.

매직 메서드는 객체의 “행동”을 언어 레벨에 맞추는 작업이다. 그래서 잘 구현하면 객체가 자연스럽게 언어와 융합되지만, 과도하게 구현하면 오히려 행동이 예측 불가능해진다. 특히 연산자 오버로딩은 가독성을 높이는 만큼, 의미가 명확하지 않다면 피하는 편이 낫다. 핵심은 “언어가 기대하는 직관”을 어기지 않는 것이다.

untitled
PY
class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    def __repr__(self):
        return f"Book(title={self.title!r}, pages={self.pages})"

    def __str__(self):
        return f"{self.title} ({self.pages}p)"

    def __len__(self):
        return self.pages

    def __iter__(self):
        for i in range(self.pages):
            yield i + 1

book = Book("Python Class", 3)
print(book)
print(len(book))
print(list(book))

__repr__은 디버깅용, __str__은 사용자용 문자열을 만드는 규약이다. 둘 다 구현해두면 로그와 콘솔 출력이 훨씬 읽기 좋아진다. 이 외에도 __eq__, __lt__, __contains__, __enter__/__exit__, __call__ 같은 메서드를 구현하면 언어 자체와 더 깊게 연결된다. 결국 “객체가 파이썬스러워진다”는 말은 이런 규약을 잘 지킨다는 뜻이다.

 

동등성, 비교, 해시

__eq__를 구현하면 비교가 가능해지지만, 이때 __hash__와의 관계도 같이 고려해야 한다. 파이썬은 기본적으로 “불변 객체는 해시 가능, 가변 객체는 해시 불가”라는 방향을 가진다. 그래서 __eq__만 정의하면 __hash__가 None이 되어 set이나 dict의 키로 쓸 수 없게 된다. 즉, “동등성”을 정의하는 순간 “식별성”의 정책도 같이 결정해야 한다.

이 규칙을 모르면 “왜 set에 못 넣지?” 같은 상황을 맞는다. 불변 값 객체를 만들 거라면 __eq__와 __hash__를 같이 설계하는 게 자연스럽다. 반대로 가변 객체라면 해시를 막는 게 안전한 선택이다. 결국 해시는 성능 최적화의 장치이면서도, 잘못 쓰면 논리적 버그를 낳는 위험한 칼이다.

untitled
PY
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return isinstance(other, Point) and self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)
print({p1, p2})

정리하면, 동등성은 “값 비교”, 해시는 “컨테이너 호환성”이다. 둘은 항상 함께 고려해야 안정적인 자료구조 사용이 가능해진다.

 

정리: 클래스는 “규약”이다

파이썬 클래스의 핵심은 문법이 아니라 규약이다. self, __init__, __repr__, @property 같은 규약을 이해하면, 결국 클래스는 “언어가 기대하는 약속을 지키는 객체”가 된다. 그리고 그 약속을 어떻게 조합하느냐가 설계다. 상속으로 책임을 나눌지, 컴포지션으로 조립할지, dataclass로 단순화할지, 메타클래스로 통제할지. 이 선택들이 결국 파이썬스러움과 유지보수성을 결정한다.

중요한 건 “기능을 구현했다”가 아니라 “어떤 규약을 선택했는가”다. 규약은 곧 도메인 모델이 되고, 그 모델은 팀의 사고방식을 결정한다. 그래서 클래스 설계는 항상 언어적 선택이 아니라 사고의 선택이다. 이 감각을 잡는 순간, 파이썬의 클래스는 단순한 문법을 넘어 설계 도구로 보이기 시작한다.