타입 객체 패턴, Type Object Pattern
클래스 하나를 인스턴스별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있다.
판타지 RPG에는 '용'이나 '트롤'같은 몬스터 종족이 다양하다. 여기서 종족은 몬스터의 최대 체력을 결정한다. 용은 트롤보다 시작 체력이 높아사 죽이기가 어렵다. 종족은 공격 문구도 결정한다. 종족이 같은 몬스터는 공격하는 방식도 모두 같다.
전형적인 OOP방식
위의 기획을 염두에 두고 코드를 만들어보자. 기획서에 따르면 용, 트롤 등은 모두 몬스터의 일종이다. 이럴 때 객체지향 방식에서는 Monster라는 상위 클래스를 만드는 게 자연스럽다.
class Monster {
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;
protected:
Monster(int startingHealth) : health_(startingHealth) {}
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(50) {}
virtual const char* getAttack() {
return "트롤이 곤봉을 내리칩니다.";
}
};
Monster의 하위 클래스는 몬스터의 최대 체력을 전달하고, getAttack() 을 오버라이드해서 종족에 맞는 공격 문구를 반환한다. 그러나 이런 식으로 몬스터 클래스를 만들다 보면 어느 순간에 슬라임부터 좀비까지 Monster 하위 클래스가 굉장히 많아 져 있을 것이다.
클래스를 위한 클래스
앞에서는 클래스 상속을 구현햇다. 용은 몬스터이고, 게임에 스폰된 용은 용 '클래스'의 인스턴스다. 종족별로 Monster라는 추상 상위 클래스의 하위 클래스를 정의하고, 게임에 스폰된 몬스터를 종족 클래스의 인스턴스로 만든다. 클래스 상속 구조는 다음과 같다.
게임에 스폰된 모든 몬스터 인스턴스의 타입은 몬스터 클래스를 상속받는다. 종족이 많아질수록 클래스 상속 구조도 커진다. 종족을 늘릴 때마다 코드를 추가하고 컴파일해야 하는 문제도 있다.
다른 방법도 있다. 몬스터마다 종족에 대한 정보를 두는 것이다. 종족마다 Monster 클래스를 상속받게 하지 않고, Monster 클래스 하나와 Breed 클래스 하나만 만든다.
이제는 상속 없이 클래스 두 개만으로 해결할 수 있다. 모든 몬스터를 Monster 클래스의 인스턴스로 표현할 수 있다. Breed 클래스에는 종족이 같은 몬스터가 공유하는 정보인 최대 체력과 공격 문구가 들어 있다.
몬스터와 종족을 결합하기 위해 모든 Monster 인스턴스는 종족 정보를 담고 잇는 Breed 객체를 참조한다. 몬스터가 공격 문구를 얻을 떄는 종족 객체 메서드를 호출한다. Breed 클래스는 본질적으로 몬스터 '타입'을 정의한다. 각각의 종족 객체는 개념적으로 다른 타입을 의미한다. 그래서 패턴이름이 '타입 객체'다.
타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는게 장점이다. 코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴 셈이다.
새로 Breed 인스턴스를 만들어 다른 값을 입력하기만 해도 또 다른 종족을 계속 만들 수 있따. 설정 파일에서 읽은 데이터로 종족 객체를 생성하게 만들고 나면, 데이터만으로 전혀 다른 몬스터를 정의할 수 있다.
패턴
타입 객체(Type Object) 클래스와 타입 사용 객체(Typed Object) 클래스를 정의한다. 모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미한다. 타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조한다.
인스턴스별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고, 개념적으로 같은 타입끼리 공유하는 데이터나 동작은 타입 객체에 저장한다. 같은 타입 객체를 참조하는 타입 사용 객체는 같은 타입인 것처럼 동작한다. 이러면 상속 처리를 하드코딩하지 않고서도 마치 상속받는 것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.
예제 코드
첫 단계로 앞에서 본 시스템을 기본만 간단하게 구현해보자. 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.hetHealth()),
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();
상속으로 데이터 공유하기
지금까지 다룬 것만 해도 쓸 만한 타입 객체 시스템을 만들기에는 충분하다. 하지만 이건 기본일 뿐이다. 게임을 개발하다 보면 종족이 수백 개가 넘어가고 속성도 훨신 많아질 것이다. 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을 전달한다.
하위 객체는 어떤 속성을 상위 객체로부터 받을지, 자기 값으로 오버라이드할지를 제어할 수 있어야 한다. 예제에서는 최대 체력이 0이 아닐 때, 공격 문구가 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();
}
이 방법은 종족이 특정 속성 값을 더 이상 오버라이드하지 않거나 상속받지 않도록 런타임에 바뀔 때 좋다. 메모리를 더 차지하고(상위 객체 포인터를 유지해야 한다.), 속성 값을 반화할 때마다 상위 객체들을 줄줄이 확인해보느라 더 느리다는 단점이 있다.
종족 속성 값이 바뀌지 않는다면 생성 시점에 바로 상속을 적용할 수 있다. 이런걸 '카피다운' 위임이라고 한다. 객체가 생성될 때 상속받는 속성 값을 하위 타입으로 복사해서 넣기 때문이다.
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();
}
}
더 이상 상위 종족 객체를 포인터로 들고 있지 않아도 된다.
이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/type-object.html 입니다.
by 소년코딩
추천은 글쓴이에게 큰 도움이 됩니다.
악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.