Frontend

[Frontend] useTransition과 Error Boundary가 만나면?

teddy bear 2026. 7. 14. 16:16
반응형

React 19: useTransition과 Error Boundary가 만나면 생기는 일

지난 글들에서 Error Boundary의 기본 개념과 react-error-boundary의 내부 구현을 다뤘습니다. 이번에는 React 19에서 등장한 Actions와 useTransition이 Error Boundary와 결합하면서 만들어진 새로운 에러 핸들링 패턴을 살펴봅니다.


1. 기존 방식의 한계: 흩어진 로딩·에러 상태

React 18까지, 비동기 작업을 다루는 코드는 대개 이런 모양이었습니다.

function ProfileForm() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    setIsLoading(true);
    setError(null);
    try {
      await updateProfile(formData);
    } catch (e) {
      setError(e);
    } finally {
      setIsLoading(false);
    }
  };

  // ...
}

로딩 상태, 에러 상태, 실제 요청 로직이 컴포넌트마다 반복적으로 흩어져 있습니다. 컴포넌트가 늘어날수록 이 보일러플레이트도 비례해서 늘어나죠.


2. React 19의 답: Actions

React 19는 startTransition에 비동기 함수를 직접 전달할 수 있도록 확장했습니다. 이렇게 트랜지션 안에서 실행되는 함수를 React 팀은 "Action" 이라고 부릅니다.

function ProfileForm() {
  const [isPending, startTransition] = useTransition();

  function submitAction() {
    startTransition(async () => {
      await updateProfile(formData);
    });
  }

  return (
    <button onClick={submitAction} disabled={isPending}>
      {isPending ? '저장 중...' : '저장'}
    </button>
  );
}

여기서 벌어지는 일은 세 가지입니다.

  1. isPending이 트랜지션 시작부터 프로미스가 resolve될 때까지 자동으로 true를 유지합니다.
  2. <cite index="21-1">트랜지션 안에서 발생한 에러는 흩어진 try/catch 블록 대신 에러 바운더리로 전파됩니다</cite>.
  3. 개발자는 더 이상 isLoading state를 수동으로 관리하지 않아도 됩니다.

이 변화가 왜 중요한지 이해하려면, "왜 에러가 자동으로 Error Boundary까지 올라가는가"를 짚어봐야 합니다.


3. 왜 트랜지션의 에러가 Error Boundary까지 도달할까

이전 글에서 다룬 useErrorBoundary 훅은 비동기 에러를 state에 담아뒀다가 다음 렌더링에서 다시 throw하는 우회 트릭이었습니다. React 19의 트랜지션은 이 문제를 라이브러리 레벨이 아니라 React 코어 레벨에서 직접 해결합니다.

React 19 캔더리 초기 체인지로그에는 이렇게 기록되어 있습니다.

<cite index="27-1">useTransition은 이제 전역 에러 대신 가장 가까운 에러 바운더리를 트리거합니다.</cite>

즉, React의 스케줄러가 트랜지션이라는 작업 단위 자체를 추적하고 있다가, 그 안에서 예외가 발생하면 해당 트랜지션을 시작한 컴포넌트 트리 기준으로 가장 가까운 Error Boundary에 위임하도록 내부적으로 연결해준 것입니다. useErrorBoundary처럼 개발자가 setState → throw를 직접 흉내 낼 필요가 없어진 셈이죠.

[React 18 이하]
  이벤트 핸들러 안의 async 에러
    → Error Boundary가 관여 못 함
    → 라이브러리가 setState+throw로 우회

[React 19]
  startTransition(async () => { ... }) 안의 에러
    → React 스케줄러가 트랜지션 단위로 추적
    → 가장 가까운 Error Boundary로 직접 전파

4. <form action>과의 결합

Actions 패턴은 <form>의 action prop에도 그대로 적용됩니다. <cite index="27-1">action에 전달된 함수가 'use server'로 표시되면 폼이 점진적으로 향상됩니다</cite>.

function DeleteButton({ itemId }) {
  const [isPending, startTransition] = useTransition();

  const handleDelete = () => {
    startTransition(async () => {
      await deleteItem(itemId);
    });
  };

  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? '삭제 중...' : '삭제'}
    </button>
  );
}

이 코드에서 deleteItem이 실패하면, 별도의 try/catch 없이도 가장 가까운 <ErrorBoundary>가 이를 잡아 폴백 UI를 보여줍니다.

<ErrorBoundary fallback={<p>삭제에 실패했습니다.</p>}>
  <DeleteButton itemId={item.id} />
</ErrorBoundary>

5. useOptimistic과의 조합: 낙관적 업데이트 + 자동 롤백

Actions 패턴이 진짜 빛을 발하는 지점은 useOptimistic과 함께 쓸 때입니다. 사용자에게는 즉시 결과를 보여주고, 실패하면 자동으로 되돌리는 패턴을 선언적으로 작성할 수 있습니다.

function TodoItem({ todo, onToggle }) {
  const [optimisticDone, setOptimisticDone] = useOptimistic(todo.done);
  const [isPending, startTransition] = useTransition();

  const handleToggle = () => {
    startTransition(async () => {
      setOptimisticDone(!optimisticDone); // 즉시 UI 반영
      await onToggle(todo.id);            // 실패 시 트랜지션이 롤백을 트리거
    });
  };

  return (
    <label>
      <input type="checkbox" checked={optimisticDone} onChange={handleToggle} disabled={isPending} />
      {todo.title}
    </label>
  );
}

onToggle이 실패하면 낙관적으로 반영했던 optimisticDone 값은 원래 상태로 되돌아가고, 에러는 상위 Error Boundary로 전파됩니다. "일단 성공한 것처럼 보여주고, 실패하면 조용히 원상복구하며 에러 UI를 띄운다" 는, 이전에는 여러 훅을 조합해야 겨우 만들 수 있던 패턴이 세 가지 API(useOptimistic, useTransition, ErrorBoundary)의 자연스러운 조합만으로 완성됩니다.


6. 계층 구조로 설계하기: Suspense와 Error Boundary 함께 쓰기

React 19 환경에서 추천되는 패턴은 Suspense와 Error Boundary의 역할을 명확히 분리하는 것입니다. Suspense는 "아직 로딩 중"을, Error Boundary는 "진짜로 실패함"을 담당합니다.

<ErrorBoundary fallback={<ErrorFallback />}>
  <Suspense fallback={<LoadingSpinner />}>
    <DataRegion />
  </Suspense>
</ErrorBoundary>

이 계층 구조에서 DataRegion이 데이터를 기다리는 동안은 Suspense의 폴백이 보이고, 렌더링 도중 실제 에러가 발생하면 바깥의 ErrorBoundary가 이를 잡습니다. 두 경계를 같은 위치에 두되 역할을 겹치지 않게 하는 것이 핵심입니다.


7. 재시도(Retry) 버튼도 트랜지션으로

트랜지션은 일회성 요청뿐 아니라 재시도 버튼에도 잘 어울립니다. startTransition으로 감싸면, 재시도 중에도 UI가 멈추지 않고 이전 화면을 유지한 채 낮은 우선순위로 처리됩니다.

function ReloadButton({ refetch }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button disabled={isPending} onClick={() => startTransition(() => refetch())}>
      {isPending ? '재시도 중...' : '다시 시도'}
    </button>
  );
}

resetErrorBoundary와 이 재시도 버튼을 결합하면, "에러 화면 → 재시도 버튼 클릭 → 트랜지션으로 낮은 우선순위 재요청 → 성공 시 정상 화면 복귀"라는 흐름을 매끄럽게 구현할 수 있습니다.


8. 주의할 점: 모든 에러를 자동으로 넘기면 안 된다

Actions 패턴이 편리하다고 해서 모든 에러를 Error Boundary에 위임하는 것이 항상 정답은 아닙니다. 폼 검증 실패처럼 "예상된 에러"는 여전히 컴포넌트 자체에서 처리하는 편이 UX상 자연스럽습니다.

function submitAction() {
  startTransition(async () => {
    const result = await validateAndSubmit(formData);
    if (!result.ok) {
      // 예상된 검증 실패 → Error Boundary로 던지지 않고 폼 안에서 처리
      setValidationError(result.message);
      return;
    }
  });
}

Error Boundary는 "예상하지 못한 예외 상황" 을 위한 마지막 방어선입니다. 값 검증처럼 사용자가 정상적인 흐름에서도 마주칠 수 있는 상황까지 Error Boundary로 흘려보내면, 사용자는 사소한 입력 실수에도 화면 전체가 폴백 UI로 바뀌는 과도한 경험을 하게 됩니다.


9. 정리: 계층별 책임 분담표

계층 책임 담당 API

값 검증, 예상된 실패 폼 내부에서 즉시 안내 useState + 조건 분기
네트워크·서버 요청의 대기 상태 로딩 UI, 낮은 우선순위 처리 useTransition, Actions
낙관적 업데이트와 실패 롤백 즉시 반영 후 실패 시 원복 useOptimistic
아직 준비되지 않은 데이터 로딩 폴백 Suspense
예상치 못한 렌더링/트랜지션 예외 최종 방어선, 폴백 UI ErrorBoundary

React 19의 Actions는 새로운 상태 관리 도구가 아니라, React가 이미 가지고 있던 렌더링·커밋 사이클에 비동기 작업의 생애주기를 자연스럽게 편입시킨 결과에 가깝습니다. 그 결과 우리가 이전 글에서 뜯어봤던 useErrorBoundary의 "우회 트릭"이, 이제는 React 코어가 공식적으로 지원하는 흐름으로 자리 잡고 있습니다.


다음 글에서는 이 패턴들을 실제 프로덕션 코드에 적용할 때 자주 만나는 엣지 케이스 중복 요청 방지, 트랜지션 취소, 에러 로깅 중복 방지를 다뤄보겠습니다.