Python 클래스: 객체지향 프로그래밍(OOP) 기초와 특수 메서드
이 글의 핵심
클래스 변수에 리스트를 두었다가 모든 인스턴스가 같은 값을 공유해 버리는 실수는 Python OOP 입문자가 가장 자주 겪는 문제입니다. 이 차이를 먼저 잡고, 다중 상속에서 MRO가 메서드 검색 순서를 정하는 방식과 __ 접두사 private 변수의 동작을 확인한 뒤 특수 메서드로 내 객체를 내장 타입처럼 다루는 방법까지 이어갑니다.
들어가며
객체지향 프로그래밍(OOP)이란?
객체지향 프로그래밍(Object-Oriented Programming)은 프로그램을 객체(Object)들의 모음으로 보는 프로그래밍 패러다임입니다. 절차지향 vs 객체지향:
# 절차지향: 함수 중심
def create_account(owner, balance):
return {"owner": owner, "balance": balance}
def deposit(account, amount):
account["balance"] += amount
def withdraw(account, amount):
if amount > account["balance"]:
return False
account["balance"] -= amount
return True
account = create_account("철수", 10000)
deposit(account, 5000)
print(account["balance"]) # 15000
# 객체지향: 객체 중심
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
return False
self.balance -= amount
return True
account = BankAccount("철수", 10000)
account.deposit(5000)
print(account.balance) # 15000
OOP의 장점:
- 캡슐화: 데이터와 기능을 하나로 묶음
- 재사용성: 상속으로 코드 재사용
- 유지보수: 변경 영향 범위 최소화
- 확장성: 새 기능 추가 용이
클래스 기본
클래스와 객체
클래스(Class)는 객체를 만들기 위한 설계도(blueprint)입니다. 붕어빵 틀에 반죽을 부으면 여러 개의 붕어빵이 나오듯, 같은 클래스에서 찍어낸 인스턴스들은 구조는 같고 내용(속성 값)은 다를 수 있습니다.
객체(Object) 또는 인스턴스(Instance)는 클래스로부터 만들어진 실체입니다.
# 클래스 정의 (설계도)
class Person:
pass # 아무것도 없는 클래스
# 객체 생성 (인스턴스화)
person1 = Person() # Person 클래스의 인스턴스
person2 = Person() # 또 다른 인스턴스
print(type(person1)) # <class '__main__.Person'>
print(person1 == person2) # False (서로 다른 객체)
클래스 정의와 메서드
class Person:
# 생성자: 객체 생성 시 자동 호출
def __init__(self, name, age):
self.name = name # 인스턴스 변수
self.age = age
# 인스턴스 메서드
def greet(self):
return f"안녕하세요, {self.name}입니다."
def get_age(self):
return self.age
def is_adult(self):
return self.age >= 18
# 객체 생성
person1 = Person("철수", 25)
person2 = Person("영희", 30)
# 메서드 호출
print(person1.greet()) # 안녕하세요, 철수입니다.
print(person2.age) # 30
print(person1.is_adult()) # True
# 속성 접근
print(person1.name) # 철수
# 속성 변경
person1.age = 26
print(person1.age) # 26
self의 이해
self는 인스턴스 자신을 가리키는 참조입니다:
class Counter:
def __init__(self):
# self: 현재 생성되는 인스턴스를 가리킴
# self.count: 이 인스턴스의 count 속성
self.count = 0
# 각 인스턴스는 독립적인 count를 가짐
def increment(self):
# self.count: 이 메서드를 호출한 인스턴스의 count
self.count += 1
return self.count
# 각 인스턴스는 독립적인 메모리 공간을 가짐
counter1 = Counter() # counter1의 count = 0
counter2 = Counter() # counter2의 count = 0 (별도 메모리)
counter1.increment() # counter1.count = 1
counter1.increment() # counter1.count = 2
print(counter1.count) # 2
counter2.increment() # counter2.count = 1
print(counter2.count) # 1 (counter1과 독립적)
# self는 자동으로 전달됨
# counter1.increment()는 내부적으로 Counter.increment(counter1)로 호출됨
# Python이 첫 번째 인자로 인스턴스를 자동 전달
self의 동작 원리:
class Example:
def __init__(self, value):
self.value = value
def show(self):
print(f"값: {self.value}")
# 인스턴스 메서드 호출
obj = Example(10)
obj.show() # 값: 10
# 위 코드는 실제로 아래와 같이 동작:
Example.show(obj) # 값: 10
# Python이 obj를 첫 번째 인자(self)로 자동 전달
# 따라서 메서드 정의 시 첫 번째 매개변수는 항상 self
self를 사용하지 않으면:
class BadCounter:
def __init__(self):
count = 0 # ❌ self 없음 → 지역 변수
def increment(self):
# count += 1 # ❌ NameError: count가 정의되지 않음
pass
# self를 붙여야 인스턴스 변수가 됨
class GoodCounter:
def __init__(self):
self.count = 0 # ✅ 인스턴스 변수
def increment(self):
self.count += 1 # ✅ 인스턴스 변수 접근
self vs 클래스 변수:
class Student:
# 클래스 변수: 모든 인스턴스가 공유
school = "서울고등학교"
def __init__(self, name):
# 인스턴스 변수: 각 인스턴스마다 독립적
self.name = name
s1 = Student("철수")
s2 = Student("영희")
print(s1.name) # 철수 (인스턴스 변수, 독립적)
print(s2.name) # 영희 (인스턴스 변수, 독립적)
print(s1.school) # 서울고등학교 (클래스 변수, 공유)
print(s2.school) # 서울고등학교 (클래스 변수, 공유)
# 클래스 변수 변경 (모든 인스턴스에 영향)
Student.school = "부산고등학교"
print(s1.school) # 부산고등학교
print(s2.school) # 부산고등학교
생성자와 소멸자
init (생성자)
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
print(f"{owner}님의 계좌 생성 (잔액: {balance}원)")
def deposit(self, amount):
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
return "잔액 부족"
self.balance -= amount
return self.balance
# 사용
account = BankAccount("철수", 10000)
# 철수님의 계좌 생성 (잔액: 10000원)
account.deposit(5000)
print(account.balance) # 15000
del (소멸자)
class FileHandler:
def __init__(self, filename):
self.file = open(filename, 'w')
print(f"{filename} 열림")
def __del__(self):
self.file.close()
print("파일 닫힘")
# 사용
handler = FileHandler("test.txt")
# test.txt 열림
del handler
# 파일 닫힘
__del__은 C++의 소멸자와 비슷해 보이지만 호출 시점을 보장하지 않는다는 점이 결정적으로 다릅니다. del handler는 객체를 지우는 명령이 아니라 handler라는 이름과 객체의 연결을 끊는 명령입니다. 같은 객체를 가리키는 다른 변수나 리스트 원소가 남아 있으면 참조 횟수가 0이 되지 않으므로 __del__은 호출되지 않습니다. 위 예제에서 “파일 닫힘”이 바로 출력되는 것은 CPython이 참조 횟수가 0이 되는 즉시 객체를 정리하기 때문인데, 두 객체가 서로를 참조하는 순환이 있으면 주기적으로 도는 가비지 컬렉터가 처리할 때까지 늦어지고, PyPy 같은 다른 구현에서는 언제 호출될지 알 수 없습니다. 프로그램 종료 시에는 호출되지 않을 수도 있습니다. 또 __init__에서 open()이 실패해 예외가 나면 self.file이 만들어지지 않은 채로 __del__이 호출되어 AttributeError: 'FileHandler' object has no attribute 'file'이 경고로 찍히기도 합니다.
그래서 파일, 네트워크 연결, 잠금처럼 반드시 제때 해제해야 하는 자원은 __del__이 아니라 with 문으로 관리하는 것이 Python의 표준 방식입니다. 클래스에 __enter__와 __exit__를 정의하면 with FileHandler("test.txt") as h: 블록이 끝나는 순간 예외 발생 여부와 상관없이 __exit__가 호출됩니다. 뒤의 Employee 예제처럼 __del__로 개수를 줄이는 방식도 같은 이유로 믿을 수 없으므로, 실무에서는 명시적인 remove() 메서드를 두는 편이 안전합니다.
클래스 변수 vs 인스턴스 변수
차이점 이해
class Dog:
# 클래스 변수: 모든 인스턴스가 공유
species = "Canis familiaris"
count = 0 # 생성된 개 수
def __init__(self, name, age):
# 인스턴스 변수: 각 인스턴스마다 독립적
self.name = name
self.age = age
Dog.count += 1 # 클래스 변수 증가
dog1 = Dog("바둑이", 3)
dog2 = Dog("멍멍이", 5)
# 클래스 변수 접근
print(dog1.species) # Canis familiaris
print(dog2.species) # Canis familiaris
print(Dog.count) # 2 (생성된 개 수)
# 인스턴스 변수 접근
print(dog1.name) # 바둑이
print(dog2.name) # 멍멍이
# 클래스 변수 변경 (모든 인스턴스 영향)
Dog.species = "개"
print(dog1.species) # 개
print(dog2.species) # 개
# 인스턴스 변수 변경 (해당 인스턴스만 영향)
dog1.name = "뽀삐"
print(dog1.name) # 뽀삐
print(dog2.name) # 멍멍이 (변경 안 됨)
클래스 변수 주의사항
class MyClass:
shared_list = [] # 클래스 변수 (위험!)
def __init__(self, value):
self.shared_list.append(value) # 모든 인스턴스가 공유
obj1 = MyClass(1)
obj2 = MyClass(2)
print(obj1.shared_list) # [1, 2] (의도하지 않은 공유!)
print(obj2.shared_list) # [1, 2]
# 올바른 방법: 인스턴스 변수 사용
class MyClass:
def __init__(self, value):
self.my_list = [] # 인스턴스 변수
self.my_list.append(value)
obj1 = MyClass(1)
obj2 = MyClass(2)
print(obj1.my_list) # [1]
print(obj2.my_list) # [2]
이 문제가 헷갈리는 이유는 읽기와 대입의 규칙이 다르기 때문입니다. self.shared_list를 읽을 때 Python은 먼저 인스턴스의 속성에서 찾고, 없으면 클래스에서 찾습니다. append()는 찾아낸 리스트 객체를 그 자리에서 수정하므로 클래스의 리스트가 바뀝니다. 반면 self.count = self.count + 1처럼 대입을 하면 인스턴스에 같은 이름의 새 속성이 생겨 클래스 변수를 가리게 되고, 그 뒤로 이 인스턴스만 다른 값을 보게 됩니다. 앞의 Dog 예제가 self.count += 1이 아니라 Dog.count += 1로 클래스 이름을 명시한 것도 이 때문입니다. self.count += 1로 쓰면 각 인스턴스에 1짜리 속성이 생길 뿐 Dog.count는 0으로 남습니다. 정리하면 클래스 변수에는 모든 인스턴스가 공유해도 되는 불변 값(상수, 기본 설정)만 두고, 리스트나 딕셔너리처럼 바뀌는 값은 __init__에서 만드는 것이 원칙입니다. 함수의 기본 인자에 def f(items=[])를 쓰면 같은 문제가 생기는 것도 같은 원리입니다.
클래스 변수 활용 예제
class Employee:
company = "ABC Corp" # 회사명 (모든 직원 공유)
employee_count = 0 # 직원 수
def __init__(self, name, position):
self.name = name
self.position = position
Employee.employee_count += 1
@classmethod
def get_employee_count(cls):
return cls.employee_count
def __del__(self):
Employee.employee_count -= 1
# 사용
emp1 = Employee("홍길동", "개발자")
emp2 = Employee("김철수", "디자이너")
print(f"회사: {Employee.company}") # ABC Corp
print(f"직원 수: {Employee.get_employee_count()}") # 2
del emp1
print(f"직원 수: {Employee.get_employee_count()}") # 1
상속 (Inheritance)
상속이란?
상속(Inheritance)은 기존 클래스의 속성과 메서드를 재사용하는 메커니즘입니다. 용어:
- 부모 클래스(Parent Class) = 기반 클래스(Base Class) = 슈퍼 클래스(Super Class)
- 자식 클래스(Child Class) = 파생 클래스(Derived Class) = 서브 클래스(Sub Class)
기본 상속
# 부모 클래스
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "소리를 냅니다"
def move(self):
return f"{self.name}이(가) 움직입니다"
# 자식 클래스 1
class Dog(Animal): # Animal 상속
def speak(self): # 메서드 오버라이딩
return f"{self.name}: 멍멍!"
# 자식 클래스 2
class Cat(Animal):
def speak(self):
return f"{self.name}: 야옹~"
# 사용
dog = Dog("바둑이")
cat = Cat("나비")
print(dog.speak()) # 바둑이: 멍멍! (오버라이딩된 메서드)
print(dog.move()) # 바둑이이(가) 움직입니다 (상속받은 메서드)
print(cat.speak()) # 나비: 야옹~
# isinstance(): 타입 확인
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Animal)) # True (부모 클래스도 True)
print(isinstance(dog, Cat)) # False
# issubclass(): 상속 관계 확인
print(issubclass(Dog, Animal)) # True
print(issubclass(Animal, Dog)) # False
super() 사용
super()는 부모 클래스의 메서드를 호출할 때 사용합니다.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def get_info(self):
return f"{self.name} - {self.salary:,}원"
def work(self):
return f"{self.name}이(가) 일합니다"
class Manager(Employee):
def __init__(self, name, salary, team_size):
super().__init__(name, salary) # 부모 생성자 호출
self.team_size = team_size
def get_info(self):
base_info = super().get_info() # 부모 메서드 호출
return f"{base_info} (팀원: {self.team_size}명)"
def manage_team(self):
return f"{self.name}이(가) {self.team_size}명의 팀을 관리합니다"
# 사용
manager = Manager("김팀장", 5000000, 5)
print(manager.get_info()) # 김팀장 - 5,000,000원 (팀원: 5명)
print(manager.work()) # 김팀장이(가) 일합니다 (상속)
print(manager.manage_team()) # 김팀장이(가) 5명의 팀을 관리합니다
다중 상속
Python은 다중 상속을 지원합니다 (C++과 유사, Java는 불가).
class Flyer:
def fly(self):
return "날아갑니다"
class Swimmer:
def swim(self):
return "헤엄칩니다"
# 다중 상속
class Duck(Flyer, Swimmer):
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name}: 꽥꽥!"
duck = Duck("도널드")
print(duck.fly()) # 날아갑니다
print(duck.swim()) # 헤엄칩니다
print(duck.speak()) # 도널드: 꽥꽥!
# MRO (Method Resolution Order): 메서드 검색 순서
print(Duck.__mro__)
# (<class '__main__.Duck'>, <class '__main__.Flyer'>,
# <class '__main__.Swimmer'>, <class 'object'>)
MRO가 중요한 순간은 두 부모가 같은 이름의 메서드를 가질 때입니다. Flyer와 Swimmer가 둘 다 move()를 정의했다면 duck.move()는 MRO에서 먼저 나오는 Flyer.move()를 호출하고, 부모 순서를 class Duck(Swimmer, Flyer)로 바꾸면 결과가 달라집니다. super()도 “부모 클래스”가 아니라 MRO에서 다음 클래스를 가리키기 때문에, 다중 상속에서 모든 클래스가 super().__init__()를 호출하도록 협력하면 각 __init__이 정확히 한 번씩 실행됩니다. 반대로 한 클래스라도 Parent.__init__(self)처럼 직접 호출하면 공통 조상의 초기화가 두 번 실행되거나 건너뛰어집니다. 실무에서 다중 상속은 이 예제처럼 상태 없이 기능만 더하는 작은 믹스인(mixin) 클래스에 한정해 쓰는 것이 일반적입니다.
상속 실전 예제
# 게임 캐릭터 시스템
class Character:
def __init__(self, name, hp, attack):
self.name = name
self.hp = hp
self.attack = attack
def take_damage(self, damage):
self.hp -= damage
if self.hp < 0:
self.hp = 0
return self.hp
def is_alive(self):
return self.hp > 0
def basic_attack(self, target):
return target.take_damage(self.attack)
class Warrior(Character):
def __init__(self, name, hp, attack, defense):
super().__init__(name, hp, attack)
self.defense = defense
def take_damage(self, damage):
reduced = max(0, damage - self.defense)
return super().take_damage(reduced)
def shield_bash(self, target):
return target.take_damage(self.attack * 1.5)
class Mage(Character):
def __init__(self, name, hp, attack, mana):
super().__init__(name, hp, attack)
self.mana = mana
def fireball(self, target):
if self.mana >= 20:
self.mana -= 20
return target.take_damage(self.attack * 2)
return 0
# 전투 시뮬레이션
warrior = Warrior("전사", 150, 20, 5)
mage = Mage("마법사", 100, 30, 50)
print(f"{warrior.name} HP: {warrior.hp}") # 전사 HP: 150
print(f"{mage.name} HP: {mage.hp}") # 마법사 HP: 100
# 전사가 마법사 공격
mage.basic_attack(warrior)
print(f"{warrior.name} HP: {warrior.hp}") # 전사 HP: 125 (30-5=25 데미지)
# 마법사가 파이어볼
mage.fireball(warrior)
print(f"{warrior.name} HP: {warrior.hp}") # 전사 HP: 70 (60-5=55 데미지)
print(f"{mage.name} 마나: {mage.mana}") # 마법사 마나: 30
캡슐화 (Encapsulation)
캡슐화란?
캡슐화(Encapsulation)는 데이터를 외부로부터 보호하며, 접근을 제어하는 것입니다. Python의 접근 제어:
public: 제한 없음 (기본)_protected: 관례상 내부 사용 (강제 아님)__private: Name Mangling으로 이름을 바꿔 실수로 인한 접근·충돌을 막음 (보안 장치는 아님)
Private 변수 (__prefix)
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self._account_number = "123" # protected (관례)
self.__balance = balance # private (__)
def get_balance(self):
return self.__balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
return True
return False
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
return True
return False
account = BankAccount("철수", 10000)
# public 접근
print(account.owner) # 철수
# protected 접근 (가능하지만 권장 안 함)
print(account._account_number) # 123
# private 접근 시도
# print(account.__balance) # AttributeError: 'BankAccount' object has no attribute '__balance'
# 메서드로 접근 (올바른 방법)
print(account.get_balance()) # 10000
# Name Mangling: 실제로는 _ClassName__attribute로 변환됨
print(account._BankAccount__balance) # 10000 (가능하지만 절대 하지 말 것!)
마지막 줄에서 보듯이 __balance는 진짜로 숨겨진 것이 아니라 이름이 _BankAccount__balance로 바뀌었을 뿐입니다. Name Mangling의 원래 목적은 외부 접근 차단이 아니라 상속했을 때 이름 충돌을 막는 것입니다. 자식 클래스가 우연히 같은 __balance라는 이름을 쓰더라도 _Child__balance로 바뀌어 부모의 속성을 덮어쓰지 않습니다. 이 때문에 자식 클래스에서 부모의 self.__balance에 접근하려 하면 AttributeError: 'SavingsAccount' object has no attribute '_SavingsAccount__balance'처럼 자기 클래스 이름으로 바뀐 이름을 찾다가 실패합니다. 자식 클래스와 공유할 내부 속성이라면 밑줄 하나(_balance)가 맞고, 밑줄 두 개는 “하위 클래스와도 충돌하면 안 되는” 드문 경우에만 쓰는 것이 Python 커뮤니티의 관례입니다. Python은 “모두가 책임 있는 어른”이라는 철학에 따라 접근을 강제로 막지 않고 이름 규칙으로 의도를 드러냅니다.
@property 데코레이터
@property는 메서드를 속성처럼 사용할 수 있게 합니다. Getter/Setter를 우아하게 구현합니다.
class Circle:
def __init__(self, radius):
self._radius = radius # protected (관례)
@property
def radius(self):
"""Getter: circle.radius로 접근"""
return self._radius
@radius.setter
def radius(self, value):
"""Setter: circle.radius = value로 설정"""
if value < 0:
raise ValueError("반지름은 양수여야 합니다")
self._radius = value
@property
def area(self):
"""읽기 전용 속성 (setter 없음)"""
return 3.14159 * self._radius ** 2
@property
def diameter(self):
return self._radius * 2
@diameter.setter
def diameter(self, value):
self._radius = value / 2
# 사용
circle = Circle(5)
print(circle.radius) # 5 (메서드지만 속성처럼 접근)
print(circle.area) # 78.53975
circle.radius = 10 # setter 호출
print(circle.area) # 314.159
# circle.area = 100 # AttributeError: property 'area' of 'Circle' object has no setter (3.11+, 이전 버전은 can't set attribute)
circle.diameter = 20 # diameter setter
print(circle.radius) # 10.0
# 유효성 검사
# circle.radius = -5 # ValueError: 반지름은 양수여야 합니다
@property의 진짜 장점은 처음에는 평범한 속성으로 시작해도 된다는 것입니다. Java처럼 처음부터 모든 필드에 get_x()/set_x()를 만들어 둘 필요 없이 self.radius로 공개해 두었다가, 나중에 검증이 필요해지면 @property로 바꿔도 호출하는 쪽 코드(circle.radius = 10)는 한 글자도 바뀌지 않습니다. 다만 주의할 점이 있습니다. 이 예제의 __init__은 self._radius = radius로 내부 변수에 직접 대입하므로, Circle(-5)처럼 생성할 때는 검증을 건너뜁니다. 생성 시에도 검증하려면 __init__에서 self.radius = radius로 setter를 거치게 해야 합니다. 또 getter 안에서 실수로 return self.radius처럼 밑줄 없는 이름을 쓰면 getter가 자기 자신을 끝없이 호출해 RecursionError: maximum recursion depth exceeded가 나는데, property를 처음 쓸 때 가장 흔히 겪는 에러입니다.
캡슐화 실전 예제
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("절대영도 이하는 불가능합니다")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5/9
@property
def kelvin(self):
return self._celsius + 273.15
# 사용
temp = Temperature(25)
print(f"섭씨: {temp.celsius}°C") # 섭씨: 25°C
print(f"화씨: {temp.fahrenheit}°F") # 화씨: 77.0°F
print(f"켈빈: {temp.kelvin}K") # 켈빈: 298.15K
temp.fahrenheit = 100 # 화씨로 설정
print(f"섭씨: {temp.celsius}°C") # 섭씨: 37.77777777777778°C
# temp.celsius = -300 # ValueError: 절대영도 이하는 불가능합니다
특수 메서드 (Magic Methods / Dunder Methods)
특수 메서드란?
특수 메서드(Magic Methods)는 __method__ 형태로, Python의 내장 연산자와 함수를 커스터마이즈합니다.
주요 특수 메서드:
| 메서드 | 설명 | 호출 방법 |
|---|---|---|
__init__ | 생성자 | obj = MyClass() |
__str__ | 문자열 표현 (사용자용) | str(obj), print(obj) |
__repr__ | 문자열 표현 (개발자용) | repr(obj) |
__len__ | 길이 | len(obj) |
__add__ | 덧셈 | obj1 + obj2 |
__eq__ | 같음 비교 | obj1 == obj2 |
__lt__ | 작음 비교 | obj1 < obj2 |
__getitem__ | 인덱싱 | obj[key] |
__setitem__ | 인덱스 할당 | obj[key] = value |
__call__ | 호출 | obj() |
특수 메서드 구현
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
"""print()에서 사용"""
return f"Vector({self.x}, {self.y})"
def __repr__(self):
"""개발자용 표현 (디버깅)"""
return f"Vector(x={self.x}, y={self.y})"
def __add__(self, other):
"""+ 연산자 오버로딩"""
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
"""- 연산자"""
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
"""* 연산자 (스칼라 곱)"""
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other):
"""== 연산자"""
return self.x == other.x and self.y == other.y
def __len__(self):
"""len() 함수 (벡터 크기)"""
return int((self.x ** 2 + self.y ** 2) ** 0.5)
def __getitem__(self, index):
"""인덱싱: v[0], v[1]"""
if index == 0:
return self.x
elif index == 1:
return self.y
else:
raise IndexError("Vector index out of range")
# 사용
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1) # Vector(1, 2) (__str__)
print(repr(v1)) # Vector(x=1, y=2) (__repr__)
v3 = v1 + v2 # __add__
print(v3) # Vector(4, 6)
v4 = v2 - v1 # __sub__
print(v4) # Vector(2, 2)
v5 = v1 * 3 # __mul__
print(v5) # Vector(3, 6)
print(v1 == v2) # False (__eq__)
print(len(v1)) # 2 (__len__)
print(v1[0], v1[1]) # 1 2 (__getitem__)
이 예제는 문법을 보여 주기 위한 것이라 실제로 쓰려면 다듬어야 할 부분이 있습니다. 첫째, __len__은 “원소 개수”를 뜻하는 프로토콜이라 반드시 0 이상의 정수를 돌려줘야 하고, if v: 같은 참/거짓 판정에도 쓰입니다. 벡터의 크기를 __len__으로 표현하면 Vector(0.3, 0.4)의 len()이 0이 되어 거짓으로 판정되는 이상한 동작이 생기므로, 크기는 magnitude() 같은 이름 있는 메서드나 __abs__로 표현하는 편이 맞습니다. 둘째, __add__와 __eq__는 other가 Vector가 아닐 때 AttributeError로 터집니다. 처리할 수 없는 타입이면 return NotImplemented를 돌려줘야 Python이 상대 객체의 메서드를 시도하거나 적절한 TypeError를 냅니다. 셋째, v1 * 3은 되지만 3 * v1은 TypeError: unsupported operand type(s) for *: 'int' and 'Vector'가 납니다. 왼쪽 피연산자가 int이면 int.__mul__이 먼저 시도되고, 실패하면 오른쪽의 __rmul__을 찾기 때문에 __rmul__ = __mul__을 함께 정의해야 합니다.
넷째, __eq__를 정의하면 Python은 그 클래스의 __hash__를 자동으로 None으로 만듭니다. “같다고 판정되는 두 객체는 해시도 같아야 한다”는 규칙을 기본 해시(객체 주소 기반)가 지킬 수 없기 때문입니다. 그래서 {v1, v2}나 딕셔너리 키로 쓰면 TypeError: unhashable type: 'Vector'가 납니다. 값이 바뀌지 않는 객체라면 def __hash__(self): return hash((self.x, self.y))를 함께 정의합니다. 이런 보일러플레이트를 매번 쓰기 번거롭다면 표준 라이브러리의 @dataclass가 __init__, __repr__, __eq__를 자동으로 만들어 주고, frozen=True를 주면 불변 객체로 만들면서 __hash__까지 생성합니다. 데이터를 담는 것이 주 목적인 클래스라면 직접 특수 메서드를 쓰기 전에 dataclass를 먼저 검토해 보세요.
컨테이너 특수 메서드
class Inventory:
def __init__(self):
self.items = {}
def __setitem__(self, key, value):
"""inventory[key] = value"""
self.items[key] = value
def __getitem__(self, key):
"""inventory[key]"""
return self.items.get(key, 0)
def __delitem__(self, key):
"""del inventory[key]"""
if key in self.items:
del self.items[key]
def __contains__(self, key):
"""key in inventory"""
return key in self.items
def __len__(self):
"""len(inventory)"""
return len(self.items)
def __str__(self):
return str(self.items)
# 사용
inv = Inventory()
inv["sword"] = 1 # __setitem__
inv["potion"] = 5
print(inv["sword"]) # 1 (__getitem__)
print("sword" in inv) # True (__contains__)
print(len(inv)) # 2 (__len__)
del inv["sword"] # __delitem__
print(inv) # {'potion': 5} (__str__)
call 메서드
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
"""객체를 함수처럼 호출"""
return x * self.factor
# 사용
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10 (객체를 함수처럼 호출)
print(triple(5)) # 15
# 실전 예제: 카운터
class Counter:
def __init__(self):
self.count = 0
def __call__(self):
self.count += 1
return self.count
counter = Counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
클래스 메서드와 정적 메서드
메서드 종류 비교
| 메서드 | 첫 인자 | 접근 가능 | 사용 시점 |
|---|---|---|---|
| 인스턴스 메서드 | self | 인스턴스 변수 | 인스턴스 데이터 필요 |
| 클래스 메서드 | cls | 클래스 변수 | 대체 생성자, 클래스 데이터 |
| 정적 메서드 | 없음 | 제한 없음 | 유틸리티 함수 |
클래스 메서드 (@classmethod)
from datetime import date
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_birth_year(cls, name, birth_year):
"""대체 생성자: 출생년도로 객체 생성"""
age = date.today().year - birth_year
return cls(name, age) # cls는 Person 클래스
@classmethod
def get_species(cls):
"""클래스 정보 반환"""
return "Homo sapiens"
def greet(self):
return f"안녕하세요, {self.name}입니다. {self.age}살입니다."
# 일반 생성자
person1 = Person("철수", 25)
# 클래스 메서드로 생성
person2 = Person.from_birth_year("영희", 1995)
print(person1.greet()) # 안녕하세요, 철수입니다. 25살입니다.
print(person2.greet()) # 안녕하세요, 영희입니다. 31살입니다.
print(Person.get_species()) # Homo sapiens
정적 메서드 (@staticmethod)
class MathUtils:
PI = 3.14159
@staticmethod
def is_even(n):
"""정적 메서드: self나 cls 불필요"""
return n % 2 == 0
@staticmethod
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
@staticmethod
def factorial(n):
if n <= 1:
return 1
return n * MathUtils.factorial(n - 1)
def __init__(self, radius):
self.radius = radius
def area(self):
"""인스턴스 메서드: self 필요"""
return MathUtils.PI * self.radius ** 2
# 정적 메서드 사용 (인스턴스 없이 호출 가능)
print(MathUtils.is_even(4)) # True
print(MathUtils.is_prime(7)) # True
print(MathUtils.factorial(5)) # 120
# 인스턴스 메서드 사용
circle = MathUtils(5)
print(circle.area()) # 78.53975
# 인스턴스에서도 정적 메서드 호출 가능 (권장 안 함)
print(circle.is_even(4)) # True
실전 예제: 날짜 유틸리티
from datetime import datetime
class DateUtils:
@staticmethod
def is_weekend(date_str):
"""날짜가 주말인지 확인"""
date = datetime.strptime(date_str, "%Y-%m-%d")
return date.weekday() >= 5 # 5: 토요일, 6: 일요일
@staticmethod
def days_between(date1_str, date2_str):
"""두 날짜 사이의 일수"""
date1 = datetime.strptime(date1_str, "%Y-%m-%d")
date2 = datetime.strptime(date2_str, "%Y-%m-%d")
return abs((date2 - date1).days)
@classmethod
def today(cls):
"""오늘 날짜 반환"""
return datetime.now().strftime("%Y-%m-%d")
# 사용
print(DateUtils.is_weekend("2026-03-29")) # True (일요일)
print(DateUtils.days_between("2026-03-01", "2026-03-29")) # 28
print(DateUtils.today()) # 2026-03-29
추상 클래스 (Abstract Class)
추상 클래스란?
추상 클래스(Abstract Class)는 직접 인스턴스화할 수 없으며, 자식 클래스가 반드시 구현해야 하는 메서드를 정의합니다.
from abc import ABC, abstractmethod
class Shape(ABC): # ABC 상속
@abstractmethod
def area(self):
"""자식 클래스에서 반드시 구현해야 함"""
pass
@abstractmethod
def perimeter(self):
pass
def describe(self):
"""일반 메서드 (구현 가능)"""
return f"넓이: {self.area()}, 둘레: {self.perimeter()}"
# shape = Shape() # TypeError: Can't instantiate abstract class
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
# 사용
rect = Rectangle(5, 10)
print(rect.describe()) # 넓이: 50, 둘레: 30
circle = Circle(5)
print(circle.describe()) # 넓이: 78.53975, 둘레: 31.4159
다형성 (Polymorphism)
다형성이란?
다형성(Polymorphism)은 같은 인터페이스로 다른 동작을 수행하는 것입니다.
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "멍멍!"
class Cat(Animal):
def speak(self):
return "야옹~"
class Cow(Animal):
def speak(self):
return "음메~"
# 다형성: 같은 메서드 이름, 다른 동작
def make_sound(animal):
print(animal.speak())
animals = [Dog(), Cat(), Cow()]
for animal in animals:
make_sound(animal)
# 멍멍!
# 야옹~
# 음메~
다형성 실전 예제: 결제 시스템
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCard(PaymentMethod):
def __init__(self, card_number):
self.card_number = card_number
def pay(self, amount):
return f"신용카드({self.card_number[-4:]})로 {amount:,}원 결제"
class BankTransfer(PaymentMethod):
def __init__(self, account_number):
self.account_number = account_number
def pay(self, amount):
return f"계좌이체({self.account_number})로 {amount:,}원 결제"
class KakaoPay(PaymentMethod):
def __init__(self, phone):
self.phone = phone
def pay(self, amount):
return f"카카오페이({self.phone})로 {amount:,}원 결제"
# 결제 처리 함수 (다형성)
def process_payment(payment_method, amount):
print(payment_method.pay(amount))
# 사용
methods = [
CreditCard("1234-5678-9012-3456"),
BankTransfer("110-123-456789"),
KakaoPay("010-1234-5678")
]
for method in methods:
process_payment(method, 50000)
# 출력:
# 신용카드(3456)로 50,000원 결제
# 계좌이체(110-123-456789)로 50,000원 결제
# 카카오페이(010-1234-5678)로 50,000원 결제
실전 프로젝트: 도서 관리 시스템
from datetime import datetime, timedelta
class Book:
def __init__(self, isbn, title, author, available=True):
self.isbn = isbn
self.title = title
self.author = author
self.available = available
def __str__(self):
status = "대출 가능" if self.available else "대출 중"
return f"[{self.isbn}] {self.title} - {self.author} ({status})"
def __eq__(self, other):
return self.isbn == other.isbn
class Member:
def __init__(self, member_id, name):
self.member_id = member_id
self.name = name
self.borrowed_books = []
def __str__(self):
return f"회원 {self.name}({self.member_id})"
class Library:
def __init__(self):
self.books = {}
self.members = {}
self.loan_records = []
def add_book(self, book):
self.books[book.isbn] = book
print(f"도서 추가: {book.title}")
def register_member(self, member):
self.members[member.member_id] = member
print(f"회원 등록: {member.name}")
def borrow_book(self, member_id, isbn):
if member_id not in self.members:
return "등록되지 않은 회원입니다"
if isbn not in self.books:
return "존재하지 않는 도서입니다"
book = self.books[isbn]
member = self.members[member_id]
if not book.available:
return f"{book.title}은(는) 이미 대출 중입니다"
book.available = False
member.borrowed_books.append(isbn)
due_date = datetime.now() + timedelta(days=14)
self.loan_records.append({
"member": member_id,
"book": isbn,
"borrow_date": datetime.now(),
"due_date": due_date
})
return f"{member.name}님이 '{book.title}'을(를) 대출했습니다 (반납일: {due_date.strftime('%Y-%m-%d')})"
def return_book(self, member_id, isbn):
if isbn not in self.books:
return "존재하지 않는 도서입니다"
book = self.books[isbn]
member = self.members[member_id]
if isbn not in member.borrowed_books:
return "대출하지 않은 도서입니다"
book.available = True
member.borrowed_books.remove(isbn)
return f"{member.name}님이 '{book.title}'을(를) 반납했습니다"
def list_available_books(self):
available = [book for book in self.books.values() if book.available]
return available
# 사용 예제
library = Library()
# 도서 추가
library.add_book(Book("978-1", "파이썬 입문", "홍길동"))
library.add_book(Book("978-2", "알고리즘 정복", "김철수"))
library.add_book(Book("978-3", "데이터 과학", "이영희"))
# 회원 등록
library.register_member(Member("M001", "박민수"))
library.register_member(Member("M002", "정수진"))
# 대출
print(library.borrow_book("M001", "978-1"))
# 박민수님이 '파이썬 입문'을(를) 대출했습니다 (반납일: 2026-04-12)
print(library.borrow_book("M002", "978-1"))
# 파이썬 입문은(는) 이미 대출 중입니다
# 반납
print(library.return_book("M001", "978-1"))
# 박민수님이 '파이썬 입문'을(를) 반납했습니다
# 대출 가능 도서 목록
print("\n대출 가능 도서:")
for book in library.list_available_books():
print(f" - {book}")
OOP 4대 원칙
캡슐화 (Encapsulation)
데이터와 메서드를 하나로 묶으며, 외부 접근을 제어합니다.
class Account:
def __init__(self, balance):
self.__balance = balance # private
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self):
return self.__balance
# 직접 접근 불가, 메서드로만 접근
상속 (Inheritance)
기존 클래스를 확장하여 재사용합니다.
class Vehicle:
def move(self):
pass
class Car(Vehicle):
def move(self):
return "도로를 달립니다"
다형성 (Polymorphism)
같은 인터페이스로 다른 동작을 수행합니다.
def process(shape):
print(shape.area()) # 각 도형마다 다른 계산
process(Rectangle(5, 10)) # 50
process(Circle(5)) # 78.53975
추상화 (Abstraction)
복잡한 내부 구현을 숨기고 필요한 기능만 노출합니다.
class Database(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def query(self, sql):
pass
# 사용자는 connect()와 query()만 알면 됨
자주 하는 실수와 해결법
실수 1: __init__에서 self 누락
# ❌ 잘못된 방법
class Person:
def __init__(name, age): # self 누락!
self.name = name
self.age = age
# person = Person("철수", 25)
# TypeError: Person.__init__() takes 2 positional arguments but 3 were given
# ✅ 올바른 방법
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
실수 2: 클래스 변수를 인스턴스 변수로 착각
# ❌ 잘못된 방법
class MyClass:
items = [] # 클래스 변수 (모든 인스턴스 공유!)
def add(self, item):
self.items.append(item)
obj1 = MyClass()
obj2 = MyClass()
obj1.add(1)
obj2.add(2)
print(obj1.items) # [1, 2] (의도하지 않은 공유)
# ✅ 올바른 방법
class MyClass:
def __init__(self):
self.items = [] # 인스턴스 변수
def add(self, item):
self.items.append(item)
실수 3: super() 호출 누락
# ❌ 잘못된 방법
class Parent:
def __init__(self, name):
self.name = name
class Child(Parent):
def __init__(self, name, age):
# super().__init__(name) 누락!
self.age = age
# child = Child("철수", 25)
# print(child.name) # AttributeError
# ✅ 올바른 방법
class Child(Parent):
def __init__(self, name, age):
super().__init__(name)
self.age = age
self 누락 에러 메시지가 “인자 2개를 받는데 3개가 주어졌다”로 나오는 이유는, Person("철수", 25)를 호출하면 Python이 새로 만든 인스턴스를 첫 번째 인자로 자동으로 넣어 실제로는 인자 3개(인스턴스, “철수”, 25)로 __init__을 호출하기 때문입니다. 메서드 쪽의 self는 이 자동 전달을 받는 자리이므로, 이름은 관례일 뿐이지만 자리를 빼먹으면 개수가 어긋납니다. 비슷하게 일반 메서드에서 self를 빠뜨리면 obj.method() 호출 시 takes 0 positional arguments but 1 was given이 납니다.
실수 4: @property setter 없이 할당
# ❌ 잘못된 방법
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
# setter 없음!
circle = Circle(5)
# circle.radius = 10 # AttributeError: property 'radius' of 'Circle' object has no setter
# ✅ 올바른 방법
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = value
연습 문제
문제 1: 은행 계좌 클래스
입금, 출금, 잔액 조회 기능을 가진 BankAccount 클래스를 구현하세요.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance
self.transaction_history = []
def deposit(self, amount):
if amount <= 0:
return "입금액은 양수여야 합니다"
self.__balance += amount
self.transaction_history.append(f"입금: +{amount:,}원")
return f"입금 완료. 잔액: {self.__balance:,}원"
def withdraw(self, amount):
if amount <= 0:
return "출금액은 양수여야 합니다"
if amount > self.__balance:
return "잔액이 부족합니다"
self.__balance -= amount
self.transaction_history.append(f"출금: -{amount:,}원")
return f"출금 완료. 잔액: {self.__balance:,}원"
@property
def balance(self):
return self.__balance
def get_history(self):
return "\n".join(self.transaction_history)
# 테스트
account = BankAccount("홍길동", 10000)
print(account.deposit(5000)) # 입금 완료. 잔액: 15,000원
print(account.withdraw(3000)) # 출금 완료. 잔액: 12,000원
print(f"현재 잔액: {account.balance:,}원") # 현재 잔액: 12,000원
print("\n거래 내역:")
print(account.get_history())
문제 2: 직원 관리 시스템
Employee 부모 클래스와 Developer, Designer 자식 클래스를 구현하세요.
class Employee:
company = "ABC Corp"
def __init__(self, name, salary):
self.name = name
self.salary = salary
def get_annual_salary(self):
return self.salary * 12
def __str__(self):
return f"{self.name} - {self.salary:,}원"
class Developer(Employee):
def __init__(self, name, salary, language):
super().__init__(name, salary)
self.language = language
def code(self):
return f"{self.name}이(가) {self.language}로 코딩합니다"
def __str__(self):
return f"{super().__str__()} [개발자 - {self.language}]"
class Designer(Employee):
def __init__(self, name, salary, tool):
super().__init__(name, salary)
self.tool = tool
def design(self):
return f"{self.name}이(가) {self.tool}로 디자인합니다"
def __str__(self):
return f"{super().__str__()} [디자이너 - {self.tool}]"
# 테스트
dev = Developer("홍길동", 5000000, "Python")
designer = Designer("김철수", 4500000, "Figma")
print(dev) # 홍길동 - 5,000,000원 [개발자 - Python]
print(designer) # 김철수 - 4,500,000원 [디자이너 - Figma]
print(dev.code()) # 홍길동이(가) Python로 코딩합니다
print(designer.design()) # 김철수이(가) Figma로 디자인합니다
print(f"연봉: {dev.get_annual_salary():,}원") # 연봉: 60,000,000원
문제 3: 쇼핑몰 상품 클래스
특수 메서드를 활용한 Product 클래스를 구현하세요.
class Product:
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
def __str__(self):
return f"{self.name} - {self.price:,}원 (재고: {self.quantity}개)"
def __add__(self, other):
"""두 상품의 총 가격"""
return self.price * self.quantity + other.price * other.quantity
def __mul__(self, n):
"""수량 증가"""
return Product(self.name, self.price, self.quantity * n)
def __lt__(self, other):
"""가격 비교 (정렬용)"""
return self.price < other.price
def __len__(self):
"""재고 수량"""
return self.quantity
# 테스트
p1 = Product("노트북", 1200000, 2)
p2 = Product("마우스", 30000, 5)
print(p1) # 노트북 - 1,200,000원 (재고: 2개)
print(p2) # 마우스 - 30,000원 (재고: 5개)
total = p1 + p2
print(f"총 가격: {total:,}원") # 총 가격: 2,550,000원
p3 = p1 * 2 # 수량 2배
print(p3) # 노트북 - 1,200,000원 (재고: 4개)
products = [p1, p2]
products.sort() # __lt__로 정렬
print("가격순 정렬:", [p.name for p in products]) # ['마우스', '노트북']
print(f"재고: {len(p1)}개") # 재고: 2개
자주 묻는 질문 (FAQ)
Q. 클래스 본문에 items = []를 선언했더니 모든 객체가 같은 리스트를 공유하는 이유는 무엇인가요?
A. 클래스 본문에 선언한 변수는 인스턴스마다 따로 생기는 것이 아니라 클래스 자체에 하나만 존재하는 클래스 변수입니다. self.items.append()는 인스턴스에 같은 이름의 속성이 없으면 클래스 변수를 찾아 그 리스트를 수정하므로, 모든 인스턴스에 변경이 보입니다. 객체마다 독립적인 값이 필요하다면 __init__ 안에서 self.items = []로 인스턴스 변수를 만들어야 합니다.