다크 모드
Chapter 12. HTTP 레이어 격리와 프론트엔드 에러 바운더리(<ErrorBoundary>)
"11장까지 작성된 코드 곳곳에 흩어져 있는
axios.get을 보라. 컴포넌트가 네트워크 주소와 HTTP 상태 코드를 직접 아는 결합을 끊어내고, Single-Flight 토큰 갱신 인터셉터·Repository 계층·React<ErrorBoundary>로 철벽 방어선을 구축하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
Repository 계층 (postRepository.ts) | 컴포넌트와 스토어에게서 HTTP URL·메서드를 숨기는 도메인 통신 계층 | 12.1, 12.3 |
axios.create & 요청/응답 인터셉터 | 공통 헤더(JWT) 자동 주입 및 401 응답 가로채기 | 12.2 🆕 박스 |
Single-Flight 토큰 갱신 큐 (refreshPromise) | 동시에 여러 API가 401을 받아도 /api/auth/refresh는 단 1번만 호출하는 기법 | 12.2 [코드 12-1] |
자기 교착(Self-Deadlock) 방지용 refreshClient | 갱신 요청 자체가 401을 받을 때 무한 대기하지 않도록 분리한 인스턴스 | 12.2 🔬 심화 |
React <ErrorBoundary> (getDerivedStateFromError) | 하위 컴포넌트 트리의 렌더링 예외를 포착해 흰 화면(WSOD)을 막는 안전망 | 12.4 [코드 12-6] |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch10.tsx와 같은 상태에서 시작합니다(11장은 BoardApp.tsx를 고치지 않았습니다). 이 장에서 손대는 파일: frontend/src/api/httpClient.ts, frontend/src/api/postRepository.ts, frontend/src/stores/usePostStore.ts, frontend/src/components/common/ErrorBoundary.tsx, frontend/src/BoardApp.tsx, frontend/tests/unit/httpClient.test.ts
12.1 컴포넌트와 스토어에서 axios를 몰아내자
지금까지 우리는 편의상 컴포넌트와 스토어 안에서 axios.get('/api/posts')를 직접 호출해 왔습니다. 그런데 백엔드 팀에서 이렇게 통보해 온다면 어떨까요?
- "다음 주부터 Access Token 유효시간이 15분으로 줄어듭니다.
401 Unauthorized가 떨어지면/api/auth/refresh로 토큰을 재발급받아 원래 요청을 다시 보내세요." - "모든 API 기본 주소 앞에
/api/v2프리픽스가 붙습니다."
50개 컴포넌트에 axios 호출이 흩어져 있다면 야근하며 모든 파일을 고쳐야 합니다. 백엔드에서 Controller와 DB 사이에 Service와 Repository를 두듯이, 프론트엔드도 **HTTP 인프라(httpClient.ts)**와 **도메인 레포지토리(postRepository.ts)**를 분리합니다.
12.2 [실습 1] 중앙 집중식 httpClient.ts와 Single-Flight 토큰 재발급 큐
한 화면이 열릴 때 게시글 목록 조회, 알림 조회, 내 프로필 조회 3개의 API가 동시에 날아갔는데 마침 Access Token이 만료되었다고 가정해 봅시다. 세 요청이 동시에 401을 받았을 때 각자 /api/auth/refresh를 호출하면 Refresh Token Rotation 정책에 걸려 세션 전체가 무효화되는 대형 사고가 터집니다.
이를 막기 위해 첫 번째 401이 만든 갱신 Promise(refreshPromise)를 뒤따라온 401 요청들이 함께 기다리는(await) Single-Flight 패턴을 구현합니다.
🏃 비유: 사무실 똘똘한 막내의 편의점 심부름 (Single-Flight)
사무실에서 5명이 동시에 "목마르다"고 각자 편의점으로 뛰어나가는 대신, 똘똘한 막내 한 명만 대표로 편의점에 보내고(refreshPromise생성) 나머지 4명은 사무실에서 얌전히 그 막내가 음료수(새 Access Token)를 사 올 때까지 기다렸다가(await refreshPromise) 다 같이 마시는 아름다운 패턴입니다.
[코드 12-1] frontend/src/api/httpClient.ts
typescript
// frontend/src/api/httpClient.ts
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
const baseConfig = {
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
};
export const httpClient = axios.create(baseConfig);
// 🌟 토큰 갱신 전용 클라이언트: 401 인터셉터가 걸려 있지 않은 '맨몸' 인스턴스 (Self-Deadlock 방지)
const refreshClient = axios.create(baseConfig);
// 1. 요청 인터셉터: 모든 발신 요청에 JWT Access Token 자동 첨부
httpClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('ACCESS_TOKEN');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// 동시에 여러 요청이 401을 받았을 때 단 1번만 갱신 요청을 보내기 위한 Single-Flight Promise
let refreshPromise: Promise<string> | null = null;
// 2. 응답 인터셉터: 401 에러 시 백그라운드 토큰 자동 갱신 및 원래 요청 재전송
httpClient.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const originalRequest = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 이미 진행 중인 토큰 갱신이 있다면 새 요청을 보내지 않고 그 Promise를 그대로 공유!
if (!refreshPromise) {
const refreshToken =
typeof localStorage !== 'undefined' ? localStorage.getItem('REFRESH_TOKEN') : null;
refreshPromise = refreshClient
.post<{ accessToken: string }>('/api/auth/refresh', { refreshToken })
.then((res) => {
const newToken = res.data.accessToken;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('ACCESS_TOKEN', newToken);
}
return newToken;
})
.finally(() => {
refreshPromise = null; // 갱신 완료 후 다음 만료를 위해 잠금 해제
});
}
const newToken = await refreshPromise;
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
// 새 토큰을 달아 원래 실패했던 요청을 투명하게 재전송!
return httpClient(originalRequest);
} catch (refreshErr) {
// 🚨 주의: localStorage.clear()를 쓰면 10장의 임시저장 글 초안까지 날아갑니다!
// 반드시 인증 토큰 키만 선택적으로 제거합니다.
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('ACCESS_TOKEN');
localStorage.removeItem('REFRESH_TOKEN');
}
if (typeof window !== 'undefined') {
window.location.href = '/login?expired=true';
}
return Promise.reject(refreshErr);
}
}
return Promise.reject(error);
}
);🆕 처음 보는 문법: axios.create와 교차 타입(& { _retry?: boolean })
axios.create(baseConfig): 기본 URL, 타임아웃, 인터셉터를 독립적으로 가지는 전용 Axios 인스턴스를 생성합니다.InternalAxiosRequestConfig & { _retry?: boolean }: Axios 기본 설정 타입에 우리가 재시도 여부를 표시할_retry속성을 덧붙인 **교차 타입(Intersection Type)**입니다. 재전송한 요청마저 401이 날 때 무한 재시도 루프에 빠지는 것을 막습니다.
🔬 심화(건너뛰어도 됨): 왜 갱신 요청은 httpClient가 아니라 refreshClient로 보내나요? (Self-Deadlock)
만약 /api/auth/refresh 요청까지 인터셉터가 걸린 httpClient로 보낸다면, Refresh Token마저 만료되어 갱신 요청 자체가 401을 받았을 때 치명적인 **자기 교착(Self-Deadlock)**에 빠집니다:
- 갱신 요청의 401 응답이 다시 같은 응답 인터셉터로 들어옵니다.
- 이미
refreshPromise가 존재하므로await refreshPromise로 줄을 서서 기다립니다. - 그런데
refreshPromise는 지금 자기 자신이 끝나야만 완료되는 Promise입니다! 결국 영원히 끝나지 않는 데드락에 걸려catch블록(로그인 페이지 이동)조차 실행되지 않습니다. 따라서 갱신 요청은 인터셉터가 없는 별도의refreshClient로 보내야 안전합니다.
[코드 12-2] Single-Flight 토큰 갱신 단위 테스트 (frontend/tests/unit/httpClient.test.ts)
typescript
// frontend/tests/unit/httpClient.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { httpClient } from '@/api/httpClient';
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
localStorage.setItem('ACCESS_TOKEN', 'expired-token');
localStorage.setItem('REFRESH_TOKEN', 'valid-refresh-token');
});
afterEach(() => {
server.resetHandlers();
localStorage.clear();
});
afterAll(() => server.close());
describe('httpClient 401 Single-Flight 토큰 갱신 인터셉터', () => {
it('동시에 2개 요청이 401을 받아도 갱신 요청은 단 1번만 보내고 원래 요청들을 재전송한다', async () => {
let refreshCallCount = 0;
server.use(
http.get('/api/posts', ({ request }) => {
if (request.headers.get('Authorization') !== 'Bearer new-token') {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json({ items: [], total: 0, page: 1, size: 10, totalPages: 0 });
}),
http.post('/api/auth/refresh', () => {
refreshCallCount++;
return HttpResponse.json({ accessToken: 'new-token' });
})
);
const [res1, res2] = await Promise.all([
httpClient.get('/api/posts'),
httpClient.get('/api/posts')
]);
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
expect(refreshCallCount).toBe(1); // 🌟 Single-Flight 검증!
expect(localStorage.getItem('ACCESS_TOKEN')).toBe('new-token');
});
it('갱신 요청까지 401로 실패하면 인증 토큰만 지우고 임시저장 초안은 보존한다', async () => {
localStorage.setItem('BOARD_APP_DRAFT_STORAGE', '작성 중이던 소중한 글 초안');
server.use(
http.get('/api/posts', () => new HttpResponse(null, { status: 401 })),
http.post('/api/auth/refresh', () => new HttpResponse(null, { status: 401 }))
);
await expect(httpClient.get('/api/posts')).rejects.toThrow();
expect(localStorage.getItem('ACCESS_TOKEN')).toBeNull();
expect(localStorage.getItem('REFRESH_TOKEN')).toBeNull();
// 10장의 초안은 무사히 살아 있어야 함!
expect(localStorage.getItem('BOARD_APP_DRAFT_STORAGE')).toBe('작성 중이던 소중한 글 초안');
});
});12.3 [실습 2] 도메인 메서드를 캡슐화한 postRepository.ts
이제 컴포넌트와 Zustand 스토어는 URL이나 httpClient를 직접 알지 못하고 오직 postRepository의 도메인 메서드만 호출합니다.
[코드 12-3] frontend/src/api/postRepository.ts
typescript
// frontend/src/api/postRepository.ts
import { httpClient } from './httpClient';
import type { Post, CreatePostRequest, PageResult } from '@/types/board';
export interface GetPostsQuery {
page: number;
size: number;
category?: string;
keyword?: string;
}
export const postRepository = {
async getPosts(params: GetPostsQuery): Promise<PageResult<Post>> {
const res = await httpClient.get<PageResult<Post>>('/api/posts', { params });
return res.data;
},
async getPostById(id: number): Promise<Post> {
const res = await httpClient.get<Post>(`/api/posts/${id}`);
return res.data;
},
async createPost(payload: CreatePostRequest): Promise<Post> {
const res = await httpClient.post<Post>('/api/posts', payload);
return res.data;
},
async deletePost(id: number): Promise<void> {
await httpClient.delete(`/api/posts/${id}`);
},
async toggleLike(id: number): Promise<Post> {
const res = await httpClient.post<Post>(`/api/posts/${id}/like`);
return res.data;
}
};[코드 12-4] usePostStore.ts에서 axios를 postRepository로 교체
typescript
// frontend/src/stores/usePostStore.ts (변경되는 핵심 줄)
import { postRepository } from '@/api/postRepository'; // ➕ 추가
// ➖ 삭제: import axios from 'axios';
// 1) fetchPosts 내부:
const data = await postRepository.getPosts(normalizedParams);
set({
posts: data.items ?? [],
totalCount: data.total ?? 0,
lastFetchedTime: Date.now(),
lastQueryKey: currentQueryKey,
isLoading: false
});
// 2) createPost 내부:
const created = await postRepository.createPost(payload);
// 3) removePost 내부:
await postRepository.deletePost(id);
// 4) toggleLikeOptimistic 내부:
const updated = await postRepository.toggleLike(postId);
const serverLikeCount = updated.likeCount;이제 프로젝트 전체에서 import axios를 검색하면 오직 src/api/httpClient.ts 단 한 곳에서만 발견됩니다!
12.4 [실습 3] React <ErrorBoundary>로 흰 화면(WSOD) 방어하기
하위 컴포넌트가 렌더링 도중 예상하지 못한 예외(예: undefined.map())를 던졌을 때 아무런 경계선이 없다면, React는 안전을 위해 **전체 컴포넌트 트리를 언마운트하여 하얀 빈 화면(White Screen of Death, WSOD)**을 띄웁니다.
이를 격리하고 [다시 시도하기] 복구 버튼을 제공하는 ErrorBoundary 컴포넌트를 작성합니다.
🆕 처음 보는 문법: 왜 ErrorBoundary는 클래스 컴포넌트로 작성하나요?
- React 19.3에서도 렌더링 중 발생하는 예외를 포착하는 생명주기 메서드인 **
static getDerivedStateFromError**와 **componentDidCatch**는 클래스 컴포넌트에서만 제공됩니다. - 하지만 우리가 직접 클래스 문법을 매번 다룰 필요는 없습니다. 아래처럼 한 번만 공통 래퍼 컴포넌트로 만들어 두고, 사용하는 곳에서는
<ErrorBoundary fallback={...}><BoardApp /></ErrorBoundary>처럼 선언적으로 감싸기만 하면 됩니다!
[코드 12-5] frontend/src/components/common/ErrorBoundary.tsx
tsx
// frontend/src/components/common/ErrorBoundary.tsx
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
export interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode | ((params: { error: Error; resetError: () => void }) => ReactNode);
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
retryKey: number;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
public state: ErrorBoundaryState = {
hasError: false,
error: null,
retryKey: 0
};
// 1. 자식 렌더링 중 에러 발생 시 다음 렌더링에서 폴백 UI가 보이도록 상태 갱신
public static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
// 2. 에러 로깅 (Sentry 등 사내 모니터링 서버 전송 지점)
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('💥 [Error Boundary 포착]:', error, errorInfo.componentStack);
}
// 3. 에러 상태 초기화 및 자식 트리 강제 재마운트(retryKey 증가)
public resetError = () => {
this.props.onReset?.();
this.setState((prev) => ({
hasError: false,
error: null,
retryKey: prev.retryKey + 1
}));
};
public render() {
const { hasError, error, retryKey } = this.state;
const { children, fallback } = this.props;
if (hasError && error) {
if (typeof fallback === 'function') {
return fallback({ error, resetError: this.resetError });
}
if (fallback) {
return fallback;
}
return (
<div className="error-fallback-box" role="alert">
<h3>⚠️ 일시적인 화면 오류가 발생했습니다</h3>
<p>{error.message || '알 수 없는 오류가 발생했습니다.'}</p>
<button type="button" onClick={this.resetError}>
다시 시도하기
</button>
</div>
);
}
// key가 바뀌면 하위 컴포넌트 트리를 깨끗한 초기 상태로 다시 마운트함
return <React.Fragment key={retryKey}>{children}</React.Fragment>;
}
}src/App.tsx에서 라우터 출력 영역을 <ErrorBoundary>로 감싸면, 게시판 화면에서 런타임 렌더링 에러가 터져도 상단 내비게이션 헤더는 죽지 않고 살아남습니다:
tsx
// frontend/src/App.tsx
import React from 'react';
import { Link } from 'react-router-dom';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { AppRouter } from '@/router';
export default function App() {
return (
<div className="app-shell">
<header className="global-nav">
<Link to="/">📋 기술 게시판</Link>
<Link to="/write">✏️ 글쓰기</Link>
<Link to="/login">🔐 로그인</Link>
</header>
<main className="global-main">
<ErrorBoundary>
<AppRouter />
</ErrorBoundary>
</main>
</div>
);
}WARNING
이벤트 핸들러(onClick)의 비동기 에러와 렌더링 에러의 차이 (Vue vs React)
React의 <ErrorBoundary>는 렌더링 과정, 생명주기 메서드, 생성자에서 던져진 에러만 잡습니다. @click(onClick) 이벤트 핸들러나 setTimeout 안에서 발생한 비동기 네트워크 에러는 ErrorBoundary가 잡지 않으므로, 복구 가능한 API 에러는 9~10장에서처럼 컴포넌트의 try / catch에서 잡아 사용자에게 안내하십시오.
12.5 [직접 해보기] 실습 미션 💪
🎯 미션: ErrorBoundary가 자식의 렌더링 폭탄을 잡아내고 복구하는지 검증하기
일부러 에러를 던지는 컴포넌트 BuggyWidget을 <ErrorBoundary> 안에 넣고, 에러 발생 시 "일시적인 화면 오류가 발생했습니다" 폴백 문구가 렌더링되는지 확인하는 방법을 코드로 작성해 보십시오.
💡 미션 정답 코드 확인하기
tsx
// frontend/src/practice/ErrorBoundaryDemo.tsx
import React, { useState } from 'react';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
function BuggyWidget({ shouldCrash }: { shouldCrash: boolean }) {
if (shouldCrash) {
throw new Error('댓글 위젯 데이터 파싱 중 치명적 오류 발생!');
}
return <div className="widget-ok">✅ 정상 작동 중인 위젯입니다.</div>;
}
export function ErrorBoundaryDemo() {
const [crash, setCrash] = useState(false);
return (
<div>
<button type="button" onClick={() => setCrash(true)}>
💣 렌더링 에러 터뜨리기
</button>
<ErrorBoundary onReset={() => setCrash(false)}>
<BuggyWidget shouldCrash={crash} />
</ErrorBoundary>
</div>
);
}12.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 컴포넌트와 스토어는
postRepository의 도메인 메서드만 호출하고, URL은postRepository가, 토큰 첨부와 401 갱신은httpClient가 전담합니다. - 동시에 여러 요청이 401을 받아도 단일
refreshPromise를 공유(Single-Flight)하여 갱신 요청은 단 1번만 나가며, 갱신 자체는refreshClient로 보내 데드락을 방지합니다. <ErrorBoundary>로 라우트 화면이나 독립 위젯을 감싸면 하위 컴포넌트의 렌더링 버그가 전체 앱을 하얗게 죽이는(WSOD) 현상을 격리할 수 있습니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/api/httpClient.ts | JWT 자동 첨부 요청 인터셉터 + Single-Flight 401 갱신 응답 인터셉터 |
src/api/postRepository.ts | getPosts, createPost, deletePost, toggleLike 도메인 메서드 캡슐화 |
src/stores/usePostStore.ts | 직접 호출하던 axios를 제거하고 postRepository로 전면 교체 |
src/components/common/ErrorBoundary.tsx | 렌더링 예외 포착 및 retryKey 기반 재마운트 복구 컴포넌트 |
tests/unit/httpClient.test.ts | 동시 401 요청 시 refresh 1회 호출 및 초안 보존 검증 테스트 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
| 토큰이 만료되었을 때 갱신 요청이 끝나지 않고 브라우저가 멈춤 | /api/auth/refresh 요청을 인터셉터가 걸린 httpClient로 보냄 | 인터셉터가 없는 별도 refreshClient 인스턴스로 갱신 요청 전송 |
| 한 번 토큰 갱신에 성공한 뒤, 나중에 또 만료되면 옛 토큰만 계속 씀 | .finally(() => { refreshPromise = null; }) 초기화를 빠뜨림 | 갱신 완료 후 반드시 refreshPromise = null로 비워주기 |
onClick 안의 await postRepository.deletePost() 에러를 ErrorBoundary가 못 잡음 | React ErrorBoundary는 렌더링 단계의 에러만 포착함 | 이벤트 핸들러 내부의 API 호출은 try / catch로 직접 처리 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 만료된 토큰으로 요청 3개가 동시에 나가 모두 401을 받으면
/api/auth/refresh는 몇 번 호출되고, 왜 그런가요? - [코드 12-1]에서
originalRequest._retry = true플래그를 지우면 어떤 위험이 생기나요? - 갱신 실패 시
localStorage.clear()대신removeItem으로 인증 토큰만 지우는 이유는 무엇인가요?
셀프 체크 정답
- 1번입니다. 첫 번째 401 응답이
refreshPromise를 생성하고, 두 번째·세 번째 401은if (!refreshPromise)조건에 걸려 새 요청을 보내지 않고 같은 Promise를await하기 때문입니다. - 새 토큰으로 재전송한 요청마저 서버 문제 등으로 또 401을 반환할 경우, 인터셉터가 끝없이 갱신과 재전송을 반복하는 무한 루프에 빠집니다.
localStorage.clear()를 호출하면 10장에서 만든BOARD_APP_DRAFT_STORAGE(작성 중인 글 초안)나 다크모드 설정 같은 사용자 데이터까지 통째로 날아가기 때문입니다.
다음 챕터로 이어지는 질문
이제 게시판은 네트워크 계층 격리와 에러 안전망까지 완벽히 갖추었습니다.
그런데 사내 핫이슈 기술 글에 댓글이 10,000개 넘게 달리자, 상세 화면을 열 때마다 브라우저가 2~3초간 얼어붙고(Long Task) 스크롤이 뚝뚝 끊기며, 화면을 오갈수록 메모리가 줄줄 새는 현상이 나타났습니다.
이어지는 Chapter 13에서는 @tanstack/react-virtual(useVirtualizer) 기반 10,000개 댓글 가상 스크롤, React 19.3의 <Activity mode="hidden">과 View Transitions, React Compiler 자동 메모이제이션, 그리고 SPA 3대 메모리 누수 방어를 실습해 보겠습니다.