Tistory
장애허용성
시스템 일부가 실패해도 전체가 죽지 않게 하는 설계 원칙. FE에서는 Retry·Circuit Breaker·Fallback UI·ErrorBoundary로 장애를 격리한다.
핵심 요약
장애허용성은 시스템의 일부가 실패해도 전체가 중단되지 않도록 실패를 격리하고 복구하는 설계 원칙이다. 프런트엔드 관점의 핵심 패턴은 네 가지다: (1) 일시적 오류에 Retry with Exponential Backoff + Jitter, (2) 반복 실패 시 요청을 끊는 Circuit Breaker, (3) 실패해도 화면은 살리는 Fallback UI, (4) 렌더 오류를 격리하는 React ErrorBoundary. 판단 기준은 오류의 성격(transient vs permanent)과 비용(재시도 시 서버 증폭 여부)이다. 무조건 재시도는 오히려 장애를 증폭시키므로 5xx·네트워크 타임아웃만 재시도하고, 4xx는 즉시 실패로 분리해야 한다.
장애허용성(Fault Tolerance)은 "실패는 일어난다"를 전제로 한 설계 사고방식이다. 질문을 뒤집어서 읽자: "이 요청이 실패하면 화면은 어떻게 되는가?" 답이 "전체가 깨진다"면 경계(boundary)와 폴백(fallback)이 부족한 것이다. 프런트엔드에서는 네트워크·서버·런타임 3층에서 실패가 오므로, 각 층마다 격리 단위(try/catch, ErrorBoundary, Suspense boundary)를 세우고 복구 전략(Retry, Fallback, Circuit Breaker)을 붙이는 구조로 읽으면 된다.
서버가 5초 느려지면 전체 페이지가 5초 멈추는 앱과, 해당 위젯만 스켈레톤으로 바뀌는 앱은 같은 장애에서 다른 사용자 경험을 만든다. 면접에서도 단골 주제다:
- API 실패 시 UI 전략을 묻는 질문은 시니어 FE 포지션 대부분에서 나온다
- 토스·당근 등 트래픽이 큰 회사일수록 부분 실패 설계를 평가 기준으로 본다
- "재시도를 몇 번 하세요?" 같은 질문에 숫자만 답하면 감점, Exponential Backoff + Jitter + 중단 조건까지 말해야 가점
ErrorBoundary·Suspense·AbortController는 React 팀이 공식 권장하는 프런트 장애허용성 기본기다
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
일시적 vs 영구적 오류
재시도(Retry)는 일시적(transient) 오류에만 해야 한다. 영구적 오류를 재시도하면 서버 부하만 늘리고 사용자 대기 시간도 길어진다.
| 분류 | 예시 | 재시도? |
|---|---|---|
| Transient | 504, 503, network timeout, ECONNRESET | O |
| Permanent | 400, 401, 403, 404, 422 | X (즉시 실패) |
| Ambiguous | 429 Too Many Requests | Retry-After 헤더 준수 |
function isRetryable(err: AxiosError) {
if (!err.response) return true; // network error
const s = err.response.status;
return s >= 500 || s === 408 || s === 429;
}
모든 실패에 재시도를 거는 것. POST /orders 같은 비멱등(non-idempotent) 요청을 무조건 재시도하면 주문이 2건 생긴다. 재시도는 GET이나 Idempotency-Key가 있는 요청에만.
Exponential Backoff + Jitter
고정 간격 재시도는 서버가 다시 살아나는 순간 모든 클라이언트가 동시에 몰려 장애를 재발시킨다(thundering herd). 지수적으로 간격을 늘리고(Backoff) 난수 지연(Jitter)을 섞어야 한다.
| 전략 | 1·2·3회차 대기 | 문제 |
|---|---|---|
| 고정 | 1s, 1s, 1s | 동시 재몰림 |
| Exponential | 1s, 2s, 4s | 여전히 동기화됨 |
| Exponential + Jitter | ~1s, ~2.3s, ~3.7s | 분산됨 |
async function retry<T>(fn: () => Promise<T>, max = 3) {
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) {
if (i === max - 1 || !isRetryable(e)) throw e;
const base = 2 ** i * 1000;
const jitter = Math.random() * base;
await new Promise(r => setTimeout(r, base + jitter));
}
}
throw new Error('unreachable');
}
setTimeout(retry, 1000)만 걸고 끝. 최대 재시도 횟수와 최대 총 지연 시간을 함께 정하지 않으면 사용자가 20초 이상 로딩 스피너를 보게 된다.
Circuit Breaker 세 상태
서버가 내려간 것을 감지했는데도 계속 요청하면 사용자 대기 + 서버 회복 지연이라는 이중 손해를 본다. Circuit Breaker 는 일정 실패가 쌓이면 회로를 열어(Open) 일정 시간 동안 요청 자체를 보내지 않고 즉시 폴백으로 전환한다.
| 상태 | 동작 | 전환 조건 |
|---|---|---|
Closed | 정상 요청 전송 | 실패 임계치 초과 → Open |
Open | 호출 차단, 즉시 fallback | 쿨다운 경과 → Half-Open |
Half-Open | 샘플 요청 1건만 허용 | 성공 → Closed / 실패 → Open |
class Breaker {
private failures = 0;
private openedAt = 0;
constructor(private threshold = 5, private cooldown = 10_000) {}
async call<T>(fn: () => Promise<T>, fallback: () => T) {
if (this.state() === 'OPEN') return fallback();
try {
const r = await fn();
this.failures = 0;
return r;
} catch (e) {
if (++this.failures >= this.threshold) this.openedAt = Date.now();
return fallback();
}
}
private state() {
if (!this.openedAt) return 'CLOSED';
return Date.now() - this.openedAt > this.cooldown ? 'HALF_OPEN' : 'OPEN';
}
}
Retry 로만 해결하려는 것. 재시도는 한 요청의 복구, Circuit Breaker 는 엔드포인트 전체의 빠른 포기. 둘은 보완 관계지 대체재가 아니다.
React ErrorBoundary + Fallback UI
렌더 중 던져진 예외는 React 16+ 부터 가장 가까운 ErrorBoundary 에서만 잡힌다. 페이지 루트에만 두면 한 컴포넌트 오류로 전체 화면이 흰 페이지가 된다. 위젯 단위로 경계를 쪼개야 부분 실패가 가능하다.
<Page>
<ErrorBoundary fallback={<HeroSkeleton />}>
<Hero />
</ErrorBoundary>
<ErrorBoundary fallback={<p>추천을 불러오지 못했어요.</p>}>
<Recommendations />
</ErrorBoundary>
</Page>
주의: ErrorBoundary 는 렌더·lifecycle·생성자의 동기 오류만 잡는다. 다음은 스스로 처리해야 한다:
- 이벤트 핸들러 (
onClick내부 throw) - 비동기 코드 (
setTimeout,fetch().then) - SSR 중 서버 측 오류 → Next.js
error.tsx - Promise rejection →
react-error-boundary의useErrorBoundary().showBoundary(e)
함수형 ErrorBoundary 를 직접 만들려 하는 것. 공식적으로는 클래스 컴포넌트만 경계가 될 수 있다. 함수 API 가 필요하면 react-error-boundary 라이브러리를 쓰거나, 로직은 Hook, 경계는 클래스 래퍼로 분리.
AbortController 로 요청 취소
페이지 이탈·타이핑 중인 자동완성처럼 결과가 이미 쓸모없어진 요청은 취소해야 좀비 응답으로 setState 경고와 UI 깜빡임이 안 생긴다. fetch 와 axios 모두 AbortController 를 지원한다.
useEffect(() => {
const ac = new AbortController();
fetch('/api/search?q=' + q, { signal: ac.signal })
.then(r => r.json())
.then(setResult)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
return () => ac.abort();
}, [q]);
타임아웃도 같은 방식으로: setTimeout(() => ac.abort(), 3000). 사용자에게 무한 로딩 대신 빠른 실패 + Retry 버튼을 주는 것이 장애허용 UX다.
AbortError 를 일반 에러와 같이 취급해 토스트를 띄우는 것. 의도된 취소는 UI 에러가 아니라 무시 대상이다.
읽는 순서
- 1이론
네 가지 패턴(
Retry/Circuit Breaker/Fallback UI/ErrorBoundary) 각각의 목적과 커버하는 장애 층을 한 줄로 정리한다. transient vs permanent 오류 구분 표도 외울 것. - 2구현
axios인터셉터에 Exponential Backoff + Jitter 재시도 로직을 붙이고,AbortController로 5초 타임아웃을 건다. 그 위에 5회 연속 실패 시 10초간 차단하는 최소 Circuit Breaker 를 얹어 직접 실패를 시뮬레이션해 본다. - 3실무
지금 맡은 프로젝트에서
ErrorBoundary가 페이지 루트 한 곳에만 걸려 있지는 않은지, 5xx 응답이 올 때 어떤 UI 가 보이는지,POST요청에Idempotency-Key가 있는지 체크리스트로 점검한다. - 4설명
동료에게 "주문 API 가 가끔 5초씩 느려지는데 어떻게 개선할까요?"를 5분 안에 설명한다. 타임아웃 → 재시도(멱등·Backoff) → 실패 시 부분
Fallback UI→ 반복 실패 시 Circuit Breaker 로 빠른 실패 순서로 이어가면 된다.
면접 연결 질문
[감점 답변] "try/catch 로 감싸고 토스트 띄워요" 수준. 실패 종류를 구분하지 않음. [좋은 답변] 네 가지 층으로 나눠 답변: (1) 네트워크/전송 층 — AbortController 타임아웃, (2) 요청 단위 — Retry with Exponential Backoff + Jitter (5xx·timeout 만, GET/멱등 요청만), (3) 엔드포인트 단위 — Circuit Breaker 로 연속 실패 시 빠른 포기, (4) 렌더 층 — ErrorBoundary + 위젯별 Fallback UI. 트레이드오프로 Retry 는 서버 부하 증폭 위험, Circuit Breaker 는 오탐 시 정상 사용자 차단을 함께 언급.
[감점 답변] "비동기는 못 잡는다"까지만 말하고 끝. [좋은 답변] 못 잡는 것 4가지를 구체화: 이벤트 핸들러, setTimeout 등 비동기 콜백, Promise rejection, SSR 서버 오류, ErrorBoundary 자기 자신의 렌더 오류. 대응: 이벤트는 try/catch, 비동기는 React Query 같은 데이터 라이브러리의 onError 나 useErrorBoundary().showBoundary(e) 로 렌더 경계로 끌어올리기, Next 13+ 는 error.tsx. 또한 ErrorBoundary 는 클래스 컴포넌트만 가능하다는 점도 언급.
[감점 답변] "3번까지 재시도하면 돼요"로 끝. [좋은 답변] 세 가지 함정을 지적: (1) 비멱등 요청 (POST /orders) 재시도는 중복 처리 — Idempotency-Key 헤더로 방어, (2) 영구 오류(400·404) 재시도는 서버 부하만 증폭 — 5xx·네트워크 오류만 재시도, (3) 동기화된 재시도 는 thundering herd 유발 — Exponential Backoff + Jitter 필요. 추가로 429 는 Retry-After 헤더 존중, 최대 총 지연 시간을 설정해 UX 보호.
[감점 답변] 세 상태 이름만 나열. [좋은 답변] Closed/Open/Half-Open 을 전환 조건과 함께 설명: 실패 임계치 초과 시 Open, 쿨다운 경과 후 Half-Open 에서 샘플 요청으로 복구 테스트. 프런트 특유 고려 사항도 덧붙이기: 엔드포인트별 인스턴스(전역 1개면 다른 API까지 막힘), 메모리에만 저장(탭 새로고침 시 초기화), 서버리스/CDN 이 앞단에 있으면 같은 장애가 재현 안 될 수 있어 서버 사이드 Circuit Breaker 와 역할 분리.
자기 점검
"POST 도 실패하면 어차피 실패니까 재시도해도 된다"는 오해. 네트워크 단절은 요청이 도달 후 응답만 실패한 경우가 있어 서버에서는 이미 처리된 상태일 수 있다. Idempotency-Key 없이는 중복 생성이 일어난다.
"Retry 만 있으면 충분"이라는 생각. 서버가 완전히 내려간 상황에서 모든 클라이언트가 몇 초마다 재시도를 반복하면 서버 복구 자체가 지연된다. Circuit Breaker 는 그 트래픽을 끊어 주는 역할.
"경계는 하나만 있으면 된다"는 생각. 한 작은 위젯의 렌더 오류로 페이지 전체가 흰 화면이 되면 가용성이 오히려 0이 된다. 위젯 단위로 경계를 쪼개 다른 영역은 계속 동작하게 해야 한다.