예외 처리를 설계하며 고민한 내용들을 간략하게 정리해 보았다. 프로젝트마다 필요한 예외 처리가 다르고, 구현 방식도 여러 가지가 있기에 개발의 많은 부분이 그렇듯 정답이 없는 것 같다. 나중에 시간이 흐른 뒤 이 포스팅을 보면 분명 '왜 이렇게 했지?'라고 생각할 것 같다. 😇 하지만 같은 고민을 하는 누군가에게 조금이나마 도움이 될 수 있기를 바란다.
1. 에러의 분류
먼저 위의 도식을 참고하여 프로젝트 상황에 맞게 에러를 분류해 보았다. 사용자에게 에러가 발생한 이유를 설명하고 사용자의 액션을 유도하는 장치를 제공하는 것을 예외 처리의 핵심으로 두었다.
1. 예상 가능한 에러
a. 사용자 입력 값에 대한 에러
- 에러 발생 상황 예시: 로그인 시 틀린 비밀번호를 입력하거나 회원가입시 중복 아이디 또는 중복 이메일을 입력
- 대응 방안: 사용자에게 입력 값이 잘못된 이유에 대해 설명해주고, 어떤 액션을 취해야 하는지 알려준다.
- 안내 방식: toast message
b. 잘못된 접근: 404 Not Found
- 에러 발생 상황 예시: 사용자가 /Model/[id] 와 같은 동적 라우팅 페이지에 접근할 때 존재하지 않거나 삭제된 id를 가진 페이지를 조회하는 경우
- 대응 방안: 페이지가 존재하지 않는다는 안내 문구와 '뒤로가기' 버튼 제공
c. 권한 미달: 401 Unauthorized 또는 403 Forbidden
- 에러 발생 생황 예시:
로그아웃 후 브라우저의 뒤로가기 버튼을 눌러서 서비스 페이지에 되돌아 왔을 때 (401 에러)
유저 타입에 따라 접근 권한이 다른데, 접근 권한이 없는 경로로 접근했을 때 (403 에러)
- 대응 방안: 접근 권한이 없다는 안내 문구와 '로그인' 버튼 제공
2. 예상 가능하지 않은 에러 - 네트워크 에러
- 에러 발생 상황 예시: 네트워크 장애가 있을 때
- 대응 방안: 일시적으로 서비스에 접속할 수 없단느 안내 문구와 '새로고침' 버튼 제공
이렇게 정리하고 나니 구현해야 하는 것이 크게 두 가지로 분류되었다.
1) 사용자 입력 값에 대한 에러
사용자 입력 값을 백엔드에 전달하고 이에 대한 응답을 받기 전까지는 프론트엔드에서 에러 여부에 대해 알 수 없다. 따라서 백엔드와 모종의 약속을 정한 다음, 약속된 코드에 따라서 사용자에게 적절한 토스트 메시지를 보여주어야 한다.
2) 잘못된 접근, 권한 미달 및 예상 가능하지 않은 네트워크 에러
사용자에게 보여줘야 하는 화면이 동일하고, 안내 메시지와 액션 버튼만 다르게 동작하면 된다. 이때, API 요청에 대한 응답의 결과로 알 수 있는 경우와 이미 프론트에서 가진 정보로 처리할 수 있는 경우 두 가지로 나뉜다. 전자의 경우는 에러 바운더리의 fallback UI를 사용했고, 후자의 경우는 <Checker> 컴포넌트를 만들었다.
각각의 경우의 구현 방법에 대해 조금 더 자세히 설명해 보겠다.
2. 예외 처리 구현 방법
2-1. 사용자에게 토스트 메시지로 알림
제일 먼저 백엔드와 커스텀 에러 코드를 정한 다음, 커스텀 에러 코드와 사용자에게 에러의 이유와 다음 행동 안내를 유도할 메시지를 매핑했다.
const API_ERROR_MESSAGE: { [errorCode: string]: string } = {
INCORRECT_PASSWORD: '비밀번호가 일치하지 않습니다.',
DUPLICATED_ID: '이미 등록된 아이디입니다.',
DUPLICATED_EMAIL: '이미 등록된 이메일입니다.',
} as const;
에러가 사전에 약속한 커스텀 에러 코드에 해당하는 경우, mutation 함수의 catch 절에서 InvalidCredentialsException 에러를 던진다.
// InvalideCredentailsException.ts
// cause 속성은 에러 객체를 직접 핸들링 해야 하는 경우, 에러 객체를 그대로 넘겨주기 위한 필드
import type { AxiosError } from 'axios';
import type { ApiErrorResponseData } from '@/types';
export class InvalidCredentialsException extends Error {
cause?: AxiosError<ApiErrorResponseData>;
constructor(message: string, cause?: AxiosError<ApiErrorResponseData>) {
super(message);
this.cause = cause;
}
}
// 로그인 mutation 함수
async postLogin({ username, password }: { username: string; password: string }): Promise<PostLogin> {
return this.axiosInstance
.post('/log-in', {
username,
password,
})
.then((res) => {
return res.data;
})
.catch((error: AxiosError<ApiErrorResponseData>) => {
if (error.response) {
if (isAxiosErrorWithCustomErrorCode(error)) {
const errorCode = error.response?.data.errors[0].code;
const errorMessage = API_ERROR_MESSAGE[errorCode];
throw new InvalidCredentialsException(errorMessage, error);
} else {
console.error('Unexpected server response format:', error.response?.data);
throw new Error('Unknown server response format');
}
} else {
throw new Error('Unknown error:' + error.message);
}
});
}
postLogin을 mutationFn으로 호출하는 커스텀 훅에서 useMutation의 제네릭으로 에러 타입을 InvalidCredentialsException으로 지정해준다. 이렇게 제네릭으로 에러 타입 지정을 해줘야 커스텀 훅을 호출하는 로컬 컴포넌트에서 onError 콜백의 인자로 InvalidCredentialsException 에러 객체를 받을 수 있다.
// useMutation을 사용하는 커스텀 훅
interface LoginCredentials {
username: string;
password: string;
}
export const useHandleLogin = () => {
const setAccount = useSetRecoilState(accountAtom);
const { mutate: handleLogin } = useMutation<PostLogin, InvalidCredentialsException, LoginCredentials>({
mutationFn: (loginCredentials: LoginCredentials) => authAPI.postLogin(loginCredentials),
onSuccess: ({ user }: PostLogin) => {
setAccount({
isLoggedIn: true,
...user,
});
},
});
return handleLogin;
};
마지막으로 useHandleLogin 커스텀 훅을 호출하는 컴포넌트의 onError 콜백에서 토스트 메시지로 사용자에게 알림을 준다. 커스텀 훅의 onError에서 처리할 수도 있지만 커스텀 훅에서는 비지니스 로직을, 컴포넌트에서는 뷰를 담당하도록 코드를 구현했기 때문에 토스트 알람의 경우 뷰에 해당하는 부분이라고 판단해 컴포넌트에서 처리했다.
function SignInForm() {
const handleLogin = useHandleLogin();
const { message } = App.useApp();
const onFinish = (loginCredentials: LoginCredentials) => {
handleLogin(loginCredentials, {
onError: (error: InvalidCredentialsException) => {
message.error(error.message, 10);
},
});
};
// 생략
}
export default SignInForm;
이렇게 예외 처리를 하면서 고민했던 부분은 catch 블록 안에서 해야 하는 일이 뭔지에 대한 답을 찾는 작업이었고, 향로 님의 블로그에서 좋은 예외(Exception) 처리에 대한 포스팅을 보면서 다음의 네 가지로 정리할 수 있었다.
- 새로운 네트워크 요청 보내기
- 사용자에게 대안 제안하기
- 로깅 장치에 에러 정보 보내기
- Layer에 적합한 Exception 변환
사용자에게 제안하는 대안은 toast 알림 메시지로 컴포넌트 단에서 구현을 해야 했기 때문에 이 경우에는 layer에 적합한 exception 변환으로, 예외 처리를 위한 별도의 클래스 만들어 커스텀 에러 코드에 해당하는 경우 해당 클래스로 감싼 예외를 던지는 방식으로 구현했다.
다른 하나는 useMutaton hook을 쓰면서 컴포넌트 단에서 onError 옵션을 사용할 때 타입 에러가 뜨는 것이었는데 위에 언급했던 대로 useMutation 제네릭의 두 번째 인자로 에러 타입을 지정해주면 되었다. useMutation 뿐 아니라 useQuery를 사용할 때도 제네릭을 써줘야만 타입 에러를 해결할 수 있는 부분이 많았다.
2-2. <Checker> 컴포넌트로 Unauthorized401 페이지나 Forbidden403 페이지 보여주기
위의 에러 분류에서 1.c에 해당하는 경우로 예외 처리를 해야 하는 상황을 다시 한 번 복기해 보면 아래와 같다.
권한 미달: 401 Unauthorized 또는 403 Forbidden
- 에러 발생 생황 예시:
로그아웃 후 브라우저의 뒤로가기 버튼을 눌러서 서비스 페이지에 되돌아 왔을 때 (401 에러)
유저 타입에 따라 접근 권한이 다른데, 접근 권한이 없는 경로로 접근했을 때 (403 에러)
여기에 대해서는 서비스에 대한 설명이 조금 필요할 수 있을 텐데, 현재 프로젝트는 MVP 단계로 정식 랜딩 페이지가 없다. 브라우저 주소창에 도메인 주소를 입력하고 들어왔을 때 바로 로그인 페이지로 이동하게 되어 있다. 즉 모든 서비스가 로그인을 해야만 이용할 수 있다. 따라서 로그아웃을 했을 때에도 로그아웃 성공 후 로그인 페이지로 리다이렉션을 하게 되는데, 이때 브라우저의 뒤로가기 버튼을 누르면 로그아웃을 하기 전 마지막으로 있었던 페이지로 돌아가게 되는데 로그인이 되어 있지 않으면 서비스 일체를 사용할 수 없기 때문에 이 경우 사용자에게 로그인되어 있지 않다고 알려주고 로그인 페이지로 돌아갈 수 있는 버튼이 필요했다.
또다른 특징으로 유저 타입이 매니저와 일반 회원으로 나뉘어져 있는 서비스라 각각의 접근 권한이 다르다는 점이다. 일반적인 네비게이션 상황에서는 각각의 권한에 맞는 페이지에만 접근할 수 있지만, 주소창에 직접 path를 입력하는 경우 로그인한 계정에 허가되지 않은 페이지로 접근할 때에 대한 예외 처리가 필요했다.
이 두 가지 경우에는 로그인 이후에 로그인 여부와 유저 타입을 비롯한 사용자 정보를 account라는 전역 변수로 관리하고 있기 때문에 백엔드에 API 요청을 하지 않아도 프론트엔드 단에서 처리를 할 수 있었다.
구체적인 구현 방식은 다음과 같다. 먼저 라우터의 최상위 컴포넌트를 다음과 같이 LoginChecker와 PermissionChecker(ManagerChecker 또는 MemberChecker)로 감싼다.
// 매니저
<LoginChecker>
<ManagerChecker>
<Outlet />
</ManagerChecker>
</LoginChecker>
// 일반회원
<LoginChecker>
<MemberChecker>
<Outlet />
</MemberChecker>
</LoginChecker>
각각의 체커 컴포넌트에서는 다음과 같은 일을 한다.
// LoginChecker.tsx
import Unauthorized401 from '@/pages/error/Unauthorized401';
type Props = {
children: React.ReactNode;
};
export default function LoginChecker({ children }: Props) {
const [account, setAccount] = useRecoilState(accountAtom);
return account && account.isLoggedIn ? <>{children}</> : <Unauthorized401 />;
}
// ManagerChecker.tsx
import Forbidden403 from '@/pages/error/Forbidden403';
type Props = {
children: React.ReactNode;
};
export default function ManagerChecker({ children }: Props) {
const { type } = useRecoilValue(accountAtom);
const isManager = type === USER_TYPE.MANAGER;
return isManager ? <>{children}</> : <Forbidden403 />;
}
2-3. ErrorBoundary를 사용해 fallback UI 보여주기
에러 바운더리는 기본적으로 하위 컴포넌트 트리에서 발생한 런타임 에러를 포착하지만, react-query를 사용해서 네트워크 응답에 대한 에러를 핸들링할 수 있다.
가령 사용자가 /Model/[id] 와 같은 동적 라우팅 페이지에 접근할 때 존재하지 않거나 삭제된 id를 가진 페이지를 조회하는 경우에 대한 에러 발생 시 사용자에게 fallback UI를 보여줄 수 있다.
1) useQuery hook의 useErrorBoundary를 true로 설정한다.
2) useQuery hook을 호출한 컴포넌트의 상위 컴포넌트(ParentComponent)에서 ErrorBoundary로 감싸야 한다.
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import ErrorFallback from '@/components/ErrorFallback';
export default function ParentComponent() {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
<ChildComponent />
</ErrorBoundary>
);
}
function ChildComponent() {
const { data, isLoading } = useQuery({
queryKey: ['child-query-key'],
queryFn: () => fetcher(),
retry: 0,
useErrorBoundary: true
});
return (
// 생략
);
}
📚 참고 자료
'돌멩이 하나 > 에러는 미래의 연봉' 카테고리의 다른 글
Checkbox 컴포넌트를 만들면서 알게 된 것들 (0) | 2024.05.22 |
---|---|
React Query를 활용한 테이블에서 데이터 정렬하기 (0) | 2024.03.21 |
React의 key에 index를 사용하면 안 되는 이유 (feat. 무한스크롤) (0) | 2024.02.25 |
ant design과 tailwind css 충돌 이슈 해결 (0) | 2023.09.04 |
styled-components에서 custom props 사용하기 (0) | 2023.07.28 |