스케치 퀴즈는 그림을 이미지가 아니라 이벤트로 동기화한다
Canvas 픽셀을 계속 전송하지 않고 좌표 비율 기반 드로잉 이벤트를 WebSocket으로 전파한 이유와, 서버가 출제자 권한·이벤트 저장·실시간 브로드캐스트를 어떻게 나눴는지 정리했습니다.
나쁜 설명과 좋은 설명의 차이
나쁜 예시는 “WebSocket으로 그림을 실시간 전송했다”에서 끝납니다. 이 설명은 어떤 데이터를 보냈는지, 왜 그 방식을 골랐는지, 어떤 보장을 포기했는지 알 수 없습니다. 개발자 블로그라면 “Canvas 전체 이미지를 매번 보내지 않고, 선을 그리는 행위를 start, move, end, clear 이벤트로 쪼개 WebSocket으로 전달했다. 픽셀 데이터보다 payload가 작고 모바일 해상도 차이에 강하지만, 중간 이벤트 유실 가능성이 있으므로 서버에 drawEvents 버퍼를 둬 복구 여지를 남겼다” 정도까지 말해야 합니다.
부리부리랜드의 스케치 퀴즈는 브라우저 Canvas를 공유 화면처럼 복제하지 않습니다. 출제자가 마우스나 터치로 움직이면 클라이언트가 현재 좌표와 이전 좌표를 계산하고, 그 정보를 “그림 이벤트”로 서버에 보냅니다. 서버는 이 사용자가 현재 출제자인지 확인한 뒤 같은 방 topic으로 이벤트를 뿌립니다. 다른 클라이언트는 받은 이벤트를 자기 Canvas에 다시 적용해서 같은 그림처럼 보이게 만듭니다.
이미지 스트리밍을 쓰지 않은 이유
가장 단순한 생각은 Canvas를 이미지로 캡처해 계속 보내는 것입니다. 하지만 그림을 그릴 때마다 PNG나 base64 이미지를 전송하면 payload가 커지고, 사용자가 빠르게 선을 그릴수록 네트워크와 서버 브로드캐스트 비용이 급격히 커집니다. 특히 스케치 퀴즈는 여러 명이 동시에 보는 파티 게임이라 한 명의 이미지 스트림이 나머지 모든 참가자에게 복제됩니다.
WebRTC도 후보가 될 수 있지만 이 게임에는 과합니다. 스케치 퀴즈는 영상이나 음성처럼 지속적인 미디어 스트림이 필요한 것이 아니라, “선 하나가 어디서 어디로 갔는지”만 알면 됩니다. NAT traversal, peer 연결, SFU 같은 복잡도를 도입하기보다 이미 방 상태와 채팅에 쓰고 있는 WebSocket 흐름 안에서 이벤트를 처리하는 편이 운영 난이도가 낮습니다.
Redis Stream도 적용하지 않았습니다. 그림 이벤트는 금융 거래처럼 ack와 재처리 보장이 필요한 데이터가 아닙니다. 잠깐 선 하나가 유실되어도 다음 move 이벤트가 이어서 도착하면 시각적으로 큰 문제가 없고, 게임 라운드가 끝나면 그림 자체도 사라집니다. 따라서 “영속 큐”보다 “가벼운 실시간 브로드캐스트 + 제한된 이벤트 버퍼”가 더 맞는 선택입니다.
프론트엔드: 좌표를 픽셀이 아니라 비율로 보낸다
프론트 구현의 핵심은 `src/app/room/[id]/SketchQuizBoard.tsx`에 있습니다. 사용자가 Canvas 위에서 움직이면 `getPos`가 브라우저 화면 좌표를 실제 Canvas 좌표로 바꿉니다. 그 다음 `toDrawPayload`는 `x`, `y`뿐 아니라 `xRatio`, `yRatio`, `prevXRatio`, `prevYRatio`를 함께 만듭니다.
비율 좌표를 함께 보내는 이유는 기기마다 Canvas가 다르게 보이기 때문입니다. 데스크탑에서는 넓은 캔버스, 모바일에서는 축소된 캔버스가 렌더링될 수 있습니다. 단순 픽셀 좌표만 보내면 받는 쪽 Canvas 크기가 다를 때 선 위치가 어긋날 수 있습니다. 그래서 수신자는 `getDrawPoint`에서 비율 좌표가 있으면 자기 Canvas 크기에 곱해서 위치를 복원합니다.
출제자 화면은 서버 응답을 기다리지 않고 먼저 로컬 Canvas에 그립니다. 그래야 펜이 손을 따라오는 느낌이 납니다. 동시에 30ms throttle을 두고 `move` 이벤트를 서버로 보냅니다. 모든 마우스 이동 이벤트를 그대로 보내면 초당 수십에서 수백 개의 메시지가 발생할 수 있기 때문에, UX와 네트워크 비용 사이에서 적당한 간격을 둔 것입니다.
서버: 현재 출제자만 draw 액션을 통과시킨다
서버 구현의 중심은 `spring-server/src/main/java/com/buriburiland/game/games/sketch/SketchQuiz.java`의 draw 처리입니다. 클라이언트가 `handle_action`으로 `draw`를 보내면 서버는 현재 게임 phase가 drawing인지 확인하고, 요청을 보낸 socketId가 현재 `drawerId`와 일치하는지 검사합니다. 이 검사를 통과하지 못하면 이벤트는 방송되지 않습니다.
이 단계가 중요한 이유는 UI만으로 권한을 막으면 안 되기 때문입니다. 관전자나 정답을 맞히는 사람이 개발자 도구로 draw 액션을 직접 보내면 화면을 망칠 수 있습니다. 그래서 서버가 “누가 그릴 수 있는가”를 최종 판정합니다. 실시간 게임에서 권한은 버튼 disabled가 아니라 서버의 상태 검증으로 완성됩니다.
권한을 통과한 이벤트는 `sketch_draw` 타입을 붙여 `/topic/room/{roomId}`로 전파됩니다. 이 이벤트는 개인화가 필요하지 않습니다. 선의 색, 굵기, 현재 좌표, 이전 좌표는 모두가 똑같이 봐야 하는 공개 정보입니다. 그래서 플레이어별 queue가 아니라 room topic으로 바로 보내는 편이 단순하고 빠릅니다.
drawEvents 버퍼는 복구를 위한 안전장치다
서버는 실시간으로 이벤트를 뿌리는 것과 별도로 `drawEvents` 리스트에도 이벤트를 저장합니다. `clear` 이벤트가 오면 기존 이벤트를 비우고, 일반 draw 이벤트는 최대 `MAX_DRAW_EVENTS`까지만 보관합니다. 무한히 저장하면 긴 라운드에서 메모리와 스냅샷 비용이 커지기 때문에 상한을 둔 것입니다.
클라이언트 쪽에는 `drawEvents`가 포함된 상태를 받으면 빈 Canvas를 먼저 칠하고, 저장된 이벤트를 순서대로 `applyDrawEvent`에 넣어 replay하는 로직이 있습니다. 즉 Canvas를 이미지 파일로 저장하지 않아도 “어떤 선을 어떤 순서로 그렸는지”만 있으면 화면을 다시 만들 수 있습니다.
다만 모든 room state 방송에 `drawEvents`를 계속 포함하면 그림을 그릴수록 매번 큰 배열이 참가자 전체에게 반복 전송됩니다. 그래서 BroadcastService는 일반 스냅샷에서 민감하거나 무거운 데이터를 마스킹합니다. 실시간 그림은 topic 이벤트로 받고, 복구가 필요한 경우에만 이벤트 로그를 replay할 수 있게 분리하는 것이 핵심입니다.
수신자는 같은 함수를 실시간과 복구에 같이 쓴다
수신 클라이언트는 `socket.on("sketch_draw")`로 이벤트를 받고 `applyDrawEvent`를 실행합니다. 재접속 복구 때도 같은 `applyDrawEvent`를 씁니다. 실시간 수신과 이벤트 replay가 서로 다른 코드를 타면 선 굵기, 지우개 처리, 좌표 변환이 미묘하게 달라질 수 있습니다.
`applyDrawEvent`는 `clear`, `start`, `move`, `end`를 구분합니다. `clear`는 Canvas 전체를 흰색으로 덮고, `move`는 이전 좌표에서 현재 좌표까지 선을 긋습니다. 지우개는 별도 도형이 아니라 stroke 색을 흰색으로 바꾸는 방식으로 처리합니다. 이 방식은 단순하지만 브라우저 Canvas API와 잘 맞고, 이벤트 payload도 작게 유지됩니다.
이 구조가 주는 교훈
실시간 기능을 구현했다고 말할 때 중요한 건 사용한 기술 이름이 아닙니다. “어떤 데이터 단위로 쪼갰는가”, “어떤 보장을 포기했는가”, “어떤 상황에서 복구할 수 있게 했는가”가 핵심입니다. 스케치 퀴즈는 그림을 이미지로 보내지 않고, 그림을 만드는 행위를 이벤트로 모델링했습니다.
이 선택 덕분에 payload는 작아지고, 모바일과 데스크탑 사이의 Canvas 크기 차이는 비율 좌표로 흡수되며, 서버는 출제자 권한만 검증하면 됩니다. 반대로 완전한 메시지 보장이나 픽셀 단위 동일성은 목표로 삼지 않았습니다. 게임의 목적은 회계 처리처럼 1바이트도 틀리면 안 되는 데이터 보존이 아니라, 사람들이 같은 그림을 충분히 빠르게 보고 맞히는 경험이기 때문입니다.
그래서 이 구현을 한 줄로 요약하면 “WebSocket으로 그림을 보냈다”가 아니라 “Canvas 전체 이미지를 스트리밍하지 않고, 출제자 권한을 서버에서 검증한 뒤 좌표 비율 기반 draw 이벤트를 topic으로 전파하고, 제한된 이벤트 버퍼로 복구 가능성을 남긴 구조”입니다. 개발자 블로그에는 이런 수준의 설명이 남아야 합니다.
지금 바로 플레이해 보세요
전략을 직접 시험하고 싶다면, 방을 만들어 친구와 함께 플레이하세요.