프로젝트: Snowpeak Game 스택: Spring Boot 3.5 (Spring Framework 6.2), Spring WebSocket STOMP, Simple Broker 한 줄 요약: 같은 클라이언트가 보낸 이동 메시지가 서버에서 보낸 순서와 다르게 처리되어 예전 위치가 최종값으로 남았습니다. Spring의 inbound 스레드 풀이 원인이었고, setPreserveReceiveOrder(true)로 해결했습니다.


1. 발견 경위

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번 연속 실패한 적도 있었습니다.

2. 가설 검증

가설 확인 방법 결과
스케줄러(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)이 "최신 위치"로 남은 것이었습니다.

3. 원인: TCP 순서 ≠ 애플리케이션 처리 순서

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]

같은 원리로 서버 → 클라이언트 방향(clientOutboundChannel)에서도 브로드캐스트 순서가 섞일 수 있습니다.

게임에서 나타나는 증상

위치 증상
다른 플레이어 화면 캐릭터가 순간적으로 뒤로 튀었다가 다시 앞으로 감
Redis 저장값 플레이어가 멈춘 뒤에도 조금 전 위치가 저장됨 → 새 입장자에게 틀린 위치가 SYNC됨
부하가 클수록 스레드 경쟁이 심해져 더 자주 발생 → 로컬에서 혼자 테스트할 때는 잘 안 보임

4. 해결