React Suspense와 지연 로딩

React 공식 문서를 기반으로 작성된 Suspense와 lazy loading 가이드

들어가며

왜 지연 로딩이 필요한가?

상상해보세요. 사용자가 여러분의 React 앱에 처음 접속했을 때, 앱의 모든 페이지, 모든 기능, 모든 컴포넌트의 코드를 한 번에 다운로드해야 한다면 어떨까요?

// 모든 코드가 하나의 번들로 묶인 경우
import Home from './pages/Home';           // 100KB
import Dashboard from './pages/Dashboard'; // 200KB
import Settings from './pages/Settings';   // 150KB
import Admin from './pages/Admin';         // 300KB
import Reports from './pages/Reports';     // 250KB

// 사용자가 Home만 보고 싶어도 총 1000KB를 다운로드!

현실적인 문제:

  • 사용자는 Home 페이지만 보고 싶은데, Admin 페이지 코드까지 다운로드
  • 3G 환경에서는 1MB 다운로드에 10초 이상 소요
  • 구글 연구에 따르면 로딩 시간이 3초를 넘으면 53%의 모바일 사용자가 페이지를 떠남

번들 크기는 어떻게 커지는가?

React 앱을 개발하다 보면 번들 크기는 빠르게 증가합니다:

초기 프로젝트:           50KB
+ 차트 라이브러리:      +200KB (Chart.js)
+ 에디터 라이브러리:    +300KB (Draft.js)
+ PDF 뷰어:            +150KB
+ 이미지 에디터:        +250KB
+ 기타 의존성:          +150KB
= 총 번들 크기:         1.1MB

하지만 대부분의 사용자는 이 기능들을 모두 사용하지 않습니다. 차트가 필요한 사람만 차트를, 에디터가 필요한 사람만 에디터를 로드하면 어떨까요?

Suspense와 lazy가 해결하는 방법

React의 Suspenselazy() 함수는 바로 이 문제를 해결합니다:

import { lazy, Suspense } from 'react';

// 필요할 때만 로드되는 컴포넌트들
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));

// 사용자가 Dashboard 페이지로 이동할 때만 해당 코드 다운로드!

결과:

  • 초기 번들: 100KB (70% 감소!)
  • Dashboard 접속 시: 추가 200KB만 다운로드
  • 초기 로딩 시간: 10초 → 2초

이 가이드에서 다루는 내용

이 문서는 React 공식 문서를 기반으로 작성되었으며, 다음 내용을 상세히 다룹니다:

기초부터 고급까지:

  1. Suspense와 lazy의 기본 개념 - 어떻게 동작하는지 이해하기
  2. 실전 사용 패턴 5가지 - 라우트 분할, 조건부 로딩, Prefetching 등
  3. React 버전별 차이점 - 16.6부터 19.2까지의 변화 (중요!)
  4. 5가지 실전 예제 - 대시보드, 모달, 탭, 에러 처리 등
  5. 7가지 베스트 프랙티스 - 프로덕션 환경에서 안전하게 사용하기
  6. 흔한 실수 6가지 - 피해야 할 안티 패턴

특히 중요한 내용:

  • ⚠️ React 18 vs 19의 중요한 차이: React 19에서 발생했던 “Waterfall” 문제와 해결 과정
  • 🎯 성능 측정 방법: Webpack Bundle Analyzer, Lighthouse 활용
  • 🚀 즉시 적용 가능한 코드: 복사해서 바로 사용할 수 있는 40개 이상의 예제

이 가이드를 읽고 나면, 여러분의 React 앱의 초기 로딩 시간을 50% 이상 줄일 수 있는 실전 기법을 완전히 이해하게 될 것입니다.

Suspense란?

로딩 상태를 선언적으로 다루기

기존의 React에서 비동기 작업(코드 로딩, 데이터 페칭 등)을 처리할 때는 다음과 같은 패턴을 사용했습니다:

function MyComponent() {
  const [isLoading, setIsLoading] = useState(true);
  const [component, setComponent] = useState(null);

  useEffect(() => {
    // 컴포넌트 동적 로드
    import('./HeavyComponent').then(module => {
      setComponent(() => module.default);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  const Component = component;
  return <Component />;
}

문제점:

  • 모든 컴포넌트마다 isLoading 상태 관리 필요
  • useEffect로 수동 로딩 처리
  • 코드가 복잡하고 반복적
  • 에러 처리까지 고려하면 더 복잡해짐

Suspense의 해결책:

<Suspense>는 이러한 비동기 작업의 로딩 상태를 선언적으로 처리할 수 있게 해주는 React 컴포넌트입니다. 자식 컴포넌트가 로딩을 완료할 때까지 대체 UI(폴백)를 자동으로 표시합니다.

// 훨씬 간단해진 코드
import { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

장점:

  • 로딩 상태를 직접 관리할 필요 없음
  • 코드가 간결하고 읽기 쉬움
  • 여러 비동기 컴포넌트를 하나의 Suspense로 관리 가능
  • React가 자동으로 로딩 상태를 추적

기본 개념

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <SomeComponent />
    </Suspense>
  );
}

이 코드에서:

  • SomeComponent가 로딩 중일 때 → <Loading /> 컴포넌트 표시
  • SomeComponent 로딩 완료 → 실제 컴포넌트 렌더링

Suspense의 Props

Props 타입 설명
children React 노드 렌더링할 실제 UI. 로딩 중이면 fallback 표시
fallback React 노드 로딩 중에 표시할 대체 UI (스피너, 스켈레톤 등)

Suspense를 활성화하는 방법

Suspense는 다음과 같은 경우에 활성화됩니다:

  1. lazy()를 통한 컴포넌트 코드 지연 로딩 (이 문서의 주요 주제)
  2. Suspense 지원 프레임워크의 데이터 페칭 (Relay, Next.js, Remix 등)
  3. use() 훅으로 Promise 읽기 (React 19+)

주의: useEffect나 이벤트 핸들러 내부의 데이터 페칭은 Suspense를 활성화하지 않습니다.

lazy()를 이용한 지연 로딩

lazy()컴포넌트 코드의 로딩을 실제로 렌더링될 때까지 연기할 수 있게 해주는 함수입니다.

기본 문법

import { lazy } from 'react';

const SomeComponent = lazy(load);

매개변수:

  • load: Promise를 반환하는 함수
    • 매개변수 없음
    • .default 속성이 유효한 React 컴포넌트여야 함
    • 함수형 컴포넌트, memo, forwardRef 컴포넌트 모두 가능

반환값:

  • React 컴포넌트를 반환
  • 코드 로딩 중에는 일시 중단(suspend) 상태

왜 lazy를 사용해야 하는가?

일반적인 import (정적):

// ❌ 번들에 즉시 포함됨 (사용하지 않아도)
import HeavyComponent from './HeavyComponent';

function App() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <button onClick={() => setShow(true)}>Show Heavy Component</button>
      {show && <HeavyComponent />}
    </div>
  );
}

이 경우 HeavyComponent는 사용자가 버튼을 클릭하지 않아도 초기 번들에 포함됩니다.

lazy를 사용한 동적 import:

// ✅ 실제로 필요할 때만 로드됨
import { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <button onClick={() => setShow(true)}>Show Heavy Component</button>
      {show && (
        <Suspense fallback={<div>Loading...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}

이제 HeavyComponent는 버튼을 클릭해야만 로드됩니다.

기본 사용법

예제 1: 단일 컴포넌트 지연 로딩

실제 사용 사례: 마크다운 에디터에서 프리뷰 기능

마크다운 프리뷰는 무거운 라이브러리(예: marked.js, highlight.js)를 필요로 합니다. 사용자가 프리뷰 버튼을 클릭하기 전까지는 이 코드들이 필요 없습니다.

import { lazy, Suspense } from 'react';

// 1. lazy로 컴포넌트 래핑
// MarkdownPreview는 약 200KB의 무거운 컴포넌트
const MarkdownPreview = lazy(() => import('./MarkdownPreview'));

function Editor() {
  const [showPreview, setShowPreview] = useState(false);

  return (
    <div>
      <button onClick={() => setShowPreview(!showPreview)}>
        Toggle Preview
      </button>

      {/* 2. Suspense로 감싸기 */}
      {showPreview && (
        <Suspense fallback={<div>Loading preview...</div>}>
          <MarkdownPreview />
        </Suspense>
      )}
    </div>
  );
}

동작 과정:

  1. 사용자가 “Toggle Preview” 버튼 클릭
  2. showPreviewtrue로 변경
  3. MarkdownPreview 컴포넌트 로드 시작
  4. 로딩 중: “Loading preview…” 표시
  5. 로딩 완료: 실제 컴포넌트 렌더링

성능 개선:

  • 초기 번들: 200KB 감소
  • 프리뷰 사용 안 하는 사용자: 200KB 절약
  • 프리뷰 사용하는 사용자: 필요할 때만 로드

예제 2: 여러 컴포넌트를 하나의 Suspense로 감싸기

실제 사용 사례: 사용자 프로필 페이지

사용자 프로필 페이지는 여러 섹션(프로필, 포스트, 댓글)으로 구성됩니다. 모든 섹션이 함께 표시되어야 하므로, 하나의 Suspense로 관리합니다.

import { lazy, Suspense } from 'react';

const UserProfile = lazy(() => import('./UserProfile'));
const UserPosts = lazy(() => import('./UserPosts'));
const UserComments = lazy(() => import('./UserComments'));

function UserPage({ userId }) {
  return (
    <div>
      <h1>User Dashboard</h1>

      {/* 모든 컴포넌트를 하나의 Suspense로 감싸기 */}
      <Suspense fallback={<div>Loading user data...</div>}>
        <UserProfile userId={userId} />
        <UserPosts userId={userId} />
        <UserComments userId={userId} />
      </Suspense>
    </div>
  );
}

주의: 이 경우 세 컴포넌트 중 하나라도 로딩 중이면, 전체가 폴백으로 표시됩니다.

트레이드오프:

  • 장점: 모든 데이터가 준비되면 한 번에 표시 (일관된 사용자 경험)
  • 단점: 가장 느린 컴포넌트가 전체 로딩 시간 결정

언제 사용하나요?

  • 모든 섹션이 함께 의미가 있을 때 (예: 프로필 + 포스트 + 댓글)
  • 부분적인 표시가 오히려 혼란스러울 때
  • 모든 컴포넌트의 로딩 시간이 비슷할 때

예제 3: 중첩된 Suspense (순차적 로딩)

실제 사용 사례: 아티스트 프로필 페이지

Biography는 중요한 정보이므로 먼저 보여주고, Albums는 그 다음에 로드해도 됩니다. 중첩된 Suspense를 사용하면 이런 우선순위를 구현할 수 있습니다.

import { lazy, Suspense } from 'react';

const Biography = lazy(() => import('./Biography'));  // 중요도: 높음 (빠르게 표시)
const Albums = lazy(() => import('./Albums'));        // 중요도: 보통 (나중에 표시)

function ArtistPage({ artistId }) {
  return (
    <div>
      <h1>Artist Profile</h1>

      {/* 외부 Suspense: Biography 로딩 */}
      <Suspense fallback={<BigSpinner />}>
        <Biography artistId={artistId} />

        {/* 내부 Suspense: Albums 로딩 */}
        <Suspense fallback={<AlbumsGlimmer />}>
          <Albums artistId={artistId} />
        </Suspense>
      </Suspense>
    </div>
  );
}

로딩 순서:

  1. 초기: BigSpinner 표시 (Biography 로딩 중)
  2. Biography 로드 완료: Biography 표시 + AlbumsGlimmer 표시 (Albums 로딩 중)
  3. Albums 로드 완료: 전체 컨텐츠 표시

사용자가 보는 화면:

[1단계]
┌─────────────────┐
│   Loading...    │  ← BigSpinner
└─────────────────┘

[2단계]
┌─────────────────┐
│   Biography     │  ← 실제 내용
│   ● Born: 1990  │
│   ● Genre: Pop  │
└─────────────────┘
┌─────────────────┐
│   ▢▢▢ ▢▢▢       │  ← AlbumsGlimmer (스켈레톤)
└─────────────────┘

[3단계]
┌─────────────────┐
│   Biography     │
│   ● Born: 1990  │
│   ● Genre: Pop  │
└─────────────────┘
┌─────────────────┐
│   Albums        │  ← 실제 내용
│   • Album 1     │
│   • Album 2     │
└─────────────────┘

왜 중첩 Suspense를 사용하나요?

  • 점진적 렌더링: 사용자가 콘텐츠를 단계적으로 볼 수 있음
  • 우선순위: 중요한 콘텐츠를 먼저 표시
  • 체감 성능 향상: 전체 로딩 시간은 같지만, 사용자는 더 빠르게 느낌

고급 패턴

패턴 1: 라우트 기반 코드 분할

가장 일반적이고 효과적인 패턴은 라우트(페이지) 단위로 코드를 분할하는 것입니다.

import { 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 fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

function PageLoader() {
  return (
    <div className="page-loader">
      <div className="spinner" />
      <p>Loading page...</p>
    </div>
  );
}

장점:

  • 사용자는 현재 페이지의 코드만 다운로드
  • 페이지 전환 시에만 추가 코드 로드
  • 초기 로딩 시간 대폭 감소

패턴 2: 조건부 렌더링과 함께 사용

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

// 무거운 컴포넌트들을 lazy로 로드
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const ImageEditor = lazy(() => import('./ImageEditor'));
const PdfViewer = lazy(() => import('./PdfViewer'));

function MediaViewer({ fileType, fileUrl }) {
  const [isLoaded, setIsLoaded] = useState(false);

  // 파일 타입에 따라 다른 컴포넌트 렌더링
  const renderMedia = () => {
    switch (fileType) {
      case 'video':
        return <VideoPlayer url={fileUrl} />;
      case 'image':
        return <ImageEditor url={fileUrl} />;
      case 'pdf':
        return <PdfViewer url={fileUrl} />;
      default:
        return <div>Unsupported file type</div>;
    }
  };

  return (
    <div>
      <h2>Media Viewer</h2>
      <Suspense fallback={<MediaLoader type={fileType} />}>
        {renderMedia()}
      </Suspense>
    </div>
  );
}

장점:

  • 사용자가 선택한 미디어 타입의 코드만 로드
  • 불필요한 코드 다운로드 방지

패턴 3: startTransition과 함께 사용 (React 18+)

startTransition을 사용하면 기존 콘텐츠를 유지하면서 백그라운드에서 새 콘텐츠를 로드할 수 있습니다.

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

const Tab1 = lazy(() => import('./Tab1'));
const Tab2 = lazy(() => import('./Tab2'));
const Tab3 = lazy(() => import('./Tab3'));

function TabbedInterface() {
  const [currentTab, setCurrentTab] = useState('tab1');
  const [isPending, startTransition] = useTransition();

  const handleTabChange = (tab) => {
    // startTransition으로 감싸면 현재 탭이 유지됨
    startTransition(() => {
      setCurrentTab(tab);
    });
  };

  return (
    <div>
      <div className="tabs">
        <button
          onClick={() => handleTabChange('tab1')}
          disabled={isPending}
        >
          Tab 1
        </button>
        <button
          onClick={() => handleTabChange('tab2')}
          disabled={isPending}
        >
          Tab 2
        </button>
        <button
          onClick={() => handleTabChange('tab3')}
          disabled={isPending}
        >
          Tab 3
        </button>
      </div>

      {isPending && <div>Loading...</div>}

      <Suspense fallback={<div>Loading tab...</div>}>
        {currentTab === 'tab1' && <Tab1 />}
        {currentTab === 'tab2' && <Tab2 />}
        {currentTab === 'tab3' && <Tab3 />}
      </Suspense>
    </div>
  );
}

차이점:

  • startTransition 없이: 탭 클릭 → 즉시 “Loading tab…” 표시 → 깜빡임
  • startTransition 사용: 탭 클릭 → 현재 탭 유지 + “Loading…” 인디케이터 → 부드러운 전환

패턴 4: 에러 바운더리와 함께 사용

import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <ErrorBoundary
      fallback={<div>Something went wrong. Please try again.</div>}
      onReset={() => window.location.reload()}
    >
      <Suspense fallback={<div>Loading...</div>}>
        <HeavyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

이점:

  • 네트워크 오류나 컴포넌트 로딩 실패 시 에러 UI 표시
  • 사용자에게 재시도 옵션 제공

패턴 5: Prefetching (사전 로드)

import { lazy, Suspense, useState, useEffect } from 'react';

const HeavyModal = lazy(() => import('./HeavyModal'));

// 사전 로드 함수
const preloadHeavyModal = () => {
  // lazy의 import를 미리 실행
  import('./HeavyModal');
};

function App() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      {/* 마우스 오버 시 사전 로드 */}
      <button
        onClick={() => setShowModal(true)}
        onMouseEnter={preloadHeavyModal}
      >
        Open Modal
      </button>

      {showModal && (
        <Suspense fallback={<div>Loading modal...</div>}>
          <HeavyModal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </div>
  );
}

전략:

  • 마우스 오버 시 사전 로드 → 클릭 시 즉시 표시
  • 사용자 경험 향상

React 버전별 차이점

버전별 차이를 이해해야 하는 이유

중요한 이야기: 많은 개발자들이 React 18에서 잘 작동하던 Suspense 코드가 React 19로 업그레이드하자 갑자기 느려지는 경험을 했습니다. 이는 버전별로 Suspense의 동작 방식이 크게 다르기 때문입니다.

예를 들어:

  • React 16/17: 서버 사이드 렌더링 불가 → 에러 발생
  • React 18: 병렬 페칭 → 빠름
  • React 19 초기: 순차 페칭 → 느림 (Waterfall)
  • React 19 최종: 다시 병렬 페칭 → 빠름

따라서 프로덕션 환경에서 Suspense를 사용하려면 반드시 현재 React 버전의 동작 방식을 확인해야 합니다.


React 16.6 (2018년 10월)

최초 도입 - 코드 분할의 혁명

배경: 당시 React 앱의 번들 크기는 계속 커지고 있었고, 개발자들은 Webpack의 복잡한 설정으로 코드 분할을 구현해야 했습니다. React 팀은 이를 프레임워크 레벨에서 해결하고자 했습니다.

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

const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <OtherComponent />
    </Suspense>
  );
}

특징:

  • Suspense와 lazy 최초 도입 (실험적 기능)
  • 클라이언트 사이드만 지원 (서버 사이드 렌더링 불가)
  • 코드 분할 전용 (데이터 페칭 불가)
  • “Legacy Suspense”로 불림

제약사항:

// ❌ React 16.6에서는 동작하지 않음
function ServerComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent /> {/* 서버에서 에러 발생 */}
    </Suspense>
  );
}

React 17 (2020년 8월)

점진적 업그레이드 중점 - 다음을 위한 준비

배경: React 팀은 대규모 업데이트를 준비 중이었지만, 기존 앱의 마이그레이션 부담을 np줄이고자 했습니다. 그래서 React 17은 “징검다리 버전”으로 기획되었습니다.

목표: React 18의 대규모 변경사항(Concurrent Mode)을 준비하면서, 개발자들이 점진적으로 업그레이드할 수 있도록 하는 것

React 17은 “새 기능 없음”을 표방한 버전으로, Suspense 관련 주요 변경사항은 없습니다.

변경사항:

  • 버그 수정 및 안정성 개선
  • unstable_expectedLoadTime prop 추가 (실험적) - React 18 준비
  • unstable_startTransition API 추가 (실험적) - React 18 준비

여전한 제약:

// ❌ 여전히 서버 사이드 렌더링 불가
// ❌ 데이터 페칭에 사용 불가

개발자 입장에서: React 17은 Suspense 사용에 있어서 React 16.6과 거의 동일합니다.


React 18 (2022년 3월) - 게임 체인저

Concurrent Suspense 도입 - 완전한 재설계

배경: React 팀은 3년 이상의 개발 끝에 Suspense를 완전히 재설계했습니다. 기존 “Legacy Suspense”의 한계(SSR 불가, 예측 불가능한 동작)를 극복하고, Concurrent 렌더링과 완벽하게 통합하고자 했습니다.

목표:

  • 서버 사이드 렌더링 지원
  • 더 나은 사용자 경험 (깜빡임 없는 전환)
  • 데이터 페칭과의 통합 (실험적)

React 18은 Suspense의 게임 체인저입니다. “Legacy Suspense”에서 “Concurrent Suspense”로 완전히 재작성되었습니다.

주요 변경사항:

1. 서버 사이드 렌더링 지원

// ✅ React 18부터 가능
// server.js
import { renderToPipeableStream } from 'react-dom/server';

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <LazyComponent />
    </Suspense>
  );
}

// 스트리밍 서버 렌더링
renderToPipeableStream(<App />, {
  onShellReady() {
    // 초기 HTML 전송
  }
});

2. Transition과 통합

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

const Tab1 = lazy(() => import('./Tab1'));
const Tab2 = lazy(() => import('./Tab2'));

function TabbedApp() {
  const [tab, setTab] = useState('tab1');
  const [isPending, startTransition] = useTransition();

  const switchTab = (newTab) => {
    startTransition(() => {
      setTab(newTab); // ✅ 현재 콘텐츠 유지
    });
  };

  return (
    <div>
      <button onClick={() => switchTab('tab1')}>Tab 1</button>
      <button onClick={() => switchTab('tab2')}>Tab 2</button>

      {/* isPending 상태로 로딩 인디케이터 표시 */}
      {isPending && <LoadingBar />}

      <Suspense fallback={<TabSkeleton />}>
        {tab === 'tab1' ? <Tab1 /> : <Tab2 />}
      </Suspense>
    </div>
  );
}

차이점 비교:

시나리오 React 16/17 (Legacy) React 18 (Concurrent)
탭 전환 시 즉시 폴백 표시 (깜빡임) 현재 콘텐츠 유지 + 로딩 인디케이터
폴백 표시 무조건 표시 transition이면 표시 안 함
사용자 경험 깜빡임, 어색함 부드러운 전환

3. 마운트 전 상태 보존

// React 16/17 (Legacy Suspense)
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {count > 5 && (
        <Suspense fallback={<div>Loading...</div>}>
          <LazyComponent />
        </Suspense>
      )}
    </div>
  );
}
// ❌ LazyComponent 로딩 중 count 상태가 리셋될 수 있음

// React 18 (Concurrent Suspense)
// ✅ 상태가 안전하게 보존됨

4. Layout Effect 처리 개선

function Component() {
  useLayoutEffect(() => {
    console.log('Component mounted');
    return () => console.log('Component unmounted');
  }, []);

  return <div>Content</div>;
}

function App() {
  const [show, setShow] = useState(true);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      {show && <Component />}
    </Suspense>
  );
}

React 16/17:

  • Suspense가 활성화되면 → Effect 실행 후 → 컴포넌트 숨김 (cleanup 실행)
  • 로드 완료 → 다시 마운트 (effect 재실행)

React 18:

  • Suspense가 활성화되면 → Effect 실행 안 함
  • 로드 완료 → 마운트 + Effect 실행 (한 번만)

5. 데이터 프레임워크 통합 (실험적)

// Next.js 13+ (App Router)
async function UserProfile({ userId }) {
  // ✅ React 18의 Suspense로 자동 처리
  const user = await fetch(`/api/users/${userId}`).then(r => r.json());

  return <div>{user.name}</div>;
}

function Page() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={123} />
    </Suspense>
  );
}

React 19 (2024년 12월)

use 훅 도입 및 초기 논란 - 커뮤니티의 피드백으로 수정

배경: React 팀은 Suspense를 데이터 페칭과 더 긴밀하게 통합하고자 했습니다. 이를 위해 use 훅을 도입했지만, 초기 구현에서 예상치 못한 성능 문제(Waterfall)가 발생했습니다.

논란의 핵심: React 18에서 완벽하게 작동하던 병렬 데이터 페칭이 React 19 초기 버전에서 순차적으로 변경되면서, 많은 앱의 성능이 저하되었습니다. 커뮤니티의 즉각적인 피드백 덕분에 React 팀은 릴리스를 보류하고 문제를 해결했습니다.

교훈: 오픈소스 커뮤니티의 피드백이 React의 품질을 유지하는 핵심 요소임을 보여준 사례

React 19는 Suspense에 큰 변화를 가져왔지만, 초기에 커뮤니티에서 논란이 있었습니다.

1. use 훅으로 Promise 읽기

import { use, Suspense } from 'react';

// Promise를 컴포넌트 밖에서 생성
const userPromise = fetch('/api/user').then(r => r.json());

function UserProfile() {
  // ✅ use 훅으로 Promise 읽기
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading user...</div>}>
      <UserProfile />
    </Suspense>
  );
}

주의사항:

// ❌ 잘못된 사용: 컴포넌트 내부에서 Promise 생성
function UserProfile() {
  const userPromise = fetch('/api/user').then(r => r.json());
  const user = use(userPromise); // 매 렌더링마다 새 요청 발생!
  return <div>{user.name}</div>;
}

// ✅ 올바른 사용: 외부에서 생성하거나 캐싱
const userPromise = fetch('/api/user').then(r => r.json());

function UserProfile() {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

2. 데이터 페칭 모델 변경 (초기 논란)

React 18의 동작:

function Parent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Child1 /> {/* 병렬로 데이터 페칭 */}
      <Child2 /> {/* 병렬로 데이터 페칭 */}
    </Suspense>
  );
}

// Child1과 Child2가 동시에 데이터 요청 → 빠름!

React 19.0 초기 버전의 동작:

function Parent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Child1 /> {/* 먼저 데이터 페칭 */}
      <Child2 /> {/* Child1 완료 후 데이터 페칭 - Waterfall! */}
    </Suspense>
  );
}

// ❌ 순차적 페칭으로 성능 저하

커뮤니티 반응:

  • React 18에서 잘 작동하던 코드가 React 19에서 느려짐
  • “Waterfall” 문제 발생
  • React 팀이 19.0 릴리스를 보류하고 수정

React 19 최종 버전:

// ✅ 다시 병렬 페칭으로 동작
function Parent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Child1 /> {/* 병렬 페칭 */}
      <Child2 /> {/* 병렬 페칭 */}
    </Suspense>
  );
}

3. 폴백 표시 최적화

function App() {
  const [data, setData] = useState(null);

  return (
    <Suspense fallback={<Spinner />}>
      <DataComponent data={data} />
    </Suspense>
  );
}

React 18:

  • 컴포넌트가 일시 중단되면 → 약간의 지연 후 폴백 표시

React 19:

  • 컴포넌트가 일시 중단되면 → 즉시 폴백 표시
  • 더 빠른 피드백

React 19.2 (2025년 10월)

서버 사이드 Suspense 배칭

// 서버 컴포넌트
function Page() {
  return (
    <div>
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>

      <Suspense fallback={<MainSkeleton />}>
        <MainContent />
      </Suspense>

      <Suspense fallback={<FooterSkeleton />}>
        <Footer />
      </Suspense>
    </div>
  );
}

React 19.0:

  • Header 로드 완료 → 즉시 클라이언트에 전송
  • MainContent 로드 완료 → 즉시 클라이언트에 전송
  • Footer 로드 완료 → 즉시 클라이언트에 전송

React 19.2:

  • 짧은 시간 동안 여러 Suspense boundary를 배칭
  • 한 번에 여러 컴포넌트를 함께 전송
  • 네트워크 왕복 횟수 감소 → 성능 향상

버전별 요약표

기능 React 16.6 React 17 React 18 React 19 React 19.2
코드 분할 (lazy)
서버 사이드 렌더링
Transition 통합 실험적
use 훅
데이터 페칭 실험적
병렬 페칭 N/A N/A
서버 배칭 N/A N/A

실전 예제

예제 1: 대시보드 애플리케이션

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

// 각 페이지를 lazy로 로드
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));

// 스켈레톤 로더
function PageSkeleton() {
  return (
    <div className="page-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content">
        <div className="skeleton-card" />
        <div className="skeleton-card" />
        <div className="skeleton-card" />
      </div>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <nav>
          <Link to="/">Dashboard</Link>
          <Link to="/analytics">Analytics</Link>
          <Link to="/reports">Reports</Link>
          <Link to="/settings">Settings</Link>
        </nav>

        <main>
          <Suspense fallback={<PageSkeleton />}>
            <Routes>
              <Route path="/" element={<Dashboard />} />
              <Route path="/analytics" element={<Analytics />} />
              <Route path="/reports" element={<Reports />} />
              <Route path="/settings" element={<Settings />} />
            </Routes>
          </Suspense>
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;

결과:

  • 초기 로딩: Dashboard만 로드 (다른 페이지는 제외)
  • 페이지 이동 시: 해당 페이지만 추가 로드
  • 번들 크기: 약 70% 감소 (페이지 4개 기준)

예제 2: 모달 다이얼로그

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

// 무거운 에디터 컴포넌트를 lazy로 로드
const RichTextEditor = lazy(() => import('./RichTextEditor'));

function CommentSection() {
  const [isEditorOpen, setIsEditorOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsEditorOpen(true)}>
        Write Comment
      </button>

      {isEditorOpen && (
        <div className="modal">
          <Suspense fallback={
            <div className="editor-loading">
              <div className="spinner" />
              <p>Loading editor...</p>
            </div>
          }>
            <RichTextEditor
              onSave={(content) => {
                console.log('Saved:', content);
                setIsEditorOpen(false);
              }}
              onCancel={() => setIsEditorOpen(false)}
            />
          </Suspense>
        </div>
      )}
    </div>
  );
}

이점:

  • 사용자가 “Write Comment”를 클릭하기 전까지 에디터 코드 미포함
  • 초기 번들 크기 대폭 감소
  • 필요할 때만 로드

예제 3: 탭 인터페이스 (React 18+)

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

// 각 탭 컴포넌트를 lazy로 로드
const ProfileTab = lazy(() => import('./tabs/ProfileTab'));
const PostsTab = lazy(() => import('./tabs/PostsTab'));
const PhotosTab = lazy(() => import('./tabs/PhotosTab'));
const VideosTab = lazy(() => import('./tabs/VideosTab'));

function UserProfileTabs({ userId }) {
  const [activeTab, setActiveTab] = useState('profile');
  const [isPending, startTransition] = useTransition();

  const handleTabClick = (tab) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };

  return (
    <div className="tabs-container">
      <div className="tab-buttons">
        <button
          onClick={() => handleTabClick('profile')}
          className={activeTab === 'profile' ? 'active' : ''}
          disabled={isPending}
        >
          Profile
        </button>
        <button
          onClick={() => handleTabClick('posts')}
          className={activeTab === 'posts' ? 'active' : ''}
          disabled={isPending}
        >
          Posts
        </button>
        <button
          onClick={() => handleTabClick('photos')}
          className={activeTab === 'photos' ? 'active' : ''}
          disabled={isPending}
        >
          Photos
        </button>
        <button
          onClick={() => handleTabClick('videos')}
          className={activeTab === 'videos' ? 'active' : ''}
          disabled={isPending}
        >
          Videos
        </button>
      </div>

      {isPending && (
        <div className="loading-bar">
          <div className="loading-progress" />
        </div>
      )}

      <div className="tab-content">
        <Suspense fallback={<TabSkeleton />}>
          {activeTab === 'profile' && <ProfileTab userId={userId} />}
          {activeTab === 'posts' && <PostsTab userId={userId} />}
          {activeTab === 'photos' && <PhotosTab userId={userId} />}
          {activeTab === 'videos' && <VideosTab userId={userId} />}
        </Suspense>
      </div>
    </div>
  );
}

function TabSkeleton() {
  return (
    <div className="tab-skeleton">
      <div className="skeleton-line" />
      <div className="skeleton-line" />
      <div className="skeleton-line" />
    </div>
  );
}

사용자 경험:

  1. 사용자가 “Posts” 탭 클릭
  2. isPendingtrue가 되어 로딩 바 표시
  3. 현재 “Profile” 탭 내용은 유지 (깜빡임 없음)
  4. PostsTab 로드 완료 → 부드럽게 전환

예제 4: 에러 바운더리와 재시도

import { lazy, Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

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

function ChartSection({ data }) {
  const [retryCount, setRetryCount] = useState(0);

  return (
    <ErrorBoundary
      resetKeys={[retryCount]}
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div className="error-container">
          <h3>Failed to load chart</h3>
          <p>{error.message}</p>
          <button onClick={resetErrorBoundary}>
            Retry
          </button>
        </div>
      )}
      onReset={() => setRetryCount(c => c + 1)}
    >
      <Suspense fallback={
        <div className="chart-loading">
          <div className="spinner" />
          <p>Loading chart...</p>
        </div>
      }>
        <HeavyChart data={data} />
      </Suspense>
    </ErrorBoundary>
  );
}

시나리오:

  1. 네트워크 오류로 차트 로드 실패
  2. 에러 UI 표시
  3. 사용자가 “Retry” 클릭
  4. retryCount 변경 → ErrorBoundary 리셋
  5. 차트 재로드 시도

예제 5: Prefetching 전략

import { lazy, Suspense, useState, useEffect } from 'react';

const ProductDetails = lazy(() => import('./ProductDetails'));

// Prefetch 함수
const preloadProductDetails = () => {
  import('./ProductDetails');
};

function ProductCard({ product }) {
  const [showDetails, setShowDetails] = useState(false);

  // 컴포넌트 마운트 후 3초 뒤에 자동 사전 로드
  useEffect(() => {
    const timer = setTimeout(preloadProductDetails, 3000);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>

      <button
        onClick={() => setShowDetails(true)}
        onMouseEnter={preloadProductDetails} // 마우스 오버 시 사전 로드
      >
        View Details
      </button>

      {showDetails && (
        <Suspense fallback={<div>Loading details...</div>}>
          <ProductDetails product={product} />
        </Suspense>
      )}
    </div>
  );
}

전략:

  1. 즉시 로드: 사용자 액션 없이 미리 로드
  2. 마우스 오버: 버튼에 마우스를 올리면 사전 로드
  3. 지연 로드: 3초 후 자동 사전 로드

베스트 프랙티스

1. lazy는 컴포넌트 외부에서 선언

// ❌ 나쁜 예: 컴포넌트 내부에서 선언
function App() {
  const LazyComponent = lazy(() => import('./Component'));

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}
// 문제: 매 렌더링마다 새로운 컴포넌트가 생성됨
// 결과: 상태가 리셋되고 불필요한 재로드 발생

// ✅ 좋은 예: 모듈 최상위에서 선언
const LazyComponent = lazy(() => import('./Component'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

2. 적절한 Suspense 경계 설정

// ❌ 나쁜 예: 너무 큰 Suspense 경계
function App() {
  return (
    <Suspense fallback={<FullPageLoader />}>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </Suspense>
  );
}
// 문제: 하나라도 로딩 중이면 전체 페이지가 로더로 표시됨

// ✅ 좋은 예: 적절한 크기의 Suspense 경계
function App() {
  return (
    <div>
      <Header /> {/* 항상 표시 */}

      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
      </Suspense>

      <Footer /> {/* 항상 표시 */}
    </div>
  );
}

3. 의미 있는 폴백 UI 제공

// ❌ 나쁜 예: 단순한 로딩 텍스트
function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

// ✅ 좋은 예: 스켈레톤 UI로 레이아웃 힌트 제공
function App() {
  return (
    <Suspense fallback={<UserProfileSkeleton />}>
      <UserProfile />
    </Suspense>
  );
}

function UserProfileSkeleton() {
  return (
    <div className="user-profile-skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-name" />
      <div className="skeleton-bio" />
      <div className="skeleton-stats">
        <div className="skeleton-stat" />
        <div className="skeleton-stat" />
        <div className="skeleton-stat" />
      </div>
    </div>
  );
}

4. 라우트 단위로 코드 분할

// ✅ 가장 효과적인 패턴
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

이점:

  • 사용자는 현재 페이지만 다운로드
  • 초기 번들 크기 대폭 감소
  • 명확한 로딩 경계

5. Transition 활용 (React 18+)

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

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  const [show, setShow] = useState(false);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(() => {
      setShow(true);
    });
  };

  return (
    <div>
      <button onClick={handleClick} disabled={isPending}>
        {isPending ? 'Loading...' : 'Show Component'}
      </button>

      {show && (
        <Suspense fallback={<div>Loading component...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}

6. 에러 바운더리 설정

import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <ErrorBoundary
      fallback={<div>Failed to load component. Please refresh.</div>}
      onError={(error) => {
        // 에러 로깅
        console.error('Lazy load error:', error);
      }}
    >
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

7. 번들 분석 및 최적화

# Webpack Bundle Analyzer 설치
npm install --save-dev webpack-bundle-analyzer

# package.json
{
  "scripts": {
    "analyze": "webpack-bundle-analyzer build/stats.json"
  }
}

분석 포인트:

  • 각 lazy 컴포넌트의 크기
  • 불필요한 의존성 확인
  • 중복된 코드 제거

주의사항

1. 과도한 코드 분할 피하기

// ❌ 나쁜 예: 모든 작은 컴포넌트를 lazy로 분할
const Button = lazy(() => import('./Button')); // 2KB
const Input = lazy(() => import('./Input'));   // 3KB
const Icon = lazy(() => import('./Icon'));     // 1KB

function Form() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Button /> {/* 네트워크 요청 1 */}
      <Input />  {/* 네트워크 요청 2 */}
      <Icon />   {/* 네트워크 요청 3 */}
    </Suspense>
  );
}
// 문제: 너무 많은 네트워크 요청으로 오히려 느려짐

// ✅ 좋은 예: 큰 컴포넌트만 분할
import Button from './Button';
import Input from './Input';
import Icon from './Icon';

const HeavyForm = lazy(() => import('./HeavyForm')); // 200KB

function App() {
  return (
    <div>
      <Button /> {/* 작은 컴포넌트는 직접 import */}
      <Input />
      <Icon />

      <Suspense fallback={<div>Loading form...</div>}>
        <HeavyForm /> {/* 큰 컴포넌트만 lazy */}
      </Suspense>
    </div>
  );
}

규칙: 최소 50KB 이상의 컴포넌트만 lazy로 분할

2. 서버 사이드 렌더링 고려

// React 16/17에서는 동작하지 않음
// React 18+에서만 가능

// SSR 환경 확인
const isBrowser = typeof window !== 'undefined';

// 조건부 lazy 사용
const LazyComponent = isBrowser
  ? lazy(() => import('./ClientOnlyComponent'))
  : () => null;

3. 무한 로딩 방지

// ❌ 나쁜 예: Promise가 resolve되지 않음
const BrokenComponent = lazy(() => {
  return new Promise((resolve) => {
    // resolve가 호출되지 않음!
  });
});

// ✅ 좋은 예: 반드시 resolve
const WorkingComponent = lazy(() => {
  return import('./Component'); // Promise가 자동으로 resolve됨
});

// 또는 명시적으로 resolve
const ManualComponent = lazy(() => {
  return new Promise((resolve) => {
    import('./Component').then((module) => {
      resolve(module);
    });
  });
});

4. 기본 내보내기(default export) 필수

// Component.jsx
// ❌ 나쁜 예: named export
export function Component() {
  return <div>Hello</div>;
}

// ✅ 좋은 예 1: default export
export default function Component() {
  return <div>Hello</div>;
}

// ✅ 좋은 예 2: named export를 default로 변환
export function Component() {
  return <div>Hello</div>;
}

// App.jsx
const LazyComponent = lazy(() =>
  import('./Component').then(module => ({
    default: module.Component
  }))
);

5. 의존성 주의

// ❌ 나쁜 예: 순환 의존성
// A.jsx
import { lazy, Suspense } from 'react';
const B = lazy(() => import('./B'));

export function A() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <B />
    </Suspense>
  );
}

// B.jsx
import { lazy, Suspense } from 'react';
const A = lazy(() => import('./A')); // 순환 의존성!

export function B() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <A />
    </Suspense>
  );
}

// ✅ 좋은 예: 의존성 제거 또는 재구성

6. React 버전 확인

// package.json에서 React 버전 확인
{
  "dependencies": {
    "react": "^18.0.0" // 최소 18.0.0 이상 권장
  }
}

// 버전별 기능 확인
import { version } from 'react';

if (parseFloat(version) < 18) {
  console.warn('React 18+ recommended for full Suspense support');
}

성능 측정

1. Webpack Bundle Analyzer

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
    })
  ]
};

2. Chrome DevTools

// Performance 탭에서 측정
// 1. 녹화 시작
// 2. 페이지 로드 또는 상호작용
// 3. 녹화 중지
// 4. "Timings" 섹션에서 lazy chunk 로딩 시간 확인

3. Lighthouse

# Chrome DevTools → Lighthouse 탭
# "Generate report" 클릭

# 주요 지표:
# - First Contentful Paint (FCP)
# - Largest Contentful Paint (LCP)
# - Time to Interactive (TTI)

4. React DevTools Profiler

import { Profiler } from 'react';

function onRenderCallback(
  id, // Profiler id
  phase, // "mount" or "update"
  actualDuration, // 렌더링 시간
  baseDuration, // 메모이제이션 없는 예상 시간
  startTime,
  commitTime
) {
  console.log(`${id} took ${actualDuration}ms to render`);
}

function App() {
  return (
    <Profiler id="LazyComponent" onRender={onRenderCallback}>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </Profiler>
  );
}

참고 자료

React 공식 문서

커뮤니티 리소스

도구

정리

React의 Suspense와 lazy loading은 웹 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 도구입니다.

핵심 포인트:

  1. lazy()로 컴포넌트 코드 분할
    • 큰 컴포넌트만 분할 (50KB+ 권장)
    • 모듈 최상위에서 선언
    • default export 필수
  2. Suspense로 로딩 상태 관리
    • 적절한 크기의 경계 설정
    • 의미 있는 폴백 UI 제공
    • 중첩 Suspense로 단계적 로딩
  3. React 버전별 차이 이해
    • React 16.6: 최초 도입 (클라이언트 전용)
    • React 18: 서버 렌더링 지원, Transition 통합
    • React 19: use 훅, 데이터 페칭 개선
  4. 베스트 프랙티스
    • 라우트 단위 코드 분할
    • 에러 바운더리 설정
    • Transition 활용 (React 18+)
    • 성능 측정 및 최적화

시작하기:

// 1. 간단한 lazy import
import { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

이 가이드를 따라 하면 React 애플리케이션의 초기 로딩 속도를 크게 개선하고, 사용자 경험을 향상시킬 수 있습니다.

댓글