이론/디자인패턴

타입 객체

tsyang 2022. 2. 10. 01:56

왜 씀?


타입 객체 패턴은 다양한 '종류'를 정의할 때 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 변경할 수 있도록 해준다.

 

판타지 배경의 RPG 게임을 만든다고 하자. 이 게임의 몬스터는 여러 종족으로 나뉜다. 종족에 따라 최대 체력, 공격 속성 등이 달라진다.

 

OOP 방식으로 구현

고블린, 오크, 용 등은 모두 몬스터의 일종이다. 따라서 몬스터라는 상위 클래스를 만드는 게 자연스럽다. (IS-A 관계)

 

class Monster
{
public:
	virtual ~Monster(){}

	//공격 문구를 받아온다.
	virtual std::string attack() = 0;
};

 

 

하위 몬스터는 다음과 같이 구현될 것이다.

class Goblin : public Monster
{
public:
	std::string getAttack() override
	{
		return "고블린의 공격";
	}
};

 

여기까지는 문제가 없어 보인다. 그런데 몬스터가 수백 종씩 생기면서 문제가 생긴다. 몬스터의 하위 클래스가 수백 가지가 되고 기존에 만든 종족을 수정하려 할 때도 일일이 컴파일을 해야 한다.

 

종족별 상태 값은 게임을 빌드하지 않고도 변경할 수 있는 게 좋다. 더 나아가 프로그래머 없이 기획자가 새로운 종족을 만들 수 있다면 더 좋을 것이다.

 

 

 

타입 객체

상속을 하는 대신 종족 객체를 만들자.

 

모든 몬스터는 Monster 클래스이다. 모든 몬스터들이 사용하는 종족 정보는 Breed 클래스에 들어있다. Monster 클래스는 Breed 인스턴스를 참조한다. 이렇게 Breed 클래스처럼 타입을 정의하는 객체를 타입 객체라고 한다.

class Breed
{
public :
	Breed(int hp, const std::string & attackString) : health_(hp), attack_(attackString){}
	int getHp() { return health_; }
	const std::string& getAttack() { return attack_; }

private:
	int health_;
	const std::string& attack_;
};

class Monster
{
public:
	Monster(Breed& breed) : breed_(breed), health_(breed.getHp()){}
	const std::string& getAttack() {return breed_.getAttack();}

private:
	int health_;
	Breed& breed_;
};

 

이렇게 되면 데이터 파일을 읽어 종족 객체를 생성하게 할 수 있으며 데이터만으로 새로운 몬스터를 정의할 수 있다.

 

혹은 팩토리 메서드 패턴을 적용해서

class Breed
{
public:
	Monster* newMonster()
	{
		return new Monster(*this);
	}
	//이하 동일
}

이렇게 정의해줄 수도 있다. 이 경우 breed 클래스 안에서 객체 생성 과정을 제어할 수 있게 된다.

 

 


 

 

상속으로 중복 데이터 처리


같은 고블린 종족 아래에 여러 고블린들이 있을 수 있다. 만약 이런 고블린들을 모두 수정하려면 많은 데이터들을 반복해서 고쳐야 한다.

 

이 경우 Breed 객체를 만들 때 상속받을 종족 객체를 넘겨줄 수 있다. 상속받을 객체가 없으면 NULL을 넘겨준다.

class Breed
{
public:
	Breed(Breed* parent, int health, const string& attack)
	: parent_(parent), health_(health), attack_(attack)
	{}

	int getHealth();
	const string& getAttack();

private:
	Breed * parent_;
	int health_;
	const string& attack_;
};

int Breed::getHealth()
{
	return parent_ != nullptr && health_ == 0 ? parent_->getHealth() : health_;
}

그리고 종족 데이터를 얻어올 때 상위 객체가 있는데 오버라이드를 하지 않은 경우 상위 객체의 데이터를 넘겨준다.

 

이런 방법은 상위 객체의 특정 값을 오버라이드 하지 않거나 상속받지 않도록 런타임에 변경할 때 유리하다. 그러나 상위 객체를 들고 있기 때문에 메모리를 더 차지하고, 속성 값을 반환할 때마다 조건을 체크하기에 더 느리다.

 

 

생성 시점에 상속 적용

만약 런타임에 종족 속성 값이 바뀌지 않는다면 생성 시점에 상속을 적용할 수 있다. 이렇게 생성 시점에 데이터를 복사해 넣는 것을 '카피다운(copy-down) 위임'이라고 한다.

class Breed
{
public:
	Breed(Breed* parent, int health, string & attack)
	: health_(health), attack_(attack)
	{
		if(parent!=nullptr)
		{
			if (health == 0) health_ = parent->getHealth();
			if (attack.empty()) attack_ = parent->getAttack();
		}
	}

	int getHealth() { return health_; }
	const string& getAttack() { return attack_; }

private:
	int health_;
	string& attack_;
};

이렇게 되면 상위 객체를 들고 있지 않아도 된다. 속성 값을 얻어올 때도 빠르다.

 

JSON 파일 데이터는 다음과 같을 것이다.

"고블린":{
	"체력" : 100,
	"공격" : "도끼로 내려찍습니다."
}
"고블린 궁수":{
	"부모": "고블린",
	"공격": "활을 쏩니다."
}
"고블린 왕":{
	"부모" : "고블린",
	"체력" : 150 
}

 

 


 

주의사항


타입 객체 패턴은 데이터로 타입을 표현하여 유연성을 취한다. 그러나 타입을 코드가 아닌 데이터로 표현하면서 표현력이 좀 떨어진다. 타입별로 동작을 표현할 때가 바로 그렇다.

 

예를 들어 위에 있는 JSON 예제에서 고블린 궁수는 적이 근접했을 때는 단검을 휘두른다고 해보자. 이런 경우는 타입 객체로는 구현하기 어렵다. 

 

이런 한계를 우회하는 방법 중 하나는  미리 동작 코드를 여러 개 정의해 놓은 뒤 이 중 하나를 선택하는 것이다. 또 다른 방법은 인터프리터 패턴 등을 이용한 스크립트 언어를 만들어 동작을 데이터로 정의하는 것이다.

 

 


 

 

 

디자인 결정


타입 객체 패턴은 설계의 폭이 넓고 여러 시도를 해볼 수 있지만, 반면에 시스템이 너무 복잡해진다면 개발과 유지보수가 힘들어지고, 기획자가 이해하기 어려워지는 점을 염두에 두자.

 

타입 객체 캡슐화? 노출?

1. 타입 객체를 캡슐화

이렇게 되면 타입 객체의 복잡성이 다른 코드에서 드러나지 않는다. 또한 타입 사용 객체(Monster 클래스)에서 동작을 선택적으로 오버라이드 할 수 있다. 그러나 타입 객체 메서드 전부를 포워딩해야 한다는 귀찮음이 있다.

 

2. 타입 객체를 노출

외부에서 타입 객체에 쉽게 접근할 수 있다. 이를 이용해 새로운 몬스터도 만들 수 있다. 그러나 복잡성이 늘어나고 유지보수가 어려워진다.

 

 

 

타입 사용 객체의 생성

객체는 타입 객체(Breed)와 타입 사용 객체(Monster)가 존재한다. 이들을 어떻게 생성하고 결합해야 할까?

 

1. 객체 생성 뒤 타입 객체 넘김

이 경우 외부 코드에서 메모리 할당을 제어할 수 있다는 장점이 있다.

 

2. 타입 객체의 '생성' 함수 호출

이 경우 타입 객체에서 메모리 할당을 제어한다. 외부에서 메모리 할당에 선택권을 주는 것이 아니라면 팩토리 메서드를 통해 객체를 만들도록 할 수 있다. 예를 들어, 모든 객체를 객체 풀에서 생성하도록 제한하고 싶다면 이 방법이 좋다.

 

 

 

타입 객체 변경 가능?

몬스터가 죽으면 좀비가 된다고 해보자. 이때 좀비 몬스터를 새로 생성할 수도 있지만 타입 객체를 변경하도록 할 수도 있다. 

1. 변경 불가

이 경우 구현과 이해가 더 쉬운 코드를 작성할 수 있으며 디버깅이 용이하다.

 

2. 변경 가능

이 경우 객체 생성 횟수가 줄어든다. 타입 객체 포인터 값만 바꾸면 된다. 그러나 구현과 유지보수가 복잡해진다.

 

 

 

 

타입 객체 상속은 어떻게?

1. 상속 불가

이 구현이 단순하다. 타입 객체끼리 공유할 데이터가 적다면 굳이 코드를 복잡하게 만들 이유가 없다. 그러나 공유할 데이터가 많아진다면 중복 작업이 많이 발생할 수 있다.

 

2. 단일 상속

이 경우도 구현이나 이해가 쉬운 편이며 데이터 중복도 적절히 처리해준다. 그러나 copy-down 위임이 아닌 방법을 사용할 경우 속성 값을 얻는 작업이 느려진다. 성능이 민감한 코드라면 바람직하지 않다.

 

3. 다중 상속

이 경우 웬만한 데이터 중복을 피할 수 있다. 그러나 요즘 개발 언어들이 다중 상속을 금지하는 것을 보면 알 수 있듯 구현과 유지보수가 매우 어려워진다.

 

 


 

 

다른 패턴과의 관계


프로토타입 패턴

프로토타입 패턴과 타입 객체 패턴은 둘 다 여러 객체끼리 데이터와 동작을 공유하기 위해 사용된다. 그러나 프로토타입 패턴은 문제를 다른 식으로 접근한다.

 

경량 패턴

경량 패턴과 비슷하다. 그러나 경량 패턴의 목표는 메모리 절약이며 공유 데이터의 객체는 개념적으로 '타입'을 나타내지 않을 수도 있다. 타입 객체 패턴의 목표는 조직화와 유연성이다.

 

상태 패턴

상태 패턴과도 비슷하다. 두 패턴 모두 다른 객체에 자기 자신을 정의하는 부분을 위임한다. 그러나 타입 객체에서는 주로 불변 데이터를 위임하고 상태 패턴은 객체의 현재 상태가 어떤지를 나타내는 임시 데이터를 주로 위임한다. 만약 타입 객체 패턴에서 타입 객체를 교체할 수 있다면, 상태 패턴의 역할도 겸하게 된다고 볼 수 있다.

 


참고 : 로버트 나이스트롬, 게임 프로그래밍 패턴, 한빛미디어, [239-256p]

 

'이론 > 디자인패턴' 카테고리의 다른 글

데이터 지역성  (0) 2022.02.28
업데이트 메서드  (0) 2022.02.20
게임 루프  (1) 2022.01.31
이중 버퍼  (0) 2022.01.26
서비스 중개자  (0) 2022.01.16