Menu

React Suspense와 lazy: 로딩 상태와 코드 분할

Suspense는 안의 컴포넌트가 준비되지 않은 동안 대체 UI를 보여 주고, React.lazy는 컴포넌트가 처음 렌더링될 때만 그 코드를 불러옵니다. 코드 분할, 중첩된 경계, React 19의 use()와 Suspense, 오류 처리를 배웁니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

Suspense는 안의 컴포넌트가 아직 준비되지 않은 동안 로딩 메시지 같은 대체 UI를 보여 줍니다. 가장 흔히 기다리는 대상은 React.lazy입니다. 컴포넌트가 처음 렌더링될 때만 그 코드를 불러오므로 첫 다운로드가 작게 유지됩니다.

Show chart를 클릭하면 Loading chart...가 1초 동안 나타난 뒤 차트가 나옵니다. 숨겼다가 다시 보여 주면 lazy가 불러온 모듈을 유지하므로, 새 로그 줄 없이 바로 나타납니다.

lazy로 코드 분할하기

에디터는 모든 것을 파일 하나에 담고 있으므로, 예제는 느린 모듈을 직접 만듭니다. 1초 뒤에 default export를 가진 객체로 완료되는 프로미스입니다. 실제 앱에서는 컴포넌트가 자기 파일에 있고 동적 import를 넘깁니다.

import { lazy, Suspense } from 'react';

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

export default function Dashboard() {
    return (
        <Suspense fallback={<p>Loading chart...</p>}>
            <Chart />
        </Suspense>
    );
}

import('./Chart.jsx')는 모듈에 대한 프로미스를 반환합니다. Vite나 webpack 같은 번들러는 동적 import를 보고 Chart.jsx와 그것만 쓰는 모든 것을 별도의 파일에 넣으며, 그 파일은 <Chart />가 처음 렌더링될 때 다운로드됩니다. 알아 둘 규칙은 다음과 같습니다.

  • 모듈에는 default export가 있어야 합니다. lazy는 프로미스가 완료된 결과의 default 속성을 읽습니다. named export라면 변환하세요: lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))).
  • lazy는 모듈의 최상위에서 호출하세요. 컴포넌트 안에서 호출하면 렌더링마다 새 컴포넌트 타입이 만들어지므로, React가 이전 것을 언마운트하고 state를 잃고 다시 불러옵니다.
  • 사용자가 어차피 기다리는 곳에서 나누세요. 라우트, 모달, 거의 열지 않는 패널, 무거운 위젯(편집기, 차트, 지도)이 좋은 후보입니다. 작은 컴포넌트를 모두 나누면 얻는 것 없이 요청과 로딩 상태만 늘어납니다.

클릭 전에 미리 불러오기

lazy 컴포넌트는 처음 렌더링될 때 불러오기 시작하므로, 사용자는 클릭한 뒤 항상 최소 한 번의 다운로드를 기다립니다. 클릭이 올 것을 짐작할 수 있다면 다운로드를 더 일찍 시작하세요. import 함수를 변수에 두고 hover나 focus 때 호출하세요. 브라우저가 모듈을 보관하므로, 나중에 lazy가 같은 import를 호출하면 두 번째 다운로드 없이 완료됩니다.

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

<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
    Show chart
</button>

Suspense가 무엇을 보여 줄지 정하는 방식

<Suspense> 안의 컴포넌트가 준비되지 않으면 일시 중단됩니다. React는 그 부분의 렌더링을 멈추고 위에 있는 가장 가까운 Suspense의 fallback을 보여 줍니다. 기다리는 컴포넌트만이 아니라 그 경계 안의 모든 것이 대체 UI로 바뀝니다. 기다리던 것이 준비되면 React가 내용을 다시 렌더링하고 대체 UI를 바꿉니다.

그래서 경계의 위치는 설계상의 결정입니다. 독립적인 부분은 각자의 경계에 두어서 준비되는 대로 나타나게 하세요.

처음에는 Header가 바깥쪽 경계에 속하므로 페이지 전체가 Loading page...를 보여 줍니다. 헤더가 준비되면 글이 나타나고 그 아래에 Loading comments...가 보이며, 댓글이 마지막에 도착합니다. 1500을 3000으로 바꾸면 미리보기가 다시 로드되면서 댓글만 더 오래 기다립니다. 안쪽 <Suspense>를 지우면(<Comments />는 유지) 페이지가 아무것도 보여 주기 전에 댓글을 기다립니다.

React 19의 use()와 Suspense

React 19에서는 컴포넌트가 use(promise)로 프로미스를 읽을 수 있습니다. 프로미스가 아직 대기 중이면 컴포넌트가 일시 중단되고 가장 가까운 Suspense가 대체 UI를 보여 주며, 완료되면 use가 값을 반환합니다. use 훅 페이지에서 자세히 다룹니다. 아래의 가짜 fetchUser가 실제 요청을 대신합니다.

사용자들을 차례로 클릭한 다음 User 1로 돌아가 보세요. 프로미스가 이미 캐시에 있으므로 즉시 나타나고 Console에는 새 fetch가 기록되지 않습니다.

프로미스를 캐시해야 하는 이유

여기서 cache 맵은 최적화가 아니라 필수입니다. 일시 중단된 컴포넌트는 그 시도에서 아무것도 유지하지 않습니다. 프로미스가 완료되면 React가 처음부터 다시 렌더링합니다. Profile이 fetchUser(id)를 직접 호출한다면, 시도할 때마다 새 프로미스를 만들고, 새 요청을 시작하고, 다시 그것 때문에 일시 중단됩니다. 프로필은 절대 나타나지 않고 Console은 fetching user 줄로 가득 찹니다. 그래서 프로미스는 렌더링보다 오래 사는 곳에서 와야 합니다.

  • 위의 맵처럼 요청을 키로 하는 캐시(TanStack Query 같은 데이터 라이브러리와 프레임워크 로더가 대신해 줍니다)
  • 이벤트 핸들러나 서버 컴포넌트에서 프로미스를 한 번 만들고 prop으로 넘기는 부모

새 사용자를 고를 때마다 프로필이 여전히 대체 UI로 바뀐다는 점에 주의하세요. 다음 사용자가 준비될 때까지 이전 사용자를 화면에 두고 싶다면 업데이트를 트랜지션으로 감싸세요: startTransition(() => setId(n)). React는 트랜지션에서 이미 보이는 내용을 숨기지 않습니다(useTransition 참고).

오류에는 에러 바운더리가 필요합니다

Suspense는 기다리는 것을 처리할 뿐, 실패를 처리하지는 않습니다. lazy import가 실패하거나(사용자가 오프라인이 되었거나, 새 배포가 이전 청크를 지웠거나) use에 넘긴 프로미스가 거부되면, React는 오류를 가장 가까운 에러 바운더리로 던집니다. 에러 바운더리가 없으면 루트 아래의 트리 전체가 언마운트됩니다. 에러 바운더리는 여전히 클래스 컴포넌트입니다(에러 바운더리 참고).

<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
    <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
    </Suspense>
</ErrorBoundary>

Suspense가 감지하지 못하는 것

Suspense는 일시 중단하는 컴포넌트에만 반응합니다. lazy 컴포넌트, use(promise), 그리고 Suspense용으로 만든 데이터 소스(프레임워크 로더, Suspense를 지원하는 라이브러리)입니다. 완료되면 state를 설정하는 useEffect 안의 fetch는 일시 중단하지 않으므로, 그것을 감싼 Suspense 경계는 대체 UI를 절대 보여 주지 않습니다. 그런 패턴에서는 데이터 가져오기 페이지에서 보여 준 것처럼 직접 loading state를 유지합니다.

자주 묻는 질문

React Suspense란 무엇인가요?

<Suspense fallback={...}>는 안의 어떤 컴포넌트가 지연 로딩되는 코드나 use로 읽는 데이터처럼 무언가를 기다리는 동안 fallback을 보여 주는 컴포넌트입니다. 안의 모든 것이 준비되면 React가 대체 UI를 내용으로 바꿉니다.

React.lazy는 무엇을 하나요?

lazy(() => import('./Chart.jsx'))는 처음 렌더링될 때 코드가 다운로드되는 컴포넌트를 만듭니다. 번들러가 그 파일을 별도의 청크에 넣으므로, 첫 페이지가 불러오는 자바스크립트가 줄어듭니다.

Suspense는 데이터 가져오기에도 동작하나요?

네, 데이터 소스가 지원한다면 동작합니다. React 19에서는 컴포넌트가 use(promise)로 프로미스를 읽고 완료될 때까지 일시 중단할 수 있습니다. Next.js 같은 프레임워크도 데이터 로딩에 Suspense를 통합합니다. useEffect 안의 fetch는 Suspense를 일으키지 않습니다.

Suspense에서 오류는 어떻게 처리하나요?

Suspense는 기다리는 것만 처리합니다. lazy import나 프로미스가 실패하면 오류는 가장 가까운 에러 바운더리로 가므로, Suspense 경계(또는 그 부모)를 에러 바운더리로 감싸세요.

lazy는 어디서 호출해야 하나요?

모듈의 최상위, 모든 컴포넌트 바깥에서 호출하세요. 컴포넌트 안에서 lazy를 호출하면 렌더링마다 새 컴포넌트 타입이 만들어지므로 state가 초기화되고 다시 불러오게 됩니다.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기