Suspense와 Error Boundary: 선언적 비동기 처리의 진짜 가치

2024년 11월 28일
reactsuspenseerror-boundaryasyncloadingdeclarative

Suspense와 Error Boundary: 선언적 비동기 처리의 진짜 가치

1. 서론: 우리는 왜 아직도 if (loading)을 쓰고 있을까?

const {
  data: taskByUuid,
  isError: isEditorPageError,
  error: taskPageError,
  isLoading: isEditorPageLoading,
  refetch: refetchTask,
} = useQueryEditorPage(taskUuid);

if (isEditorPageLoading) return <LoadingSpinner />;
if (isEditorPageError) return <ErrorMessage error={taskPageError} />;

return <TaskEditor task={taskByUuid} />;

이 코드를 볼 때마다 불편했다.

정작 중요한 건 <TaskEditor /> 컴포넌트인데, 로딩과 에러 처리가 더 많은 공간을 차지한다. 모든 컴포넌트마다 이 패턴이 반복된다. Copy & Paste의 향연이다.

더 큰 문제는 이게 "당연하다"고 생각했다는 거다. "비동기는 원래 이렇게 처리하는 거 아냐?"

React 팀은 이미 답을 제시했다. Suspense와 Error Boundary. 하지만 우리는 여전히 2018년의 방식으로 코드를 짜고 있다.

2. 명령형 vs 선언형: 패러다임의 차이

명령형: "어떻게" 처리할 것인가

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return <Profile user={user} />;
}

우리는 React에게 "어떻게" 해야 하는지 일일이 설명한다:

  1. 로딩 상태를 true로 설정해
  2. 데이터를 가져와
  3. 성공하면 로딩을 false로 바꾸고 데이터를 저장해
  4. 실패하면 로딩을 false로 바꾸고 에러를 저장해

선언형: "무엇"을 원하는가

function UserProfile({ userId }) {
  const user = useUser(userId); // 그냥 데이터를 원해!
  
  return <Profile user={user} />;
}

선언형에서는 "나는 user 데이터가 필요해"라고만 선언한다. 어떻게 가져올지, 로딩을 어떻게 표시할지, 에러를 어떻게 처리할지는 관심사가 아니다.

3. Suspense의 본질: "아직 준비되지 않았다"는 선언

Suspense는 단순한 로딩 처리 도구가 아니다. "이 컴포넌트는 아직 렌더링할 준비가 되지 않았다"는 선언이다.

Promise를 throw하는 마법

function useData(url) {
  const [resource, setResource] = useState(null);
  
  if (!resource) {
    // Promise를 throw하면 Suspense가 잡아낸다!
    throw fetchData(url).then(data => {
      setResource(data);
    });
  }
  
  return resource;
}

일반적인 JavaScript에서 Promise를 throw하는 건 말이 안 된다. 하지만 React는 이를 "아직 준비 중"이라는 신호로 해석한다.

Suspense Boundary: 로딩의 경계 설정

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />
        </Suspense>
      </Suspense>
    </Suspense>
  );
}

중첩된 Suspense로 로딩 UI의 세밀한 제어가 가능하다. 더 이상 각 컴포넌트가 자신의 로딩 상태를 관리할 필요가 없다.

4. Error Boundary: 에러도 선언적으로

에러를 "경계"에서 처리하기

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    // 에러 로깅 (DataDog, Sentry 등)
    logErrorToService(error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return <ErrorFallback onRetry={() => this.setState({ hasError: false })} />;
    }
    
    return this.props.children;
  }
}

Error Boundary는 컴포넌트 트리에서 발생하는 에러를 "경계"에서 잡아낸다. try-catch처럼 명령형이 아니라, 선언적으로 에러 처리 영역을 정의한다.

에러의 전파와 격리

function App() {
  return (
    <ErrorBoundary fallback={<AppCrashed />}>
      <Header />
      <ErrorBoundary fallback={<ContentError />}>
        <MainContent />
      </ErrorBoundary>
      <ErrorBoundary fallback={<SidebarError />}>
        <Sidebar />
      </ErrorBoundary>
    </ErrorBoundary>
  );
}

에러가 발생해도 전체 앱이 죽지 않는다. 각 영역별로 독립적인 에러 처리가 가능하다.

5. 실전: Apollo Client와의 조합

기존의 지옥

function TaskList() {
  const { data, loading, error } = useQuery(GET_TASKS);
  
  if (loading) return <TaskListSkeleton />;
  if (error) return <TaskListError error={error} />;
  
  return (
    <div>
      {data.tasks.map(task => (
        <TaskItem key={task.id} task={task} />
      ))}
    </div>
  );
}

모든 컴포넌트마다 반복되는 패턴. 지겹다.

Suspense와 함께라면

// Apollo Client 설정
const client = new ApolloClient({
  uri: '/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          // Suspense를 위한 설정
        }
      }
    }
  }),
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'all',
      notifyOnNetworkStatusChange: true,
    }
  }
});

// 사용
function TaskList() {
  const { data } = useSuspenseQuery(GET_TASKS);
  
  return (
    <div>
      {data.tasks.map(task => (
        <TaskItem key={task.id} task={task} />
      ))}
    </div>
  );
}

로딩? Suspense가 처리한다. 에러? Error Boundary가 처리한다. 컴포넌트는 오직 "성공 케이스"만 다루면 된다.

6. 실무 적용을 위한 패턴들

패턴 1: 레이어별 Error Boundary

// 전역 에러 처리
<GlobalErrorBoundary>
  {/* 페이지 레벨 에러 처리 */}
  <PageErrorBoundary>
    {/* 기능별 에러 처리 */}
    <FeatureErrorBoundary>
      <Feature />
    </FeatureErrorBoundary>
  </PageErrorBoundary>
</GlobalErrorBoundary>

패턴 2: 에러 복구 전략

function RetryErrorBoundary({ children, maxRetries = 3 }) {
  const [retryCount, setRetryCount] = useState(0);
  const [hasError, setHasError] = useState(false);
  
  const resetError = useCallback(() => {
    setRetryCount(prev => prev + 1);
    setHasError(false);
  }, []);
  
  if (hasError && retryCount < maxRetries) {
    return (
      <ErrorRetrying 
        onRetry={resetError}
        attempt={retryCount + 1}
        maxAttempts={maxRetries}
      />
    );
  }
  
  if (hasError) {
    return <FinalError />;
  }
  
  return (
    <ErrorBoundary
      onError={() => setHasError(true)}
      resetKeys={[retryCount]}
    >
      {children}
    </ErrorBoundary>
  );
}

패턴 3: 통합 데이터 페칭 훅

// 우리가 만들 패키지의 모습
function useApolloSuspense(query, options) {
  const client = useApolloClient();
  const cacheKey = getCacheKey(query, options);
  
  // 캐시 확인
  const cached = suspenseCache.get(cacheKey);
  if (cached) return cached;
  
  // Promise 생성 및 throw
  const promise = client
    .query({ query, ...options })
    .then(result => {
      suspenseCache.set(cacheKey, result);
      return result;
    });
    
  throw promise;
}

7. 왜 선언적 프로그래밍인가?

관심사의 분리

// 명령형: 모든 게 뒤섞여 있다
function ProductPage() {
  // 제품 데이터 로딩 로직
  // 리뷰 데이터 로딩 로직  
  // 추천 상품 로딩 로직
  // 각각의 로딩, 에러 상태 관리
  // UI 렌더링
}

// 선언형: 각자의 역할이 명확하다
function ProductPage() {
  return (
    <Layout>
      <ProductInfo />      {/* 제품 정보만 */}
      <ProductReviews />   {/* 리뷰만 */}
      <Recommendations />  {/* 추천 상품만 */}
    </Layout>
  );
}

조합 가능성 (Composability)

선언형 컴포넌트는 레고 블록처럼 조합이 쉽다. 각 컴포넌트가 독립적이고, 자신의 책임만 가지기 때문이다.

테스트 용이성

// 로딩과 에러 상태를 mocking할 필요가 없다!
test('ProductInfo renders correctly', () => {
  render(
    <MockedProvider mocks={[productMock]}>
      <ProductInfo productId="123" />
    </MockedProvider>
  );
  
  // 바로 제품 정보가 렌더링되는지 확인
  expect(screen.getByText('iPhone 15')).toBeInTheDocument();
});

8. 마이그레이션 전략

Step 1: 작은 단위부터 시작

// 기존 코드를 감싸는 래퍼 컴포넌트
function withSuspense(Component) {
  return function SuspenseWrapper(props) {
    return (
      <ErrorBoundary>
        <Suspense fallback={<ComponentSkeleton />}>
          <Component {...props} />
        </Suspense>
      </ErrorBoundary>
    );
  };
}

Step 2: 점진적 확대

  1. 리프 컴포넌트부터 시작
  2. 독립적인 기능 단위로 확대
  3. 페이지 레벨로 확장
  4. 전체 앱 적용

9. 결론: 진짜 React다운 코드를 향해

// Before: 명령형 지옥
if (loading1 || loading2 || loading3) return <Loading />;
if (error1 || error2 || error3) return <Error />;

// After: 선언형 천국
return <App />;

Suspense와 Error Boundary는 단순한 기능이 아니다. React가 추구하는 "선언적 UI"의 완성이다.

우리가 정말로 집중해야 할 것은 "사용자에게 무엇을 보여줄 것인가"이지, "로딩을 어떻게 처리할 것인가"가 아니다.

이제 진짜 React다운 코드를 작성할 시간이다.


"로딩과 에러는 더 이상 우리의 관심사가 아니다. 우리는 단지 선언할 뿐이다."