채팅 메시지에 Optimistic Update 적용하기

leave_a_comment·2026년 5월 22일
post-thumbnail

채팅 기능을 구현하다 보면 가장 먼저 체감되는 문제가 하나 있다.

"전송 버튼을 눌렀는데 내 메시지가 바로 안 뜬다."

WebSocket 기반이든 HTTP 기반이든,
서버 응답 이후에만 메시지를 추가하면 그 사이 UI가 멈춘 것처럼 보인다.

특히 네트워크가 느릴수록:

  • 버튼을 눌렀는데 반응이 없고
  • 메시지가 늦게 나타나며
  • UX가 답답하게 느껴진다.

이번 글에서는 채팅 메시지 전송에 Optimistic Update를 적용하는 방법과,
서버 메시지와 로컬 메시지를 어떻게 동기화하는지 정리해보려 한다.


기본 구조 — pending 상태 추가

먼저 메시지 타입에 pending 상태를 추가한다.

type LocalMessage = ChatMessage & {
  pending?: boolean;
};
  • 서버 메시지 → pending 없음 또는 false
  • 아직 서버 확인 전인 메시지 → pending: true

즉:

"아직 서버 검증이 끝나지 않은 임시 메시지"

라는 상태를 표현하는 용도다.

실제로는 이 한 줄의 타입 확장만으로도 Optimistic Update의 기반이 만들어진다.


메시지 전송 — 먼저 렌더링하고 나중에 전송

const sendMessage = useCallback((content: string) => {
  const ws = wsRef.current;

  if (!ws || ws.readyState !== WebSocket.OPEN) return;

  // 1. 먼저 UI에 추가
  const optimistic: LocalMessage = {
    id: `pending_${Date.now()}`,
    content,
    author: currentUser,
    createdAt: new Date().toISOString(),
    pending: true,
  };

  setMessages((prev) => [...prev, optimistic]);

  // 2. 실제 전송은 그 다음
  ws.send(
    JSON.stringify({
      type: "chat:send",
      payload: { content },
    }),
  );
}, []);

핵심은 순서다.

기존 방식은:

전송 → 서버 응답 → 렌더

였다면,

Optimistic Update는:

렌더 → 전송 → 서버 검증

순서로 동작한다.

즉 유저 입장에서는 버튼을 누르는 즉시 내 메시지가 화면에 보인다.


서버 응답 처리 — pending 메시지 교체

이후 서버에서 chat:message 이벤트가 도착하면:

  1. 내가 보낸 pending 메시지인지 확인
  2. 맞다면 실제 서버 메시지로 교체
  3. 아니라면 다른 유저 메시지로 추가

처리한다.

case "chat:message": {
  const incoming = data.payload.message;

  setMessages((prev) => {
    const pendingIdx = prev.findIndex(
      (m) =>
        m.pending &&
        m.content === incoming.content &&
        m.author.id === incoming.author.id,
    );

    // 1. pending 메시지 교체
    if (pendingIdx !== -1) {
      const next = [...prev];
      next[pendingIdx] = incoming;
      return next;
    }

    // 2. 중복 방지
    if (prev.some((m) => m.id === incoming.id)) {
      return prev;
    }

    // 3. 다른 유저 메시지 추가
    return [...prev, incoming];
  });
}

전체 흐름

전송 버튼 클릭
  ↓
pending 메시지 즉시 추가
(id: pending_xxx, pending: true)
  ↓
WebSocket 전송
  ↓
서버에서 chat:message 이벤트 도착
  ↓
pending 메시지 탐색
  ↓
일치하면 → 서버 메시지로 교체
불일치하면 → 새 메시지 추가

핵심 — pending 메시지 매칭 기준

가장 중요한 건:

"서버 메시지와 어떤 기준으로 매칭할 것인가"

다.

현재 구현에서는:

m.pending &&
m.content === incoming.content &&
m.author.id === incoming.author.id

조건으로 찾고 있다.


왜 id로 비교하지 않을까?

pending 메시지의 id는:

pending_123123

같은 임시값이다.

반면 서버 메시지는:

msg_87asd7

처럼 실제 DB 기반 id를 가진다.

즉 서로 값이 다르기 때문에 id 비교로는 매칭할 수 없다.

그래서:

  • content
  • author.id

조합으로 "같은 메시지"인지 판단한다.


UI에서 pending 상태 표현하기

pending 플래그를 사용하면 전송 중 상태를 자연스럽게 표시할 수 있다.

{messages.map((msg) => (
  <div
    key={msg.id}
    style={{
      opacity: msg.pending ? 0.5 : 1,
    }}
  >
    {msg.content}

    {msg.pending && (
      <span>전송 중...</span>
    )}
  </div>
))}

유저 입장에서는:

  1. 메시지가 즉시 나타나고
  2. 서버 응답 전까지 흐리게 표시되며
  3. 서버 확인 후 정상 상태로 변경

되는 흐름으로 보인다.


주의할 점 — rollback 처리

현재 구조는 사실상:

ws.send(...)

후 응답을 기다리지 않는 fire-and-forget 방식이다.

즉 전송 실패 시 pending 메시지가 계속 남아 있을 수 있다.


실패 처리 방법

예를 들어 연결이 끊겼다면:

setMessages((prev) =>
  prev.filter((m) => !m.pending),
);

처럼 rollback 처리할 수 있다.

또는:

pending: "failed"

같은 상태를 추가해:

  • 재전송 버튼
  • 실패 표시 UI

를 제공할 수도 있다.


케이스별 처리 전략

상황처리 방식
서버 응답 성공pending → 실제 메시지 교체
연결 끊김pending 제거 또는 실패 표시
중복 메시지 수신id 기준 중복 방지
전송 실패rollback 또는 재전송

정리

항목기존 방식Optimistic Update
메시지 표시 시점서버 응답 후전송 즉시
전송 상태 표시없음pending 상태
서버 응답 처리단순 추가pending 메시지 교체
실패 처리필요 없음rollback 필요
UX응답 지연 체감즉각적인 반응

채팅에서 Optimistic Update의 핵심은 결국 두 가지다.

먼저 보여주고,

나중에 서버 상태와 맞춘다.

그리고 서버 응답이 도착하면:

임시 메시지를 실제 메시지로 교체한다.

이 흐름만 잘 설계해도 채팅 UX 체감 속도는 꽤 크게 개선된다.

profile
나도 성장하고파

0개의 댓글