실시간 멀티플레이어 게임을 개발하면서 마주친 문제가 있다. 특정 상황에서 유저가 게임 흐름에서 이탈하는 현상이었다. 이 글에서는 문제를 분석하고 해결한 과정을 공유한다.
문제 인식
유저 피드백
ZZOL은 실시간으로 여러 명이 함께 플레이하는 게임 서비스다. 배포와 동시에 여러 사람들에게 피드백을 받아왔었는데, 예상치 못한 불만이 있었다.
"게임 중에 카톡 확인하고 돌아왔는데, 저만 다른 화면에 있어요"
"친구들은 룰렛 돌리고 있는데 저는 아직 결과 화면이에요"
처음에는 단순한 버그인 줄 알았다. 하지만 피드백이 반복되면서 특정 패턴이 있다는 걸 인식하게 됐다.
모니터링 분석
문제를 파악하기 위해 모니터링 로그를 분석했다. 이탈이 발생하는 시점을 추적해보니 공통점이 있었다. 대부분 화면 전환 직후에 이탈이 발생하고 있었다. 더 파고들어보니 이탈 직전에 WebSocket 연결이 끊겼다가 재연결되는 로그가 있었다. 연결이 끊기는 원인은 다양했다.
- 앱 간 이동 (크롬에서 카카오톡으로 전환 후 복귀)
- 모바일 네트워크 순단 (WiFi ↔ LTE 전환, 일시적 신호 불안정)
- 브라우저 백그라운드 전환 (모바일에서 다른 앱 사용 후 복귀)
이런 상황들이 화면 전환 타이밍과 겹치면 문제가 발생했다. 게임이라는 도메인 특성상, 한 명이라도 흐름에서 이탈하면 전체 게임 진행이 어색해지기 때문에 꽤 치명적인 문제였다.
도메인 분석
문제를 제대로 이해하려면 먼저 ZZOL의 게임 흐름을 알아야 한다.
ZZOL은 여러 명이 방에 모여서 미니게임을 하고, 결과에 따라 룰렛을 돌려 당첨자를 정하는 서비스다. 흐름을 단순화하면 이렇다.
방 입장 → 대기실 → 미니게임 → 결과 발표 → 룰렛 → 당첨자 발표여기서 핵심 제약이 있다. 모든 플레이어가 항상 같은 화면을 보고 있어야 한다. 호스트가 "다음 단계로" 버튼을 누르면, 모든 플레이어의 화면이 동시에 전환되어야 한다. 한 명이라도 다른 화면에 있으면 게임이 성립하지 않는다.
이 동기화를 WebSocket으로 구현했다. 호스트가 버튼을 누르면 서버가 모든 클라이언트에게 화면 전환 메시지를 브로드캐스트하고, 각 클라이언트는 메시지를 받으면 해당 화면으로 이동한다.
문제 발생 시나리오
그런데 WebSocket은 연결된 클라이언트에게만 메시지를 전달한다. 연결이 끊긴 클라이언트는 메시지를 받지 못하고, 나중에 재연결해도 이미 지나간 메시지는 다시 오지 않는다.
구체적인 시나리오를 보자. 플레이어1(호스트)과 플레이어2가 결과 화면에 있다. 이때 플레이어2가 카카오톡 알림을 확인하러 앱을 전환한다. 모바일 브라우저는 백그라운드로 전환되면서 WebSocket 연결이 끊긴다. 그 사이에 호스트가 룰렛 버튼을 누른다. 서버는 화면 전환 메시지를 브로드캐스트하고, 플레이어1은 룰렛 화면으로 이동한다. 하지만 플레이어2는 연결이 끊겨 있었기 때문에 이 메시지를 받지 못한다.
플레이어2가 다시 브라우저로 돌아온다. WebSocket이 재연결된다. 하지만 서버는 이미 메시지를 보낸 상태라 다시 보내주지 않는다. 결과적으로 플레이어2만 결과 화면에 남게 되고, 이후 게임 진행을 따라갈 수 없게 된다.
이게 단순히 화면만 다른 게 아니라, 게임 진행 자체가 꼬인다. 룰렛 결과도 못 보고, 다음 단계 진행도 못 따라간다. 새로고침을 해도 이미 진행된 게임 상태와 동기화가 안 되기 때문에 해결되지 않는다. 결국 방을 나갔다가 다시 들어오거나, 게임을 처음부터 다시 시작해야 한다.
해결 방법 탐색
RabbitMQ 도입 검토
첫 번째로 고려한 방법은 RabbitMQ 같은 메시지 브로커 도입이었다. Durable Queue를 사용하면 클라이언트가 연결되어 있지 않아도 메시지를 쌓아뒀다가, 재연결 시 자동으로 전달해줄 수 있다.
하지만 검토 결과 우리 상황에는 적합하지 않았다.
첫째, 기존 아키텍처와 맞지 않았다. 이미 Redis를 세션 관리, 캐싱, 분산락 등 여러 용도로 사용하고 있었다. 메시지 복구 하나를 위해 새로운 인프라를 도입하면 관리 포인트가 늘어난다. 또한 현재 WebSocket 메시지 흐름을 전면 수정해야 했다.
둘째, 게임 특성상 "메시지 쏟아짐" 문제가 있었다. 레이싱 게임을 예로 들면, 플레이어 위치가 실시간으로 업데이트되면서 초당 수십 개의 메시지가 오간다. 만약 3초간 연결이 끊겼다가 재연결되면, 그동안 쌓인 수백 개의 위치 업데이트 메시지가 한꺼번에 쏟아진다. 클라이언트는 이 과거 데이터를 순차적으로 처리하느라 버벅이게 되고, 사실 과거 위치 정보는 어차피 의미가 없다.
RabbitMQ는 "메시지 유실 방지"에 최적화되어 있다. 모든 메시지를 빠짐없이 순서대로 전달하는 게 목표다. 하지만 실시간 게임은 다르다. 과거 데이터보다 현재 상태가 중요하다. 우리에게 필요한 건 "모든 메시지 재전달"이 아니라 "현재 상태 동기화"였다.
셋째, 게임의 생명주기가 짧았다. 방 하나가 생성되고 게임이 끝나면 삭제된다. 길어야 1시간. 방마다 큐를 만들었다가 정리하는 오버헤드가 있고, 이런 짧은 생명주기에는 과한 솔루션이었다.
Redis Stream 기반 직접 구현
결국 기존에 사용하던 Redis Stream을 활용해서 직접 구현하기로 했다.
핵심 아이디어는 단순하다. 중요한 메시지는 Redis Stream에 저장해두고, 클라이언트는 마지막으로 받은 메시지의 ID를 기억해둔다. 재연결 시 "이 ID 이후로 놓친 메시지가 있으면 달라"고 서버에 요청한다.
RabbitMQ와 다른 점은 두 가지다. 모든 메시지가 아니라 선택적으로 저장한다. 그리고 서버가 자동으로 쏟아붓는 게 아니라 클라이언트가 필요할 때 요청한다.
구현
전체 아키텍처
정상 상태 (메시지 전송)
[ Server ]
|
+-- WebSocket message ----+--------------+
| | |
v v v
[ Redis Stream ] [ Client A ] [ Client B ]
(save message + (received) (received)
generate streamId) | |
v v
localStorage localStorage
save streamId save streamId복구 상태 (재연결 시)
[ Client B ] -- reconnect detected
|
v
get lastStreamId from localStorage
|
v
call Recovery API --------------------------> [ Server ]
"give me messages after lastStreamId" |
v
[ Redis Stream ]
XRANGE query
|
<-------- return missed messages --------------+
|
v
process by message type
|
+-- screen transition --> process only last one --> navigate
|
+-- state sync ---------> dispatch to subscribers백엔드
백엔드에서는 세 가지를 구현했다.
첫째, 메시지 저장 로직이다. WebSocket 메시지를 보낼 때 Redis Stream에 함께 저장한다. 이때 모든 메시지를 저장하는 게 아니라, 복구가 필요한 메시지만 선택적으로 저장한다. 화면 전환 메시지나 게임 결과처럼 놓치면 안 되는 것들만 대상이다. 레이싱 게임 위치 업데이트 같은 빈도 높은 메시지는 저장하지 않는다.
둘째, streamId 전달이다. Redis Stream에 메시지를 저장하면 고유한 ID가 생성된다. 이 ID를 클라이언트에게 함께 내려준다. 클라이언트는 이 ID를 저장해뒀다가 재연결 시 사용한다.
셋째, Recovery API다. 클라이언트가 "이 ID 이후로 놓친 메시지 달라"고 요청하면, Redis Stream에서 해당 ID 이후의 메시지들을 조회해서 반환한다.
프론트엔드
프론트엔드에서는 네 가지를 구현했다.
첫째, streamId 저장이다. WebSocket 메시지를 받을 때마다 streamId를 localStorage에 저장한다. 키는 joinCode와 playerName 조합으로 했다. 같은 브라우저에서 탭을 여러 개 띄워 다른 플레이어로 테스트하는 경우가 있는데, 이때 streamId가 섞이면 안 되기 때문이다.
둘째, 재연결 감지다. WebSocket 연결이 끊겼다가 다시 연결되는 시점을 감지한다. 이때 저장해둔 streamId를 가지고 Recovery API를 호출한다.
셋째, 복구 메시지 처리다. Recovery API에서 받은 메시지들을 처리한다. 여기서 중요한 설계 결정이 있다. 화면 전환 메시지는 마지막 것만 처리한다. 연결이 끊긴 동안 화면 전환이 여러 번 일어났을 수 있다. 결과 화면에서 룰렛으로, 룰렛에서 당첨자 화면으로. 이때 중간 과정을 다 거칠 필요 없이, 최종 상태인 당첨자 화면으로 바로 이동하면 된다.
넷째, 구독자 라우팅이다. 화면 전환이 아닌 상태 동기화 메시지는 해당 화면의 구독자에게 전달한다. 예를 들어 카드 게임 상태 메시지는 카드 게임 화면 컴포넌트가 처리하도록 라우팅한다.
복구 메시지 처리 흐름
+---------------------------------------------------------------+
| Recovery Message Processing |
+---------------------------------------------------------------+
| |
| Recovery API response (3 messages) |
| | |
| | +-----------------+ |
| +-->| SHOW_ROULETTE |-- screen transition ---- save |
| | +-----------------+ | |
| | v |
| | +-----------------+ lastScreenMsg |
| +-->| GAME_STATE |-- state sync -- dispatch to subs |
| | +-----------------+ |
| | | |
| | +-----------------+ v |
| +-->| SHOW_WINNER |-- screen transition ---- save |
| +-----------------+ | |
| v |
| lastScreenMsg |
| | |
| ---------------------------------------------------------- |
| after loop |
| | |
| v |
| process lastScreenMsg (SHOW_WINNER) |
| | |
| v |
| navigate('/winner') --> jump to final screen |
| |
| * SHOW_ROULETTE is ignored (intermediate step) |
| |
+---------------------------------------------------------------+복구 전략
모든 메시지를 똑같이 처리하는 건 비효율적이다. 메시지 특성에 따라 저장 여부를 다르게 가져갔다.
+---------------------------------------------------------------+
| Message Storage Strategy |
+---------------------------------------------------------------+
| |
| Message Type Characteristics Storage |
| ---------------------------------------------------------- |
| |
| Screen Transition - frequency: low O Save |
| (SHOW_ROULETTE, - importance: high (Redis Stream) |
| SHOW_WINNER) - critical if missed |
| |
| ---------------------------------------------------------- |
| |
| Game Progress - frequency: high X Don't Save |
| (racing position, - importance: low |
| realtime updates) - only latest needed |
| |
+---------------------------------------------------------------+화면 전환 메시지 → 저장
화면 전환 메시지는 Redis Stream에 저장하고, 재연결 시 복구한다.
이런 메시지는 빈도가 낮다. 게임 한 판에 3~5개 정도. 하지만 놓치면 치명적이다. 한 번 놓치면 이후 게임 진행을 전혀 따라갈 수 없다. 그래서 반드시 복구해야 한다.
게임 진행 상태 → 저장 안 함
레이싱 게임 위치 업데이트 같은 메시지는 저장하지 않는다.
이런 메시지는 빈도가 높다. 프레임마다 위치가 업데이트된다. 이걸 전부 저장했다가 재생하면 앞서 말한 "메시지 쏟아짐" 문제가 생긴다. 게다가 과거 위치를 순차적으로 재생해봤자 의미가 없다. 현재 위치만 알면 된다.
현재는 이런 메시지에 대한 복구는 구현하지 않았다. 실시간 게임 특성상 잠깐 끊겼다가 돌아오면 자연스럽게 다음 업데이트부터 받게 되기 때문에, 치명적인 문제는 아니라고 판단했다.
마무리
정리하면 핵심은 세 가지다.
첫째, 선택적 저장이다. 모든 메시지가 아니라 복구가 필요한 메시지만 저장한다. 화면 전환처럼 놓치면 치명적인 메시지만 대상이다.
둘째, 클라이언트 주도 복구다. 서버가 자동으로 쏟아붓는 게 아니라, 클라이언트가 필요할 때 요청한다.
셋째, 마지막 상태로 점프다. 화면 전환 메시지는 중간 과정을 다 거칠 필요 없이 최종 상태로 바로 이동한다.
RabbitMQ 같은 메시지 브로커를 도입하면 자동으로 해결될 것 같지만, 도메인 특성을 고려하면 오히려 문제가 될 수 있다. "모든 메시지 유실 방지"가 아니라 "현재 상태 동기화"가 목표라면, 필요한 만큼만 직접 구현하는 게 더 적합한 선택이었다.