비동기 호출을 하다보면 다양한 상태코드에 따라 401(인증되지 않은 사용자), 404(존재하지 않는 리소스), 500(서버 내부 에러) 혹은 CORS 에러와 같은 다양한 에러가 발생합니다.
타입 스크립트에선 이러한 비동기 API 에러를 어떻게 처리 및 명시할 수 있는지 6가지 정리해보았습니다.
1. 타입 가드 활용하기
타입 가드를 활용하면 서버 에러를 명시적으로 확인할 수 있습니다.
1) 명시적으로 표시하기
아래와 같이 서버에서 전달하는 공통 에러 객체에 대해 타입을 정의할 수 있습니다.
// 서버에서 전달하는 공통 에러 객체 타입 정의
interface ErrorResponse {
status: string;
errorCode: string;
errorMessage: string;
}
2) Axios 라이브러리의 isAxiosError
ErrorResponse 인터페이스를 사용해 처리해야할 Axios 에러 형태는 AxiosError로 표현되며, 아래와 같이 명시할 수 있습니다.
타입 가드 정의 시에는 parameterName is Type 형태의 타입 명제를 정의해주는 것이 좋고, parameterName은 타입 가드 함수의 시그니처*에 포함된 매개변수여아 합니다.
*함수 시그니처 : 함수 이름, 매개변수의 타입, 반환 타입을 정의하는 부분
function isServerError(error: unknown): error is AxiosError<ErrorResponse> {
return axios.isAxiosError(error);
}
실제 사용 예시
아래와 같이 명시가 되어있으면 한 눈에 서버 에러임을 확인할 수 있습니다. (손이 가리키는 부분)
// 실제 사용 예시
async function deleteUser(id: string) {
try {
await axios.delete(`/api/users/${id}`);
alert("삭제되었습니다.");
} catch (error: unknown) {
// 타입가드로 서버 에러임을 확인할 수 있습니다.
// 👇👇👇
if (isServerError(error) && error.response?.data.errorMessage) {
alert(error.response.data.errorMessage);
return;
}
// 그 외 에러 처리
alert("알 수 없는 에러가 발생했습니다.");
}
}
2. 에러 서브클래싱하기
서브 클래싱(Subclassing)이란 부모 클래스를 확장해서 새로운 하위 자식 클래스를 만드는 과정으로 자식 클래스는 부모 클래스으 모든 속성과 메서드를 상속받아 사용할 수 있고, 추가적인 속성과 메서드 정의가 가능합니다.
📌 서브 클래싱의 장점 요약
1) 에러 발생 시 코드상에서 어떤 에러인지 바로 확인 가능
2) 에러 인스턴스가 무엇인지에 따라 에러 처리 방식을 다르게 할 수 있음
장점 1) 에러 발생 시 코드상에서 어떤 에러인지 바로 확인 가능
// 1. 기본 에러 클래스 생성
class AppError extends Error {
constructor(message: string) {
super(message);
this.name = 'AppError'; // ⭐️ 에러 이름 지정도 가능해요 !
}
}
// 2. 구체적인 에러 클래스들
class NetworkError extends AppError {
constructor(message: string = '네트워크 연결에 실패했습니다.') {
super(message);
this.name = 'NetworkError';
}
}
class AuthError extends AppError {
constructor(message: string = '인증에 실패했습니다.') {
super(message);
this.name = 'AuthError';
}
}
사용 예시
async function fetchUserData(userId: string) {
try {
const response = await axios.get(`/api/users/${userId}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
switch (error.response?.status) {
case 401:
throw new AuthError('로그인이 필요합니다.'); //👈 에러 종류 바로 확인 가능
case 404:
throw new AppError('사용자를 찾을 수 없습니다.');
case 500:
throw new AppError('서버 오류가 발생했습니다.');
}
}
throw new NetworkError();// 👈
}
}
// 에러 처리
async function handleUserData(userId: string) {
try {
const userData = await fetchUserData(userId);
console.log(userData);
} catch (error) {
if (error instanceof AuthError) {
// 로그인 페이지로 리다이렉트
redirect('/login');
} else if (error instanceof NetworkError) {
// 재시도 로직
retryFetch(userId);
} else {
// 기본 에러 처리
showErrorMessage(error.message);
}
}
}
장점 2) 에러 인스턴스가 무엇인지에 따라 에러 처리 방식을 다르게 할 수 있음
아래와 같은 타입 가드문을 통해 코드상에서 인스턴스에 따른 에러를 다르게 처리할 수 있습니다.
async function handleUserData(userId: string) {
try {
const userData = await fetchUserData(userId);
console.log(userData);
} catch (error) {
// 👇 인스턴스에 따라 에러 처리도 가능하고, 어떤 에러인지도 확인할 수 있습니다.
if (error instanceof AuthError) {
// 로그인 페이지로 리다이렉트
redirect('/login');
} else if (error instanceof NetworkError) {
// 재시도 로직
retryFetch(userId);
} else {
// 기본 에러 처리
showErrorMessage(error.message);
}
}
}
3. Axios 인터셉터를 활용한 에러처리
Axios 같은 페칭 라이브러리는 인터셉터 기능을 제공하는데, 이를 통해 HTTP 요청이나 응답을 가로채서 HTTP 에러에 일관된 처리를 적용할 수 있습니다.
아래 토큰 추가 및 갱신처리와 관련된 예시 코드를 전반적으로 살펴보며 확인해보겠습니다.
기본 설정
// 1. API 기본 설정과 타입 정의
interface ErrorResponse {
status: string;
code: string;
message: string;
}
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000,
headers: {
'Content-Type': 'application/json'
}
});
// 2. 응답에 대한 에러 핸들러 함수 정의
const errorHandler = (error: AxiosError<ErrorResponse>): Promise<Error> => {
const { response } = error;
// 👇 상태 코드별로 일관된 에러 처리가 가능합니다.
switch (response?.status) {
case 401: {
// 인증 에러 처리
const currentUrl = window.location.href;
window.location.href = `/login?redirect=${currentUrl}`;
return Promise.reject(new Error('인증이 필요합니다.'));
}
case 403: {
// 권한 부족 에러 처리
alert('접근 권한이 없습니다.');
window.location.href = '/home';
return Promise.reject(new Error('권한이 없습니다.'));
}
case 404: {
return Promise.reject(new Error('요청한 리소스를 찾을 수 없습니다.'));
}
case 500: {
return Promise.reject(new Error('서버 오류가 발생했습니다.'));
}
default: {
return Promise.reject(new Error('알 수 없는 에러가 발생했습니다.'));
}
}
};
요청과 응답에 대한 인터셉터 설정
// 요청 인터셉터 설정
api.interceptors.request.use(
(config) => {
// 토큰 추가
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터 설정
api.interceptors.response.use(
(response) => response, // 성공 응답은 그대로 반환
async (error) => {
// 토큰 만료 체크 및 갱신 처리
if (axios.isAxiosError(error) &&
error.response?.status === 401 &&
error.config) {
try {
// 토큰 갱신 시도
const newToken = await refreshToken();
localStorage.setItem('token', newToken);
// 실패했던 요청 재시도
const config = error.config;
config.headers.Authorization = `Bearer ${newToken}`;
return api.request(config);
} catch (e) {
// 👇 앞서 정의해준 응답에 대한 에러 핸들러 함수
return errorHandler(error);
}
} // 👇
return errorHandler(error);
}
);
API 사용 예시
// API 사용 예시
async function fetchUserData(userId: string) {
try {
const response = await api.get(`/users/${userId}`);
return response.data;
} catch (error) {
// 📌 인터셉터에서 처리되지 않은 에러 처리
console.error('API 호출 중 에러 발생:', error);
throw error;
}
}
4. 에러 바운더리를 활용한 에러처리
에러 바운더리는 React 16에서 도입된 기능으로 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 에러를 처리하는 특별한 컴포넌트 입니다.
리액트 컴포넌트 트리 하위에 있는 컴포넌트에서 발생한 에러를 캐치하고, 해당 에러를 가장 가까운 부모 에러 바운더리에서 처리할 수 있게 합니다.
🤔 언제사용할까?
에러가 발생한 컴포넌트 대신에 에러 처리를 하거나, 커스텀 에러 페이지와 같이 예상하지 못한 에러를 공통으로 처리할 때 사용할 수 있습니다.
기본적인 에어 바운더리 구현 : 에러 발생 시, 커스텀 에러페이지 return
// ErrorBoundary.tsx
class ErrorBoundary extends React.Component<
{ children: React.ReactNode }, // 👈 ErrorBoundaryProps
{ hasError: boolean; error: Error | null } // 👈 ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
// 👇 에러 발생시 호출되는 메서드
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
// 👇 에러 발생시 부가적인 작업
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 에러 로깅, 분석 도구에 보고 등
console.error('Error:', error);
console.error('Error Info:', errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-ui">
<h1>문제가 발생했습니다. 😢</h1>
<p>{this.state.error?.message}</p>
</div>
);
}
return this.props.children;
}
}
사용 예시
// 전역 설정
// App.tsx
function App() {
return (
<ErrorBoundary>
<div className="app">
<Header />
<Main />
<Footer />
</div>
</ErrorBoundary>
);
}
// 특정 컴포넌트에만 적용
function UserProfile() {
return (
<ErrorBoundary>
<UserData />
</ErrorBoundary>
);
}
에러 바운더리 정리
이러한 에러 바운더리를 이용하면, 에러가 발생해도 전체 앱이 중단되지 않고, 커스텀 에러 페이지와 같은 대체 UI가 보여지도록 할 수 있습니다.
5. react-query를 활용한 에러처리
react-query 또는 swr과 같은 페칭 라이브러리를 사용하면 요청에 대한 상태를 반환 받을 수 있어 요청 상태를 확인하기 쉽습니다.
상대적으로 많이 쓰는 react-query로 사용자 프로필 구현 시 발생할 수 있는 에러 처리를 구현한 코드를 살펴보겠습니다.
기본 설정 코드
// 👇 필요한 타입 정의
interface User {
id: number;
name: string;
email: string;
}
interface ErrorResponse {
message: string;
code: string;
}
// 👇 API 함수 정의
const fetchUser = async (id: number): Promise<User> => {
const response = await axios.get(`/api/users/${id}`);
return response.data;
};
기본적인 react-query 사용과 에러 처리
// userId를 props로 받아 해당 사용자의 정보를 조회하고 표시
function UserProfile({ userId }: { userId: number }) {
// User: 응답 데이터 타입, Error: 에러 타입
const {
data, // API 호출 성공 시 받아오는 데이터
isLoading, // 데이터 로딩 중인지 여부 (true/false)
error, // 에러 발생 시 에러 객체
isError // 에러 발생 여부 (true/false)
} = useQuery<User, Error>( // 👈 React Query의 useQuery 훅 사용
// User = 'user'와 userId로 구성된 고유한 쿼리 키
// 이 키로 데이터를 캐싱하고 관리
['user', userId],
() => fetchUser(userId),// 실제 API 호출을 수행하는 함수 - userId를 인자로 받아 사용자 정보를 가져옴
// useQuery 옵션 설정
{
retry: 3, // API 호출 실패 시 최대 3번까지 재시도
retryDelay: 1000, // 재시도 사이의 대기 시간 (1초)
// 에러 발생 시 실행될 콜백 함수
onError: (error) => {
console.error('사용자 정보 로딩 실패:', error);
}
}
); 📍useQuery fin
// 로딩 중일 때 표시할 UI
if (isLoading) {
return <div>로딩 중...</div>;
}
// 에러 발생 시 표시할 UI
// 새로고침 버튼을 통해 페이지 리로드 가능
if (isError) {
return (
<div>
<p>에러: {error.message}</p>
<button onClick={() => window.location.reload()}>
새로고침
</button>
</div>
);
}
// 데이터 로드 성공 시 사용자 정보 표시
// data가 없으면 null 반환
return data ? (
<div>
<h1>{data.name}의 프로필</h1>
<p>이메일: {data.email}</p>
</div>
) : null;
}
// userId를 props로 받아 해당 사용자의 정보를 조회하고 표시
function UserProfile({ userId }: { userId: number }) {
// User: 응답 데이터 타입, Error: 에러 타입
const {
data,
isLoading,
error,
isError
} = useQuery<User, Error>( // 👈 React Query의 useQuery 훅 사용
['user', userId],
() => fetchUser(userId),
{
retry: 3,
retryDelay: 1000,
onError: (error) => {
console.error('사용자 정보 로딩 실패:', error);
}
}
);
// 로딩 중일 때 표시할 UI
if (isLoading) {
return <div>로딩 중...</div>;
}
// 에러 발생 시 표시할 UI
if (isError) {
return (
<div>
<p>에러: {error.message}</p>
<button onClick={() => window.location.reload()}>
새로고침
</button>
</div>
);
}
// 데이터 로드 성공 시 사용자 정보 표시
return data ? (
<div>
<h1>{data.name}의 프로필</h1>
<p>이메일: {data.email}</p>
</div>
) : null;
}
6. 상태 관리 라이브러리(Redux)에서의 에러 처리
상태 관리 라이브러리 Redux를 사용하면 비동기 작업의 상태를 체계적으로 관리할 수 있습니다.
Redux의 슬라이스 생성을 통해 이용한 '컴포넌트 마운트 시 자동으로 사용자 데이터를 로드하는 기능 구현' 코드를 예시로 살펴보겠습니다.
초기 세팅 코드
기본 타입 및 상태 정의
// types.ts
interface User {
id: number;
name: string;
}
interface UserState {
data: User | null;
loading: boolean;
error: string | null;
}
// API 에러 타입
interface ApiError {
message: string;
status: number;
}
API 호출 및 에러 처리
// api.ts
const api = axios.create({
baseURL: 'https://api.example.com'
});
export const fetchUser = async (id: number) => {
try {
const response = await api.get(`/users/${id}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.message || '사용자 정보를 불러오는데 실패했습니다.');
}
throw error;
}
};
Redux를 이용한 설정
// userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 🎯 비동기 액션 생성
// createAsyncThunk는 비동기 작업을 위한 액션 생성자를 만듭니다.
export const fetchUserById = createAsyncThunk(
'user/fetchById', // 액션 타입의 접두사
// 실제 비동기 작업을 수행하는 함수
async (id: number, { rejectWithValue }) => {
try {
const user = await fetchUser(id);
return user;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 🔄 리듀서 설정
const userSlice = createSlice({
name: 'user',
// 초기 상태 설정
initialState: {
data: null,
loading: false,
error: null
} as UserState,
//1️⃣ 일반 리듀서
reducers: {
// 에러 상태를 초기화하는 리듀서
clearError: (state) => {
state.error = null;
}
},
// 2️⃣ 비동기 액션을 처리하는 리듀서
extraReducers: (builder) => {
builder
// 요청 시작
.addCase(fetchUserById.pending, (state) => {
state.loading = true; // 로딩 시작
state.error = null; // 에러 초기화
})
// 요청 성공
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = false; // 로딩 완료
state.data = action.payload; // 데이터 저장
})
// 요청 실패
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = false; // 로딩 완료
state.error = action.payload as string; // 에러 메시지 저장
});
}
});
일반 리듀서와 비동기 리듀서 (참고 1)
reducers
일반적인 동기 작업을 처리하며, 단순 상태 업데이트 및 직접 액션을 디스패치하여 호출합니다.
extraReducers
createAsyncThunk로 생성된 비동기 액션을 처리하고, 다른 슬라이스의 액션에 응답합니다.
복잡한 상태 변화 처리나 여러 액션 타입에 대한 공통 처리에 적합합니다.
Redux reducer와 React useReducer (참고 2)
기본 구조 비교
// React useReducer
const [state, dispatch] = useReducer(reducer, initialState);
// state: 현재 상태 / dispatch: 액션을 발생시키는 함수
// reducer: 상태를 변경하는 함수 / initialState: 초기 상태
// Redux reducer
const store = createStore(reducer, initialState);
실제 코드 비교
// 🔵 React useReducer
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, action.payload];
default:
return state;
}
}
function TodoComponent() {
const [todos, dispatch] = useReducer(todoReducer, []);
// 컴포넌트 내부에서만 사용 가능
}
// 🔴 Redux reducer
const todoReducer = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push(action.payload);
}
}
});
// 어떤 컴포넌트에서든 접근 가능
function AnyComponent() {
const dispatch = useDispatch();
const todos = useSelector(state => state.todos);
}
주요 차이점
- 범위
- useReducer: 컴포넌트 로컬 상태 관리
- Redux: 전역 상태 관리
- 상태 접근
- useReducer: 해당 컴포넌트와 자식 컴포넌트만
- Redux: 애플리케이션 어디서든 접근 가능
- 미들웨어
- useReducer: 미들웨어 개념 없음
- Redux: 미들웨어를 통한 부가 기능 (로깅, 비동기 처리 등)
- 개발자 도구
- useReducer: 기본 React 개발자 도구
- Redux: Redux DevTools를 통한 상태 추적
정리
- 간단한 상태 관리: useReducer
- 복잡하고 전역적인 상태 관리: Redux
사용 예시
// UserProfile.tsx
function UserProfile() {
// 👇 Redux - dispatch 훅 사용
const dispatch = useAppDispatch();
// Redux의 액션을 발생시키는 함수를 가져옵니다.
// - 이를 통해 fetchUserById 같은 액션을 실행할 수 있어요. 🍎로 표시된 부분
// 👇 Redux - selector 훅 사용
const { data, loading, error } = useAppSelector(state => state.user);// 🤔 error?
// Redux 스토어의 user 상태를 가져오는 훅입니다.
// - data: 사용자 정보
// - loading: 로딩 상태
// - error: 에러 상태
// 컴포넌트 마운트 시 데이터 로드
useEffect(() => {
// async/await를 사용하지 않고 Promise 체이닝으로 처리
dispatch(fetchUserById(1)) //🍎
.unwrap() // 성공/실패 여부를 Promise로 변환
.catch((error) => {
// 추가적인 에러 처리 (로깅, 분석 등)
console.error('사용자 정보 로딩 실패:', error); // 🤔 error?
});
}, [dispatch]);
// 재시도 핸들러
const handleRetry = () => {
dispatch(fetchUserById(1));
};
// 로딩 중 UI
if (loading) {
return <div>데이터를 불러오는 중입니다...</div>;
}
// 에러 발생 시 UI
if (error) {
return (
<div className="error-container">
<p>에러가 발생했습니다: {error}</p>
<button onClick={handleRetry}>
다시 시도하기
</button>
</div>
);
}
// 정상적인 데이터 표시 UI
return data ? (
<div className="user-profile">
<h1>{data.name}님의 프로필</h1>
<p>이메일: {data.email}</p>
</div>
) : null;
}
🤔 error 전달의 과정
표시된 error는 어떤 과정을 거쳐 전달되고, 반영될까요?
한 문장으로 정리하면 'API 호출 실패' → 'catch 블록' → 'Redux 상태 업데이트' → 'useAppSelector'로 가져오는 순서로 전달됩니다.
좀 더 자세히 알아보면,
1) API 호출이 실패하면 catch 블록에서 에러를 잡습니다.
2) 이 에러는 Redux의 createAsyncThunk에서 rejectWithValue로 처리되어 리듀서로 전달됩니다:
extraReducers: (builder) => {
builder
.addCase(fetchUserById.rejected, (state, action) => {
state.error = action.payload; // 👈 여기서 Redux 상태의 error가 설정됨
})
}
3) 리듀서에서 설정된 error가 다시 useAppSelector를 통해 컴포넌트로 전달됩니다.
Redux 와 react-query
Redux
- 전역 상태 관리
- 클라이언트 상태 관리 (예: UI 상태, 사용자 설정)
- 복잡한 상태 로직 처리
React Query
- 서버 상태 관리
- 데이터 페칭, 캐싱, 동기화
- 서버 데이터 업데이트
항목별 상세 비교
데이터 관리
- Redux: 수동적인 데이터 관리, 직접 상태 업데이트
- React Query: 자동 캐싱, 자동 백그라운드 업데이트
코드 복잡도
- Redux: 보일러플레이트 코드 많음 (액션, 리듀서 등)
- React Query: 간단한 훅 기반 API
성능
- Redux: 수동 최적화 필요
- React Query: 자동 최적화 (캐싱, 중복 요청 방지)
기능
- Redux: 전역 상태 관리에 중점
- React Query: 서버 상태 관리, 캐싱, 재시도, 폴링 등 추가 기능