하루스터디 서비스를 개발하고 운영하면서 다음과 같은 사용자 피드백이 종종 들어오곤 합니다.
- 버튼을 눌렀는데 갑자기 알 수 없는 에러라는 alert이 떠요.
- 어떤 기능을 사용하려고 하는데 무한 로딩에 빠져요.
이러한 사용자 피드백은 서비스의 에러 핸들링이 제대로 되어있지 않기 때문에 발생하는 문제였습니다. 보다 나은 사용자 경험을 위해, 그리고 안정적인 서비스 운영을 위해 견고한 에러 핸들링이 필요했습니다.
대부분의 에러는 API 통신 중에 발생하는 에러이고, 이 API 통신에 대해 빈틈없고 단단한 에러 핸들링이 필요했습니다. 이러한 API 에러 핸들링을 잘 다루기 위해 하루스터디 서비스에선 어떤 고민을 했고, 또 어떻게 처리 했는지 공유하려고 합니다.
기존의 에러 핸들링 방식은 대부분 다음과 같이 처리해줬습니다.
// 컴포넌트 내부 로직
const { ..., isError } = useFetch("/api/nickname");
if(isError) {
alert("올바르지 않은 닉네임입니다.");
}
...
하루스터디에서는 자체 개발한 비동기 처리 커스텀훅인 useFetch
를 사용하고 있는데, useFetch
에서 반환하는 isError
의 값이 true
면 alert을 통해 메시지를 사용자에게 알려주었습니다.
위의 예시 코드에는 다음과 같은 문제점이 있습니다.
발생하는 에러가
닉네임 검증 에러
가 아닌,네트워크 에러
,서버 내부 에러
등 다양한 에러 상황이 있을 수 있지만 닉네임 검증 에러라고 뭉뚱 그려서 처리합니다. 이는 사용자에게 잘못된 피드백을 전달하게 됩니다.중복되는 코드가 많습니다. 매번 에러가 발생할 컴포넌트마다 일일이
isError
분기 처리를 해줘야 하고, 이는 에러가 어디서 처리되는지 잘 알지 못해 유지 보수하기 어렵습니다.
이렇듯 기존에는 에러 핸들링이 매우 허술했고, 체계적인 에러 핸들링 방식이 필요했습니다.
하루스터디 프로젝트에서는 서버에서 에러가 발생할 경우, 에러 코드와 에러 메시지를 Response의 body에 담아서 보내주게됩니다.
// 에러 응답값 예시
{
code:1300,
message:"참여 코드가 만료되었거나 존재하지 않습니다."
}
기존에는 이러한 에러 코드와 메시지를 제대로 활용하지 못했는데, 바뀐 에러 구조에서는 이를 쉽게 다룰 수 있는 구조로 바꾸려고 합니다.
위와 같은 API 에러를 catch 하는 영역에서 효율적으로 잡아낼 수 있도록 커스텀 에러를 구현했습니다.
export class ApiError extends Error {
code: number;
config: RequestInit;
constructor(message: string, code: number, config: RequestInit = {}) {
super(message);
this.name = 'API Error';
this.code = code;
this.config = config;
}
}
export class UnknownApiError extends Error {
config: RequestInit;
constructor(config: RequestInit = {}) {
super('서버 요청에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.');
this.name = 'Unknown API Error';
this.config = config;
}
}
위에서 언급한 {code: number, message: string}
형식의 API 에러의 경우, ApiError
라는 커스텀 에러를 사용하고, 그 외의 네트워크 에러, 500번대 에러 등 예상하지 못한 에러의 경우는 UnknownApiError
라는 커스텀 에러를 사용하게 됩니다.
그렇다면, 이 커스텀 에러를 어떤식으로 throw 해줄 수 있을까요?
하루스터디에서는 자체 개발한 http
인스턴스를 활용해 서버 요청을 관리하고 있습니다. http
인스턴스에는 각 요청과 응답 상황을 가로챌수 있는 interceptor
기능이 존재하는데, 이 기능을 활용해 다음과 같이 에러를 throw 해줄 수 있습니다.
(axios
에도 interceptor
기능이 있으므로 유사하게 구현할 수 있습니다.)
http.registerInterceptor({
onRequestError: (error) => {
throw new UnknownError();
},
onResponse: (response) => {
if (response.ok) return response;
if (isApiErrorData(response.data)) {
throw new ApiError(response.data.message, response.data.code, response.config);
}
throw new UnknownApiError(response.config);
},
});
onRequestError
는 요청 중간에 발생하는 에러(네트워크 에러 등)를 가로챌 수 있는 인터셉터 함수입니다. onRequestError
함수 내부에서 UnknownError
를 throw 해줍니다.
onResponse
는 서버 응답을 가로채는 인터셉터 함수입니다. http
는 fetch API를 기반으로 하고 있기 때문에, response
의 ok
필드를 통해 정상적인 200번대 응답인지, 그 외의 에러 응답인지 구별해야 합니다.
response
가 정상적인 응답(ok === true
)라면 response
를 바로 반환 시켜주고, 에러가 있는 응답(ok === false
)이라면 API 에러 형식인지 검증합니다. API 에러 형식이 맞다면 ApiError
를 throw, API 에러 형식이 아니라면(서버 내부 에러 등) UnknwonApiError
를 throw합니다.
위와 같은 방식으로 던진 에러는 어떻게 잡아서 처리해줄수 있을까요? 저희 팀은 ErrorBoundary
를 활용하기로 했습니다. ErrorBoundary
를 통한 에러 처리는 다음과 같은 장점들이 있습니다.
- 컴포넌트 내부에서 에러 처리해주는 것이 아닌, 외부에서 에러 처리가 가능하므로, 보다 선언적인 에러 처리가 가능합니다.
- 하위 컴포넌트에서 던져진 에러를 모두 받아서 처리하므로 예상하지 못한 에러들에 대해서도 쉽게 추상화하여 처리해줄 수 있습니다.
ErrorBoundary의 경우 fallback UI를 보여주는 일반적인 ErrorBoundary와 알림 Toast UI를 표시하는 NotificationBoundary로 나누어 개발했습니다. 두 가지 버전의 ErrorBoundary를 도입한 이유는 각 상황에 맞게 적절한 UI를 제공하여 사용자 경험을 향상시키기 위함입니다.
다음은 각 ErrorBoundary의 코드를 살펴보고, 추가적으로 ErrorBoundary로 처리할 수 없는 에러는 어떻게 처리하는지도 알아보겠습니다.
class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> {
state: ErrorBoundaryState = {
error: null,
};
resetErrorBoundary() {
this.setState({ error: null });
};
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
const { children, fallback: Fallback } = this.props;
const { error } = this.state;
if (error) {
return <Fallback error={error} resetErrorBoundary={this.resetErrorBoundary} />;
}
return children;
}
}
위 코드는 ErrorBoundary 공식 문서의 코드를 약간 변경한 코드로, 공식 문서 코드에서 필요없는 부분은 제외하고, error reset 로직을 추가해줬습니다.
ErrorBoundary는 보다시피 클래스 컴포넌트로 작성해야 합니다. 그 이유는 클래스 컴포넌트만이 갖고 있는 라이프사이클 메소드인 getDerivedStateFromError
메소드 때문인데요. getDerivedStateFromError
메소드는 던져진 에러를 잡고 상태를 업데이트 할 수 있는 메소드입니다.
ErrorBoundary는 error state를 가지고 있고, getDerivedStateFromError
메소드에서 에러를 감지하면 error state를 변경시켜줍니다. render
메소드에서는 error state를 확인 후 에러가 발생했다면 프롭스로 받은 Fallback 컴포넌트를 렌더링하고, 에러가 발생하지 않았다면 children을 렌더링합니다. 여기서 resetErrorBoundary
라는 메소드를 새롭게 만들어 줬는데, 이 메소드는 에러를 초기화 시켜주는 역할을 합니다.
에러가 발생했을 때 렌더링 하는 Fallback 컴포넌트의 코드는 다음과 같습니다.
const ErrorFallback = ({ error, resetErrorBoundary }: ErrorFallbackProps) => {
let errorMessage = '알 수 없는 에러가 발생했습니다.';
if (error instanceof ApiError) {
errorMessage = error.message;
}
if (error instanceof UnknownApiError) {
errorMessage = '서버에 문제가 발생했습니다.';
}
return (
<div>
<h3>문제가 발생했습니다.</h3>
<p>{errorMessage}</p>
<button onClick={resetErrorBoundary}>
다시 시도하기
</button>
// react-router-dom 라이브러리의 Link 컴포넌트
<Link to={ROUTES_PATH.landing} onClick={resetErrorBoundary}>
홈으로 이동하기
</Link>
</div>
);
};
Fallback 컴포넌트에서는 에러 형식에 따라 사용자에게 적절한 메시지를 보여줍니다. 또한, ‘다시 시도하기’ 혹은 ‘홈으로 이동하기’ 버튼을 통해 사용자가 에러를 보고 적절히 액션을 취할수 있도록 했습니다.
class NotificationBoundary extends Component<PropsWithChildren, NotificationBoundaryState> {
state: NotificationBoundaryState = {
error: null,
};
static getDerivedStateFromError(error: Error) {
return { error: error };
}
static contextType = NotificationContext;
componentDidCatch(error: Error): void {
const { send } = this.context as NotificationContextType;
send({
type: 'error',
message: error.message,
});
}
render() {
const { children } = this.props;
return children;
}
}
ErrorBoundary 컴포넌트와 유사하지만, 에러 발생시 fallback 컴포넌트를 렌더링하는 것이 아닌, Toast UI를 띄우게 됩니다. 하루스터디에서는 Notification
이라는 Toast UI 컴포넌트가 존재합니다. 이 컴포넌트를 활용하여 에러 발생시 에러 메시지 내용을 Toast UI로 표시하도록 했습니다.
화면에 표시할 데이터를 불러올 때(GET)와 같은 상황의 에러의 경우, fallback 화면을 보여주는 기본 ErrorBoundary가 적절합니다. 그러나 어떠한 입력 값을 버튼을 눌러 전송 할 때(POST, PATCH 등)와 같은 상황의 에러의 경우, fallback 화면을 보여주는 것은 오히려 사용자 경험을 해칠 수 있습니다. 이러한 경우 기존 화면은 유지 시키고 에러 메시지만 Toast UI로 보여주는 것이 사용자 경험 측면에서 더 나은 방안이라고 생각했기 때문에 NotificationBoundary
라는 에러 바운더리를 별도로 만들게 되었습니다.
NotificationBoundary
에서 잡은 에러는 다음과 같이 화면에 보여지게 됩니다.
에러는 크게 예측할 수 있는 에러와 예측할 수 없는 에러로 나뉩니다. 하루스터디에서는 ApiError
라는 커스텀 에러들이 예측 가능한 에러이고, UnknownApiError
커스텀 에러 혹은 네트워크 에러 등 ApiError
외의 모든 에러는 예측할 수 없는 에러입니다.
예측 가능한 에러는 해당 에러가 발생할 컴포넌트에 ErrorBoundary를 감싸서 에러 대응을 해줄 수 있습니다.
<ErrorBoundary>
<Component/>
</ErrorBoundary>
하지만 예측할 수 없는 에러의 경우에는 어디에서 에러가 발생할지 알 수 없습니다. 그렇기 때문에 최상단 App 컴포넌트에 ErrorBoundary를 감싸서 예측하지 못하는 에러가 세어나가지 못하도록 방지했습니다.
// App.tsx
const App = () => {
return (
<ErrorBoundary>
// 하위 컴포넌트
</ErrorBoundary>
)
}
ErrorBoundary를 활용하니 기존보다 훨씬 간결하면서 안정적인 에러 처리를 할 수 있게되었습니다. 그러나 fallback UI를 보여주거나, Toast UI를 띄우는 것 외의 에러 처리를 해줘야 하는 상황도 종종 있습니다. 예를 들어 다음과 같은 상황이 있습니다.
이와 같은 상황은 ErrorBoundary로는 처리하기 애매한 도메인 종속 로직입니다. 이러한 경우는 하루스터디에서 사용하는 API 비동기 처리 커스텀 훅인 useFetch/useMutation
의 onError
옵션을 사용해 예외적으로 처리해주었습니다.
(useFetch/useMutation
커스텀 훅은 tanstack-query
라이브러리의 useQuery/useMutation
훅으로 대체 가능합니다.)
스터디 참여 요청시 필요한 에러 처리 로직을 useMutation
의 onError
로 구현한 코드는 다음과 같습니다.
const { mutate } = useMutation(requestParticipateStudy, {
onError: (error) => {
if (isApiErrorData(error) && error.code === DUPLICATE_PARTICIPATION_CODE) {
// 스터디 재참여 선택지를 화면에 띄우는 로직
}
}
})
하루스터디에서 대부분의 API 요청은 useFetch/useMutation
커스텀 훅을 통해 사용됩니다. 그러므로 위와 같이 특수한 에러 처리의 경우 onError
옵션으로 컴포넌트 내부에서 쉽게 처리할 수 있습니다.
에러 핸들링은 사용자 경험 측면에서 중요하지만, 기능 구현에 우선 순위가 밀려 소홀하게 관리되는 경우가 자주 있습니다. 저의 경우에서도 이전 프로젝트에서는 에러를 console.error
로 처리하거나 alert
으로만 대응해왔었습니다. 이러한 미흡한 에러 처리는 애플리케이션의 전체적인 완성도를 저해하고 사용자 경험을 떨어뜨리는 요인이었습니다.
이러한 문제점을 인식 후, 하루스터디 프로젝트에서는 커스텀 에러와 ErrorBoundary라는 개념을 도입해 추상적이고 선언적인 에러 핸들링을 할 수 있도록 해줬습니다. 이로 인해 사용자 입장에선 더 나은 경험을 제공하게되는 것은 물론, 개발자 입장에서도 간단하게 에러 처리를 할 수 있게되면서 사용자 경험과 개발자 경험 두마리 토끼를 모두 잡을 수 있게되었습니다.