🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

React Suspense Lazy Loading 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 10. 30. · 26 Views

React Suspense와 Lazy Loading 완벽 가이드

React의 Suspense와 Lazy Loading을 활용하여 애플리케이션의 초기 로딩 속도를 개선하고 사용자 경험을 향상시키는 방법을 배웁니다. 코드 스플리팅부터 실전 활용까지 모든 것을 다룹니다.


목차

  1. React.lazy
  2. Suspense 기본
  3. Suspense fallback
  4. 여러 컴포넌트 로딩
  5. 라우트 기반 코드 스플리팅
  6. Error Boundary 통합
  7. Preloading 전략
  8. Suspense 중첩

1. React.lazy

시작하며

여러분이 React 애플리케이션을 배포했는데 사용자들이 "페이지가 너무 느려요"라고 불평하는 상황을 겪어본 적 있나요? 개발자 도구를 열어보니 초기 JavaScript 번들이 무려 3MB나 되어서 로딩에 10초 이상 걸리는 것을 발견했습니다.

이런 문제는 실제 개발 현장에서 매우 흔합니다. 모든 컴포넌트를 한 번에 번들링하면 사용자가 실제로 보지도 않을 코드까지 다운로드해야 합니다.

특히 모바일 환경이나 느린 네트워크에서는 치명적인 사용자 경험 저하로 이어집니다. 바로 이럴 때 필요한 것이 React.lazy입니다.

필요한 순간에만 컴포넌트를 로드하여 초기 번들 크기를 극적으로 줄일 수 있습니다.

개요

간단히 말해서, React.lazy는 컴포넌트를 동적으로 임포트할 수 있게 해주는 React의 내장 함수입니다. 왜 이것이 필요한가요?

대부분의 사용자는 애플리케이션의 모든 기능을 사용하지 않습니다. 예를 들어, 관리자 대시보드는 일반 사용자에게 필요 없고, 상세한 분석 차트는 처음 방문자에게는 불필요합니다.

이런 컴포넌트들을 미리 로드하면 초기 로딩 시간만 늘어납니다. 기존에는 모든 컴포넌트를 import Component from './Component'로 최상단에서 임포트했다면, 이제는 필요한 시점에만 로드할 수 있습니다.

React.lazy의 핵심 특징은 동적 임포트, 자동 코드 스플리팅, 그리고 Suspense와의 완벽한 통합입니다. 이러한 특징들이 모여 애플리케이션의 초기 로딩 속도를 50% 이상 개선할 수 있습니다.

코드 예제

// 기존 방식: 모든 컴포넌트를 즉시 로드
// import HeavyChart from './components/HeavyChart';

// React.lazy 방식: 필요할 때만 로드
import React, { lazy } from 'react';

// 동적 임포트로 번들을 분리
const HeavyChart = lazy(() => import('./components/HeavyChart'));

function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>
      {/* 이 컴포넌트는 실제로 렌더링될 때 로드됩니다 */}
      <HeavyChart data={chartData} />
    </div>
  );
}

설명

이것이 하는 일: React.lazy는 컴포넌트를 별도의 JavaScript 청크로 분리하고, 해당 컴포넌트가 실제로 렌더링될 때까지 로딩을 지연시킵니다. 첫 번째로, lazy(() => import('./Component'))를 호출하면 React가 이 컴포넌트를 별도의 번들 파일로 분리합니다.

빌드 시 Webpack이나 Vite 같은 번들러가 자동으로 코드 스플리팅을 수행하여 Component.chunk.js 같은 별도 파일을 생성합니다. 이렇게 하면 메인 번들에서 이 컴포넌트의 코드가 제외됩니다.

그 다음으로, 애플리케이션이 실행되고 Dashboard 컴포넌트가 렌더링될 때 비로소 HeavyChart가 필요하다는 것을 인식합니다. 이 시점에 React가 동적으로 네트워크 요청을 보내 HeavyChart.chunk.js 파일을 다운로드합니다.

이 과정은 완전히 자동으로 처리됩니다. 마지막으로, 파일이 다운로드되고 파싱이 완료되면 React가 실제 컴포넌트를 렌더링하여 화면에 표시합니다.

전체 과정이 Promise 기반으로 동작하기 때문에 Suspense와 완벽하게 통합됩니다. 여러분이 이 코드를 사용하면 초기 로딩 시간이 크게 단축되고, 사용자가 실제로 필요로 하는 코드만 다운로드하게 됩니다.

특히 대규모 애플리케이션에서는 메인 번들이 500KB에서 150KB로 줄어드는 등의 극적인 효과를 볼 수 있습니다.

실전 팁

💡 lazy는 반드시 컴포넌트 외부에서 호출하세요. 컴포넌트 내부에서 호출하면 매 렌더링마다 새로운 컴포넌트가 생성되어 상태가 초기화됩니다.

💡 default export만 지원됩니다. named export를 사용하려면 중간에 default로 다시 export하는 모듈을 만들어야 합니다.

💡 번들 크기가 30KB 이하인 작은 컴포넌트는 lazy를 사용하지 마세요. 오히려 추가 네트워크 요청으로 인한 오버헤드가 더 클 수 있습니다.

💡 개발자 도구의 Network 탭에서 청크 파일들이 언제 로드되는지 확인하여 코드 스플리팅이 제대로 작동하는지 검증하세요.

💡 Webpack의 Magic Comments를 사용하면 청크 파일명을 지정할 수 있습니다: lazy(() => import(/* webpackChunkName: "chart" */ './HeavyChart'))


2. Suspense 기본

시작하며

여러분이 React.lazy로 컴포넌트를 분리했는데 콘솔에 "A React component suspended while rendering, but no fallback UI was specified"라는 에러가 뜨는 상황을 경험해보셨나요? 페이지는 하얗게 표시되고 사용자는 무슨 일이 일어나는지 알 수 없습니다.

이 문제는 lazy 컴포넌트가 로딩되는 동안 React가 무엇을 렌더링해야 할지 모르기 때문에 발생합니다. 전통적인 방법으로는 로딩 상태를 직접 관리하고 조건부 렌더링을 해야 했지만, 이는 코드를 복잡하게 만듭니다.

바로 이럴 때 필요한 것이 Suspense입니다. 비동기 작업이 완료될 때까지 자동으로 로딩 UI를 표시하는 선언적 방법을 제공합니다.

개요

간단히 말해서, Suspense는 자식 컴포넌트가 로딩 중일 때 대체 UI를 자동으로 표시해주는 React의 내장 컴포넌트입니다. 왜 이것이 중요한가요?

비동기 컴포넌트 로딩은 네트워크 상태, 캐싱, 에러 처리 등 복잡한 상태 관리가 필요합니다. Suspense를 사용하면 이런 복잡성을 React에게 위임하고, 개발자는 "로딩 중일 때 무엇을 보여줄까"에만 집중할 수 있습니다.

예를 들어, 여러 lazy 컴포넌트가 있는 복잡한 페이지에서도 하나의 Suspense로 모든 로딩 상태를 관리할 수 있습니다. 기존에는 const [loading, setLoading] = useState(true)로 로딩 상태를 직접 관리했다면, 이제는 Suspense가 자동으로 처리합니다.

Suspense의 핵심 특징은 선언적 API, 자동 로딩 감지, 그리고 여러 컴포넌트의 로딩을 하나로 통합하는 능력입니다. 이러한 특징들이 코드를 더 간결하고 유지보수하기 쉽게 만듭니다.

코드 예제

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

const HeavyChart = lazy(() => import('./components/HeavyChart'));
const DataTable = lazy(() => import('./components/DataTable'));

function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>
      {/* Suspense로 감싸서 로딩 UI 지정 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        {/* 이 안의 모든 lazy 컴포넌트가 준비될 때까지 fallback 표시 */}
        <HeavyChart data={chartData} />
        <DataTable rows={tableData} />
      </Suspense>
    </div>
  );
}

설명

이것이 하는 일: Suspense는 자식 컴포넌트 트리를 모니터링하며, Promise가 throw되면(lazy 컴포넌트가 로딩 중이면) 자동으로 fallback을 렌더링합니다. 첫 번째로, Suspense 내부의 컴포넌트가 렌더링을 시도할 때 lazy 컴포넌트가 아직 로드되지 않았다면, React는 특수한 Promise를 throw합니다.

이는 일반적인 에러가 아니라 "아직 준비되지 않았다"는 신호입니다. Suspense 경계가 이 Promise를 catch하고 fallback prop에 지정된 컴포넌트를 대신 렌더링합니다.

그 다음으로, 백그라운드에서 lazy 컴포넌트의 다운로드와 파싱이 진행됩니다. 여러 lazy 컴포넌트가 있다면 모두 병렬로 로딩됩니다.

Suspense는 내부의 모든 컴포넌트가 준비될 때까지 기다립니다. 이 과정에서 사용자는 fallback UI를 보게 됩니다.

마지막으로, 모든 컴포넌트가 준비되면 Suspense가 fallback을 제거하고 실제 컴포넌트들을 렌더링합니다. 이때 React는 자동으로 트리를 다시 렌더링하여 최종 UI를 표시합니다.

전환은 부드럽고 자연스럽게 이루어집니다. 여러분이 이 코드를 사용하면 복잡한 로딩 상태 관리 없이도 전문적인 로딩 경험을 제공할 수 있습니다.

특히 여러 비동기 컴포넌트를 다룰 때 각각의 로딩 상태를 추적할 필요가 없어져 코드가 훨씬 깔끔해집니다.

실전 팁

💡 Suspense는 lazy 컴포넌트를 직접 감싸는 부모에 있을 필요는 없습니다. 트리 어디에든 배치할 수 있으며 가장 가까운 Suspense 경계가 작동합니다.

💡 fallback은 가능한 한 간단하게 유지하세요. 복잡한 컴포넌트를 fallback으로 사용하면 오히려 성능이 저하될 수 있습니다.

💡 Suspense 내부에서 에러가 발생하면 Error Boundary로 전파됩니다. 두 가지를 함께 사용하여 로딩과 에러를 모두 처리하세요.

💡 개발 모드에서는 로딩이 너무 빨라 fallback을 보기 어려울 수 있습니다. Chrome DevTools의 Network 탭에서 속도를 제한하여 테스트하세요.

💡 React 18부터는 Suspense가 더 강력해져서 데이터 페칭에도 사용할 수 있지만, 아직 대부분의 경우 컴포넌트 로딩에 사용됩니다.


3. Suspense fallback

시작하며

여러분이 "로딩 중..."이라는 단순한 텍스트만 표시했는데 사용자들이 "앱이 멈췄나요?"라고 물어보는 경험을 해보셨나요? 특히 3G 네트워크에서는 로딩이 5초 이상 걸릴 수 있어서 사용자는 불안해합니다.

이런 문제는 로딩 UI가 충분히 명확하지 않거나 시각적 피드백이 부족할 때 발생합니다. 단순한 텍스트는 정적이어서 무언가 진행 중임을 확신시키지 못합니다.

사용자는 앱이 작동하는지 아니면 버그인지 구분할 수 없습니다. 바로 이럴 때 필요한 것이 잘 디자인된 fallback UI입니다.

스피너, 스켈레톤, 진행 표시 등으로 사용자에게 명확한 피드백을 제공할 수 있습니다.

개요

간단히 말해서, fallback은 Suspense의 prop으로, 로딩 중일 때 표시할 React 엘리먼트를 지정합니다. 왜 좋은 fallback UI가 중요한가요?

사용자 경험 연구에 따르면, 명확한 로딩 피드백이 있으면 사용자는 최대 2배 더 오래 기다릴 의향이 있습니다. 예를 들어, 데이터 테이블을 로드할 때 테이블과 같은 모양의 스켈레톤을 보여주면 사용자는 무엇이 로드되는지 예상할 수 있어 더 인내심을 가집니다.

기존에는 조건부 렌더링으로 {loading ? <Spinner /> : <Content />}를 사용했다면, 이제는 Suspense fallback으로 더 선언적으로 처리합니다.

fallback의 핵심 특징은 어떤 React 엘리먼트든 사용 가능하다는 점, 컴포넌트의 레이아웃을 미리 예고할 수 있다는 점, 그리고 여러 로딩 상태를 일관되게 처리할 수 있다는 점입니다. 이러한 특징들이 전문적이고 통일된 사용자 경험을 만듭니다.

코드 예제

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

// 재사용 가능한 로딩 컴포넌트
function LoadingSpinner() {
  return (
    <div style={{ textAlign: 'center', padding: '20px' }}>
      {/* CSS 애니메이션이 적용된 스피너 */}
      <div className="spinner" />
      <p>데이터를 불러오는 중입니다...</p>
      <small>잠시만 기다려주세요</small>
    </div>
  );
}

const HeavyChart = lazy(() => import('./components/HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyChart />
    </Suspense>
  );
}

설명

이것이 하는 일: fallback prop은 Suspense 내부의 컴포넌트가 준비될 때까지 임시로 표시할 대체 UI를 정의합니다. 첫 번째로, fallback에는 어떤 React 엘리먼트든 전달할 수 있습니다.

단순한 문자열, JSX 엘리먼트, 완전한 컴포넌트 모두 가능합니다. 위 예제에서는 LoadingSpinner라는 별도 컴포넌트를 만들어 재사용성을 높였습니다.

이 컴포넌트는 CSS 애니메이션, 메시지, 그리고 추가 설명을 포함하여 사용자에게 명확한 피드백을 제공합니다. 그 다음으로, fallback UI는 로드될 컴포넌트의 레이아웃과 유사하게 디자인하는 것이 좋습니다.

예를 들어 테이블을 로드한다면 테이블 모양의 스켈레톤을, 카드 리스트를 로드한다면 카드 모양의 플레이스홀더를 보여주세요. 이렇게 하면 로딩이 완료됐을 때 레이아웃 시프트(Layout Shift)가 최소화되어 더 부드러운 전환을 만들 수 있습니다.

마지막으로, 여러 Suspense를 사용할 때 각각 다른 fallback을 지정할 수 있습니다. 페이지의 중요한 부분은 더 상세한 로딩 UI를, 덜 중요한 부분은 간단한 스피너를 사용하는 식으로 우선순위를 표현할 수 있습니다.

이는 사용자에게 어떤 콘텐츠가 더 중요한지 암묵적으로 전달합니다. 여러분이 잘 디자인된 fallback을 사용하면 사용자가 로딩 시간을 더 짧게 체감하고, 이탈률이 감소하며, 전반적인 앱 품질에 대한 인식이 향상됩니다.

실제로 스켈레톤 UI를 도입한 후 사용자 만족도가 30% 이상 증가한 사례도 있습니다.

실전 팁

💡 스켈레톤 UI 라이브러리(react-loading-skeleton, react-content-loader)를 사용하면 전문적인 로딩 화면을 빠르게 만들 수 있습니다.

💡 fallback 컴포넌트에는 상태나 부수효과를 넣지 마세요. 로딩이 완료되면 언마운트되므로 정리되지 않은 상태가 남을 수 있습니다.

💡 로딩이 1초 이내로 끝날 것으로 예상되면 fallback을 지연시켜 표시하세요(startTransition 사용). 짧은 로딩에서 스피너가 깜빡이면 오히려 산만합니다.

💡 Accessibility를 위해 fallback에 role="status"와 aria-live="polite"를 추가하여 스크린 리더 사용자에게도 로딩 상태를 알리세요.

💡 개발 도구에서 "Highlight updates when components render"를 켜서 fallback과 실제 컴포넌트 간 전환을 시각적으로 확인하세요.


4. 여러 컴포넌트 로딩

시작하며

여러분이 대시보드 페이지에 차트, 테이블, 맵 등 여러 무거운 컴포넌트를 배치했는데 각각 다른 시점에 로드되어 페이지가 계속 깜빡이는 경험을 해보셨나요? 차트가 먼저 나타나고, 3초 후 테이블이 나타나고, 또 2초 후 맵이 나타나면서 사용자가 혼란스러워합니다.

이 문제는 여러 lazy 컴포넌트를 각각 별도의 Suspense로 감쌌을 때 발생합니다. 각 컴포넌트가 독립적으로 로드되고 렌더링되면서 레이아웃이 계속 변경되고, 사용자는 언제 페이지가 완성될지 알 수 없습니다.

이는 Cumulative Layout Shift(CLS)를 증가시켜 Core Web Vitals 점수에도 악영향을 미칩니다. 바로 이럴 때 필요한 것이 하나의 Suspense로 여러 컴포넌트를 묶는 전략입니다.

모든 컴포넌트가 준비될 때까지 기다렸다가 한 번에 표시하여 안정적인 경험을 제공할 수 있습니다.

개요

간단히 말해서, 하나의 Suspense 경계 안에 여러 lazy 컴포넌트를 배치하면 모든 컴포넌트가 로드될 때까지 fallback이 유지됩니다. 왜 이 접근법이 유용한가요?

관련된 컴포넌트들은 함께 표시될 때 의미가 있습니다. 예를 들어, 차트와 그 차트를 설명하는 범례는 동시에 나타나야 합니다.

하나만 먼저 보이면 불완전한 정보를 제공하는 것입니다. 또한 여러 번의 레이아웃 변경보다 한 번의 긴 로딩이 사용자 경험 측면에서 더 나을 때가 많습니다.

기존에는 각 컴포넌트마다 <Suspense>를 감싸서 개별적으로 로딩했다면, 이제는 논리적으로 관련된 컴포넌트들을 하나의 Suspense로 묶을 수 있습니다. 이 패턴의 핵심 특징은 병렬 로딩과 순차적 렌더링의 조합, 일관된 로딩 경험, 그리고 레이아웃 안정성입니다.

이러한 특징들이 더 전문적이고 안정적인 애플리케이션을 만듭니다.

코드 예제

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

const SalesChart = lazy(() => import('./SalesChart'));
const RevenueTable = lazy(() => import('./RevenueTable'));
const UserMap = lazy(() => import('./UserMap'));

function Dashboard() {
  return (
    <div>
      <h1>비즈니스 대시보드</h1>
      {/* 하나의 Suspense로 세 컴포넌트를 모두 감싸기 */}
      <Suspense fallback={<div>대시보드를 불러오는 중...</div>}>
        {/* 이 세 컴포넌트가 모두 준비될 때까지 fallback 유지 */}
        <SalesChart />
        <RevenueTable />
        <UserMap />
      </Suspense>
    </div>
  );
}

설명

이것이 하는 일: Suspense는 내부의 모든 Promise가 resolve될 때까지 fallback을 유지하고, 모든 컴포넌트가 준비되면 동시에 렌더링합니다. 첫 번째로, Dashboard가 렌더링을 시작하면 세 개의 lazy 컴포넌트가 거의 동시에 로딩을 시작합니다.

브라우저는 세 개의 청크 파일을 병렬로 다운로드합니다. 이는 매우 효율적입니다 - 순차적으로 로드하는 것보다 훨씬 빠릅니다.

네트워크 탭을 보면 세 파일이 동시에 요청되는 것을 확인할 수 있습니다. 그 다음으로, Suspense는 세 컴포넌트 중 어느 하나라도 아직 준비되지 않았다면 계속 fallback을 표시합니다.

SalesChart가 1초 만에 로드되고 RevenueTable이 2초 만에 로드되더라도, UserMap이 3초가 걸린다면 3초 동안 fallback이 유지됩니다. 이것이 핵심입니다 - 부분적인 렌더링을 방지하여 안정성을 확보합니다.

마지막으로, 모든 컴포넌트가 준비되면 React가 한 번에 모든 것을 렌더링합니다. 사용자 관점에서는 "로딩 중..." 메시지가 사라지면서 완전한 대시보드가 한 번에 나타납니다.

레이아웃 시프트가 없고, 깜빡임도 없으며, 명확한 before-after 전환을 경험합니다. 여러분이 이 패턴을 사용하면 더 안정적이고 전문적인 로딩 경험을 제공할 수 있습니다.

특히 데이터 대시보드, 복잡한 폼, 멀티미디어 갤러리 등 여러 관련 컴포넌트가 있는 페이지에서 큰 효과를 발휘합니다.

실전 팁

💡 관련 없는 컴포넌트까지 하나의 Suspense로 묶지 마세요. 사이드바와 메인 콘텐츠는 별도의 Suspense로 분리하여 독립적으로 로드하는 것이 좋습니다.

💡 가장 느린 컴포넌트를 기준으로 전체 로딩 시간이 결정됩니다. 만약 한 컴포넌트가 특히 느리다면 해당 컴포넌트만 별도 Suspense로 분리하는 것을 고려하세요.

💡 React DevTools의 Profiler를 사용하여 각 컴포넌트의 로딩 시간을 측정하고 어떻게 그룹화할지 결정하세요.

💡 사용자 행동 분석을 통해 어떤 컴포넌트가 실제로 함께 사용되는지 파악하고, 그에 맞게 Suspense 경계를 설정하세요.

💡 개발 환경에서는 Fast Refresh가 Suspense 동작을 방해할 수 있습니다. 프로덕션 빌드로 실제 로딩 경험을 테스트하세요.


5. 라우트 기반 코드 스플리팅

시작하며

여러분이 10개 이상의 페이지를 가진 React 애플리케이션을 만들었는데 홈페이지에 접속하자마자 모든 페이지의 코드가 다운로드되는 상황을 경험해보셨나요? 사용자는 홈페이지만 보고 싶은데 관리자 페이지, 설정 페이지, 통계 페이지까지 모든 코드를 다운로드해야 합니다.

이 문제는 React Router나 Next.js 같은 라우팅 라이브러리를 사용할 때 흔히 발생합니다. 모든 라우트 컴포넌트를 최상단에서 임포트하면 번들러가 하나의 거대한 파일로 합쳐버립니다.

사용자가 실제로 방문하지 않을 페이지까지 미리 로드하는 것은 엄청난 낭비입니다. 바로 이럴 때 필요한 것이 라우트 기반 코드 스플리팅입니다.

각 페이지를 별도의 청크로 분리하여 사용자가 실제로 방문할 때만 로드할 수 있습니다.

개요

간단히 말해서, 라우트 기반 코드 스플리팅은 각 페이지 컴포넌트를 React.lazy로 감싸서 해당 라우트로 이동할 때만 코드를 로드하는 패턴입니다. 왜 이것이 최고의 코드 스플리팅 전략인가요?

라우트는 자연스러운 분할 지점입니다. 사용자는 명확하게 페이지를 전환하고, 페이지 간 이동 시 로딩을 기대합니다.

예를 들어, 블로그 사이트에서 "글 쓰기" 페이지는 실제로 글을 작성하는 사용자에게만 필요합니다. 대부분의 독자는 이 기능을 사용하지 않으므로 해당 코드를 미리 로드할 이유가 없습니다.

기존에는 모든 라우트를 import Home from './pages/Home'로 정적 임포트했다면, 이제는 각 라우트를 동적으로 임포트하여 필요할 때만 로드합니다. 라우트 기반 스플리팅의 핵심 특징은 명확한 경계, 사용자 경험과의 자연스러운 조화, 그리고 극적인 초기 로딩 개선입니다.

이러한 특징들이 10페이지 앱의 초기 번들을 80% 이상 줄일 수 있습니다.

코드 예제

import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 각 페이지를 lazy로 동적 임포트
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      {/* 전체 라우팅을 Suspense로 감싸기 */}
      <Suspense fallback={<div>페이지를 불러오는 중...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

설명

이것이 하는 일: 라우트 기반 코드 스플리팅은 각 페이지를 별도의 JavaScript 청크로 분리하여 사용자가 실제로 방문하는 페이지의 코드만 다운로드하게 합니다. 첫 번째로, 애플리케이션이 처음 로드될 때는 메인 번들과 현재 라우트(예: Home)의 코드만 다운로드됩니다.

About, Dashboard, Settings의 코드는 아직 다운로드되지 않습니다. 이로써 초기 로딩 시간이 극적으로 단축됩니다.

예를 들어 각 페이지가 200KB라면, 4개 페이지의 정적 임포트는 800KB지만 동적 임포트는 200KB만 필요합니다. 그 다음으로, 사용자가 "/dashboard"로 이동하면 React Router가 라우트 변경을 감지하고 Dashboard 컴포넌트를 렌더링하려고 시도합니다.

이때 lazy가 Dashboard.chunk.js 파일을 동적으로 다운로드합니다. Suspense가 로딩을 감지하고 fallback을 표시하는 동안, 백그라운드에서 필요한 코드가 다운로드됩니다.

마지막으로, 코드가 로드되면 Suspense가 fallback을 제거하고 실제 Dashboard 페이지를 렌더링합니다. 이후 같은 페이지를 다시 방문하면 이미 로드된 코드를 재사용하므로 즉시 렌더링됩니다.

브라우저 캐싱까지 고려하면 두 번째 방문은 거의 즉각적입니다. 여러분이 이 패턴을 사용하면 초기 로딩 속도가 크게 개선되고, 사용자는 필요한 기능의 코드만 다운로드하게 됩니다.

특히 SPA(Single Page Application)에서는 필수적인 최적화 기법이며, Lighthouse 점수와 Core Web Vitals 지표가 크게 향상됩니다.

실전 팁

💡 홈페이지처럼 대부분 사용자가 방문하는 페이지는 lazy를 사용하지 않는 것이 좋을 수 있습니다. 주요 페이지는 preload하여 즉시 렌더링하세요.

💡 중첩된 라우트가 있다면 부모 라우트와 자식 라우트를 각각 분리하여 더욱 세밀한 최적화가 가능합니다.

💡 React Router v6의 loader와 결합하면 데이터 페칭과 코드 로딩을 동시에 처리할 수 있습니다.

💡 각 라우트의 번들 크기를 webpack-bundle-analyzer로 분석하여 예상치 못한 의존성이 포함되지 않았는지 확인하세요.

💡 공통 컴포넌트(헤더, 푸터, 레이아웃)는 lazy로 만들지 마세요. 모든 페이지에서 사용되므로 메인 번들에 포함하는 것이 효율적입니다.


6. Error Boundary 통합

시작하며

여러분이 네트워크가 불안정한 환경에서 사용자가 페이지를 로드하려다 실패했는데 화면이 완전히 하얗게 표시되고 아무런 안내도 없는 상황을 경험해보셨나요? 사용자는 새로고침을 해야 할지, 기다려야 할지, 앱이 고장난 건지 전혀 알 수 없습니다.

이 문제는 lazy 컴포넌트 로딩이 실패했을 때 발생합니다. 네트워크 오류, 서버 다운, CDN 문제 등 다양한 이유로 청크 파일을 다운로드하지 못할 수 있습니다.

Suspense는 로딩 상태만 처리하고 에러는 처리하지 않기 때문에, 에러가 발생하면 React가 전체 컴포넌트 트리를 언마운트해버립니다. 바로 이럴 때 필요한 것이 Error Boundary와 Suspense의 통합입니다.

로딩 실패를 우아하게 처리하고 사용자에게 복구 옵션을 제공할 수 있습니다.

개요

간단히 말해서, Error Boundary는 자식 컴포넌트에서 발생한 에러를 catch하여 대체 UI를 표시하는 React 컴포넌트입니다. Suspense와 함께 사용하면 로딩과 에러를 모두 처리할 수 있습니다.

왜 이것이 필수적인가요? 실제 운영 환경에서는 항상 예상치 못한 일이 발생합니다.

사용자의 5%는 느린 네트워크를 사용하고, 2%는 광고 차단기나 방화벽 때문에 특정 리소스를 로드하지 못할 수 있습니다. 예를 들어, 기업 네트워크에서는 CDN이 차단될 수 있고, 모바일 환경에서는 터널이나 지하철에서 연결이 끊길 수 있습니다.

이런 상황에서도 앱이 완전히 망가지지 않아야 합니다. 기존에는 try-catch나 .catch()로 개별적으로 에러를 처리했다면, 이제는 Error Boundary가 선언적으로 컴포넌트 트리 레벨에서 에러를 처리합니다.

Error Boundary의 핵심 특징은 컴포넌트 트리의 에러 격리, 우아한 성능 저하(Graceful Degradation), 그리고 사용자 친화적인 복구 메커니즘입니다. 이러한 특징들이 견고한 프로덕션 애플리케이션을 만듭니다.

코드 예제

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

// Error Boundary 클래스 컴포넌트
class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>페이지 로딩에 실패했습니다</h2>
          <p>{this.state.error.message}</p>
          <button onClick={() => window.location.reload()}>
            새로고침
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>로딩 중...</div>}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

설명

이것이 하는 일: Error Boundary는 Suspense 내부에서 발생한 에러를 catch하고, 전체 앱이 크래시되는 대신 사용자 친화적인 에러 메시지를 표시합니다. 첫 번째로, Dashboard 컴포넌트의 청크 파일 다운로드가 실패하면(404, 네트워크 오류 등), 브라우저는 에러를 throw합니다.

이 에러는 React의 에러 처리 메커니즘을 통해 상위로 전파됩니다. Suspense는 이 에러를 처리하지 않고 더 상위로 전달하며, Error Boundary가 이를 catch합니다.

그 다음으로, Error Boundary의 getDerivedStateFromError 메서드가 호출되어 hasError 상태를 true로 설정합니다. 이는 컴포넌트를 에러 상태로 전환시킵니다.

componentDidCatch 메서드를 추가하면 Sentry나 LogRocket 같은 에러 추적 서비스로 에러 정보를 전송할 수도 있습니다. 마지막으로, Error Boundary가 자식 컴포넌트 대신 에러 UI를 렌더링합니다.

사용자는 무슨 일이 일어났는지 알 수 있고, 새로고침 버튼을 통해 직접 복구를 시도할 수 있습니다. 더 정교한 구현에서는 자동 재시도, 오프라인 모드로 전환, 또는 캐시된 데이터 표시 등의 옵션을 제공할 수 있습니다.

여러분이 Error Boundary를 적절히 배치하면 일부 컴포넌트에서 에러가 발생해도 앱의 나머지 부분은 정상적으로 작동합니다. 예를 들어, 사이드바는 실패해도 메인 콘텐츠는 여전히 사용할 수 있습니다.

이는 사용자 경험과 앱의 견고성을 크게 향상시킵니다.

실전 팁

💡 Error Boundary는 클래스 컴포넌트로만 만들 수 있습니다. react-error-boundary 라이브러리를 사용하면 훅 기반으로 더 쉽게 구현할 수 있습니다.

💡 여러 레벨에 Error Boundary를 배치하세요. 최상위에 하나, 주요 섹션마다 하나씩 두면 에러의 영향 범위를 제한할 수 있습니다.

💡 에러 정보를 항상 로깅 서비스로 전송하세요. 사용자가 보고하지 않은 문제도 파악할 수 있습니다.

💡 개발 모드에서 Error Boundary가 제대로 작동하는지 테스트하려면 프로덕션 빌드를 사용하세요. 개발 모드는 에러를 다르게 처리합니다.

💡 청크 로딩 실패는 종종 캐시 문제입니다. 새 배포 후 오래된 index.html이 존재하지 않는 청크를 참조할 수 있으므로, 에러 발생 시 자동으로 페이지를 새로고침하는 로직을 고려하세요.


7. Preloading 전략

시작하며

여러분이 사용자가 "대시보드" 버튼에 마우스를 올렸는데 클릭 후에야 코드 다운로드가 시작되어 3초 동안 로딩 화면만 보는 상황을 경험해보셨나요? 사용자는 이미 어디로 갈지 명확히 의도를 표현했는데, 그제야 준비를 시작하는 것은 비효율적입니다.

이 문제는 lazy 컴포넌트가 너무 늦게 로딩을 시작하기 때문에 발생합니다. 기본적으로 컴포넌트가 실제로 렌더링될 때까지 기다리는데, 사용자의 의도는 이미 그 전에 파악할 수 있습니다.

링크에 마우스를 올리거나, 탭에 포커스하거나, 심지어 페이지 로딩이 완료된 후 유휴 시간을 활용할 수도 있습니다. 바로 이럴 때 필요한 것이 preloading 전략입니다.

사용자가 실제로 필요로 하기 전에 미리 코드를 로드하여 거의 즉각적인 페이지 전환을 만들 수 있습니다.

개요

간단히 말해서, preloading은 lazy 컴포넌트를 사용자가 필요로 하기 전에 미리 로드하는 기법입니다. 왜 이것이 중요한가요?

사용자는 빠른 반응을 기대합니다. 연구에 따르면 100ms 이내의 응답은 즉각적으로 느껴지지만, 1초 이상 걸리면 사용자의 사고 흐름이 끊깁니다.

예를 들어, 사용자가 "프로필 편집" 링크에 마우스를 올린 순간부터 실제로 클릭하기까지 평균 200-300ms의 시간이 있습니다. 이 시간을 활용하면 클릭 시 즉시 페이지를 표시할 수 있습니다.

기존에는 컴포넌트가 렌더링될 때까지 기다렸다면, 이제는 사용자의 의도를 사전에 감지하고 미리 준비할 수 있습니다. Preloading의 핵심 특징은 예측적 로딩, 유휴 시간 활용, 그리고 사용자 경험의 극적 개선입니다.

이러한 특징들이 네이티브 앱과 같은 빠른 반응성을 웹 애플리케이션에서도 구현할 수 있게 합니다.

코드 예제

import React, { lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';

// lazy 컴포넌트를 변수에 저장
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function Navigation() {
  // 마우스 오버 시 preload
  const handleMouseEnter = (component) => {
    // lazy 컴포넌트의 preload 메서드 호출
    component._payload._result || component._payload._fn();
  };

  return (
    <nav>
      <Link
        to="/dashboard"
        onMouseEnter={() => handleMouseEnter(Dashboard)}
      >
        대시보드
      </Link>
      <Link
        to="/settings"
        onMouseEnter={() => handleMouseEnter(Settings)}
      >
        설정
      </Link>
    </nav>
  );
}

설명

이것이 하는 일: Preloading은 사용자 인터랙션(마우스 오버, 포커스 등)을 감지하여 해당 컴포넌트의 코드를 미리 다운로드합니다. 첫 번째로, 사용자가 "대시보드" 링크에 마우스를 올리면 onMouseEnter 이벤트가 발생합니다.

이때 Dashboard lazy 컴포넌트의 import 함수를 직접 호출하여 청크 파일 다운로드를 시작합니다. 이는 실제로 컴포넌트를 렌더링하지는 않고 코드만 미리 가져옵니다.

사용자가 클릭을 고민하는 동안 백그라운드에서 다운로드가 진행됩니다. 그 다음으로, 사용자가 실제로 링크를 클릭하면 React Router가 Dashboard 컴포넌트를 렌더링하려고 시도합니다.

하지만 이미 코드가 로드되어 있기 때문에 추가 네트워크 요청 없이 즉시 렌더링이 시작됩니다. Suspense가 활성화될 틈도 없이 컴포넌트가 바로 나타나므로 로딩 화면을 보지 않게 됩니다.

마지막으로, 이 패턴을 더 발전시켜 idle time에 모든 주요 라우트를 preload하거나, 사용자의 이전 행동 패턴을 분석하여 다음에 방문할 가능성이 높은 페이지를 예측적으로 로드할 수도 있습니다. 예를 들어, 90%의 사용자가 홈페이지 방문 후 대시보드로 이동한다면, 홈페이지가 로드된 직후 대시보드를 자동으로 preload할 수 있습니다.

여러분이 preloading을 적절히 활용하면 사용자는 "이 앱은 정말 빠르다"고 느끼게 됩니다. 실제 로딩 시간은 같지만 체감 성능이 크게 향상되어, 사용자 만족도와 전환율이 증가합니다.

실전 팁

💡 모든 페이지를 preload하지 마세요. 자주 사용되는 주요 페이지만 선택적으로 preload하여 불필요한 대역폭 낭비를 방지하세요.

💡 모바일 환경에서는 데이터 요금과 배터리를 고려하여 preloading을 제한하거나 비활성화하는 것을 고려하세요.

💡 React Router v6에서는 <Link prefetch="intent">를 사용하여 더 쉽게 preloading을 구현할 수 있습니다.

💡 requestIdleCallback API를 사용하여 브라우저가 유휴 상태일 때만 preload하면 주요 작업에 영향을 주지 않습니다.

💡 Preload한 컴포넌트를 5분 내에 사용하지 않으면 메모리 효율을 위해 unload하는 로직을 고려하세요.


8. Suspense 중첩

시작하며

여러분이 복잡한 대시보드를 만들었는데 헤더의 작은 아이콘이 로딩 중이라는 이유로 전체 페이지가 로딩 화면만 보이는 상황을 경험해보셨나요? 메인 콘텐츠는 이미 준비되었는데 사소한 부분 때문에 사용자가 아무것도 볼 수 없다면 매우 비효율적입니다.

이 문제는 모든 것을 하나의 Suspense로 처리할 때 발생합니다. 중요한 콘텐츠와 덜 중요한 콘텐츠를 구분하지 않으면 가장 느린 컴포넌트가 전체 페이지를 지연시킵니다.

사용자는 80%의 콘텐츠를 볼 수 있었는데 20%의 부가 기능 때문에 기다려야 합니다. 바로 이럴 때 필요한 것이 Suspense 중첩입니다.

중요도에 따라 여러 Suspense 경계를 만들어 점진적으로 콘텐츠를 표시할 수 있습니다.

개요

간단히 말해서, Suspense 중첩은 컴포넌트 트리의 여러 레벨에 Suspense를 배치하여 각 부분이 독립적으로 로딩되도록 하는 패턴입니다. 왜 이것이 필요한가요?

모든 콘텐츠가 동일한 중요도를 가지는 것은 아닙니다. 핵심 콘텐츠는 빠르게 표시하고, 부가 기능은 나중에 로드해도 괜찮습니다.

예를 들어, 뉴스 기사에서 본문은 즉시 보여야 하지만 댓글 섹션은 조금 늦게 로드되어도 문제없습니다. 이런 우선순위를 Suspense 경계로 표현할 수 있습니다.

기존에는 all-or-nothing 방식으로 모든 것이 준비될 때까지 기다렸다면, 이제는 점진적으로 콘텐츠를 공개하여 더 빠른 체감 로딩을 만듭니다. Suspense 중첩의 핵심 특징은 독립적인 로딩 경계, 우선순위 기반 렌더링, 그리고 Progressive Enhancement입니다.

이러한 특징들이 더 세밀하고 사용자 친화적인 로딩 경험을 만듭니다.

코드 예제

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

const MainContent = lazy(() => import('./MainContent'));
const Sidebar = lazy(() => import('./Sidebar'));
const Comments = lazy(() => import('./Comments'));

function ArticlePage() {
  return (
    <div>
      {/* 최상위: 핵심 콘텐츠 */}
      <Suspense fallback={<div>기사를 불러오는 중...</div>}>
        <MainContent />

        {/* 중첩: 부가 콘텐츠 */}
        <Suspense fallback={<div>사이드바 로딩 중...</div>}>
          <Sidebar />
        </Suspense>

        {/* 중첩: 덜 중요한 콘텐츠 */}
        <Suspense fallback={<small>댓글 로딩 중...</small>}>
          <Comments />
        </Suspense>
      </Suspense>
    </div>
  );
}

설명

이것이 하는 일: Suspense 중첩은 컴포넌트 트리를 여러 로딩 구역으로 나누어, 각 구역이 자체적으로 로딩 상태를 관리하고 준비되는 대로 표시되도록 합니다. 첫 번째로, ArticlePage가 렌더링을 시작하면 최상위 Suspense가 MainContent의 로딩을 감지합니다.

MainContent가 로드되는 동안 "기사를 불러오는 중..." 메시지가 표시됩니다. 이때 Sidebar와 Comments도 병렬로 다운로드를 시작하지만, 중첩된 Suspense 경계로 인해 MainContent의 로딩에 영향을 주지 않습니다.

그 다음으로, MainContent가 준비되면 최상위 Suspense가 해제되고 MainContent가 화면에 나타납니다. 동시에 중첩된 Suspense들이 활성화되어 각각의 fallback을 표시합니다.

사용자는 이제 핵심 기사 내용을 읽을 수 있고, 사이드바와 댓글은 백그라운드에서 로드됩니다. 마지막으로, Sidebar가 준비되면 "사이드바 로딩 중..." 메시지가 실제 Sidebar로 교체되고, 조금 후 Comments가 준비되면 댓글 섹션도 나타납니다.

각 전환은 독립적으로 발생하므로 다른 섹션에 영향을 주지 않습니다. 사용자 관점에서는 페이지가 점진적으로 풍부해지는 부드러운 경험을 하게 됩니다.

여러분이 Suspense 중첩을 효과적으로 사용하면 First Contentful Paint와 Time to Interactive 지표가 크게 개선됩니다. 사용자는 더 빨리 콘텐츠를 소비하기 시작할 수 있고, 전체적인 체감 성능이 향상됩니다.

특히 콘텐츠가 풍부한 애플리케이션에서 필수적인 패턴입니다.

실전 팁

💡 Suspense 경계를 너무 많이 만들지 마세요. 3-4개 레벨 이상은 오히려 복잡성을 증가시키고 코드를 읽기 어렵게 만듭니다.

💡 각 Suspense의 fallback은 해당 콘텐츠의 중요도를 반영하세요. 핵심 콘텐츠는 큰 스피너, 부가 콘텐츠는 작은 로딩 표시를 사용하여 시각적 위계를 표현하세요.

💡 React 18의 startTransition과 결합하면 급하지 않은 업데이트를 백그라운드로 보내 더 부드러운 경험을 만들 수 있습니다.

💡 중첩된 Suspense는 에러 처리도 독립적입니다. 각 섹션마다 Error Boundary를 추가하여 한 부분의 실패가 전체에 영향을 주지 않도록 하세요.

💡 Analytics를 추가하여 각 섹션의 로딩 시간을 추적하고, 어떤 부분이 사용자 경험에 가장 큰 영향을 주는지 데이터 기반으로 최적화하세요.


#React#Suspense#LazyLoading#CodeSplitting#Performance

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.