상태 · 데이터
실패한 메시지의 재전송·삭제 — FE 실패와 서버 실패를 가르기
채팅에서 메시지 전송이 실패했을 때 "다시 보내기"와 "삭제"를 붙이는 일은 버튼 두 개면 끝날 줄 알았다. 그런데 막상 손을 대보니 실패에 두 종류가 있었다. "서버가 아예 못 받은 실패"와 "서버는 받았는데 그다음 전송이 실패한 경우". 이 둘은 데이터가 사는 곳도, 새로고침에서의 행동도, 재전송·삭제의 처리도 전부 달랐다. 가르는 기준부터 다시 세워야 했고, 그 위에서도 낙관적 UI와 스크롤 때문에 세 번을 더 깨졌다.
두 개의 실패는 데이터가 사는 곳이 다르다
먼저 우리 화면 구조를 짚어야 한다. 채팅 스레드는 두 출처의 메시지를 위아래로 이어 붙여 그린다.
- 서버 메시지 — React Query 무한쿼리 캐시에 들어 있는, 서버가 아는 메시지
- 낙관적 메시지 —
appendedMessages라는 FE 로컬 상태에 들어 있는, 방금 내가 보낸 메시지
낙관적 UI라 메시지를 보내는 순간 일단 appendedMessages에 pending으로 꽂아 화면에 띄우고, 서버
응답이 오면 서버 ID로 swap한 뒤 캐시로 흡수시킨다. 문제는 이 흡수가 끝나기 전에 실패하느냐, 끝난 뒤에
실패하느냐에 따라 메시지가 전혀 다른 곳에 남는다는 것이었다.
| Case 1 — FE-only 실패 | Case 2 — 서버 실패 | |
|---|---|---|
| 원인 | sendMessage.mutateAsync 호출 자체가 실패 (네트워크 끊김, 서버 다운) | 서버는 받았지만 그 뒤 외부 메신저로 보내는 단계에서 실패 |
| 메시지 위치 | appendedMessages (FE 로컬 상태) | React Query 캐시 (messageStatus: 'FAILED') |
| 서버 인식 | 없음 — 서버는 이 메시지의 존재 자체를 모름 | 있음 — 응답 DTO의 messageStatus가 FAILED |
| 새로고침 시 | 사라진다 (로컬 상태라 날아감) | 유지된다 (서버 데이터라 다시 내려옴) |
이 표의 마지막 줄이 핵심이다. Case 1은 서버가 그 메시지를 모르니, 새로고침하면 흔적도 없이 사라진다.
Case 2는 서버에 FAILED 상태로 남아 있으니, 새로고침해도 빨간 실패 메시지가 그대로 다시 그려진다.
같은 "전송 실패"인데 새로고침 한 번에 운명이 갈리는 것이다. 그러니 재전송과 삭제도 이 둘을 같은
코드로 처리할 수가 없었다.
어떻게 구분하나 — ID 포맷이 아니라 "어디에 있는가"
재전송·삭제 버튼이 받는 건 messageId 하나뿐이다. 이 ID만 보고 "이게 FE 메시지냐 서버 메시지냐"를
판단해야 했다.
처음 떠오른 건 ID 포맷으로 가르는 방법이었다. 낙관적 메시지는 임시로 room-1-1712... 같은 문자열 ID를
달고, 서버 메시지는 숫자 ID를 쓰니, 포맷만 봐도 구분이 될 것 같았다.
// 처음 생각한 방식 — ID 모양으로 구분 (채택 안 함)
if (typeof messageId === 'string' && messageId.startsWith('room-')) {
// FE 메시지로 간주
}그런데 이건 ID 생성 규칙에 코드가 묶이는 방식이었다. 임시 ID 포맷을 room-에서 다른 접두사로
바꾸거나, 서버가 언젠가 문자열 ID를 내려주기라도 하면 이 분기는 조용히 틀린 길로 빠진다. 컴파일러는
아무 말도 안 해주고, 런타임에서 엉뚱한 케이스로 처리될 뿐이다.
그래서 포맷이 아니라 이 메시지가 지금 어느 출처에 들어 있는가로 갈랐다. appendedMessages는
FE-only 메시지의 source of truth다. 거기서 직접 찾아보면, 있으면 Case 1이고 없으면 Case 2다.
const feMessage = (appendedMessages[selectedRoomId] ?? []).find((m) => m.id === messageId);
if (feMessage) {
// Case 1: FE-only — appendedMessages에 존재
} else {
// Case 2: 서버 — React Query 캐시에 존재
}이 기준이 깨지지 않는 이유가 하나 더 있다. 낙관적 메시지가 서버로 흡수될 때 ID를 swap하면서
appendedMessages에서 제거되므로, "흡수가 끝났는데도 appendedMessages에 남아 있는" 어정쩡한 타이밍이
존재하지 않는다. 출처 자체가 상태를 명확히 말해주니, 값의 모양을 추측할 필요가 없었다.
값의 포맷이 아니라 데이터의 출처로 분기한다. 포맷은 바뀔 수 있지만, "어느 상태 컨테이너에 들어 있는가"는 그 데이터의 정체 그 자체다.
재전송 — 두 케이스를 따로 짠다
Case 1: FE-only 재전송
서버가 모르는 메시지니, 처음 보내는 것과 똑같이 다시 보내면 된다. appendedMessages 안에서 상태만
pending으로 되돌리고 전송 mutation을 재호출한다.
1. appendedMessages에서 메시지 찾기 (status === 'failed')
2. status → 'pending' 으로 전환 (낙관적)
3. sendMessage.mutateAsync() 재호출
4. 성공 → 서버 ID로 swap → 캐시 invalidate → appendedMessages에서 제거
5. 실패 → status → 'failed' 로 복원Case 2: 서버 재전송
서버는 이 메시지를 FAILED로 알고 있으니, 처음부터 다시 보내는 게 아니라 그 메시지를 다시 시도하라는
retry 요청을 보낸다. 여기서 그냥 캐시의 상태값만 PENDING으로 바꾸면 될 것 같았는데, 이게 첫 번째
함정이었다.
1. 무한쿼리 캐시(InfiniteData)에서 해당 메시지를 찾아 제거
2. mapChatMessageDtoToViewModel() 로 화면용 모델로 변환
3. appendedMessages에 pending 으로 추가 → 스레드 맨 아래에 표시
4. scrollToBottom() 호출
5. 서버에 retry 요청 전송
6. 성공 → 서버 ID로 swap → 캐시 invalidate → appendedMessages 정리
7. 실패 → appendedMessages에서 failed 로 표시2~3번이 의외의 결정이다. 서버 메시지인데도 굳이 캐시에서 빼서 appendedMessages로 옮긴다.
왜 이렇게 했는지는 바로 다음에 깨진 버그가 설명해준다.
함정 ①: 재전송한 메시지가 맨 아래로 안 올라온다
처음엔 캐시 안에서 상태값만 바꾸는, 누가 봐도 정석인 방식으로 짰다.
// 초기 구현 — 캐시 안에서 status만 PENDING으로
onMutate: async ({ id, messageId }) => {
queryClient.setQueryData(infiniteKey, (old) => ({
...old,
pages: old.pages.map((page) => ({
...page,
content: page.content.map((m) =>
m.id === messageId ? { ...m, messageStatus: 'PENDING' } : m,
),
})),
}));
};증상은 이랬다. retry를 누르면 상태는 PENDING으로 바뀌는데, 메시지가 원래 자리(스레드 중간)에 그대로
머물렀다. 실패 메시지가 스크롤을 한참 올려야 보이는 위치에 있으면, retry를 눌러도 화면엔 아무 변화가
없는 것처럼 보였다. scrollToBottom()을 불러도 정작 그 메시지는 맨 아래가 아니라 중간에 있으니,
viewport 밖에 머물러 안 보였다.
원인을 따져보니 사용자의 기대와 데이터 모델이 어긋나 있었다. 사람은 "다시 보내기"를 누르면 그 메시지가 방금 새로 보낸 것처럼 맨 아래에 나타나길 기대한다. 그런데 서버 retry 응답이 와서 캐시가 갱신되기 전까지는, 메시지가 원래 정렬 위치에 그대로 박혀 있다. 상태만 바꾸는 방식으론 위치를 바꿀 수 없었다.
해결은 위치 문제를 위치로 푸는 것이었다. 캐시에서 메시지를 제거하고, 스레드 맨 아래에 렌더되는
appendedMessages로 추가한다.
// 1. 캐시에서 찾아서 제거 (찾은 DTO는 보관)
let foundDto;
queryClient.setQueryData(infiniteKey, (old) => ({
...old,
pages: old.pages.map((page) => {
const target = page.content.find((m) => m.id === numericMessageId);
if (target) foundDto = target;
return { ...page, content: page.content.filter((m) => m.id !== numericMessageId) };
}),
}));
// 2. appendedMessages에 pending으로 추가 (스레드 맨 아래)
const pendingMessage = { ...mapChatMessageDtoToViewModel(foundDto), status: 'pending' };
setAppendedMessages((prev) => ({
...prev,
[roomId]: [...(prev[roomId] ?? []), pendingMessage],
}));appendedMessages는 항상 캐시 메시지들 아래에 그려지므로, 옮기는 순간 자연스럽게 맨 아래로 올라온다.
서버 retry 응답이 왔을 때 그 메시지가 최신 위치(맨 아래)에 나타날 텐데, 낙관적 업데이트도 같은 자리에서
보여줘야 응답 전후가 일관됐다. 낙관적 UI의 위치는 "최종 결과가 어디 나타날지"에 맞춰야 한다는 걸
여기서 배웠다.
함정 ②: 재전송 후 스크롤이 자동으로 안 따라 내려간다
맨 아래로 옮기는 것까진 됐는데, 이번엔 스크롤이 말썽이었다. retry 직후 scrollToBottom()을 부르는데도,
그 뒤에 메시지 버블이 실제로 렌더될 때 스크롤이 다시 따라 내려가지 않았다.
우리 스크롤 훅은 shouldStickToBottomRef라는 플래그로 "지금 맨 아래에 붙어 있어야 하는 상태인가"를
기억한다. scrollToBottom()은 이 플래그를 true로 세우고, 이후 ResizeObserver가 DOM 높이 변화를
감지하면 그 플래그를 보고 다시 맨 아래로 붙인다. 그런데 타이밍이 꼬였다.
1. scrollToBottom() 호출 시점 — 새 메시지가 아직 DOM에 없음 (React 배치 업데이트)
2. 그 사이 다른 scroll 이벤트가 발생하면 shouldStickToBottomRef를 false로 되돌릴 수 있음
3. 뒤늦게 DOM이 갱신되고 ResizeObserver가 발화하지만, 플래그는 이미 false
→ 다시 안 붙음처음엔 retry mutation 안 어딘가에서 스크롤을 부르려 했는데, 그러면 호출 시점이 DOM 갱신과 어긋나는 문제가 반복됐다. 그래서 책임을 호출부로 끌어올렸다. 스레드 컴포넌트에서 retry 콜백을 감싸, 사용자 액션 직후 플래그를 세우게 했다.
const handleRetryWithScroll = useCallback(
(messageId: string) => {
onRetry?.(messageId);
scrollToBottom(); // shouldStickToBottomRef = true 로 의도를 먼저 박아둔다
},
[onRetry, scrollToBottom],
);핵심은 scrollToBottom()을 "지금 당장 스크롤을 내리는 명령"이 아니라 "앞으로 들어올 DOM 변화에 맞춰
맨 아래에 붙겠다는 의도 선언"으로 쓴 것이다. 액션 시점에 의도를 먼저 박아두면, 새 버블이 렌더돼
ResizeObserver가 발화하는 순간 그 의도를 읽고 알아서 따라 내려간다.
삭제 — 여기서도 두 케이스가 갈린다
Case 1: FE-only 삭제
로컬 상태에서 빼면 끝이다. 다만 무한쿼리가 다시 데이터를 합칠 때 되살아나지 않도록, 삭제한 ID를 따로 모아 필터링한다.
1. deletedMessageIds에 추가 (이후 렌더에서 필터링)
2. appendedMessages에서 제거
3. 삭제 다이얼로그 닫기Case 2: 서버 삭제
서버에 삭제 요청을 보내야 한다. 여기선 TanStack Query의 낙관적 삭제 정석 패턴을 그대로 썼다.
1. onMutate: cancelQueries (진행 중 refetch가 낙관적 변경을 덮어쓰지 못하게)
+ setQueryData (캐시에서 즉시 제거)
2. 서버에 삭제 요청 전송
3. onSettled: invalidateQueries (성공/실패 모두 서버 상태로 refetch)
4. onError: 에러 토스트한 가지 의도적으로 뺀 게 있다. 보통 낙관적 업데이트라고 하면 onMutate에서 이전 캐시를 snapshot 떠두고
onError에서 그걸로 롤백하는 패턴을 쓴다. 그런데 여기선 onSettled에서 성공이든 실패든 무조건 서버
상태로 refetch하므로, 굳이 snapshot을 떠둘 필요가 없었다. 실패하면 서버엔 메시지가 그대로 남아 있을
테니, refetch 한 번이면 화면이 알아서 원복된다. 롤백 코드를 더하는 대신 "항상 서버를 다시 믿는다"로
단순화한 셈이다.
| snapshot 롤백 방식 | 채택한 refetch 방식 | |
|---|---|---|
onMutate | 이전 캐시 snapshot + 낙관적 제거 | cancelQueries + 낙관적 제거 |
onError | snapshot으로 캐시 복원 | 토스트만 (복원은 onSettled가) |
onSettled | (선택) | 항상 invalidateQueries로 refetch |
| 복잡도 | snapshot 보관·복원 코드 필요 | 서버를 단일 진실로 위임, 코드 적음 |
함정 ③: 삭제하면 스크롤이 꿈틀거린다
서버 삭제가 동작은 잘 됐는데, 메시지가 사라질 때 스크롤 위치가 갑자기 튀는 현상이 있었다. 특히 무한스크롤로 이전 메시지를 한참 불러온 상태에서 중간 메시지를 지우면, 보고 있던 위치가 엉뚱한 데로 점프했다.
범인은 스크롤 위치 보존 로직이었다. 무한스크롤로 위에 메시지가 prepend되면 스크롤이 위로 밀리니까,
높이 증가분만큼 scrollTop을 더해 보고 있던 위치를 유지하는 코드가 있었다.
// Before (버그)
if (newHeight > prevHeight && prevHeight > 0) {
viewport.scrollTop = prevTop + (newHeight - prevHeight);
}이 조건은 content가 늘어나는 경우(newHeight > prevHeight)만 본다. prepend만 생각하고 짠 코드였다.
그런데 삭제는 content를 줄인다. newHeight < prevHeight가 되니 조건이 아예 성립하지 않고, scrollTop이
보정되지 않은 채 viewport가 엉뚱한 메시지를 보여줬다.
고친 건 비교 연산자 하나였다.
// After (수정)
if (newHeight !== prevHeight && prevHeight > 0) {
viewport.scrollTop = prevTop + (newHeight - prevHeight);
}>를 !==로 바꾸자, 삭제로 높이가 줄면 newHeight - prevHeight가 음수가 되어 scrollTop이 그만큼
줄어들고, 보고 있던 메시지가 제자리에 유지됐다. 높이 변화를 "증가"로만 좁혀 생각한 게 화근이었다 —
prepend도 삭제도 결국 같은 "높이가 변했다"는 사건인데 말이다.
더블클릭 방어 — 케이스별로 막는 신호가 다르다
실패 메시지의 재전송 버튼은 연타되기 쉽다. 두 케이스는 "이미 진행 중인가"를 판단하는 신호 자체가 달랐다.
// 서버 retry — mutation의 isPending으로 막는다
if (retryMessageMutation.isPending) return;
// FE retry — 메시지 자신의 status로 막는다
if (message.status === 'pending') return;서버 retry는 mutation 단위로 진행 상태를 알 수 있으니 isPending을 보면 되고, FE retry는 mutation이
아니라 로컬 상태 전이라 메시지의 status가 이미 pending이면 중복으로 본다. 같은 "두 번 누르지 마라"인데
근거가 출처에 따라 갈리는 게, 이 기능 전체를 관통하는 패턴이었다.
마지막 함정: 무한쿼리 캐시를 직접 만질 때의 타입
재전송·삭제 모두 queryClient.setQueryData로 캐시를 직접 조작한다. 여기서 무한쿼리의 키와 타입을
잘못 잡으면 런타임에서 조용히 어긋난다.
// 잘못 — 무한쿼리가 아닌 단일 페이지 키/타입으로 접근
queryClient.setQueryData(getMessagesQueryKey(roomApiId), ...);
// 올바름 — 무한쿼리의 실제 키는 baseKey 뒤에 'infinite'가 붙고,
// 데이터는 { pages, pageParams } 로 감싸진 InfiniteData 구조
queryClient.setQueryData(
[...getMessagesQueryKey(roomApiId), 'infinite'],
(old) => ({ ...old, pages: old.pages.map(/* ... */) }),
);무한쿼리의 데이터는 메시지 배열이 아니라 { pages: [...], pageParams: [...] } 구조이고, 키도 일반 쿼리
키에 'infinite' 세그먼트가 더 붙는다. 이걸 단일 페이지 타입으로 잘못 잡으면 old.pages가 undefined라
터지거나, 더 나쁘게는 엉뚱한 키를 건드려 아무 일도 안 일어난다. 캐시를 직접 만지는 코드일수록 "지금
내가 어떤 모양의 데이터를 어떤 키로 건드리는가"를 정확히 알아야 했다.
배운 점
- 두 출처를 가진 데이터는 "어느 출처에 있나"로 분기한다. FE 낙관적 상태와 서버 캐시에 동시에 살 수 있는 데이터는, 값의 포맷(ID 모양)이 아니라 데이터가 들어 있는 컨테이너로 갈라야 한다. 포맷으로 가르면 포맷이 바뀌는 날 컴파일러도 못 잡고 조용히 깨진다
- 낙관적 UI의 위치는 "최종 결과가 나타날 자리"에 맞춘다. 서버 재전송 메시지를 캐시에서 빼
appendedMessages맨 아래로 옮긴 건, 응답이 오면 어차피 맨 아래에 나타날 것을 미리 거기 보여주기 위함이었다. 상태값만 바꾸면 위치가 안 맞아 화면이 안 따라온다 - 스크롤 의도는 "지금 내려라"가 아니라 "앞으로 붙어 있어라"로 선언한다. DOM 갱신과 스크롤 명령은
타이밍이 어긋나기 마련이라, 액션 시점에 플래그로 의도를 박아두고
ResizeObserver가 그 의도를 읽어 따라오게 하는 편이 안정적이었다 - 낙관적 삭제는 snapshot 롤백보다 "항상 서버를 다시 믿기"가 단순하다.
onSettled에서 무조건 refetch하면 실패 시 서버 상태로 알아서 원복되니, snapshot 보관·복원 코드를 통째로 덜어낼 수 있었다 - 높이 변화는 "증가"가 아니라 "변화"다. prepend만 보고
>로 좁혔던 스크롤 보존 로직이 삭제(감소)에서 깨졌다. 한쪽 방향만 가정한 조건은 반대 방향 이벤트에서 반드시 한 번 깨진다