이론/디자인패턴

이벤트 큐 패턴

tsyang 2022. 1. 3. 03:13

이게머임?


이벤트 큐 패턴은 메시지나 이벤트를 보내고 처리하는 시점을 분리하는 패턴이다. (시점의 디커플링) 또한 관찰자 패턴을 멀티 쓰레딩 환경에서 사용할 때 클래스끼리 서로 비동기적으로 상호작용 하도록 할 때 쓸 수 있다.

 

https://tsyang.tistory.com/83?category=1006818#%EA%B4%80%EC%B0%B0%EC%9E%90 

 

디자인 패턴 - 행동패턴

개요 행동 패턴에는 다음 패턴들이 포함되어 있다. (취소선은 다루지 않음) 전략(Strategy) 상태(State) 탬플릿 메서드(Template Method) 명령(Command) 책임 연쇄(Chain of Responsibility) 관찰자 (Observer) 메..

tsyang.tistory.com

 

기본적으로 관찰차 패턴처럼 게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준 통신을 하고 싶을 때 사용한다. 단, 이벤트 큐 패턴의 경우 시스템이 자기가 원할 때 이벤트를 가져온다는 점이다. 이를 이벤트를 모아두는 큐가 필요하다. 

 


왜 비동기?


간단한 오디오 시스템을 생각해보자. 

class Audio
{
public:
	void playSound(SoundId id, int volume)
	{
		//id로 리소스와 재생할 채널을 얻어온다.
		startSound(resouce, channel, volume); //그리고 재생
	}
};

 

playSound는 동기적이다. 스피커에서 소리가 나올 때 까지 호출자는 블록된다. 

 


문제1 - 호출자 블록

id로 오디오를 재생한다. 만약에 재생하고자 하는 id의 오디오파일이 로딩이 안 되어있다면? 오디오파일의 playSound를 호출한 스레드는 오디오파일이 로딩되고 스피커에서 소리가 날때까지 멈춘다

 

혹은 디아블로와 같은 핵앤슬레쉬 게임을 한다고 하자. 스킬을 써서 30마리의 고블린을 동시에 죽였다. 이제 고블린들이 죽으면서 내는 비명소리 30개가 동시에 재생되지만 위의 오디오 시스템은 playSound를 하나씩 처리하기 때문에 어쩔 수 없다.

 


문제2 - 멀티스레드

분야별로 쓰레드를 나눴다. 렌더링용, 물리처리용, AI처리용... 이제 각 분야의 스레드에서 playSound 를 호출한다. playSound는 동기식이기 때문에 호출한 쪽 스레드에서 실행된다. 그런데 playSound API는 동기화 처리가 되어있지 않다. 

 

오디오용 쓰레드를 만들어도 문제가 된다. 호출한 스레드에서 playSound를 실행하기 때문에 오디오용 쓰레드는 아무것도 안 하고 가만히 있게 된다.

 


 

따라서


 

문제1, 2가 생기는 이유는 즉시성 때문이다. 따라서 요청이나 알림을 저장하는 큐를 둔다. 이렇게 되면 호출하는 쪽에서는 결과를 기다리지 않는다. 받는 쪽에서는 원하는 시점에 요청을 처리한다. 요청을 보내는 쪽과 받는 쪽을 코드 뿐 아니라 시간 측면에서도 디커플링 한다. 반대로 요청을 보내는 쪽에서 응답을 받아야 한다면 큐를 쓰는게 적합하지 않다.

 

class Audio
{
public:
	static void init() { numPending_ = 0; }
	static void playSound(int id, int volume);
	static void update();

private:
	static const int MAX_PENDING = 16;
	static queue<PlayMessage> pending_;
	static int numPending_;
};

void Audio::playSound(int id, int volume)
{
	for (auto pending : pending_)
	{
		if (pending.id == id)
		{
			//이미 받은 요청 중 같은 id가 있다면 볼륨을 큰 쪽으로 설정해준다. 그리고 리턴
			pending.volume = max(volume, pending.volume);
			return;
		}
	}
	//큐에 넣는다.
	pending_.emplace(id, volume);
	++numPending_;
}

void Audio::update()
{
	//1프레임당 여러개를 처리할 수도 있다. 처리 갯수만큼 아래 스코프 반복
	{
		auto msg = pending_.front(); pending_.pop();

		//큐에서 메시지를 꺼내와 소리를 출력한다.
	}
}

 


 

주의사항


 

  1. 이벤트 큐 패턴은 게임 구조 자체에 전반적으로 영향을 미치는 경향이 있다. 따라서 사용 전에 어떻게 쓸지, 꼭 써야하는지를 잘 생각해봐야 한다.
  2. 중앙 이벤트 큐는 전역변수의 단점을 가진다. 
  3. 이벤트를 호출한 시점과 처리하는 시점에서 참고하는 데이터들의 상태가 바뀔 수 있다. 따라서 데이터 자체를 이벤트에 담아 보내야 할 수 있어 성능이 하락할 수 있다.
  4. 피드백 루프에 빠질 수 있다. 이벤트를 처리하는 객체가 다시 이벤트를 보낼 수 있다. 동기적인 상황에서는 바로 프로그램이 터지지만 이벤트 큐에서는 그게 티가 안나기 때문에 주의해야 한다.

 


 

디자인 결정


큐에 무엇을 넣어?

이벤트 넣는 경우

이벤트는 '몬스터가 사망함'과 같은 이미 발생한 사건을 표현한다. 이 경우 이벤트를 누구에게든 전파하는 용도로 쓰이기 때문에 리스너가 최대한 유연할 수 있도록 큐를 더 전역적으로 노출하는 편이며 리스너가 여러 개인 경우가 많다.

 

메시지를 넣는 경우

메시지나 요청은 '몬스터 사망 사운드 재생'과 같이 나중에 실행했으면 하는 행동을 표현한다. 즉, 서비스에 비동기적으로 API를 호출하는 것과 비슷하다. 따라서 이 경우 보통 리스너가 하나이다.

 


누가 큐를 읽어?

 

싱글 캐스트

리스너가 1개. 큐가 어떤 클래스 API의 일부일 때 적합하다. 오디오 예제처럼 호출하는 쪽에서는 playSound만 보인다.

 

브로드 캐스트

리스너가 n개. 대부분의 이벤트 시스템이 이런 방식이다. 이벤트 하나가 들어왔을 때 n개의 리스너가 모두 그 이벤트를 본다. 이 경우 보통 리스너가 많기 때문에 핸들러 호출 횟수를 줄이기 위해 이벤트를 필터링하여 핸들러를 호출하기도 한다.

 

작업 큐

리스너가 n개. 브로드 캐스트와 차이점은 큐에 있는 데이터가 리스너 중 한 곳에만 간다. 보통 스레드에 일을 나눠줄 때 쓰는 패턴이다.

 


누가 큐에 넣어?

 

넣는 애가 하나

이 경우 보통 이벤트가 어디서 오는지 알 수 있으며, 리스너가 여러 개인 경우가 많다. 리스너가 하나라면 1:1 통신이 되는데 이러면 그냥 단순한 큐 자료구조에 가깝기 때문이다.

 

넣는 애가 여러 개

오디오 시스템의 예제가 여기에 속한다. 이 경우 피드백 루프가 생기기 쉽기에 주의해야 한다. 이 경우 누가 이벤트를 보냈는지를 데이터로 넘겨줘야 할 수도 있다.

 


데이터의 생명주기 관리는 누가?

큐에 들어가는 데이터의 소유권은 누가 가질까? GC가 있는 언어라면 덜 신경써도 되는 문제이지만 그렇지 않다면 생각할 필요가 있다.

 

소유권을 전달

가장 전통적으로 사용하는 방식이다. 메시지가 큐에 들어가면 큐가 소유권을 가져간다. 이후 데이터를 처리할 때는 받는 쪽에서 메시지 소유권을 가져간 뒤 해제한다.

 

소유권을 공유

shared_ptr처럼 소유권을 공유한다. 

 

큐가 소유권을 관리

객체를 생성 => 큐에 넣음(X)

큐한테 객체를 만들어 달라고 함 => 객체의 데이터 수정(O)

위와 같은 방식으로 데이터의 수명을 관리한다. 데이터를 처리한 뒤에 해제도 큐가 해준다.

 


참고 : 로버트 나이스트롬, 게임 프로그래밍 패턴, 한빛미디어, [281~301p]

'이론 > 디자인패턴' 카테고리의 다른 글

이중 버퍼  (0) 2022.01.26
서비스 중개자  (0) 2022.01.16
컴포넌트 패턴  (1) 2021.12.25
디자인 패턴 - 행동패턴  (0) 2021.12.12
디자인 패턴 - 구조  (1) 2021.12.03