정의
React 앱의 outbox는 네트워크가 끊겼을 때 사용자 쓰기를 실패로 버리지 않고, 브라우저의 영속 저장소에 명령으로 보관했다가 연결이 돌아오면 서버로 보내는 큐다. 메일 앱의 보낼 편지함처럼 사용자는 먼저 작업을 끝내고, 전송은 나중에 완료된다.
중요한 점은 화면 상태가 아니라 사용자 의도를 저장한다는 것이다. 예를 들어 좋아요 결과인 { liked: true, count: 13 } 전체를 저장하는 대신 { type: "like", itemId, value: true }처럼 서버가 다시 실행할 수 있는 명령을 저장한다. 서버 응답 캐시는 시간이 지나면 낡지만, 아직 보내지 못한 명령은 사라지면 안 된다.
왜 필요한가
React state나 일반적인 메모리 mutation 큐는 탭을 새로고침하거나 브라우저가 프로세스를 정리하면 사라진다. online 이벤트만 기다렸다 재시도하는 구현도 같은 문제가 있다. 연결이 돌아오기 전에 사용자가 페이지를 닫으면 방금 작성한 댓글이나 체크 상태를 잃는다.
내구성 있는 outbox는 쓰기 흐름을 둘로 나눈다.
- 로컬 완료 — 명령을 IndexedDB에 먼저 저장하고 화면을 낙관적으로 바꾼다.
- 서버 완료 — 전송 성공 응답을 받은 뒤에만 outbox에서 명령을 제거한다.
이렇게 하면 React 렌더 생명주기와 네트워크 생명주기가 분리된다. 컴포넌트가 언마운트되어도 큐는 남고, 앱 시작 시 다시 drain(대기 명령을 차례로 비우는 작업)할 수 있다.
동작 원리
type OutboxEntry = {
id: string; // 서버 idempotency key와 동일한 값
type: "set-like" | "add-comment";
payload: unknown;
createdAt: number;
attempts: number;
};
- 사용자가 작업하면
crypto.randomUUID()로 명령 ID를 만든다. - 명령을 IndexedDB에 저장한 뒤 React Query 캐시 등을 낙관적으로 갱신한다.
- 앱 시작·재접속·새 명령 추가 시 하나의 drain 루프를 실행한다.
- 서버 요청에 같은 ID를 idempotency key로 보낸다.
- 성공하면 명령을 삭제하고 관련 쿼리를 invalidate한다.
- 네트워크 오류와 5xx는 큐에 남겨 재시도하고, 검증 실패 같은 4xx는
failed로 표시해 사용자에게 알린다. - 409 충돌은 최신 서버 상태를 받은 뒤 명령을 재적용하거나 사용자의 선택을 받는다(optimistic-outbox-rebase).
let draining = false;
async function drainOutbox() {
if (draining) return;
draining = true;
try {
for (const entry of await outbox.oldestFirst()) {
const response = await send(entry, {
idempotencyKey: entry.id,
});
if (response.ok) await outbox.remove(entry.id);
else if (response.status === 401 || response.status >= 500) break;
else if (response.status === 409) await outbox.markConflict(entry.id);
else await outbox.markFailed(entry.id);
}
} finally {
draining = false;
}
}
draining은 한 탭 안의 중복 루프만 막는다. 여러 탭이나 React Strict Mode의 중복 effect까지 완전히 안전하게 만들려면 서버가 같은 idempotency key를 한 번만 처리해야 한다. 클라이언트 잠금은 최적화이고, 서버 중복 제거가 정확성의 경계다.
React Query와 연결하기
TanStack Query를 사용한다면 query cache와 outbox의 책임을 구분한다.
- query cache: 서버에서 읽은 결과를 빠르게 보여준다. 없어져도 다시 받을 수 있다.
- outbox: 아직 서버가 확인하지 않은 쓰기를 보존한다. 전송 성공 전에는 없어지면 안 된다.
- optimistic update: outbox에 넣은 의도를 즉시 화면에 투영한다.
- invalidate: ack 이후 서버 진실을 다시 읽는다(react-query-invalidate-vs-staletime).
TanStack Query의 paused mutation을 persist하는 방법도 같은 원리다. 새로고침 뒤 함수 자체는 직렬화할 수 없으므로 mutationKey별 기본 mutationFn을 등록하고, 실행에 필요한 대상 ID와 값은 모두 variables에 넣어야 한다. hydration이 끝나면 resumePausedMutations()를 호출한다. persist, 기본 mutation 함수, variables 중 하나라도 빠지면 복원은 됐지만 실행할 수 없는 stuck 큐가 된다.
재시도와 순서
| 결과 | 처리 |
|---|---|
| 네트워크 오류, 5xx | 큐 유지, 지수 backoff 후 재시도 |
| 401 | 토큰을 큐에 저장하지 말고 로그인 복구 뒤 재개 |
| 400·422 | 자동 재시도 중단, failed로 표시 |
| 409 | 최신 상태 refetch 후 rebase 또는 사용자에게 충돌 표시 |
| 2xx 또는 중복 처리 응답 | ack로 보고 삭제, 관련 query invalidate |
처음에는 전역 FIFO(먼저 들어온 명령부터 하나씩 처리)면 충분하다. 처리량이 실제 병목일 때만 서로 다른 엔티티끼리 병렬화하되, 같은 엔티티의 명령 순서는 유지한다. navigator.onLine은 drain을 깨우는 힌트로만 쓰고, 온라인 여부의 최종 판단은 실제 요청 결과에 맡긴다.
트레이드오프
- 앱이 열린 동안만 drain하면 구현과 인증이 단순하지만, 앱을 닫은 상태에서는 전송되지 않는다. 백그라운드 전송이 실제 요구일 때만 Service Worker Background Sync를 추가한다.
- 낙관적 UI는 빠르지만 영구 실패를 되돌리거나 수정할 UI가 필요하다. 실패를 조용히 버리면 데이터 손실과 같다.
- 큐 payload는 오래 남을 수 있으므로 액세스 토큰·민감 정보·큰 파일을 직접 저장하지 않는다. 파일 업로드는 별도의 용량·만료 정책이 필요하다.
- 클라이언트 재시도만으로 exactly-once 실행을 만들 수 없다. 요청은 중복될 수 있고, 서버의 idempotency 처리가 부작용 중복을 막는다.
사용하면 안 되는 경우
- 결제·재고 차감처럼 서버 확인 전 성공으로 보여주면 안 되는 작업. outbox를 쓰더라도
pending으로 명확히 표시하고 서버 승인을 기다린다. - 온라인 연결이 필수이고 실패 시 즉시 사용자가 수정해야 하는 짧은 관리자 폼.
- 서버 API가 idempotency key나 안전한 중복 처리를 제공하지 않는데 명령에 비가역 부작용이 있는 경우.
흔한 실수
- React state에만 큐를 두어 새로고침 때 쓰기를 잃는다.
- HTTP 요청을 먼저 보내고 나중에 outbox에 기록해, 그 사이 탭이 닫히면 결과를 추적하지 못한다.
- 성공 응답 전에 큐에서 제거해 timeout 뒤 실제 처리 여부를 알 수 없게 만든다.
- 모든 4xx를 무한 재시도해 영구 실패 명령이 뒤 명령까지 막는다.
- 현재 액세스 토큰을 명령과 함께 persist해 만료된 비밀을 오래 보관한다.
online이벤트를 연결 보장으로 믿는다. 와이파이에 붙어 있어도 captive portal이나 서버 장애로 요청은 실패할 수 있다.
관련 개념
- optimistic-outbox-rebase — 원격 변경과 pending 명령을 함께 수렴시키는 방법
- react-query-invalidate-vs-staletime — ack 이후 서버 상태를 다시 읽는 캐시 무효화
- race-safe-async-ui-requests — 늦게 도착한 응답이 최신 UI를 덮지 않게 하는 요청 경계
- service-worker-stale-while-revalidate — 읽기 캐시와 쓰기 outbox의 역할 차이