Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

15 상속

Updated: 15 jun 2026

클래스와 객체를 보다 실용적으로 활용하는 두 가지 방법 상속구성을 소개하면서 객체 지향 프로그래밍(OOP)의 기본 디자인 원칙을 살펴본다.

이번 장에서는 먼저 Human 클래스를 정의하고, 사람의 기본 속성을 물려받는 Hero 클래스를 만드는 방식으로 상속의 필요성과 장단점을 살펴본다.

15.1인스턴스 생성 활용법의 단점

객체 지향 프로그래밍에서는 필요한 자료형을 클래스로 정의하고, 그 클래스로부터 여러 개의 인스턴스를 생성하여 사용할 수 있다.

먼저 사람을 나타내는 가장 단순한 Human 클래스를 정의하자. 여기서는 설명을 단순하게 하기 위해 사람의 속성을 이름, 나이, 성별로 제한한다.

예제: 사람 클래스

class Human:
    def __init__(self, name, age, gender):
        self.name = name
        self.age = age
        self.gender = gender

    def introduction(self):
        print(f"이름: {self.name}")
        print(f"나이: {self.age}")
        print(f"성별: {self.gender}")        

아래 코드는 두 명의 사람 객체를 생성한다.

kim = Human("김민수", 20, "남성")
lee = Human("이영희", 21, "여성")

introduction() 메서드는 사람 객체의 자기소개를 출력한다.

kim.introduction()
이름: 김민수
나이: 20
성별: 남성

이제 일반 사람이 아니라 영웅 객체를 만들고 싶다고 하자. 영웅은 사람과 마찬가지로 이름, 나이, 성별을 갖지만, 추가로 파워, 체력, 공격력, 장비 같은 정보와 공격 기능도 필요하다.

나쁜 방법: 비슷한 코드를 다시 작성하기

가장 단순한 방법은 Human 클래스와 비슷한 코드를 다시 작성하여 Hero 클래스를 새로 만드는 것이다. 하지만 이렇게 하면 이름, 나이, 성별을 저장하고 자기소개를 하는 코드가 중복된다.

class Hero:
    def __init__(self, name, age, gender, power, health, damage, inventory):
        self.name = name
        self.age = age
        self.gender = gender
        self.power = power
        self.health = health
        self.damage = damage
        self.inventory = inventory

    def introduction(self):
        print(f"이름: {self.name}")
        print(f"나이: {self.age}")
        print(f"성별: {self.gender}")        
        
    def attack(self, other):
        print(f"{self.name}: {other.name} 공격!")
        attack_power = self.damage * 0.1
        other.health -= attack_power

영웅 객체를 생성하고 사용할 수는 있다.

ironman = Hero("아이언맨", 45, "남성", 100, 100, 200, {"suit": 500, "weapon": "레이저"})
hulk = Hero("헐크", 42, "남성", 400, 400, 300, {"suit": 0, "weapon": "주먹"})

ironman.introduction()
ironman.attack(hulk)
print("헐크 체력:", hulk.health)
이름: 아이언맨
나이: 45
성별: 남성
아이언맨: 헐크 공격!
헐크 체력: 380.0

하지만 이 방식은 좋은 설계가 아니다. HumanHero가 공유하는 코드가 중복되며, 사람의 기본 정보 처리 방식이 바뀌면 두 클래스를 모두 수정해야 한다.

이런 문제를 해결하기 위해 상속inheritance을 사용할 수 있다.

15.1.1예제

예제 1

Human 클래스와 상속을 사용하지 않은 Hero 클래스에서 중복되는 코드를 찾아 설명하라.

답:

두 클래스의 __init__() 메서드는 name, age, gender 속성을 같은 방식으로 저장한다. 또한 introduction() 메서드도 완전히 같은 기능을 수행한다. 이처럼 여러 클래스에 같은 코드를 반복하면 기능을 변경할 때 모든 클래스를 함께 수정해야 하므로 유지보수가 어려워진다.

예제 2

아래 코드처럼 Human.introduction()의 출력 형식을 바꾸고 싶다고 하자.

def introduction(self):
    print(f"{self.name}({self.age}세, {self.gender})")

상속을 사용하지 않은 현재의 Hero 클래스에는 어떤 문제가 생기는가?

답:

Human 클래스만 수정하면 Hero 객체의 자기소개 형식은 바뀌지 않는다. 두 클래스가 같은 기능을 각각 구현했기 때문에 Hero.introduction()도 별도로 수정해야 한다. 클래스가 많아질수록 빠뜨리는 클래스가 생기기 쉽고, 같은 기능의 동작이 서로 달라질 위험도 커진다.

kim = Human("김민수", 20, "남성")
ironman = Hero(
    "아이언맨", 45, "남성",
    100, 100, 200,
    {"suit": 500, "weapon": "레이저"},
)

kim.introduction()
ironman.introduction()
이름: 김민수
나이: 20
성별: 남성
이름: 아이언맨
나이: 45
성별: 남성

15.1.2연습문제

문제 1

다음 두 클래스에서 중복되는 인스턴스 변수와 메서드를 각각 찾아라.

class Student:
    def __init__(self, name, age, school):
        self.name = name
        self.age = age
        self.school = school

    def introduce(self):
        print(self.name, self.age)


class Teacher:
    def __init__(self, name, age, subject):
        self.name = name
        self.age = age
        self.subject = subject

    def introduce(self):
        print(self.name, self.age)

문제 2

문제 1의 두 클래스를 그대로 유지한 채 introduce() 메서드가 이름 앞에 "이름:"을 출력하도록 수정하려 한다. 몇 곳을 수정해야 하는지 답하고, 이런 구조가 유지보수에 불리한 이유를 설명하라.

문제 3

자동차를 나타내는 Car 클래스와 버스를 나타내는 Bus 클래스를 상속 없이 각각 정의하라. 두 클래스는 모두 model, speed 속성과 move() 메서드를 가져야 하며, Bus 클래스에는 capacity 속성을 추가하라. 완성한 코드에서 어떤 부분이 중복되는지 주석으로 표시하라.

15.2상속

클래스를 선언할 때 다른 클래스의 메서드와 클래스 속성을 상속하여 활용할 수 있다. 상속받는 클래스를 자식 클래스 또는 하위 클래스, 상속의 대상이 되는 클래스를 부모 클래스 또는 상위 클래스라고 부른다.

15.2.1부모 클래스와 자식 클래스

상속을 이용하여 새로운 클래스를 정의하는 방식은 다음과 같다.

class 자식클래스(부모클래스):
    클래스 본문

상속은 기존 클래스의 기능을 재사용하고 새로운 기능을 추가하여 클래스 사이의 관계를 체계적으로 표현하는 OOP의 핵심 기술 중 하나이다.

상속의 주요 장점은 다음과 같다.

  • 부모 클래스의 기존 기능을 재사용할 수 있다.

  • 여러 자식 클래스의 공통 기능을 부모 클래스에서 관리할 수 있다.

  • 부모 클래스와 자식 클래스의 관계를 명확하게 표현할 수 있다.

다만 자식 클래스는 부모 클래스의 설계에 영향을 받으므로 “영웅은 사람이다”처럼 자식 클래스의 객체를 부모 클래스의 객체로 보아도 자연스러운지 신중하게 판단해야 한다.

Hero는 특별한 능력을 가진 사람이므로 Human의 한 종류로 볼 수 있다. 따라서 Human을 상속하는 자식 클래스로 Hero를 선언할 수 있다. HeroHuman의 메서드를 물려받고, 부모 클래스의 __init__()을 호출하여 사람의 기본 속성을 초기화한 뒤 영웅에게 필요한 속성과 메서드를 추가한다.

class Hero(Human):
    def __init__(self, name, age, gender, power, health, damage, inventory):
        super().__init__(name, age, gender)
        self.power = power
        self.health = health
        self.damage = damage
        self.inventory = inventory

    def attack(self, other):
        print(f"{self.name}: {other.name} 공격!")
        attack_power = self.damage * 0.1
        other.health -= attack_power

Hero 클래스는 Human 클래스를 상속한다. 따라서 Hero의 인스턴스는 영웅 객체이면서 동시에 사람 객체이기도 하다.

ironman = Hero("아이언맨", 45, "남성", 100, 100, 200, {"suit": 500, "weapon": "레이저"})

isinstance(ironman, Hero), isinstance(ironman, Human)
(True, True)

HeroHuman의 메서드를 물려받으므로 introduction() 메서드를 바로 사용할 수 있다.

ironman.introduction()
이름: 아이언맨
나이: 45
성별: 남성

하지만 모든 사람이 영웅인 것은 아니다. 부모 클래스의 인스턴스는 자식 클래스에 새로 추가된 기능을 알지 못한다.

park = Human("박지민", 19, "여성")

try:
    park.attack(ironman)
except AttributeError:
    print("일반 사람은 attack() 메서드를 갖고 있지 않습니다.")
일반 사람은 attack() 메서드를 갖고 있지 않습니다.

parkHuman 클래스의 객체이기는 하지만 Hero 클래스의 객체는 아니다.

isinstance(park, Human), isinstance(park, Hero)
(True, False)

15.2.2메서드 상속과 재정의

__init__() 메서드 재정의와 super()

Hero 클래스는 상속받은 __init__() 메서드를 재정의한다. 재정의된 메서드에서는 super()를 사용하여 부모 클래스인 Human__init__() 메서드를 호출한다.

super().__init__(name, age, gender)

이를 통해 Human이 담당하는 name, age, gender 속성을 Hero 인스턴스에 초기화할 수 있다. 이때 현재 인스턴스가 자동으로 전달되므로 self를 직접 전달하지 않는다.

그런 다음 Hero__init__() 메서드는 영웅에게만 필요한 power, health, damage, inventory 속성을 추가로 초기화한다.

메서드 오버라이딩

부모 클래스에서 상속받은 메서드를 자식 클래스에서 같은 이름으로 다시 정의하는 것을 메서드 오버라이딩method overriding이라고 한다.

아래 Hero 클래스는 부모 클래스인 Human 클래스로부터 상속받은 introduction() 메서드도 재정의하여 일반적인 자기소개와 함께 파워와 무기 정보도 제공한다.

class Hero(Human):
    def __init__(self, name, age, gender, power, health, damage, inventory):
        super().__init__(name, age, gender)
        self.power = power
        self.health = health
        self.damage = damage
        self.inventory = inventory

    def introduction(self):
        super().introduction()
        print(f"파워: {self.power}")
        print(f"무기: {self.inventory['weapon']}")

    def attack(self, other):
        print(f"{self.name}: {other.name} 공격!")
        attack_power = self.damage * 0.1
        other.health -= attack_power
ironman = Hero("아이언맨", 45, "남성", 100, 100, 200, {"suit": 500, "weapon": "레이저"})
hulk = Hero("헐크", 42, "남성", 400, 400, 300, {"suit": 0, "weapon": "주먹"})

ironman.introduction()
ironman.attack(hulk)
print("헐크 체력:", hulk.health)
이름: 아이언맨
나이: 45
성별: 남성
파워: 100
무기: 레이저
아이언맨: 헐크 공격!
헐크 체력: 380.0

15.2.3예제

예제 1

아래 코드의 실행 결과를 예측하고, Hero 객체가 Human의 인스턴스로도 판정되는 이유를 설명하라.

print(isinstance(ironman, Hero))
print(isinstance(ironman, Human))
print(isinstance(park, Hero))

답:

차례대로 True, True, False가 출력된다. HeroHuman을 상속하므로 Hero의 인스턴스는 자식 클래스인 Hero의 인스턴스이면서 부모 클래스인 Human의 인스턴스이기도 하다. 반대로 Human의 인스턴스가 자동으로 Hero의 인스턴스가 되지는 않는다.

print(isinstance(ironman, Hero))
print(isinstance(ironman, Human))
print(isinstance(park, Hero))
True
True
False

예제 2

Hero.__init__()에서 다음 코드를 생략하면 어떤 문제가 생기는가?

super().__init__(name, age, gender)

답:

부모 클래스의 생성자가 실행되지 않으므로 name, age, gender 속성이 만들어지지 않는다. 그 결과 이 속성을 사용하는 introduction()이나 attack() 메서드를 호출하면 AttributeError가 발생할 수 있다. super()를 사용하면 부모 클래스가 담당하는 초기화 작업을 중복 없이 재사용할 수 있다.

class IncompleteHero(Human):
    def __init__(self, name, age, gender, power):
        self.power = power


hero = IncompleteHero("테스트 영웅", 20, "미정", 50)

try:
    hero.introduction()
except AttributeError as error:
    print(type(error).__name__, error)
AttributeError 'IncompleteHero' object has no attribute 'name'

예제 3

부모 클래스의 메서드를 재사용하면서 자식 클래스에 맞는 내용을 추가하려면 어떻게 해야 하는가? 다음 Hero.introduction() 메서드의 동작을 설명하라.

def introduction(self):
    super().introduction()
    print(f"파워: {self.power}")

답:

먼저 super().introduction()이 부모 클래스의 자기소개 기능을 실행하여 이름, 나이, 성별을 출력한다. 그다음 자식 클래스에만 있는 power 속성을 추가로 출력한다. 이 방식은 부모 메서드의 기존 동작을 재사용하면서 자식 클래스의 기능을 확장하는 메서드 재정의이다.

15.2.4연습문제

문제 1

Human을 상속하는 Student 클래스를 정의하라. Student 클래스는 school 속성을 추가로 가지며, 생성자에서 super()를 사용하여 이름, 나이, 성별을 초기화해야 한다.

문제 2

문제 1의 Student 클래스에 introduction() 메서드를 재정의하라. 먼저 부모 클래스의 introduction()을 호출한 다음 학교 이름을 출력해야 한다.

문제 3

다음 객체를 생성한 후 isinstance()를 이용하여 studentStudentHuman의 인스턴스인지 각각 확인하라.

student = Student("김하나", 18, "여성", "파이썬고")

문제 4

Hero를 상속하는 마법사 클래스 MagicHero 클래스를 정의하라. mana 속성을 추가하고, 대상의 체력을 mana만큼 감소시키는 magic_attack(target) 메서드를 구현하라. 부모 클래스의 속성 초기화에는 super()를 사용한다.

15.3프로그램 확장성

상속은 공통 기능을 재활용하는 데 유용하지만, 모든 확장을 상속으로만 해결하려 하면 유연성이 떨어질 수 있다. 특히 객체의 기능을 상황에 따라 교체하거나 확장하기가 어려워질 수 있다.

예를 들어 아래처럼 영웅 캐릭터마다 서로 다른 비행 방식을 고려해야 한다고 가정하자.

  • 아이언맨: 로켓 장치 사용

  • 팔콘: 날개 사용

  • 토르: 특별한 장치 없이 스스로 비행

가장 단순한 해결책은 비행 방식에 따라 RocketFlyingHero, WingFlyingHero, SelfFlyingHero 같은 여러 자식 클래스를 만드는 것이다.

class RocketFlyingHero(Hero):
    def fly(self):
        print(f"{self.name}: 로켓으로 날아갑니다!")


class WingFlyingHero(Hero):
    def fly(self):
        print(f"{self.name}: 날개로 날아갑니다!")


class SelfFlyingHero(Hero):
    def fly(self):
        print(f"{self.name}: 스스로 날아갑니다!")
ironman = RocketFlyingHero(
    "아이언맨", 45, "남성", 100, 100, 200, {"suit": 500, "weapon": "레이저"}
)
falcon = WingFlyingHero(
    "팔콘", 40, "남성", 80, 100, 150, {"suit": 200, "weapon": "기관단총"}
)
thor = SelfFlyingHero(
    "토르", 1500, "남성", 500, 500, 300, {"suit": 200, "weapon": "망치"}
)

ironman.fly()
falcon.fly()
thor.fly()
아이언맨: 로켓으로 날아갑니다!
팔콘: 날개로 날아갑니다!
토르: 스스로 날아갑니다!

반면에 비행 능력이 없는 헐크는 Hero 클래스의 인스턴스로 만들어야 한다.

hulk = Hero("헐크", 42, "남성", 400, 400, 300, {"suit": 0, "weapon": "주먹"})

try:
    hulk.fly()
except AttributeError:
    print("헐크는 날 수 없습니다.")
헐크는 날 수 없습니다.

영웅 캐릭터와 비행 방식의 종류가 적고, 처음 지정한 비행 능력을 계속 사용한다면 이 방식도 문제가 없다. 하지만 비행 방식이 다양해지거나 실행 중에 바뀌어야 한다면 다음과 같은 단점이 드러난다.

  • 첫째, 비행 방식이 추가될 때마다 새로운 자식 클래스를 만들어야 한다. 비행 방식이 수십, 수백 가지로 늘어나면 클래스 수도 함께 증가한다.

  • 둘째, 비행 방식이 객체의 클래스에 의해 결정되므로 실행 중에 바꾸기 어렵다. 예를 들어 원래 날지 못하던 헐크가 멋진 수트를 입고 날게 하려면, 다른 자식 클래스의 객체로 다시 생성하거나 Hero 클래스에 별도의 조건과 동작을 추가해야 한다.

이 문제를 해결하려면 비행 기능을 영웅의 클래스에 고정하지 않고 독립된 객체로 분리하여 필요에 따라 교체할 수 있어야 한다. 이를 위해 구성을 활용할 수 있다.

15.3.1예제

예제 1

새로운 비행 방식인 순간이동을 상속만으로 구현하려면 다음과 같은 클래스를 추가할 수 있다.

class TeleportFlyingHero(Hero):
    def fly(self):
        print(f"{self.name}: 순간이동합니다!")

비행 방식이 계속 추가될 때 이 설계에서 생기는 문제를 설명하라.

답:

비행 방식 하나를 추가할 때마다 Hero의 자식 클래스를 새로 정의해야 한다. 캐릭터의 다른 특징까지 자식 클래스로 표현하면 특징의 조합마다 클래스가 필요해져 클래스 수가 빠르게 증가한다. 또한 생성된 객체의 비행 방식은 클래스에 고정되므로 실행 중에 다른 방식으로 바꾸기도 어렵다.

class TeleportFlyingHero(Hero):
    def fly(self):
        print(f"{self.name}: 순간이동합니다!")


strange = TeleportFlyingHero(
    "닥터 스트레인지", 45, "남성",
    120, 100, 180,
    {"suit": 100, "weapon": "마법"},
)
strange.fly()
닥터 스트레인지: 순간이동합니다!

예제 2

아이언맨이 전투 중 로켓 장치를 잃고 날개 장비를 얻었다고 하자. RocketFlyingHero로 생성된 객체의 비행 방식을 WingFlyingHero 방식으로 바꾸기 어려운 이유를 설명하라.

답:

상속 기반 설계에서는 비행 동작이 객체의 클래스에 정의되어 있다. 이미 생성된 객체의 클래스를 자연스럽게 교체할 수 없으므로, 다른 비행 방식을 사용하려면 새 객체를 만들거나 클래스 내부에 여러 조건문을 추가해야 한다. 따라서 실행 중에 기능이 바뀌는 요구사항에는 유연하지 않다.

15.3.2연습문제

문제 1

상속만 사용하여 걷는 영웅, 달리는 영웅, 수영하는 영웅을 각각 구현한다고 하자. 각 방식마다 자식 클래스를 만드는 설계의 장점 한 가지와 단점 두 가지를 설명하라.

문제 2

비행 방식 4개와 공격 방식 3개를 각각 클래스 상속의 조합으로 표현한다면, 모든 조합을 위한 자식 클래스는 최대 몇 개가 필요한가? 비행 방식이나 공격 방식이 늘어날 때 클래스 수가 어떻게 변하는지도 설명하라.

문제 3

RocketFlyingHero 객체가 실행 중에 비행 방식을 날개 비행으로 변경해야 한다고 가정하라. 상속 기반 설계를 유지할 때 생각할 수 있는 해결 방법을 하나 제시하고, 그 방법이 갖는 불편함을 설명하라.

15.4구성

구성composition은 다른 클래스의 객체를 인스턴스 속성으로 지정하여 사용하는 기법을 의미한다.

구성을 활용하면 비행 기능을 Hero 클래스에서 분리하여 별도의 객체로 구현할 수 있다. Hero 객체는 비행 동작을 직접 처리하는 대신, 인스턴스 속성으로 지정된 비행 객체에 위임한다. 따라서 비행 방식이 추가되어도 Hero의 자식 클래스를 새로 만들 필요가 없으며, 비행 객체만 교체하여 실행 중에도 비행 방식을 바꿀 수 있다. 영웅과 비행 기능을 독립적으로 수정하고 확장할 수 있다는 점도 구성의 장점이다.

다만 서로 다른 비행 객체를 Hero 객체가 동일한 방식으로 사용하려면 모든 비행 객체가 fly()와 같은 공통 메서드를 제공한다는 규칙이 필요하다. 여기서는 추상 클래스와 구상 클래스의 관계를 활용하여 이 규칙을 정의한다. 추상 클래스는 비행 객체가 따라야 할 공통 인터페이스를 지정하고, 각 구상 클래스는 로켓, 날개, 자체 비행과 같은 구체적인 동작을 구현한다.

15.4.1추상 클래스

추상 클래스abstract class는 자식 클래스가 구현해야 할 메서드를 지정하는 데 사용되는 클래스이다. 이처럼 자식 클래스에서 구현하도록 요구하는 메서드를 추상 메서드abstract method라고 한다.

파이썬에서 추상 클래스와 추상 메서드를 선언하는 기본 과정은 다음과 같다.

  • abc 모듈에서 ABC 클래스와 abstractmethod 장식자를 불러온다.

  • ABC 클래스를 상속하여 추상 클래스를 정의한다.

  • @abstractmethod 장식자를 사용하여 추상 메서드를 지정한다.

구성을 구현하는 데 추상 클래스가 반드시 필요한 것은 아니지만, 여러 비행 객체가 동일한 fly() 인터페이스를 따르도록 명시하는 데 유용하다.

추상 메서드가 남아 있는 클래스는 직접 인스턴스를 생성할 수 없다. 먼저 자식 클래스에서 모든 추상 메서드를 구현해야 한다.

예를 들어 비행 방식에 필요한 공통 인터페이스를 정의하는 Flying 추상 클래스를 다음과 같이 선언할 수 있다.

from abc import ABC, abstractmethod


class Flying(ABC):
    @abstractmethod
    def fly(self):
        pass

추상 클래스는 인스턴스 생성을 허용하지 않는다. 먼저 모든 추상 메서드를 구현한 자식 클래스를 만든 후에야 인스턴스를 생성할 수 있다.

try:
    flying = Flying()
except TypeError:
    print("주의: 먼저 모든 추상 메서드를 구현해야 합니다.")
주의: 먼저 모든 추상 메서드를 구현해야 합니다.

15.4.2구상 클래스

추상 메서드를 전혀 포함하지 않은 클래스를 구상 클래스concrete class라고 부른다. 여기서는 Flying 추상 클래스를 상속하는 구상 클래스 세 개를 비행 방식에 따라 구현한다.

  • 로켓 장치 비행

class RocketFlying(Flying):
    def fly(self):
        print("로켓으로 날아요!")
  • 날개 비행

class WingFlying(Flying):
    def fly(self):
        print("날개가 멋져요!")
  • 스스로 비행

class SelfFlying(Flying):
    def fly(self):
        print("스스로 날아요!")

예제: 동적으로 비행 방식 변경하기

이제 Hero 객체가 비행 방식을 직접 구현하지 않고, 지정된 Flying 객체에 비행 기능을 위임하도록 만든다.

변경 사항은 다음과 같다.

  • __init__() 메서드: 비행 방식을 인스턴스 속성으로 지정하는 기능 추가

  • fly() 메서드: 직접 구현하지 않고 지정된 비행 객체에 기능 위임

  • set_flying() 메서드: 동적으로 비행 방식 변경

class Hero(Human):
    def __init__(self, name, age, gender, power, health, damage, inventory, flying=None):
        super().__init__(name, age, gender)
        self.power = power
        self.health = health
        self.damage = damage
        self.inventory = inventory
        self.flying = flying

    def introduction(self):
        super().introduction()
        print(f"파워: {self.power}")
        print(f"무기: {self.inventory['weapon']}")

    def attack(self, other):
        print(f"{self.name}: {other.name} 공격!")
        attack_power = self.damage * 0.1
        other.health -= attack_power
        
    def fly(self):
        if self.flying is None:
            print(f"{self.name}: 저는 날 수 없어요.")
        else:
            self.flying.fly()

    def set_flying(self, flying):
        self.flying = flying

아래 코드는 토르 객체를 생성하면서 flying 속성에 구체적인 비행 방식을 구현한 Flying의 자식 클래스의 객체를 지정한다. 따라서 thor.fly()를 호출하면 Herofly() 메서드는 실제 비행 동작을 담당하는 flying 객체의 fly() 메서드에 위임한다.

이후 set_flying() 메서드에 새로운 RocketFlying 객체를 전달하면 토르 객체를 다시 생성하거나 Hero 클래스를 수정하지 않고도 비행 방식을 바꿀 수 있다. 이처럼 구성을 이용하면 객체가 사용할 기능을 실행 중에 다른 객체의 기능으로 교체할 수 있다.

thor = Hero(
    "토르",
    1500,
    "남성",
    power=500,
    health=500,
    damage=300,
    inventory={"suit": 200, "weapon": "망치"},
    flying=SelfFlying(),
)

thor.fly()
thor.set_flying(RocketFlying())
thor.fly()
스스로 날아요!
로켓으로 날아요!

15.4.3예제

예제 1

다음 코드에서 실제 비행 동작을 수행하는 객체와 비행을 요청하는 객체를 각각 찾아라.

thor = Hero(..., flying=SelfFlying())
thor.fly()

답:

비행을 요청받는 객체는 thor이고, 실제 비행 동작을 수행하는 객체는 thor.flying이 가리키는 SelfFlying 객체이다. Hero.fly()는 비행 방법을 직접 구현하지 않고 self.flying.fly()를 호출하여 작업을 위임한다. 이처럼 객체가 다른 객체를 속성으로 포함하고 그 기능을 사용하는 관계가 구성이다.

예제 2

아래 코드의 출력 결과를 예측하고, 같은 thor 객체의 동작이 바뀌는 이유를 설명하라.

thor.fly()
thor.set_flying(WingFlying())
thor.fly()

답:

처음에는 SelfFlying 객체가 지정되어 있으므로 "스스로 날아요!"가 출력된다. 이후 set_flying()flying 속성을 WingFlying 객체로 교체하므로 두 번째 호출에서는 "날개가 멋져요!"가 출력된다. thor의 클래스를 바꾸지 않고 협력 객체만 교체하여 동작을 변경한 것이다.

thor.set_flying(SelfFlying())
thor.fly()

thor.set_flying(WingFlying())
thor.fly()
스스로 날아요!
날개가 멋져요!

예제 3

Flying 추상 클래스의 fly() 메서드를 구현하지 않은 자식 클래스는 왜 인스턴스를 생성할 수 없는가?

답:

@abstractmethod로 지정된 메서드는 구상 자식 클래스가 반드시 구현해야 하는 기능의 약속이다. 이를 구현하지 않은 클래스는 여전히 추상 클래스로 취급되므로 인스턴스를 생성할 수 없다. 이 규칙 덕분에 Hero는 전달받은 비행 객체가 fly() 메서드를 제공한다고 기대할 수 있다.

class BrokenFlying(Flying):
    pass


try:
    BrokenFlying()
except TypeError as error:
    print(type(error).__name__, error)
TypeError Can't instantiate abstract class BrokenFlying without an implementation for abstract method 'fly'

15.4.4연습문제

문제 1

Flying을 상속하는 TeleportFlying 구상 클래스를 정의하라. fly() 메서드는 "순간이동합니다!"를 출력해야 한다. 완성한 객체를 thor.set_flying()에 전달하여 동작을 확인하라.

문제 2

비행 능력이 지정되지 않은 영웅이 fly()를 호출하면 "날 수 없습니다."를 출력하도록 Hero.fly() 메서드를 수정하라.

문제 3

공격 동작을 나타내는 Attacking 추상 클래스와 PunchAttack, LaserAttack 구상 클래스를 정의하라. 각 구상 클래스는 attack() 메서드에서 서로 다른 메시지를 출력해야 한다.

문제 4

문제 3의 공격 객체를 Hero의 속성으로 저장하도록 클래스를 확장하라. set_attacking() 메서드로 공격 방식을 실행 중에 교체할 수 있어야 한다. 이 설계에서 상속보다 구성이 유리한 점을 설명하라.

15.5OOP 디자인 원칙

객체 지향 프로그래밍은 단순히 데이터와 메서드를 하나의 클래스에 모으는 기법이 아니다. 프로그램이 담당할 일을 여러 객체의 책임으로 나누고, 각 객체가 공개된 메서드를 통해 서로 요청하고 협력하도록 설계하는 방식이다. 객체 내부의 상태와 구현 방법은 감추고 필요한 기능만 외부에 제공하면, 한 객체의 구현이 바뀌더라도 그 객체를 사용하는 다른 코드가 받는 영향을 줄일 수 있다.

좋은 객체 지향 설계는 현재의 코드 중복만 줄이는 것이 아니라 앞으로 어떤 부분이 변경될 수 있는지 예상하고 그 변경 범위를 제한하는 데에도 관심을 둔다. 이번 장의 예제에서는 다음 두 가지 원칙을 확인할 수 있다.

디자인 원칙 1: 변하는 부분과 변하지 않는 부분을 구분하라.

영웅 객체의 이름, 나이, 성별과 같은 기본 정보와 자기소개 기능은 비교적 안정적이다. 반면 비행 방식은 영웅마다 다르며 실행 중에도 달라질 수 있다. 이처럼 변경되는 이유와 시점이 다른 기능을 하나의 클래스에 함께 넣으면, 비행 방식을 추가하거나 수정할 때마다 Hero 클래스도 계속 변경해야 한다.

따라서 자주 변하는 비행 기능을 Hero에서 분리하여 Flying 객체의 책임으로 만든다. Hero는 비행 방법을 알 필요 없이 fly() 메서드를 호출하여 동작을 요청한다. 구체적인 비행 방법을 캡슐화하면 새로운 비행 방식을 추가해도 Hero의 코드는 대부분 그대로 유지할 수 있다.

디자인 원칙 2: 상속과 구성을 함께 활용하라.

상속과 구성은 서로 대체하는 기법이라기보다 서로 다른 관계를 표현하는 도구이다. HeroHuman의 한 종류이므로 두 클래스는 ~이다is-a 관계에 있고, 사람의 공통 속성과 기능을 상속하는 것이 자연스럽다. 반면 영웅은 비행 방식의 한 종류가 아니라 비행 기능을 가지므로 HeroFlying~을 가진다has-a 관계로 표현하는 편이 자연스럽다.

상속으로 비행 기능을 구현하면 비행 방식마다 영웅의 자식 클래스가 필요하고, 객체를 생성한 뒤에는 그 종류를 바꾸기 어렵다. 구성을 사용하면 Hero가 구체적인 비행 클래스 대신 공통 fly() 인터페이스에 의존하므로, RocketFlying, WingFlying, SelfFlying 객체를 같은 방식으로 사용하고 필요할 때 교체할 수 있다. 이처럼 상속은 객체의 본질적인 종류와 공통 기능을 표현하는 데 사용하고, 구성은 독립적으로 변하거나 교체될 수 있는 기능을 조합하는 데 사용하는 것이 좋다.

결국 중요한 것은 클래스의 수를 늘리는 것이 아니라 각 객체의 책임과 관계를 명확하게 정하는 일이다. 변경 가능성이 높은 기능을 분리하고 객체들이 공통 인터페이스를 통해 협력하게 하면, 기존 코드에 미치는 영향을 줄이면서 새로운 기능을 추가할 수 있다.

15.5.1예제

예제 1

비행 메시지를 바꾸어야 할 때 Hero 클래스와 RocketFlying 클래스 중 어느 클래스를 수정하는 것이 적절한가? 객체의 책임과 연결하여 설명하라.

답:

로켓 비행의 구체적인 출력 방식은 RocketFlying의 책임이므로 RocketFlying을 수정하는 것이 적절하다. Hero는 어떤 비행 객체를 사용할지 보관하고 비행 요청을 위임하는 책임만 맡는다. 책임을 분리하면 로켓 비행의 구현 변화가 Hero나 다른 비행 클래스에 영향을 주지 않는다.

예제 2

다음 두 문장 중 객체 내부 구현을 더 잘 감추는 코드를 고르고 이유를 설명하라.

thor.flying = RocketFlying()
thor.set_flying(RocketFlying())

답:

thor.set_flying(RocketFlying())이 더 적절하다. 공개된 메서드를 통해 상태를 변경하면 Hero가 전달된 객체를 검사하거나 추가 작업을 수행하도록 구현을 바꾸기 쉽다. 외부 코드가 속성에 직접 의존하는 정도도 줄어든다.

thor.set_flying(RocketFlying())
thor.fly()
로켓으로 날아요!

15.5.2연습문제

문제 1

현재 예제에서 Human, Hero, Flying 클래스가 각각 담당하는 책임을 한 문장씩 정리하라.

문제 2

다음 기능을 어느 클래스에 두는 것이 적절한지 답하고 이유를 설명하라.

  • 사람의 이름 변경

  • 영웅의 체력 감소

  • 로켓 비행 메시지 출력

  • 비행 방식 교체

문제 3

Hero.set_flying() 메서드가 Flying의 인스턴스만 받도록 검사 기능을 추가하라. 잘못된 객체가 전달되면 TypeError를 발생시켜야 한다.

def set_flying(self, flying):
    # 여기에 검사 코드 추가
    self.flying = flying

문제 4

비행 기능을 상속으로 구현한 설계와 구성으로 구현한 설계를 다음 기준으로 비교하라.

  1. 코드 재사용

  2. 실행 중 동작 변경

  3. 새로운 비행 방식 추가

  4. 클래스 수 증가

15.6종합 연습문제

도시 수호대의 마지막 임무

문자열 출력만 사용하는 간단한 텍스트 게임을 만든다. 아이언맨, 토르, 헐크는 서로 대화하고 격려하며, 도시를 위협하는 타이탄과 맞선다. 각 문제를 순서대로 해결하면 마지막 문제에서 하나의 이야기가 완성된다.

이번 문제에서는 다음 관계를 활용한다.

  • HumanCharacterHero, Villain: 상속

  • HeroTechHero, MagicHero, PowerHero: 상속과 메서드 오버라이딩

  • HeroFlying 객체를 속성으로 가짐: 구성

  • 영웅과 악당이 서로를 메서드의 인자로 사용: 객체 사이의 상호작용

문제 1: 공통 전투 캐릭터

Human을 상속하는 Character 클래스를 정의하라.

  • super()를 이용하여 name, age, gender를 초기화한다.

  • healthdamage 속성을 추가한다.

  • is_alive()는 체력이 0보다 크면 True를 반환한다.

  • take_damage(amount)는 체력을 감소시키되 0보다 작아지지 않게 한다.

  • 공격받은 이름, 피해량, 남은 체력을 문자열로 출력한다.

문제 2: 영웅과 악당

Character를 상속하는 HeroVillain 클래스를 정의하라.

Heropower, trust, flying 속성을 추가로 갖는다. trust의 초깃값은 0이고 flying의 기본값은 None이다. Villain은 악당의 목적을 저장하는 goal 속성을 추가로 갖는다. 두 클래스에서 introduction()을 오버라이딩하여 다음 형식으로 출력하라.

[영웅] 아이언맨 | 체력: 100 | 능력치: 90 | 신뢰도: 0
[악당] 타이탄 | 체력: 100 | 목적: 도시의 에너지를 차지한다

문제 3: 서로 다른 유형의 영웅

Hero를 상속하는 TechHero, MagicHero, PowerHero 클래스를 정의하라. 각 클래스에서 specialty() 메서드를 서로 다르게 오버라이딩한다.

아이언맨: 첨단 장비로 상황을 분석합니다.
토르: 번개의 힘을 불러옵니다.
헐크: 강한 힘으로 장애물을 부숩니다.

문제 4: 대화, 격려, 반응

Hero 클래스에 추가할 다음 메서드를 정의하라.

  • talk(other, message): 다른 영웅에게 메시지를 전달한다.

  • encourage(other): 상대의 체력을 최대 100까지 10 증가시키고 신뢰도를 1 증가시킨다.

  • respond(): 신뢰도 0이면 "아직 혼자 싸우는 편이 낫겠어.", 1~2이면 "조금은 믿어도 될 것 같아.", 3 이상이면 "우리는 한 팀이야."를 출력한다.

문제 5: 비행 능력을 이용한 동료 구조

Hero 클래스에 추가할 rescue(other) 메서드를 정의하라.

  • flyingNone이면 구조 실패 메시지를 출력하고 False를 반환한다.

  • 비행 능력이 있으면 self.flying.fly()에 동작을 위임한다.

  • 구조에 성공하면 두 영웅의 신뢰도를 각각 1 증가시키고 True를 반환한다.

문제 6: 악당의 위협과 공격

Villain 클래스에 추가할 threaten(heroes)attack(other) 메서드를 정의하라. threaten()은 모든 영웅의 이름을 언급하고, attack()은 대상의 take_damage()를 호출한다.

타이탄: 아이언맨, 토르, 헐크! 너희는 나를 막을 수 없다!
타이탄이 헐크를 공격합니다!
헐크: 25의 피해를 받았습니다. 남은 체력: 75

문제 7: 갈등과 화해

Hero 클래스에 추가할 disagree(other)reconcile(other) 메서드를 정의하라.

  • disagree()는 두 영웅의 신뢰도를 각각 1 감소시키되 0보다 작아지지 않게 한다.

  • reconcile()은 두 영웅의 신뢰도를 각각 1 증가시킨다.

  • 두 메서드 모두 영웅 사이의 대화를 문자열로 출력한다.

문제 8: 신뢰를 이용한 협동 공격

Hero 클래스에 추가할 team_attack(other, villain) 메서드를 정의하라.

  • 두 영웅의 신뢰도가 모두 2 이상일 때만 성공한다.

  • 협동 공격력은 (self.power + other.power) // 2이다.

  • 성공하면 악당의 take_damage()를 호출하고 True를 반환한다.

  • 신뢰가 부족하면 실패 메시지를 출력하고 False를 반환한다.

문제 9: 최종 텍스트 게임 완성

지금까지 구현한 내용을 하나의 프로그램으로 완성하라.

  1. 세 영웅과 타이탄을 생성한다.

  2. 모든 캐릭터가 자기소개한다.

  3. 타이탄이 영웅들을 위협하고 헐크를 공격한다.

  4. 아이언맨과 토르가 작전 방식 때문에 다툰다.

  5. 토르가 헐크를 격려하고 아이언맨이 헐크를 구조한다.

  6. 아이언맨과 토르가 화해하고 서로를 격려한다.

  7. 세 영웅의 체력과 신뢰도를 출력한다.

  8. 아이언맨과 토르가 협동 공격을 사용한다.

  9. 타이탄의 체력이 0이면 임무 성공을 출력한다.