기록공간

12장. 행동 패턴 - 타입 객체 본문

Game Design Pattern, Logic

12장. 행동 패턴 - 타입 객체

입코딩 2020. 6. 14. 19:33
반응형
클래스 하나를 인스턴스 별로 다른 객체형으로 표현할 수 있게 만드어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

의도

개발 중인 판타지 RPG에서, 포악한 몬스터 무리를 구현해야 한다고 해보자. 몬스터는 체력, 공격력, 그래픽 리소스, 사운드 등 다양한 속성이 있지만 여기서는 체력과 공격 속성만을 고려한다.

 

모든 몬스터에는 체력 값이 있다. 체력은 최대 체력에서 시작해 피해를 입을 때마다 조금씩 줄어든다. 몬스터에게는 공격 문구(attack string) 속성도 있다. 몬스터가 영웅을 공격할 때, 이 공격 문구는 유저에게 표시된다.

 

기획자는 '용'이나 '트롤'같이 몬스터 종족(breed)을 다양하게 만들고 싶어 한다. 각 종족은 몬스터의 특징을 나타내고, 던전에는 같은 종족 몬스터가 여러 마리 동시에 돌아다닐 수 있다.

 

종족은 몬스터의 최대 체력을 결정한다. 용은 트롤보다 시작 체력이 높아서 죽이기 어렵다. 종족은 공격 문구도 결정한다. 종족이 같은 몬스터는 공격하는 방식이 모두 같다.

 

전형적인 OOP 방식

위와 같은 기획을 염두에 두고 코드를 만들어보자. 기획서에 따르면 용, 트롤 등은 모두 몬스터의 일종이다. 이럴 때 객체지향 방식에서는 Monster라는 상위 클래스를 만드는 것이 더 자연스럽다. 

class Monster
{
public:
    virtaul ~Monster();
    virtual const char* getAttack() = 0;
    
protected:
    Monster(int straightHealth) : health_(straightHealth) {}
    
private:
    int health_; // 현재 체력
}

public에 있는 getAttack()은 몬스터가 영웅을 공격할 때 보여줄 문구를 반환한다. 하위 클래스는 이 함수를 오버라이드 해서 다른 공격 문구를 보여준다.

 

생성자는 protected이고 몬스터의 최대 체력을 인수로 받는다. 각각의 종족을 정의한 하위 클래스에서는 public 생성자를 정의해 상위 클래스의 생성자를 호출하면서 종족에 맞는 최대 체력을 인수로 전달한다.

 

다음 코드는 종족을 표현한 하위 클래스들이다.

class Dragon : public Monster
{
public:
    Dragon() : Monster(230) {}
    virtual const char* getAttack() 
    {
        return "용이 불을 뿜습니다!";
    }
};

class Troll : public Monster
{
public:
    Troll() : Monster(48) {}
    virtual const char* getAttack()
    {
        return "트롤이 당신을 곤봉으로 내리칩니다!"
    }
}

Monster의 하위 클래스는 몬스터의 최대 체력을 전달하고, getAttack()을 오버라이드해서 종족에 맞는 공격 문구를 반환한다. 지금까지는 모든 게 계획대로 잘 이루어지고 있다. 

 

하지만 이런 식으로 몬스터 클래스를 만들다 보면 어느 순간에 Monster의 하위 클래스가 굉장히 많아져 있을 것이다.

 

이쯤 되면 작업이 이상하게 느려진다. 몬스터를 수백 종 이상 만드는 게 목표다 보니, 프로그래머들은 하루 종일 몇 줄 안 되는 Monster 하위 클래스를 계속 작성한 후 컴파일해야 한다. 이런 상황은 기획자들이 이미 만들어놓은 종족을 다듬으려 할 때 더 나빠진다. 이전에는 좋았던 작업이 생산성이 어떤 식으로 악화되는지 사례로 보도록 하자.

 

  1. 기획자로부터 트롤 최대 체력을 48에서 52로 바꿔달라고 부탁받는다.

  2. Troll.h 파일을 체크아웃한 뒤 수정한다.

  3. 컴파일한다.

  4. 변경사항을 체크인한다.

  5. 이메일에 답장한다.

  6. 1부터 5를 반복한다.

프로그래머들은 데이터 몽키(생각 없이 데이터만 입력하는 사람)가 되어버린 자기 자신에게 좌절한다. 기획자들 역시 숫자 몇 개 만 바꾸는 데에 하루 종일 걸리는 것에 좌절한다.

 

종족 상태 값은 게임 코드를 빌드하지 않고 변경할 수 있어야 한다. 더 나아가 프로그래머 도움 없이 기획자가 새로운 종족을 만들고 값을 수정할 수 있어야 한다.

 

클래스를 위한 클래스

위 내용만 놓고 보면 해결하고자 하는 문제는 간단한다. 게임에 몬스터가 여러 종 있으니 몇몇 속성은 여러 몬스터가 공유하게 만들면 된다. 예를 들어 몬스터가 영웅을 공격할 때 일부 몬스터들은 같은 공격 문구를 공유해서 보여주고 싶다. 이런 몬스터들은 '종족'이라고 정의하고, 종족이 같으면 공격 문구를 같게 만든다.

 

앞에서는 이 개념을 클래스 상속으로 구현했다. 종족을 Monster라는 추상 상위 클래스의 하위 클래스로 정의하고, 게임에 스폰된 몬스터를 종족 클래스의 인스턴스로 만든다. 클래스 상속 구조는 다음과 같다.

 

하위 클래스가 너무 많다.

게임에 스폰된 모든 몬스터 인스턴스의 타입은 몬스터 클래스를 상속받는다. 종족이 많아질수록 클래스 상속 구조도 커진다. 종족을 늘릴 때마다 코드를 추가하고 컴파일해야 하는 문제도 있다.

 

다른 방법도 있다. 몬스터마다 종족에 대한 정보를 두는 것이다. 종족마다 Monster 클래스를 상속받게 하지 않고, Monster 클래스 하나와 Breed 클래스 하나만 만든다.

 

클래스 두 개로 무한애의 종족을 표현한다.

자, 이제 상속 없이 클래스 두 개만으로 해결할 수 있다. 모든 몬스터를 Monster 클래스의 인스턴스로 표현할 수 있다. Breed 클래스에는 종족이 같은 몬스터가 공유하는 정보인 최대 체력과 공격 문구가 들어 있다.

 

몬스터와 종족을 결합하기 위해 모든 Monster 인스턴스는 종족 정보를 담고 있는 Breed 객체를 참조한다. 몬스터가 공격 문구를 얻을 때 종족 객체 메서드를 호출한다. Breed 클래스는 본질적으로 몬스터 '타입'을 정의한다. 각각의 종족 객체는 개념적으로 다른 타입을 의미한다. 그래서 패턴 이름이 '타입 객체'이다.

 

타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는 것이 장점이다. 코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴 셈이다.

 

새로 Breed 인스턴스를 만들어 다른 값을 입력하기만 해도 또 다른 종족을 계속 만들 수 있다. 설정 파일에서 읽은 데이터로 종족 객체를 생성하게 만들고 나면, 데이터만으로 전혀 다른 몬스터를 정의할 수 있다. 이제는 기획자도 쉽게 새로운 몬스터를 만들 수 있다.

 

예제 코드

기본만 간단하게 구현해보도록 하겠다. Breed 클래스부터 보자.

class Breed
{
public:
    Breed(int health, const char* attack)
    : health_(health), attack_(attack) {}
    
    int getHealth() { return health_; }
    const char* getAttack() { return attack_; }
    
private:
    int health_;  // 최대 체력
    const char* attack_;
};

Breed 클래스에는 최대 체력(health_)과 공격 문구(attack_) 멤버 변수 두 개만 있다. Monster 클래스에서 Breed 클래스를 어떻게 쓰는지 보자.

class Monster
{
public:
    Monster(Breed& breed)
    : health_(breed.getHealth()), breed_(breed) {}
    
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    int health_; // 현재 체력
    Breed& breed;
};

Monster 클래스 생성자는 Breed 객체를 레퍼런스로 받는다. 이를 통해 상속 없이 몬스터 종족을 정의한다. 최대 체력은 생성자에게 breed 인수를 통해 얻는다. 공격 문구는 breed_에 포워딩해서 얻는다. 

 

여기까지가 타입 객체 패턴의 핵심이다. 

 

생성자 함수를 통해 타입 객체를 좀 더 타입같이 만들기

이제까지는 몬스터를 만들고 그 몬스터에 맞는 종족 객체도 직접 전달했다. 이런 방식은 메모리를 먼저 할당한 후에 그 메모리 영역에 클래스를 할당하는 것과 다를 바 없다. 대부분 OOP 언어에서는 이런 식으로 객체를 만들지 않는다. 대신, 클래스 생성자를 호출해 클래스가 알아서 새로운 인스턴스를 생성하게 한다.

 

타입 객체도 이 패턴을 적용할 수 있다.

class Breed
{
public:
    Monster* newMonster()
    { return new Monster(*this); }
    
    // 나머지는 동일.
;

Monster 클래스는 다음과 같이 바뀐다.

class Monster
{
    friend class Breed;
    
public:
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    Monster(Breed& breed);
    : health_(breed.getHealth()),
      breed_(breed) {}
    
    int health_; // 현재 체력
    Breed& breed_;
}

가장 큰 차이점은 Breed 클래스의 newMonster 함수이다. 이게 팩토리 메서드 패턴의 '생성자'이다.

 

이전 코드에서는 몬스터를 다음과 같이 생성했다.

Monster* monster = new Monster(someBreed);

수정하고 나면 다음과 같다.

Monster* monster = someBreed.newMonster();

이러면 무엇이 좋아지는 걸까? 객체는 메모리 할당과 초기화 2단계로 생성된다. Monster 클래스 생성자 함수에서는 필요한 모든 초기화 작업을 다 할 수 있다. 예제에서는 breed 객체를 전달하는 게 초기화의 전부이지만, 실제 프로젝트에서는 그래픽을 로딩하고, 몬스터 AI를 설정하는 등 다른 초기화 작업이 많이 있을 수 있다.

 

하지만 이런 초기화 작업은 메모리를 할당한 다음에 진행된다. 아직 제대로 초기화되지 않은 몬스터가 메모리에 올라가 있는 것이다. 게임에서는 객체 생성 과정을 제어하고 싶을 때가 종종 있다. 그럴 때는 보통 커스텀 할당자나 객체 풀 패턴을 이용해 객체가 메모리 어디에 생성될지를 제어한다.

 

Breed 클래스에  '생성자' 함수를 정의하면 이런 로직을 둘 곳이 생긴다. 그냥 new를 호출하는 게 아니라 newMonster 함수를 호출하면 Monster 클래스에 초기화 제어권을 넘겨주기 전에 메모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다. 몬스터를 생성할 수 있는 유일한 곳인 Breed 클래스 안에 이런 로직을 두어, 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라 생성되도록 강제할 수 있다.

 

상속으로 데이터 공유하기

지금까지 예제로 개발을 하기에 충분할지 모르겠다. 하지만 게임 개발을 하다 보면 종족이 수백 가지가 넘어가고 속성도 훨씬 많아질 것이다. 기획자가 30개가 넘는 트롤 종족을 더 강하게 만들어달라 하면, 상당히 많은 데이터를 반복해서 고쳐야 한다. 

 

이럴 땐 종족을 통해 여러 몬스터가 속성을 공유했던 것처럼 여러 종족이 속성 값을 공유할 수 있게 만들면 좋다. 맨 처음 본 OOP 방식처럼 상속을 사용하여 속성 값을 공유할 수 있다. 여기서는 상속 기능이 아닌 타입 객체끼리 상속할 수 있는 시스템을 구현한다.

 

간단하게 단일 상속만 지원해보자. 클래스가 상위 클래스를 갖듯, 종족 객체도 상위 종족 객체를 가질 수 있게 만든다.

class Breed
{
public:
    Breed(Breed* parent, int health, const char* attack)
     : parent_(parent), health_(health), attack_(attack)
     {}
    
    int getHealth();
    const char* getAttack();
    
private:
    Breed* parent_; // 상위 종족
    int health_; // 최대 체력
    const char* attack_;
};

Breed 객체를 만들 땐 상속받을 종족 객체를 넘겨준다. 상위 종족이 없는 최상위 종족은 parent에 NULL을 전달한다.

 

하위 객체는 어떤 속성을 상위 객체로부터 받을지, 자기 값으로 오버라이드 할지를 제어할 수 있어야 한다. 두 가지 방식으로 구현할 수 있다.

 

속성 값을 요청받을 때마다 동적으로 위임하는 방식부터 살펴보자.

int Breed::getHealth()
{
    // 오버라이딩
    if(health_ != 0 || parent_ == NULL) 
    { return health_; }
    
    // 상속
    return parent_->getHealth();
}

const char* Breed:getAttack()
{
    // 오버라이딩
    if(attack_ != NULL || parent_ == NULL)
    { return attack_; }
    
    // 상속
    return parent_->getAttack();
}

이 방법은 종족이 특정 속성 값을 더 이상 오버라이드 하지 않거나 상속받지 않도록 런타임에 바뀔 때 좋다. 하지만 메모리를 더 차지하고, 속성 값을 반환할 때마다 상위 객체들을 줄줄이 확인해보느라 더 느리다는 단점이 있다.

 

종족 속성 값이 바뀌지 않는다면 생성 시점에 바로 상속을 적용할 수 있다. 이런 걸 '카피다운(copy-down)'위임이라고 한다. 객체가 생성될 때 상속받는 속성 값을 하위 타입으로 복사해서 넣기 때문이다.

Breed(Breed* parent, int health, const char* attack)
 : health_(health), attack_(attack)
 {
    // 오버라이드 하지 않고 속성만 상속받는다.
    if(parent != NULL)
    {
        if(health == 0)
            health_ = parent->getHealth();
        if(attack == NULL)
            attack_ = parent->getAttack();
    }
 } 

더 이상 상위 종족 객체를 포인터로 들고 있지 않아도 된다. 생성자에서 상위 속성을 전부 복사했지 때문에 더 이상 신경 쓰지 않아도 된다. 종족 속성 값을 반환할 때에는 필드 값을 그대로 쓰면 된다. 

int getHealth() { return health_; }
const char* getAttack() { return attack_; }

훨씬 깔끔하고 빠르다.

 

게임에서 JSON 파일로 종족을 정의한다고 해보자.

{
    "트롤" : {
        "체력" : 25,
        "공격문구" : "트롤이 당신을 때립니다!"
    },
    "트롤 궁수" : {
        "부모" : "트롤",
        "체력" : 25,
        "공격문구" : "트롤 궁수가 활을 쏩니다!"
    },
     "트롤 마법사" : {
        "부모" : "트롤",
        "체력" : 0,
        "공격문구" : "트롤 마법사가 마법 공격을 합니다!"
    }
}

이 코드는 종족 데이터를 읽어 새로운 종족 인스턴스를 만든다. "부모": "트롤" 필드에서 볼 수 있듯이, 트롤 궁수와 트롤 마법사 종족은 트롤 종족으로부터 값을 상속받는다.

 

둘 다 체력이 0이기 때문에 상위 종족인 트롤로부터 얻는다. 즉, 기획자가 트롤 종족의 체력만 바꾸면 세 가지 트롤 종족 체력을 전부 바꿀 수 있다. 종족과 종족별 속성 개수가 늘어날수록 상속으로 시간을 많이 아낄 수 있다. 얼마 안 되는 코드로 기획자가 자유롭게 제어할 수 있는 열린 시스템을 만들었다. 기획자는 시간을 최대한 활용할 수 있게 되었고, 프로그래머도 다른 기능 구현에 집중할 수 있게 되었다.


자료의 출처는 http://gameprogrammingpatterns.com/type-object.html 입니다.

반응형
Comments