Python 클래스 | 객체지향 프로그래밍(OOP) 완벽 정리

Python 클래스 | 객체지향 프로그래밍(OOP) 완벽 정리

이 글의 핵심

Python 클래스에 대한 실전 가이드입니다. 객체지향 프로그래밍(OOP) 완벽 정리 등을 예제와 함께 상세히 설명합니다.

들어가며

객체지향 프로그래밍(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의 장점:

  • 캡슐화: 데이터와 기능을 하나로 묶음
  • 재사용성: 상속으로 코드 재사용
  • 유지보수: 변경 영향 범위 최소화
  • 확장성: 새 기능 추가 용이

1. 클래스 기본

클래스와 객체

클래스(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)  # 부산고등학교

2. 생성자와 소멸자

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
# 파일 닫힘

3. 클래스 변수 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]

클래스 변수 활용 예제

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

4. 상속 (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'>)

상속 실전 예제

# 게임 캐릭터 시스템
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

5. 캡슐화 (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 (가능하지만 절대 하지 말 것!)

@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: can't set attribute (읽기 전용)

circle.diameter = 20  # diameter setter
print(circle.radius)  # 10.0

# 유효성 검사
# circle.radius = -5  # ValueError: 반지름은 양수여야 합니다

캡슐화 실전 예제

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: 절대영도 이하는 불가능합니다

6. 특수 메서드 (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__)

컨테이너 특수 메서드

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

7. 클래스 메서드와 정적 메서드

메서드 종류 비교

메서드첫 인자접근 가능사용 시점
인스턴스 메서드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

8. 추상 클래스 (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

9. 다형성 (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원 결제

10. 실전 프로젝트: 도서 관리 시스템

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}")

11. OOP 4대 원칙

1. 캡슐화 (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

# 직접 접근 불가, 메서드로만 접근

2. 상속 (Inheritance)

기존 클래스를 확장하여 재사용합니다.

class Vehicle:
    def move(self):
        pass

class Car(Vehicle):
    def move(self):
        return "도로를 달립니다"

3. 다형성 (Polymorphism)

같은 인터페이스로 다른 동작을 수행합니다.

def process(shape):
    print(shape.area())  # 각 도형마다 다른 계산

process(Rectangle(5, 10))  # 50
process(Circle(5))         # 78.54

4. 추상화 (Abstraction)

복잡한 내부 구현을 숨기고 필요한 기능만 노출합니다.

class Database(ABC):
    @abstractmethod
    def connect(self):
        pass
    
    @abstractmethod
    def query(self, sql):
        pass

# 사용자는 connect()와 query()만 알면 됨

12. 자주 하는 실수와 해결법

실수 1: __init__에서 self 누락

# ❌ 잘못된 방법
class Person:
    def __init__(name, age):  # self 누락!
        self.name = name
        self.age = age

# person = Person("철수", 25)  # TypeError

# ✅ 올바른 방법
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

실수 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: can't set attribute

# ✅ 올바른 방법
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

13. 연습 문제

문제 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개

정리

핵심 요약

  1. 클래스: 데이터 + 기능을 묶는 설계도
  2. 객체: 클래스로부터 생성된 실체
  3. __init__: 생성자, 객체 초기화
  4. self: 인스턴스 자신을 가리키는 참조
  5. 상속: 코드 재사용, super()로 부모 호출
  6. 캡슐화: __private, @property로 데이터 보호
  7. 다형성: 같은 인터페이스, 다른 동작
  8. 추상 클래스: ABC, @abstractmethod로 인터페이스 정의
  9. 특수 메서드: __str__, __add__ 등으로 연산자 오버로딩
  10. 클래스/정적 메서드: @classmethod, @staticmethod

OOP 설계 원칙

  • 단일 책임 원칙: 클래스는 하나의 책임만
  • 개방-폐쇄 원칙: 확장에는 열려있고, 수정에는 닫혀있어야
  • 리스코프 치환 원칙: 자식 클래스는 부모 클래스를 대체 가능해야
  • 인터페이스 분리 원칙: 클라이언트는 사용하지 않는 메서드에 의존하지 않아야
  • 의존성 역전 원칙: 구체화가 아닌 추상화에 의존해야

다음 단계

  • Python 모듈과 패키지
  • Python 예외 처리
  • Python 데코레이터

관련 글

  • Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
  • Python 기본 문법 | 변수, 연산자, 조건문, 반복문 완벽 가이드