객체 지향 프로그래밍의 핵심 개념 중 하나인 상속은 코드 재사용성을 높여주고, 다형성은 코드를 유연하게 확장할 수 있는 기반을 제공합니다. 이 챕터에서는 파이썬에서 상속과 다형성을 어떻게 활용하여 더 효율적이고 유지보수하기 쉬운 코드를 작성할 수 있는지 구체적인 예제를 통해 살펴보겠습니다.
상속은 왜 필요할까요?
상속은 코드 중복을 줄이고 구조를 깔끔하게 만드는 객체 지향 프로그래밍의 강력한 도구입니다. 처음 파이썬을 배울 때 저는 비슷한 기능을 하는 여러 클래스를 만들면서 반복되는 코드를 보며 비효율적이라고 느꼈습니다. 하지만 상속을 이해한 후로는 마치 레고 블록을 조립하듯이 기존 코드를 재활용하며 훨씬 빠르게 개발할 수 있게 되었습니다. 예를 들어, ‘동물’이라는 큰 범주 아래 ‘개’, ‘고양이’와 같은 구체적인 동물들이 있다면, 각 동물은 ‘이름’, ‘나이’와 같은 공통 속성과 ‘울음소리 내기’, ‘움직이기’와 같은 공통 행동을 가집니다. 이때 상속을 사용하면 이 공통된 부분들을 한 번만 정의하고, 각 동물 클래스는 자신만의 특별한 특징만 추가할 수 있습니다.
상속의 기본 개념
상속은 한 클래스가 다른 클래스의 속성과 메서드를 물려받아 사용하는 메커니즘을 말합니다. 여기서 속성을 물려주는 클래스를 ‘부모 클래스(Parent Class)’ 또는 ‘기반 클래스(Base Class)’라고 부르고, 물려받는 클래스를 ‘자식 클래스(Child Class)’ 또는 ‘파생 클래스(Derived Class)’라고 합니다. 자식 클래스는 부모 클래스의 기능을 그대로 사용할 수 있을 뿐만 아니라, 자신만의 새로운 기능이나 속성을 추가하거나, 부모 클래스의 기능을 변경하여 사용할 수도 있습니다. 이것이 바로 코드 재사용성의 핵심입니다. 마치 부모에게서 특정 재능을 물려받아 자신만의 방식으로 발전시키는 것과 같습니다.
파이썬에서 클래스 상속하기
파이썬에서 클래스를 상속하는 방법은 매우 간단합니다. 클래스를 정의할 때 괄호 안에 부모 클래스의 이름을 넣어주면 됩니다. 클래스 정의와 인스턴스 생성을 다뤘던 내용을 기억하신다면 어렵지 않을 것입니다.
class Animal: # 부모 클래스
def __init__(self, name):
self.name = name
print(f"{self.name} (이)가 태어났습니다.")
def speak(self):
raise NotImplementedError("하위 클래스에서 speak 메서드를 구현해야 합니다.")
def move(self):
print(f"{self.name} 이(가 움직입니다.")
class Dog(Animal): # Animal 클래스를 상속받는 Dog 클래스
def speak(self):
return "멍멍!"
class Cat(Animal): # Animal 클래스를 상속받는 Cat 클래스
def speak(self):
return "야옹!"
# 인스턴스 생성
my_dog = Dog("바둑이") # 출력: 바둑이 (이)가 태어났습니다.
my_cat = Cat("나비") # 출력: 나비 (이)가 태어났습니다.
print(f"개 소리: {my_dog.speak()}") # 출력: 개 소리: 멍멍!
print(f"고양이 소리: {my_cat.speak()}") # 출력: 고양이 소리: 야옹!
my_dog.move() # 출력: 바둑이 이(가 움직입니다.
my_cat.move() # 출력: 나비 이(가 움직입니다.
위 코드에서 Dog와 Cat 클래스는 Animal 클래스를 상속받습니다. 이로 인해 Animal의 생성자 __init__과 move 메서드를 별도로 정의하지 않아도 사용할 수 있습니다. 특히 __init__ 메서드를 통해 name 속성이 초기화되는 것을 볼 수 있습니다. 객체 초기화에 대한 내용은 이전 챕터에서 다루었으니 참고하시면 좋습니다. 이처럼 공통된 초기화 로직은 부모 클래스에 두고 자식 클래스는 필요한 부분만 정의하는 것이 일반적입니다.
메서드 오버라이딩: 부모의 동작을 변경하기
메서드 오버라이딩은 자식 클래스에서 부모 클래스가 가지고 있는 메서드의 동작을 재정의하는 것입니다. 위 예제에서 Animal 클래스의 speak 메서드는 NotImplementedError를 발생시키도록 되어 있습니다. 이는 Animal 자체는 추상적인 개념이므로 구체적인 울음소리를 낼 수 없고, 자식 클래스에서 이 메서드를 반드시 구현해야 함을 의미합니다. Dog와 Cat 클래스는 각각 자신만의 speak 메서드를 정의하여 부모의 speak 메서드를 오버라이딩했습니다. 이렇게 하면 각 동물 객체가 자신에게 맞는 고유한 소리를 내게 할 수 있습니다.
class Vehicle:
def __init__(self, brand):
self.brand = brand
def drive(self):
return f"{self.brand}가 움직입니다."
class Car(Vehicle):
def __init__(self, brand, model):
super().__init__(brand) # 부모 클래스의 __init__ 호출
self.model = model
def drive(self):
# 부모의 drive 메서드를 호출하고 추가 동작 정의
return f"{self.brand} {self.model}이(가 도로를 시원하게 달립니다."
my_car = Car("현대", "쏘나타")
print(my_car.drive()) # 출력: 현대 쏘나타이(가 도로를 시원하게 달립니다.
Car 클래스에서는 super().__init__(brand)를 사용하여 부모 클래스인 Vehicle의 생성자를 호출했습니다. 이렇게 하면 부모 클래스의 초기화 로직을 그대로 사용하면서 자식 클래스만의 초기화 로직(self.model = model)을 추가할 수 있습니다. drive 메서드 역시 부모 클래스의 메서드를 오버라이딩하여 Car 객체에 특화된 동작을 정의했습니다. 저는 이런 식으로 GUI 애플리케이션의 위젯들을 만들 때, 기본 위젯 클래스를 상속받아 제가 필요한 대로 동작을 변경하는 경우가 많습니다. 이는 속성과 메서드를 유연하게 다루는 좋은 예시입니다.
다형성이란 무엇일까요?
다형성은 하나의 객체가 여러 형태를 가질 수 있는 객체 지향 프로그래밍의 중요한 특성입니다. 프로그래밍에서 ‘다형성(Polymorphism)’은 같은 이름의 메서드가 객체의 종류에 따라 다르게 동작하는 것을 의미합니다. 쉽게 말해, 우리는 ‘움직이다’라는 동일한 명령을 내리지만, 그 명령을 받는 대상(개, 고양이, 자동차 등)에 따라 실제로 움직이는 방식이 달라지는 것과 같습니다. 제가 데이터 분석 작업을 자동화할 때, 다양한 형태의 데이터를 처리하기 위해 일관된 인터페이스를 제공하는 클래스를 설계할 때 다형성을 자주 활용합니다.
다형성의 원리 이해하기
다형성은 주로 메서드 오버라이딩을 통해 구현되며, 상속 관계에 있는 클래스들 사이에서 빛을 발합니다. 파이썬은 본질적으로 다형성을 잘 지원하는 언어입니다. 다른 언어에서는 인터페이스나 추상 클래스를 명시적으로 선언해야 할 때가 많지만, 파이썬에서는 ‘덕 타이핑(Duck Typing)’이라는 개념 덕분에 객체가 특정 메서드를 가지고 있으면 해당 타입으로 간주합니다. 즉, “오리처럼 꽥꽥거리고 오리처럼 걷는다면, 그것은 오리다”라는 말처럼, 객체가 특정 행동을 할 수 있으면 그 객체는 그 행동을 할 수 있는 타입으로 취급될 수 있다는 의미입니다. 이 덕분에 우리는 더 유연하게 코드를 작성할 수 있습니다.
class Dog:
def speak(self):
return "멍멍!"
class Cat:
def speak(self):
return "야옹!"
class Duck:
def speak(self):
return "꽥꽥!"
def make_sound(animal):
print(animal.speak())
make_sound(Dog()) # 출력: 멍멍!
make_sound(Cat()) # 출력: 야옹!
make_sound(Duck()) # 출력: 꽥꽥!
이 코드에서 make_sound 함수는 Dog, Cat, Duck 어떤 객체가 와도 speak() 메서드를 호출합니다. 각 객체가 다른 결과(울음소리)를 반환하지만, make_sound 함수 자체는 어떤 객체가 오든 상관없이 동일한 코드를 실행합니다. 이것이 바로 다형성의 마법입니다. 코드를 더욱 깔끔하고 확장 가능하게 만들어줍니다. 클래스와 객체의 개념이 익숙하다면 이 예제를 이해하는 데 큰 도움이 될 것입니다.
다형성을 활용한 유연한 코드 작성
다형성을 활용하면 새로운 클래스가 추가되어도 기존 코드를 거의 수정하지 않고 확장할 수 있습니다. 예를 들어, 게임에서 다양한 종류의 적 캐릭터가 있다고 가정해 봅시다. 각 적 캐릭터는 ‘공격’이라는 공통된 행동을 하지만, 그 방식은 다릅니다. 다형성을 이용하면 모든 적 캐릭터를 하나의 리스트에 담아두고, 반복문을 통해 각 캐릭터의 attack() 메서드를 호출하기만 하면 됩니다. 새로운 적 캐릭터가 추가되어도 Enemy 클래스를 상속받아 attack() 메서드만 오버라이딩하면 되므로, 게임의 핵심 로직을 건드릴 필요가 없습니다. 이는 유지보수와 확장성 측면에서 큰 이점입니다.
상속과 다형성을 함께 활용하는 예제
상속과 다형성은 함께 사용될 때 가장 큰 시너지를 냅니다. 저는 업무 자동화 스크립트를 만들 때, 다양한 유형의 보고서를 처리해야 하는 경우가 많습니다. 예를 들어 PDF 보고서, Excel 보고서, CSV 보고서가 있을 수 있죠. 이때 각 보고서 유형별로 클래스를 만들고, 이들을 하나의 추상적인 ‘Report’ 클래스로부터 상속받게 하면 매우 유용합니다.
class Report:
def __init__(self, filename):
self.filename = filename
def generate(self):
raise NotImplementedError("하위 클래스에서 generate 메서드를 구현해야 합니다.")
def save(self):
print(f"{self.filename} 보고서를 저장합니다.")
class PdfReport(Report):
def generate(self):
print(f"PDF 형식으로 {self.filename} 보고서를 생성합니다.")
class ExcelReport(Report):
def generate(self):
print(f"Excel 형식으로 {self.filename} 보고서를 생성합니다.")
class CsvReport(Report):
def generate(self):
print(f"CSV 형식으로 {self.filename} 보고서를 생성합니다.")
def process_reports(reports):
for report in reports:
report.generate()
report.save()
# 다양한 보고서 객체 생성
reports_to_process = [
PdfReport("월간_실적.pdf"),
ExcelReport("연간_결산.xlsx"),
CsvReport("일일_데이터.csv")
]
# 다형성을 이용해 모든 보고서 처리
process_reports(reports_to_process)
이 예제에서 Report는 부모 클래스이고, PdfReport, ExcelReport, CsvReport는 자식 클래스입니다. generate 메서드는 각 자식 클래스에서 오버라이딩되어 자신에게 맞는 보고서 생성 방식을 구현합니다. process_reports 함수는 Report 타입의 객체 리스트를 받아서 각 객체의 generate()와 save() 메서드를 호출합니다. 여기서 중요한 것은 process_reports 함수가 어떤 종류의 Report 객체가 오든 동일한 방식으로 처리한다는 점입니다. 이처럼 상속과 다형성을 활용하면 코드를 훨씬 더 유연하고 확장 가능하게 만들 수 있습니다. 2024년 현재 많은 기업 시스템에서 이런 방식으로 모듈의 유연성을 확보하고 있습니다.
상속과 다형성을 사용할 때의 고려 사항
상속과 다형성은 강력한 도구이지만, 남용하면 코드를 복잡하게 만들 수 있습니다. 저도 처음에는 모든 것을 상속으로 해결하려다 오히려 코드의 관계가 너무 복잡해져서 디버깅에 어려움을 겪었던 경험이 있습니다. 특히 ‘깊은 상속 트리’는 피하는 것이 좋습니다. 클래스 계층 구조가 너무 깊어지면 특정 기능이 어디서 왔는지 추적하기 어려워지고, 작은 변경에도 예상치 못한 부작용이 발생할 수 있습니다. 그래서 보통 2~3단계 정도의 상속 깊이를 유지하는 것을 권장합니다. 또한, ‘상속 대신 컴포지션(Composition)을 사용하라’는 원칙도 있습니다. 이는 ‘A는 B이다(is-a)’ 관계일 때 상속을 사용하고, ‘A는 B를 포함한다(has-a)’ 관계일 때는 상속 대신 다른 객체를 속성으로 가지는 방식을 사용하라는 의미입니다. 이 두 가지 원칙을 잘 이해하고 적절히 활용하는 것이 중요합니다. 이 챕터를 통해 데이터 구조 활용 팁에 대한 이해와 함께 객체 지향의 깊은 개념을 파악하는 데 도움이 되기를 바랍니다.
참고 자료
- [1] Python 공식 튜토리얼 — 클래스 — https://docs.python.org/ko/3/tutorial/classes.html
