안녕하세요, 수박 키우기를 만들고 있는 몽실입니다. 지난 개발 이야기에서 게임 본체를 만든 과정을 적었는데요, 이번 글은 그 다음 이야기입니다. 최대 6인이 초대 코드로 모여 같은 판을 두고 겨루는 대결방을, 서버 한 대 없이 만들어낸 과정을 기록해 봤습니다.
문제 정의: 호스팅이 WebSocket을 지원하지 않습니다
대결방의 그림은 처음부터 분명했습니다. 방을 만들면 6자리 초대 코드가 나오고, 친구가 링크(subakup.com/?room=코드)로 들어오면 방장이 시작을 누르고, 3초 카운트다운과 GO 신호에 맞춰 전원이 동시에 출발합니다. 플레이 중에는 상대 보드가 작은 미니뷰로 보여야 하고, 아이템전이라면 콤보를 터뜨렸을 때 상대 화면에 방해 체리가 떨어져야 하죠.
이런 기능을 만들 때 교과서적인 답은 WebSocket입니다. 그런데 이 사이트가 올라가 있는 Vercel의 서버리스 함수는 요청을 받아 응답을 돌려주면 그걸로 끝이라, 연결을 계속 붙들고 있어야 하는 WebSocket을 쓸 수 없습니다. 상시 서버를 따로 빌리자니 무료 웹게임 하나 돌리는 데 배보다 배꼽이 커지는 상황이었고요.
선택: 2초 폴링 + Upstash Redis
그래서 방향을 바꿨습니다. 진짜 실시간 대신, 각 플레이어의 브라우저가 2초마다 서버에 "제 상태 드릴게요, 남들 상태 주세요" 하고 묻는 폴링 방식입니다. 서버리스 함수는 자기 스스로 아무것도 기억하지 못하니, 상태를 받아 줄 저장소로는 궁합이 좋은 Upstash Redis를 붙였습니다.
2초면 충분할까요? 게임 장르를 보고 판단했습니다. 수박 키우기 대결은 상대를 직접 조작으로 방해하는 격투 게임이 아니라, 각자 자기 판에서 점수를 쌓는 경쟁입니다. 상대 보드는 "지금 몇 점이고 판이 얼마나 찼나"만 보이면 되기 때문에, 미니뷰가 2초마다 갱신돼도 체감상 어색하지 않았습니다. 먼저 죽은 사람도 남은 경기를 그대로 관전할 수 있고요.
공정성은 폴링이 아니라 시드로 해결했습니다. 방장이 시작을 누르면 전원이 같은 시드를 받아, 여섯 명 모두 완전히 같은 순서의 과일을 받습니다. 네트워크가 조금 느려도 "쟤는 과일 운이 좋았잖아"라는 변명이 성립하지 않는 구조입니다. 신원 관리도 최대한 가볍게, 회원가입 없이 닉네임과 브라우저 토큰만으로 처리했습니다.
버그 이야기 1: 죽었다가 살아난 플레이어
첫 번째로 크게 데인 버그는 무응답 처리였습니다. 폴링 방식에서는 누가 창을 닫았는지 알 방법이 없어서, 한동안 요청이 없는 플레이어를 탈락으로 판정해야 합니다. 처음에는 이 판정 결과를 Redis에 저장했습니다. "이 사람은 탈락"이라고 도장을 찍어 두는 방식이죠.
문제는 잠깐 끊겼다가 돌아온 플레이어였습니다. 탈락 도장을 찍는 쓰기와 복귀한 플레이어의 점수 갱신 쓰기가 거의 동시에 서버에 도착하면 두 요청이 서로 부딪치면서, 한 박자 늦게 찍힌 탈락 도장이 방금 올라온 최신 점수를 과거 상태로 덮어써 버렸습니다. 열심히 쌓은 점수가 눈앞에서 되감기는, 전형적인 lost-update 레이스였습니다.
해결책은 잠금을 거는 게 아니라 저장 자체를 없애는 것이었습니다. 탈락 판정을 어디에도 기록하지 않고, 응답을 만들 때마다 각 플레이어의 마지막 요청 타임스탬프를 보고 그 자리에서 파생 계산하도록 바꿨습니다. 저장된 판정이 없으니 덮어쓸 대상도 없고, 레이스가 생길 틈 자체가 사라집니다. 상태를 하나 지웠더니 버그가 통째로 사라진, 개인적으로 가장 마음에 드는 수정이었습니다.
버그 이야기 2: 같은 와이파이의 비극
두 번째 버그는 혼자 하는 테스트로는 절대 못 잡았을 종류였습니다. 서버를 보호하려고 요청 제한(rate limit)을 걸어 뒀는데, 그 단위를 IP로 잡은 게 화근이었습니다. 사무실이나 집 와이파이에서는 여러 명이 같은 공인 IP를 쓰기 때문에, 셋이 함께 접속하는 순간 세 명분 요청이 한 사람 것으로 합산돼 전원이 차단당했습니다.
하필 대결방은 "옆자리 동료랑 한 판"이 가장 자연스러운 기능이라, 핵심 사용 장면이 정확히 차단 조건과 겹쳤던 겁니다. 제한 단위를 IP+닉네임 조합으로 바꿔 같은 와이파이에서도 각자 따로 계산되도록 고쳤습니다. 제한이든 판정이든, 그 '단위'가 실제 사용자들이 모여 노는 모습과 맞는지부터 물어야 한다는 걸 배웠습니다.
공격 전달은 원자 카운터로
아이템전에서는 콤보를 터뜨리면 살아 있는 상대 전원에게 방해 체리가 날아갑니다. 보내는 양은 아래처럼 정해져 있습니다.
| 트리거 | 방해 체리 |
|---|---|
| 콤보 ×2 | 1개 |
| 콤보 ×3 | 2개 |
| 콤보 ×4 | 3개 |
| 콤보 ×5 이상 | 4개 |
| 수박 폭발 | 4개 |
여섯 명이 거의 동시에 콤보를 터뜨리면 한 플레이어에게 공격이 겹쳐 들어옵니다. "현재 값을 읽고, 더하고, 다시 쓰는" 식으로 구현하면 여기서도 갱신이 유실될 수 있어서, 공격 수신함은 Redis의 HINCRBY 원자 카운터로 만들었습니다. 더하기 연산이 Redis 안에서 한 번에 끝나기 때문에, 몇 명이 동시에 쏴도 빠짐없이 합산됩니다.
받는 쪽에는 경고음과 함께 0.9초의 예고를 준 뒤 떨어뜨리고, 이미 죽은 플레이어는 공격을 보내지도 받지도 않습니다. 참고로 방해 체리는 일반 체리와 완전히 같아서, 잘 받아내면 오히려 합성 재료가 됩니다. 이 '위기를 기회로' 바꾸는 운영법은 대결·아이템전 가이드에 따로 정리해 뒀습니다.
가상 플레이어 부대
대결 기능은 고칠 때마다 사람이 여섯 명 필요하다는 게 문제였습니다. 배포할 때마다 지인을 소집할 수는 없으니, 가상 플레이어들이 자동으로 방을 만들고, 참가하고, 대결을 치르고, 순위 발표까지 확인하는 E2E 테스트를 만들었습니다. 무응답 탈락 레이스 수정처럼 민감한 변경도 이 테스트 덕분에 마음 편히 내보낼 수 있었습니다.
배운 점
- 기술이 막히면 요구사항을 다시 봅니다. 점수 경쟁 게임에는 WebSocket이 아니라 2초 폴링으로도 충분했습니다.
- 동시성 버그는 잠금보다 설계로 잡는 편이 낫습니다. 애초에 저장하지 않으면 덮어쓸 수도 없습니다.
- 제한과 판정의 '단위'는 사용자들이 실제로 모여 있는 환경, 그러니까 같은 와이파이 같은 현실에 맞춰야 합니다.
- 사람을 모으기 어려운 기능일수록 가상 플레이어 같은 자동 테스트가 절실합니다.
아이템전에서 공격의 원천이 되는 콤보를 더 자주 터뜨리고 싶다면 콤보 공략도 함께 읽어 보세요. 그리고 무엇보다, 직접 한 판 해 보시는 게 제일 빠릅니다. 옆자리 친구와 함께라면 더 좋고요.