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

게이트웨이가 죽었는데 사용자가 F5를 누른다 — 브라우저 서킷 브레이커
AI Claude Code React Next.js TanStack Query

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

같은 백엔드를 공유하는 쌍둥이 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을 던진다. 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초 차이로 같은 커밋이 들어갔다. 쌍둥이 프론트엔드에서 공통 인프라 코드는 결국 같은 파일을 두 번 쓰게 된다. 공유 패키지로 뽑을지는 계속 미뤄둔 숙제다.

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