2024. 7. 1. 15:26ㆍGame Develop/Design Pattern
타입 객체 패턴은 클래스 하나를 인스턴스별로 다른 개체형으로 표현할 수 있게 만들어 새로운 클래스들을 유연하게 만들 수 있게 하는 디자인 패턴이다.
RPG 게임을 개발한다고 해보자. 우리는 몬스터를 구현하기 위해서 체력, 공격, 그래픽 리소스, 사운드 등 다양한 속성을 정의해야한다. 모든 몬스터는 위의 속성들을 무조건 가지고 있다.
기획자는 용이나 트롤 등과 같은 다양한 몬스터 종족을 만들고 싶어한다. 각 종족은 몬스터의 특징을 나타내고 또 필드에는 같은 종족 몬스터가 여러 마리 동시에 돌아다닐 수 있다.
종족은 몬스터의 최대 체력을 결정한다. 따라서 용은 트롤보다 최대 체력이 더 높기 때문에 죽이기가 훨씬 힘들다. 또한 종족이 같은 몬스터들은 공격하는 방식도 모두 같아야한다.
자 이제 전형적인 객체 지향 프로그래밍으로 코드를 작성해보자. 일단 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(48) {}
virtual const char* getAttack(){
return "트롤이 당신을 곤봉으로 내려칩니다!";
}
};
위 처럼 Monster의 하위 클래스는 몬스터의 최대 체력을 전달해 getAttack()을 오버라이드 해서 종족에 맞는 공격 문구를 반환한다.
이렇게 하면 문제가 없을 것 같지만 Monster 하위 클래스가 굉장히 많아진다면 작업이 이상하게 느려질 것이다.
몬스터 수가 많아지게 되면 프로그래머들은 하루 종일 몇 줄 안되는 Monster 하위 클래스를 계속 작성한 후 컴파일해야 한다. 게다가 기존에 있던 종족을 수정하려고 하면 더 안좋아 진다. 같은 종족의 다른 몬스터들의 특성을 전부 바꿔야하기 때문이다.
대충 이런 식이다.
- 기획자로부터 트롤의 최대 체력을 48에서 52로 바꿔달라는 이메일을 받는다.
- Troll.h 파일을 체크아웃한 뒤에 수정한다.
- 컴파일한다.
- 변경사항을 체크인한다.
- 이메일에 답장한다.
- 1부터 5를 계속 반복한다.
이런 시스템은 너무 비효율 적이다. 따라서 우리는 종족 상태 값 같은 것은 게임 코드를 빌드하지 않고도 변경할 수 있게해야하고 더 나아가 프로그래머 도움 없이 기획자가 새로운 종족을 만들고 값을 수정할 수 있게 해야한다.
게임에는 수십수백 종의 몬스터 종족이 있고 모든 몬스터는 Monster라는 상위 클래스를 상속 받는다.
종족이 많아질수록 하위 클래스가 너무 많아서 클래스 상속 구조가 커지기 때문에 관리가 힘들다. 코드를 추가하고 컴파일해야하는 것은 덤이다.
다른 방법은 무엇일까? 몬스터마다 종족에 대한 정보를 두는 것이다. 종족마다 Monster 클래스를 상속받게 하지 않고, Monster 클래스 하나와 Breed 클래스 하나만 만든다.
이 클래스 두 개로도 무수히 많은 종족을 표현할 수 있다.상속도 필요 없다. 모든 몬스터를 Monster 클래스의 인스턴스로 표현할 수 있다.
몬스터와 종족을 결한하기 위해 모든 Monster 인스턴스는 종족 정보를 담고있는 Breed 객체를 참고만하면 된다.
몬스터가 공격 문구를 얻을 때는 종족 객체 메서드를 호출한다. Breed 클래스는 기본적으로 몬스터 타입을 의미한다.
그래서 패턴 이름이 타입 객체다.
타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는 게 장점이다.
코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴 셈이다.
새로 Breed 인스턴스를 만들어 다른 값을 입력하기만 해도 또 다른 종족을 계속 만들 수 있다.
이렇다면 기획자도 쉽게 몬스터를 만들 수 있게 되었다.
자 이제 본격적으로 패턴 적용을 해보자.
먼저 타입 객체 클래스와 타입 사용 객체 클래스를 정의한다. 모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미한다. 타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조한다.
좀 더 구체적으로 말하자면 각 객체 마다 다른 데이터는 그 객체 안에 저장하지만, 개념적으로 같은 타입끼리 공유하는 데이터나 동작(예: getAttack() 등)은 타입 객체에 저장한다. 이렇게 하면 상속 처리를 하드코딩하지 않고서도 마치 상속받는 것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.
자 그렇다면 이 패턴은 언제 써야할까? 대표적으로 두 가지 경우가 있다.
- 나중에 어떤 타입이 필요할지 알 수 없다(새로운 몬스터가 등장하는 DLC를 제공해야 할지도 모른다).
- 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.
위 두 경우 처럼 다양한 종류를 정의해야 하는데 개발 언어의 타입 시스템이 유연하지 않아 코드로 표현하기 어려울 때 적합하다. 두 가지 중 하나만 해당 하더라도 사용해봄직 하다.
다만 몇 가지 주의사항이 있다. 표현력은 좋지만, 타입 코드가 아닌 데이터로 표현하면서 잃는 것도 있다.
1. 사용 언어나 플랫폼에 따라 다를 수 있겠지만, 이 패턴을 사용하게 되면 타입 객체 또한 우리가 직접 관리해야한다.
타입 객체를 생성하고, 이를 필요로 하는 몬스터가 있는 한 메모리에 유지해야 한다. 몬스터 인스턴스를 생성할 떄 알맞은 종족 객체 레퍼런스로 초기화하는 것도 우리의 몫이다.
2. 타입벼로 동작을 표현하기가 더 어려워진다.
상속 방식에서는 메서드를 오버라이드하거나 다른 코드를 호출하는 등 마음대로 할 수 있다.
하지만 타입 객체 패턴에서는 Monster 클래스를 상속받아 메서드 오버라이드로 공격 문구를 표현하는게 아니라 종족 객체 변수에 공격 문구를 저장하는 식으로 표현한다.
타입 객체로 타입 종속적인 데이터를 정의하기는 쉽지만 타입 종속적인 동작을 정의하기는 어렵다. 종족이 다른 몬스터에게는 다른 AI 알고리즘을 적용하고 싶다면? 타입 객체로는 구현하기가 더 어렵다.
다행이게도 이런 한계를 우회할 몇 가지 방법이 있다. 가장 간단한 방법은 미리 동작 코드를 여러 개 정의 해놓은 뒤에 타입 객체 데이터에서 이 중 하나를 선택하는 것이다. 그 다음 타입 객체가 적당한 함수 포인터를 저장하게 하면 타입 객체를 AI 알고리즘과 연계할 수 있다.
더 나아가 데이터만으로 동작을 정의할 수도 있는데, 앞전의 바이트코드 패턴과 GoF의 인터프리터 패턴을 이용하면 동작을 표현하는 객체를 만들 수 있다. 파일에서 데이터를 읽어 이들 패턴으로 자료구조를 만들면 동작 정의를 코드에서 데이터로 완전히 옮길 수 있다.
코드를 살펴보자.
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_;
};
Brred 클래스에는 최대 체력과 공격 문구 두 개만 있다. Monster 클래스에서 Breed 클래스를 어떻게 쓰는 지도 살펴보자.
class Monster{
public:
Monster(Breed& breed) : helath_(breed.getHealth()), breed_(breed){}
const char* getAttack() { return breed_.getAttack(); }
private:
int health_;
Breed& breed_;
};
Monster 클래스 생성자는 Breed 객체를 참조한다. 이를 통해 상속 없이 몬스터 종족을 정의할 수 있게 됐다.
이것이 타입 객체 패턴의 핵심이다.
이제까지는 몬스터를 만들고 그 몬스터에 맞는 종족 객체도 직접 전달해주었다. 대신 이제부터는 클래스의 생성자 함수를 호출해 클래스가 알아서 새로운 인스턴스를 생성하게 하면 어떨까?
타입 객체에도 이 패턴을 적용할 수 있다.
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) : helath_(breed.getHealth()), breed_(breed){}
int health_;
Breed& breed_;
};
가장 큰 차이점은 Breed 클래스의 newMonster 함수다. 이게 Gof의 팩토리 메서드 패턴의 생성자다.
이전 코드와 현재 코드를 비교해보자.
Monster* monster = new Monster(someBreed); // 수정 전
Monster* monster = someBreed.newMonster(); // 수정 후
이렇게 되면 가장 좋은건 초기화를 한 번만 하면 된다는 것이다.수정 전의 코드는 위의 예제 코드보다 훨씬 다양한 메서드와 데이터가 들어있을 것이고, 이들 중 하나라도 정확하게 초기화 되어있지 않다면 어떤 문제를 불러일으킬 지 알 수 없다.
따라서 Breed 클래스에 생성자 함수를 정의하면 이런 로직을 둘 곳이 생긴다. new를 호출하는 것이 아니라 newMonster 함수를 호출함으로써 Monster 클래스에 초기화 권한을 넘겨주기 전에 메모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다. 이렇게 몬스터를 생성할 수 있는 유일한 곳인 Breed 클래스 안에 이런 로직을 둠으로써, 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라 생성되도록 강제할 수 있다.
게임을 개발하다 보면 종족이 수백 개가 넘어가고 속성도 훨씬 많아질 것이다. 만약 기획자가 수십 종의 트롤 종족을 더 강하게 만들어 달라고 한다면, 그만큼 많은 데이터를 반복해서 고쳐야한다. 이럴 때도 역시 여러 종족이 속성 값을 공유할 수 있게 만들면 좋다. 상속이 대표적인 방법이다. 하지만 이는 조금 다른 상속인데 프로그래밍 언어의 상속 기능이 아닌 타입 객체끼리 상속할 수 있는 시스템을 직접 구현해야 한다.
클래스가 상위 클래스를 갖는 것처럼 종족 객체도 상위 종족 객체를 가질 수 있게 해보자.
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){
reutrn 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();
}
}
더 이상 상위 종족 객체를 포인터로 들고 있지 않아도 된다.생성자에서 상위 속성을 전부 복사했기 때문에 더이상 신경 쓰지 않아도 된다. 종족 속성 값을 반환할 때에도 그대로 반환하면 된다.
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
훨씬 깔끔하고 빠르다!
만약 게임에서 JSON 파일로 종족을 정의한다고 해보자
{
"트롤"{
"체력": 25,
"공격문구": "트롤이 당신을 때립니다!"
},
"트롤 궁수"{
"부모": "트롤",
"체력": 0,
"공격문구": "트롤 궁수가 활을 쏩니다!"
},
"트롤 마법사"{
"부모": "트롤",
"체력": 0,
"공격문구": "트롤 마법사가 마법 공격을 합니다!"
}
}
위는 종족 데이터를 읽어 새로운 종족 인스턴스를 만든다. 트롤 궁수, 마법사 모두 트롤 종족으로부터 값을 상속받는다.
이 때 둘 다 체력이 0이기 때문에, 체력은 상위 종족인 트롤로부터 얻는다.종족과 종족별 속성 개수가 늘어날수록 상속으로 시간을 많이 아낄 수 있다. 이렇게하면 기획자도 자유롭게 제어할 수 있는 열린 시스템을 다룰 수 있게 된다.
자 위와 같이 타입 객체 패턴은 설계의 폭이 넚고 여러 가지 재미있는 시도를 해볼 수 있다.
하지만 현실적으로는 몇몇 이유 때문에 가능성이 많이 제한되기도 한다. 시스템이 복잡하면 개발 기간이 늘어나고 유지보수하기 어려워진다. 또한 타입 객체 패턴은 주 사용층이 프로그래머가 아닌 경우가 많아 이해하기 쉬워야한다.
따라서 우리는 검증된 방식만 살펴보자.
타입 객체를 숨길 것인지 노출할 것인지는 어떻게 결정해야할까
앞에서 Monster 클래스는 Breed 객체를 참조하지만 이를 외부에 노출하지 않아 외부 코드에서 몬스터 종족 객체를 직접 접근할 수 없었다.
class Monster{
public:
Breed& getBreed() { return breed_; }
// 나머지 코드
};
이러면 Monster 클래스의 설계가 바뀐다. 모든 몬스터에 종족 객체가 있다는 사실이 공개 API에 포함된다. 둘 다 장단점이 있다.
| 타입 객체를 캡슐화하면 |
- 타입 객체 패턴의 복잡성이 나머지 다른 코드에는 드러나지 않는다. 이런 구현 상세는 타입 사용 객체에서만 고민하면 된다.
- 타입 사용 객체는 타입 객체로부터 동작을 선택적으로 오버라이드할 수 있다. 몬스터가 거의 죽어갈 때 다른 공격 문구를 보여주고 싶다면 코드만 작성해서 넣으면 된다.
- 타입 객체 메서드를 전부 포워딩해야 한다. 타입 객체 클래스에 메서드가 많다면 타입 사용 객체 크래스에서 외부에 공개할 메서드 전부에 대해 포워딩 메서드를 만들어야한다.
| 타입 객체를 노출하면 |
- 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있다. 이래야만 앞서 본 생성자 타입에서처럼 타입 사용 객체 없이도 타입 객체인 Breed의 메서드를 호출해 새로운 몬스터를 생성할 수 있다.
- 타입 객체가 공개 API의 일부가 된다. 일반적으로 인터페이스를 적게 노출할수록 복잡성은 줄어들고 유지보수하기는 좋아진다. 타입 객체를 노출함으로써, 타입 객체가 제공하는 모든 것이 객체 API에 포함된다.
타입 사용 객체를 어떻게 생성할까
타입 객체 패턴에서 객체는 타입 객체와 타입 사용 객체의 쌍으로 존재한다. 이 둘은 어떻게 생성하고 결합하는 게
| 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우 |
- 외부 코드에서 메모리 할당을 제어할 수 있다. 두 객체 모두 외부에서 생성하기 때문에, 메모리 어디에서 할당할지를 결정할 수 있다. 덕분에 객체를 여러 다른 메모리에 둘 수 있다.
| 타입 객체의 생성자 함수를 호출하는 경우 |
- 타입 객체에서 메모리 할당을 제어한다. 위와는 반대 개념이다. 외부에서 타입 객체를 어느 메모리에 생성할지 선택권을 주고 싶지 않다면, 타입 객체 팩토리 메서드를 통해서 객체를 만들게 해 메모리를 제어할 수 있다.
타입을 바꿀 수 있을까
지금까지는 한 번 객체가 만들어지고 나면 연결된 타입 객체가 절대 바뀌지 않는다고 가정했다. 객체가 필요하면 타입을 변경하게 할 수도 있다.
어떤 몬스터는 죽은 뒤에 좀비로 되살아나게 하고 싶다고 해보자. 물론 몬스터가 죽을 때 종족이 좀비인 몬스터를 새로 만들 수도 있지만, 기존 몬스터의 종족을 좀비로 바꾸는 방법도 있다.
| 타입을 바꿀 수 없다면 |
- 코드를 구현하고 이해하기가 더 쉽다. 개념상 '타입'은 바뀌지 않는다고 생각하는데 코드로 못을 박는다.
- 디버깅하기 쉽다. 몬스터의 상태가 이상해지는 버그를 찾아야 할 때 종족은 변하지 않는다고 가정할 수 있다면 일이 한결 수월하다.
| 타입을 바꿀 수 있다면 |
- 객체 생성 횟수가 줄어든다.타입을 바꿀 수 있다면 타입 객체 포인터 값만 바꾸면 된다.
- 가정을 깨지 않도록 주의해야 한다. 타입 사용 객체와 타입 객체는 상당히 강하게 커플링된다. 종족 타입을 바꿀 수 있다면 기존 객체 상태를 새로운 타입의 요구사항에 맞춰야한다. 이를 위해 검증 코드가 필요할 수도 있다.
상속을 어떻게 지원할까
| 상속 없음 |
- 단순하다. 단순한 게 최고일 때가 많다. 타입 객체끼리 공유해야 할 데이터가 그리 많지 안핟면 코드를 복잡하게 만들 필요가 없다.
- 중복 작업을 해야할 수도 있다. 나는 콘텐츠 저작 툴에 상속 개념이 들어가는 걸 싫어하는 기획자를 아직 보지 못했다.
| 단일 상속 |
- 그나마 단순한 편이다. 구현하기도 쉽지만 이해하기 쉽다는 게 더 중요하다. 단일 상속이 상속의 장점을 취하면서도 코드가 너무 복잡해지지 않는 균형점이라고 보는 시각이 많기 때문이다.
- 속성 값을 얻는 데 오래 걸린다. 타입 객체로부터 원하는 데이터를 얻으려면 실제로 값을 정의한 타입을 찾을 때까지 상속 구조를 타고 올라가야 한다. 성능이 민감한 코드에서 이런 식의 런타임 낭비는 바람직하지 않다.
| 다중 상속 |
- 거의 모든 데이터 중복을 피할 수 있다. 제대로 된 다중 상속 시스템에서는 사용자가 타입 객체 상속 구조만 잘 만들면 데이터 중복을 거의 제거할 수 있다. 수많은 복사 & 붙여넣기 없이도 수치를 조정할 수 있다.
- 복잡하다. 다중 상속의 장점은 실무보다는 이론에 가깝다. 다중 상속은 이해하기가 더 어렵다.
'Game Develop > Design Pattern' 카테고리의 다른 글
Subclass Sandbox Pattern(하위 클래스 샌드박스 패턴) (0) | 2024.06.27 |
---|---|
Update Method Pattern(업데이트 메서드 패턴) (0) | 2024.06.25 |
Bytecode Pattern(바이트코드 패턴) (0) | 2024.06.24 |
Game Loop Pattern(게임 루프 패턴) (0) | 2024.06.17 |
Double Buffer Pattern(이중 버퍼 패턴) (1) | 2024.06.15 |