이벤트 큐 (Event Queue)
메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.
동기
UI의 버튼을 클릭하는 등 프로그램과 상호작용할 때마다 OS는 이벤트를 만들어서 프로그램 쪽으로 전달하고, 프로그램은 이를 받아서 이벤트 핸들러 코드에 전달하여 원하는 행동을 하도록 처리해야 한다.
보통 이런 이벤트들을 받기 위해서 코드 깊숙한 곳 어딘가에 이벤트 루프가 작성되어 돌아간다.
while (running) {
Event event = getNextEvent();
// .. 이벤트 처리
}
대략적으로 이런식으로 생겼다.
getNextEvent를 호출하여 아직 처리되지 않은 사용자 입력을 가져오고 이벤트 핸들러로 보내면 정의된 동작을 수행한다. 이 때, 프로그램이 원할 때 이벤트를 가져오는 것이지 OS가 프로그램의 코드를 바로 호출하는 것이 아니다.
OS는 프로그램이 이벤트를 가져가기 전까지 큐에 보관해둔다.
보통 OS의 큐를 이용하기 보다는 게임에 알맞게 자체 제작된 이벤트 큐를 만들어서 중추 통신 시스템으로 활용한다.
게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준의 통신을 하고 싶을 때 이벤트 큐를 사용한다.
특정한 인게임 이벤트가 발생할 때 팝업 도움말을 보여주는 튜토리얼 시스템을 만든다고 가정해보자.
게임플레이와 전투와 관련된 코드는 이미 충분히 복잡하게 구현되어있기 때문에 튜토리얼 시스템과 관련된 코드를 끼워넣어서 더 복잡하게 만드는 대신 중앙 이벤트 큐를 만들면 어느 시스템에서도 큐에 이벤트를 보낼 수 있게된다.
각 시스템들은 큐의 존재만 알뿐, 그 외 다른 시스템을 모르더라도 어떤 이벤트가 발생한 사실을 전달할 수 있다.
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);
}
이번에는 위와 같은 오디오 엔진을 간단하게 구현해서 적용해본다고 해보자. 정적 함수이기 때문에 어디서든 호출할 수 있다. 그런데 반복적으로 호출하다보면 몇 프레임씩 멈출 때가 있다. 그 이유를 알아보자.
◾ 문제 1: API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 블록한다
playSound 함수는 동기적이라서 내부적으로 호출한 API의 실행이 끝나기 전까지 계속 대기한다.
그 와중에 리소스를 로딩하는 작업까지 해야한다면 더 오래 대기해야 하고 그동안 게임은 멈추게된다.
게다가 몬스터 여러마리가 동시에 피격되기라도 하면 파형이 더해지기 때문에 소리가 매우 커져서 거슬리게 된다.
이런 문제는 전체 사운드 호출을 취합하고 우선순위에 따라 나열한 후 처리해야하지만 동기적으로 처리하기 때문에 해결할 수 없다.
◾ 문제 2: 요청을 모아서 처리할 수가 없다
설령 오디오용 스레드를 별도로 만든다 하더라도 문제가 더 심각해질 뿐 전혀 해결되지 않는다.
◾ 문제 3: 요청이 원치 않는 스레드에서 처리된다
결론적으로 원인은 playSound 호출 시, 하던 일을 멈추고 사운드를 즉시 처리하라고 해석하는 것에 있다.
패턴
큐는 요청이나 알림을 들어온 순서대로 저장한다. 알림을 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. 이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링한다.
언제 쓸 것인가?
단순히 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 관찰자 또는 명령 패턴을 사용하면 된다.
이벤트 큐는 송수신 시점까지 분리하고 싶을 때에만 필요한 것이다.
요청을 처리하는 제어권은 수신측에서 처리하는게 자연스럽고 편하다. 받은 요청을 지연시키거나 모아서 한번에 처리할 수 있고 심지어 요청을 버릴수도 있다. 만약 송신 측에서 피드백을 받아야 한다면 큐를 사용하는 것이 적합하지 않다.
주의사항
이벤트 큐는 전체 시스템의 맨 위에서 서로 메시지를 주고받게 해주는 통로 역할로 많이 사용된다.
사실상 전역 변수와 같기 때문에 아무리 조심해도 상호 의존성 문제가 생기게 되어있다.
그나마 이벤트 큐 패턴은 중앙 이벤트 큐를 간단한 프로토콜로 깔끔하게 래핑하지만 그래도 결국 전역이기 때문에 그와 관련된 문제가 사라지지 않는다.
월드의 상태는 언제든지 변화한다는 것도 이벤트 큐 시점에서 바라보면 문제가 될 수 있다.
주변의 정보를 이용하는 이벤트가 지연 처리된다면 지연되는 그 사이에 정보가 사라질 가능성이 있다.
이벤트가 만들어진 시점의 월드와 처리되는 시점의 월드의 상태가 다를 수 있다는 점을 매우 주의해야한다.
그래서 동기적으로 처리되는 이벤트보다 큐에 들어가는 이벤트에는 데이터가 훨씬 많이 필요하게 된다.
피드백 루프에 빠질 수 있다는 점도 주의해야한다.
메시징 시스템이 동기적이면 크래시가 발생하기 때문에 디버깅이 어렵지 않겠지만 비동기로 처리되다 보니 콜스택이 풀려서 쓸데없이 계속 이벤트를 주고받아서 자원을 갉아먹고 있음에도 불구하고 게임은 계속 실행된다.
보통 이런 문제를 피하기 위해서 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는다.
예제
struct PlayMessage {
SoundId id;
int volume;
};
지연처리할 때 필요한 정보를 저장하는 간단한 구조체를 정의했다.
class Audio {
public:
static void init() { numPending_ = 0; }
static void playSound(SoundId id, int volume);
static void update();
// ...
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_++;
}
void Audio::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;
}
이제 사운드 재생을 즉시 처리하지 않고 큐에 집어넣게 된다.
update 함수는 메인 게임 루프나 오디오 스레드 등 적당한 곳에서 호출해주면 된다.
그런데 위의 방법은 아직 반쪽짜리 큐다. update를 한 번 호출하면 모든 사운드 요청을 다 처리하기 때문에 여전히 동기적이다.
◾ 원형 버퍼
class Audio {
public:
static void init() {
head_ = 0;
tail_ = 0;
}
static void playSound(SoundId id, int volume);
static void update();
private:
static int head_;
static int tail_;
};
void Audio::playSound(SoundId id, int volume) {
assert(tail_ < MAX_PENDING);
pending_[tail_].id = id;
pending_[tail_].volume = volume;
tail_ = (tail_ + 1) % MAX_PENDING;
}
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;
}
인덱스 대신 배열을 쪼개서 head(가장 오래 보류된 메시지)와 tail(새로운 메시지가 들어갈 위치)로 관리한다.
또한 update호출 시 한번에 모든 요청을 처리하는 대신에 한 번에 하나만 처리하도록 변경되었다.
◾ 요청 취합
원형버퍼를 사용함으로써 큐는 완성되었지만 파형의 합성으로 인해 같은 소리를 동시에 재생하면 커지는 문제부터 해결해보자.
큐를 사용함으로써 대기중인 요청을 확인할 수 있게 되었으므로 버퍼를 탐색하여 같은 요청이 있으면 병합시켜 버리면 된다.
같은 소리를 출력하라는 요청이 있으면 둘 중에 소리가 더 큰 값으로 덮어씌우고 요청을 큐에 넣지 않는다.
요청을 처리할때가 아니라 큐에 넣기 전에 취합이 일어난다는 점에 주의해야한다.
대신 호출하는 쪽의 처리 부담이 늘어나는 단점은 있지만 구현이 더 쉽다는 장점이 있다.
만약 큐가 크다면 요청을 처리할 때 취합하는게 나을 수도 있다. 또는 배열대신 해시 테이블 같은것을 사용해서 시간 복잡도를 줄이는 방법도 고려해 볼 수 있다.
큐에 메시지가 너무 오래 남아있으면 의도치 않은 동작이 될 수도 있으니 그점도 유의해야한다.
◾ 멀티스레드
스레드에 코드를 분배하는 방법은 다양하지만 오디오, 렌더링, AI 등 분야 또는 컴포넌트 별로 할당하는 전략을 많이 사용하는 편이다.
이미 코드도 분리되어 있고 큐는 캡슐화 되어있기 때문에 큐를 변경하는 코드인 playSound와 update만 thread-safe 하게 만들기만 하면 된다.
고수준에서만 언급하자면 큐가 동시에 수정되는 것만 막아내면 된다.
playSound는 일부 필드에만 값을 할당할 뿐 작업량이 많지 않기 때문에 mutex를 적용해도 그리 오래 걸리지 않고 update는 조건 변수 같은것으로 대기하게 하면 처리할 요청이 없는 동안 CPU의 낭비를 막을 수 있다.
디자인 결정
◾ 큐에 무엇을 넣을 것인가?
이벤트와 메시지는 비슷한 개념이라 용어를 혼용하며 사용했지만 실제 개념은 약간 다르다.
◾ 이벤트 : 이미 발생한 사건을 표현한다.
큐에 이미 발생한 일이 들어 있기 때문에 송신자는 수신자가 누군지 신경쓰지 않는다. 그래서 다수의 리스너를 지원해야 할 때도 많다.
비동기 관찰자 패턴 같은 방식으로 이벤트에 대해 반응할 수 있다.
◾ 메시지 : 나중에 실행했으면 하는 행동을 표현한다.
특정 리스너 하나가 요청을 들어줬으면 해서 메시지를 보내기 때문에 대부분은 리스너가 하나이다.
서비스에 비동기적으로 API을 호출하는 것과 비슷하다.
◾ 누가 큐를 읽는가?
◾ 싱글캐스트 큐 : 예제의 Audio처럼 큐 자체가 어떤 클래스의 API 일부일 때 적합하다.
◾ 브로드캐스트 큐 : 대부분의 이벤트 시스템이 브로드캐스트 큐이다. 리스너가 10개이고 이벤트가 하나 들어오면 리스터 모두가 해당 이벤트를 볼 수 있다.
만약 리스너가 하나도 없다면 이벤트가 버려진다.
또한 이벤트 개수×리스너 개수만큼 이벤트 핸들러가 호출되기 때문에 호출 횟수 조절을 위한 필터링이 필요할 수 있다.
◾ 작업 큐 : 브로드캐스트 큐와 마찬가지로 리스너가 여러개지만 큐의 데이터가 하나의 리스너에게만 전달되는 차이가 있다.
스레드 풀에 작업을 분배할 때 일반적으로 사용한다.
◾ 누가 큐에 값을 넣는가?
◾ 넣는 측이 하나일 때 : 동기형 관찰자 패턴에 가까운 형태이다. 하나의 특정 객체에서만 이벤트를 만들 수 있기 때문에 모든 리스너들은 데이터가 어디서 오는지 안전하게 추측할 수 있다.
◾ 넣는 측이 여러개일 때 : 피드백 루프 발생을 주의해야 한다. 또한 이벤트를 누구나 보낼 수 있기 때문에 리스너에서 보낸 쪽에 정보가 필요하다면 송신측의 객체 레퍼런스도 같이 전달해 줄 필요가 있다.
◾ 큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?
GC를 지원하는 언어에서는 크게 신경쓰지 않아도 되지만 C/C++에서는 객체의 생명을 직접 관리해야한다.
◾ 소유권을 전달하는 경우 : 메시지가 큐에 들어가고 나면 소유권은 송신자에서 큐에 넘어간다. 메시지를 처리할때는 수신 측에서 소유권을 가져가고 메시지 해제도 같이 해야한다. 이런 방식으로 동작하는 것이 unique_ptr이다.
◾ 소유권을 공유하는 경우 : 메시지를 참조하는 곳이 하나라도 있으면 계속 메모리에 남아있어야 한다. 이런 방식으로 동작하는 것이 shared_ptr이다.
◾ 큐가 소유권을 가지는 경우 : 송신 측에서 메시지를 직접 생성하지 않고, 큐에 새로운 메시지를 하나 달라고 요청한다. 큐는 미리 할당해놓은 메시지의 레퍼런스를 반환하고 송신 측에서 값을 채우면 수신측에서는 이 메시지를 참조한다.
말하자면 객체 풀이 큐의 보조 기억장치가 되는 셈이다.
이벤트 큐는 사실상 관찰자 패턴의 비동기형이다.
'도서 > 게임 프로그래밍 패턴' 카테고리의 다른 글
최적화 패턴 : 데이터 지역성 (0) | 2023.03.03 |
---|---|
디커플링 패턴 : 서비스 중개자 (0) | 2023.02.28 |
디커플링 패턴 : 컴포넌트 (0) | 2023.02.27 |
행동 패턴 : 타입 객체 (0) | 2023.02.27 |
행동 패턴 : 하위 클래스 샌드박스 (0) | 2023.02.24 |