Python & AI Tutorials Logo
Python 프로그래밍

31. 고급 클래스 기능

30장에서는 인스턴스 속성과 메서드를 가진 기본 클래스를 만드는 방법을 배웠습니다. 이제 객체가 어떻게 동작하는지에 대해 세밀하게 제어할 수 있게 해주는 더 정교한 클래스 기능을 살펴보겠습니다. 이런 기능을 사용하면 덧셈, 비교, 인덱싱 같은 연산을 자연스러운 문법으로 처리하면서, 내장 Python 타입처럼 느껴지는 클래스를 만들 수 있습니다.

31.1) 클래스 변수 vs 인스턴스 변수

클래스에서 속성(attribute)을 만들 때, 이를 저장할 수 있는 근본적으로 서로 다른 두 위치가 있습니다. 클래스 자체에 저장하는 방법과 개별 인스턴스에 저장하는 방법입니다. 이 차이를 이해하는 것은 올바른 객체지향 코드를 작성하는 데 매우 중요합니다.

31.1.1) 인스턴스 변수 이해하기

인스턴스 변수(instance variable) 는 특정 객체에 속하는 속성입니다. 각 인스턴스는 이런 변수의 별도 복사본을 각각 가집니다. 30장 전반에서 인스턴스 변수를 사용해 왔는데, self를 사용해서 __init__에서 만드는 속성이 바로 그것입니다.

python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner      # 인스턴스 변수
        self.balance = balance  # 인스턴스 변수
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.balance)  # Output: 1000
print(account2.balance)  # Output: 500

BankAccount 인스턴스는 자신만의 ownerbalance를 가집니다. account1.balance를 변경해도 account2.balance에는 영향을 주지 않습니다. 둘은 완전히 독립적입니다.

31.1.2) 클래스 변수 이해하기

클래스 변수(class variable) 는 특정 인스턴스가 아니라 클래스 자체에 속하는 속성입니다. 모든 인스턴스가 같은 클래스 변수를 공유합니다. 클래스 변수는 어떤 메서드 바깥, 클래스 본문에 직접 정의합니다.

python
class BankAccount:
    interest_rate = 0.02  # 클래스 변수 - 모든 인스턴스가 공유
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def apply_interest(self):
        self.balance += self.balance * BankAccount.interest_rate
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.interest_rate)  # Output: 0.02
print(account2.interest_rate)  # Output: 0.02
print(BankAccount.interest_rate)  # Output: 0.02

interest_rate는 인스턴스(account1.interest_rate)를 통해서도, 클래스 자체(BankAccount.interest_rate)를 통해서도 접근할 수 있다는 점을 확인하세요. 둘 다 같은 변수를 가리킵니다.

클래스 변수가 강력한 이유는 여기에 있습니다. 클래스 변수를 바꾸면 모든 인스턴스가 그 변경을 보게 됩니다.

python
class BankAccount:
    interest_rate = 0.02
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.interest_rate)  # Output: 0.02
print(account2.interest_rate)  # Output: 0.02
 
# 클래스 변수 변경
BankAccount.interest_rate = 0.03
 
print(account1.interest_rate)  # Output: 0.03
print(account2.interest_rate)  # Output: 0.03

두 인스턴스는 모두 같은 클래스 변수를 바라보고 있기 때문에 즉시 새 이자율을 확인합니다.

31.1.3) 섀도잉 함정: 인스턴스 변수가 클래스 변수를 가릴 때

미묘하지만 중요한 동작이 하나 있습니다. 인스턴스를 통해 어떤 속성에 값을 대입하면, Python은 클래스 변수를 가리는(숨기는) 인스턴스 변수를 만들어 버립니다.

python
class BankAccount:
    interest_rate = 0.02
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
# 클래스 변수를 가리는 인스턴스 변수 생성
account1.interest_rate = 0.05
 
print(account1.interest_rate)  # Output: 0.05 (instance variable)
print(account2.interest_rate)  # Output: 0.02 (class variable)
print(BankAccount.interest_rate)  # Output: 0.02 (class variable)

이제 account1은 클래스 변수를 가리는 자신만의 interest_rate 인스턴스 변수를 갖게 됩니다. 클래스 변수는 여전히 존재하지만, account1.interest_rate는 대신 인스턴스 변수를 가리킵니다. 이는 보통 원하는 동작이 아닙니다. 클래스 변수를 변경해야 한다면 인스턴스를 통해 바꾸지 말고 클래스 이름을 통해 변경하세요.

31.1.4) 클래스 변수의 실용적인 사용 예

클래스 변수는 모든 인스턴스에서 공유되어야 하는 데이터에 유용합니다.

python
class Student:
    school_name = "Python High School"  # 모든 학생에게 동일
    total_students = 0  # 학생이 몇 명 존재하는지 추적
    
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
        Student.total_students += 1  # 학생 생성 시 증가
    
    def __str__(self):
        return f"{self.name} (Grade {self.grade}) at {Student.school_name}"
 
student1 = Student("Alice", 10)
student2 = Student("Bob", 11)
student3 = Student("Carol", 10)
 
print(student1)  # Output: Alice (Grade 10) at Python High School
print(f"Total students: {Student.total_students}")  # Output: Total students: 3

__init__에서 Student.total_students( self.total_students가 아님)를 사용하는 방식에 주목하세요. 이렇게 하면 인스턴스 변수를 만드는 것이 아니라 클래스 변수를 수정하고 있다는 점이 명확해집니다.

클래스 변수

클래스 본문에 정의됨

모든 인스턴스가 공유

ClassName.variable로 접근

인스턴스 변수

__init__에서 self로 정의됨

각 인스턴스마다 고유

instance.variable로 접근

31.2) @property로 속성 관리하기

때로는 누군가 속성에 접근하거나 수정할 때 어떤 일이 일어날지 제어하고 싶을 때가 있습니다. 예를 들어 값이 양수인지 검증하고 싶거나, 값을 저장하는 대신 필요할 때마다 즉석에서 계산하고 싶을 수 있습니다. Python의 @property 데코레이터(decorator)를 사용하면 단순한 속성 접근처럼 보이게 하면서도 메서드를 작성할 수 있습니다.

31.2.1) 문제: 직접 속성 접근으로는 검증할 수 없음

속성에 직접 접근하면 값을 검증하거나 변환할 수 없습니다:

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
 
temp = Temperature(25)
print(temp.celsius)  # Output: 25
 
# 물리적으로 불가능한 온도 설정을 막을 방법이 없습니다
temp.celsius = -500  # 절대영도(-273.15°C) 이하!
print(temp.celsius)  # Output: -500
 
# 또는 터무니없이 높은 값
temp.celsius = 1000000
print(temp.celsius)  # Output: 1000000

검증이 없으면 실수로 잘못된 데이터를 설정할 수 있고, 이는 나중에 프로그램에서 버그를 일으킵니다. get_celsius()set_celsius() 같은 메서드를 사용할 수도 있지만, 이는 관용적인 Python 방식이 아닙니다. Python 개발자들은 Java나 C++처럼 getter/setter 메서드를 통하지 않고 속성에 직접 접근하기를 기대합니다.

31.2.2) 계산되는 속성에 @property 사용하기

@property 데코레이터는 메서드를 속성처럼 접근되는 "게터(getter)"로 바꿉니다.

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    
    @property
    def fahrenheit(self):
        """섭씨를 화씨로 즉석에서 변환"""
        return self.celsius * 9/5 + 32
 
temp = Temperature(25)
print(temp.celsius)  # Output: 25
print(temp.fahrenheit)  # Output: 77.0 (저장된 값이 아니라 계산된 값)

temp.fahrenheit를 괄호 없이 호출한다는 점에 주목하세요. 속성에 접근하는 것처럼 보이지만 실제로는 메서드를 호출하는 것입니다. 화씨 값은 접근할 때마다 계산되므로 항상 섭씨 값과 동기화됩니다.

python
temp = Temperature(0)
print(temp.fahrenheit)  # Output: 32.0
 
temp.celsius = 100
print(temp.fahrenheit)  # Output: 212.0 (자동으로 업데이트됨)

31.2.3) @property_name.setter로 세터 추가하기

프로퍼티에 값을 설정할 수 있게 하려면 @property_name.setter 데코레이터를 사용해 세터(setter) 메서드를 추가합니다.

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    
    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """설정 시 화씨를 섭씨로 변환"""
        self.celsius = (value - 32) * 5/9
 
temp = Temperature(0)
print(temp.celsius)  # Output: 0
print(temp.fahrenheit)  # Output: 32.0
 
# 화씨로 온도 설정
temp.fahrenheit = 212
print(temp.celsius)  # Output: 100.0
print(temp.fahrenheit)  # Output: 212.0

세터 메서드는 새 값을 인자로 받아서 저장하기 전에 검증하거나 변환할 수 있습니다.

31.2.4) 검증을 위해 프로퍼티 사용하기

프로퍼티는 제약 조건을 강제하는 데 매우 유용합니다.

python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance  # 언더스코어는 "내부 용도"를 의미합니다
    
    @property
    def balance(self):
        """현재 잔액 가져오기"""
        return self._balance
    
    @balance.setter
    def balance(self, value):
        """잔액 설정(단, 음수가 아니어야 함)"""
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value
 
account = BankAccount("Alice", 1000)
print(account.balance)  # Output: 1000
 
account.balance = 1500  # 정상 동작
print(account.balance)  # Output: 1500
 
# 여기서는 에러가 발생합니다
account.balance = -100
# Output: ValueError: Balance cannot be negative

명명 규칙(naming convention)에 주목하세요: 실제 값은 _balance(앞에 언더스코어가 있음)에 저장하고 balance 프로퍼티를 통해 노출합니다. 언더스코어는 "이것은 내부 구현 세부사항입니다"를 나타내는 Python 관례이지만, 기술적으로는 여전히 접근 가능합니다. 이 패턴을 사용하면 실제 저장소를 별도로 유지하면서 프로퍼티를 통해 접근을 제어할 수 있습니다.

31.2.5) 읽기 전용 프로퍼티

세터 없이 프로퍼티를 정의하면 읽기 전용(read-only)이 됩니다.

python
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    @property
    def area(self):
        """계산되는 읽기 전용 프로퍼티"""
        return self.width * self.height
 
rect = Rectangle(5, 3)
print(rect.area)  # Output: 15
 
rect.width = 10
print(rect.area)  # Output: 30 (자동으로 업데이트됨)
 
# area를 설정하려고 하면 에러가 발생합니다
rect.area = 50
# Output: AttributeError: property 'area' of 'Rectangle' object has no setter

이는 저장하지 않고 계산되어야 하는 파생 값(derived value)에 유용합니다.

@property 데코레이터

메서드를 getter로 변환

속성처럼 접근

값을 즉석에서 계산 가능

@property_name.setter

프로퍼티에 setter 추가

저장 전 검증 가능

값 변환 가능

31.3) @classmethod를 사용하는 클래스 메서드

때로는 인스턴스가 아니라 클래스 자체와 동작하는 메서드가 필요할 수 있습니다. 클래스 메서드(class method) 는 첫 번째 인자로 인스턴스(self) 대신 클래스(관례적으로 cls)를 받습니다.

31.3.1) 클래스 메서드 정의하기

클래스 메서드는 @classmethod 데코레이터로 만듭니다.

python
class Student:
    school_name = "Python High School"
    
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    
    @classmethod
    def get_school_name(cls):
        """클래스 메서드 - 인스턴스가 아니라 클래스를 받음"""
        return cls.school_name
 
# 클래스 자체에서 호출
print(Student.get_school_name())  # Output: Python High School
 
# 인스턴스에서 호출할 수도 있습니다(하지만 cls는 여전히 클래스)
student = Student("Alice", 10)
print(student.get_school_name())  # Output: Python High School

cls 파라미터는 일반 메서드에서 self가 인스턴스를 자동으로 받는 것처럼, 자동으로 클래스를 받습니다.

31.3.2) 클래스 메서드로 대안 생성자 만들기

클래스 메서드의 가장 흔한 용도 중 하나는 대안 생성자(alternative constructor)입니다. 인스턴스를 만드는 또 다른 방법을 제공하는 것입니다.

python
class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    
    @classmethod
    def from_string(cls, date_string):
        """'2024-12-27' 같은 문자열로부터 Date 생성"""
        year, month, day = date_string.split('-')
        return cls(int(year), int(month), int(day))
    
    @classmethod
    def today(cls):
        """오늘 날짜의 Date 생성(단순화된 예시)"""
        # 실제 코드에서는 datetime 모듈을 사용합니다
        return cls(2024, 12, 27)
    
    def __str__(self):
        return f"{self.year}-{self.month:02d}-{self.day:02d}"
 
# 일반 생성자
date1 = Date(2024, 12, 27)
print(date1)  # Output: 2024-12-27
 
# 문자열로부터 생성하는 대안 생성자
date2 = Date.from_string("2024-12-27")
print(date2)  # Output: 2024-12-27
 
# 오늘 날짜를 위한 대안 생성자
date3 = Date.today()
print(date3)  # Output: 2024-12-27

from_stringtoday가 모두 cls(...)를 반환한다는 점에 주목하세요. 이는 클래스의 새 인스턴스를 생성합니다. Date를 하드코딩하지 않고 cls를 사용하면 서브클래스에서도 코드가 올바르게 동작합니다(상속은 32장에서 배웁니다).

31.3.3) 팩토리 패턴을 위한 클래스 메서드

클래스 메서드는 서로 다른 설정으로 인스턴스를 생성하는 데 유용합니다.

python
class DatabaseConnection:
    def __init__(self, host, port, database, username):
        self.host = host
        self.port = port
        self.database = database
        self.username = username
    
    @classmethod
    def for_development(cls):
        """개발 환경용으로 설정된 연결 생성"""
        return cls("localhost", 5432, "dev_db", "dev_user")
    
    @classmethod
    def for_production(cls):
        """프로덕션 환경용으로 설정된 연결 생성"""
        return cls("prod.example.com", 5432, "prod_db", "prod_user")
    
    def __str__(self):
        return f"Connection to {self.database} at {self.host}:{self.port}"
 
# 미리 설정된 연결을 쉽게 생성
dev_conn = DatabaseConnection.for_development()
prod_conn = DatabaseConnection.for_production()
 
print(dev_conn)  # Output: Connection to dev_db at localhost:5432
print(prod_conn)  # Output: Connection to prod_db at prod.example.com:5432

31.3.4) 인스턴스 개수 세기를 위한 클래스 메서드

클래스 메서드는 클래스 변수와 함께 작동하여 모든 인스턴스에 대한 정보를 추적할 수 있습니다.

python
class Product:
    total_products = 0
    
    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.total_products += 1
    
    @classmethod
    def get_total_products(cls):
        """생성된 제품의 총 개수 반환"""
        return cls.total_products
    
    @classmethod
    def reset_count(cls):
        """제품 카운터 초기화"""
        cls.total_products = 0
 
product1 = Product("Laptop", 999)
product2 = Product("Mouse", 25)
product3 = Product("Keyboard", 75)
 
print(Product.get_total_products())  # Output: 3
 
Product.reset_count()
print(Product.get_total_products())  # Output: 0

31.4) @staticmethod를 사용하는 정적 메서드

정적 메서드(static method) 는 첫 번째 인자로 인스턴스(self)나 클래스(cls)를 받지 않는 메서드입니다. 단지 클래스 안에 정의되어 있을 뿐, 사실상 그 클래스와 논리적으로 관련된 일반 함수입니다.

31.4.1) 정적 메서드 정의하기

정적 메서드는 @staticmethod 데코레이터로 만듭니다.

python
class MathUtils:
    @staticmethod
    def is_even(number):
        """숫자가 짝수인지 확인"""
        return number % 2 == 0
    
    @staticmethod
    def is_prime(number):
        """숫자가 소수인지 확인(단순화)"""
        if number < 2:
            return False
        for i in range(2, int(number ** 0.5) + 1):
            if number % i == 0:
                return False
        return True
 
# 클래스에서 정적 메서드를 호출
print(MathUtils.is_even(4))  # Output: True
print(MathUtils.is_even(7))  # Output: False
print(MathUtils.is_prime(17))  # Output: True
print(MathUtils.is_prime(18))  # Output: False
 
# 인스턴스에서도 호출할 수 있지만(동일한 함수입니다)
utils = MathUtils()
print(utils.is_even(10))  # Output: True

정적 메서드는 인스턴스나 클래스 데이터에 접근할 필요가 없으며, 자체적으로 완결된 유틸리티 함수입니다.

31.4.2) 정적 메서드 vs 클래스 메서드 vs 인스턴스 메서드: 언제 무엇을 쓸까

선택 기준은 다음과 같습니다.

python
class Temperature:
    # 클래스 변수
    absolute_zero_celsius = -273.15
    
    def __init__(self, celsius):
        self.celsius = celsius
    
    # 인스턴스 메서드 - 인스턴스 데이터(self)에 접근 필요
    def to_fahrenheit(self):
        return self.celsius * 9/5 + 32
    
    # 클래스 메서드 - 클래스 데이터(cls)에 접근 필요
    @classmethod
    def get_absolute_zero(cls):
        return cls.absolute_zero_celsius
    
    # 정적 메서드 - 인스턴스나 클래스 데이터가 필요 없음
    @staticmethod
    def celsius_to_kelvin(celsius):
        return celsius + 273.15
    
    @staticmethod
    def fahrenheit_to_celsius(fahrenheit):
        return (fahrenheit - 32) * 5/9
 
temp = Temperature(25)
 
# 인스턴스 메서드 - 인스턴스 데이터 사용
print(temp.to_fahrenheit())  # Output: 77.0
 
# 클래스 메서드 - 클래스 데이터 사용
print(Temperature.get_absolute_zero())  # Output: -273.15
 
# 정적 메서드 - 단순 유틸리티 함수
print(Temperature.celsius_to_kelvin(25))  # Output: 298.15
print(Temperature.fahrenheit_to_celsius(77))  # Output: 25.0

가이드라인:

  • 인스턴스 메서드는 인스턴스 속성에 접근해야 할 때 사용합니다 (self)
  • 클래스 메서드는 클래스 속성에 접근해야 하거나 대체 생성자가 필요할 때 사용합니다 (cls)
  • 정적 메서드는 인스턴스나 클래스 데이터에 접근할 필요는 없지만, 함수가 클래스와 논리적으로 연관되어 있을 때 사용합니다

참고: 정적 메서드는 독립적인 함수로도 만들 수 있지만, 클래스 안에 두면 관련된 기능을 함께 묶고 전역 네임스페이스를 어지럽히지 않을 수 있습니다.

메서드 타입첫 번째 매개변수사용 시기
인스턴스 메서드self인스턴스 데이터에 접근해야 할 때
클래스 메서드cls클래스 데이터에 접근하거나 대체 생성자가 필요할 때
정적 메서드(없음)클래스와 관련된 유틸리티 함수

31.4.3) 실용 예시: 검증 유틸리티

정적 메서드는 검증과 유틸리티 함수에 아주 적합합니다.

python
class User:
    def __init__(self, username, password):
        if not User.is_valid_username(username):
            raise ValueError("Invalid username")
        if not User.is_valid_password(password):
            raise ValueError("Invalid password")
        
        self.username = username
        self._password = password
    
    @staticmethod
    def is_valid_username(username):
        """username이 요구 사항을 만족하는지 확인"""
        return len(username) >= 3 and username.isalnum()
    
    @staticmethod
    def is_valid_password(password):
        """password가 보안 요구 사항을 만족하는지 확인"""
        return len(password) >= 8 and any(c.isdigit() for c in password)
 
# 이 검증 메서드들은 독립적으로 사용할 수 있습니다
print(User.is_valid_username("alice123"))  # Output: True
print(User.is_valid_username("ab"))  # Output: False
print(User.is_valid_password("pass1234"))  # Output: True
 
# 그리고 클래스의 어떤 메서드에서도 사용할 수 있습니다
try:
    user = User("ab", "short")
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: Invalid username

31.5) 특수 메서드(매직 메서드) 이해하기

Python의 특수 메서드(special method)(또는 매직 메서드(magic method), 혹은 이름에 밑줄이 두 개씩 들어가서 던더 메서드(dunder method) 라고도 부릅니다)는 내장 연산으로 객체가 어떻게 동작할지 커스터마이징할 수 있게 해줍니다. 30장에서는 이미 __init__, __str__, __repr__를 사용했습니다. 이제 더 많은 메서드를 살펴보겠습니다.

31.5.1) 특수 메서드가 하는 일

특수 메서드는 특정 문법이나 내장 함수를 사용할 때 Python이 자동으로 호출합니다.

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __str__(self):
        return f"Point({self.x}, {self.y})"
 
point = Point(3, 4)
 
# print()를 호출하면 Python이 __str__()을 호출합니다
print(point)  # Output: Point(3, 4)
# 이는 다음과 동일합니다: print(point.__str__())

특수 메서드를 사용하면 내장 타입처럼 동작하는 클래스를 만들 수 있습니다. 예를 들어 객체가 다음을 지원하게 만들 수 있습니다.

  • 산술 연산(+, -, *, /)
  • 비교( <, >, ==)
  • len(), in, 인덱싱과 함께 동작
  • 컨테이너나 시퀀스처럼 동작

31.5.2) 자주 쓰는 특수 메서드 범주

특수 메서드의 주요 범주는 다음과 같습니다.

문자열 표현(String Representation):

  • __str__() - print()str()
  • __repr__() - REPL과 repr()

비교(Comparison):

  • __eq__() - ==
  • __ne__() - !=
  • __lt__() - <
  • __le__() - <=
  • __gt__() - >
  • __ge__() - >=

산술(Arithmetic):

  • __add__() - +
  • __sub__() - -
  • __mul__() - *
  • __truediv__() - /

컨테이너/시퀀스(Container/Sequence):

  • __len__() - len()
  • __contains__() - in
  • __getitem__() - 인덱싱 obj[key]
  • __setitem__() - 대입 obj[key] = value

다음 절들에서 이를 자세히 살펴보겠습니다.

31.6) 예시 1: 컬렉션 인터페이스(len, contains)

아이템 컬렉션을 관리하는 클래스를 만들고, Python의 내장 len() 함수와 in 연산자와 함께 동작하게 해보겠습니다.

31.6.1) len()을 위한 len 구현하기

__len__() 특수 메서드는 객체에 len()을 사용할 때 호출됩니다.

python
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def __len__(self):
        """카트에 담긴 아이템 개수 반환"""
        return len(self.items)
 
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
cart.add_item("Orange")
 
# len()이 __len__()을 호출합니다
print(len(cart))  # Output: 3

__len__()이 없다면 len(cart) 호출은 TypeError를 발생시킵니다. 이를 구현하면 ShoppingCart가 내장 컬렉션처럼 동작합니다.

31.6.2) in 연산자를 위한 contains 구현하기

__contains__() 특수 메서드는 in 연산자를 사용할 때 호출됩니다.

python
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def __len__(self):
        return len(self.items)
    
    def __contains__(self, item):
        """아이템이 카트에 있는지 확인"""
        return item in self.items
 
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
 
# in 연산자가 __contains__()를 호출합니다
print("Apple" in cart)  # Output: True
print("Orange" in cart)  # Output: False

이제 카트는 멤버십 테스트를 위한 자연스러운 Python 문법을 지원합니다.

31.6.3) 더 완성도 있는 컬렉션 클래스 만들기

학생 성적을 추적하는 좀 더 현실적인 컬렉션 클래스를 만들어 보겠습니다.

python
class GradeBook:
    def __init__(self):
        self.grades = {}  # student_name: list of grades
    
    def add_grade(self, student, grade):
        """학생의 성적을 추가"""
        if student not in self.grades:
            self.grades[student] = []
        self.grades[student].append(grade)
    
    def __len__(self):
        """학생 수 반환"""
        return len(self.grades)
    
    def __contains__(self, student):
        """학생에게 성적이 하나라도 있는지 확인"""
        return student in self.grades
    
    def get_average(self, student):
        """학생의 평균 성적 가져오기"""
        if student not in self:
            return None
        grades = self.grades[student]
        return sum(grades) / len(grades)
    
    def __str__(self):
        return f"GradeBook with {len(self)} students"
 
gradebook = GradeBook()
gradebook.add_grade("Alice", 85)
gradebook.add_grade("Alice", 90)
gradebook.add_grade("Bob", 78)
gradebook.add_grade("Bob", 82)
gradebook.add_grade("Bob", 88)
 
print(gradebook)  # Output: GradeBook with 2 students
print(len(gradebook))  # Output: 2
 
print("Alice" in gradebook)  # Output: True
print("Carol" in gradebook)  # Output: False
 
print(f"Alice's average: {gradebook.get_average('Alice')}")  # Output: Alice's average: 87.5
print(f"Bob's average: {gradebook.get_average('Bob')}")  # Output: Bob's average: 82.66666666666667

get_average()if student not in self를 사용한다는 점에 주목하세요. 이는 우리가 구현한 __contains__() 메서드를 호출하므로 코드가 자연스럽게 읽힙니다.

31.7) 예시 2: 연산자 오버로딩(add, eq, lt)

연산자 오버로딩(operator overloading) 이란 +, ==, < 같은 연산자가 커스텀 클래스에서 무엇을 의미하는지 정의하는 것입니다. 이를 통해 객체가 Python 문법과 자연스럽게 어울리게 됩니다.

31.7.1) 덧셈을 위한 add 구현하기

__add__() 특수 메서드는 + 연산자를 사용할 때 호출됩니다.

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """두 벡터 더하기"""
        return Vector(self.x + other.x, self.y + other.y)
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(3, 4)
 
# + 연산자가 __add__()를 호출합니다
v3 = v1 + v2
print(v3)  # Output: Vector(4, 6)

Python은 v1 + v2를 보면 v1.__add__(v2)를 호출합니다. 왼쪽 피연산자의 __add__() 메서드는 오른쪽 피연산자를 인자로 받습니다.

31.7.2) 동등 비교를 위한 eq 구현하기

__eq__() 특수 메서드는 == 연산자를 사용할 때 호출됩니다.

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __eq__(self, other):
        """두 벡터가 같은지 확인"""
        return self.x == other.x and self.y == other.y
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(1, 2)
v3 = Vector(3, 4)
 
# == 연산자가 __eq__()를 호출합니다
print(v1 == v2)  # Output: True
print(v1 == v3)  # Output: False

__eq__()이 없으면 Python은 값이 아니라 객체 동일성(메모리에서 같은 객체인지)을 비교합니다. __eq__()을 구현하면 우리 클래스에서 "같음"의 의미를 정의할 수 있습니다.

31.7.3) 비교 연산자 구현하기

Money 클래스에 비교 연산자를 구현해 봅시다.

python
class Money:
    def __init__(self, amount):
        self.amount = amount
    
    def __eq__(self, other):
        """금액이 같은지 확인"""
        return self.amount == other.amount
    
    def __lt__(self, other):
        """이 금액이 other보다 작은지 확인"""
        return self.amount < other.amount
    
    def __le__(self, other):
        """이 금액이 other보다 작거나 같은지 확인"""
        return self.amount <= other.amount
    
    def __gt__(self, other):
        """이 금액이 other보다 큰지 확인"""
        return self.amount > other.amount
    
    def __ge__(self, other):
        """이 금액이 other보다 크거나 같은지 확인"""
        return self.amount >= other.amount
    
    def __str__(self):
        return f"${self.amount:.2f}"
 
price1 = Money(10.50)
price2 = Money(15.75)
price3 = Money(10.50)
 
print(price1 == price3)  # Output: True
print(price1 < price2)  # Output: True
print(price1 <= price3)  # Output: True
print(price2 > price1)  # Output: True
print(price2 >= price1)  # Output: True

31.7.4) 연산자에서 타입 불일치 처리하기

연산자를 구현할 때는 다른 피연산자가 기대한 타입이 아닐 때의 경우도 처리해야 합니다.

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """두 벡터를 더하거나 스칼라를 두 성분에 더하기"""
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        elif isinstance(other, (int, float)):
            return Vector(self.x + other, self.y + other)
        else:
            return NotImplemented  # Python이 other.__radd__(self)를 시도하게 함
    
    def __eq__(self, other):
        if not isinstance(other, Vector):
            return False
        return self.x == other.x and self.y == other.y
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(3, 4)
 
print(v1 + v2)  # Output: Vector(4, 6) (vector addition)
print(v1 + 5)  # Output: Vector(6, 7) (scalar addition)
 
print(v1 == v2)  # Output: False
print(v1 == "not a vector")  # Output: False (no error)

NotImplemented(특별한 내장 상수)를 반환하면 Python에게 다른 피연산자에 대한 반사 연산(reflected operation)을 시도하라고 알립니다. 이는 서로 다른 타입과 함께 연산자가 올바르게 동작하도록 만드는 데 중요합니다.

연산자 오버로딩

산술 연산자

비교 연산자

+를 위한 add

-를 위한 sub

*를 위한 mul

/를 위한 truediv

==를 위한 eq

<를 위한 lt

<=를 위한 le

>를 위한 __gt__
>=를 위한 __ge__

31.8) 예시 3: 시퀀스 접근(getitem, setitem)

__getitem__()__setitem__() 특수 메서드를 사용하면 커스텀 클래스에 인덱싱 문법(obj[key])을 사용할 수 있습니다. 이렇게 하면 객체가 리스트, 딕셔너리, 다른 시퀀스처럼 동작합니다.

31.8.1) 인덱싱을 위한 getitem 구현하기

__getitem__() 메서드는 대괄호로 아이템에 접근할 때 호출됩니다.

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        """인덱스로 노래 가져오기"""
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
# 인덱싱이 __getitem__()를 호출합니다
print(playlist[0])  # Output: Song A
print(playlist[1])  # Output: Song B
print(playlist[-1])  # Output: Song C (음수 인덱싱도 동작!)

self.songs[index]로 위임(delegate)하기 때문에, 리스트의 모든 인덱싱 기능이 자동으로 동작합니다. 양수 인덱스, 음수 인덱스, 그리고 잘못된 인덱스에 대한 IndexError 발생까지 모두 포함됩니다.

31.8.2) __getitem__으로 슬라이싱 지원하기

같은 __getitem__() 메서드는 슬라이싱도 처리합니다.

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        """인덱스 또는 슬라이스로 노래 가져오기"""
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
playlist.add_song("Song D")
 
# 슬라이싱도 __getitem__()를 호출합니다
print(playlist[1:3])  # Output: ['Song B', 'Song C']
print(playlist[:2])  # Output: ['Song A', 'Song B']
print(playlist[::2])  # Output: ['Song A', 'Song C']

슬라이싱을 사용하면 Python은 slice 객체를 __getitem__()에 전달합니다. self.songs[index]로 위임하면 모든 슬라이스 문법을 자동으로 지원하게 됩니다.

31.8.3) 대입을 위한 setitem 구현하기

__setitem__() 메서드는 특정 인덱스에 값을 대입할 때 호출됩니다.

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        return self.songs[index]
    
    def __setitem__(self, index, value):
        """특정 인덱스의 노래 교체"""
        self.songs[index] = value
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
print(playlist[1])  # Output: Song B
 
# 대입이 __setitem__()를 호출합니다
playlist[1] = "New Song B"
print(playlist[1])  # Output: New Song B

31.8.4) __getitem__으로 객체를 이터러블하게 만들기

흥미로운 부작용 하나가 있습니다. 0부터 시작하는 정수 인덱스를 처리하는 __getitem__()을 구현하면, 객체는 자동으로 iterable이 됩니다.

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
# for 반복문이 자동으로 동작합니다!
for song in playlist:
    print(song)
# Output:
# Song A
# Song B
# Song C

Python은 __getitem__(0)을 호출한 뒤, __getitem__(1)을 호출하는 식으로 IndexError를 받을 때까지 계속 호출하며 반복(iterate)하려고 합니다. 이는 오래된 반복 프로토콜이며, 최신 이터레이터 프로토콜은 35장에서 배우겠습니다.

31.8.5) 문자열 키로 딕셔너리처럼 접근하기

__getitem__()__setitem__()은 정수뿐 아니라 어떤 타입의 키에서도 동작합니다.

python
class ScoreBoard:
    def __init__(self):
        self.scores = {}
    
    def __getitem__(self, player_name):
        """플레이어의 점수를 가져옵니다"""
        return self.scores.get(player_name, 0)
    
    def __setitem__(self, player_name, score):
        """플레이어의 점수를 설정합니다"""
        self.scores[player_name] = score
    
    def __contains__(self, player_name):
        return player_name in self.scores
    
    def __len__(self):
        return len(self.scores)
 
scoreboard = ScoreBoard()
 
# 문자열 키를 사용하여 점수 설정
scoreboard["Alice"] = 100
scoreboard["Bob"] = 85
 
# 점수 업데이트
scoreboard["Alice"] = 120
 
# 점수 조회
print(scoreboard["Alice"])  # Output: 120
print(scoreboard["Bob"])    # Output: 85
print(scoreboard["Carol"])  # Output: 0
 
print("Alice" in scoreboard)  # Output: True
print(len(scoreboard))  # Output: 2

시퀀스 접근

getitem

setitem

obj[key]에서 호출

인덱싱 처리

슬라이싱 처리

객체를 iterable로 만듦

obj[key] = value에서 호출

대입 가능

값 검증 가능


이 장에서는 Python 문법과 매끄럽게 통합되는 정교한 클래스를 만드는 방법을 보여주었습니다. 클래스 변수, 프로퍼티, 클래스 메서드, 정적 메서드, 특수 메서드를 구현하면 커스텀 클래스가 내장 타입처럼 동작하게 만들 수 있습니다. 32장에서는 상속(inheritance)과 다형성(polymorphism)을 살펴보는데, 이를 통해 동작을 공유하고 확장하는 관련 클래스들의 계층을 구축할 수 있습니다.


© 2025. Primesoft Co., Ltd.
support@primesoft.ai