파이썬 클래스와 객체지향: __init__, self, 메서드, 상속, dataclass
파이썬 클래스는 객체의 설계도입니다. 클래스는 필드와 메서드를 정의하고, 클래스를 호출하면 객체(인스턴스)가 만들어집니다. __init__은 필드를 채우고, self는 현재 객체를 가리킵니다. 상속은 부모의 동작을 물려받아 일부를 바꾸게 하며, @dataclass는 주로 데이터를 담는 클래스의 __init__, __repr__, __eq__를 자동으로 만들어 줍니다.
파이썬 클래스는 객체의 설계도입니다. 객체가 어떤 데이터를 갖고 어떤 동작을 하는지 정의합니다. 객체(인스턴스)는 그 설계도로 만든 실제 물건입니다. 파이썬의 객체지향 프로그래밍은 클래스, 상속, 그리고 __init__이나 __repr__ 같은 특수 메서드에 기반합니다. 이 글에서는 하나의 예제 — 지출 관리 — 를 가지고 전체 흐름을 살펴봅니다.
딕셔너리 대신 클래스를 쓰는 이유
데이터는 딕셔너리로도 담을 수 있습니다: {"title": "커피", "amount": 250}. 하지만 딕셔너리는 어떤 키가 맞는지 알지 못합니다. expense.get("amout", 0)처럼 키 이름을 틀려도 오류가 나지 않고 0이 돌아옵니다. 보고서가 조용히 틀린 값을 내게 됩니다.
클래스 객체는 필드를 미리 선언합니다. 존재하지 않는 속성에 접근하면 바로 AttributeError가 나고, 최신 파이썬은 올바른 이름까지 제안합니다. 또 클래스는 데이터와 그 데이터를 다루는 동작을 한곳에 모읍니다. 지출 객체가 세금 포함 금액을 계산하고, 보고서 객체가 합계를 구하는 식입니다.
파이썬 클래스 만들기
class 키워드로 클래스를 정의합니다. 이름은 PEP 8에 따라 CamelCase로 씁니다. 가장 간단한 클래스 예제는 다음과 같습니다.
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
coffee = Expense("커피", 250)
taxi = Expense("택시", 300)
print(coffee.title, coffee.amount) # 커피 250
Expense("커피", 250)을 호출하면 새 인스턴스가 만들어집니다. coffee와 taxi는 같은 타입이지만 서로 다른 객체이며, 각자 자기 필드 값을 가집니다. isinstance(coffee, Expense)는 True를 반환합니다.
생성자 __init__: 객체 초기화
__init__은 흔히 생성자라고 부르지만, 정확히는 초기화 메서드입니다. 객체 자체는 __new__가 만들고, __init__은 그 직후에 호출되어 받은 인자를 필드에 넣습니다. 이 메서드는 아무 값도 반환하지 않아야 합니다(반드시 None을 반환해야 합니다).
__init__에서 입력값을 검사하면, 잘못된 객체가 프로그램 안에 생겨나지 않습니다.
class Expense:
def __init__(self, title, amount):
if amount < 0:
raise ValueError("금액은 음수가 될 수 없습니다")
self.title = title
self.amount = amount
self란 무엇인가
self는 메서드가 지금 작업하고 있는 바로 그 객체를 가리킵니다. coffee.with_tax()를 호출하면, 파이썬은 사실상 Expense.with_tax(coffee)를 실행하며 객체를 첫 번째 인자로 넘깁니다. 그래서 메서드 정의에서는 self가 첫 번째 매개변수이고, 호출할 때는 쓰지 않습니다.
self는 키워드가 아니라 관례입니다. 이름을 바꿔도 동작하지만 아무도 그렇게 하지 않습니다. 초보자가 자주 하는 실수는 메서드 정의에서 self를 빠뜨리는 것입니다. 그러면 TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given가 납니다.
메서드와 특수 메서드
메서드는 클래스 안에 선언된 함수입니다. self를 통해 객체의 필드에 접근합니다.
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
def with_tax(self, rate=0.2):
return self.amount * (1 + rate)
def __repr__(self):
return f"Expense({self.title!r}, {self.amount})"
coffee = Expense("커피", 250)
print(coffee.with_tax()) # 300.0
print(coffee) # Expense('커피', 250)
__repr__이 없으면 출력이 <__main__.Expense object at 0x7f...>처럼 나와서 디버깅에 도움이 되지 않습니다. 앞뒤에 밑줄 두 개가 붙은 메서드를 특수 메서드(던더 메서드)라고 합니다. 파이썬이 상황에 맞춰 자동으로 호출합니다. __str__은 사용자에게 보이는 문자열, __eq__는 == 비교, __len__은 len(obj)를 담당합니다.
@classmethod와 @staticmethod
일반 메서드는 인스턴스를 다룹니다. @classmethod를 붙인 메서드는 첫 인자로 클래스 자체(cls)를 받습니다. 주로 다른 형식의 데이터에서 객체를 만드는 대체 생성자로 씁니다. @staticmethod는 객체도 클래스도 받지 않는 일반 함수입니다. 논리적으로 클래스와 관련된 함수일 때 씁니다.
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
@classmethod
def from_csv(cls, line):
title, amount = line.split(";")
return cls(title, float(amount))
@staticmethod
def is_valid_amount(value):
return value >= 0
e = Expense.from_csv("점심;520")
print(e.amount) # 520.0
print(Expense.is_valid_amount(-1)) # False
소멸자 __del__
파이썬에는 객체가 사라지기 직전에 가비지 컬렉터가 호출하는 __del__ 메서드가 있습니다. 하지만 호출 시점이 보장되지 않으므로 자원 해제를 여기에 맡기면 안 됩니다. 파일이나 연결은 with 구문이나 close() 메서드로 명시적으로 닫으세요.
클래스 속성과 인스턴스 속성
self.이름 = ...으로 설정한 필드는 특정 객체에만 속합니다. 클래스 본문에서 바로 선언한 변수는 클래스 속성이며, 모든 인스턴스가 공유합니다. 보통 상수를 이렇게 둡니다.
class Expense:
CURRENCY = "KRW" # 클래스 속성, 상수
def __init__(self, title, amount):
self.title = title # 인스턴스 속성
self.amount = amount
a = Expense("커피", 250)
b = Expense("차", 150)
print(a.CURRENCY, Expense.CURRENCY) # KRW KRW
함정은 변경 가능한 클래스 속성입니다. 클래스 본문에 rows = []를 쓰고 self.rows.append(...)로 채우면, 리스트가 모든 객체에서 공유됩니다. 변경 가능한 필드는 항상 __init__에서 만드세요.
객체의 필드를 보려면 vars(obj)나 obj.__dict__를, 문자열로 된 이름으로 속성을 읽으려면 getattr(obj, "amount")를 씁니다.
프로퍼티: @property
프로퍼티는 메서드이지만 바깥에서는 괄호 없이 일반 필드처럼 읽힙니다. 계산으로 얻는 값에 씁니다. 합계를 따로 저장해 두고 갱신을 잊는 대신, 필요할 때 계산하면 됩니다.
class Report:
def __init__(self):
self.rows = []
def add(self, expense):
self.rows.append(expense)
@property
def total(self):
return sum(e.amount for e in self.rows)
r = Report()
r.add(Expense("커피", 250))
r.add(Expense("택시", 300))
print(r.total) # 550
값을 쓰는 것도 통제하려면 @total.setter를 추가합니다. 세터가 없으면 r.total = 0은 AttributeError를 냅니다.
캡슐화에 관해서도 짚고 넘어가겠습니다. 파이썬에는 엄격한 비공개 필드가 없습니다. 밑줄 하나(_cache)는 '외부에서 건드리지 마세요'라는 약속이고, 밑줄 두 개(__secret)는 이름 맹글링(name mangling)을 적용해서, 하위 클래스가 실수로 같은 이름을 덮어쓰지 않도록 합니다.
파이썬 상속: 부모 클래스 확장하기
상속은 부모 클래스를 바탕으로 자식 클래스를 만드는 것입니다. 자식은 부모의 필드와 메서드를 모두 받고, 필요한 메서드만 재정의합니다. 부모는 괄호 안에 씁니다.
class MonthlyReport(Report):
def __init__(self, month):
super().__init__() # 부모의 __init__ 호출
self.month = month
def add(self, expense): # 메서드 재정의
print(f"[{self.month}] + {expense}")
super().add(expense) # 부모의 동작 유지
m = MonthlyReport("10월")
m.add(Expense("커피", 250)) # [10월] + Expense('커피', 250)
print(m.total) # 250 — 프로퍼티도 물려받았습니다
super()는 부모 메서드를 호출하기 위한 중간 객체를 돌려줍니다. 자식의 __init__에서 super().__init__()을 빠뜨리는 것은 흔한 실수입니다. 부모가 만들 rows 필드가 없어서, 첫 add 호출에서 AttributeError가 납니다.
상속 관계는 issubclass(MonthlyReport, Report)로 확인합니다. 파이썬 3의 모든 클래스는 암묵적으로 object를 상속하며, 그래서 기본 __repr__, __eq__ 등을 가집니다. 다중 상속(class C(A, B))도 지원하며, 메서드를 찾는 순서는 C.__mro__로 볼 수 있습니다.
추상 클래스
추상 클래스는 인터페이스를 정합니다. 자식 클래스가 반드시 구현해야 하는 메서드를 정하고, 추상 클래스 자체는 인스턴스로 만들 수 없습니다. 이를 위해 abc 모듈을 씁니다.
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, report): ...
class CsvExporter(Exporter):
def export(self, report):
return "\n".join(f"{e.title};{e.amount}" for e in report.rows)
Exporter() # TypeError: Can't instantiate abstract class Exporter ...
추상 메서드를 모두 구현하지 않은 자식 클래스도 여전히 추상이며, 객체를 만들면 같은 오류가 납니다.
상속 대신 조합(컴포지션)
상속을 배우고 나면 계층을 깊게 쌓고 싶어집니다. 기본 지출, 금액 지출, 원화 지출처럼 다섯 단계까지요. 파이썬에서는 이런 구조가 드뭅니다. 상속은 '~이다'(is-a) 관계를, 조합은 '~을 가진다'(has-a) 관계를 나타냅니다. 보고서는 지출 목록이 아니고, 지출 목록을 rows 필드에 담고 있을 뿐입니다.
실용적인 규칙은 이렇습니다. 다른 객체의 기능이 필요하면 상속하지 말고 필드로 담으세요. 데이터 중심의 클래스는 dataclass로, 로직은 일반 함수로 두면 충분한 경우가 많습니다.
dataclass: 데이터 클래스
데이터를 주로 담는 클래스에서 __init__, __repr__, __eq__를 손으로 쓰는 것은 번거롭습니다. dataclasses 모듈의 @dataclass 데코레이터는 타입 힌트가 달린 필드를 보고 이 메서드들을 생성합니다.
from dataclasses import dataclass, field
@dataclass
class Expense:
title: str
amount: float
tags: list[str] = field(default_factory=list)
def __post_init__(self):
if self.amount < 0:
raise ValueError("금액은 음수가 될 수 없습니다")
e = Expense("커피", 250)
print(e) # Expense(title='커피', amount=250, tags=[])
print(e == Expense("커피", 250)) # True
이전에 __init__에 쓰던 검증은 __post_init__으로 옮깁니다. 이 메서드는 생성된 초기화 코드 직후에 호출됩니다. 변경 가능한 기본값은 field(default_factory=list)로만 지정할 수 있습니다. tags: list = []처럼 쓰면 dataclass가 막고 ValueError를 냅니다.
frozen과 slots
frozen=True는 객체를 변경할 수 없게 만들고, slots=True(Python 3.10부터)는 __slots__를 만들어 메모리를 아낍니다. 고정된 객체의 필드를 바꾸려 하면 FrozenInstanceError가 납니다. 새 값이 필요하면 복사본을 만듭니다.
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class Expense:
title: str
amount: float
e = Expense("커피", 250)
# e.amount = 300 -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2) # Expense(title='커피', amount=250) Expense(title='커피', amount=300)
Python 3.13부터는 같은 일을 범용 함수 copy.replace()로도 할 수 있습니다. 고정된 dataclass 객체는 __hash__가 있으므로 집합의 원소나 딕셔너리의 키로 쓸 수 있습니다.
클래스 관련 새 기능: Self, @override, __slots__
타입 힌트로 클래스를 표현하는 데 쓰는 도구가 두 가지 있습니다. typing.Self(Python 3.11, PEP 673)는 '같은 클래스의 객체를 반환한다'는 뜻이며, 상속받은 메서드에서도 올바르게 동작합니다. @override 데코레이터(Python 3.12, PEP 698)는 부모 메서드를 재정의한다고 표시합니다. 이름에 오타가 있으면 mypy나 pyright가 부모에 그런 메서드가 없다고 알려 줍니다.
from typing import Self, override
class Report:
def __init__(self):
self.rows = []
def add(self, e) -> Self:
self.rows.append(e)
return self # 체이닝 호출이 가능합니다
class MonthlyReport(Report):
@override
def add(self, e) -> Self:
return super().add(e)
Python 3.15에서는 좁은 제약 하나가 풀렸습니다. tuple의 하위 클래스에서 비어 있지 않은 __slots__를 쓸 수 있고, 모든 클래스의 __slots__에 __dict__와 __weakref__를 넣을 수 있습니다. Python 3.14에서는 튜플 하위 클래스가 이렇게 선언하면 TypeError: nonempty __slots__ not supported for subtype of 'tuple'가 났습니다.
클래스에서 자주 하는 실수
| 실수 | 증상 | 해결 |
|---|---|---|
메서드 정의에서 self 빠뜨림 | TypeError: ... takes 0 positional arguments but 1 was given | 메서드의 첫 매개변수는 항상 self |
__init__에서 self. 없이 대입 | 값이 생성자를 벗어나면 사라짐 | self.amount = amount로 쓰기 |
| 클래스 속성으로 리스트 선언 | 데이터가 객체끼리 섞임 | __init__이나 default_factory에서 생성 |
super().__init__() 누락 | 부모 필드에서 AttributeError | 자식 초기화에서 부모 초기화 호출 |
| 프로퍼티를 괄호로 호출 | TypeError: 'int' object is not callable | 괄호 없이 r.total로 읽기 |
정리
파이썬 객체지향은 간단한 원칙에서 출발합니다. 클래스가 필드와 메서드를 정의하고, __init__이 필드를 채우고, self가 현재 객체를 가리킵니다. 상속과 super()로 동작을 확장하고, 추상 클래스로 인터페이스를 정하고, @property로 계산되는 필드를 만듭니다. 데이터 중심의 클래스에는 @dataclass를 쓰고, 복잡한 계층보다는 조합을 선택하세요. 이 주제는 과정 강의에서 실습합니다. 지출 관리기에서 지출과 보고서를 클래스로 나눕니다.
자주 묻는 질문
클래스와 객체는 무엇이 다른가요?
클래스는 타입의 설명입니다. 어떤 필드와 메서드를 가지는지를 정합니다. 객체(인스턴스)는 Expense("커피", 250)처럼 클래스를 호출해 만들며, 구체적인 값을 가집니다.
__init__을 반드시 써야 하나요?
아닙니다. 선언하지 않으면 object에서 상속한 기본 초기화가 쓰이며, 인자를 받지 않습니다. dataclass를 쓰면 데코레이터가 __init__을 만들어 줍니다.
__str__과 __repr__은 무엇이 다른가요?
__repr__은 개발자와 디버거를 위한 정확한 표현이고, __str__은 print나 f-문자열에서 사용자에게 보이는 문장입니다. __str__이 없으면 파이썬은 __repr__을 사용합니다.
dataclass와 일반 클래스 중 무엇을 골라야 하나요?
주로 데이터를 담고 __init__, __repr__, 비교 기능이 필요하면 dataclass가 적합합니다. 복잡한 초기화 로직이나 동작이 중요한 클래스는 일반 클래스가 낫습니다.
파이썬에도 비공개 필드가 있나요?
엄격한 비공개는 없습니다. 이름 앞의 밑줄 하나는 '외부 사용 금지'라는 관례이고, 밑줄 두 개는 이름 맹글링으로 하위 클래스와의 이름 충돌을 막아 줍니다.