객체 지향 설계란? 데이터를 보호하고 기능을 확장하는 방법: 캡슐화, 상속, 다형성
캡슐화·상속·다형성이란? 객체 지향 프로그래밍의 핵심 원리#
객체는 관련된 데이터와 동작을 한곳에 모아 표현합니다. 하지만 클래스 안에 변수와 함수를 넣었다는 이유만으로 변경하기 쉬운 프로그램이 되지는 않습니다. 객체의 값을 아무 곳에서나 바꿀 수 있거나, 공통 기능을 여러 클래스에 복사하거나, 객체 종류가 늘어날 때마다 조건문을 고쳐야 한다면 구조는 금방 복잡해집니다.
객체 지향 프로그래밍에서는 이런 문제를 다루기 위해 캡슐화, 상속, 다형성이라는 원리를 사용합니다.
| 원리 | 해결하려는 문제 | 핵심 질문 |
|---|---|---|
| 캡슐화 | 객체의 상태가 정해진 규칙을 벗어나 변경된다 | “이 값은 어떤 방법으로 바꿀 수 있는가?” |
| 상속 | 여러 종류의 객체에 공통된 코드가 반복된다 | “이 대상들은 공통된 종류인가?” |
| 다형성 | 객체 종류마다 호출하는 쪽의 분기 코드가 늘어난다 | “같은 요청에 각 객체가 자기 방식으로 응답할 수 있는가?” |
세 원리는 서로 연결되지만 같은 뜻은 아닙니다. 각각 무엇을 해결하는지 구분하면 클래스를 설계할 때 어느 방법이 필요한지 판단하기 쉬워집니다. 이 글에서는 Python으로 예시를 보여주되, 설명은 여러 객체 지향 언어에 적용되는 개념을 중심으로 합니다.
캡슐화: 객체의 상태를 정해진 방법으로 변경하기#
캡슐화는 객체와 관련된 데이터와 동작을 묶고, 외부에서 객체의 상태를 다루는 방법을 정하는 것입니다. 특히 중요한 값은 아무 곳에서나 변경하게 두기보다, 객체가 제공하는 메서드를 통해 규칙을 확인하도록 설계합니다.
왜 상태를 마음대로 바꾸면 문제가 될까?#
체력(hp)이 0에서 100 사이여야 하는 게임 캐릭터를 생각해 보겠습니다.
class Player:
def __init__(self, name):
self.name = name
self.hp = 100
player = Player("전사")
player.hp = -9999
print(player.hp) # -9999Python 문법에는 문제가 없습니다. 하지만 “체력은 0 아래로 내려가지 않는다”라는 게임 규칙은 깨졌습니다.
체력을 바꾸는 코드가 프로그램 곳곳에 흩어져 있다면, 잘못된 값이 어디서 만들어졌는지 찾기도 어려워집니다. 객체에 상태 변경을 맡기면 규칙을 확인하는 위치를 모을 수 있습니다.
메서드를 통해 변경 규칙을 적용하기#
class Player:
def __init__(self, name):
self.name = name
self._hp = 100
self._max_hp = 100
def take_damage(self, damage):
if damage <= 0:
raise ValueError("피해량은 0보다 커야 합니다.")
self._hp = max(0, self._hp - damage)
def heal(self, amount):
if amount <= 0:
raise ValueError("회복량은 0보다 커야 합니다.")
self._hp = min(self._max_hp, self._hp + amount)
def get_hp(self):
return self._hp사용하는 쪽에서는 체력을 변경할 때 메서드를 호출합니다.
player = Player("전사")
player.take_damage(30)
print(player.get_hp()) # 70
player.take_damage(80)
print(player.get_hp()) # 0
player.heal(500)
print(player.get_hp()) # 100이 클래스에는 세 가지 규칙이 들어 있습니다.
- 피해량과 회복량은 0보다 커야 합니다.
- 피해를 받아도 체력은 0 아래로 내려가지 않습니다.
- 회복해도 최대 체력인 100을 넘지 않습니다.
호출하는 코드는 체력 계산식을 매번 작성할 필요가 없습니다. take_damage()와 heal()이 체력을 바꾸는 공식적인 경로가 됩니다.
Python의 _hp는 접근을 완전히 차단할까?#
그렇지는 않습니다. Python에서 앞에 밑줄 하나를 붙인 _hp는 “클래스 내부 구현으로 취급해 달라”는 관례입니다.
player._hp = -9999 # Python에서 실행은 가능하다.따라서 _hp라는 이름만으로 외부 수정을 기술적으로 막을 수는 없습니다. Java나 C#의 private 접근 제어자와 같은 강제력을 기대하면 안 됩니다.
Python에서는 사용자가 내부 속성을 직접 수정하지 않도록 공개 메서드를 설계하고, 팀에서 그 사용 규칙을 지키는 방식이 일반적입니다. 외부에 읽기 전용 형태로 값을 보여주고 싶다면 프로퍼티도 사용할 수 있습니다.
class Player:
def __init__(self, name):
self.name = name
self._hp = 100
@property
def hp(self):
return self._hp
def take_damage(self, damage):
if damage <= 0:
raise ValueError("피해량은 0보다 커야 합니다.")
self._hp = max(0, self._hp - damage)player = Player("전사")
print(player.hp) # 100
player.take_damage(20)
print(player.hp) # 80
# player.hp = -9999
# AttributeError: 이 예제에서는 hp에 값을 할당할 setter가 없다.player.hp는 속성을 읽는 것처럼 보이지만 실제로는 hp() 프로퍼티가 값을 반환합니다. 예제에서는 외부에서 hp에 직접 값을 할당할 수 없도록 구성했습니다.
다만 프로퍼티가 있다고 해서 객체 전체가 완벽하게 보호되는 것은 아닙니다. 캡슐화의 핵심은 밑줄이나 문법 하나가 아니라 객체가 지켜야 할 규칙을 정하고, 그 규칙을 적용하는 동작을 제공하는 것입니다.
모든 값을 숨겨야 할까?#
그럴 필요는 없습니다. 이름을 읽는 일까지 복잡한 메서드로 감쌀 이유는 없을 수 있습니다. 반면 체력처럼 변경할 때 확인해야 하는 값은 별도의 메서드를 두는 편이 적절합니다.
캡슐화에서는 “속성을 몇 개 숨겼는가?”보다 다음 질문이 중요합니다.
이 객체의 상태를 변경하는 사람이 반드시 지켜야 할 규칙은 무엇이며, 그 규칙은 어디에서 검사하는가?
상속: 공통된 종류의 기능을 공유하기#
상속은 기존 클래스의 속성과 메서드를 바탕으로 새로운 클래스를 정의하는 방법입니다. 공통 기능을 한곳에 두고, 구체적인 종류마다 필요한 기능을 추가하거나 변경할 수 있습니다.
전사와 마법사의 공통점 찾기#
게임에 전사와 마법사가 있다고 가정해 보겠습니다. 둘 다 이름과 체력이 있고 이동할 수 있습니다. 하지만 공격 방식은 다릅니다.
공통 부분을 복사해서 두 클래스를 만들 수도 있습니다.
class Warrior:
def __init__(self, name):
self.name = name
self.hp = 200
def move(self):
print(f"{self.name}이(가) 이동합니다.")
class Wizard:
def __init__(self, name):
self.name = name
self.hp = 100
def move(self):
print(f"{self.name}이(가) 이동합니다.")move()가 두 곳에 반복됩니다. 캐릭터 이동에 공통 규칙이 추가되면 두 클래스 모두 수정해야 합니다. 종류가 더 늘어날수록 같은 문제가 커집니다.
Character라는 공통 클래스를 정의해 보겠습니다.
class Character:
def __init__(self, name, hp):
self.name = name
self.hp = hp
def move(self):
print(f"{self.name}이(가) 이동합니다.")
def show_info(self):
print(f"이름: {self.name}, 체력: {self.hp}")전사와 마법사는 Character를 상속받습니다.
class Warrior(Character):
def __init__(self, name):
super().__init__(name, 200)
def attack(self):
print(f"{self.name}이(가) 검으로 공격합니다.")
class Wizard(Character):
def __init__(self, name):
super().__init__(name, 100)
self.mp = 150
def attack(self):
print(f"{self.name}이(가) 마법으로 공격합니다.")warrior = Warrior("전사")
wizard = Wizard("마법사")
warrior.move()
wizard.move()
warrior.attack()
wizard.attack()
warrior.show_info()
wizard.show_info()Warrior와 Wizard는 각각 move()와 show_info()를 작성하지 않았지만, 부모 클래스인 Character에 정의된 메서드를 사용합니다. attack()은 각 클래스가 자신의 방식으로 구현합니다.
super()는 무엇을 할까?#
super().__init__(name, 200)이 코드는 부모 클래스의 초기화 메서드를 호출합니다. Warrior는 부모에게 이름과 체력의 초기 설정을 맡깁니다.
class Warrior(Character):
def __init__(self, name):
super().__init__(name, 200)위 코드로 Warrior("전사")를 만들면 Character.__init__()의 name과 hp에 각각 "전사"와 200이 전달됩니다.
마법사는 부모의 초기화 결과에 자신만의 속성도 추가합니다.
class Wizard(Character):
def __init__(self, name):
super().__init__(name, 100)
self.mp = 150공통 속성은 부모가 설정하고, 마법사에게만 필요한 마력(mp)은 자식 클래스에서 설정하는 구조입니다.
메서드 오버라이딩: 물려받은 동작 바꾸기#
자식 클래스가 부모 클래스의 메서드와 같은 이름의 메서드를 다시 정의하는 것을 오버라이딩이라고 합니다.
이번에는 Character가 기본 공격을 정의하고, 전사와 마법사가 이를 바꿔 보겠습니다.
class Character:
def __init__(self, name, hp):
self.name = name
self.hp = hp
def attack(self):
print(f"{self.name}이(가) 기본 공격을 합니다.")
class Warrior(Character):
def __init__(self, name):
super().__init__(name, 200)
def attack(self):
print(f"{self.name}이(가) 검으로 공격합니다.")
class Wizard(Character):
def __init__(self, name):
super().__init__(name, 100)
def attack(self):
print(f"{self.name}이(가) 마법으로 공격합니다.")character = Character("훈련용 캐릭터", 100)
warrior = Warrior("전사")
wizard = Wizard("마법사")
character.attack() # 훈련용 캐릭터이(가) 기본 공격을 합니다.
warrior.attack() # 전사이(가) 검으로 공격합니다.
wizard.attack() # 마법사이(가) 마법으로 공격합니다.원문에서는 부모 클래스에 attack()을 정의하지 않은 상태에서 자식의 attack()을 오버라이딩이라고 설명하는 부분이 있었습니다. 부모에게 같은 메서드가 없다면 자식 클래스의 새 메서드를 정의한 것입니다. 위 예제에서는 부모의 attack()을 실제로 재정의해 오버라이딩을 보여줍니다.
상속을 쓰기 전에 확인할 관계#
상속은 보통 “자식은 부모의 한 종류인가?”라는 질문에 자연스럽게 답할 수 있을 때 검토합니다.
- 전사는 캐릭터의 한 종류입니다.
- 마법사는 캐릭터의 한 종류입니다.
- 주문은 사용자의 한 종류가 아닙니다.
공통 코드가 조금 있다는 이유만으로 상속 관계를 만들면 부모 클래스에 맞지 않는 기능을 자식이 억지로 물려받을 수 있습니다. 예를 들어 “움직일 수 있다”는 공통점만으로 자동차와 게임 캐릭터를 같은 부모 클래스 아래에 두는 것은 어색합니다.
상속은 코드 복사량을 줄이는 데 도움이 되지만, 두 대상 사이의 관계를 표현하는 설계 선택이기도 합니다.
다형성: 같은 요청에 객체마다 다르게 응답하기#
다형성은 동일한 방식으로 메서드를 호출해도 실제 객체에 따라 다른 동작이 실행되는 성질입니다.
앞의 전사와 마법사는 모두 attack()을 호출할 수 있지만, 공격 방식은 다릅니다.
warrior = Warrior("전사")
wizard = Wizard("마법사")
warrior.attack() # 검으로 공격
wizard.attack() # 마법으로 공격이 차이가 가장 유용해지는 곳은 여러 객체를 사용하는 코드입니다.
객체 종류를 매번 확인하는 코드#
전사와 마법사를 차례로 공격시키려는 함수를 작성한다고 가정해 보겠습니다.
def perform_attack(character):
if isinstance(character, Warrior):
character.attack()
elif isinstance(character, Wizard):
character.attack()두 분기에서 하는 일은 결국 attack() 호출로 같습니다. 궁수가 추가되면 분기도 추가해야 할 것처럼 보입니다.
객체들이 attack()이라는 공통 동작을 제공한다면 호출하는 코드는 더 간단해집니다.
def perform_attack(character):
character.attack()party = [
Warrior("전사"),
Wizard("마법사"),
]
for character in party:
perform_attack(character)perform_attack()은 전사인지 마법사인지 검사하지 않습니다. 전달받은 객체의 attack()을 호출하고, 어떻게 공격할지는 그 객체가 결정합니다.
새 종류를 추가하면 어떻게 될까?#
궁수를 추가해 보겠습니다.
class Archer(Character):
def __init__(self, name):
super().__init__(name, 120)
def attack(self):
print(f"{self.name}이(가) 활로 공격합니다.")party = [
Warrior("전사"),
Wizard("마법사"),
Archer("궁수"),
]
for character in party:
character.attack()for문은 바뀌지 않았습니다. 궁수는 자신의 attack()을 실행합니다.
다형성의 가치는 여기에 있습니다. 객체 종류가 추가되어도 공통 동작을 호출하는 코드가 각 종류의 내부 구현을 알 필요가 줄어듭니다.
다형성을 사용하려면 반드시 상속해야 할까?#
그렇지는 않습니다. Python에서는 공통 부모 클래스가 없어도 객체가 필요한 동작을 제공하면 같은 방식으로 호출할 수 있습니다.
class EmailNotification:
def send(self, message):
print(f"이메일 발송: {message}")
class ConsoleNotification:
def send(self, message):
print(f"화면 출력: {message}")
def notify(notifier, message):
notifier.send(message)notify(EmailNotification(), "주문이 완료되었습니다.")
notify(ConsoleNotification(), "주문이 완료되었습니다.")두 클래스는 같은 부모를 상속하지 않았지만 send()를 제공하므로 notify()에서 같은 방식으로 사용할 수 있습니다. Python에서는 이런 방식도 흔히 활용합니다.
다만 이름만 같다고 언제나 안전하게 교체할 수 있는 것은 아닙니다. 호출하는 쪽이 기대하는 매개변수, 반환값, 동작의 의미까지 맞아야 합니다. 이 약속을 더 명확히 표현하는 방법은 별도의 인터페이스 문서에서 다룰 수 있습니다.
세 가지 원리는 어떻게 함께 동작할까?#
게임 캐릭터의 체력과 공격 기능을 합쳐 보겠습니다.
class Character:
def __init__(self, name, hp):
if hp <= 0:
raise ValueError("초기 체력은 0보다 커야 합니다.")
self.name = name
self._hp = hp
@property
def hp(self):
return self._hp
def take_damage(self, damage):
if damage <= 0:
raise ValueError("피해량은 0보다 커야 합니다.")
self._hp = max(0, self._hp - damage)
def attack(self):
raise NotImplementedError("자식 클래스에서 attack()을 구현해야 합니다.")
class Warrior(Character):
def __init__(self, name):
super().__init__(name, 200)
def attack(self):
return f"{self.name}이(가) 검으로 공격합니다."
class Wizard(Character):
def __init__(self, name):
super().__init__(name, 100)
self.mp = 150
def attack(self):
return f"{self.name}이(가) 마법으로 공격합니다."이 코드에는 세 원리가 각각 다른 역할로 들어 있습니다.
- 캡슐화: 체력을 변경할 때
take_damage()에서 피해량과 체력의 최솟값을 확인합니다. - 상속: 전사와 마법사는
Character의 이름, 체력 초기화, 피해 처리 기능을 함께 사용합니다. - 다형성: 둘 다
attack()을 호출할 수 있지만 실제 공격 동작은 서로 다릅니다.
party = [
Warrior("전사"),
Wizard("마법사"),
]
for character in party:
print(character.attack())
character.take_damage(30)
print(f"{character.name}의 남은 체력: {character.hp}")실행 결과:
전사이(가) 검으로 공격합니다.
전사의 남은 체력: 170
마법사이(가) 마법으로 공격합니다.
마법사의 남은 체력: 70같은 for문에서 같은 메서드들을 호출했지만, 객체별로 공격 결과와 체력 상태가 달라집니다.
여기서 Character.attack()은 자식 클래스가 직접 공격 방식을 정의하도록 요구하기 위해 NotImplementedError를 발생시킵니다. 더 명시적인 계약이 필요하다면 Python의 추상 기본 클래스나 Protocol을 사용할 수 있습니다. 그 내용은 인터페이스를 다루는 문서에서 이어서 살펴볼 수 있습니다.
실무에서 자주 하는 오해#
“캡슐화는 모든 속성에 getter와 setter를 만드는 것이다”#
모든 속성에 읽기·쓰기 메서드를 기계적으로 붙이면 외부에서 상태를 마음대로 바꾸는 문제를 다른 문법으로 옮긴 것에 그칠 수 있습니다.
def set_hp(self, value):
self._hp = value이 메서드는 self.hp = value와 마찬가지로 음수 체력을 허용합니다. 체력을 바꾸는 이유가 피해인지 회복인지에 따라 take_damage()와 heal()처럼 의도가 드러나는 동작을 제공하는 편이 규칙을 적용하기 쉽습니다.
“중복 코드가 있으면 반드시 상속한다”#
중복된 코드가 있어도 두 대상이 부모와 자식의 관계가 아닐 수 있습니다. 상속을 선택하면 자식이 부모의 기능과 변경에 영향을 받는 관계가 생깁니다.
먼저 공통된 종류와 책임이 있는지 확인하세요. 단지 일부 기능을 공유하고 싶다면 별도의 객체를 함께 사용하도록 설계하는 방법도 있습니다.
“다형성은 상속과 오버라이딩을 해야만 가능하다”#
상속과 오버라이딩은 다형성을 보여주기 좋은 방법이지만 유일한 방법은 아닙니다. 앞의 EmailNotification과 ConsoleNotification처럼 공통으로 사용할 수 있는 동작을 제공하는 방식도 가능합니다.
“객체 지향을 쓰면 조건문이 없어져야 한다”#
객체 지향 프로그램에도 조건문은 필요합니다. 중요한 것은 객체의 종류별 동작을 처리하려고 같은 타입 검사 분기를 여러 곳에 반복하는지입니다. 새로운 종류가 생길 때마다 프로그램 곳곳의 if문을 수정해야 한다면, 그 동작을 객체가 맡도록 바꿀 수 있는지 살펴보세요.
직접 확인해 보기#
아래 코드의 결과를 먼저 예상해 보세요.
class Animal:
def speak(self):
return "소리를 냅니다."
class Dog(Animal):
def speak(self):
return "멍멍"
class Cat(Animal):
def speak(self):
return "야옹"
animals = [Dog(), Cat()]
for animal in animals:
print(animal.speak())실행 결과:
멍멍
야옹Dog와 Cat은 Animal을 상속하고 speak()를 재정의했습니다. 반복문은 각 객체의 종류를 검사하지 않고 speak()를 호출하지만, 실행되는 메서드는 실제 객체에 따라 달라집니다.
이제 Cow 클래스를 추가해 "음메"를 반환하도록 작성해 보세요. for문을 수정하지 않고 결과에 새 동작을 포함할 수 있다면 다형성이 어떻게 작동하는지 확인한 것입니다.
정리#
캡슐화는 객체가 자신의 상태를 어떤 규칙에 따라 다룰지 정합니다. 상속은 자연스러운 부모·자식 관계에 있는 클래스가 공통 기능을 공유하도록 합니다. 다형성은 여러 객체에 같은 동작을 요청하고, 실제 수행 방식은 각 객체에 맡깁니다.
이 원리를 적용할 때는 문법보다 먼저 다음 세 가지를 물어보면 좋습니다.
- 이 객체가 반드시 지켜야 할 상태의 규칙은 무엇인가?
- 공통 기능을 가진 대상들이 정말 같은 종류의 관계인가?
- 객체가 늘어날 때 호출하는 코드의 종류별 분기도 계속 늘어나는가?
이 질문에 답할 수 있으면 캡슐화, 상속, 다형성을 외운 용어가 아니라 실제 설계 판단의 도구로 사용할 수 있습니다.