이벤트 큐 (Event Queue)
15.1. 의도
메시지나 이벤트를 보내는 시점과 처리하는 시점을 분리합니다.
15.2. 동기
인터넷이 들어오지 않는 산골짜기에 사는 게 아니라면 ‘이벤트 큐’라는 말을 들어보셨을 것입니다. ‘메시지 큐’, ‘이벤트 루프’, ‘메시지 펌프’ 같은 말이라도 들어보셨겠지요. 기억을 되살리기 위해 이벤트 큐의 일반적인 특징부터 훑어보겠습니다.
원주 1 이 장에서는 ‘이벤트’와 ‘메시지’를 함께 사용합니다. 정확하게 구별해야 할 때는 따로 언급하겠습니다.
GUI 이벤트 루프
UI 프로그래밍을 한 번이라도 해봤다면 이벤트를 잘 아실 것입니다. 버튼을 클릭하거나 메뉴를 선택하거나 키보드를 눌러 프로그램과 상호작용할 때마다, 운영체제는 이벤트를 만들어 프로그램으로 전달합니다. 프로그램에서는 이를 받아 원하는 행위를 처리하는 이벤트 핸들러 코드에 전달해야 합니다.
원주 2 이런 구조는 워낙 일반적이어서 이벤트 주도 프로그래밍(event-driven programming)이라는 하나의 패러다임이 되었습니다.
이벤트를 받으려면 코드 깊숙한 곳 어딘가에 이벤트 루프가 있어야 합니다. 이벤트 루프 코드는 대강 다음과 같습니다.
while (running) {
Event event = getNextEvent();
// 이벤트를 처리한다...
}
C++
복사
getNextEvent()는 아직 처리하지 않은 사용자 입력을 가져옵니다. 이를 이벤트 핸들러로 보내면 마법처럼 애플리케이션이 살아 움직입니다. 재미있는 점은 애플리케이션이 자기가 원할 때 이벤트를 가져온다는 사실입니다. 사용자가 주변기기를 눌렀다고 해서 OS가 애플리케이션 코드를 바로 호출하는 것은 아닙니다.
원주 3 이런 식으로 작동하는 것은 인터럽트입니다. 인터럽트가 발생하면 OS는 애플리케이션이 하던 작업을 중지한 뒤 인터럽트 핸들러로 실행 위치를 옮겨버립니다. 이런 갑작스러움 때문에 인터럽트 관련 코드는 다루기가 굉장히 어렵습니다.
이벤트를 원할 때 가져올 수 있다는 말은, OS가 디바이스 드라이버에서 입력값을 받은 뒤 애플리케이션이 getNextEvent()로 가져갈 때까지 그 값을 어딘가에 저장해 둔다는 뜻입니다. 그 ‘어딘가’가 바로 큐(queue) 입니다.
그림 15-1. 이벤트는 큐를 통해 OS에서 애플리케이션으로 전달됩니다.
사용자 입력이 들어오면 OS는 이를 아직 처리하지 않은 이벤트 큐에 추가합니다. getNextEvent()는 가장 먼저 들어온 이벤트부터 큐에서 꺼내 애플리케이션에 전달합니다.
그림을 읽는 방법 — OS가 입력을 넣는 시점과 프로그램이 꺼내는 시점은 서로 다릅니다. 큐가 둘 사이에서 입력을 보관하므로, 프로그램은 자신의 처리 흐름에 맞춰 이벤트를 가져올 수 있습니다.
중앙 이벤트 버스
이런 이벤트 주도 방식으로 구현된 게임은 거의 없지만, 게임에서 자체 이벤트 큐를 만들어 중추 통신 시스템으로 활용하는 경우는 흔합니다. 여기에는 ‘중앙’, ‘전역’, ‘메인’ 같은 형용사가 흔히 붙습니다. 게임 시스템들이 서로 분리된 상태를 유지하면서 고수준 통신을 하고 싶을 때 사용합니다.
원주 4 게임에서 이벤트 주도 방식을 쓰지 않는 이유가 궁금하다면 게임 루프(9장)를 참고하세요.
특정 인게임 이벤트가 발생할 때 풍선 도움말을 보여주는 튜토리얼 시스템을 만든다고 해봅시다. 예를 들어 플레이어가 괴물을 처음 잡으면 ‘X를 눌러 전리품을 획득하세요’라는 말풍선을 보여주고 싶습니다.
원주 5 튜토리얼 시스템은 깔끔하게 구현하기가 어렵고, 전체 플레이 시간에 비해 튜토리얼이 차지하는 구간이 얼마 되지 않아 의욕이 잘 나지 않습니다. 하지만 그 짧은 튜토리얼 구간이 플레이어가 게임에 쉽게 적응하는 데는 매우 중요할 수 있습니다.
게임플레이 코드와 전투 코드는 이미 충분히 복잡할 것입니다. 그런 코드 곳곳에 튜토리얼을 보여줄지 검사하는 코드를 끼워 넣고 싶지는 않을 것입니다. 대신 중앙 이벤트 큐를 만들면 어느 게임 시스템에서도 큐에 이벤트를 보낼 수 있습니다. 전투 코드라면 적을 죽일 때마다 ‘적이 죽었음’ 이벤트를 보내는 식입니다.
마찬가지로 모든 게임 시스템은 큐에서 이벤트를 받을 수 있습니다. 튜토리얼 코드는 ‘적이 죽었음’ 이벤트가 오면 알려달라고 큐에 자신을 등록해 둡니다. 이렇게 하면 두 시스템이 서로를 모르더라도 전투 시스템에서 튜토리얼 코드로 적이 죽었다는 사실을 전달할 수 있습니다.
원주 6 개체가 정보를 보내고 통지를 받을 수 있는 공용 공간이 있다는 점에서 AI 분야의 흑판(blackboard) 시스템과 비슷합니다. 한국어판 옮긴이는 흑판 시스템 참고 자료를 덧붙였습니다.
그림 15-2. 전투 시스템과 튜토리얼 시스템은 공유 큐를 통해 상호작용합니다.
튜토리얼 시스템을 예제로 삼아 이 장을 설명할까도 생각했지만, 저자는 기본적으로 거대한 전역 시스템을 좋아하지 않습니다. 이벤트 큐를 언제나 게임 전체의 통신 시스템으로 사용해야 하는 것은 아닙니다. 클래스 하나나 분야 하나 안에서도 유용하게 사용할 수 있습니다.
진짜?
그런 의미에서 튜토리얼 시스템 대신 사운드 시스템을 예제로 보겠습니다. 인간은 주로 시각에 의존하지만, 청각 역시 감정과 공간 지각에 강하게 연결되어 있습니다. 실감 나는 울림을 들려주면 검은 화면만으로도 거대한 동굴 속에 들어와 있는 듯한 착각을 불러일으킬 수 있습니다. 적절한 타이밍의 바이올린 소리는 플레이어의 마음을 울릴 수 있습니다.
사운드 시스템에 가장 간단한 방법부터 적용해 보고 어떻게 돌아가는지 살펴보겠습니다. 아이디와 볼륨을 받아 사운드를 출력하는 API를 제공하는 단순한 ‘오디오 엔진’부터 만듭니다.
class Audio {
public:
static void playSound(SoundId id, int volume);
};
C++
복사
원주 7 저자는 싱글턴 패턴(6장)을 최대한 피하는 편이지만, 사운드 시스템은 하드웨어에 보통 오디오 출력이 하나뿐이므로 싱글턴이 유용할 수 있다고 설명합니다. 여기서는 최대한 단순하게 구현하기 위해 정적 메서드로 만듭니다.
오디오 엔진은 적당한 사운드 리소스를 로딩하고 이를 출력할 수 있는 채널을 찾아 틀어줍니다. 특정 플랫폼의 오디오 API를 설명하는 것이 이 책의 목적은 아니므로, 오디오 시스템이 어딘가에 구현되어 있다고 가정하겠습니다. 메서드 구현은 다음과 같습니다.
void Audio::playSound(SoundId id, int volume) {
ResourceId resource = loadSound(id);
int channel = findOpenChannel();
if (channel == -1) return;
startSound(resource, channel, volume);
}
C++
복사
코드를 소스 관리 도구에 체크인하고 사운드 파일을 만들고 나면, 오디오 요정처럼 코드 여기저기에서 playSound()를 호출할 수 있습니다. 예를 들어 UI에서 선택한 메뉴가 바뀔 때 작게 삑삑 소리를 내고 싶다면 다음과 같이 하면 됩니다.
class Menu {
public:
void onSelect(int index) {
Audio::playSound(SOUND_BLOOP, VOL_MAX);
// 그 외...
}
};
C++
복사
이 상태에서 메뉴를 옮겨 다니다 보면 화면이 몇 프레임 정도 멈출 때가 있습니다. 무엇이 문제일까요?
문제 1: 요청이 완전히 처리될 때까지 호출자가 멈춥니다
playSound()는 동기적(synchronous)입니다. 스피커에서 삑 소리가 나기 전까지 API는 호출자를 블록(block)합니다. 사운드 파일을 먼저 디스크에서 로딩해야 한다면 더 오래 기다려야 합니다. 그동안 게임은 멈춥니다.
문제는 이것만이 아닙니다. 몹이 플레이어에게 피해를 입으면 비명을 내도록 AI 코드를 추가했다고 해봅시다. 가상 생명체가 고통에 차 지르는 비명보다 플레이어의 피를 들끓게 하는 것은 없을 것입니다.
처음에는 문제가 없어 보입니다. 그런데 영웅이 강공격을 하면 몹 두 마리가 한 프레임에 함께 맞을 수 있습니다. 그러면 같은 비명을 동시에 두 개 틀어야 합니다. 사운드에 대해 조금이라도 안다면 여러 소리가 섞일 때 파형이 더해진다는 사실을 아실 것입니다. 같은 소리 파형 두 개를 동시에 출력하면 하나의 소리를 두 배 크기로 트는 것과 같아 더 거슬리게 들립니다.
원주 8 저자는 《헨리 해츠워스 인 더 퍼즐링 어드벤처》에서 똑같은 문제를 겪었습니다. 해결책 역시 이제 설명할 방법과 거의 비슷했습니다.
부하들이 우르르 몰려 나오는 보스전에도 비슷한 문제가 있습니다. 하드웨어에서 동시에 출력할 수 있는 소리에는 한계가 있습니다. 그 이상이 되면 사운드 출력이 무시되거나 끊깁니다.
이 문제를 해결하려면 전체 사운드 호출을 취합하고 우선순위에 따라 나열해야 합니다. 하지만 지금 오디오 API는 playSound()를 하나씩 처리하므로 사운드 요청을 한 번에 하나밖에 볼 수 없습니다.
문제 2: 요청을 모아서 처리할 수 없습니다
이런 문제는 다음 문제에 비하면 그저 성가실 뿐입니다. 지금까지는 여러 게임 시스템에서 playSound()를 마음대로 호출했습니다. 그런데 최신 멀티코어 하드웨어에서 실행된다면 어떨까요? 멀티코어를 최대한 활용하려면 렌더링용 스레드, AI용 스레드처럼 게임 시스템을 별도의 스레드로 나누어야 합니다.
playSound() API는 동기식이므로 코드가 호출한 쪽 스레드에서 실행됩니다. 여러 게임 시스템이 이를 호출하면 여러 스레드에서 동시에 실행됩니다. 앞선 예제 코드에는 동기화 처리가 있었나요? 없었습니다.
오디오용 스레드를 별도로 만들면 문제는 더 심각해집니다. 다른 스레드들이 바쁘게 서로를 침범하고 깨뜨리는 동안, 오디오용 스레드는 아무것도 하지 않고 멍하니 있을 뿐입니다.
문제 3: 요청이 원치 않는 스레드에서 처리됩니다
이 모든 문제의 원인은 오디오 엔진이 playSound() 호출을 ‘하던 일을 멈추고 당장 사운드를 틀어!’라고 해석하는 데 있습니다. 즉시성이 문제입니다. 다른 게임 시스템은 자기가 편할 때 playSound()를 호출합니다. 하지만 오디오 엔진 입장에서 호출받은 시점이 언제나 사운드 요청을 처리하기에 적당한 것은 아닙니다. 이를 해결하기 위해 요청을 받는 부분과 처리하는 부분을 분리하려고 합니다.
15.3. 패턴
큐는 요청이나 알림을 들어온 순서대로 저장합니다. 알림을 보내는 곳은 요청을 큐에 넣고 결과를 기다리지 않은 채 반환합니다. 처리하는 곳은 큐에 들어 있는 요청을 나중에 처리합니다. 요청은 그곳에서 직접 처리할 수도 있고, 다른 여러 곳으로 보낼 수도 있습니다. 이를 통해 보내는 쪽과 받는 쪽을 코드뿐 아니라 시간 측면에서도 분리합니다.
관찰자와 비교해 보기 — 일반적인 관찰자 알림은 알리는 함수 안에서 수신자 코드를 바로 실행합니다. 이벤트 큐는 요청을 먼저 보관하고 나중에 실행합니다. 누가 누구를 아는지와 함께, 누가 처리 시점을 결정하는지도 비교해 보세요.
보충 GIF · A·B·C를 큐에 넣은 뒤 소비자가 먼저 들어온 항목부터 처리하는 FIFO 흐름
이 예에서는 생산자가 넣은 뒤 소비자가 나중에 처리합니다. 큐가 실행 시점을 분리한다는 뜻이며, 큐 자체가 자동으로 스레드를 만들거나 모든 시스템의 순서를 결정하는 것은 아닙니다.
15.4. 언제 쓸 것인가?
메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 관찰자 패턴(4장)이나 명령 패턴(2장)으로 더 간단히 처리할 수 있습니다. 메시지를 보내는 시점과 받는 시점을 분리하고 싶을 때만 큐가 필요합니다.
원주 9 반복되는 이야기지만 중요하니 다시 강조합니다. 복잡하면 작업이 느려집니다. 단순함을 귀한 자원으로 취급하세요.
밀어 넣기(pushing)와 가져오기(pulling)의 관점에서 생각해 봅시다. A 코드는 B 코드에 일을 요청합니다. A 입장에서는 B에게 요청을 밀어 넣는 것이 자연스럽습니다.
반대로 B 입장에서는 자기가 편할 때 요청을 가져와 처리하는 것이 편합니다. 한쪽에서는 밀어 넣고 다른 쪽에서는 가져온다면 둘 사이에 버퍼가 필요합니다. 이것이 다른 디커플링 패턴은 제공하지 못하고 큐가 제공할 수 있는 기능입니다.
큐는 요청을 받는 쪽에 제어권을 제공합니다. 받는 쪽은 처리를 지연하거나, 요청을 모아서 처리하거나, 전부 버릴 수도 있습니다. 보내는 쪽에서 제어권을 가져왔기 때문에 가능한 일입니다. 보내는 쪽이 할 수 있는 일은 큐에 요청을 넣고 잘 처리해 주기를 바라는 것뿐입니다. 보내는 쪽에서 처리 응답을 받아야 한다면 큐는 적합하지 않습니다.
15.5. 주의사항
이 책의 다른 단순한 패턴과 달리 이벤트 큐는 복잡하며, 게임 구조 전반에 영향을 미치는 경향이 있습니다. 그러므로 어떻게 사용할지, 혹은 정말 사용할 것인지 잘 생각해야 합니다.
중앙 이벤트 큐는 전역 변수와 같습니다
이벤트 큐는 모든 게임 시스템이 서로 메시지를 주고받는 복잡한 중앙역 같은 역할로 자주 사용됩니다. 강력한 기반 시스템이지만, 강력한 것이 언제나 좋은 것은 아닙니다.
우리 대부분은 오랜 경험 끝에 전역 변수가 좋지 않다는 사실을 배웠습니다. 어떤 상태에 프로그램 어디에서나 접근할 수 있다면 온갖 미묘한 상호의존성 문제가 생기기 마련입니다. 이벤트 큐 패턴은 중앙 이벤트 큐를 간단한 프로토콜로 깔끔하게 감싸지만, 그래도 전역이므로 관련 문제가 여전히 존재합니다.
월드 상태는 언제든 바뀔 수 있습니다
게임 속 가상 생명체가 번뇌를 벗고 세상을 떠날 때 AI 코드가 ‘개체가 죽었음’ 이벤트를 큐에 넣는다고 해봅시다. 이벤트를 꺼내 처리할 때쯤에는 몇 프레임이 지나 있을 것입니다.
동시에 경험치 시스템은 주인공이 몹을 얼마나 효율적으로 죽였는지에 따라 보상을 주려고 합니다. ‘개체가 죽었음’ 이벤트를 받으면 어떤 개체가 죽었는지, 얼마나 어려운 상대였는지에 따라 적절한 보상을 제공합니다.
이를 위해서는 다양한 월드 상태를 알아야 합니다. 어떤 개체가 죽었는지를 알아야 전투 난이도를 알 수 있습니다. 전투 당시 근처에 어떤 장애물이나 다른 몹이 있었는지도 필요할 수 있습니다. 그러나 이벤트가 바로 처리되지 않으므로 이런 정보가 사라질 수 있습니다. 개체가 할당 해제되었거나 주변 적이 떠났을 수도 있습니다.
이벤트를 받았을 때는 현재 월드 상태가 이벤트 생성 당시의 상태와 다를 수 있다는 점에 주의해야 합니다. 그래서 동기적으로 처리하는 이벤트보다 큐에 넣는 이벤트에는 훨씬 많은 데이터가 필요합니다. 동기 이벤트에서는 ‘뭔가 발생했다’는 알림을 받은 쪽이 곧바로 상황을 확인할 수 있습니다. 큐를 사용한다면 이런 일시적인 정보를 이벤트와 함께 담아야 나중에 사용할 수 있습니다.
게임에서 활용하기 — 처치 이벤트에 적 객체의 포인터만 저장하면 처리 전에 적이 사라질 수 있습니다. 경험치 계산에 필요한 적 종류, 처치 위치, 당시 보상 조건을 이벤트에 복사하거나, 수명을 확인할 수 있는 핸들과 명확한 유효성 규칙을 사용하세요.
피드백 루프에 빠질 수 있습니다
모든 이벤트·메시지 시스템에서는 다음과 같은 순환이 생기지 않도록 주의해야 합니다.
1.
A가 이벤트를 보냅니다.
2.
B가 이벤트를 받아 응답으로 다른 이벤트를 보냅니다.
3.
이 이벤트가 우연히 A에서 처리해야 하는 작업이라 A가 이벤트를 받습니다. A는 응답으로 다른 이벤트를 보냅니다.
4.
2번으로 돌아갑니다.
메시징 시스템이 동기적이라면 스택 오버플로로 중단되므로 순환을 금방 찾을 수 있습니다. 문제는 큐 시스템입니다. 비동기 처리라 호출 스택이 풀려서, A와 B가 쓸데없이 계속 이벤트를 주고받는데도 게임은 실행됩니다. 이를 피하는 일반적인 규칙은 이벤트를 처리하는 코드 안에서는 이벤트를 보내지 않는 것입니다.
원주 10 · 옮긴이 호출 스택이 ‘풀린다’는 것은 함수 호출이 끝난 뒤 자신이 쌓아 놓은 호출 스택을 제거한다는 뜻입니다.
원주 11 이벤트 시스템에 간단한 디버깅용 로거를 넣는 것도 괜찮은 방법입니다.
15.6. 예제 코드
앞서 본 Audio::playSound()는 완벽하지는 않아도 공개 API를 통해 적절한 저수준 오디오 시스템을 호출한다는 기본 기능은 제공합니다. 이제 앞에서 확인한 문제들을 해결하면 됩니다.
첫 번째 문제는 API가 블록된다는 것입니다. 사운드 함수를 실행하면 playSound()에서 리소스를 로딩하고 실제로 스피커에서 소리가 나오기 전까지는 아무것도 하지 못하고 기다려야 했습니다.
playSound()가 바로 반환하게 만들려면 사운드 출력 작업을 지연할 수 있어야 합니다. 요청을 보류했다가 나중에 사운드를 출력할 때 필요한 정보를 저장하도록 간단한 구조체부터 정의합니다.
struct PlayMessage {
SoundId id;
int volume;
};
C++
복사
Audio 클래스에 보류된 사운드 메시지를 저장할 공간을 만듭시다. 알고리즘 전공 교수라면 피보나치 힙(Fibonacci heap), 스킵 리스트(skip list), 하다못해 연결 리스트라도 추천할지 모릅니다. 하지만 실무에서 동일한 데이터를 저장하는 가장 좋은 방법은 대부분 기본 배열을 쓰는 것입니다. 기본 배열에는 다음 장점이 있습니다.
•
동적 할당이 필요 없습니다.
•
메모리에 추가 정보나 포인터를 저장하지 않아도 됩니다.
•
메모리가 이어져 있어서 캐시하기 좋습니다.
원주 12 알고리즘 연구자는 최신 자료구조를 연구하는 것이 일이다 보니 기본 자료구조는 덜 언급하게 됩니다.
원주 13 ‘캐시하기 좋은’ 방법은 데이터 지역성 패턴(17장)을 참고하세요.
다음 코드를 보겠습니다.
class Audio {
public:
static void init() { numPending_ = 0; }
// 그 외...
private:
static const int MAX_PENDING = 16;
static PlayMessage pending_[MAX_PENDING];
static int numPending_;
};
C++
복사
배열 크기는 최악의 경우에 맞추어 조정하면 됩니다. 소리를 내고 싶다면 배열 맨 뒤에 메시지를 넣습니다.
void Audio::playSound(SoundId id, int volume) {
assert(numPending_ < MAX_PENDING);
pending_[numPending_].id = id;
pending_[numPending_].volume = volume;
numPending_++;
}
C++
복사
이제 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;
}
// 그 외...
};
C++
복사
원주 14 이름에서 알 수 있듯 업데이트 메서드 패턴(10장)을 사용했습니다.
이제 Audio::update()를 적당한 곳에서 호출하면 됩니다. ‘적당한 곳’은 상황에 따라 다릅니다. 메인 게임 루프(9장)에서 호출해도 되고, 별도 오디오 스레드에서 호출해도 됩니다.
이렇게 하면 동작은 하지만, update() 한 번으로 모든 사운드 요청을 처리할 수 있다고 가정합니다. 사운드 리소스가 로딩된 뒤 비동기적으로 요청을 처리해야 한다면 이 방식은 맞지 않습니다. update()가 한 번에 하나의 요청만 처리하게 하려면 버퍼에서 요청을 하나씩 꺼낼 수 있어야 합니다. 즉, 진짜 큐가 필요합니다.
원형 버퍼 (Ring Buffer)
큐를 구현하는 방법은 다양하지만 저자는 원형 버퍼를 가장 좋아합니다. 원형 버퍼에는 일반 배열의 장점이 모두 있으면서도 큐의 앞에서부터 순차적으로 데이터를 가져올 수 있습니다.
원형 버퍼를 처음 접한다면 ‘배열 앞에서 데이터를 꺼내려면 나머지 데이터를 앞으로 옮겨야 하지 않을까? 느리지 않을까?’라고 생각할 수 있습니다.
그래서 우리는 연결 리스트를 배웠습니다. 연결 리스트는 나머지 값을 앞으로 옮기지 않고도 노드를 제거할 수 있습니다. 하지만 배열로도 나머지 값을 옮기지 않는 큐를 구현할 수 있습니다. 이를 보기 전에 용어부터 분명히 하겠습니다.
•
머리(head) 는 큐에서 요청을 읽을 위치입니다. 가장 먼저 보류된 요청을 가리킵니다.
•
꼬리(tail) 는 반대로 새로운 요청이 들어갈 배열 위치입니다. 큐에 있는 마지막 요청의 다음 칸을 가리킨다는 점에 주의하세요. 수학의 반열린 구간과 비슷하다고 생각해도 좋습니다.
원주 15 · 옮긴이 반열린 구간은 한쪽 끝점이 포함되지 않는 구간입니다. 이 예제에서는 [head, tail)로 쓸 수 있으며, 배열 인덱스 i의 범위가 head <= i < tail이라는 뜻입니다.
playSound()는 배열 맨 뒤에 요청을 추가합니다. 머리는 0번 인덱스에 있고 꼬리는 오른쪽으로 증가합니다.
그림 15-3. 배열을 이벤트로 채웁니다.
코드를 보겠습니다. 원래 인덱스 대신 머리(head_)와 꼬리(tail_)를 멤버 변수로 추가합니다.
class Audio {
public:
static void init() {
head_ = 0;
tail_ = 0;
}
// 메서드...
private:
static int head_;
static int tail_;
// 배열...
};
C++
복사
playSound()는 numPending_을 tail_로 바꾼 것 외에는 그대로입니다.
void Audio::playSound(SoundId id, int volume) {
assert(tail_ < MAX_PENDING);
// 배열 맨 뒤에 추가한다.
pending_[tail_].id = id;
pending_[tail_].volume = volume;
tail_++;
}
C++
복사
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_++;
}
C++
복사
머리가 가리키는 요청을 처리한 뒤에는 머리 포인터를 오른쪽으로 옮겨 요청값을 버립니다. 머리와 꼬리가 겹치는지 확인하면 큐가 비었는지 알 수 있습니다.
원주 16 그래서 꼬리를 마지막 데이터의 다음 위치에 둡니다. 머리와 꼬리가 같은 인덱스를 가리키면 큐가 비어 있다는 뜻입니다.
값을 뒤로 추가하고 앞에서 꺼내는 큐가 만들어졌습니다. 물론 문제가 있습니다. 요청을 처리하는 동안 머리와 꼬리는 계속 오른쪽으로 이동합니다. 언젠가 tail_이 배열 끝에 도달하면 더 추가할 수 없습니다. 이제 원형 버퍼의 진가가 드러납니다.
그림 15-4. 큐는 빈칸을 뒤로 남겨 둔 채 배열을 따라 이동합니다.
생각해 보면 꼬리만이 아니라 머리도 오른쪽으로 움직입니다. 더 사용하지 않는 배열값이 배열 앞에 쌓이는 셈입니다. 그러니 꼬리가 배열 끝에 도달하면 다시 배열 앞으로 보내면 됩니다. 마치 원형 배열처럼 작동하므로 원형 버퍼라고 부릅니다.
그림 15-5. 꼬리는 배열 맨 앞으로 되돌아갑니다.
구현은 아주 쉽습니다. 데이터를 큐에 넣을 때 꼬리가 배열 끝에 도달하면 앞으로 돌려보내면 됩니다.
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;
}
C++
복사
원래 tail_++였던 코드를, tail_에 1을 더한 뒤 배열 크기로 나눈 나머지를 받도록 바꿨습니다. 끝에 도달하면 맨 앞으로 갑니다. 단언문도 추가했습니다. 큐는 오버플로되어서는 안 됩니다. 요청이 MAX_PENDING개 미만이면 머리와 꼬리 사이에 칸이 남습니다. 큐가 가득 찬 뒤에는 뒤집어진 우로보로스(Ouroboros)처럼 꼬리가 머리와 충돌한 뒤 값을 덮어 쓰기 시작합니다. 단언문으로 이를 막습니다.
원주 17 · 옮긴이 우로보로스는 ‘꼬리를 삼키는 자’라는 뜻으로, 뱀이나 용이 원형으로 자기 꼬리를 먹는 모습으로 표현됩니다.
빈 큐와 가득 찬 큐를 구별하기 — 이 구현에서는 head_ == tail_을 ‘비었음’으로 사용하므로 항상 한 칸을 비워 둡니다. 따라서 배열 크기가 16이라면 실제로 담을 수 있는 요청은 15개입니다. tail_을 한 칸 이동했을 때 head_와 만나는지를 검사하는 이유가 여기에 있습니다.
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;
}
C++
복사
드디어 동적 할당도, 데이터 이동도 필요 없고 단순 배열만큼 캐시하기 좋은 큐가 완성되었습니다.
원주 18 큐의 최대 용량(MAX_PENDING)이 신경 쓰이면 늘어나는 배열을 사용할 수 있습니다. 큐가 꽉 찼을 때 현재 큐의 두 배, 또는 적절한 배수 크기로 새 배열을 만들어 데이터를 옮기는 방식입니다. 늘어날 때 데이터를 복사해야 하지만, 데이터를 넣는 작업의 분할 상환(amortized) 비용은 여전히 상수 시간입니다.
요청 취합하기
큐를 만들었으니 다음 문제로 넘어가겠습니다. 첫 번째는 같은 소리를 동시에 틀면 너무 커지는 현상이었습니다. 이제 대기 중인 요청을 확인할 수 있으므로 같은 요청이 있으면 병합하면 됩니다.
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;
}
}
// 이전 코드...
}
C++
복사
같은 소리를 출력하려는 요청이 먼저 들어 있다면, 둘 중 소리가 큰 값 하나로 합쳐집니다. 이 취합 과정은 매우 기초적이지만, 더 재미있는 배치(batch) 작업도 같은 방식으로 처리할 수 있습니다.
요청을 처리할 때가 아니라 큐에 넣기 전에 취합한다는 점에 주의하세요. 어차피 취합하면서 없어질 요청을 큐에 둘 필요도 없고, 구현도 더 쉽습니다.
대신 호출하는 쪽의 처리 부담이 늘어납니다. playSound()는 전체 큐를 돈 뒤 반환하므로 큐가 크면 느릴 수 있습니다. 그럴 때는 update(), 즉 요청을 처리할 때 취합하는 편이 나을 수 있습니다.
원주 19 자료구조를 바꾸면 큐 확인 비용을 O(n)보다 줄일 수 있습니다. SoundId를 키로 쓰는 해시 테이블 같은 것을 사용하면 상수 시간에 중복 여부를 확인할 수 있습니다.
여기서 꼭 알아둘 점이 있습니다. 취합 가능한 최대 ‘동시’ 요청 수는 큐 크기와 같습니다. 요청을 너무 빨리 처리해 큐가 거의 비어 있다면 요청을 합칠 가능성도 줄어듭니다. 반대로 처리가 늦어 큐가 거의 차 있다면 합칠 만한 요청을 찾을 가능성이 더 높습니다.
이벤트 큐 패턴은 요청자가 요청의 실제 처리 시점을 모르게 합니다. 하지만 큐가 이렇게 상황에 따라 다르게 반응한다면, 큐에 넣은 요청이 실제로 처리될 때까지 걸리는 시간이 동작에 영향을 줄 수 있습니다. 이래도 괜찮은지 먼저 확인하고 사용하세요.
원주 20 · 옮긴이 요청이 큐에 오래 들어 있으면 나중 요청 때문에 이전 요청이 취합되거나, 뒤에 들어온 요청이 버려질 수 있어서 동작이 달라질 수 있다는 뜻입니다.
멀티스레드
마지막으로 가장 골치 아픈 문제입니다. 동기식 오디오 API에서는 playSound()를 호출한 스레드에서 요청도 처리해야 했습니다. 바람직한 상황은 아닙니다.
멀티코어 하드웨어에서는 멀티스레드를 사용해 하드웨어 성능을 최대한 끌어내야 합니다. 스레드에 코드를 분배하는 방법은 다양하지만 오디오, 렌더링, AI처럼 분야별로 할당하는 전략을 많이 사용합니다.
원주 21 직선형 코드(straight-line code)는 한 번에 한 코어에서만 실행됩니다. 멀티스레드를 사용하지 않으면 비동기 방식으로 만들더라도 CPU 코어 하나만 바쁠 뿐 전체 CPU 능력을 사용하지 못합니다. 서버 프로그래머는 애플리케이션을 여러 프로세스로 나누어 이를 보완합니다. 그러면 OS가 프로세스들을 서로 다른 코어에서 병렬로 실행합니다. 게임 클라이언트는 대부분 프로세스를 하나만 사용하므로 멀티스레딩이 큰 도움이 될 수 있습니다.
우리는 이미 멀티코어를 적용하기 위한 세 가지 주요 조건을 준비했습니다.
1.
사운드 요청 코드와 재생 코드가 분리되어 있습니다.
2.
양쪽 코드 사이에 마샬링(marshalling)을 제공할 큐가 있습니다.
3.
큐는 나머지 코드로부터 캡슐화되어 있습니다.
이제 큐를 변경하는 playSound()와 update()를 스레드 안전하게 만들면 됩니다. 보통이라면 코드도 간단히 보겠지만, 특정 API나 동기화 기법을 자세히 설명하는 것은 설계를 주제로 하는 이 책과 거리가 있어 넘어가겠습니다.
고수준에서 말하자면 큐가 동시에 수정되는 것을 막으면 됩니다. playSound()는 몇몇 필드에 값을 할당할 뿐 작업이 많지 않아서 블록하더라도 오래 걸리지 않습니다. update()는 조건 변수(condition variable) 등으로 기다리게 만들면 처리할 요청이 없는 동안 CPU를 낭비하지 않습니다.
주의할 점 — 위 예제의 배열과 인덱스만으로 스레드 안전성이 생기지는 않습니다. 여러 생산자와 소비자가 함께 접근한다면 큐의 읽기·쓰기와 인덱스 변경을 보호하는 동기화가 필요합니다. ‘큐가 있으니 비동기’와 ‘여러 스레드에서 안전’은 별도로 확인할 조건입니다.
15.7. 디자인 결정
많은 게임이 이벤트 큐를 통신 구조의 핵심 요소로 사용합니다. 메시지를 분산하고 필터링하는 복잡한 구조를 만드는 데 엄청난 시간을 들일 수도 있습니다. 하지만 LA 전화 교환대처럼 복잡한 것부터 무작정 만들기보다는 간단한 것부터 만드는 편이 좋습니다. 시작할 때 고민할 사항을 살펴보겠습니다.
큐에 무엇을 넣을 것인가?
지금까지는 비슷한 개념인 ‘이벤트’와 ‘메시지’를 섞어 썼습니다. 어느 것을 큐에 넣어도 디커플링과 취합을 할 수 있지만, 개념적으로는 조금 다릅니다.
큐에 이벤트를 넣는 경우
‘이벤트’나 ‘통지’는 ‘몬스터가 죽었음’처럼 이미 발생한 사건을 표현합니다. 이벤트를 큐에 넣으면 다른 객체가 비동기 관찰자 패턴(4장)과 같은 방식으로 반응할 수 있습니다.
•
여러 리스너(listener)를 지원해야 할 때가 많습니다. 이미 발생한 일이 들어 있으므로 보내는 쪽은 누가 받는지 신경 쓰지 않습니다. 이런 점에서 이벤트는 과거의 일이자 이미 잊힌 일입니다.
•
큐의 범위가 더 넓은 편입니다. 이벤트를 원하는 누구에게나 전파하는 용도이므로, 리스너가 최대한 유연하게 동작하도록 큐를 더 전역적으로 노출하는 편입니다.
큐에 메시지를 넣는 경우
‘메시지’나 ‘요청’은 ‘사운드 틀기’처럼 나중에 실행했으면 하는 행동을 표현합니다. 서비스의 API를 비동기적으로 호출하는 것과 비슷합니다.
원주 22 ‘요청’은 명령 패턴(2장)의 ‘명령’이기도 합니다. 명령 패턴에서도 큐를 사용하기도 합니다.
•
대부분 리스너가 하나입니다. 예제의 메시지는 오디오 API가 소리를 틀도록 만든 요청입니다. 다른 게임 코드가 여기저기서 큐의 메시지를 가져가 버릴 수 있다면 좋을 것이 없습니다.
원주 23 ‘대부분’이라고 한 이유는, 원하는 대로 처리되기만 한다면 어느 코드가 처리하는지 상관하지 않고 메시지를 넣을 수도 있기 때문입니다. 서비스 중개자 패턴(16장)과 비슷한 것을 사용하는 셈입니다.
누가 큐를 읽는가?
예제에서는 큐가 캡슐화되어 Audio 클래스만 읽을 수 있었습니다. UI 이벤트 시스템 같은 곳은 원하는 대로 리스너를 등록할 수 있습니다. 이를 구별하기 위해 싱글캐스트(singlecast)와 브로드캐스트(broadcast)라는 용어를 쓰며, 두 방식 모두 쓸모가 있습니다.
싱글캐스트 큐
큐가 어떤 클래스 API의 일부일 때 적합합니다. 오디오 예제처럼 호출하는 쪽에서는 playSound() 메서드만 보입니다.
•
큐는 밖에서 보이지 않는 내부 구현이 됩니다. 보내는 쪽은 메시지를 보냈다는 사실만 압니다.
•
큐가 더 캡슐화되어 있습니다. 다른 조건이 같다면 캡슐화가 더 잘된 쪽이 좋습니다.
•
리스너 사이의 경쟁을 고민하지 않아도 됩니다. 리스너가 여러 개라면 모두에게 이벤트를 보낼지, 즉 브로드캐스트할지, 아니면 리스너 하나에 이벤트 하나씩 나누어 줄지 정해야 합니다. 후자는 작업 큐에 더 가깝습니다. 어느 쪽이든 리스너가 작업을 중복하거나 서로 간섭할 수 있어 원하는 대로 동작하도록 신경 써야 합니다. 리스너가 하나라면 이런 복잡성이 사라집니다.
브로드캐스트 큐
대부분의 ‘이벤트’ 시스템이 이 방식입니다. 리스너가 10개라면 이벤트 하나가 들어왔을 때 10개 모두 그 이벤트를 볼 수 있습니다.
•
이벤트가 무시될 수 있습니다. 리스너가 하나도 없으면 아무도 이벤트를 볼 수 없습니다. 대부분의 브로드캐스트 시스템에서는 리스너가 없을 때 발생한 이벤트를 그대로 버립니다.
•
이벤트 필터링이 필요할 수 있습니다. 거의 모든 코드가 브로드캐스트 큐에 접근할 수 있고 리스너도 많이 두는 편입니다. 그러면 ‘이벤트 개수 × 리스너 개수’만큼 핸들러가 자주 호출됩니다. 호출 횟수를 줄이기 위해 대부분의 시스템은 리스너가 받고 싶은 이벤트 집합을 조절할 수 있게 합니다. 마우스 이벤트만 받거나, 특정 영역 안의 UI 이벤트만 받는 식입니다.
작업 큐
브로드캐스트 큐처럼 리스너가 여러 개 있습니다. 다른 점은 큐의 데이터가 리스너 중 한 곳에만 간다는 것입니다. 여러 스레드가 동시에 실행 중인 스레드 풀에 작업을 나누어 줄 때 일반적으로 사용하는 패턴입니다.
•
작업을 분배해야 합니다. 데이터가 리스너 하나에만 가므로, 큐는 어느 리스너에 보낼지 알아야 합니다. 라운드 로빈(round-robin)이나 무작위 선택을 쓸 수도 있고, 더 복잡한 우선순위 시스템을 적용할 수도 있습니다.
같은 큐라도 전달 규칙이 다릅니다 — 적 처치 사실을 업적·UI·통계가 모두 알아야 한다면 브로드캐스트가 어울립니다. 파일 처리 작업 하나를 여러 작업 스레드 중 하나가 맡아야 한다면 작업 큐가 어울립니다. ‘리스너가 여러 개’라는 사실만으로 어느 방식인지 결정되지는 않습니다.
누가 큐에 값을 넣는가?
앞과는 반대 질문입니다. 이벤트 큐 패턴은 일대일, 일대다, 다대일, 다대다 등 모든 읽기·쓰기 조합에 사용할 수 있습니다.
원주 24 다대일 통신 시스템을 팬인(fan-in), 일대다 통신을 팬아웃(fan-out)이라고도 부릅니다.
넣는 쪽이 하나라면
동기형 관찰자 패턴(4장)에 가장 가까운 형태입니다. 특정 객체 하나만 이벤트를 만들 수 있고, 나머지는 받기만 합니다.
•
이벤트가 어디서 오는지 암시적으로 압니다. 값을 추가할 수 있는 객체가 하나뿐이므로 모든 리스너가 보낸 사람을 안전하게 추측할 수 있습니다.
•
보통 리스너가 여러 개입니다. 일대일 큐도 가능하지만, 이는 이벤트 큐를 이용한 통신 시스템보다는 단순한 큐 자료구조에 가깝습니다.
넣는 쪽이 여러 개라면
예제의 오디오 엔진이 여기에 해당합니다. playSound()는 공개 함수이므로 코드 어디에서나 큐에 요청을 넣을 수 있습니다. ‘전역’ 또는 ‘중앙’ 이벤트 버스도 이렇게 작동합니다.
•
이벤트 순환에 주의해야 합니다. 어디에서나 데이터를 넣을 수 있으므로 이벤트 처리 도중 실수로 큐에 데이터를 넣기 쉽습니다. 잘못하면 피드백 루프가 생깁니다.
•
이벤트를 보낸 객체의 레퍼런스를 이벤트에 추가해야 할 수 있습니다. 누구나 보낼 수 있으므로 리스너는 이벤트만 받아서는 누가 보냈는지 모릅니다. 발신자 정보가 필요하다면 나중에 사용할 수 있도록 이벤트 객체에 함께 넣어야 합니다.
큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?
동기형 알림에서는 받는 쪽이 메시지 처리를 끝내기 전까지 반환하지 않으므로 보내는 쪽이 기다립니다. 따라서 메시지를 스택의 지역 변수로 두어도 충분합니다. 큐에서는 메시지를 추가하는 함수 호출이 끝난 뒤에도 메시지 객체가 유지되어야 합니다.
GC를 지원하는 언어에서는 이를 크게 신경 쓰지 않아도 됩니다. 큐에 들어 있는 데이터는 필요한 만큼 메모리에 유지됩니다. C나 C++에서는 객체의 수명을 직접 관리해야 합니다.
소유권을 전달합니다
메모리를 직접 관리하는 언어에서 가장 전통적으로 사용하는 방식입니다. 메시지가 큐에 들어가면 큐가 소유권을 가져가고, 보내는 쪽은 더 이상 메시지를 소유하지 않습니다. 데이터를 처리할 때는 받는 쪽이 소유권을 가져가며 메시지 해제도 담당합니다.
원주 25 C++의 unique_ptr<T>가 이런 식으로 동작합니다.
소유권을 공유합니다
요즘은 C++ 프로그래머도 GC에 익숙하므로 소유권 공유가 훨씬 받아들이기 쉽습니다. 공유 방식에서는 메시지를 참조하는 곳이 하나라도 있으면 메모리에 남아 있다가, 아무도 참조하지 않으면 알아서 해제됩니다.
원주 26 C++의 shared_ptr<T>가 이런 방식입니다.
큐가 소유권을 가집니다
메시지 큐가 메시지 수명을 관리할 수도 있습니다. 보내는 쪽이 직접 생성하지 않고 큐에 ‘새로운’ 메시지 하나를 요청합니다. 큐는 미리 할당한 메시지의 레퍼런스를 반환하고, 보내는 쪽은 여기에 값을 채웁니다. 한국어판은 이어서 ‘처리가 끝나고 나면 받는 쪽에서 이 메시지를 참조한다’고 설명합니다.
원주 27 말하자면 객체 풀(19장)이 큐의 보조 기억장치(backing store)가 되는 셈입니다.
수명 규칙을 함께 정하세요 — 큐가 메시지 저장 공간을 재사용한다면, 수신자가 그 메시지를 언제까지 참조할 수 있는지 명확해야 합니다. 처리 뒤에도 값을 보관할 필요가 있다면 필요한 내용을 복사하거나 별도 소유권 규칙을 두세요. shared_ptr의 참조 계수만으로 객체의 동시 접근까지 보호되는 것은 아닙니다.
15.8. 관련 자료
•
앞서 여러 번 말했듯 이벤트 큐는 잘 알려진 관찰자 패턴(4장)의 비동기형입니다.
•
다른 많은 패턴처럼 이벤트 큐도 여러 이름으로 불립니다. 그중 하나가 ‘메시지 큐’입니다. 메시지 큐라는 말은 더 고수준의 구조를 부를 때 흔히 사용합니다. 이벤트 큐가 애플리케이션 내부에 있다면, 메시지 큐는 여러 애플리케이션 사이의 통신에 사용합니다. ‘발행자/구독자(publish/subscribe)’, 줄여서 ‘pubsub’이라고 부르기도 합니다. 이 역시 여기서 본 예제보다 더 큰 분산 처리 시스템을 가리킬 때 많이 사용합니다.
•
GoF 상태 패턴(7장)과 유사한 유한 상태 기계(FSM)는 입력값을 스트림(stream)으로 받습니다. FSM이 입력에 비동기적으로 응답하게 하려면 입력을 큐에 넣어야 합니다. 서로 통신하는 상태 기계가 여러 개 있고 각각 입력을 보류하기 위해 우체통(mailbox)이라는 작은 큐를 사용한다면 계산 액터 모델(actor model)을 만든 셈입니다.
•
프로그래밍 언어 Go는 이벤트 큐나 메시지 큐로 사용하는 channel 자료형을 언어 차원에서 지원합니다.
출처: 사용자 제공 한국어판 『게임 프로그래밍 패턴』, 로버트 나이스트롬 지음·박일 옮김, 인쇄 281~301쪽(PDF 282~302쪽, 다음 PDF 303쪽은 빈 면). 본문·코드·원주 1~27과 원본 그림 15-1~15-5를 원문 순서에 맞춰 옮겼습니다.
콜아웃은 이해를 돕기 위한 추가 설명입니다.
콜아웃은 이해를 돕기 위한 추가 설명입니다.








