요즘 개발을 하면서 Next.js에서는 에러 처리를 어떤식으로 할까 라는 고민이 많습니다.
저도 이러한 부분들을 고민했고, Next.js App Router 기준에서 어떤식으로 처리하면 좋을지 한번 고민한 부분들을 정리한 글입니다.
error.tsx는 Error Boundary(에러 바운더리) 입니다.
특정 컴포넌트에서 렌더링 과정 중 예상하지 못한 에러가 발생하면, 버블링되어 가장 가까운 error.tsx가 렌더됩니다.
추가 내용
또한, error.tsx는 에러 경계 (Error Boundary) 역할을 하며 Client Component여야 합니다.
(예: useEffect(() => { throw new Error('boom') }, [])처럼 즉시 throw면 잡힙니다)
setTimeout, await 내부 throw 등)이것을 설명하려면 먼저 Error.tsx가 누구인지 먼저 알 필요가 있습니다.
Error.tsx는 에러 바운더리 입니다.
에러 바운더리란?
렌더링 도중 발생하는 렌더링 에러를 잡아내는 것을 의미합니다.
그래서 에러 바운더리를 통해 저희가 원하는 범위만큼 컴포넌트를 감싸고, 나머지 UI에는 영향이 가지 않도록 하는것입니다.
이러한 에러 바운더리가 못하는것중 하나인 비동기 에러 캐치입니다. 왜냐면 그것은 렌더링 하는 과정에서 일어나는 오류가 아닙니다.
// app/users/page.tsx (Server Component)
export default async function UsersPage() {
const res = await fetch('https://api.example.com/users', { cache: 'no-store' });
if (!res.ok) {
// 렌더 경로에서 throw → 같은 세그먼트의 error.tsx가 받음
throw new Error('Failed to load users');
}
const data = await res.json();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
(Next.js는 세그먼트의 error.tsx를 자동 경계로 감쌉니다.)
onClick이나 await 내부에서 던진 에러는 경계가 바로 못 잡습니다.
그래서 이럴 때는 상태에 에러를 올려 다음 렌더에서 throw하도록 해야합니다.
function Escalate({ error }: { error: Error | null }) {
if (error) throw error; // 렌더 경로에서 throw → error.tsx로
return null;
}
function SomeClient() {
const [fatal, setFatal] = useState<Error | null>(null);
const onClick = async () => {
try {
await doAsync(); // 비동기
} catch (e) {
setFatal(e instanceof Error ? e : new Error('Unknown'));
}
};
return (
<>
<Escalate error={fatal} />
<button onClick={onClick}>실행</button>
</>
);
}
저는 여기서 고생 좀 했습니다..
처음엔 onError에서 잡아서 경계로 “보내자”라고 생각했는데, 콜백에서 Promise.reject만 던진다고 경계가 받지 않습니다.
정답은 쿼리/뮤테이션에 throwOnError를 켜는 것입니다.
버전 정리
throwOnError: true | (error) => booleanuseErrorBoundary: true | (error) => booleanReact Query는 내부에서 에러를 잡고, 다음 렌더 사이클에서 재-throw해서 가장 가까운 에러 경계(error.tsx)가 받게 해줍니다. (참고 : Suspense | TanStack Query React Docs)
그래서 혹시나 onError 안에서 return Promise.reject(err)를 하셨다면 이제는 하지마세요.!!
의미 없습니다 ⇒ 콜백임
// useQuery 예시 (v5)
const { data } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users'),
// 예: 5xx만 경계로, 4xx는 로컬 UI에서 처리
throwOnError: (err: any) => (err?.status ?? 0) >= 500,
// 선택적으로 실무 감각 옵션
staleTime: 1000 * 30,
gcTime: 1000 * 60 * 5,
})
// useMutation 예시 (v5)
const mutation = useMutation({
mutationFn: (vars) => api.post('/upload', vars),
throwOnError: (err: any) => (err?.status ?? 0) >= 500,
})
| 유형 | 어디서 처리? | 사용자 피드백 |
|---|---|---|
| 404(예상 가능) | notFound() → not-found.tsx | “없어요/삭제됨” 페이지 |
| 4xx(검증/권한) | 컴포넌트/폼/뷰 로직 | 인라인 에러/토스트 |
| 5xx(서버 오류) | 경계로 던짐 (throwOnError) → error.tsx | 일관된 에러 화면 + 재시도 |
| 네트워크/타임아웃 | 컴포넌트/전역 배너 | 오프라인 배너 + 재시도(백오프) |
| 401(인증 만료) | 인터셉터/미들웨어 | 세션 만료 토스트 + 로그인 유도 |
| 이벤트/비동기 중 예외 | “승격”(상태→렌더에서 throw) 또는 React Query의 throwOnError | error.tsx 또는 토스트 |
회사에서 지금 하는것중 하나는 API 정규화 작업을 진행하고있습니다.
// 공통 에러 타입
type AppError = {
status: number | null;
code: string; // 서버 코드 또는 AXIOS_ERROR 등
message: string; // 사용자 메세지
description?: string | null;
};
// 예: axios 인터셉터에서 AppError로 정규화 후 reject
이렇게 해두면 React Query의 throwOnError, 토스트, 인라인 메시지, 로깅이 모두 일관됩니다.
error.tsx는 렌더링 중의 “예상치 못한” 에러를 잡는 세그먼트 경계입니다. 비동기/이벤트 에러는 그대로는 안 잡히는 특징이 있습니다.throwOnError*로 쿼리/뮤테이션 에러를 경계로 전파**해줍니다. 5xx는 경계, 4xx는 로컬 UI로 가르는 패턴이 깔끔하다고 생각합니다.useQueryErrorResetBoundary + reset()로 연결하면 더 나은 UX를 제공할수있습니다. tanstack.comnotFound(), 401은 인터셉터/미들웨어, 네트워크는 배너/재시도. 레이어를 나눠서 처리하면 팀 전체 UX가 안정적이라고 생각합니다.