Codeit/프로젝트

[모두의 플리] 트러블슈팅 - 실시간 시청 세션 동기화와 Race condition

leejunkim 2025. 12. 2. 14:33

내가 짰던 코드 흐름

  • WatchingSessionController
    • 현재 시청자 목록을 조회(GET)하여 화면에 렌더링하는 역할
  • WebSocketEventListener
    • SessionSubscribeEvent 발생시 여기서 시청 세션 (WatchingSession 엔티티)를 생성하고, 그 정보를 가져다 payload로 보내서 실시간 입장 브로드캐스트
    • SessionUnsubscribeEvent / SessionDisconnectEvent 발생 시 삭제된 WatchingSession 의 정보를 가져다 payload로 보내서 실시간 퇴장 브로드캐스트
    • 결국 화면에 보이는 실시간 참여자 수를 실시간으로 업데이트가 되게 해준다.
  • 컨트롤러의 조회 API는 이미 들어와 있던 사람들 정보를 볼 수 있게 해주고, WebSocketEventListener는 새로 들어온 "나"의 정보를 목록조회에 더한다.

문제 상황

  • 페이지 진입 시, DB에는 데이터가 있는데 화면에는 시청자 수가 잘못 표시되는 현상이 발생했다. 보니까 watchingSession을 GET할때 빈 데이터를 받아오지만, DB를 확인해보면 또 데이터는 정상적으로 저장되어 있었다.

원인 분석

  • HTTP 요청(Controller)과 WebSocket 연결(Listener) 간의 실행 순서 불일치(Race Condition)가 원인이었다.
    • 초기 진입 시 - WebSocket 핸드셰이크 비용으로 인해 연결이 늦게 맺어지고 SessionSubscribeEvent도 늦게 발행된다.그래서 HTTP GET 요청이 먼저 실행될때 아직 DB 저장 전이므로, 빈 목록을 조회했던 것이다.
    • 이후 WebSocketEventListener의 SessionSubscribeEvent가 실행됨 -  뒤늦게 DB에 저장되지만, 클라이언트는 이미 빈 목록을 받은 상태.
    • 로그를 확인해보니까 순서가 이렇게 되어있었다.

2025-12-01T13:59:56.790+09:00  INFO 17648 --- [mopl] [nio-8080-exec-4] .w.r.CustomWatchingSessionRepositoryImpl : [Repository] findWatchingSessions called with: userId=1e084ee4-95aa-4346-b8c5-1fab1ea9c2d5, contentId=0d07f1d0-e2d2-45c3-a964-1f525b32d7a4, watcherNameLike=null, cursor=null, idAfter=null, limit=51
2025-12-01T13:59:56.803+09:00  INFO 17648 --- [mopl] [nio-8080-exec-4] .w.r.CustomWatchingSessionRepositoryImpl : [Repository] Total sessions in DB for contentId 0d07f1d0-e2d2-45c3-a964-1f525b32d7a4: 0
...
2025-12-01T13:59:57.161+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] handleSessionSubscribe 시작 - sessionId: nzlculvh, destination: /sub/contents/0d07f1d0-e2d2-45c3-a964-1f525b32d7a4/watch
2025-12-01T13:59:57.161+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] getContentId 시작 - destination: /sub/contents/0d07f1d0-e2d2-45c3-a964-1f525b32d7a4/watch
2025-12-01T13:59:57.161+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebSocketEventListener] 컨텐트 아이디 파싱 완료: contentId=0d07f1d0-e2d2-45c3-a964-1f525b32d7a4
2025-12-01T13:59:57.161+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] getUser 시작 - sessionId: nzlculvh
2025-12-01T13:59:57.169+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] getUser 완료 - userId: 1e084ee4-95aa-4346-b8c5-1fab1ea9c2d5
2025-12-01T13:59:57.169+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] 서비스 - DB 저장 시작: sessionId=nzlculvh
2025-12-01T13:59:57.289+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] 서비스 - DB 저장 성공: sessionId=nzlculvh
2025-12-01T13:59:57.331+09:00  INFO 17648 --- [mopl] [nio-8080-exec-5] c.c.m.e.w.WebSocketEventListener         : [WebsocketEventListener] handleSessionSubscribe 완료 - userId: 1e084ee4-95aa-4346-b8c5-1fab1ea9c2d5, contentId: 0d07f1d0-e2d2-45c3-a964-1f525b32d7a4, watcherCount: 1

시도 1

  • 시도 1 (책임 분리): 세션 생성을 HTTP 요청 시점으로 이동하고, WebSocket은 SubscribeEvent가 발행할때 세션을 조회하는 로직으로 바꿨다 (여기서 이제 생성+저장은 안함).
    • 결과: 첫 진입은 해결되었지만, 그 이후 다른 컨텐츠 클릭 시 WebSocket 이벤트가 HTTP 요청보다 먼저 터졌다.
      • 이때 SubscribeEvent시 DB에 데이터가 없어 실시간 시청자 수와 정보가 제대로 업데이트가 안됐었다.

 

  • 그 이유는 웹소켓은 최초 연결 시에만 느리고, 그 이후에는 GET보다 빠르게 실행됐었다.
    • 최초 진입때는 브라우저가 서버와 TCP 연결을 맺고, 다시 HTTP 업그레이드 요청을 보내서 웹소켓으로 전환하는 과정이 필요하고  단순 HTTP 요청보다 무겁다. 

시도 2 (해결) 

  • 최종적으로, HTTP와 WebSocket 중 누가 먼저 도착할지 보장할 수 없다는 점을 인지하고 로직을 변경했다.
    • 양쪽 진입점(Controller, EventListener) 모두에서 joinSession() 메서드를 호출하게 했다.
    • joinSession 메서드
      • 이미 세션이 존재하면 반환하고, 없으면 새로 생성하는 로직을 구현해서 실행 순서와 관계없이 데이터 정합성을 보장했다.

  • 정리
    • 초반에 최초 연결할 때는 컨트롤러로 GET 메서드가 먼저 불려서 joinSession을 호출하면 새로운 WatchingSession이 생성+저장된다. 그리고 이제 최초 웹소켓 연결이 맺어질때 SessionSubscribeEvent가 발행하고 마찬가지로 joinSession을 부른다. 이때 이미 컨트롤러가 데이터를 저장했으므로 그냥 있는 데이터를 가져온다.
    •  그 이후에는, 이미 웹소켓 연결이 됀 상태라 SessionSubscribeEvent가 GET 보다 더 빨리 호출된다. 그래서 이떄는 웹소켓 쪽에서 먼저 joinSession을 호출하고 데이터를 생성+저장하고, 그 이후 컨트롤러의 GET이 호출되서 이미 저장된 데이터를 조회한다.
  • 결과적으로, 새로고침(Cold Start)과 페이지 이동(Warm Connection) 모든 상황에서 시청자 목록 조회 및 실시간 입장 알림이 정확하게 동작함을 확인했다.