프로젝트: Snowpeak Game 스택: Spring Boot 3.5 (Spring Framework 6.2), Spring WebSocket STOMP, Simple Broker 한 줄 요약: 같은 클라이언트가 보낸 이동 메시지가 서버에서 보낸 순서와 다르게 처리되어 예전 위치가 최종값으로 남았습니다. Spring의 inbound 스레드 풀이 원인이었고,
setPreserveReceiveOrder(true)로 해결했습니다.
Redis 저장 구조를 바꾸면서 "연속으로 움직이면 마지막 위치가 저장되는가" 를 확인하는 통합 테스트를 작성했습니다.
alice.session.send("/app/move", Map.of("x", 10, "y", 20, "direction", "up"));
alice.session.send("/app/move", Map.of("x", 30, "y", 40, "direction", "up"));
awaitCondition(() -> playerRepository.findAll("1").stream()
.anyMatch(p -> p.getX() == 30 && p.getY() == 40)); // 3초 안에 (30,40)이 저장돼야 함
이 테스트가 간헐적으로 실패했습니다. 7번 실행해 5번 실패했고, 4번 연속 실패한 적도 있었습니다.
| 가설 | 확인 방법 | 결과 |
|---|---|---|
| 스케줄러(flush)가 동작하지 않는다 | 두 번째 send 뒤 500ms 대기 후 저장소 출력 | x=30.0 저장됨 → 기각 (대기를 넣으면 통과) |
| 메시지 처리 순서가 바뀐다 | 대기 없이, 실패 시점의 저장소 상태 출력 | x=10.0, y=20.0이 최종값 → 채택 |
DEBUG timeout store=[PlayerState(playerId=alice, x=10.0, y=20.0, direction=up, ...)]
flush는 정상이었습니다. 문제는 두 번째 메시지 (30,40)가 첫 번째 (10,20)보다 먼저 처리됐고, 마지막에 처리된 (10,20)이 "최신 위치"로 남은 것이었습니다.
flowchart LR
C[Client] -- "TCP: (10,20) → (30,40) 순서대로 도착" --> WS[WebSocket Session]
WS --> IN[clientInboundChannel<br/>ThreadPoolTaskExecutor]
IN -- "thread-1: (10,20)" --> H["@MessageMapping /move"]
IN -- "thread-2: (30,40)" --> H
H --> P[pendingMoves.put]
clientInboundChannel에 넣고, 스레드 풀이 꺼내 @MessageMapping 메서드를 실행합니다.pendingMoves.put(nickname, state)가 나중에 실행된 쪽(옛 위치)으로 덮어써졌습니다.같은 원리로 서버 → 클라이언트 방향(clientOutboundChannel)에서도 브로드캐스트 순서가 섞일 수 있습니다.
| 위치 | 증상 |
|---|---|
| 다른 플레이어 화면 | 캐릭터가 순간적으로 뒤로 튀었다가 다시 앞으로 감 |
| Redis 저장값 | 플레이어가 멈춘 뒤에도 조금 전 위치가 저장됨 → 새 입장자에게 틀린 위치가 SYNC됨 |
| 부하가 클수록 | 스레드 경쟁이 심해져 더 자주 발생 → 로컬에서 혼자 테스트할 때는 잘 안 보임 |