발행일

STOMP 채팅에서 두 번째 메시지부터 동기화가 깨지던 문제 — 범인은 key였다

STOMP 채팅에서 두 번째 메시지부터 동기화가 깨지던 문제

[TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발)의 일정 채팅(연결 문제를 해결한 뒤의 이야기입니다)에서 이상한 증상이 나왔습니다.

  1. 첫 메시지는 잘 동기화된다
  2. 두 번째 메시지부터 화면이 안 갱신된다
  3. 새로고침하면 보낸 메시지가 전부 보인다

3번이 중요한 단서였습니다. 새로고침(REST GET)으로는 다 보인다는 건 전송도 저장도 정상이라는 뜻이에요. 백엔드·브로커·DB는 용의선상에서 빠지고, "받은 데이터를 화면에 반영하는 지점" — 프런트의 렌더링만 남습니다.


1. 원인 좁히기 — 저장은 되는데 렌더만 안 된다면

채팅 화면의 데이터 흐름은 이렇습니다.

  1. REST GET으로 과거 메시지 로드
  2. STOMP 구독 시작
  3. 메시지 전송 → 백엔드가 MongoDB에 저장(이때 messageId 자동 생성) → 구독 채널로 브로드캐스트
  4. 구독 콜백이 setMessages로 목록에 추가

구독 콜백에 console.log를 넣어 보니 메시지는 도착하고 있었습니다. setMessages도 호출되고요. 그런데 화면이 안 바뀝니다. 상태는 바뀌는데 렌더가 안 따라온다면, React가 "바뀐 게 없다"고 판단할 수 있는 지점key를 봐야 합니다.

<div key={msg.messageId} className={styles.userMessages}>

목록의 각 메시지는 messageIdkey로 씁니다. 그런데 도착한 메시지 객체를 찍어 보니 messageId가 없었습니다. 브로드캐스트 응답을 만드는 백엔드 코드가 저장 의 객체로 응답을 만들고 있었거든요.

ChatMessage message = ChatMessage.of(member, chatMessageRequest);
chatMessageRepository.save(message);          // 저장 — MongoDB가 messageId 발급
return ChatResponse.from(member, message);    // 그런데 응답은 발급 전 객체 기준

프런트는 빈 messageId난수로 임시 생성해 채우고 있었습니다. 이게 최악의 조합이었어요. key가 렌더마다 달라질 수 있으니 React의 리스트 대조(reconciliation)가 무의미해지고, 같은 메시지가 다른 항목으로 취급되거나 갱신이 누락됩니다. "첫 번째는 되고 두 번째부터 안 된다" 같은 비결정적 증상은 대체로 이런 식별자 불안정에서 나옵니다.


2. 수정 — 식별자는 발급한 쪽이 돌려준다

수정 자체는 한 줄입니다. 저장이 반환하는 객체(발급된 messageId 포함)로 응답을 만들면 됩니다. 백엔드는 팀원 담당이라, 원인 분석 결과와 "응답에 messageId가 있어야 한다"는 요구를 전달해 반영했습니다.

ChatMessage message = chatMessageRepository.save(ChatMessage.of(member, chatMessageRequest));
return ChatResponse.from(member, message);    // 저장된 messageId 포함

프런트는 난수 생성 로직을 제거하고 서버가 준 messageId를 그대로 key로 씁니다. 이후 새로고침 없이 연속 메시지가 정상 동기화됐습니다.

원칙으로 남긴 것: 고유 식별자는 그것을 발급하는 쪽(DB)이 응답으로 돌려줘야 하고, 클라이언트가 임시로 지어내면 안 됩니다. 임시 ID가 필요한 낙관적 업데이트를 하더라도, 서버 확정 ID가 오면 반드시 교체하는 설계여야 해요.


3. 다시 보니 — 당시 정리가 놓친 것

이 글의 원래 버전은 "해결" 절에 프런트 코드를 이렇게 실어 놨었습니다.

setMessages((prev) => {
  const combinedMessages = [...prev, newMessage]
  return combinedMessages.sort(
    (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
  )
})

문제는 이 코드가 "문제 원인" 절에 실린 코드와 완전히 동일했다는 겁니다. 같은 코드를 문제라고 한 번, 해결이라고 한 번 실어 놓고 눈치채지 못했어요. 실제 변경은 백엔드 응답과 key 사용부였는데, 정리하면서 바뀐 지점이 아니라 손에 익은 코드를 붙인 거죠. "수정 전/후 코드는 diff가 있어야 한다" — 당연한 말인데 당시 글은 어기고 있었습니다.

그리고 이 setMessages 코드 자체에도 지금 보면 걸리는 게 두 개 있습니다.

  • 중복 방어가 없습니다. STOMP는 재연결(reconnectDelay) 후 재구독하는데, 이때 같은 메시지가 다시 올 수 있습니다. messageId가 생겼으니 추가 전에 prev.some(m => m.messageId === newMessage.messageId)로 걸러야 완성입니다.
  • timestamp 정렬은 동순위에서 불안정합니다. 같은 밀리초에 두 메시지가 오면 순서가 렌더마다 뒤집힐 수 있어요. MongoDB의 ObjectId는 생성 시각 기반이라 messageId 정렬이 더 안정적입니다.

4. 돌아보면

  • "새로고침하면 보인다"가 디버깅의 절반이었습니다. 저장 경로가 정상임을 증명해 주니, 남은 반경이 렌더링뿐이었어요. 증상 중에 이미 정상인 경로가 뭔지부터 확정하는 게 가장 빠른 소거법이었습니다.
  • 비결정적 UI 버그는 key부터 봅니다. "될 때도 있고 안 될 때도 있다"는 리스트 버그의 상당수가 불안정한 key였습니다.
  • 한계 — 위에서 지적한 중복 방어와 messageId 정렬은 당시에는 반영하지 못했고, 이후 언마운트 중 send로 인한 에러를 cleanup(deactivate())으로 막는 작업과 함께 정리됐습니다. 재연결 시 메시지 유실(끊긴 동안 온 메시지) 처리도 이 글 범위 밖인데, 재구독 시 마지막 messageId 이후를 REST로 보충하는 방식이 후속 과제로 남았습니다.