볼링 점수 기록 앱 Strike Log 제작기 5편입니다. 이번 글에서는 Flutter 앱과 NestJS 백엔드를 Socket.IO로 연결해 클럽 실시간 경기방을 만든 과정을 정리합니다. 핵심은 단순히 소켓을 붙이는 것이 아니라, 인증된 사용자만 방에 들어오게 하고, 점수 변경을 방 전체에 브로드캐스트하며, 모바일 환경의 일시적인 연결 끊김까지 감안하는 것입니다.

왜 REST API만으로는 부족했나
점수 저장 자체는 REST API로 충분합니다. 사용자가 경기를 끝낸 뒤 /games 같은 엔드포인트로 기록을 저장하면 됩니다. 하지만 클럽 경기방은 성격이 다릅니다.
- 누군가 방을 만들면 다른 사람이 바로 참가해야 합니다.
- 한 명이 점수를 입력하면 참가자 전체 화면이 갱신되어야 합니다.
- 호스트가 게임을 시작하면 모두 같은 상태로 전환되어야 합니다.
- 네트워크가 잠깐 끊겨도 참가자가 바로 사라지면 안 됩니다.
이 흐름을 REST API의 polling으로 처리하면 불필요한 요청이 많아지고, 상태 동기화도 복잡해집니다. 그래서 Strike Log에서는 실시간 경기방을 Socket.IO 기반 WebSocket 구조로 분리했습니다.
전체 구조: Flutter SocketService와 NestJS Gateway
클라이언트 쪽에는 SocketService를 두었습니다. Flutter 화면에서 직접 socket.io 코드를 흩뿌리지 않고, 방 생성, 참가, 점수 업데이트 같은 이벤트를 서비스 메서드로 감쌌습니다.
서버 쪽은 NestJS의 GameRoomsGateway와 GameRoomsService로 나눴습니다. GameRoomsGateway는 WebSocket 이벤트를 받는 입구이고, GameRoomsService는 실제 방 상태를 관리합니다. 방과 참가자는 game_rooms, game_room_participants 테이블에 저장합니다.

- Flutter 앱이 JWT를 포함해 Socket.IO 서버에 연결합니다.
- 서버가 handshake 단계에서 JWT를 검증합니다.
- 사용자가 방을 만들거나 참가합니다.
- 서버가 최신 방 상태를 조회합니다.
- 같은 방의 모든 클라이언트에게
roomStateUpdated이벤트를 보냅니다.
handshake 단계에서 JWT 검증하기
실시간 연결에서도 인증은 필요합니다. REST API에서는 Authorization 헤더로 JWT를 검증했지만, Socket.IO에서는 연결 시점의 handshake에 토큰을 실어 보냈습니다.
Flutter의 SocketService는 저장된 access token을 읽어 auth.token으로 전달합니다. 서버의 GameRoomsGateway는 이 값을 꺼내 JWT를 검증하고, 검증된 사용자 정보를 client.data.user에 저장합니다.
중요한 점은 이벤트 body의 user_id를 신뢰하지 않는다는 것입니다. 클라이언트에서 user_id를 보낼 수는 있지만, 실제 서버 로직에서는 handshake에서 검증한 사용자 ID를 기준으로 처리합니다. 이렇게 해야 다른 사용자의 ID를 임의로 넣어 점수를 조작하는 문제를 막을 수 있습니다.
방 생성과 참가 처리
방 생성은 createRoom 이벤트로 시작합니다. 호스트가 방을 만들면 서버는 랜덤한 방 ID를 생성하고, game_rooms에 방을 저장합니다. 동시에 호스트를 첫 참가자로 game_room_participants에 추가합니다.
방 ID는 짧은 코드 형태로 만들고, 충돌이 나면 다시 생성하도록 했습니다. 사용자가 직접 공유하거나 입력해야 하는 값이므로 너무 길게 만들지 않는 쪽이 앱 사용성에 맞았습니다.
참가는 joinRoom 이벤트로 처리합니다. 서버는 방이 존재하는지, 이미 종료된 방은 아닌지, 정원이 가득 차지는 않았는지 확인합니다. 신규 참가자라면 participant row를 추가하고, 이미 있던 사용자가 재접속한 경우에는 끊김 상태를 해제합니다.
여기서 정원 체크는 트랜잭션과 pessimistic write lock을 사용했습니다. 여러 사용자가 동시에 들어올 때 정원을 넘겨 참가하는 문제를 막기 위해서입니다.
점수 업데이트는 방 상태 전체를 다시 보낸다
점수 입력 화면에서 사용자가 점수를 바꾸면 Flutter 앱은 updateScore 이벤트를 보냅니다. 이때 서버로 전달하는 값은 단순 총점만이 아닙니다. Strike Log에서는 스트라이크, 스페어, 오픈 프레임 같은 통계도 함께 보낼 수 있게 했습니다.
서버는 참가자의 점수를 갱신한 뒤, 다시 방의 현재 상태를 조회합니다. 그리고 같은 방에 연결된 모든 클라이언트에게 roomStateUpdated를 보냅니다.
처음에는 변경된 사용자만 따로 보내는 방식도 생각할 수 있습니다. 하지만 모바일 앱에서는 상태가 조금만 어긋나도 화면 복구가 번거롭습니다. 그래서 이벤트를 받을 때마다 최신 방 상태 전체를 기준으로 UI를 다시 그리는 방식이 더 단순하고 안정적이었습니다.

호스트만 게임을 시작할 수 있게 하기
경기방에는 호스트 개념이 있습니다. 방을 만든 사람이 호스트가 되고, 게임 시작은 호스트만 할 수 있습니다.
서버에서는 startGame 이벤트가 오면 현재 방 상태를 조회한 뒤 hostId와 요청 사용자의 ID를 비교합니다. 일치하지 않으면 에러를 보내고, 일치하면 방 상태를 playing으로 바꾼 뒤 gameStarted 이벤트를 브로드캐스트합니다.
이런 권한 검사는 클라이언트 UI에서도 버튼 노출로 한 번 막을 수 있지만, 최종 판단은 서버에서 해야 합니다. 실시간 이벤트도 API와 마찬가지로 서버 권한 검사가 기준입니다.
모바일 환경에서 disconnect를 바로 퇴장으로 보지 않기
실시간 기능을 만들 때 가장 쉽게 놓치는 부분이 연결 끊김입니다. PC 웹에서는 disconnect를 사용자의 퇴장으로 봐도 큰 문제가 없을 때가 많지만, 모바일 앱은 다릅니다. 잠깐 화면을 전환하거나, 네트워크가 흔들리거나, 앱이 백그라운드로 내려가기만 해도 연결이 끊길 수 있습니다.
그래서 Strike Log에서는 disconnect가 발생해도 참가자를 즉시 삭제하지 않았습니다. 대신 game_room_participants에 disconnected_at 시간을 기록합니다.
이후 사용자가 grace period 안에 다시 들어오면 기존 참가자 정보를 복구합니다. 일정 시간이 지나도 돌아오지 않으면 cron 작업이 해당 참가자를 정리합니다. 현재 구조에서는 일시 disconnect 후 30초를 grace period로 두고, 오래 방치된 방도 별도 기준에 따라 정리합니다.
클라이언트에서도 무한 연결 대기를 막기
서버만 안정적으로 만든다고 끝은 아닙니다. Flutter 쪽에서도 연결 실패를 사용자가 이해할 수 있게 처리해야 합니다.
SocketService에서는 연결 timeout을 두고, 재연결 시도 횟수도 제한했습니다. 서버가 내려가 있거나 네트워크가 안 되는 상황에서 앱이 “연결 중...” 상태로 끝없이 멈춰 있으면 사용자는 앱이 고장났다고 느낍니다.
그래서 연결이 일정 시간 안에 완료되지 않으면 실패로 보고, 호출한 화면에서 스낵바나 상태 복구를 할 수 있게 했습니다. 실시간 기능은 “연결되면 편한 기능”이지만, 연결 실패가 앱 전체 UX를 무너뜨리면 안 됩니다.
내기 모드까지 확장할 수 있었던 이유
초기 구조는 클럽 경기방을 위한 것이었지만, 이후에는 bet 모드도 붙일 수 있었습니다.
방에는 mode가 있고, 참가자에는 handicap 값이 있습니다. 클럽 모드는 클럽 접근 권한을 확인하고, 내기 모드는 더 가볍게 사용할 수 있도록 분리했습니다.
핸디캡 추천은 기존 게임 기록의 평균 점수를 기준으로 계산합니다. 참가자 중 최고 평균 점수와 각 참가자의 평균 점수 차이를 추천 핸디로 제시하는 방식입니다. 이 기능은 방 생성, 참가, 점수 갱신, 종료와 순위 계산이 이미 이벤트 단위로 나뉘어 있었기 때문에 붙이기 쉬웠습니다.
만들면서 얻은 교훈
이번 기능에서 가장 중요한 교훈은 “WebSocket도 결국 상태 관리”라는 점이었습니다. Socket.IO를 붙이는 것 자체는 어렵지 않습니다. 어려운 부분은 어떤 상태를 서버가 신뢰할지, 어떤 이벤트 후에 누구에게 무엇을 다시 보낼지, 연결이 끊겼을 때 사용자를 어떻게 다룰지 결정하는 것입니다.
- 사용자 식별은 handshake JWT 기준으로 합니다.
- 점수 변경 후에는 방 상태 전체를 브로드캐스트합니다.
- disconnect는 즉시 퇴장이 아니라 일시 끊김으로 봅니다.
- 오래 남은 방과 참가자는 cron으로 정리합니다.
- 클라이언트는 timeout과 재연결 제한으로 UX를 보호합니다.
마무리
클럽 실시간 경기방은 Strike Log가 단순 기록 앱에서 같이 쓰는 앱으로 넘어가는 지점이었습니다.
Flutter에서는 SocketService로 이벤트 송수신을 감싸고, NestJS에서는 GameRoomsGateway와 GameRoomsService로 실시간 입구와 도메인 로직을 분리했습니다. 여기에 JWT 인증, 방 상태 브로드캐스트, disconnect grace period, stale room cleanup을 더해 실제 모바일 환경에서 버틸 수 있는 구조로 만들었습니다.
다음 편에서는 이 실시간 경기방의 기반이 되는 클럽 운영 기능을 다룰 예정입니다. 클럽 생성 승인, 가입 신청, 1인 1클럽 정책, 체험판 만료 같은 운영 로직을 어떻게 앱과 백엔드에 연결했는지 정리해보겠습니다.
'APP' 카테고리의 다른 글
| 배지와 출석 streak로 다시 켜고 싶은 앱 만들기 (0) | 2026.07.06 |
|---|---|
| 클럽 운영 기능 만들기: 가입 승인제와 1인 1클럽 정책 (0) | 2026.07.03 |
| NestJS와 TypeORM으로 볼링 기록 API 설계하기 (0) | 2026.06.29 |
| Dio와 JWT로 게임 기록 저장하기: 실패해도 UX가 무너지지 않게 (0) | 2026.06.28 |
| Flutter 볼링 점수 입력 화면: 10프레임 계산 로직 (0) | 2026.06.25 |
