파이썬에서 클래스와 객체를 다루는 방식은 C++이나 Java와 같은 전통적인 객체 지향 언어와는 다른 철학을 따릅니다. 이 글에서는 데이터 캡슐화, 생명주기 관리, 상속 구조, 그리고 클래스 속성과 인스턴스 속성의 차이점을 중심으로 파이썬 OOP의 실무적 적용 방법을 살펴봅니다.
데이터 캡슐화와 접근 제어
객체의 내부 상태를 보호하기 위해 직접적인 속성 수정을 제한하고, 제어된 메서드를 통해 간접적으로 데이터를 조작하는 것이 일반적인 패턴입니다. 파이썬에는 명시적인 public 또는 private 키워드가 존재하지 않으며, 대신 네이밍 컨벤션을 통해 접근 의도를 표현합니다.
- 관례적 비공개: 속성명 앞에 언더스코어(_)를 붙여 외부에서의 직접 사용을 자제하도록 유도합니다.
- 네임 맨글링(Name Mangling): 속성명 앞에 이중 언더스코어(__)를 사용하면 인터프리터가 클래스 이름을 포함하여 속성을 변환합니다. 이를 통해 서브클래스와의 이름 충돌을 방지하고 더 엄격한 비공식적 보호를 제공합니다.
아래 예제는 검증 로직을 포함한 setter/getter 메서드를 사용하여 안전한 데이터 변경을 구현한 것입니다.
class UserProfile:
def __init__(self, username):
# 이중 언더스코어를 사용하여 내부 저장소로 처리
self.__username = username
@property
def username(self):
return self.__username
@username.setter
def username(self, new_name):
if len(new_name) < 5:
raise ValueError("사용자 이름은 최소 5자 이상이어야 합니다.")
self.__username = new_name
# 사용 예시
user = UserProfile("admin")
print(user.username) # 'admin' 출력
try:
user.username = "abc" # 에러 발생
except ValueError as e:
print(e)
user.username = "super_admin"
print(user.username) # 'super_admin' 출력
객체 생명주기와 참조 카운팅
파이썬은 가비지 컬렉션을 위해 참조 카운팅(refcounting) 방식을 주로 사용합니다. 객체가 생성될 때 __init__이 호출되며, 참조 카운트가 0이 되어 메모리 해제가 필요할 때 __del__(디스트럭터)가 호출됩니다.
주의할 점은 변수 삭제(del)가 즉시 객체를 소멸시키는 것은 아니라는 것입니다. 만약 해당 객체를 참조하는 다른 변수가 남아있다면 참조 카운트만 감소하며, 마지막 참조까지 제거되었을 때 비로소 __del__이 실행됩니다.
import time
class ResourceHandler:
def __init__(self, resource_id):
print(f"[생성] 리소스 {resource_id} 초기화 완료")
self.resource_id = resource_id
def __del__(self):
print(f"[소멸] 리소스 {self.resource_id} 메모리 해제 중...")
def demonstrate_reference_count():
res_a = ResourceHandler("A-101")
# 여러 변수가 같은 객체를 참조함 (참조 카운트 증가)
res_b = res_a
res_c = res_a
print("--- 변수 삭제 시작 ---")
del res_a # 참조 카운트 3 -> 2
del res_b # 참조 카운트 2 -> 1
del res_c # 참조 카운트 1 -> 0, 여기서 __del__ 호출됨
print("--- 변수 삭제 종료 ---")
demonstrate_reference_count()
time.sleep(1)
상속과 MRO(Method Resolution Order)
파이썬은 단일 상속뿐만 아니라 다중 상속도 지원합니다. 하위 클래스는 상위 클래스의 속성과 메서드를 자동으로 물려받으며, 이는 코드 재사용성을 높이는 핵심 요소입니다.
단일 상속의 기본 구조
하위 클래스가 부모 클래스의 __init__을 정의하지 않으면 부모의 생성자가 자동 호출됩니다. 하지만 초기화 로직이 다르다면 오버라이딩(Override) 후 super()를 통해 부모 생성자를 명시적으로 호출해야 합니다.
class Animal:
def __init__(self, species, legs=4):
self.species = species
self.legs = legs
def make_sound(self):
return "Generic animal sound"
class Dog(Animal):
def __init__(self, breed):
# super()를 사용해 부모 클래스의 초기화 로직 실행
super().__init__(species="Canine", legs=4)
self.breed = breed
def fetch(self):
return f"{self.breed} is fetching the ball!"
my_dog = Dog("Golden Retriever")
print(my_dog.make_sound()) # 부모 클래스 메서드 호출
print(my_dog.fetch()) # 자식 클래스 고유 메서드 호출
다중 상속과 메서드 탐색 순서
여러 부모 클래스를 상속받을 경우, 동명의 메서드가 존재하면 파이썬은 MRO 규칙에 따라 가장 먼저 발견된 메서드를 실행합니다. __mro__ 속성을 확인하면 실제 검색 순서를 파악할 수 있습니다.
class BaseFeature:
def activate(self):
print("Base feature activated")
class FeatureA(BaseFeature):
def activate(self):
print("Feature A activated")
class FeatureB(BaseFeature):
def activate(self):
print("Feature B activated")
class ComplexSystem(FeatureA, FeatureB):
pass
system = ComplexSystem()
system.activate()
# 출력: Feature A activated (MRO: ComplexSystem -> FeatureA -> FeatureB -> BaseFeature)
print(ComplexSystem.__mro__)
정적 메서드와 클래스 메서드의 활용
인스턴스 상태에 의존하지 않는 기능을 분리하기 위해 정적 메서드(@staticmethod)와 클래스 메서드(@classmethod)를 사용합니다.
- 클래스 메서드 (@classmethod): 첫 번째 인자로 클래스 자체(
cls)를 받습니다. 팩토리 패턴이나 클래스 속성 조작에 유용합니다. - 정적 메서드 (@staticmethod): 인스턴스나 클래스에 대한 암시적 매개변수가 없습니다. 단순히 논리적으로 클래스 내에 묶여있는 유틸리티 함수 역할 수행에 적합합니다.
class MathUtils:
PI = 3.14159
@classmethod
def get_pi(cls):
"""클래스 속성에 접근하여 값을 반환"""
return cls.PI
@staticmethod
def calculate_area(radius):
"""인스턴스/클래스 정보 없이 순수 계산 수행"""
return MathUtils.PI * (radius ** 2)
print(MathUtils.get_pi()) # 클래스 객체로 직접 호출 가능
print(MathUtils.calculate_area(2)) # 정적 메서드 호출
클래스 속성과 인스턴스 속성의 미묘한 차이
두 속성 유형의 가장 큰 혼란은 '수정' 동작에서 발생합니다. 클래스 속성은 모든 인스턴스가 공유하는 반면, 인스턴스 속성은 개별 객체마다 독립적입니다.
특히 주의해야 할 점은 인스턴스를 통해 클래스 속성명을 키로 사용하여 값을 할당하면(obj.attr = value), 기존 클래스 속성을 덮어쓰는 것이 아니라 새로운 인스턴스 속성이 생성되어 이후부터는 그 인스턴스에만 영향이 미친다는 것입니다. 이를 '섀도잉(Shadowing)'이라고 부릅니다.
class Company:
headquarters = "Seoul" # 클래스 속성 (모든 직원 공유)
def __init__(self, name):
self.name = name # 인스턴스 속성 (개인별 소유)
emp1 = Company("Alice")
emp2 = Company("Bob")
print(emp1.headquarters) # Seoul (클래스 속성 읽기)
# 아래 줄은 emp1 객체에 'headquarters'라는 새로운 인스턴스 속성을 생성함
emp1.headquarters = "Busan"
print(emp1.headquarters) # Busan (인스턴스 속성 우선)
print(emp2.headquarters) # Seoul (원본 클래스 속성 유지)
print(Company.headquarters) # Seoul (클래스 자체는 변하지 않음)
다형성과 덕 타이핑(Duck Typing)
파이썬은 타입 선언이 없으므로, 인터페이스 기반의 다형성보다는 '덕 타이핑' 원칙을 따릅니다. 즉, 어떤 객체가 특정 메서드를 가지고 있다면, 그것이 어떤 클래스에 속하든 상관없이 해당 메서드를 호출할 수 있다는 개념입니다.
이는 Java의 추상 클래스나 인터페이스 구현보다 유연하지만, 런타임 오류 위험이 있으므로 문서화나 타입 힌트(Type Hints)를 활용해 명확성을 확보하는 것이 좋습니다.
class Cat:
def speak(self):
return "Meow"
class Dog:
def speak(self):
return "Woof"
class Robot:
def speak(self):
return "Beep Boop"
def perform_interaction(entity):
# entity가 'speak' 메서드를 가지고 있는지 검사하지 않고 그냥 호출함
print(entity.speak())
animals = [Cat(), Dog(), Robot()]
for animal in animals:
perform_interaction(animal)