객체 지향 프로그래밍: 상속과 구성

객체 지향 프로그래밍: 소개 에서 객체를 활용하여 구현 대상인 객체(object)를 효율적으로 생성하고 활용하는 방법을 turtle 모듈의 Turtle 클래스 활용 예제를 통해 살펴보았다. 또한 코드 추상화: 클래스와 객체 1부2부 에서 클래스와 객체의 기본 개념과 활용법을 자세하게 설명하였다.

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

주요 예제: 게임 캐릭터 설정

In [1]:
class Character(object):
    
    def __init__(self, name, power, damage, inventory):
        self.name = name
        self.power = power
        self.damage = damage
        self.inventory = inventory
        
    # 자기소개 매소드
    def introduction(self):
        print("제 이름은 %s입니다" % self.name)
        print("현재 저의 파워는 %s입니다." % self.power)
        print("저는 공격할 때마다 상대방에게 %s만큼의 손상을 줍니다." % self.damage)
        print("제 수트의 방어력은 %d이며 사용하는 무기는 %s입니다." % \
              (self.inventory['suit'], self.inventory['weapon']))
        
    # 파워 정보 확인 메서드
    def getPower(self):
        return self.power
    
    # 파워 조절 메서드
    def setPower(self, power):
        self.power = self.power + power
        
    # 상대 캐릭터 공격 메서드
    # 둘째 인자로 상대 캐릭터 인스턴스가 사용될 것임.
    def attack(self, other):
        # print("%s: %s 공격하기!" % (self.name, other.name))
        # 공격력의 10% 만큼 상대 파워 감소시킴
        attackPower = self.damage * 0.1 
        other.setPower(-attackPower)

아래 코드는 어벤저스 영화의 아이언맨과 데드풀 캐릭터를 생성한다.

In [2]:
ironman = Character('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})
deadpool = Character('데드풀', 300, 30, {'suit': 300, 'weapon': '장검'})
  • introduction() 메서드: 캐릭터 자기소개
In [3]:
ironman.introduction()
제 이름은 아이언맨입니다
현재 저의 파워는 100입니다.
저는 공격할 때마다 상대방에게 200만큼의 손상을 줍니다.
제 수트의 방어력은 500이며 사용하는 무기는 레이저입니다.
  • getPower(): 캐릭터의 파워 확인
  • setPower(): 캐릭터의 파워 조절

예를 들어, 아이언맨 인스턴스의 파워를 키우고나서 확인하는 과정은 다음과 같다.

In [4]:
print(ironman.getPower())
100
In [5]:
ironman.setPower(50)
print(ironman.getPower())
150

위 예제에서 살펴 본 getPower()setPower() 같이 인스턴스의 속성값을 확인하거나 지정하는 메서드를 각각 게터(getter) 메서드와 세터(setter) 메서드라 부른다. 관용적으로 게터 메서드 이름은 get으로 시작하며, 세터 메서드 이름은 set으로 시작한다.

  • attack() 메서드: 게터와 세터 활용

파워가 100인 침입자가 출현했다고 가정한다.

In [6]:
intruder = Character('적군', 100, 50, {'suit': 100, 'weapon': '독'})

이제 데드풀이 침입자를 공격하니 파워가 3만큼 줄게 된다. 여기서 3은 데드풀의 공격력의 10분의 1을 가리킨다.

In [7]:
deadpool.attack(intruder)
intruder.getPower()
Out[7]:
97.0

연습문제

  1. Character 클래스에서 사용되는 모든 인스턴스 속성에 대한 게터/세터 메서드를 선언한 후에 attack() 메서드를 게터/세터 메서드만을 사용하여 선언하라. 즉, 인스턴스 속성 변수를 직접 사용하지 않아야 한다.

인스턴스 생성 기술의 단점

지금까지 OOP에 대하여 살펴본 내용의 핵심은 다음과 같다.

하나의 클래스를 이용하여 다양한 인스턴스를 쉽게 생성할 수 있다.

그런데 한 가지 단점은 인스턴스 변수에 저장된 값에 따라 성능의 차이가 있기는 하지만 모든 인스턴스가 동일한 기능을 갖는다는 것이다. 원래 클래스를 활용하는 주요 이유이지만, 경우에 따라 단점으로 작동할 수 있음을 보여주고자 한다.

인스턴스 생성 기술의 단점을 설명하기 위해 지금까지 사용한 Character 클래스를 다시 살펴보자. Character 클래스의 인스턴스는 모두 공격(attack) 기능을 갖게 된다. 그런데 게임 캐릭터에 따라 비행 능력을 갖는 캐릭터와 그렇지 못한 캐릭터를 구분해야 하는 경우가 있다. 어떻게 구분할까?

먼저 Character에 비행 기능인 fly 메서드를 추가해보자.

In [8]:
class Character(object):
    
    def __init__(self, name, power, damage, inventory):
        self.name = name
        self.power = power
        self.damage = damage
        self.inventory = inventory
        
    # 자기소개 매소드
    def introduction(self):
        print("제 이름은 %s입니다" % self.name)
        print("현재 저의 파워는 %s입니다." % self.power)
        print("저는 공격할 때마다 상대방에게 %s만큼의 손상을 줍니다." % self.damage)
        print("제 수트의 방어력은 %d이며 사용하는 무기는 %s입니다." % \
              (self.inventory['suit'], self.inventory['weapon']))
        
    # 파워 정보 확인 메서드
    def getPower(self):
        return self.power
    
    # 파워 조절 메서드
    def setPower(self, power):
        self.power = self.power + power
        
    # 상대 캐릭터 공격 메서드
    # 둘째 인자로 상대 캐릭터 인스턴스가 사용될 것임.
    def attack(self, other):
        print("%s: %s 공격하기!" % (self.name, other.name))
        # 공격력의 10% 만큼 상대 파워 감소시킴
        attackPower = self.damage * 0.1 
        other.setPower(-attackPower)
        
    # 지정된 속도로 날아가기 메서드
    def fly(self, speed):
        print("%s: 시속 %d km로 날아갑니다." % (self.name, speed))        

이렇게 하면 Character 클래스의 모든 인스턴스가 fly 기능을 갖게 된다.

In [9]:
ironman = Character('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})
ironman.fly(100)
아이언맨: 시속 100 km로 날아갑니다.

그런데 예를 들어 헐크 캐릭터도 날 수 있게 된다.

In [10]:
hulk = Character('헐크', 400, 300, {'suit': 0, 'weapon': '주먹'})
hulk.fly(1000)
헐크: 시속 1000 km로 날아갑니다.

우리가 아는 헐크 캐릭터는 날지 못하는데 이와같이 단순히 인스턴스를 생성하면 비행 능력을 기본적으로 갖게 된다. 어떻게 할까?

여러 방법이 있을 수 있다. 먼저 Character 클래스의 __init__() 메서드에 매개변수를 하나 더 추가하는 방식을 사용해 보자. 즉, 캐릭터를 생성할 때 비행능력을 추가로 입력받아서 날지 못하는 경우 fly 메서드가 호출되면 "저는 날지 못합니다" 라는 문구를 출력하도록 해보자.

  • __init()__ 메서드에 flight=False 옵션 변수 추가
  • fly() 메서드 수정
In [11]:
class Character(object):
    
    # 비행능력 여부 확인 매개변수 flight 추가. 기본값은 False.
    def __init__(self, name, power, damage, inventory, flight=False):
        self.name = name
        self.power = power
        self.damage = damage
        self.inventory = inventory
        self.flight = flight                  # 비행능력
        
    # 자기소개 매소드
    def introduction(self):
        print("제 이름은 %s입니다" % self.name)
        print("현재 저의 파워는 %s입니다." % self.power)
        print("저는 공격할 때마다 상대방에게 %s만큼의 손상을 줍니다." % self.damage)
        print("제 수트의 방어력은 %d이며 사용하는 무기는 %s입니다." % \
              (self.inventory['suit'], self.inventory['weapon']))
        
    # 파워 정보 확인 메서드
    def getPower(self):
        return self.power
    
    # 파워 조절 메서드
    def setPower(self, power):
        self.power = self.power + power
        
    # 상대 캐릭터 공격 메서드
    # 둘째 인자로 상대 캐릭터 인스턴스가 사용될 것임.
    def attack(self, other):
        print("%s: %s 공격하기!" % (self.name, other.name))
        # 공격력의 10% 만큼 상대 파워 감소시킴
        attackPower = self.damage * 0.1 
        other.setPower(-attackPower)
        
    # 지정된 속도로 날아가기 메서드
    # 비행능력 여부에 따라 다른 행동 지정
    def fly(self, speed):
        if self.flight:
            print("%s: 시속 %d km로 날아갑니다." % (self.name, speed))
        else:
            print("%s: 저는 날지 못합니다." % self.name)

주의: __init__() 메서드에 추가된 flight 매개변수는 키워드 매개변수이다. 즉, 인스턴스를 생성할 때 flight 매개변수를 통해 전달하는 인자를 굳이 입력하지 않아도 되며 그럴 경우 지정된 기본값인 False가 자동으로 전달되도록 설정되어 있다.

이제 아이언맨과 헐크를 아래와 같이 생성할 수 있다.

  • 아이언맨의 경우: 다섯째 인자로 True를 입력하면 flight 매개변수에 인자로 전달 된다.
  • 헐크의 경우: 다섯째 인자가 없으면 기본값인 Falseflight 매개변수에 인자로 전달 된다.
In [12]:
ironman = Character('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'}, True)
hulk = Character('헐크', 400, 300, {'suit': 0, 'weapon': '주먹'})
In [13]:
ironman.fly(1000)
아이언맨: 시속 1000 km로 날아갑니다.
In [14]:
hulk.fly(1000)
헐크: 저는 날지 못합니다.

flight 인자에 전달되는 값에 따라 비행능력을 확실하게 보여주게 되었다. 하지만 헐크의 경우 비행능력을 묻는 것 자체가 이상하게 보일 수 있다.

또한 인스턴스를 생성할 때마다 매번 옵션인자의 값을 지정하는 일도 바람직하지 않다. 실제 영화나 게임에 등장하는 캐릭터의 수는 경우에 따라 수천, 수만, 수십만인데 그 모든 캐릭터의 옵션을 일일이 다르게 설정하는 일은 피해야 한다.

그렇다면 헐크 캐릭터를 생성할 때 애초부터 '난다(fly)' 기능을 신경쓸 필요 없게 만들 수는 없을까? 여기서는 상속구성을 이용한 해결책을 제시한다.

상속

상속(inheritance)은 기존 클래스에서 선언된 속성(변수)과 기능(메서드)을 필요에 따라 재활용하거나 속성과 기능을 추가해서 보다 효율적으로 객체와 데이터를 관리하기 위해 사용되는 OOP의 핵심 기술 중 하나이다.

앞서 예제에서 살펴본 인스턴스 생성의 단점을 상속을 이용하여 해결하는 방법을 설명하기 위해 fly 기능을 추가하기 이전의 Character 클래스로 다시 돌아가자.

In [15]:
class Character(object):
    
    def __init__(self, name, power, damage, inventory):
        self.name = name
        self.power = power
        self.damage = damage
        self.inventory = inventory
        
    # 자기소개 매소드
    def introduction(self):
        print("제 이름은 %s입니다" % self.name)
        print("현재 저의 파워는 %s입니다." % self.power)
        print("저는 공격할 때마다 상대방에게 %s만큼의 손상을 줍니다." % self.damage)
        print("제 수트의 방어력은 %d이며 사용하는 무기는 %s입니다." % \
              (self.inventory['suit'], self.inventory['weapon']))
        
    # 파워 정보 확인 메서드
    def getPower(self):
        return self.power
    
    # 파워 조절 메서드
    def setPower(self, power):
        self.power = self.power + power
        
    # 상대 캐릭터 공격 메서드
    # 둘째 인자로 상대 캐릭터 인스턴스가 사용될 것임.
    def attack(self, other):
        print("%s: %s 공격하기!" % (self.name, other.name))
        # 공격력의 10% 만큼 상대 파워 감소시킴
        attackPower = self.damage * 0.1 
        other.setPower(-attackPower)

아이언맨과 헐크를 Character 캐릭터 인스턴스를 생성하면서 아이언맨에게만 비행능력을 주고, 헐크는 아예 날다라는 개념을 모르게 하고 싶다고 가정하자.

이렇게 하려면 비행능력을 가진 클래스를 Character 클래스의 자식 클래스로 선언하면 된다. 상속을 정의하는 방식은 다음과 같다.

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

이제 상속을 이용하여 Character 클래스의 속성과 기능을 모두 물려받으면서 동시에 비행 능력을 추가한 클래스인 FlyingCharacter 클래스를 선언해 보자.

주의: flight 매개변수는 전혀 사용하지 않는다.

In [16]:
class FlyingCharacter(Character):
    
    # 지정된 속도로 날아가기 메서드
    def fly(self, speed):
        print("%s: 시속 %d km로 날고 있습니다." % (self.name, speed))

자식클래스의 인스턴스는 부모클래스의 인스턴스!

아이언맨의 경우 비행능력이 있으므로 Character 클래스가 아닌 FlyingCharacter 클래스의 인스턴스로 생성한다.

In [17]:
ironman = FlyingCharacter('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})

isinstance() 함수를 사용하여 ironman은 여전히 Character 클래스의 인스턴스임을 확인할 수 있다. 즉, 자식클래스의 인스턴스는 모두 부모클래스의 인스턴스이기도 하다.

In [18]:
isinstance(ironman, Character)
Out[18]:
True

따라서 ironmanCharacter 클래스의 모든 메서드를 사용할 수 있다.

In [19]:
ironman.getPower()
Out[19]:
100

아이언맨은 비행능력도 당연히 있다.

In [20]:
ironman.fly(100)
아이언맨: 시속 100 km로 날고 있습니다.

부모클래스의 인스턴스는 자식클래스를 모름!

헐크 캐릭터는 아예 비행능력이 없으므로 Character 클래스의 인스턴스로 선언한다.

In [21]:
hulk = Character('헐크', 400, 300, {'suit': 0, 'weapon': '주먹'})

앞서 언급한 대로 ironman 역시 Character 클래스의 기능을 공유한다. 따라서, 예를 들어, ironmanhulk가 서로 공격할 수 있다.

In [22]:
ironman.attack(hulk)
아이언맨: 헐크 공격하기!
In [23]:
hulk.attack(ironman)
헐크: 아이언맨 공격하기!

헐크는 비행능력이 없으며 fly 메서드를 아예 모른다.

주의: 아래 코드는 오류발생을 피하기 위해 예외처리를 사용한다.

In [24]:
try: 
    hulk.fly(100)
except AttributeError: 
    print("헐크: 전 날지 못해요!")
헐크: 전 날지 못해요!

메서드 상속과 재정의

__init__() 메서드 상속과 재정의

모든 클래스에는 초기 설정 메서드인 init() 메서드가 포함되어야 한다. 그런데 FlyingCharacter 클래스에는 초기 설정 메서드가 포함되어 있지 않다. 이럴 때는 부모 클래스인 Character 클래스의 init() 메서드를 그대로 사용한다.

FlyingCharacter의 인스턴스를 생성할 때 Character 클래스의 인스턴스를 생성하는 것처럼 네 개의 인자를 사용한 이유가 여기에 있다. 실제로 FlyingCharacter 클래스의 초기 설정 메서드를 아래와 같이 선언하는 것과 동일하게 작동한다.

def __init__(self, name, power, damage, inventory):
    super().__init__(name, power, damage, inventory)
  • 둘째 줄에 있는 super()는 부모클래스인 Character 클래스를 가리킨다.
  • 따라서 자식클래스인 FlyingCharacter의 초기 설정 메서드를 호출하면 부모클래스인 Character 클래스의 초기 설정 메서드가 자동으로 호출된다.
  • 부모클래스의 초기 설정 메서드를 호출할 때 사용되는 인자는 자식클래스의 초기 설정 메서드를 통해 전달되는 인자들을 이용한다.
  • 주의: 초기 설정 메서드를 호출할 때 self 매개변수는 사용하지 않는다.

이제 초기 설정 메서드를 구체적으로 선언하여 FlyingCharacter를 정의하면 다음과 같다.

In [25]:
class FlyingCharacter(Character):
    # 자식클래스 초기 설정 메서드
    def __init__(self, name, power, damage, inventory):
        super().__init__(name, power, damage, inventory)
    
    # 지정된 속도로 날아가기 메서드
    def fly(self, speed):
        print("%s: 시속 %d km로 날고 있습니다." % (self.name, speed))    

앞서 설명한 경우와 동일하게 작동함을 아래 예제가 보여주고 있다.

In [26]:
ironman = FlyingCharacter('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})
hulk = Character('헐크', 400, 300, {'suit': 0, 'weapon': '주먹'})

ironman.attack(hulk)
hulk.attack(ironman)
ironman.fly(100)

try: 
    hulk.fly(100)
except AttributeError: 
    print("헐크: 전 날지 못해요!")
아이언맨: 헐크 공격하기!
헐크: 아이언맨 공격하기!
아이언맨: 시속 100 km로 날고 있습니다.
헐크: 전 날지 못해요!

자식클래스 초기 설정 메서드가 부모클래스 초기 설정 메서드가 하는 일에 추가하여 다른 일을 할 수도 있다. 이렇게 자식클래를 선언할 때 부모클래스로부터 상속한 메서드의 일부를 재정의할 수 있다. 메서드 재정의를 오버라이딩(overriding)이라 부르기도 한다.

예를 들어, 아래에 정의된 FlyingCharacter 클래스의 인스턴스를 생성할 때 생성된 캐릭터의 suit의 능력에 따라 비행 속도를 다르게 지정할 수 있다.

In [27]:
class FlyingCharacter(Character):
    # 자식클래스 초기 설정 메서드
    # hero 매개변수 추가
    def __init__(self, name, power, damage, inventory):
        super().__init__(name, power, damage, inventory)
        
        # suit의 능력이 400 이상인 경우 두 배 속도로 비행하도록 설정
        if self.inventory['suit'] >= 400:
            self.speedUp = 2
        else:
            self.speedUp = 1
    
    # 지정된 속도로 날아가기 메서드
    # 영웅 캐릭터인 경우 지정 속도보다 두 배 빠르게 날게 함
    # fly 메서드 역시 재정의됨.
    def fly(self, speed):
        print("%s: 시속 %d km로 날고 있습니다." % (self.name, speed * self.speedUp))    

FlyingCharacter의 새로운 초기 설정 메서드의 변화는 다음과 같다.

  • self.inventory[suit] 값의 크기에 따라 speedUp 속성을 다르게 설정
  • speedUp 속성: fly 메서드를 선언할 때 사용되었음.

아래 코드는 아이언맨과 울트론을 생성한 후에 시속 100으로 날으라고 할 때 두 캐릭터의 속도가 2배 차이나는 것을 보여준다.

In [28]:
ironman = FlyingCharacter('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})
ultron = FlyingCharacter('울트론', 400, 300, {'suit': 300, 'weapon': '플라즈마 빔'})

ironman.attack(ultron)
ultron.attack(ironman)
# 비행속도를 100으로 지정하면 아이언맨은 200으로 날아 오름.
ironman.fly(100)
ultron.fly(100)
아이언맨: 울트론 공격하기!
울트론: 아이언맨 공격하기!
아이언맨: 시속 200 km로 날고 있습니다.
울트론: 시속 100 km로 날고 있습니다.

*args, **kwargs 활용

*args**kwargs는 함수를 정의할 때 인자의 수를 미리 지정하지 않을 때 사용하는 표현식이다.

  • *args: 임의의 개수의 매개변수를 의미함
  • **kwargs: 임의의 개수의 옵션 매개변수를 의미함.

참조: args는 kwargs는 각각 arguments(인자)와 keyword arguements(옵션 인자)의 줄임말이다.

이 방식을 이용하면 FlyingCharacter 클래스의 __init__() 메서드를 다음과 같이 정의할 수 있다.

def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)

    # suit의 능력이 400 이상인 경우 두 배 속도로 비행하도록 설정
    if self.inventory['suit'] >= 400:
        self.speedUp = 2
    else:
        self.speedUp = 1

위와같이 하면 모든 매개변수가 부모클래스의 초기 설정 메서드에 그대로 전달된다.

참조:

  • 여기서는 kwargs에 해당하는 옵션 매개변수는 사용하지 않지만, 그래도 항상 위와 같은 방식으로 사용한다.
  • 이런 방식의 장점은, 부모클래스의 메서드에 사용되는 매개변수가 달라져도 자식클래스에서 일일이 그에 맞게 수정할 필요가 없다는 데에 있다.

사용자 정의 메서드 재정의

부모클래스에서 선언된 메서드를 자식클래스에서 모두 재정의할 수 있다. 예를 들어 설명하자.

Character 클래스에 선언된 attack 메서드를 영웅 캐릭터가 공격하면 보다 큰 치명타를 가할 수 있도록 하고자 한다.

  • 기존의 attack 메서드는 공격자의 damage 파워의 10%만큼 상대방 파워(power)를 감소시켰다.
  • 이제 캐릭터의 파워가 400보다 크면 캐릭터를 공격할 때 공격 캐릭터의 damage 파워의 10%가 아닌 20%만큼 상대방 파워를 감소시키도록 하자.

그러려면 FlyingCharacter에서 attack 메서드를 아래와 같이 재정의해야 한다.

In [29]:
class FlyingCharacter(Character):
    # 자식클래스 초기 설정 메서드
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        
        # suit의 능력이 400 이상인 경우 두 배 속도로 비행하도록 설정
        if self.inventory['suit'] >= 400:
            self.speedUp = 2
        else:
            self.speedUp = 1
    
    # 지정된 속도로 날아가기 메서드
    # 영웅 캐릭터인 경우 지정 속도보다 두 배 빠르게 날게 함
    def fly(self, speed):
        print("%s: 시속 %d km로 날고 있습니다." % (self.name, speed * self.speedUp))    
        
    # attack 메서드 재정의
    def attack(self, other):
        print("%s: %s 공격하기!" % (self.name, other.name))
        # 영웅이 악당 공격할 때 타격효과 두 배
        if self.power >= 300:
            attackPower = self.damage * 0.2
        else:
            attackPower = self.damage * 0.1 
        other.setPower(-attackPower)

이제 아이언맨과 울트론을 이용하여 확인해보자.

In [30]:
ironman = FlyingCharacter('아이언맨', 100, 200, {'suit': 500, 'weapon': '레이저'})
ultron = FlyingCharacter('울트론', 400, 300, {'suit': 300, 'weapon': '플라즈마 빔'})

print('아이언맨 파워:', ironman.getPower())
print('울트론 파워:', ultron.getPower())

# 10% 타격
ironman.attack(ultron)                      
print('울트론 파워:', ultron.getPower())

# 20% 타격
ultron.attack(ironman)
print('아이언맨 파워:', ironman.getPower())
아이언맨 파워: 100
울트론 파워: 400
아이언맨: 울트론 공격하기!
울트론 파워: 380.0
울트론: 아이언맨 공격하기!
아이언맨 파워: 40.0

구성

구성(composition)은 다른 클래스의 인스턴스를 인스턴스 속성으로 지정하여 사용하는 기법을 의미한다. 구성을 이용한 예제를 이미 앞서 살펴보았다. 바로 코드 추상화: 클래스와 객체 1부 에서 Person 클래스의 인스턴스를 생성할 때 필요한 생년월일에 대한 정보를 datetime 모듈의 date 클래스의 객체에 담아 활용하였다.

예를 들어, 아래 코드에서 Jane Doe의 개인정보를 담은 Person 클래스의 객체를 생성할 때 생년월일 정보는 datetime.date(1992, 3, 12) 객체에 담겨져 있다.

class Person:

    def __init__(self, name, surname, birthdate, address, telephone, email):
        self.name = name
        self.surname = surname
        self.birthdate = birthdate

        self.address = address
        self.telephone = telephone
        self.email = email

jDoe = Person(
    "Jane",
    "Doe",
    datetime.date(1992, 3, 12), # 년, 월, 일
    "No. 12 Short Street, Greenville",
    "555 456 0987",
    "jane.doe@example.com"
    )

프로그램 확장성

구성 방식을 활용하면 상속을 사용하지 않으면서 보다 확장성이 좋은 프로그램을 작성할 수 있다. 확장성이 좋은 프로그램이란 프로그램 전체 구조의 변화 없이 특정 기능을 추가하고 변경할 수 있는 프로그램을 의미한다.

예를 들어, 앞서 사용한 FlyingChracter 클래스의 경우를 보자. 생성되는 캐릭터마다 비행 방식이 다를 수 있다.

  • 아이언맨: 로켓 장치 사용
  • 팔콘: 날개 사용
  • 토르: 특별한 장치 없음. 스스로 비행능력 있음.

이렇게 캐릭터마다 다른 비행 방식을 어떻게 다룰 수 있을까?

가장 간단한 방식은 비행 방식에 따라 fly() 메서드를 다르게 구현한

  • RocketFlyingCharacter: 로켓 장치 사용 비행 캐릭터 클래스
  • WingFlyingCharacter: 날개 비행 캐릭터 클래스
  • SelfFlyingCharacter: 스스로 비행하는 캐릭터 클래스

등과 같은 여러 개의 클래스를 구현하고 필요한 클래스를 상속하여 캐릭터 인스턴스를 생성하는 것이다. 즉, 아이언맨, 팔콘, 토르를 각각 RockerFlyingCharacter, WingFlyingCharacter, SelfFlyingCharacter 클래스의 인스턴스로 생성하는 것이다.

그런데 이런 방식은 한 가지 문제를 갖는다. 바로 비행 방식이 캐릭터 인스턴스를 생성할 때 고정된다는 것이다. 예를 들어, 스스로 비행하는 캐릭터가 중간에 로켓 장치를 이용할 수 밖에 없는 상황에 처하면 비행 방식을 바꾸어야 하는데 이런 것을 매번 따로 처리해야 한다. 아마도 새로운 도구나 장치를 이용하는 기능을 캐릭터에 추가해야 할 것이다. 하지만 이방식을 사용하려면 기존에 사용했던 클래스 자체를 수정해야 한다.

만약에 상황에 따라 동적으로 비행 방식을 바꿀 수 있다면 기존 클래스와 코드를 수정할 필요 없이 미리 준비된 장치만 교체하는 방식으로 비행 방식을 동적으로 결정할 수 있다.

어떻게 하면 이런 프로그램을 작성할 수 있을까? 바로 추상클래스와 구성 방식을 이용하면 된다.

추상 클래스

추상 클래스(abstract class)는 구현은 되지 않고 언급만 된 메서드를 포함하는 클래스를 의미한다. 구현이 되지 않은 메서드를 추상 메서드(abstract method)라 부른다. 추상 클래스와 추상 메서드를 선언하는 기본 과정은 다음과 같다.

  • abc 모듈로부터 ABC 클래스와 abstractmethod 장식자를 불러온다.
  • ABC 클래스를 상속한다.
  • @abstractmethod 장식자를 이용하여 추상 메서드를 선언한다.

예를 들어, 비행 방식과 관련된 Flying 이란 추상 클래스를 아래와 같이 정의하자. fly() 메서드를 추상 메서드로 선언한다.

In [31]:
from abc import ABC, abstractmethod

class Flying(ABC):

    @abstractmethod
    def fly(self):
        pass

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

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

구상 클래스

추상 메서드를 전혀 포함하지 않은 클래스를 구상 클래스(concrete class)라고 부른다. 여기서 구상은 '세부 내용이 구체적이다' 라는 의미로 사용된다. 사실 지금까지 다뤄온 모든 클래스가 구상 클래스이다. 앞으로도 구상 클래스란 표현은 추상 클래스와 구분하는 용도로만 사용할 것이다.

여기서는 Flying 추상 클래스를 상속하는 구상 클래스 세 개를 비행 방식에 따라 구현한다.

  • 로켓 장치 비행
In [33]:
class RocketFlying(Flying):
    def fly(self):
        print("로켓으로 날아요!")
  • 날개 비행
In [34]:
class WingFlying(Flying):
    def fly(self):
        print("날개가 멋져요!")
  • 스스로 비행
In [35]:
class SelfFlying(Flying):
    def fly(self):
        print("스스로 날아요!")

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

이제 FlyingCharacter 클래스 대신에 비행 방식에 따른 클래스를 필요에 따라 사용하면서 동적으로 비행 방식을 바꿀 수 있는 캐릭터를 생성하는 프로그램을 구성을 이용하여 구현한다.

먼저, Character 클래스를 다음과 같이 수정한다.

  • __init__() 메서드: 비행 방삭을 인스턴스 속성으로 지정하는 기능 추가
    • self.flying 인스턴스 변수에 비행 방식 저장.
    • flying 매개변수 인자: Flying 클래스의 객체
  • fly() 메서드: 비행하도록 하는 메서드.
    • 직접 구현하지 않음.
    • 대신에 지정된 Flying 객체에 비행 기능 위임.
  • setFlying() 메서드: self.flying 속성값 변경
    • 동적으로 비행 방식을 바꿀 때 사용
    • 예제: 스스로 비행하다가 로켓 장치 활용 비행으로 변경 가능
In [36]:
class Character(object):
    # 비행 방식을 인스턴스 속성으로 추가
    # 사용하지 않으면 그대로 None으로 둠.
    def __init__(self, name, power, damage, inventory, flying=None):
        self.name = name
        self.power = power
        self.damage = damage
        self.inventory = inventory
        self.flying = flying
        
    # 자기소개 매소드
    def introduction(self):
        print("제 이름은 %s입니다" % self.name)
        print("현재 저의 파워는 %s입니다." % self.power)
        print("저는 공격할 때마다 상대방에게 %s만큼의 손상을 줍니다." % self.damage)
        print("제 수트의 방어력은 %d이며 사용하는 무기는 %s입니다." % \
              (self.inventory['suit'], self.inventory['weapon']))
        
    # 파워 정보 확인 메서드
    def getPower(self):
        return self.power
    
    # 파워 조절 메서드
    def setPower(self, power):
        self.power = self.power + power
        
    # 상대 캐릭터 공격 메서드
    # 둘째 인자로 상대 캐릭터 인스턴스가 사용될 것임.
    def attack(self, other):
        print("%s: %s 공격하기!" % (self.name, other.name))
        # 공격력의 10% 만큼 상대 파워 감소시킴
        attackPower = self.damage * 0.1 
        other.setPower(-attackPower)
        
    # 비행 기능은 사용하는 Flying 객체에 위임
    def fly(self):
        self.flying.fly()
        
    # 비행 방식 변경 지정
    def setFlying(self, flying):
        self.flying = flying

예를 틀어, 토르 캐릭터가 비행 중에 동적으로 비행 방식을 변경하며 비행하도록 만들 수 있다.

In [37]:
thor = Character('토르', '500', '300', {'suit': 200, 'weapon': 'hammer'}, SelfFlying())

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

OOP 기본 디자인 원칙

OOP의 기본 아이디어는 클래스를 활용하는 캡슐화(encapsulation)이다. 지금까지 코드 추상화로 설명하였지만 하나의 클래스 안에 다양한 성분을 포함해서 하나의 객체로 활용할 수 있다는 의미에서 캡슐화라는 표현도 함께 사용한다.

확장성이 높은 프로그램을 구현하려면 적절한 캡슐화를 이용하는 것이며, 가장 기본적인 디자인 원칙은 다음 두 가지이다.

디자인 원칙 1

변하는 부분과 변하지 않는 부분을 구분하라.

Character 클래스를 선언할 때 비행 방식은 캐릭터마다 다양하게 변할 수 있다. 이런 부분까지 하나의 클래스에 포함하기 보다는 독립적으로 다루어야 한다.

디자인 원칙 2

상속보다 구성에 집중하라.

캐릭터를 구현하는 프로그램을 작성할 때 구현 대상을 기능별로 구분하여 어떻게 구성할 것인가에 집중해야 한다. 그러면 클래스와 상속만으로 해결하는 것보다 훨씬 확장성이 높은 프로그램을 구현할 수 있다.

OOP 디자인 패턴

지금까지 설명한 내용은 OOP를 이용하여 확장성이 높은 프로그램을 작성하는 여러 디자인 패턴 중에서 가장 기본적인 내용을 담고 있다. OOP 언어를 이용한 소프트웨어 개발을 제대로 하고 싶다면 디자인 패턴에 대한 깊은 이해가 필수적이다.

OOP 디자인 패턴에 대한 간단한 역사는 위키: 소프트웨어 디자인 패턴에서 읽어볼 수 있으며, 참고서는 아래 책을 추천한다. 비록 자바 언어를 사용하지만 OOP 디자인 패턴의 핵심을 친절하게 설명한다.

  • Head First Design Patterns
    • 저자: 에릭 프리먼 , 엘리자베스 프리먼 , 케이시 시에라 , 버트 베이츠
    • 번역: 서환수
    • 사용 언어: 자바

파이썬, 자바스크립트 등 기타 언어로 작성된 책들도 있다.

연습문제

  1. 구성 방식을 이용하여 Character 클래스가 보다 다양한 기능을 제공하도록 만들어라. 예를 들어, 캐릭터가 사용하는 무기를 동적으로 변경할 수 있도록 만들 수 있다.