본문 바로가기

React

React에서 비동기 컴포넌트 로딩 순서 제어하기

대부분의 SPA는 비동기 컴포넌트 로딩과 데이터 페칭이 동시에 일어납니다. 사용자가 먼저 봐야 할 영역부터 단계적으로 보여주면 체감 성능이 크게 좋아집니다. React 18의 Suspense, SuspenseList, useTransition, 그리고 라우터의 defer/Await를 조합해 로딩 순서를 실무적으로 제어하는 방법을 정리합니다.

1. 기본: lazy + Suspense로 경계 나누기

우선 컴포넌트를 기능 단위로 분리하고, 각 섹션을 Suspense 경계로 감쌉니다. 이렇게 해야 이후 순서 제어의 단위가 생깁니다.

import React, { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart'));
const Table = lazy(() => import('./Table'));

export default function Dashboard() {
  return (
    <>
      <Suspense fallback={<div>차트 로딩...</div>}>
        <Chart />
      </Suspense>
      <Suspense fallback={<div>테이블 로딩...</div>}>
        <Table />
      </Suspense>
    </>
  );
}

위처럼 각 섹션을 독립 경계로 만들면, 이후 순서를 제어하거나 프리패치할 때 유연해집니다.

2. SuspenseList로 노출 순서 제어

여러 Suspense 경계가 있을 때 먼저 준비된 것부터 보여줄지, 위에서 아래 순서로 보여줄지 제어할 수 있습니다. revealOrder와 tail을 사용합니다.

import React, { Suspense, SuspenseList, lazy } from 'react';

const Chart = lazy(() => import('./Chart'));
const Table = lazy(() => import('./Table'));
const News = lazy(() => import('./News'));

export default function Home() {
  return (
    <SuspenseList revealOrder="forwards" tail="collapsed">
      <Suspense fallback={<div>차트...</div>}><Chart /></Suspense>
      <Suspense fallback={<div>테이블...</div>}><Table /></Suspense>
      <Suspense fallback={<div>뉴스...</div>}><News /></Suspense>
    </SuspenseList>
  );
}

forwards는 위에서 아래 순서로 준비되는 대로 노출합니다. together는 모두 준비되면 동시에 노출합니다. tail="collapsed"는 아래쪽 경계의 폴백을 과하게 보여주지 않도록 접습니다. 헤더처럼 중요한 상단 컴포넌트를 먼저 노출하고, 하단 섹션은 뒤따라 오게 하는데 유용합니다.

3. 사용자 이벤트와 함께: useTransition

탭 전환처럼 사용자가 즉시 반응을 느껴야 하는 상호작용은 동기 상태 업데이트로, 무거운 컴포넌트 로드는 transition으로 분리해 우선순위를 낮춥니다.

import React, { lazy, Suspense, useState, useTransition } from 'react';

const Summary = lazy(() => import('./Summary'));
const Details = lazy(() => import('./Details'));

export default function Panel() {
  const [tab, setTab] = useState('summary');
  const [isPending, startTransition] = useTransition();

  const onTabChange = (next) => {
    startTransition(() => setTab(next));
  };

  return (
    <div>
      <button onClick={() => onTabChange('summary')}>요약</button>
      <button onClick={() => onTabChange('details')}>상세</button>
      {isPending && <span>전환 중...</span>}
      <Suspense fallback={<div>콘텐츠 로딩...</div>}>
        {tab === 'summary' ? <Summary /> : <Details />}
      </Suspense>
    </div>
  );
}

이렇게 하면 클릭 반응은 즉시 보이고, 무거운 탭 내용은 뒤에서 로드됩니다. 체감 속도가 개선됩니다.

4. 라우트 단위: React Router defer/Await로 우선순위 분리

중요 데이터와 비중요 데이터를 분리 로드해 상단 UI를 먼저 보여줄 수 있습니다. critical만 먼저 기다리고, secondary는 뒤에서 대기시키는 방식입니다.

// routes/dashboard.jsx
import { defer } from 'react-router-dom';

export async function loader() {
  return defer({
    critical: fetch('/api/header').then(r => r.json()),
    secondary: fetch('/api/list').then(r => r.json()),
  });
}
// components/Dashboard.jsx
import React, { Suspense } from 'react';
import { useLoaderData, Await } from 'react-router-dom';

export default function Dashboard() {
  const data = useLoaderData();

  return (
    <div>
      <Suspense fallback={<div>헤더...</div>}>
        <Await resolve={data.critical}>{(header) => <Header data={header} />}</Await>
      </Suspense>

      <Suspense fallback={<div>목록 로딩...</div>}>
        <Await resolve={data.secondary}>{(list) => <List data={list} />}</Await>
      </Suspense>
    </div>
  );
}

헤더가 먼저 그려지고, 목록은 뒤따라 렌더링됩니다. 페이지 퍼스트 페인트가 빨라지고 SEO 친화적인 하이브리드 렌더링 전략과도 잘 맞습니다.

5. 프리로드/프리패치로 실제 로딩 순서 앞당기기

렌더링 순서만 제어하면 한계가 있습니다. 번들을 미리 가져와 네트워크 레벨에서 순서를 앞당깁니다.

// Webpack: 마우스 오버 시 미리 청크를 가져오기
const Chart = React.lazy(() => import(/* webpackChunkName: "analytics" */ './Chart'));
Chart.preload = () => import(/* webpackChunkName: "analytics", webpackPreload: true */ './Chart');

function Nav() {
  return (
    <button onMouseEnter={() => Chart.preload?.()}>차트 열기</button>
  );
}
// 공통 의존성을 같은 청크로 묶어 워터폴 방지 (Webpack 전용)
const Chart = React.lazy(() => import(/* webpackChunkName: "analytics" */ './Chart'));
const Stats = React.lazy(() => import(/* webpackChunkName: "analytics" */ './Stats'));
// HTML에서 직접 프리로드 (번들러에 맞게 경로 조정)
// <link rel="preload" as="script" href="/assets/analytics.chunk.js" />

Vite/Rollup 환경에서는 manualChunks로 묶거나, 라우터 프리패치(예: 링크 진입 전 프리패치)를 사용해 비슷한 효과를 얻습니다.

6. 안티패턴과 체크리스트

전체 앱을 하나의 Suspense로 감싸면 폴백 화면만 오래 보이게 됩니다. 섹션 단위로 경계를 나눕니다.

서로 연관된 컴포넌트를 무분별하게 분리하면 네트워크 워터폴이 생깁니다. 공통 의존성을 묶어 청크 수를 줄입니다.

폴백 UI는 정보 위계를 반영해야 합니다. 상단은 간단한 스켈레톤, 하단은 지연 표시를 사용해 시각적 안정감을 줍니다.

사용자 입력과 무거운 로드를 섞지 마십시오. useTransition으로 낮은 우선순위로 전환하세요.

측정 없이 추측하지 마세요. React Profiler, Web Vitals, Lighthouse로 TTI, INP, LCP를 확인하고 경계를 조정합니다.

7. 실무 예시: 중요 헤더 우선, 본문 후순위

import React, { lazy, Suspense, SuspenseList } from 'react';

const Header = lazy(() => import('./Header'));
const Content = lazy(() => import('./Content'));
const Sidebar = lazy(() => import('./Sidebar'));

export default function App() {
  return (
    <SuspenseList revealOrder="forwards" tail="collapsed">
      <Suspense fallback={<div>헤더...</div>}><Header /></Suspense>
      <Suspense fallback={<div>본문...</div>}><Content /></Suspense>
      <Suspense fallback={<div>사이드...</div>}><Sidebar /></Suspense>
    </SuspenseList>
  );
}

위처럼 노출 순서를 고정하고, 필요하다면 Header는 빌드에서 분리하지 않아 첫 페인트를 더 빠르게 가져갈 수 있습니다. Content와 Sidebar는 프리패치로 체감 속도를 끌어올립니다.

정리하면, 로딩 순서는 렌더링 경계(Suspense), 노출 순서(SuspenseList), 상호작용 우선순위(useTransition), 네트워크 프리패치(프리로드/청크 그룹화), 라우터 defer를 함께 설계해야 실무에서 효과가 큽니다.