Skip to main content

[Design Pattern] 게임 행동 : 바이트 코드, 하위 클래스 샌드박스, 타입 객체

게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.




바이트 코드 #

  • 인터프리터 패턴의 경우 느리고 메모리를 많이 필요로 한다는 단점이 있었다.
  • 반면에 기계어의 경우 밀도가 높고, 선형적이며 저수준이고, 빠르다. 하지만 해커에게 취약해진다.

  • 이 둘을 절충할 수는 없을까?
    • 실제 기계어를 읽어서 바로 실행하는 대신 우리만의 가상 기계어(바이트 코드) 를 정의하면 어떨까?
    • 그리고 가상 기계어를 실행하는 에뮬레이터(가상 머신: VM) 도 만드는 거다.
  • 바이트 코드 패턴
    • 실행할 수 있는 저수준 작업들을 명령어 집합으로 정의한다.
    • 그 명령어는 일련의 바이트로 인코딩된다.
    • 가상 머신은 중간 값들을 스택에 저장해나가면서 명령어들을 하나씩 실행한다.
  • 언제 쓸까?
    • 언어가 너무 저수준이라 만드는 데 손이 많이 가거나 오류가 생기기 쉽다.
    • 컴파일 시간이나 다른 빌드 환경 때문에 반복 개발하기가 너무 오래 걸린다.
    • 새로 만드려는 행동을 나머지 코드로부터 분리해서 보안에 신경쓰고 싶다.

코드 예제 #

  • 마법 주문을 구현하는 데 필요한 API들은 다음과 같다.
void setHealth(int wizard, int amount)`
void setWisdom(int wizard, int amount)`
void setAgility(int wizard, int amount)`
int getHealth(int wizard)`
int getWisdom(int wizard)`
int getAgility(int wizard)`
void playSound(int soundId)`
void spawnParticles(int particleType)`

  • 이 API들을 단순한 명령어 집합으로 바꿀 수 있겠다.
enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_GET_HEALTH      = 0x03,
  INST_GET_WISDOM      = 0x04,
  INST_GET_AGILITY     = 0x05,
  INST_PLAY_SOUND      = 0x06,
  INST_SPAWN_PARTICLES = 0x07
};

  • 인터프리터 패턴은 중첩 객체 트리 형태로 중첩식을 표현했고, 매우 느리다.
  • 트리가 아닌 1차원으로 명령어를 나열해도 순서에 맞게 실행할 수 있다면 속도가 빨라질 것이다.
    • CPU처럼 스택을 사용해서 명령어 실행 순서를 제어해보자.
  • 스택을 이용해서 마법 전체를 실행하는 VM은 다음과 같이 구현한다.
class VM
{
private:
  // 스택으로 명령어 실행 순서를 제어한다. 
  // 스택 크기를 조절해서 VM의 메모리 사용량을 조절할 수 있다. 
  static const int MAX_STACK = 128;
  int stackSize;
  int stack[MAX_STACK];
  
  void push(int value)
  {
    assert(stackSize < MAX_STACK);
    stack[stackSize++] = value;
  }
  
  int pop()
  {
    assert(stackSize > 0);
    return stack[--stackSize];
  }

public:
  VM() : stackSize(0) {}
  
  // VM은 바이트 코드를 읽어서 해당하는 명령에 따라 모두 실행한다. 
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      
      switch(instruction)
      {
        case INST_SET_HEALTH:
        {
          // 명령어가 매개변수를 받을 때는 다음과 같이 스택에서 꺼내온다. 
          int amount = pop();
          int wizart = pop();
          setHealth(wizard, amount);
        } 
        break; 
        
        case INST_GET_HEALTH:
        {
          int wizart = pop();
          push(getHealth(wizard));
        }
        break;
        
        // ....
      }
  }
};

  • 스택에서 pop()으로 값을 읽어오려면…
    • 바이트 코드에서 값을 읽어서 push()해놓아야 한다.
    • 리터럴 명령어가 필요하겠다.
// 바이트 코드에서 리터럴 명령어가 나오면, 
case INST_LITERAL:
{
  // 바이트 코드에서 다음 값을 읽어서 스택에 넣는다. 
  int value = bytecode[++i];
  push(value);
}
break;

  • 이제 명령어들을 조합해서 아래와 같은 표현식을 만들어보자.
setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);
  • 표현식은 다음과 같은 순서로 실행된다.
    1. 체력을 가져와 저장한다.
    2. 민첩성을 가져와 저장한다.
    3. 지혜를 가져와 저장한다.
    4. 민첩성과 지혜를 가져와 더한 뒤에 그 결과를 저장한다.
    5. 결과를 2로 나눈 뒤 저장한다.
    6. 체력을 가져와 결과에 더하고 저장한다.
    7. 결과를 가져와 마법사의 체력으로 세팅한다.

  • 덧셈을 하려면 아래와 같은 명령어가 추가되어야 하겠다. 나눗셈도 마찬가지다.
case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
}
break;

  • 바이트 코드를 다음과 같이 만들어낼 수 있겠다.
    명령어 스택 상태 목적
    LITERAL 0 [0] 마법사 인덱스
    LITERAL 0 [0, 0] 마법사 인덱스
    GET_HEALTH [0, 45] getHealth()
    LITERAL 0 [0, 45, 0] `마법사 인덱스
    GET_AGILITY [0, 45, 7] getAgility()
    LITERAL 0 [0, 45, 7, 0] 마법사 인덱스
    GET_WISDOM [0, 45, 7, 11] getWisdom()
    ADD [0, 45, 18] 민첩성과 지혜를 더함
    DIVIDE [0, 45, 9] 2로 나눔
    ADD [0, 54] 결과에 현재 체력을 더함
    SET_HEALTH [] 결과를 체력으로 세팅함

  • 이렇게 손으로 직접 바이트 코드를 컴파일하는 건 현실성이 없다.
  • 이제 사용자가 바이트 코드를 고수준 형식으로 편하게 표현할 수 있어야 하겠다.
    • 텍스트 기반 언어를 정의하거나, 명령어들을 조합하는 GUI 툴을 만들면 괜찮겠다.

디자인 결정에 고려할 사항들 #

  • VM의 종류
    • 스택 기반 VM
    • 레지스터 기반 VM
  • 명령어의 종류
    • 외부 원시명령
    • 내부 원시명령
    • 흐름 제어
    • 추상화
  • 값을 표현하는 자료형을 어떻게 저장할 것인가?
    • 단일 자료형
    • 태그 불은 변수
    • 태그가 붙지 않은 공용체
    • 인터페이스
  • 바이트코드를 어떻게 만들 것인가?
    • 텍스트 기반 언어를 정의할 경우
    • UI가 있는 저작 툴을 만들 경우



하위 클래스 샌드박스 #

  • 하위 클래스 샌드박스 패턴
    • 상위 클래스에는 중복되는 코드를 넣어 하위 클래스에서 재사용할 수 있게 한다.
    • 이 코드를 사용하는 샌드박스 메서드는 순수 가상 함수로 만들어 protected에 둔다.
    • 그런다음 하위 클래스에서 샌드박스 메서드를 구현한다.
  • 언제 쓸까?
    • 클래스 하나에 하위 클래스가 많이 있다.
    • 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.
    • 하위 클래스들의 행동 중에 겹치는 게 많아서 이들을 공유하게 만들고 싶다.
    • 하위 클래스들 사이의 커플링, 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.
  • 주의사항
    • 하위 클래스는 상위 클래스에 접근하는 모든 시스템과 커플링된다. 그래서 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽다. (fragile base class 문제)

코드 예제 #

  • 슈퍼히어로 게임을 만들고 싶다. 여기에는 수십 개가 넘는 다양한 초능력이 존재한다.
class Superpower
{
public:
  virtual ~Superpower();

protected:
  // 샌드박스 메서드 
  virtual void activate() = 0;
  
  // 하위 클래스에서 공통으로 사용되는 코드들
  void move(double x, double y, double z);
  void playSound(SoundId sound, double volume);
  void spwanParticles(ParticleType type, int count);
};

class SkyLaunch : public Superpower
{
protected:
  // 하위 클래스에서 샌드박스 메서드를 구현한다. 
  virtual void activate()
  {
    playSound(SOUND_SPROING, 1.0f);
    spawnParticles(PARTICLE_DUST, 10);
    move(0, 0, 20);
  }
};

디자인 결정에 고려할 사항들 #

  • 상위 클래스에서 어떤 공통적인 기능을 제공해야 할까?
  • 상위 클래스에서 메서드를 직접 제공할 것인가? 아니면 객체 자체를 제공할 것인가?
  • 상위 클래스에서 필요한 객체는 어떻게 얻는가?
    • 생성자 매개변수로 받는다.
    • 생성자에서 객체를 생성한다.
    • 정적 객체로 만든다.
    • 중재자 패턴을 사용해서 외부에서 중재자가 객체를 넣어준다.



타입 객체 #

  • 게임에는 정말이지 다양한 몬스터가 존재한다. 하나의 최상위 클래스 Monster를 상속받는 Dragon, Troll같은 하위 클래스를 종류만큼 많이 만들면 하위 클래스가 정말 많아질 것이다. 그리고 종족을 늘릴 때마다 코드를 추가하고 컴파일해야한다.
  • 이런 방법은 어떨까? 몬스터마다 종족에 대한 정보를 따로 두고 그것을 참조하는 것이다.
    • 그러면 상속 없이도 Monster, Breed 클래스 두 개만으로 다양한 몬스터를 표현할 수 있겠다.
    • Breed 클래스는 몬스터의 ‘타입’을 정의하므로 이 객체는 ‘타입’ 객체이다.

  • 타입 객체 패턴
    • 타입 사용 객체(typed object)타입 객체(type object) 클래스를 정의한다.
    • 고유한 코드는 타입 사용 객체에 저장하고, 같은 타입끼리 공유하는 코드는 타입 객체에 저장한다.
    • 타입 사용 객체는 타입 객체를 참조한다.
    • 같은 타입 객체를 참조하는 타입 사용 객체는 같은 타입인 것처럼 동작한다.
  • 언제 쓸까?
    • 나중에 어떤 타입이 필요할지 알 수 없다. 새로운 몬스터가 등장하는 DLC(downloadable content)를 제공해야 할지도 모른다.
    • 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.
  • 주의사항
    • 타입 객체를 직접 관리해야 한다.
    • 타입 객체로 데이터를 정의하기는 쉽지만 동작을 정의하기는 어렵다.

코드 예제 #

// 타입 객체 
// 같은 종족이 가지는 공통적인 데이터나 동작을 저장하고 있다. 
class Breed
{
private:
  int health; // 초기 체력
  const char* attack;
  
public:
  Breed(int h, const char* a) : health(h), attack(a) {}
  
  int getHealth() { return health; }
  const char* getAttack() { return attack; }
};

// 타입 사용 객체 
// 각각의 몬스터는 
class Monster
{
private:
  Breed& breed; // 타입 객체를 참조한다. 
  int health;   // 현재 체력

public:
  Monster(Breed& b) : breed(b), health(b.getHealth()) {}
  
  const char* getAttack() { return breed.getAttack(); }
};

  • 팩토리 메서드 패턴을 활용해보자.
// 위의 코드에서는 이렇게 몬스터를 생성하지만 
Monster* m = new Monster(someBreed);

// 아래 코드에서는 이렇게 생성할 수 있다. 
Monster* m = someBreed.newMonster();
class Breed
{
public:
  // 팩토리 메서드. 여기서 객체를 생성한다. 
  // 이렇게 하면 Monster 클래스가 초기화되기 전에, 
  // 여기에서 다양한 메모리 관리 기법(메모리 풀 등)을 사용해볼 수 있겠다. 
  Monster* newMonster() 
  { 
    return new Monster(*this); 
  }
  
  // ...
};

class Monster
{
  friend class Breed;

private:
  Breed& breed; 
  int health;
  Monster(Breed& b) : breed(b), health(b.getHealth()) {}

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

  • 타입 객체도 상속 구조를 사용해서 데이터를 공유하게 만들 수 있다.
class Breed
{
private:
  Breed* parent; // 상속받을 종족 객체 
  int health;
  const char* attack;
  
public:
  Breed(Breed* p, int h, const char* a) : parent(p), health(h), attack(a) {}
  
  int getHealth()
  {
    // 오버라이딩
    if (health != 0 || parent == NULL) return health;
    
    // 상속
    return parent->getHealth();
  }
  
  // ...
};

  • 만약에 종족이 바뀌지 않는다면, 굳이 parent 객체 포인터를 들고 있지 않고 생성 시점에 바로 상속을 적용시킬 수도 있겠다.
class Breed
{
private:
  int health;
  const char* attack;
  
public:
  Breed(Breed* p, int h, const char* a) : parent(p), health(h), attack(a)
  {
    // 생성자에서 상위 속성을 전부 복사한다. 
    if (parent != NULL)
    {
      if (health == 0) health = parent->getHealth();
      if (attack == NULL) attack = parent->getAttack();
    }
  }
  
  int getHealth() { return health; }
  const char* getAttack() { return attack; }
};

디자인 결정에 고려할 사항들 #

  • 타입 객체를 숨길 것인가? 노출할 것인가?
  • 타입 사용 객체를 어떻게 생성할 것인가?
  • 타입을 바꿀 수 있는가?
  • 상속 없음? 단일 상속? 다중 상속? 어떤 걸 지원할 것인가?