개발을 하면서 화면을 잘 만드는 것도 매우 중요하지만 그것보다도 더 중요한 게 화면이 보이지 않을 때의 처리인 것 같아요. 어떤 문제 상황인지 어떻게 해결할 수 있는지를 사용자에게 빠르게 전달하고 해결책을 제시해야 서비스에 대한 신뢰도가 떨어지지 않을 것이에요.
저는 next.js의 page Router를 활용하여 개발하고 있어요. getServerSideProps를 통해서 서버에서 api를 호출해오는 경우가 많죠. 이때 어떤 방식으로 에러를 핸들링 했는지 적어보려고 해요. 그리고 같은 에러 처리를 클라이언트에서는 어떻게 처리했는지도 함께 정리해 볼게요.
먼저 에러처리, 에러 핸들링이란 아래같은 과정을 모아 이야기해요.
api 요청
api 요청시 이상 발생
…
!! 에러 발생 !! ⇒ next 자체에러 화면!
이 일련의 과정 속에서 우리는 3번을 잘 처리해야 하는 것이죠. “잘”을 풀어서 생각해 보면 각 에러 상황에 맞는 해결 및 정보를 담은 사용자에게 해결을 위한 힌트를 잘 줘야 한다는 것으로 정의해 볼 수 있겠어요.
그렇지 않다면 사용자는 이유도 모른 채 흰 화면 혹은 에러 화면을 보고 있을 테니까요.

이런 에러 핸들링은 서비스를 이용하는 사용자와의 신뢰도와 직결되기도 하며 구멍이 없는 촘촘한 서비스를 유지하기 위해서 필수적으로 챙겨야 하는 요소라고 생각해요.
집을 짓는데 이 집이 툭 쳤을 때 우르르 무너지는 게 아니라 뼈대를 만들어두어 고장은 났지만 무너지지 않게 하고 빠르게 보수를 할 수 있는 방법을 알려주는 것이죠.

그러면 어떻게 이런 에러 처리 구조를 잡을 수 있을까요?
저는 현재 next 14버전의 page router를 사용하고 있습니다.
page router의 경우 서버에서 데이터를 패칭해오기 위해서는 동적 렌더링이거나, 정적 렌더링 중 하나를 선택하여 렌더링 해야 해요.
getServerSidePropsgetStaticProps, getStaticPaths서버에서 데이터를 가져오려면 반드시 위 3개의 메서드를 거쳐야만 가능하죠.
위와 같은 메소드를 통해서 서버측에서 데이터를 패칭해오는 과정에서 에러 핸들링을 한 과정을 풀어볼게요.
이 과정에서는 이 글을 참고했어요.
진행할 내용을 먼저 이야기해 보자면,
이런 순서로 api의 결과를 처리한다면 에러가 발생했을 때 미리 대비해둔 로직대로 처리가 될 것임을 예상할 수 있어요. 이것을 이제 직접 코드로 옮겨볼게요.
//일단 자바스크립트의 기본 Error Class를 확장해서 우리가 사용할 커스텀 에러 구현.
export function isInstanceOfAPIError(object: unknown): object is ApiError {
return (
object instanceof ApiError &&
('redirectUrl' in object || 'notFound' in object)
);
}
export class ApiError extends Error {
redirectUrl: string = '';
notFound: boolean = false;
}
export class NotFoundError extends ApiError {
name = 'NotFoundError';
message = '찾을 수 없습니다.';
notFound = true;
}
export class ForbiddenError extends ApiError {
name = 'ForbiddenError';
message = '인증처리에 실패했습니다.';
redirectUrl = '/error';
}
export class AuthError extends ApiError {
name = 'AuthError';
message = '인증되지 않은 사용자입니다.';
redirectUrl = '/auth';
}
여기서는 다양한 커스텀 에러를 정의해요.
기본적인 404, 401, 403을 만들어 두었는데 상황에 따라 BE와 논의하여 다양한 에러가 추가될 수 있겠지요.
이제 api를 직접적으로 호출하는 로직에서 에러가 발생하면 발생한 에러에 맞는 커스텀 에러를 throw 해줘야 해요.
export const createApiError = (response: Response) => {
switch (response.status) {
case 404:
return new NotFoundError();
case 403:
return new ForbiddenError();
case 401:
return new AuthError();
default:
throw new ApiError(
`HTTP 오류: ${response.status} ${response.statusText}`,
);
}
};
export const RequestGetMethod = async (addr: string, params?: any) => {
try {
const queryString = new URLSearchParams(params).toString();
const reqURL = `${addr}?${queryString}`;
const response = await fetch(reqURL, {
method: 'GET',
});
if (!response.ok) {
throw createApiError(response);
}
return await response.json();
} catch (error) {
throw error;
}
};
저는 fetch를 활용하여 api를 호출했는데 어떤 것이든 상관없어요.
앞에서 말한 대로 api를 요청하고 그에 대한 결과물이 성공이 아니라면 에러를 발생시켜야 하죠.
에러를 발생시키는 로직은 createApiError함수로 분리했는데요.
fetch의 response를 받은 뒤 status에 따라 아까 정의한 custom error를 throw 해주죠.
이렇게 api를 호출하는 메서드의 역할은 끝이 나게 됩니다.
이제는 실제적으로 서버에서 api를 호출하고 에러를 핸들링 하는 과정을 다뤄볼게요.
앞서 이야기 한대로 서버에서 getServerSideProps , getStaticProps, getStaticPaths 를 활용하여 api를 요청해요.
저는 getServerSideProps 로 예시를 들어볼게요.
만약 공통의 에러 처리가 없다면 api를 요청을 하는 과정마다 try-catch문을 작성하여 에러를 처리해야 할 것이에요.
export const getServerSideProps: GetServerSideProps = async context => {
const {
req: { cookies },
query,
} = context;
try {
const res: DiagnosticsServerProps = await RequestGetMethod(TEST, {
session_id: cookies.uuid,
});
} catch (e) {
// ...
}
return {
props: {
// ....
},
};
};
아마 이런 형태를 띠고 있겠죠.
강조되고 반복되는 코드는 강아지..아니아니 개발자를 불안하게 해요.

그러나 수많은 요청들을 try-catch로 묶고 있을 생각을 해본다면…아찔함이 먼저 다가올 거예요
만약 에러가 수정이 생긴다면… 깜빡하고 에러 처리 요소 하나를 빠트린다면…
그래서 우리는 앞서서 에러를 미리 정의하고 api를 호출하는 로직에서 error가 있다면 그것을 throw하는 로직을 만들어주었죠. 그 로직을 이제 getServerSideProps에서 처리할 수 있게 해주면 되는 것이죠.
이 과정에서 HOC 패턴을 사용해 볼게요 (해당 패턴이 어색하신 분들은 이 글을 읽어보시면 도움이 될 것이에요)
// 에러를 처리할 higher order component
import { GetServerSideProps, GetServerSidePropsContext } from 'next';
import { isInstanceOfAPIError } from '../types/error';
function withGetServerSideProps(
getServerSideProps: GetServerSideProps,
): GetServerSideProps {
return async (context: GetServerSidePropsContext) => {
try {
return await getServerSideProps(context);
} catch (error) {
if (isInstanceOfAPIError(error)) {
const { redirectUrl, notFound } = error;
if (notFound) {
return {
notFound: true,
};
}
return {
redirect: {
destination: redirectUrl,
permanent: false,
},
};
}
console.error('unhandled error', error);
throw error;
}
};
}
export default withGetServerSideProps;
getServerSideProps를 평소처럼 실행하고 만약 이것에서 apiError가 잡힌다면 isInstanceOfAPIError를 통해 에러 정보를 가져와 원하는 url로 redirect 합니다.
이렇게 hoc를 만들고 나서는 이제 직접 감싸주기만 하면 됩니다.
export const getServerSideProps: GetServerSideProps = withGetServerSideProps(
async context => {
const {
req: { cookies },
query,
} = context;
const res: DiagnosticsServerProps = await RequestGetMethod(ML_ADDRS, {
session_id: cookies.uuid,
});
return {
props: {
// ...
},
};
},
);
그러면 이런 모습이 될 것이에요!
try-catch문이 없어지고 공통적으로 나타나는 error는 withGetServerSideProps 에서 redirect가 가능해지죠.
이후 클라이언트에서 다시 get을 해와야 하거나 post를 보내거나 api를 보내는 경우가 분명 있을 것이에요.
그럴 때는 ErrorBoundary를 사용하여 에러를 처리하면 됩니다.
api를 요청하는 과정에서 에러가 발생하면 error를 throw 하는 로직을 만들어 두었죠. 이것을 클라이언트에서 캐치하고 처리해 줄 수 있는 장치만 있으면 될 텐데 이것을 ErrorBoundary로 사용하는 것입니다.
import { isInstanceOfAPIError } from '@/src/types/error';
import Router from 'next/router';
import React from 'react';
import Page404 from '@/src/pages/404';
type ErrorBoundaryProps = React.PropsWithChildren<{}>;
interface ErrorBoundaryState {
error: Error | null;
}
const errorBoundaryState: ErrorBoundaryState = {
error: null,
};
export default class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = errorBoundaryState;
}
static getDerivedStateFromError(error: Error) {
console.error(error);
return { error };
}
private resetState = () => {
this.setState(errorBoundaryState);
};
private setError = (error: Error) => {
console.error(error);
this.setState({ error });
};
// 전역 에러 중 캐치하지 못한 에러
private handleError = (event: ErrorEvent) => {
this.setError(event.error);
event.preventDefault?.();
};
// promise 중 캐치하지 못한 rejection
private handleRejectedPromise = (event: PromiseRejectionEvent) => {
event?.promise?.catch?.(this.setError);
event.preventDefault?.();
};
componentDidMount() {
window.addEventListener('error', this.handleError);
window.addEventListener('unhandledrejection', this.handleRejectedPromise);
Router.events.on('routeChangeStart', this.resetState);
}
componentWillUnmount() {
window.removeEventListener('error', this.handleError);
window.removeEventListener(
'unhandledrejection',
this.handleRejectedPromise,
);
Router.events.off('routeChangeStart', this.resetState);
}
render() {
const { error } = this.state;
if (isInstanceOfAPIError(error)) {
const { redirectUrl, notFound } = error;
if (notFound) {
return <Page404 />;
}
if (redirectUrl) {
window.location.href = redirectUrl;
}
}
console.log('unhandled client error', error);
return this.props.children;
}
}
이제 ErrorBoundary를 상위에 걸어둔 뒤, 클라이언트에서 api 요청에 error가 발생한다면 throw된 error가 ErrorBoundary에서 처리되는 것을 확인할 수 있을 것이에요!
추가적으로 적절한 에러에 대한 action을 미리 생각해서 사용자에게 제공한다면 에러를 마주해도 사용자는 해결할 수 있는 힌트를 얻게 될 것이에요.
예시로

404 error를 마주했다면 사용자에게 "페이지를 찾을 수 없다"는 문구와 함께 홈으로 이동시키는 액션을 제시할 수 있겠죠.
서비스를 운영함에 있어서는 사용자와의 신뢰도가 가장 중요하다고 생각을 해요. 꼼꼼한 에러처리를 통해 혹시라도 생기는 에러에 사용자에게는 대처법을 알려주고 그런 상황이 반복되지 않도록 수정하고 보수해나가는 것이 서비스를 개발과 유지보수하는 것에 가장 중요한 점이라고 생각합니다.
page Router를 쓰시는 중 에러 핸들링을 설계 하시는 분이 있다면 이글이 도움이 되었으면 합니다!