Skip to main content

[Design Pattern] 게임 디커플링 : 컴포넌트, 이벤트 큐, 서비스 중개자

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




컴포넌트 #

  • 플랫포머 게임을 만들고자 한다. 플레이어는 컨트롤러 입력 값도 처리해야 하고, 지형이나 플랫폼과도 상호작용해야 되니 물리 처리도 필요하다. 또한 화면에 나와야 하니 애니메이션과 렌더링도 필요하다. 소리도 들려야하겠다.
  • AI, 물리, 렌더링, 사운드 처럼 서로 다른 분야는 서로 몰라야 하겠지만 이 기능들을 하나의 클래스 안에 다 욱여넣는다면 코드가 방대해지는 것은 물론이고, 서로 실타래처럼 얽히게(커플링) 될 것이다.

  • 컴포넌트 패턴
    • 각각의 분야를 별도의 컴포넌트로 제작하고, 플레이어는 이 컴포넌트들을 컨테이너처럼 들고만 있으면 어떨까?
  • 언제 쓸까?
    • 한 클래스에서 여러 분야를 건드리고 있는데 이것들을 서로 디커플링하고 싶다.
    • 한 클래스가 거대해져서 작업하기가 어렵다.
    • 여러 기능을 공유하는 다양한 객체를 만들고 싶은데 상속으로는 원하는 부분만 재사용할 수가 없다.
  • 주의사항
    • 코드 규모가 작으면 클래스 하나에 코드를 모아놨을 때보다 더 복잡해질 가능성이 있다.
    • 무엇이든 한 단계 포인터를 거쳐 처리되므로 성능이 떨어질 수 있다.

코드 예제 #

  • 컴포넌트 패턴을 적용하기 전, 통짜 클래스를 살펴보고 문제점을 파악해보자.
class Mario
{
private:
  static const int WALK_ACCELERATION = 1;
  int velocity, x, y;
  Volume volume;
  Sprite spriteStand;
  Sprite spriteWalkLeft;
  Sprite spriteWalkRight;
  
public:
  Mario() : velocity(0), x(0), y(0) {}
  
  void update(World& world, Graphics& graphics)
  {
    // (1) 입력에 따라 속도를 조절한다. 
    switch(Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        velocity -= WALK_ACCELERATION;
        break;
      case DIR_RIGHT:
        velocity += WALK_ACCELERATION;
        break;
    }
    
    // (2) 속도에 따라 위치를 바꾼다.
    x += velocity;
    world.resolveCollision(volume, x, y, velocity);
    
    // (3) 알맞은 스프라이트를 그린다. 
    Sprite* sprite = &spriteStand;
    if (velocity < 0)      sprite = &spriteWalkLeft;
    else if (velocity > 0) sprite = &spriteWalkRight;
    graphics.draw(*sprite, x, y);
  }
};

  • 별 기능이 없는데도 update() 함수에 여러가지 기능이 섞여서 어지럽다.
  • 각 기능을 클래스별로 나눠보자.
// (1) 입력에 따라 속도를 조절한다. 
class InputComponent
{
private:
  static const int WALK_ACCELERATION = 1;
  
public:
  void update(Mario& mario)
  {
    switch(Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        mario.velocity -= WALK_ACCELERATION;
        break;
      case DIR_RIGHT:
        mario.velocity += WALK_ACCELERATION;
        break;
    }
  }
};

// (2) 속도에 따라 위치를 바꾼다.
class PhysicsComponent
{
private:
  Volume volume;
  
public:   
  void update(Mario& mario, World& world)
  {
    mario.x += velocity;
    world.resolveCollision(volume, mario.x, mario.y, mario.velocity);    
  }
};

// (3) 알맞은 스프라이트를 그린다. 
class GraphicsComponent
{
private:
  Sprite spriteStand;
  Sprite spriteWalkLeft;
  Sprite spriteWalkRight;
  
public:   
  void update(Mario& mario, World& world)
  {
    Sprite* sprite = &spriteStand;
    if (velocity < 0)      sprite = &spriteWalkLeft;
    else if (velocity > 0) sprite = &spriteWalkRight;
    graphics.draw(*sprite, mario.x, mario.y);
  }
};

class Mario
{
private:
  // 각 컴포넌트를 들고 있다. 
  InputComponent input;
  PhysicsComponent physics;
  GraphicsComponent graphics;

public:
  int velocity, x, y;
  
  void update(World& world, Graphics& graphics)
  {
    // 간단해졌다. 
    input.update(*this);
    physics.update(*this, world);
    graphics.update(*this, graphics);
  }
};

  • 각 컴포넌트, 플레이어 클래스의 구현부와 인터페이스를 분리해서 추상화시켜보자.
class InputComponent
{
public:
  virtual ~InputComponent();
  virtual void update(GameObject& obj) = 0;
};

class PhysicsComponent
{
public:
  virtual ~PhysicsComponent();
  virtual void update(GameObject& obj, World& world) = 0;
};

class GraphicsComponent
{
public:
  virtual ~GraphicsComponent();
  virtual void update(GameObject& obj, Graphcis& graphics) = 0;
};

class GameObject
{
private:
  InputComponent* input;
  PhysicsComponent* physics;
  GraphicsComponent* graphics;

public:
  int velocity, x, y;
  
  GameObject(InputComponent* i, PhysicsComponent* p, GraphicsComponent* g) : input(i), physics(p), graphics(g) {}
  
  void update(World& world, Graphics& graphics)
  {
    input->update(*this);
    physics->update(*this, world);
    graphics->update(*this, graphics);
  }
};

  • 이렇게 하면 새로운 기능의 컴포넌트, 캐릭터를 마음껏 만들어낼 수 있다.
  • 예를 들면, 아래 처럼 데모 모드용 플레이어 객체를 생성할 수도 있겠다.
// 데모 모드용 입력 처리 클래스 
class DemoInputComponent : public InputComponent
{
public:
  virtual void update(GameObject& obj)
  {
    // AI가 알아서 플레이어를 조정한다. 
  }
};

class MarioPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // ...물리 코드 
  }
};

class MarioGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphcis& graphics)
  {
    // ...그래픽스 코드 
  }
};

// 데모모드용 플레이어 객체를 다음과 같이 생성할 수 있다. 
GameObejct* createDemoMario()
{
  return new GameObject(
    new DemoInputComponent(), 
    new MarioPhysicsComponent(), 
    new MarioGraphicsComponent());
}

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

  • 객체는 컴포넌트를 어떻게 얻는가?
    • 객체가 알아서 생성한다.
    • 외부 코드에서 컴포넌트를 제공한다.
  • 컴포넌트끼리는 어떻게 통신할 것인가?
    • 객체의 상태를 변경해서 통신하는 방식
    • 컴포넌트가 서로를 참조하는 방식
    • 메시지를 전달하는 방식



이벤트 큐 #

  • UI 이벤트의 처리는 어떻게 이루어질까?
    • 버튼을 클릭하면 운영체제는 이벤트를 만든다.
    • 이 이벤트는 큐(queue)에 저장된다.
    • 그리고 애플리케이션에서 원할 때 큐에 있는 이벤트를 꺼내서 처리한다.
  • 게임에서는 자체적으로 이벤트 큐를 만들어서 통신 시스템으로 활용한다.

  • 이벤트 큐 패턴
    • 큐는 요청들을 순서대로 저장해놓는다.
    • 요청을 보낸 쪽에서는 요청을 큐에 넣은 뒤에 기다리지 않고 리턴한다.
    • 요청을 받는 쪽에서는 원하는 때에 큐에 있는 요청을 확인하고 처리한다.
    • 요청은 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다.
    • 요청을 보낸 쪽과 받는 쪽은 코드뿐만 아니라 시간 측면에서도 디커플링한다.
  • 언제 쓸까?
    • 요청을 보낸 쪽과 받는 쪽을 분리하고 싶을 뿐이라면 옵저버 패턴이나 커맨드 패턴으로 처리할 수 있다.
    • 이벤트 큐 패턴은 요청을 보내는 시점과 받는 시점을 분리하고 싶을 때 필요하다.
  • 주의사항
    • 중앙 이벤트 큐는 전역 변수와 같다. 따라서 온갖 미묘한 상호의존성 문제가 생길 수 있다.
    • 요청할 당시와 요청을 처리할 당시의 월드 상태는 언제든 달라질 수 있다. 따라서 주의가 필요하다.
    • 서로 계속해서 요청을 보내는 피드백 루프에 빠질 수 있다. 동기 방식이면 스택 오버플로가 나겠지만, 비동기 방식이라 게임은 정상적으로 계속 실행된다. 이벤트를 처리하는 곳에서 이벤트를 보내지 않으면 해결될 수 있다.

코드 예제 #

  • 예를 들어, 사운드 시스템을 만들고 싶다고 하자.
class Audio 
{
public: 
  static void playSound(SoundId id, int volume)
  {
    // 리소스를 로딩하고, 채널을 찾아서 소리를 재생한다. 
    ResourceId resource = loadSound(id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, volume);
  }
};

class Menu
{
public:
  void onSelect(int index)
  {
    // UI 메뉴를 누르면 소리가 나게한다. 
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
  }
};

  • 하지만 위의 코드는 문제가 있다.
    • playSound()는 동기적이라 소리가 출력되기 전까지 API가 블록된다.
    • 요청을 모아서 순서대로 처리할 수 없다. 멀티코어 하드웨어에서 실행된다면 playSound()에 동기화 처리가 없기 때문에 여러 스레드에서 동시에 사운드가 출력될 수 있다.
    • playSound()를 호출하면 하던 일을 멈추고 당장 사운드를 출력한다. 하지만 이 때가 늘 적당한 때가 아닐 수 있다.

  • 이제 이벤트 큐 패턴을 적용해보자.
    • 요청을 큐에 넣는 시점과 요청을 처리하는 시점을 달리 하기 위해 함수를 따로 둔다.
    • 원형 버퍼를 사용해본다.
// 요청을 나중에 처리할 때 필요한 정보들을 저장하기 위해 구조체를 선언한다. 
struct PlayMessage
{
  SoundId id;
  int volume;
};

class Audio 
{
private:
  static const int MAX_PENDING = 16;
  static PlayMessage pending[MAX_PENDING];
  static int head;
  static int tail;
  
public: 
  static void init() 
  { 
    head = 0;
    tail = 0;
  }
  
  static void playSound(SoundId id, int volume)
  {
    // 똑같은 요청이 또 들어온 경우라면
    // 소리가 더 큰 값 하나로 합쳐지게 한다. 
    for (int i = head; i != tail; i = (i + 1) % MAX_PENDING)
    {
      if (pending[i].id == id)
      {
        pending[i].volume = max(volume, pending[i].volume);
        return;
      }
    }
    
    // 큐가 가득 찬 경우 덮어쓰지 않기 위해 단언문으로 검사한다. 
    assert((tail + 1) % MAX_PENDING != head);

    // 배열의 맨 뒤에 메시지를 넣는다.
    pending[tail].id = id;
    pending[tail].volume = volume;
    
    // 만약에 꼬리가 배열의 마지막에 도달했다면, 
    // 맨 앞으로 가게 만든다. (원형 버퍼)
    tail = (tail + 1) % MAX_PENDING; 
  }
   
  // 이 함수는 적당한 곳(메인 게임. 루프 혹은 별도의 오디오 스레드)에서 호출되면 된다. 
  static void update()
  {
    // 요청이 없으면 아무것도 안 한다. 
    if (head == tail) return;
  
    // 리소스를 로딩하고, 채널을 찾아서 소리를 재생한다. 
    ResourceId resource = loadSound(pending[head].id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, pending[head].volume);
    
    // 다음 위치로 간다. 
    head = (head + 1) % MAX_PENDING;
  }
};

  • 이 코드에 멀티 스레드에서도 안전하게 만들고 싶다면?
    • 큐를 변경하는 코드인 playSound()update()를 스레드 안전하게 만들기만 하면 된다.

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

  • 큐에 무엇을 넣을 것인가?
    • 이벤트 : 이미 발생한 사건
    • 메시지 : 나중에 실행했으면 하는 행동
  • 누가 큐를 읽는가?
    • 싱글캐스트 큐
    • 브로드캐스트 큐
    • 작업 큐
  • 누가 큐에 값을 넣는가?
    • 넣는 측이 하나
    • 넣는 측이 여러 개
  • 큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?
    • 소유권을 전달한다
    • 소유권을 공유한다
    • 큐가 소유권을 가진다



서비스 중개자 #

중재자 패턴