[React] React.js 강좌 21. 전역 에러 처리와 인터셉터 기반 인증 흐름 관리
프로젝트를 하다 보면, 의도치 않게 에러가 터지고 그게 UI까지 그대로 드러나는 경우가 있습니다. 예전엔 콘솔에 찍힌 에러 메시지만 보고 해결하려고 했는데, 서비스 규모가 커지면서 그런 방식이 통하지 않게 됐습니다. 에러가 한 군데서만 나는 게 아니라, API 요청, 렌더링, 상태 관리, 네트워크 등 여러 지점에서 터지기 시작하니까요.
그래서 이번에는 제가 최근에 적용했던 “전역 에러 처리”와 “Axios 인터셉터 기반 인증 흐름 관리” 방법을 정리해보려 합니다. 단순히 개념 설명이 아니라, 실제 서비스에 적용하면서 얻은 시행착오 중심으로 이야기해보겠습니다.
화면이 멈추지 않게 만드는 첫 번째 장치
리액트를 쓰다 보면, 한 컴포넌트에서 에러가 나면 그 아래 트리 전체가 렌더링되지 않는 경우가 있습니다. 화면이 통째로 사라져버리는 상황이죠. 초반엔 “어디서 터졌지?” 하며 console.log만 찍었는데, 나중에야 이게 React의 기본 동작 방식이라는 걸 알게 됐습니다.
이때 사용해야 하는 게 Error Boundary입니다. 한마디로, 화면이 죽지 않도록 지켜주는 안전망입니다.
저는 이걸 가장 바깥쪽, 즉 App을 감싸는 최상단에서 적용했습니다. 덕분에 내부 컴포넌트 중 하나가 에러를 내더라도 전체 앱이 멈추지 않았습니다. 아래는 실제로 쓰고 있는 코드입니다.
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
console.error('에러 발생:', error, info);
}
render() {
if (this.state.hasError) {
return <h2>문제가 생겼습니다. 잠시 후 다시 시도해주세요.</h2>;
}
return this.props.children;
}
}
export default ErrorBoundary;
이걸 루트 컴포넌트에 감싸주면 됩니다.
import ErrorBoundary from './ErrorBoundary';
import App from './App';
export default function Root() {
return (
<ErrorBoundary>
<App />
</ErrorBoundary>
);
}
이렇게 적용한 뒤로, 예기치 못한 오류가 발생해도 앱이 멈추는 일은 사라졌습니다. 에러 메시지를 Toast로 띄우거나, 에러 페이지로 연결해주는 식으로 확장하면 훨씬 안정적인 사용자 경험을 줄 수 있습니다.
Axios 인터셉터를 알기 전까지
API 통신에서 에러를 관리하는 건 생각보다 골치 아픈 일입니다.
처음에는 모든 요청마다 try-catch를 걸어뒀습니다. 로그인, 회원가입, 게시물 작성 등등. 그런데 규모가 커질수록 코드가 점점 복잡해지고, 같은 예외 처리 로직이 중복되기 시작했습니다.
결정적으로, 토큰이 만료되면 401 에러가 발생하는데, 그걸 처리하지 못해서 로그인 페이지로 강제 이동하는 문제도 생겼습니다. 사용자 입장에선 정말 불편한 상황이었죠.
이때 도입한 게 **Axios 인터셉터(Interceptor)**입니다. 인터셉터는 요청과 응답이 오가는 중간에서 코드를 끼워 넣을 수 있는 기능입니다. 이걸 이용하면 “모든 요청에 공통 로직을 추가”할 수 있습니다.
토큰 관리 구조를 바꾼 뒤 생긴 변화
아래는 지금 실제 프로젝트에서 사용하는 Axios 설정 파일입니다.
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true,
});
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response && error.response.status === 401) {
console.warn('인증 만료: 새 토큰 발급 시도 중');
try {
const refresh = localStorage.getItem('refresh_token');
const res = await axios.post('/auth/refresh', { refresh });
localStorage.setItem('access_token', res.data.access);
window.location.reload();
} catch {
console.log('리프레시 실패 → 로그인 페이지로 이동');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;
이 구조를 한 번 적용하면,
모든 API 요청이 동일한 흐름으로 처리됩니다.
- 요청을 보낼 때 자동으로 토큰이 붙고
- 만약 401 에러가 발생하면 새 토큰을 요청한 뒤
- 성공 시 다시 요청을 시도하거나, 실패 시 로그인 페이지로 이동
덕분에 개별 컴포넌트에서는 에러를 신경 쓸 필요가 거의 없어졌습니다.
토큰 만료 시 자동으로 복구되는 흐름
처음엔 토큰이 만료되면 그냥 “다시 로그인하세요” 메시지를 띄웠습니다.
그런데 사용자 피드백이 생각보다 안 좋았습니다.
결국 리프레시 토큰을 이용한 자동 로그인 갱신 구조로 바꿨습니다.
결과는 완전히 달라졌습니다.
로그인이 풀려도 화면이 끊기지 않고 자연스럽게 유지됐습니다.
서버 쪽에서도 “이제 요청이 한 번만 들어와서 훨씬 낫다”고 하더군요.
지금 생각해보면, 전역에서 인증 흐름을 관리하는 게 단순히 개발자 편의 때문이 아니라,
사용자 경험 자체를 지켜주는 핵심 구조라는 걸 깨달았습니다.
리액트 쿼리와 함께 써보면서 느낀 점
React Query와 Axios 인터셉터를 함께 쓰면 훨씬 강력해집니다.
리액트 쿼리가 서버 상태를 캐싱하기 때문에,
401 에러로 새 토큰을 받아온 뒤 자동으로 쿼리가 갱신됩니다.
예를 들어, 관리자 페이지에서 유저 리스트를 불러오는 쿼리가 있다면
토큰이 만료돼도 자동으로 재요청되어 최신 데이터를 다시 표시합니다.
이런 구조가 만들어졌을 때 비로소 “리액트 앱이 안정적으로 동작한다”는 느낌을 받았습니다.
결국 전역 에러 처리와 인터셉터는 “에러를 잡기 위한 기술”이라기보다는
“앱의 신뢰도를 유지하기 위한 구조”라는 게 제 결론입니다.
이 두 가지를 잘 세팅해두면, 예상치 못한 상황에서도 앱이 무너지지 않습니다.
특히 사용자 입장에서는 “에러가 안 보인다”는 게 곧 신뢰로 이어집니다.
이걸 한 번 구현해보면 왜 다들 인터셉터를 필수로 두는지 알게 됩니다.