React

게이트웨이가 죽었는데 사용자가 F5를 누른다 — 브라우저 서킷 브레이커

문제: 새로고침이 서버를 한 번 더 때린다

구성:

  • 같은 백엔드를 공유하는 쌍둥이 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초 차이로 동일 커밋 유입
  • 쌍둥이 프론트엔드의 공통 인프라 코드 — 결국 같은 파일을 두 번 작성
  • 공유 패키지 추출 여부: 계속 미뤄둔 숙제

그리고 “에러를 보여주는 것”과 “에러 상황에서 행동을 바꾸는 것”은 다른 일이다. 오버레이를 만들면서 전자만 했다고 착각했던 게 애초의 결함이었다.