
채팅 기능을 구현하다 보면 가장 먼저 체감되는 문제가 하나 있다.
"전송 버튼을 눌렀는데 내 메시지가 바로 안 뜬다."
WebSocket 기반이든 HTTP 기반이든,
서버 응답 이후에만 메시지를 추가하면 그 사이 UI가 멈춘 것처럼 보인다.
특히 네트워크가 느릴수록:
이번 글에서는 채팅 메시지 전송에 Optimistic Update를 적용하는 방법과,
서버 메시지와 로컬 메시지를 어떻게 동기화하는지 정리해보려 한다.
먼저 메시지 타입에 pending 상태를 추가한다.
type LocalMessage = ChatMessage & {
pending?: boolean;
};
pending 없음 또는 falsepending: 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는:
렌더 → 전송 → 서버 검증
순서로 동작한다.
즉 유저 입장에서는 버튼을 누르는 즉시 내 메시지가 화면에 보인다.
이후 서버에서 chat:message 이벤트가 도착하면:
처리한다.
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 메시지 탐색
↓
일치하면 → 서버 메시지로 교체
불일치하면 → 새 메시지 추가
가장 중요한 건:
"서버 메시지와 어떤 기준으로 매칭할 것인가"
다.
현재 구현에서는:
m.pending &&
m.content === incoming.content &&
m.author.id === incoming.author.id
조건으로 찾고 있다.
pending 메시지의 id는:
pending_123123
같은 임시값이다.
반면 서버 메시지는:
msg_87asd7
처럼 실제 DB 기반 id를 가진다.
즉 서로 값이 다르기 때문에 id 비교로는 매칭할 수 없다.
그래서:
조합으로 "같은 메시지"인지 판단한다.
pending 플래그를 사용하면 전송 중 상태를 자연스럽게 표시할 수 있다.
{messages.map((msg) => (
<div
key={msg.id}
style={{
opacity: msg.pending ? 0.5 : 1,
}}
>
{msg.content}
{msg.pending && (
<span>전송 중...</span>
)}
</div>
))}
유저 입장에서는:
되는 흐름으로 보인다.
현재 구조는 사실상:
ws.send(...)
후 응답을 기다리지 않는 fire-and-forget 방식이다.
즉 전송 실패 시 pending 메시지가 계속 남아 있을 수 있다.
예를 들어 연결이 끊겼다면:
setMessages((prev) =>
prev.filter((m) => !m.pending),
);
처럼 rollback 처리할 수 있다.
또는:
pending: "failed"
같은 상태를 추가해:
를 제공할 수도 있다.
| 상황 | 처리 방식 |
|---|---|
| 서버 응답 성공 | pending → 실제 메시지 교체 |
| 연결 끊김 | pending 제거 또는 실패 표시 |
| 중복 메시지 수신 | id 기준 중복 방지 |
| 전송 실패 | rollback 또는 재전송 |
| 항목 | 기존 방식 | Optimistic Update |
|---|---|---|
| 메시지 표시 시점 | 서버 응답 후 | 전송 즉시 |
| 전송 상태 표시 | 없음 | pending 상태 |
| 서버 응답 처리 | 단순 추가 | pending 메시지 교체 |
| 실패 처리 | 필요 없음 | rollback 필요 |
| UX | 응답 지연 체감 | 즉각적인 반응 |
채팅에서 Optimistic Update의 핵심은 결국 두 가지다.
먼저 보여주고,
나중에 서버 상태와 맞춘다.
그리고 서버 응답이 도착하면:
임시 메시지를 실제 메시지로 교체한다.
이 흐름만 잘 설계해도 채팅 UX 체감 속도는 꽤 크게 개선된다.