Game Development, 게임개발/디자인패턴

Type Object, 타입 객체 [디자인패턴](행동)

게임이 더 좋아 2021. 10. 26. 16:28
반응형
728x170

 

이러한 패턴을 만드는 의도는

클래스 하나를 인스턴스 별로 다른 객체형으로 표현할 수 있게 만들어서

새로운 "클래스들"을 유연하게 만드는 것이다.

 

이것은.. 객체지향프로그래밍의 원칙, SOLID에서 비슷한 것이 있었지 아마?

 

?? 왜..??

 


 

게임에서 플레이어가 잡을 몬스터 무리를 구현해보자.

몬스터는 체력, 공격, 그래픽 리소스, 사운드 등 다양한 속성이 있다.

다만.. 예제에서는 체력과 공격 속성만 고려해보자.

 

class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;

protected:
  Monster(int startingHealth)
  : health_(startingHealth)
  {}

private:
  int health_; // Current health.
};

 

모든 몬스터에게는 체력 값이 존재한다.

체력은 우리가 생각한대로 공격 받으면 깎이는 그런 형식이다.

 

하지만 우리는 몬스터 한가지만 만들고 싶지는 않다.

기획자는 다양한 몬스터를 만들어 플레이어에게 재미를 주려고 한다.

 

용이라든지 도깨비라든지 시각적으로 다양한 모습을 보여주기 위해 많은 종류의 몬스터를 만들고 싶어한다.

용이면 엄청난 체력과 방어력을 가지고 있다든가.

도깨비라면 마력이 많다든가.. 그런 식이다.

 


 

하지만 여전히 용이든 도깨비든 몬스터의 일종이다.

개발자는 Monster를 상위 클래스에 두는 것이 합리적으로 보인다.

실제로 합리적이긴 하다.

 

class Dragon : public Monster
{
public:
  Dragon() : Monster(230) {}

  virtual const char* getAttack()
  {
    return "The dragon breathes fire!";
  }
};

class Troll : public Monster
{
public:
  Troll() : Monster(48) {}

  virtual const char* getAttack()
  {
    return "The troll clubs you!";
  }
};

 

getAttack()을 오버라이드 함으로 다양한 공격을 만들 수 있겠다.

또한 생성자는 protected의 접근자를 가지면서

Monster의 최대체력을 인수로 받는다.

 

음.. 지금까진 나쁘진 않다.

이렇게 몬스터의 하위클래스를 만들어서  최대 체력을 전달하고 구현하면 될 것 같다.

 

게임이 점점 커지고 몬스터의 수가 많아졌다.

???

빌드가 느려지더라..??

개발자들은 Monster 하위 클래스를 계속 작성한 후 컴파일 해야하는 위기에 빠졌다.

몇 줄 안되는 코드가 숫자가 많아지자..비효율적으로 변했다.

 

다시 말해서

숫자 하나만 바꿔도 새로 빌드해야한다.

숫자 하나 바꿔도 모든 것을 새로 빌드해야 한다.

이러한 비효율이 엄청난 생산성 저하를 가져오는 것이다.

 


 

해결하고자 하는 문제는 무엇인가?

 

게임에 여러 종류의 몬스터들이 존재하지만 몬스터마다 하위 클래스를 만드려니까 비효율적이다.

몬스터를 만들 때 하위클래스의 인스턴스를 이용하는데.. 그 하위 클래스가 너무 많다.

 

 

종족이 많아질수록 클래스 상속 구조의 크기 자체가 커지고

종족을 늘릴 때마다 코드도 추가해야 하고 컴파일해야 하는 문제도 있다.

 

이를 해결하고자 방법을 생각해낸 것이 있는데

아래와 같이 종족마다 Monster 클래스를 상속받는 것이 아니라.

Monster 클래스와 Breed 클래스 하나만 만드는 것이다.

 

 

이렇게 되면 상속 없이 클래스 2개만으로 해결할 수 있다.

 

모든 몬스터를 Monster 클래스의 인스턴스로 표현할 수 있다.

Breed 클래스에는 종족이 같은 몬스터가 공유하는 정보인 최대 체력과 getAttack의 문구가 들어있다.

 

몬스터와 종족을 결합하기 위해 모든 Monster 인스턴스는 종족 정보를 담고 있는 Breed 객체를 참조한다.

몬스터가 공격 문구를 얻을 때는 종족 객체 메서드를 호출한다.

Breed 클래스는 본질적으로 몬스터 "타입"을 정의한다.

각각의 종족 객체는 개념적으로 다른 타입을 의미한다.

 

그래서 이 패턴이 "타입 객체"다.

 

이 패턴의 장점은

코드 수정 없이 새로운 타입을 정의할 수 있다는 것이 장점이다.

코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴 것이다.

 

새로 Breed 인스턴스에서 만들어 다른 값을 입력하기만 해도 또 다른 종족이 만들어지는 것이다!!

설정 파일에서 읽은 데이터로 종족 객체를 생성하게 만들고 나면

데이터만으로 전혀 다른 몬스터를 정의할 수 있다.

 


 

타입 객체 클래스와 타입 사용 객체를 정의해야 한다.

 

모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미한다.

반대로 타입 사용 객체자신의 타입을 나타내는 타입 객체를 참조한다.

 

인스턴스 별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고

개념적으로 같은 타입끼리 공유하는 데이터나 동작은 타입 객체에 저장한다.

 

**같은 타입 객체를 참조하는 타입 사용 객체는 같은 타입인 것처럼 동작한다.

이러면 상속 처리를 하드코딩하지 않고서도

마치 상속받는 것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.

 


 

몬스터 만들 때도 쓰고

또 언제 쓸 수 있을까?

 

타입 객체 패턴은 다양한 "종류"를 만들 때 쓴다.

나중에 어떤 타입이 만들어질지 모를 경우

컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 변경하고 싶은 경우에 쓴다.

 


 

다만 이러한 디자인 패턴이 항상 좋은 것은 아니므로 참고해야 할 점 몇가지 보자

 

1. 타입 객체를 직접 관리해야하는 불편함

 

C++ 같은 타입 시스템은 컴파일러가 클래스를 위한 온갖 일들을 알아서 해준다.

각각의 클래스를 정의하는 데이터는 컴파일될 때 자동으로 실행 파일의 정적 메모리 영역에 들어가 동작한다.

 

타입 객체 패턴에서는 몬스터 인스턴스뿐만 아니라 타입 객체도 직접 관리해야 한다.

타입 객체를 생성하고, 이를 필요로 하는 몬스터가 있는 한 메모리에 유지해야 한다.

몬스터 인스턴스를 생성할 때 알맞은 종족 객체 레퍼런스로 초기화하는 것도 직접 해줘야 한다.

 

컴파일러가 알아서 해주던 일을 개발자가 직접 하는 번거로움이 생긴다.

 

2. 타입별로 동작을 표현하기가 어려움

 

상속 방식에서는 메서드를 오버라이드해서 코드로 값을 계산하거나

다른 코드를 호출해서 마음대로 바꿀 수 있지만

타입 객체 패턴에서는 Monster 클래스를 상속받아 메서드 오버라이드로 공격 문구를 표현하는 것이 아니라

종족 객체 변수에 공격 문구만 그저 저장하는 식으로 표현된다.

즉, 실제 구체적인 움직임이 아닌.. 그냥 큰 줄기만 바꿀 수 있다는 느낌이다.

 

타입 객체로 타입 종속적인 데이터를 정의하기는 쉽지만

타입 종속적인 "동작" 자체를 정의하기는 어렵다는 것이다.

 

이런 한계를 극복하기 위한 몇가지 방법이 존재하는데

가장 간단한 방법은 미리 "동작 코드"를 만들어서 정의해두는 것이다.

그렇게 하면 타입 객체 데이터에서 몇가지만 선택하면 된다.

하지만 이런 방법도.. 미리 얼만큼 만들어놔야 하는가? 또는 추가했을 때의 오버헤드가 발생하기는 한다.

 

더 나아가 데이터만으로 동작을 정의할 수도 있다.

앞서 데이터로 코드를 만드는.. 바이트코드 패턴을 배웠다.

즉, 인터프리터 패턴을 이용하면 동작을 표현하는 객체도 만들 수 있다.

 

파일에서 데이터를 읽고 이들 패턴으로 자료구조를 만들어

동작 정의를 코드에서 데이터로 옮길 수 있는 것이다!

 


 

 

말로 설명해서 이해가 안되니

예제를 보자

 

앞의 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_; // Starting health.
  const char* attack_;
};

 

현재는 최대 체력, health_ 와 공격 문구 attack_ 2가지만 가지고 있다.

Monster에서는 이것을 어떻게 쓰는 것일까?

 

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  const char* getAttack()
  {
    return breed_.getAttack();
  }

private:
  int    health_; // Current health.
  Breed& breed_;
};

 

Monster 클래스 생성자는 Breed 객체를 레퍼런스로 받는다.

이를 통해서 상속 없이 몬스터 종족을 정의한다.

최대 체력은 생성자에서 breed 인수를 통해 얻는다.

공격 문구는 breed_에 포워딩해서 얻는다.

 

이것이 타입 패턴의 전체라고 말해도 될만큼 요약해놓았다.

 


 

하지만 우리가 앞서한 방식

몬스터 만들기, 해당 몬스터에 맞는 종족 객체 전달의 방식은

메모리를 먼저 할당한 후에 메모리 영역에 클래스를 할당하는 것과 같다.

 

우리가 OOP에서 메모리 먼저 할당한 적이 있었나..?

거의 없다.

우리는 클래스의 생성자 함수를 호출해 클래스가 새로운 인스턴스를 생성하게 한다.

 

타입 객체도 이렇게 할 수 있다.

예를 보자

 

class Breed
{
public:
  Monster* newMonster() { return new Monster(*this); }

  // Previous Breed code...
};

 

Monster 클래스도 바뀌어야 한다.

 

class Monster
{
  friend class Breed;

public:
  const char* getAttack() { return breed_.getAttack(); }

private:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  int health_; // Current health.
  Breed& breed_;
};

 

가장 크게 바뀐 점은

Breed 클래스의 newMonster 함수다.

이게 팩토리 메서드 패턴의 "생성자"이다.

 

이전 코드에서는 몬스터를 아래와 같이 생성했지만

Monster* monster = new Monster(someBreed);

수정한 뒤에는 아래와 같이 선언한다.

Monster* monster = someBreed.newMonster();

 

음.. 근데 바뀐 것은 그렇다 치고??

뭐가 좋아진건가??

 

객체는 사실 2가지 단계로 생성된다.

1. 메모리 할당

2. 초기화

 

즉, Monster 클래스 생성자 함수에서 필요한 모든 초기화 작업을 할 수 있다. 

예제에서는 Breed 객체를 전달하는 것이 전부지만

실제 프로젝트 안에서는 그래픽을 로딩하고, 몬스터 AI를 설정하는 등 다른 초기화 작업이 많이 필요할 것이다.

 

하지만 이런 초기화 진행 작업은 메모리를 할당한 다음에 진행된다.

아직 제대로 초기화되지 않은 몬스터가 메모리에 먼저 올라가 있는 것이다.

게임에서는 객체 생성 과정을 제어하고 싶을 때가 종종 있다.

그럴 때는 보통 커스텀 할당자나 오브젝트 풀링을 이용해서 객체가 메모리에 어디에 생성될 지를 제어한다.

 

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_; // Starting health.
  const char* attack_;
};

 

Breed 객체를 만들 땐 상속받을 종족 객체를 넘겨준다.

상위 종족이 없는 최상위 종족은 parent에 NULL을 전달한다.

 

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

예제에서는 최대 체력이 0이 아닐 때, 공격 문구가 NULL이 아닐 때는 자기 값을 쓰고

아니면 상위 객체 값을 쓰기로 결정했다.


 

우선 속성 값을 요청받을 때마다 동적으로 위임하는 방식이 있다.

 

int Breed::getHealth()
{
  // Override.
  if (health_ != 0 || parent_ == NULL) return health_;

  // Inherit.
  return parent_->getHealth();
}

const char* Breed::getAttack()
{
  // Override.
  if (attack_ != NULL || parent_ == NULL) return attack_;

  // Inherit.
  return parent_->getAttack();
}

 

이러한 방법은 종족이 특정 속성 값을 더 이상 오버라이드하지 않거나 상속받지 않도록 런타임에 바뀔 때 좋다.

메모리를 더 차지하고, 속성 값을 반환할 때마다 상위 객체들을 줄줄이 확인해야해서 더 느리긴 하다.

 

종족 속성 값만 바뀌지 않는다면 생성 시점에 바로 상속을 적용할 수 있다.

이런 것을 copy down, 카피 다운 위임 이라고 한다. 

객체가 생성될 때 상속받는 속성 값을 하위 타입으로 복사해서 넣는 것이라 그렇다.

 

Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // Inherit non-overridden attributes.
  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 파일로 종족을 정의한다고 해보자.

 

{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}

 

이 코드는 종족 데이터를 읽어서 새로운 종족 인스턴스를 만든다.

긍수와 마법사는 둘다 체력이 0이기 때문에 상위 종족인 트롤에서 체력을 얻어다 쓴다.

즉, 기획자가 트롤 종족의 체력만 바꾸면 모든 트롤 종족의 체력이 바뀔 것이다.

 

종족과 종족별 속성 개수가 늘어날수록 상속으로 시간을 많이 아낄 수 있다. 

우리는 얼마 안 되는 코드로 기획자가 자유롭게 제어할 수 있게 시스템을 만들었다.

생산성을 높인 것이다.

 

 

반응형
그리드형