문제: 새로고침이 서버를 한 번 더 때린다
구성:
- 같은 백엔드를 공유하는 쌍둥이 Next.js 프론트엔드 — 관리자 포털, 사용자 포털
- 게이트웨이가 502/503/504 반환 시 두 앱 모두 전역 오버레이 표시 (“잠시 후 다시 시도해 주세요”)
진짜 문제는 그 다음:
- 사용자가 멈춘 화면을 보고 F5
- Zustand store의 오버레이 상태는 메모리 → 새로고침 시 전부 소실
- 페이지 재마운트 → TanStack Query가 등록된 쿼리를 한꺼번에 발사
- 이미 죽어 있는 게이트웨이로 수십 개 요청 재유입
- 새로고침을 반복할수록 악화
차단 UI는 있는데 차단 로직이 없던 셈이다. 오버레이는 사람에게만 보이지, 다음 fetch를 막지는 못한다.
어떻게 고쳤나
- 서킷 브레이커를 브라우저 쪽에 배치
- 서버 인프라의 서킷 브레이커와 목적이 다름 — 여기서 막을 대상은 “장애를 인지한 이 탭이 계속 요청을 쏘는 것”
-
gatewayCircuit.ts신규 — 50줄, arm / read / clear 세 함수가 전부
const STORAGE_KEY = "gateway_circuit_v1";
/** 새로고침 직후 동시 API 폭주 방지 (동일 탭 sessionStorage 유지) */
const DEFAULT_TTL_MS = 120_000;
type CircuitPayload = { until: number; httpStatus: number };
export function readGatewayCircuit(): CircuitPayload | null {
if (typeof window === "undefined") return null;
const p = parse(sessionStorage.getItem(STORAGE_KEY));
if (!p) return null;
if (Date.now() > p.until) {
sessionStorage.removeItem(STORAGE_KEY);
return null;
}
return p;
}
export function armGatewayCircuit(httpStatus: number, ttlMs = DEFAULT_TTL_MS) {
if (typeof window === "undefined") return;
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ until: Date.now() + ttlMs, httpStatus })
);
}
- 페이로드 —
{until, httpStatus}두 필드 - 만료 시각을 값으로 저장 → read 시점에
Date.now()비교만으로 TTL 판정 완료 -
타이머 불필요, 정리 작업 불필요. 만료 항목은 읽을 때 삭제
- 확인 위치:
apiRequest진입부 — fetch보다 먼저
if (typeof window !== "undefined" && readGatewayCircuit()) {
clearTimeout(timeoutId);
const httpError = createHttpError(503, "서버 게이트웨이가 일시적으로 과부하 상태입니다.");
Object.assign(httpError, { httpStatus: 503 });
throw httpError;
}
const response = await fetch(fullUrl, requestConfig);
여기가 핵심이다.
- 서킷 열림 시 동작: 네트워크 미진입, 즉시 503 throw
- TanStack Query 관점: 그냥 실패한 쿼리 → 별도 처리 불필요
- 결과: 앱 전체가 자동으로 조용해짐
왜 localStorage가 아니라 sessionStorage인가
-
sessionStorage: 탭 단위 격리 — 정확히 원하던 스코프
| 저장소 | 스코프 | 이 문제에서의 문제점 |
|---|---|---|
| 메모리(Zustand) | 페이지 생명주기 | 새로고침하면 소실 — 원래 문제 |
| localStorage | 오리진 전체, 영구 | 다른 탭·다음 접속까지 차단이 전염됨 |
| sessionStorage | 탭 + 세션 | 새로고침엔 살아남고 탭을 닫으면 사라짐 |
localStorage 채택 시:
- A 탭에서 겪은 장애가 B 탭까지 차단
- 브라우저 재시작 후에도 TTL 잔여 시 계속 차단
- 근본 문제: 장애를 관측한 문맥과 차단이 적용되는 문맥의 불일치
sessionStorage 채택 시:
-
전달 범위: “이 탭이 방금 502를 봤다”는 관측을 새로고침 너머로만 전달
- 세 함수 모두 앞머리에
typeof window === "undefined"가드 - 이유 — App Router라 서버 컴포넌트/SSR 경로에서도 이 모듈이 로드될 수 있음. 가드가 없으면 빌드 타임에 터짐
UI 복원과 리셋
- 오버레이 없이 차단 로직만 살아남을 경우: 사용자에겐 “왜 아무것도 안 되지”만 남음
- 복원 위치: Providers의 useEffect
useEffect(() => {
restoreGatewayCircuitOverlay();
}, []);
- 반대로 오버레이를 닫는 행위 = “다시 해볼게”라는 의사표시
- Zustand store의
hide()에clearGatewayCircuit()연결
hide: () => {
clearGatewayCircuit();
set({ isOpen: false, httpStatus: null, isChecking: false });
},
- 결과: UI 상태(오버레이 열림/닫힘)와 차단 로직(서킷 열림/닫힘)이 서로 다른 레이어에 있으면서도 사용자의 한 동작으로 동시 이동
- 사용자 포털: Jest 테스트 추가
it("게이트웨이 서킷이 켜져 있으면 fetch 없이 차단해야 한다", async () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ until: Date.now() + 60_000, httpStatus: 502 }));
await expect(apiRequest("/test")).rejects.toMatchObject({ httpStatus: 503 });
expect(global.fetch).not.toHaveBeenCalled();
});
expect(fetch).not.toHaveBeenCalled()가 이 기능의 전부다. 503을 던졌다는 것보다 네트워크로 안 나갔다는 게 이 코드의 존재 이유다.
남는 교훈
- 두 저장소에 1초 차이로 동일 커밋 유입
- 쌍둥이 프론트엔드의 공통 인프라 코드 — 결국 같은 파일을 두 번 작성
- 공유 패키지 추출 여부: 계속 미뤄둔 숙제
그리고 “에러를 보여주는 것”과 “에러 상황에서 행동을 바꾸는 것”은 다른 일이다. 오버레이를 만들면서 전자만 했다고 착각했던 게 애초의 결함이었다.