logo

DowanKim

에러 처리를 선언형으로 바꾸기

2026년 8월 28일

PLick

현재 에러처리는 구현마다 새로 isError, isPending 수동 분기를 처리하고 있습니다.

이것도 나쁘지 않다고 볼 수 있지만, 어느 한곳에서 빼먹을 수 있다는 것이 가장 큰 문제일 것 같습니다.

특히나 AI를 사용해서 개발을 진행하는 입장에선, 이렇게 매번 에러와 대기를 수동으로 분기처리를 진행한다는 것은 AI 가 언젠가는 한번 빼먹을 수 있는 상황이 올 수 있기에 리스크가 크다고 생각했습니다.


1. 문제 상황

image.png

좋아요 토글은 실패하면 낙관적 갱신을 롤백하고 console.error만 남깁니다. 사용자 입장에선 하트가 커졌다가 소리없이 원상복구 되고 아무 안내가 없습니다

image.png

기사 상세페이지는 본문 fetch 가 500이면 그대로 throw 되어서 error.tsx 페이지로 전체가 교체됩니다. 즉 부분실패를 표현하지 못하고 있는 상황입니다.

분기처리 반복 54곳, 그리고 그로인해 빠진 부분이 존재한다는 것이 문제입니다.


에러바운더리는 비동기 에러를 잡지 못합니다.

에러바운더리는 React가 렌더링하는 도중 던져진 에러만 잡습니다. 트리를 그리다가 어딘가에서 throw가 터지면 가장 가까운 경계가 받아서 그 영역만 fallback으로 바꾸는 방식입니다.

문제는 fetch 콜백이나 이벤트 핸들러 안에서 터지는 에러입니다. 렌더링이 끝난 뒤 별개의 콜스택에서 실행되기 때문에, throw를해도 잡히지 않습니다. 정확히는 React가 개입하지 못한다는게 맞는 것 같습니다,

그런데 우회하는 개념으로, 비동기에서 받은 에러를 state에 저장해 뒀다가 다음 렌더에서 다시 throw하면, 비동기 에러가 렌더 중 에러로 바뀌어 에러바운더리로 처리가 되게 할 수 있습니다.

  • 1.throwOnError는 이 개념을 실제로 사용할 수 있게 해주는 TanStack Query 내장판입니다. 쿼리가 실패하면 isError 상태로 끝내는 대신 다음 렌더에서 throw해서 경계로 올려보냅니다.
  • 2.Suspense는 같은 발상의 로딩 버전입니다. 로딩을 상태 분기 대신 suspend로 표현합니다.
  • 3.useAsyncError는 이 트릭을 직접 구현한 훅입니다

적용

경계

ErrorBoundary는 클래스 컴포넌트로 만들었습니다. 렌더 에러를 잡는 getDerivedStateFromError가 훅 대응물이 없어서, 클래스 생명주기를 사용했습니다.

export class ErrorBoundary extends Component<Props, State> { state = { hasError: false, error: undefined }; static getDerivedStateFromError(error: unknown) { return { hasError: true, error }; } reset = () => { this.props.onReset?.(); this.setState({ hasError: false, error: undefined }); }; render() { if (this.state.hasError) return this.props.fallback(this.state.error, this.reset); return this.props.children; } }

TanStack의 QueryErrorResetBoundary에서 reset을 받아 ErrorBoundary의 onReset에 연결하고, suspense 훅을 쓰는 자식이면 Suspense까지 씌웁니다.

<QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallback={...}> {fallback !== undefined ? <Suspense fallback={fallback}>{children}</Suspense> : children} </ErrorBoundary> )} </QueryErrorResetBoundary>

reset 연결이 없으면 에러가 났다는 사실을 경계의 상태에서만 초기화되고 쿼리 캐시엔 에러가 남아서, 리마운트된 suspense 훅이 캐시의 에러를 그대로 다시 던지고 앞의 과정이 무한 반복되는 상황이 발생할 수 있습니다. reset이 쿼리의 에러 상태를 지워야 리마운트가 refetch로 이어집니다.

throwOnError 기본값

QueryClient 전역 기본 정책

throwOnError: (_error, query) => query.state.data === undefined;

보여줄 데이터가 하나도 없는 실패만 경계로 던지게 했습니다.

첫 페이지부터 실패하면 캐시가 비어 있으니 throw돼 경계가 받고, 릴스 20개를 잘 보다가 3페이지째 실패하면 캐시가 있으니 던지지 않습니다.

후자의 상황까지 만약에 던지면 보던 목록이 통째로 에러 화면이 되고 앞에서 받아왔던 데이터들도 다 날라가게 되므로, 데이터가 있던 상황에는 기존처럼 isFetchNextPageError 상태로 남기고 목록 아래 재시도 버튼이 받게했습니다.

suspense 전환

댓글 훅을 useSuspenseInfiniteQuery로 바꿨습니다.

// before const { data, isPending, isError, isFetching, refetch, ... } = useComments(id, initial); const comments = data?.pages.flatMap((p) => p.items) ?? []; if (isPending) return <스켈레톤 15/>; if (isError && comments.length === 0) return <에러문구 + refetch 버튼 15/>;
// after const { data, ... } = useComments(id, initial); const comments = data.pages.flatMap((p) => p.items);

suspense 훅은 로딩이면 suspend, 실패면 throw, 성공이면 data가 항상 있다는 계약이라 옵셔널 체이닝이 필용없어졌습니다.

<CommentsHeader count={...} /> <CommentComposer ... /> <QueryBoundary fallback={<CommentListSkeleton />} errorMessage="댓글을 불러오지 못했어요."> <CommentList ... /> </QueryBoundary>

throw가 라우트 error.tsx까지 올라가 페이지 전체가 죽는 상황에서, 댓글 API가 500이어도 throw가 경계에서 멈추고 기사 본문과 투표는 살고 댓글 자리만 에러가 되게 개선되었습니다..

뮤테이션 전역 안전망

MutationCache의 onError는 콜사이트에 onError가 있든 없든 항상 불린다는 성질을 이용해, 여기에 토스트를 걸었습니다.

mutationCache: new MutationCache({ onError: (error, _v, _c, mutation) => { if (mutation.meta?.errorHandled) return; if (error instanceof ApiError && SCREEN_HANDLED_CODES.has(error.code)) return; useErrorToast.getState().show(...); }, }),

onError를 안 단 뮤테이션이 실패하면 기본으로 알립니다.

이중 안내를 막는 필터는 두개 있습니다. 댓글 CRUD처럼 자체 에러 UI가 완결된 훅은 meta.errorHandled로 빠지고(meta 타입은 Register 인터페이스 보강으로 붙여 오타가 컴파일에 걸립니다), AUTH_REQUIRED 로그인 유도처럼 화면 상태로 처리되는 코드는 코드로 걸러 뺀다.

useAsyncError

export function useAsyncError(): (error: unknown) => void { const [, setError] = useState<undefined>(undefined); return useCallback((error: unknown) => { setError(() => { throw error; }); }, []); }

setState에 값 대신 갱신 함수를 넘깁니다. React는 갱신 함수를 다음 렌더에서 실행하는데, 그 함수가 throw하면 렌더 중 에러가 되어 경계가 잡힙니다. setState 호출 자체가 리렌더를 즉시 예약하고 리렌더가 발생되어 에러가 잡힙니다

TanStack Query를 지나는 에러는 throwOnError가 같은 일을 해 주니 이 훅이 필요 없습니다. RQ 밖의 비동기, 그러니까 이벤트 핸들러에서 직접 부른 async 함수같은 것들이 위 상황의 대상입니다.

릴스에서 커서에 특정 오류가 있어 400이 오면 재시도 대신 피드를 첫 페이지부터 다시 받는 복구 함수를 핸들러에서 직접 부르는데, 이 복구마저 실패하면 before 에선 void로 그냥 아무 일도 안일어났었습니다

// before
void restartFeedQuery(queryClient, key);
// after
restartFeedQuery(queryClient, key).catch(throwAsync);

복구마저 실패하면 말그대로 실패한 것이기 때문에 바운더리가 잡을 수 있게 합니다.


결과

image.png

image.png

댓글만 500을 주면 기사 본문과 투표는 살고 댓글 섹션만 에러와 다시 시도가 뜹니다. 좋아요가 500을 주면 하트가 롤백되고 토스트가 뜹니다.

image.png

무반응 실패 2곳이 0이 됐고, 부분 실패 표현이 불가에서 가능이 됐습니다.

컴포넌트는 성공 케이스만 그리고, 에러를 직접 소유하는 게 나은 화면만 원래 방식을 사용합니다. 그 외에 에러들은 에러바운더리 기반 선언적?으로 처리가 되어, 일관성이 높아졌습니다.