점차 한 프로덕트를 만들어갈 수록 중요하다고 생각되는 점이 크게 두 가지 있다.
-
첫 번째는 테스트코드이다.
TDD를 하고 있진 않는데, 이런 상황이 생기면 꼭 테스트코드를 작성하려고 한다.
한 에러가 발생했는데, 이 에러가 어디서 발생했는지, 왜 발생했는지, 그리고 여러번 이 에러가 발생해서 수정해줬어야했을 때, 이럴 땐 꼭 테스트코드를 작성하려고 한다.
-
두 번째는, 에러 분기처리다.
기존에는 console.error로 로깅만 했었는데, 문구도 network is not working 정도로만 작성했다.
status
나message
를 따로 처리하지 않고 그저 string으로만 처리하는 상황이었다. 그 후, 같은 에러 문구가 여러 곳에서 뜨니 어디가 어딘지 알 수가 없었다. 그래서 에러를 한 번 정리해줄 필요성이 있었고, 이 글을 쓰게 됐다.
먼저 현재 사용하고 있는 기술스택은 다음과 같다
- next.js, app dir
- tanstack-query
- Errorboundary
먼저 프론트 에러처리부터 작성해보려고 한다.
onSuccess와 onError
다음의 글을 참고했다.
Status Checks in React Query
React Query Error Handling
[번역] React Query API의 의도된 중단
순서대로 읽는 걸 권하고 싶다.
맨 마지막 글을 읽으며, tanstack-query v5에선 onError
, onSuccess
, onSettled
가 없어졌다는 것을 알게 됐다.
사실 알게 된건 몇 달 전이었던 것 같은데, 문제에 직면하지 않아서
따로 찾아보지 않았다.
당시엔 server component
에 대한 문제를 해결하기 바빴다.
그래서 먼저, useQuery 내에서 onSuccess와 onError를 제거해주었다.
// useRecordGetQuery.ts
const getMessage = async (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const encodedEmail = encodeURIComponent(userEmail || '');
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/record/${encodedEmail}`,
{
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
throw new Error('메세지를 가져오는 getMessage에서 오류가 발생했어요.');
}
return res.json();
};
export const useRecordGetQuery = (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const { data: messages = [] } = useQuery(
[...recordManagerKeys.record, userEmail],
() => getMessage(userEmail, token),
{
enabled: !!userEmail,
select: (data: Message[]) => data.map(decodeMessages),
},
);
return {
messages,
};
};
- 사실 여기선
select
를 사용하고 있는데, 이 또한onSuccess
와 유사하기 때문에onSuccess
라고 생각하고 작성한다. - 참고로 useQuery 내에 callback만 제거된다. useMutation에선 콜백에 여전히 존재한다.
- useMutation에서 onSuccess는 invalidateQueries를 무효화할 때 필수적이기 때문에 제거하면, tanstack-query를 사용할 이유가 많이 사라질 것이라고 개인적으로 생각한다..!
먼저 useQuery 내에서 Error부터 처리해주려고한다.
fetch API를 사용하고 있어서, if(!res.ok)
와 같이 따로 분기처리를 해주어야한다.
// useRecordGetQuery.ts
class FetchError extends Error {
constructor(public message: string, public status: number) {
super(message);
}
}
const getMessage = async (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const encodedEmail = encodeURIComponent(userEmail || '');
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/record/${encodedEmail}`,
{
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
// 에러 처리는 다음과 같이 수정했다. api 서버에서 에러를 message와 status를 보내주도록 수정했다.
// 그리고 throw new FetchError로 에러를 던져주었다.
const { message, status } = await res.json();
throw new FetchError(message, status);
}
const data = await res.json()
const decodedData = data.map(decodeMessages) // 여기서 select 부분을 제거할 수 있다.
return decodedData;
};
export const useRecordGetQuery = (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const { data: messages = [] } = useQuery(
[...recordManagerKeys.record, userEmail],
() => getMessage(userEmail, token),
{
enabled: !!userEmail,
},
);
return {
messages,
};
};
- 위와 같이 수정해서 onSuccess와 onError를 제거해주었다.
- 여기서 onError는 조금 더 알아보자.
tanstack-query global Error
-
위 글에서 React-query는 크게 세 가지로 에러를 처리할 수 있다고 제시한다.
- useQuery 내에서 오류 반환
- onError callback (query 자체에서 또는, QueryCache, MutationCache)
- ErrorBoundary로 처리
일단 여기서 나는, 1번은 내키지 않았다. onError가 없어지는 시점에선 meta 필드를 이용해서 Error를 처리할 수 있다.
하지만, 별도 에러를 분기처리하는 경우는 크게 없었고, 서버에서 status와 message를 온전히 QueryCache와, MutationCache에서 처리해주길 바랐다.
그래서 다음과 같이 구성했다.
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
const queryErrorHandler = (error: unknown) => {
if (error instanceof Error) {
toast.error(error.message);
}
return;
};
export const createQueryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.errorMessage) {
// 만약 meta field를 사용한다면 여기서 tracking 할 수 있다.
queryErrorHandler(query.meta?.errorMessage);
}
queryErrorHandler(error);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.onError) {
queryErrorHandler(mutation.options.onError);
}
queryErrorHandler(error);
},
}),
defaultOptions: {
queries: {
// ...
suspense: true, // 참고로 suspense를 true로 설정하면 useErrorBoundary도 true가 된다.
},
},
});
- 예전 유데미에서 제공하는 react-query 강의에선 defaultOptions에 onError를 설정해주었었다.
- 하지만, tkdodo 형님의 블로그를 확인하면, QueryCache와 MutationCache에 onError를 설정해야한다고 제시한다.
// TkDodo 형님의 블로그 댓글 중
No, the default options you provide are merged with the default options,
so unless you set refetchOnWindowFocus to false, it will stay on.
Further, this is not the way to provide a global onError handler.
As the article tried to explain,
you have to set onError on the QueryCache or the MutationCache for a truly global handler.
ErrorBoundary 사용하기
우리는 try…catch를 왜 사용할까? 사실 이 전까지 나는 try…catch가 단지 에어를 catch해 주는 용도로’만’ 존재하는줄 알았다.
하지만 try…catch로 감싸줬을 때, 에러가 떠도, 에러문을 catch에서 잡아주고, 애플리케이션은 계속 유지되도록 해준다.
즉, 사용자 경험을 훨씬 더 좋게 만들어주기 때문에 try…catch를 쓰는 것이었다. 쓰지 않는다면, 유저는 갑자기 에러문도 보고, 애플리케이션도 다운되어버린다.
근데 여기서 한 발 더 나아가, try..catch를 선언적으로 사용할 수 있게 해주는 ErrorBoundary가 존재한다.
하지만, 이는 오직 Class로만 제공해주고 있는데, react-error-boundary라는 라이브러리는 훨씬 더 ErrorBoundary를 편하게 사용할 수 있게 해준다.
나의 경우 app 디렉토리를 사용하고 있어서 다음과 같은 보일러플레이트 작업을 해주었다.
// components/common/ErrorBoundaryContext.tsx
'use client';
interface ErrorBoundaryContextProps {
children: React.ReactNode;
}
const logError = (error: Error, info: { componentStack: string }) => {
console.error('에러 정보를 로깅해요!', error, info);
};
const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
const { status, message } = error;
const onClickHandler = () => {
if (isNotAuthorized) {
return redirect('/');
}
return resetErrorBoundary();
};
return (
<div className="flex h-screen flex-col items-center justify-center bg-gray-100">
<div className="mb-4 text-6xl font-bold text-gray-500">404</div>
<button
className="rounded bg-yellow-400 px-4 py-2 text-white hover:bg-yellow-300 focus:border-blue-700 focus:outline-none focus:ring focus:ring-blue-200"
onClick={onClickHandler}
>
{buttonMessage}
</button>
</div>
);
};
const ErrorBoundaryContext = ({ children }: ErrorBoundaryContextProps) => {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
onReset={reset}
onError={logError}
FallbackComponent={ErrorFallback}
>
{children}
</ErrorBoundary>
);
};
export default ErrorBoundaryContext;
- 이를 layout 가장 바깥쪽에서 래핑해주었다.
// app/layout.tsx
import ErrorBoundaryContext from 'components/client/common/ErrorBoundaryContext';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body suppressHydrationWarning className={`${notoSansKR.className}`}>
<div id="modal" />
<ErrorBoundaryContext> // 감싸주었다.
<QueryContext>
<AuthContext>
<Toaster />
<ChakraContext>
<main>
{children}
</main>
</ChakraContext>
</AuthContext>
</QueryContext>
</ErrorBoundaryContext>
</body>
</html>
);
}
그리고 여기서 한 단계 더 나아가고 싶다.
에러 핸들링 분기처리하기
음… 400번대 에러는 보통 client error를 처리한다. 예전 인프런에서 모든 개발자를 위한 HTTP 웹 기본 지식을 수강할 때 간단히 정리해 놓은 것을 참고하면, 400번대는 클라이언트 에러, 500번대는 서버에러를 의미한다.
그래서 이를 조금 분기처리하고 싶었다.
-
클라이언트에러? → 이건 사용자가 알아야할 에러겠지? 이메일을 잘못입력 했을 수도 있고, 비밀번호가 틀릴 수도 있으니 말이다.
- 하지만, ErrorBoundary에서 처리하고 싶지 않다. ErrorBoundary에서 처리한다면 404 페이지 같은
FallbackComponent
가 실행되는데, 이는 사용자에게 알려주기에는 너무 무겁다. - 그래서 이 에러는 toast로만 사용자에게 알려주고 싶었다. 즉, 기존 페이지는 유지되었으면 한다.
- 예를 들어 record 페이지에서 메시지를 불러온 상태에서 userEmail이 없다고 가정해보면, userEmail 없다는 toast를 띄우고, record 페이지는 유지되길 바랐다.
- 또 이렇게 한 이유가, tanstack-query는 refetch가 빈번히 발생한다. 즉, stale 된 데이터라도, 유지된 상태에서, 다음 refresh 한 데이터의 요청이 실패했을 때, 404페이지로 이동하는게 아니라, stale한 데이터라도 보여주고, toast로 사용자에게 에러가 발생했음을 알려주고 싶었다.
- 하지만, ErrorBoundary에서 처리하고 싶지 않다. ErrorBoundary에서 처리한다면 404 페이지 같은
-
그럼 500번대 에러는? → 이것 또한 사용자에게 알려주어야한다.
- 하지만, 이는 서버쪽에서 발생했을 가능성이 높다. 예를 들어 서버가 다운되었을 경우가 있을 것 같다.
- 그럼 이때
FallbackComponent
가 실행되어 user에서 404페이지를 띄워주고 싶었고, 이를 위해 ErrorBoundary에서 처리하고 싶었다. - 그리고 404페이지에 마찬가지로 어떤 상태메시지 오류인지 알려주고 싶었다.
// queryClient
export const createQueryClient = new QueryClient({
// ...
defaultOptions: {
queries: {
suspense: true, // 여기
},
},
});
- 이를 위해 위에서도 잠깐 언급했지만, useErrorBoundary true가 되어있거나, suspense가 true가 되어있어야한다.
- 그리고 useQuery내에서 useErrorBoundary를 다음과 같이 처리해주었다.
// useRecordGetQuery.ts
export const useRecordGetQuery = (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const messages = useQuery({
queryKey: [...recordManagerKeys.record, userEmail],
queryFn: () => getMessage(userEmail, token),
useErrorBoundary: (error: any) => {
return error.status >= 500; // 500번대 이상 에러가 발생하면, ErrorBoundary에서 처리, 500 미만이라면, 위에서 보여준 toast로만 처리
},
});
return { messages };
};
- 참고로 ErrorBoundary에서는 비동기 함수 / 이벤트 리스너 / 서버사이드렌더링 / 에러바운더리 자체에서 발생하는 에러는 잡지 못한다.
- 그럼에도 불구하고 비동기 함수, 즉 tanstack-query에서 발생하는 에러는 어떻게 처리할 수 있는걸까?
- 이는 위에 코드에서 볼 수 있듯이, suspense를 true로 설정하거나, useErrorBoundary를 true로 설정했기 때문이다.
- 그리고 useQuery 인자내에서 useErrorBoundary의 특정 조건을 다음과 같이 처리해주면, ErrorBoundary에서 처리할 수 있다.
그 외.
- meta field를 적용하는 방법은 다음과 같다.
Defining on-demand messages
export const useRecordGetQuery = (
userEmail: string | null | undefined,
token: string | undefined,
) => {
const { data: messages = [] } = useQuery(
[...recordManagerKeys.record, userEmail],
() => getMessage(userEmail, token),
{
meta: {
errorMessage: '이게 있다면 실패한 에러가 작성되겠죠.', // 이렇게 하면 위의 query.meta?.errorMessage에서 읽힌다.
},
},
);
return {
messages,
};
};
-
ChakraUI의 toast가 있는데도 불구하고 react-hot-toast를 사용한 이유
- 위
layout.tsx
를 보면 ChakraContext가 존재한다. 즉 ChakraUI의 toast를 사용할 수 있다. - 하지만 queryClient에선 Chakra(이하 차크라)를 사용할 수 없었다.
- 그 이유는 QueryClinet를 useState내에 넣어주게 되는데, 차크라 UI 같은 경우는 toast를 불러오기 위해 useToast hooks를 제공한다.
- useState내에 useHooks가 들어가면 에러가 발생했다. 즉, react-hot-toast를 임의로 적용해주었다.
- 위
결론
- 최대한 정확하게 적어보려고 했다.
혹시나, 잘못된 부분이 있다면
dlrmsgnl0823@gmail.com
으로 꼭 연락주시길 바랍니다. 🙇♂️
참고자료
훨씬훨씬 많은데, 회사 탭에 열어놓은게 있어서 내일 업데이트 해야겠다..!