소년코딩

이벤트 큐 패턴, Event Queue Pattern

메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.

GUI 이벤트 루프

UI 프로그래밍을 한 번이라도 해봤다면 이벤트를 알 것이다. 버튼을 클릭하거나 메뉴를 선택하거나 키보드를 눌러서 프로그램과 삭호작용할 때마다, 운영체제는 이벤트를 만들어 프로그램 쪽으로 전달한다. 프로그램에서는 이를 받아서 원하는 행위츨 처리하도록 이벤트 핸들러 코드에 전달해야 한다.

이벤트를 받기 위해서는 코드 깊숙한 곳 어딘가에 이벤트 루프가 있어야 한다. 이벤트 루프 코드는 대강 이렇게 생겼다.

while(running) {
    Event event = getNextEvent();
    // 이벤트를 처리한다...
}

getNextEvent()는 아직 처리하지 않은 사용자 입력을 가져온다. 이를 이벤트 핸드러로 보내면 마법처럼 애플리케이션이 살아 움직인다. 여기에서 재미있는 점은 애플리케이션이 자기가 자기가 원할 때 이벤트를 가져온다는 사실이다. 사용자가 주변기기를 눌렀다고 해서 OS에서 우리 쪽 애플리케이션 코드를 바로 호출하는 것은 아니다.

이벤트를 원할 때 가져올 수 있다는 애기는 OS가 디바이스 드라이버로부터 입력 값을 받은 뒤 애플리케이션 getNextEvent()로 가져갈 떄까지 그 값을 어딘가엔가 저장해 둔다는 뜻이다. 그 '어딘가'가 바로 큐(Queue)이다.

event1

사용자 입력이 들어오면, OS는 이를 아직 처리 안된 이벤트 큐에 추가한다. getNextEvent()는 가장 먼저 들어온 이벤트부터 큐에서 꺼내 애플리케이션에 전달한다.

사운드 시스템

사운드 시스템을 예제로 보자. 사운드 시스템을 추가하기 위해 가장 간단한 방법부터 적용해본 뒤에 어떻게 돌아가는지를 볼것이다. 아이디와 볼륨을 받아 사운드를 출력하는 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);
        // 그 외...
    }
};

이 상태에서 메뉴를 옮겨다니다 보면 화면이 몇 프레임 정도 멈출 때가 있다. 뭔가 문제 일까??

| 문제 1: API는 엔진이 요청을 완전히 처리할 떄까지 호출자를 블록(block)한다. |

playSound() 는 동기적(syschronous)이다. 스피커로부터 삑 소리가 나기 전까지 API는 블록된다. 사운드 파일을 먼저 디스크에서 로딩하기라도 해야 한다면 더 오래 기다려야 한다. 그동안 게임은 멈춘다.

문제는 이뿐만이 아니라 몹이 플레이어에게 피해를 입으면 비명 소리를 내도록 코드를 추가했다고 해보자. 만약 몹 두마리가 한 프레임에 같이 맞는다면 같은 비명 소리를 동시에 두 개 틀어야 한다. 같은 소리 파형 두 개를 동시에 출력하면, 하나의 소리를 두 배 크기로 트는 것과 같아서 거슬리게 들린다.

이런 문제를 해결하려면 전체 사운드 호출을 취합하고 우선순위에 다라 나열해야 한다. 하지만 위에서 만든 오디오 API는 playSound()를 하나씩 처리하기 때문에 사운드 요청을 한 번에 하나밖에 볼 수 없다.

| 문제 2: 요청을 모아서 처리할 수가 없다. | 

이제까지는 여러 다른 게임 시스템에서 playSound()를 마음대로 호출했다. 하지만 최신 멀티코어 하드웨어에서 실행한다면 어떨까? 멀티코어를 최대한 활용하려면 렌더링용 스레드, AI용 스레드처럼 게임 시스템들을 별로듸 스레드로 나눠야 한다.

playSound API가 동기식이기 때문에 코드는 호출한 쪽 스레드에서 실행된다. 여러 다른 게임시스템에서 playSound를 호출하면 여러 스레드에서 동시에 실행된다.

오디오용 스레드를 별도로 만들면 문제가 더 심각해진다. 오디용 스레드는 다른 스레드가 바쁘게 서로를 침법하고 꺠먹는 동안 아무것도 하지 않고 멍하니 있을 뿐이다.

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

이 모든 문제의 원인은 playSound() 호출하는데 있어 즉시성이 문제다. 이를 해결하기 위해 요청을 받는 부분과 요청을 처리하는 부분을 분리해야 한다.


패턴

요청이나 알림을 들어온 순서대로 저장한다. 알리는 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. 이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링한다.


예제 코드

앞에서 본 Audio::playSound() 는 완벽하지는 않지만 공개 API를 통해 적적한 저수준 오디오 시스템을 호출한다는 기본 기능을 제공한다. 이제 앞에서 봤던 문제들만 해결하면 된다.

첫 번째 문제는 API가 블록된다느 점이다. 사운드 함수를 실행하며 playSound()에서 리소스를 로딩해 실제로 스피커에서 소리가 나오기 전에는 아무것도 못 하고 기다려야 했다.

playSond()가 바로 리턴하게 만들려면 사운드 출력 작업을 지연시킬 수 있어야 한다. 요청을 보류해놨다가 사운드를 출력할 때 필요한 정보를 저장할 수 잇도록 간단한 구조체부터 정의한다.

struct playMessage {
    SoundId id;
    int volume;
};

Audio 클래스가 보류된 사운드 관련 메시지를 저장해둘 수 있도록 저장 공간을 만들자. 연결리스트 보다는 기본 배열을 사용해보자. 기본 배열의 장점은 다음과 같다.

  • 동적 할당이 필요 없다.
  • 메모리에 추가 정보나 포인터를 저장하지 않아도 된다.
  • 메모리가 이어져 있어서 캐시하기 좋다.
class Aundio {
public:
    static void init() { numPending_ = 0; }

    // More...

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()를 바로 리턴시킬 수 있다. 물론 아직 사운드를 출력하지 않았다. 사운드 출력 코드는 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;
    }

    // More...
};

이제 Audio::update()를 어딘가 적당한 곳에서 호출하면 된다. '적당한 곳'은 상황에 따라 다르다. 메인 게임 루프에서 호출해도 되고. 별도의 오디오 스레드에서 호출해도 된다.

이렇게 하면 동작은 하지만, update()를 한 번 호출해서 모든 사운드 요청을 다 처리할 수 있다고 가정하고 있다. 사운드 리소스가 로딩된 다음에 비동기적으로 요청을 처리해야 한다면 이렇게는 안 된다. update()에서 한 번에 하나의 요청만 처리하게 하려면 버퍼에서 요청을 하나씩 꺼낼 수 있어야 한다. 즉, 진짜 큐가 필요하다.

원형 버퍼

큐를 구현하는 방법은 다양하지만, 원형 버퍼를 사용해보자. 원형 버퍼는 일반 배열의 장점은 다 있으면서도 큐 앞에서부터 순차적으로 데이터를 가져올 수 있다.

  • 머리(head)는 큐에서 요청을 읽을 위치다. 가장 먼저 보류된 요청을 가리킨다.
  • 꼬리(tail)는 반대다. 배열에서 새로운 요청이 들어갈 자리를 가리킨다. 꼬리는 큐에 마지막 요청의 다음칸을 가리킨다는 점에 주의하자.

playSound()는 배열 맨 뒤에 요청을 추가한다. 머리는 0번 인덱스에 있고 꼬리는 오른쪽으로 증가한다.

event2

코드를 보자. 먼저 원래 인덱스 대신 머리(head_)와 꼬리(tail_)를 멤버 변수로 추가한다.

class Audio {
public:
    static void init() {
        head_ = 0;
        tail_ = 0;
    }
    // 메서드...

private:
    static int head_;
    static int tail_;
    // 배열...
};

playSound()에서는 numPending_이 tail_로 바뀌었을 뿐 나머지는 그대로다.

void Audio::playSound(SoundId id, int volume) {
    assert(tail_ < MAX_PENDING);
    // 배열 맨 뒤에 추가한다.
    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_++;
}

update()의 변경사항이 더 흥미롭다.

void Audio::update() {
    // 보류된 요청이 없다면 아무것도 하지 않는다.
    if(head_ == tail) return;
    ResouceId resource = loadSound(pending_[head_].id);
    int channel = findOpenChannel();
    if(channel == -1) return;
    startSound(resource, channel, pending_[head_].volume);
    head_++;
}

머리가 가리키는 요청을 처리한 후에는 머리 포인터를 오른쪽으로 옮겨서 요청 값을 버린다. 머리와 꼬리가 겹쳤는지를 보고 큐가 비었는지를 확인할 수 있다.

물론 문제가 있다. 큐를 통해서 요청을 처리하는 동안 머리와 꼬리는 계속 오른 쪽으로 이동한다. 언젠가 tail_이 배열 끝에 도달하면 더 이상 추가할 수가 없다. 이제부터 원형 버퍼의 진가가 드러난다.

event3

생각해보면 꼬리뿐만 아니라 머리도 오른쪽으로 움직인다. 더 이상 사용하지 않는 배열 값이 배열 에 쌓여 있는 셈이다. 그러니 꼬리가 배열 끝에 도달하면 다시 배열 앞으로 보내면 된다. 마치 원형 배열같이 동작하기 때문에 이런 큐를 원형 버퍼라고 부른다.

event4

구현은 굉장히 쉽다. 데이터를 큐에 넣을 때 꼬리가 배열 끝까지 가면 다시 배열 앞으로 보내면 된다.

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

    // 배열 맨 뒤에 추가한다.
    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_ = (tail_ + 1) % MAX_PENDING;
}

원래 tail_++였던 코드를 tail_에 1을 더한 뒤에 배열 크기로 나눈 나머지 값을 받게 바꿔서, 끝에 도달하면 맨 앞으로 가게 했따. 단언무도 추가해서 오버플로가 되지 않게 했다.

update()에서는 머리도 배열을 순회하도록 처리한다.

void Audio::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;
}

드디어 동적 할당도 필요 없고 데이터를 옮길 필요도 없고 단순 배열만큼이나 캐시하기 좋은 큐가 완성되었다. (큐의 최대 용량이 신경 쓰인다면 늘어나는 배열을 사용하면 된다. 큐가 꽉 차면 현재 큐의 두 배크기로 배열을 새로 만들어 데이터를 옮기는 방식이다.)

요청 취합하기

큐를 만들었으니 다음 문제로 넘어가자. 첫 번째 문제는 같은 소리를 동시에 틀면 소리가 너무 커지는 현상이었다. 이제는 대기 중인 요청을 확인할 수 있기 때문에 같은 요청이 있다면 병합해버리면 된다.

void Audio::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;
        }
    }
    // 이전 코드...
}

같은 소리를 출력하려는 요청이 먼저 들어와 있다면, 둘 중 소리가 큰 값 하나로 합쳐진다. 이런 '취합'과정은 굉장히 기초적이지만, 더 재미있는 배치작업도 같은 방식으로 처리할 수 있다.

요청을 처리할 때가 아니라, 큐에 넣기 전에 취합이일어난다는 점에 주의하자. 어차피 취합하면서 없어질 요청을 큐에 둘 필요도 없고, 구현하기도 더 쉽다.

멀티스레드

마지막으로 가장 골치 아픈 문제다. 동기식으로 만든 오디오 API에서는 playSound()를 호출한 스레드에서 요청도 같이 처리해야 했다. 그리 바람직하진 않다.

요즘 같은 멀티코어 하드웨어에서는 멀티스레드를 사용해 하드웨어의 성능을 최대한 끌어내야 한다. 스레드에 코드를 분배하는 방법은 다양하지만, 오디오, 렌더링, AI같이 분야별로 할당하는 전략을 많이 쓴다.

이미 멀티코어를 적용하기 위한 세 가지 주요 조건을 준비해 두었다.

  1. 사운드 요청 코드와 사운드 재생 코드가 분리되어 있다.
  2. 양쪽 코드 사이에 마샬링을 제공하기 위한 큐가 있다.
  3. 큐는 나머지 코드로부터 캡슐화되어 있다.

이제 큐를 변경하는 코드인 playSound()와 update()를 스레드 안전하게 만들기만 하면 된다. 고수준에서만 언급하자면 큐가 동시에 수정되는 것만 막으면 된다. playSound()는 몇몇 필드에 값만 할당할 뿐 작업이 많지 않기 때문에 블록을 해도 그리 오래 걸리지 않는다. update()에서는 조건 변수 같은 것으로 기다리게 만들면 처리할 요청이 없는 동안 CPU 낭비를 막을 수 있다.


More

  • 이벤트 큐는 잘 알려진 관찰자 패턴의 비동기형이다.
  • GoF의 상태 패턴과 유사한 유한 상태 기계(FSM)에서는 입력 값을 스트림(stream)으로 받는다. FSM이 입력에 비동기적으로 응답하게 하고 싶다면 입력을 큐에 넣어야 한다.
  • Go언어에서는 이벤트 큐나 메시지 큐로 사용하는 'channel'이라는 자료형을 언어 차원에서 지원한다.

이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/event-queue.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기