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

Event Queue, 이벤트 큐 [디자인패턴](디커플링)

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

 

이 패턴의 의도는

메시지와 이벤트를 보내는 시점처리하는 시점을 다르게 하기 위함이다.

 

큐라는 이름대로 실제로 큐를 이용한다.

 


 

이벤트와 큐는 각각 들어봤는데

이벤트 큐는 뭘까? 싶지만

그냥 이벤트가 들어있는 큐다. 걱정말자.

 

예를 들어 UI 프로그래밍을 해봤다면

이벤트에 대해서 조금이라도 더 알텐데

버튼 같은 것을 User가 누르면 이벤트가 발생하고

프로그램에서 해당 이벤트를 이용해 작동하는 형식이다.

 

다시 말해서 버튼 클릭이든

뭐든 프로그램과 상호작용을 위해 OS가 이벤트를 발생시켜서 프로그램으로 전달하는 것이다.

프로그램에서는 이벤트를 받아서 행위를 처리하도록 

이벤트 핸들러, Event Handler에게 전달한다.

 

예를 들어 아래처럼 이벤트 루프가 있는 것이다.

 

while (running)
{
  Event event = getNextEvent();
  // Handle event...
}

 

이벤트를 처리하는 루프다.

 

getNextEvent( )는 아직 처리하지 않은 사용자 입력을 가져온다. 

이를 이벤트 핸들러로 보내면 어플리케이션이 살아 움직이는 것처럼 상호작용할 수 있다. 

 

어플리케이션은 자기가 원할 때 이벤트를 가져온다는 사실이다.

사용자가 주변기기를 눌렀다고 해서 OS에서 바로 어플리케이션 코드를 호출하는 것은 아니다.

 

?? 무슨 말이지?

즉, 이벤트를 원할 때 가져올 수 있다는 것인데

OS가 디바이스 드라이버로부터 입력 값을 받은 뒤 어플리케이션에 전달하기 전에

그 값을 어디에다가 저장하고 나중에 어플리케이션에서 가져온다는 것을 의미한다.

그 장소가 바로 "큐, Queue"다.

 

 

위의 큐에 무슨 일이 일어났는지 이벤트를 저장한다.

 

OS는 해당 큐에 Enqueue를 해주고

APP는 해당 큐에 Dequeue를 해주는 것이다(getNextEvent)

 

하지만 게임에서 이벤트큐 자체로 게임을 돌리는 경우는 드물다.

왜냐면.. 입력하지 않아도 게임 시간은 흘러가야하니까.

Event 지향 방식은 거의 쓰지 않는다.

 

다만 자체적으로 이벤트 큐를 만들어 통신 시스템으로 활용하고는 한다.

특히 게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준 통신을 하고 싶을 때 이를 사용한다.

 

특정 인게임 이벤트가 발생할 때 풍선 도움말을 보여주는 튜토리얼 시스템이 있다고 해보자

 

플레이어가 몬스터를 잡으면 "Z"를 눌러 전리품을 획득하세요...?(메이플에서 본듯)

말풍선을 보여주고 싶다.

 

게임플레이 코드와 전투 코드는 이미 충분히 복잡한데..

거기다가 튜토리얼 때문에 해당 코드를 추가하기는 너무 까다롭다.

대신 중앙 이벤트 큐를 만들면 어느 게임 시스템에서도 큐에 이벤트를 보낼 수 있다.

즉, 전투 코드라면 매번 적을 죽일 때마다 "적이 죽었음"을 나타내는 이벤트를 보내는 식이다.

 

마찬가지로 모든 게임 시스템은 큐로부터 이벤트를 받을 수 있다. 

튜토리얼 코드는 "적이 죽었음" 이벤트를 받으면 알려달라고 큐에 자기 자신을 등록한다.

이렇게 하면 디커플링된 시스템이라도 전투시스템으로부터

튜토리얼 코드로 적이 죽었다는 사실을 전달할 수 있다.

 

 

이벤트 큐가 게임 전체 통신에도 물론 많이 사용하지만 다른 곳에서도 사용할 수 있다.

 

튜토리얼 시스템말고 사운드 시스템을 보자

적절한 타이밍에 적절한 소리로 플레이어에게 뭔가를 전달하고 싶다.

 

사운드 시스템을 추가하기 위해 가장 간단한 방법부터 적용해본 뒤에 어떻게 돌아가는지를 볼 것이다.

아이디와 볼륨을 받아 사운드를 출력하는 API를 제공하는 단순한 "오디오 엔진"부터 만들어보자

class Audio
{
public:
  static void playSound(SoundId id, int volume);
};

 

오디오 엔진은 적당한 사운드 리소스를 로딩해서 출력할 수 있는 채널을 찾아서 틀어준다.

**정확한 오디오 시스템을 구현한 것은 아니다

 

void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}

 

메서드 구현은 위와 같다.

위 코드에서 소스 관리 툴에 체크인하고 사운드 파일을 만들고 나면

코드 여기 저기에서 playSound()를 호출할 수 있다.

예를 들어 UI 코드에서 선택한 메뉴가 바뀔 때 작게 어떤 소리를 내고 싶다면

아래처럼 하면 된다.

 

class Menu
{
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // Other stuff...
  }
};

하지만 이상태로 메뉴를 옮겨다니다 보면

화면이 몇 프레임 갑자기 멈출 때가 있다.

뭐지???

 

문제 여러가지가 있다.

1. playSound는 동기적, Synchronous 수행이다.

 

때문에 API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 block한다.

스피커로부터 삑 소리가 나기 전까지 API는 블록된다.

사운드 파일을 먼저 디스크에서 로딩하기라도 해야 한다면 더 오래 기다린다.

그동안 게임은 멈춰있는 것이다.

더욱이 문제되는 것도 있는데 다수의 몬스터에 대한 피격음을 재생해야하는데

한 프레임에 2 몬스터가 동시에 맞는다면 사운드가 동시에 재생될텐데

여러 소리가 섞인다면.. 더욱이 파형이 같은 소리가 섞이면 그냥 진폭만 2배가 된 시끄러운 소리가 들릴 것이다.

 

즉, 하드웨어적으로 동시에 출력할 수 있는 소리의 한계가 있다.

이런 문제를 해결하기 위해서는

전체 사운드 호출을 취합한 후 우선 순위에 따라 나열해서 수행해야 한다.

하지만 위의 예시에서는 오디오 API가 playSound() 하나씩 처리하기 때문에

요청을 한 번에 하나 밖에 볼 수 없다.

 

2. 요청을 모아서 한 번에 처리하는 것이 아니라 한 번에 하나씩이다.

 

뭐 아까는 다른 게임 시스템에서 playSound를 호출해서 실행했다.

하지만 멀티 코어 환경에서는 게임 시스템 자체를 별도의 스레드로 나누어 병렬 실행을 추구한다.

playSound API 가 동기식이기 때문에 코드는 호출한 쪽 스레드에서 실행된다.

즉, 여러 게임 시스템에서 playSound를 호출하면 여러 스레드에서 동시에 실행된다.

playSound 예제 코드에 동기화 처리가 없어서.. 문제가 생긴다.

 

3. 요청이 원치 않는 스레드에서 처리된다.

 

이 모든 문제의 원인은

오디오 엔진이 playSound만 나왔다하면 하던 일을 멈추고 사운드를 재생하고 싶어하기 때문에 생긴 일이다.

"즉시성"이 문제다. 

다른 게임 시스템에서는 자기가 편할 때 playSound를 호출해서 재생하지만

오디오 엔진 입장에서는 호출받았을 때가 사운드 요청에 가장 적절할 때인지는? 생각해봐야 한다.

이를 해결하기 위해서 요청을 받는 부분과 요청을 처리하는 부분을 분리하려고 하는 것이다!!!

 


 

요청에 가장 맞는 자료구조를 생각해보니

큐다.

큐는 우리가 줄을 서는 것과 비슷해서 먼저 온 사람이 먼저 무엇인가 서비스를 받는다.

즉, 시간순대로 무엇인가가 수행된다.

 

우리는 큐를 요청이나 알림을 저장하게끔 만들면 된다.

알림을 보내는 곳에서는 그저 요청을 큐에 넣는 동작만 수행하고 다시 리턴하면 된다. (비동기)

요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리할 수 있게 된다.

요청은 바로 처리될 수도 있고 해당 요청이 다른 곳으로 보내질 수도 있다.

이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링된다.

 


 

그렇다면 언제 써야하는가? 가 문제다.

메세지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면.. 명령 패턴이나 관찰자 패턴을 써도 되겠다.

하지만 우리는 메시지를 "보내는" 시점"처리"하는 시점을 분리하고자 하는 것이다.

** 보내는 쪽에서 처리 응답을 "꼭" 받아야 한다면 굳이 큐를 쓸 필요는 없다. 

 


 

이벤트 큐 패턴은 게임 구조에 전반적으로 영향을 줄 수 있기 때문에

꼭 써야하는가? 어떻게 써야하는가? 에 대해 많은 생각을 해봐야 한다.

 

앞서 설명했듯이

이벤트 큐가 중앙 통신 수단이 될 수 있는데

강력한 시스템이라고 항상 좋기만 한 것은 아니다.

즉, 전역 변수 같이 어디에서나 접근 하는 중앙 ㅇㅇ이 생긴다면 

무엇인가 의존성 문제가 생기기 마련이다.

이벤트 큐 패턴도 마찬가지다.

중앙 이벤트 큐를 간단한 프로토콜로 래핑하지만 그래도 관련된 문제가 생기는 것이다.

 

또한 게임은 이벤트 처리 여부와 상관없이 진행되는 경우도 있기 때문에 생기는 문제가 있다.

예를 들어 보스 타임어택 퀘스트이지만.. 해당 이벤트 처리가 나중에 된다?

즉, 실제 게임의 상태와 이벤트 처리 시간의 분리로 인해 제대로 처리되지 않는 문제가 생긴다.

 

마지막으로 피드백 루프같은 것에 빠질 수 있다.

모든 이벤트, 메시지 시스템은 순환이 생길 수 있는데 이를 피해야 한다.

 

1. A가 이벤트를 보냄

2. B가 이벤트를 받아서 처리 후 다른 이벤트를 보냄

3. 이 이벤트가 A가 처리해줘야 하는 작업이기 떄문에 다시 A가 받아서 응답에 대한 이벤트를 다시 보냄

4. 2번이 다시 수행됨.

 

메시징 시스템같은 경우는 동기적이라면 스택 오버플로 에러로 쉽게 찾을 수 있지만

문제는 큐 시스템일 때는 비동기다 보니 콜스택이 풀려서 A와 B가 쓸데없이 이벤트를 주고 받음에도 

게임이 실행되므로 찾지 못할 수 있다.

이 문제는 일반적으로 이벤트를 처리하는 코드 자체에서는 이벤트를 보내지 않도록 설계해서 해결한다.

 


 

예제로 다시 한 번 배워보자.

앞에서 본 오디오의 playSound는 완벽하게는 아니어도 적절하게 작동은 했다.

바로 직전에 언급했던 문제를 해결해보자.

 

1. API의 blocking

사운드 함수를 실행하면 playSound()가 리소스를 로딩해 실제로 스피커로 출력되기까지 기다려야 했다.

즉, playSound가 바로 리턴하게 만드려면 사운드 출력 작업을 지연시킬 수 있어야 한다.

요청을 보류했다가 나중에 사운드를 출력할 때 필요한 정보를 저장할 수 있도록 간단한 구조체를 만들어보자

 

struct PlayMessage
{
  SoundId id;
  int volume;
};

 

해당 구조체는 Audio 클래스가 보류된 사운드 관련 메시지를 저장해둘 수 있도록 저장 공간을 만든 것이다.

다음 코드를 보자

 

class Audio
{
public:
  static void init()
  {
    numPending_ = 0;
  }

  // Other stuff...
private:
  static const int MAX_PENDING = 16;

  static PlayMessage pending_[MAX_PENDING];
  static int numPending_;
};

 

배열 크기는 최악의 경우에 맞춰서 조정해주고

소리를 내려면 배열 맨 뒤에 메시지를 넣으면 된다.

 

void Audio::playSound(SoundId id, int volume)
{
  assert(numPending_ < MAX_PENDING);

  pending_[numPending_].id = id;
  pending_[numPending_].volume = volume;
  numPending_++;
}

 

이렇게 하면 playSound 자체는 바로 return이 되어 다른 일도 할 수 있다. 

물론 사운드를 바로 출력하지는 않았다.

사운드 출력 코드는 update에 있는 것이 더 알맞아 보인다.

 

class Audio
{
public:
  static void update()
  {
    for (int i = 0; i < numPending_; i++)
    {
      ResourceId resource = loadSound(pending_[i].id);
      int channel = findOpenChannel();
      if (channel == -1) return;
      startSound(resource, channel, pending_[i].volume);
    }

    numPending_ = 0;
  }

  // Other stuff...
};

 

이제 update를 적당한 곳에서 호출하면 된다.

여기서 적당한 곳이란 상홍에 따라 달라진다.

 

위와 같이 만들면 update를 한 번 호출해서 모든 사운드 요청을 다 처리할 수 있다고 가정하고 있다.

사운드 리소스가 로딩된 다음에 비동기적 요청을 처리해야 한다면 이렇게는 안 된다.

update에서 한 번에 하나의 요청만 처리하게 하기 위해선

버퍼에서 요청을 하나씩 꺼내야 한다. 즉, 그저 단순 배열이 아닌 진짜 큐로 구성해야 한다는 말이다.

 

**원형 버퍼가 가장 이상적으로 보인다. 

원형 버퍼는 단순 배열의 장점은 가지면서도 큐 앞에서 순차적으로 데이터를 가져오게끔 한다.

또한 해당 버퍼를 연결리스트로 구현하면 데이터를 옮기지 않아도 접근할 수 있어 좋다.

 

playSound가 실행된다면 큐 맨 뒤에 요청을 추가하면 된다.

머리는 0번부터 시작하며 꼬리는 오른쪽으로 길어진다.

 

 

코드를 보자

인덱스 대신 head_, tail_이 추가되었다.

class Audio
{
public:
  static void init()
  {
    head_ = 0;
    tail_ = 0;
  }

  // Methods...
private:
  static int head_;
  static int tail_;

  // Array...
};

 

playSound에서는 그저 numPending_이 tail_로 바뀌었을 뿐이다.

void Audio::playSound(SoundId id, int volume)
{
  assert(tail_ < MAX_PENDING);

  // Add to the end of the list.
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_++;
}

 

update는 변경사항이 바뀌었다.

보류된 요청이 없다면 하는 일이 없다.

void Audio::update()
{
  // If there are no pending requests, do nothing.
  if (head_ == tail_) return;

  ResourceId resource = loadSound(pending_[head_].id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, pending_[head_].volume);

  head_++;
}

 

머리가 가리키는 요청을 처리한 후 포인터를 옮겨서 요청을 처리해나간다.

원형 버퍼이기 때문에 밀거나 당기지 않아도 만들어진 배열의 크기만큼은 완전히 온전히 사용할 수 있다는 장점이 있다.

 

 

 

꼬리가 끝에 도달하면 다시 배열의 앞으로가서 빈 부분에 저장해넣는다.

그저 ++ 처럼 후위연산이 아닌 크기로 다시 나누어 값을 받아서 원형 버퍼를 구현한다.

 

void Audio::playSound(SoundId id, int volume)
{
  assert((tail_ + 1) % MAX_PENDING != head_);

  // Add to the end of the list.
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_ = (tail_ + 1) % MAX_PENDING;
}

 

만약 꼬리와 머리가 충돌하게 되는 불상사가 생기지 않도록 assert문을 만들어서 미리 방지한다.

void Audio::update()
{
  // If there are no pending requests, do nothing.
  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를 지연시키는 일은 했다.

하지만 같은 요청에 대해서 2번 피격음을 재생하여 그냥 소리만 2배가 되는 좋지 않은 경우를 해결해야 한다.

즉, 요청을 취합하는 일이다.

이제는 큐를 조사하면 대기 중인 이벤트, 요청이 무엇인지 알 수 있기 때문에

같은 요청이 있다면 병합해버리면 된다.

 

void Audio::playSound(SoundId id, int volume)
{
  // Walk the pending requests.
  for (int i = head_; i != tail_;
       i = (i + 1) % MAX_PENDING)
  {
    if (pending_[i].id == id)
    {
      // Use the larger of the two volumes.
      pending_[i].volume = max(volume, pending_[i].volume);

      // Don't need to enqueue.
      return;
    }
  }

  // Previous code...
}

 

같은 소리를 출력하려는 요청이 먼저 들어와 있다면, 둘 중 소리가 큰 값 하나로 합쳐진다.

이런 "취합" 과정은 굉장히 기초적이지만, 더 재미있는 배치 작업도 같은 방식으로 처리할 수 있다.

 

요청을 처리할 때가 아니라 큐에 넣기 전에 취합이 일어난다는 점에 주의하자

어차피 취합하면서 없어질 요청을 굳이 큐에 넣을 필요가 없으니 이것이 더 효율적이다.

** 호출하는 쪽의 처리 부담이 늘어나긴 한다.

 

playSound는 전체 큐를 쭉 돈 다음에야 return 하기 때문에 큐가 커지면 느려지는데

즉, update에서 요청을 처리할 때 취합하는 것이 나을 수 있다.

 

여기에서 짚고 넘어갈 점이 있는데

취합 가능한 최대 "동시" 요청 수는 큐의 크기와 같다.

요청을 너무 빨리 처리하는 바람에 큐가 거의 비어있다면 요청을 합칠 가능성이 그만큼 줄어든다.

반대로 요청 처리가 늦어져 큐가 거의 차있다면 합칠만한 요청을 찾을 가능성이 높다.

 

이벤트 큐 패턴은 요청자가 실제로 요청이 언제 처리되는지를 모르게 한다.

하지만 큐가 이런 식으로 상황에 따라 다르게 반응한다면

큐에 넣은 요청이 실제로 처리될 때까지 걸리는 시간이 동작에 영향을 미칠 수 있다.

 

그 문제마저 해결됐다면 마지막 문제다.

멀티 스레드

동기식으로 만든 오디오 API에서는 playSound를 호출한 스레드에서 요청도 같이 처리했다.

그리 바람직하지는 않다.

그래서 멀티 코어 시스템에서는 멀티 스레드를 사용해서 하드웨어의 성능을 최대한 이끌어내야 한다.

스레드에 코드를 분배하는 법은 다양하지만, 오디오, 렌더링, AI 같이 분야별로 할당하는 것이 보통이다.

 

멀티 코어에 적용하기 위해 조건 3개를 준비해뒀다.

 

1. 사운드 요청 코드와 사운드 재생 코드는 분리됨

2. 양쪽 코드 사이의 마샬링, marshalling 을 제공하기 위한 큐가 있음

3. 큐는 나머지 코드로부터 캡슐화됨

 

이제 큐를 변경하는 코드인 playSound와 update 를 thread-safe 하게 만들기만 하면 된다.

보통은 큐가 동시에 수정되는 것을 막으면 된다.

반응형
그리드형