프론트엔드 에러 핸들링 구조 정리 (Axios + CustomError 적용)
여러분은 어떻게 에러를 관리하시나요? 저는 사이드 프로젝트를 하며 어질어질했습니다.
에러관리 코드가 어질러진 방처럼 흩어져 있었거든요. 그래서 ErrorHandling 구조를 고민하며 청소를 시작했습니다.
한장요약
1. 에러와 에러핸들링?
Errors?
JS코드를 실행할때, 각각 다른 에러들이 발생할 수 있다. 에러들은 개발자들에의해서, 잘못된 INPUT에 의해서 또는 예측할 수 없는 일들에 의해 발생한다.
에러가 일어났을 때, JS는 멈춘후에 에러메세지를 생성한다.
이를 기술적으로는 Throw an exception 즉, 에러를 던진다고 한다.
- W3Schools 등 공식 문서나 내 경험상에서도 자바스크립트에서는 Error 와 Exception을 명확히 구분하지 않고 비슷한 맥락으로 다뤄진다.
- Error 프로그래밍 중에 발생할 수 있는 모든 오류를 총칭하는 용어. JavaScript의 Error 객체 또는 그 하위 클래스.
Error | 프로그래밍 중에 발생할 수 있는 모든 오류를 총칭하는 용어. JavaScript의 Error 객체 또는 그 하위 클래스. |
Exception | 처리해야 하는 예외적인 상황, 즉 예외 처리(try/catch)가 필요한 상태. 보통 throw를 통해 던져짐. |
그런데 백엔드에서는 Error와 Exception을 프론트보다 명확하게 구분한다.
Error | 프로그램 코드로 수습할 수 없는 심각한 오류, 메모리 부족, 스택오버플로우 등 발생하면 복구할 수 없는 오류로 예측불가능, JVM에 문제가 생긴것으로 개발자가 대처할 방법이 없음 |
Exception | 프로그램 코드에 의해서 수습될 수 있는 다소 미약한 오류, 개발자가 수습할 수 있는 비교적 덜 심각한 오류, |
일화
예전에 내가 400에러를 에러가 난다라고 말했을 때, 백엔드분이 "어? 에러 안났는데요?" 해서 의사소통이 잘 안된 적이 있었다. 그분에게 400 에러는 서버와 클라이언트가 통신이 잘 된 거고 에러가 아니라 예외의 개념이었던 것이다(!)
그 이후로 나는 둘을 좀 더 명확하게 구분하고 500, 400 같은 HTTP 상태 코드 기준으로 더 명확하게 구분해서 말하게 되었다. (500 에러났어요!, 400 친구가 왔어요!)
그리고 에러(aka.Exception)을 다루는것을 Error Handling, ExeptionHandling 이라고 합니다.
2. 발단: 에러핸들링의 중요성
[ 기존의 구조 ]
비동기 함수를 사용하는 페이지 마다 try-catch로 응답을 잡고. 에러처리가 여러파일에 분산되어 있는 구조.
[ 문제점 ]
- 서버에서 API가 변경되었을때 각각의 파일을 찾으며 업데이트해야해서 유지보수하기 힘들다.
- 다루는 api가 다르더라도 Unauthorized 같은 에러는 공통이기 때문에 사용자에게 같은 응답메세지를 띄워주고 싶다.
- 서버에서 주는 메시지를 그대로 안주고 유저가 이해할 수 있는 친숙한 메세지를 보내고 싶다.
- 에러 메세지를 통합관리하고 싶다. 변경되었을 때 찾기 귀찮다.
프로젝트가 커지면서 예외처리 로직이 더 분산되기 전에 빨리 구조를 잡아야 나중에 유지보수가 쉽겠다는 생각이 들었다!!
[ 설계시 중점을 둔 요소 ]
- 유지보수성
- 예외 발생 시 프론트에서 사용자 친화적인 메세지로 변환해서 보내줄 수 있도록 구분하기
[ 에러핸들링 전 ]
const onClickCreateSession = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // 기본 제출 동작 방지
const formData = new FormData(e.currentTarget); // 현재 폼의 데이터 수집
const { gameParticipationCode, maxGroupParticipants } = Object.fromEntries(formData.entries()); // 객체로 변환
const strGameParticipationCode = gameParticipationCode as string;
const reqData = {
gameParticipationCode: strGameParticipationCode || null,
maxGroupParticipants: Number(maxGroupParticipants),
};
try {
if (!accessToken) {
toast.warn('토큰이 없습니다. 잠시후 다시 시도해주세요');
return;
}
if (!sessionInfo?.sessionCode && accessToken) {
const response = await createContentsSession(reqData, accessToken);
if (response && response.data) {
setSessionInfo(response.data);
toast.success('✅ 세션이 성공적으로 생성되었습니다!');
router.push(`/streamer/list?max=${maxGroupParticipants}`);
}
} else {
const response = await updateContentsSession(reqData, accessToken);
if (response && response.data) {
setSessionInfo(response.data);
toast.success('✅ 세션이 성공적으로 업데이트 되었습니다!');
router.push(`/streamer/list?max=${maxGroupParticipants}`);
}
}
} catch (error) {
console.log('settings error');
console.error(error);
//if 400 ...
//if 403 ...
//if 500 ...
}
};
4. 설명을 시작하기 전에 플로우를 그려봤어요
[ 흐름 ]
- 사용자가 API요청
- 전용 API Client로 요청을 보냄
- 전용 Client의 interceptors의 response를 통해 Error가 CustomError로 바뀜
- 각각의 Error에 맞는 전용 Handler로 에러처리
- 사용자 친화적 메세지 띄움
에러 코드에 맞게 별개의 커스텀 메시지를 주고싶었고,
이를 효율적으로 관리하기 위해 커스텀 에러와 전용 client를 만들었습니다.
5. 구현
1. CustomError만들기
js의 Error객체는 기본적으로 아래와같은 속성을 가지고있어요.
이제 이걸 상속받아 CustomError를 만들어 보겠습니다
class CustomError extends Error {
code: number; //커스텀 에러에서 추가한 속성
status: number; //커스텀 에러에서 추가한 속성
constructor(errorData: {//errorData형식의 에러를 생성자로 받음
name: string;
message: string;
code: number;
status: number;
}) {
super(errorData.message); //부모클래스(Error)를 생성하는 코드, 부모의 message 속성 오버라이딩
this.name = errorData.name; //Error객체는 기본이름이 Error, 우리는 커스텀 에러이름지정
this.code = errorData.code; //ErrorData의 코드
this.status = errorData.status; //ErrorData의 상태
if (Error.captureStackTrace) { //Node.js에서 StackTrace를 더 깔끔하게 보여줌 에러 추적을 위해
Error.captureStackTrace(this, CustomError);
}
}
}
export default CustomError;
CustomError 클래스는 기본 Error를 확장해, code, status, message, name을 통합 관리할 수 있도록 설계되었습니다. 이를 통해 서버에서의 에러 메시지를 프론트에서 사용자 친화적인 메시지로 매핑하고, 에러별 처리 로직을 모듈화할 수 있게 되었습니다
2. 전용Error 와 전용 Errorcode정의
- 저는 세션관련 clinet이므로 세션에러라고 이름지어 주었습니다.
// viewer와 streamer Services에 사용됩니다.
class SessionError extends CustomError {
constructor(errorCode = SessionErrorCode.LIVE_STREAM_INACTIVE) {
super({ ...errorCode });
}
}
export default SessionError;
- 아래는 SessionErrorCode를 관리하는 상수입니다.
// 1. 에러 코드 상수 정의
export const SessionErrorCode = {
LIVE_STREAM_INACTIVE: {
//진행중인 라이브가 없을 경우
name: 'LiveStreamInactiveError',
status: 400,
code: 301,
message: '라이브 방송이 꺼져있습니다, 라이브 방송을 키고 다시 시도해주세요',
},
LIVE_SESSION_NOT_FOUND: {
//진행중인 세션이 없을 경우
name: 'LiveSessionNotFoundError',
status: 400,
code: 302,
message: '라이브 방송이 꺼져있습니다, 라이브 방송을 키고 다시 시도해주세요',
},
INVALID_PARTICIPANT_COUNT: {
name: 'InvalidParticipantCountError',
status: 400,
code: 303,
message: '그룹당 최대 참가자 수는 1명 이상이어야 합니다. 현재 값: 0',
},
PARTICIPANT_NOT_FOUND: {
name: 'participantNotFoundError',
status: 400,
code: 304,
message: '해당 참여자가 존재하지 않습니다.',
},
LIVE_SESSION_EXISTS: {
//진행중인 세션이 없을 경우
name: 'LiveSessionExists',
status: 400,
code: 305,
message: `이미 진행 중인 컨텐츠 세션이 존재합니다. 중복 생성을 할 수 없습니다. \n(진행중이던 세션은 잠시 후 자동적으로 종료됩니다. 잠시만 기다려 주세요)`,
},
SESSION_CODE_NOT_FOUND: {
//세션코드가 세션스토리지에서 삭제 되었을 때
name: 'SessionCodeNotFoundError',
status: 400,
code: 306,
message: `세션코드를 찾을 수 없습니다. 시참방을 다시 생성해 주세요`,
},
SESSION_CLOSED: {
name: 'SessionClosedError',
status: 400,
code: 307,
message: `해당 세션이 이미 닫혔으니 세션을 다시 생성해주세요`,
},
};
3. Client(axiosInstance) 작성
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { ErrorResponse } from '../streamer/type';
import SessionError, { SessionErrorCode } from '@/errors/sessionError';
const sessionClient: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, // API의 기본 URL
timeout: 1000000,
headers: {
'Content-Type': 'application/json', // 기본 Content-Type
},
});
// 요청 인터셉터
sessionClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 요청 전 처리 (예: 토큰 추가)
const accessToken = sessionStorage.getItem('accessToken');
if (accessToken) {
console.log('axios : ', accessToken);
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
// 요청 오류 처리
console.log(error);
console.log(error.status);
console.log(error.error);
console.log('intercepter error');
return Promise.reject(error);
},
);
// 응답 인터셉터
sessionClient.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError<ErrorResponse>) => {
// 요청 오류 처리
if (error && error.response) {
const { status, error: message } = error.response?.data;
if (status === 400) {
if (message === '현재 진행중인 라이브 방송을 찾을 수 없습니다. 다시 확인해 주세요.') {
throw new SessionError(SessionErrorCode.LIVE_SESSION_NOT_FOUND);
}
if (message === '이미 진행 중인 컨텐츠 세션이 존재합니다. 중복 생성을 할 수 없습니다.') {
throw new SessionError(SessionErrorCode.LIVE_SESSION_EXISTS);
}
if (message === '현재 진행 중인 시청자 참여 세션이 없습니다. 다시 확인해 주세요.') {
throw new SessionError(SessionErrorCode.SESSION_CLOSED);
}
}
} else if (axios.isAxiosError(error)) {
if (error.response) {
// 서버에서 응답한 에러
console.warn('🚨 Server Response:', error.response.data);
return {
status: error.response.status,
error: error.response.data?.error || '서버 오류가 발생했습니다.',
data: error.response.data?.data,
};
} else if (error.request) {
// 요청이 전송되었지만 응답이 없음
return {
status: 503,
error: '서버 응답이 없습니다. 네트워크 상태를 확인하세요.',
data: 'null',
};
}
}
// Axios 외의 일반적인 예외 처리
console.warn('❌ Unexpected Error:', error);
return {
status: 500,
error: '알 수 없는 오류가 발생했습니다.',
data: 'null',
};
},
// 응답 데이터 가공
);
export default sessionClient;
- 정상요청이면 정상 response를 넘깁니다.
(response: AxiosResponse) => {
return response;
},
- Axios.interceptor의 response 를 통해 서버에서 보내주는 예외를 커스텀 에러로 던지는 코드입니다.
- 서버에서 보내주는 400Error(통신 OK, 잘못된 요청)는 SessionError가 되서 호출된 부분으로 던져집니다.
if (error && error.response) {
const { status, error: message } = error.response?.data;
if (status === 400) {
if (message === '현재 진행중인 라이브 방송을 찾을 수 없습니다. 다시 확인해 주세요.') {
throw new SessionError(SessionErrorCode.LIVE_SESSION_NOT_FOUND);
}
if (message === '이미 진행 중인 컨텐츠 세션이 존재합니다. 중복 생성을 할 수 없습니다.') {
throw new SessionError(SessionErrorCode.LIVE_SESSION_EXISTS);
}
if (message === '현재 진행 중인 시청자 참여 세션이 없습니다. 다시 확인해 주세요.') {
throw new SessionError(SessionErrorCode.SESSION_CLOSED);
}
}
}
- 현재는 서버에서 커스텀 에러코드를 지정하지 않은 상태이기 때문에 서버에서 내려주는 메세지를 기준으로 구분했습니다. (나중에 서버에서 주는 커스텀 에러코드로 수정하면 됩니다!)
else if (axios.isAxiosError(error)) {
if (error.response) {
// 서버에서 응답한 에러
console.warn('🚨 Server Response:', error.response.data);
return {
status: error.response.status,
error: error.response.data?.error || '서버 오류가 발생했습니다.',
data: error.response.data?.data,
};
} else if (error.request) {
// 요청이 전송되었지만 응답이 없음
return {
status: 503,
error: '서버 응답이 없습니다. 네트워크 상태를 확인하세요.',
data: 'null',
};
}
}
4. Services단에 적용하기
- sessionClient란 client를 사용해서 세션관련 예외가 발생하면 SessionError를 throw합니다
- 발생한 에러는 handleSessionErrro로 이동됩니다.
//세션에서 참가자 추방
export const deleteContentsSessionParticipant = async (
accessToken: string,
viewerId: number,
): Promise<DeleteContentsSessionResponse> => {
console.log(accessToken);
try {
const response = await sessionClient.delete(`${SESSION_URLS.contentsParticipants}/${viewerId}`, {
headers: {
Authorization: `Bearer ${accessToken}`
},
});
return response.data; // 성공적인 응답 데이터 반환
} catch (error: unknown) {
return handleSessionError(error); // 에러 핸들링 함수 사용
}
};
5. 에러를 토스트하고 처리하는 Handler 만들기
- 세션에러라면 사용자에게 커스텀 에러에 맞는 토스트 메세지를 띄우고 커스텀 에러정보를 반환합니다.
- 아니라면 500에러를 띄운후 반환합니다.
export const handleSessionError = (error: unknown): ErrorResponse => {
if (error instanceof SessionError) {
toast.warn(`${error.message}`);
return {
status: error.status,
code: error.code,
error: error.name || '서버 오류가 발생했습니다.',
data: error.message,
};
}
toast.warn(`500 서버에러\n 알 수 없는 오류가 발생했습니다.`);
return {
status: 500,
error: '알 수 없는 오류가 발생했습니다.',
data: 'null',
};
};
6. 결과
[ 장점 ]
- 서버에서 보내주는 에러에 맞춰 사용자 친화적인 메세지를 줄 수 있었습니다.
- 서버에서 추후 에러메세지를 추가해도 해당 fetcher와 관련된 error만 수정하면 되서 유지보수가 쉬워졌습니다.
[ 단점 ]
- 그래도 여전히 에러에 맞춰 라우팅을 해줘야 한다거나 할때는 코드를 분산해서 써야합니다. (next.js의 에러바운더리로 해결할 수 있을지도?)
[ 고민 ]
- 세션에러에는 세션에러만 던져주니까 핸들러를 구분하는게 소용이 있나? 싶긴 합니다만, 만약 서비스가 커진다면 쓸모있을 것 같아서 굳이 구분해두었습니다.
- 각각의 핸들러를 하나로 통합하는 거대한 ErrorHandler함수를 만들어야 겠군요. 글을쓰며 아이디어를 얻었네요
7. 결론
- ✅서버 에러 메시지를 그대로 사용자에게 보여주는 대신 사용자 친화적인 메시지 주기 완료
- ✅CustomError, SessionError, 에러코드 매핑 구조를 통해 일관된 구조, 유지보수성 증가
- 여러분의 예외 핸들링 구조도 궁금해요!
- 이건 100% 정답이 아닐 수도 있다는 거 염두해주시고 피드백은 환영입니다!