About→

← Studies

08. React.lazy / Suspense — Code Splitting과 초기 로딩

앞에서는 React 성능을 이야기하면서

text
Render
→ Re-render
→ memo
→ useMemo
→ useCallback

을 살펴봤습니다.

여기서는 주로 React가 실행된 이후 불필요한 작업을 줄이는 방법을 다뤘습니다.

그런데 React 애플리케이션의 성능을 생각할 때 한 가지 문제가 더 있습니다.

바로 처음 애플리케이션을 실행할 때 너무 많은 JavaScript를 받아오는 문제입니다.

예를 들어 하나의 서비스에 다음과 같은 기능이 있다고 해봅시다.

text
ClariPlatform
├── 로그인
├── WorkList
├── 파일 업로드
├── DICOM Viewer
├── 통계
├── 설정
└── 관리자 페이지

사용자가 처음 접속했을 때 실제로 필요한 것은 로그인이나 WorkList 정도일 수 있습니다.

그런데 애플리케이션이 모든 기능의 JavaScript를 처음부터 다운로드하고 실행해야 한다면 어떻게 될까요?

text
브라우저 접속
    ↓
JavaScript 다운로드
    ↓
모든 기능의 코드 다운로드
    ↓
JavaScript 파싱
    ↓
JavaScript 실행
    ↓
React 실행
    ↓
화면 표시

애플리케이션이 커질수록 초기 로딩에 부담이 생길 수 있습니다.

그래서 등장하는 개념이

Code Splitting

입니다.

1. JavaScript를 하나로 가져오는 방식

먼저 일반적인 번들링을 생각해봅시다.

프로젝트에 여러 파일이 있다고 해보겠습니다.

text
src
├── App.tsx
├── WorkList.tsx
├── Viewer.tsx
├── Settings.tsx
└── Admin.tsx

개발할 때는 여러 파일로 나누어져 있지만 빌드 과정에서는 Webpack, Vite 같은 도구가 의존성을 분석하고 JavaScript 번들을 만들어냅니다.

개념적으로 단순화하면

text
App
 ├── WorkList
 ├── Viewer
 ├── Settings
 └── Admin
        ↓
   하나의 큰 Bundle

처럼 만들 수 있습니다.

결과적으로 브라우저가 처음 접속할 때 큰 JavaScript 파일을 받아야 할 수 있습니다.

예를 들어 개념적으로

text
main.js
  └── 8MB

라고 생각해봅시다.

사용자가 WorkList만 사용하더라도 Viewer와 Admin 기능에 필요한 코드까지 포함되어 있다면 처음부터 받아야 할 수 있습니다.

2. Code Splitting이란?

Code Splitting은 말 그대로 코드를 여러 조각으로 나누는 것입니다.

예를 들어

text
기존

main.js
└── 모든 기능

이었다면

text
Code Splitting

main.js
worklist.js
viewer.js
settings.js
admin.js

처럼 나눌 수 있습니다.

그러면 브라우저가 처음부터 모든 코드를 받을 필요가 없어집니다.

text
처음 접속
    ↓
main.js
worklist.js
    ↓
WorkList 표시

Viewer 이동
    ↓
viewer.js 다운로드
    ↓
Viewer 표시

즉,

필요한 코드를 필요한 시점에 가져오는 것

이 Code Splitting의 핵심입니다.

3. Code Splitting은 왜 초기 로딩에 도움이 될까?

브라우저가 JavaScript를 받는 데는 단순히 다운로드 시간만 존재하지 않습니다.

대략적으로 보면

text
JavaScript
    ↓
Download
    ↓
Parse
    ↓
Compile
    ↓
Execute

과 같은 비용이 발생합니다.

따라서 JavaScript 자체가 많아지면 네트워크뿐만 아니라 브라우저의 JavaScript 처리 비용도 커질 수 있습니다.

예를 들어

text
모든 코드를 한 번에 로드

8MB JavaScript
    ↓
Download
    ↓
Parse
    ↓
Compile
    ↓
Execute

와

text
초기 코드만 로드

2MB JavaScript
    ↓
Download
    ↓
Parse
    ↓
Compile
    ↓
Execute

를 비교하면 초기 진입 시 처리해야 할 코드의 양 자체가 달라집니다.

그래서 Code Splitting은 단순히 파일 크기를 줄이는 기술이라기보다,

초기 실행에 필요하지 않은 코드를 초기 경로에서 분리하는 기술

이라고 이해하는 것이 더 정확합니다.

4. Dynamic Import

JavaScript에서 Code Splitting을 이해하려면 먼저 dynamic import를 알아야 합니다.

일반적인 import는

jsx
import Viewer from "./Viewer";

처럼 작성합니다.

이 경우 해당 모듈은 애플리케이션의 정적 의존성으로 포함됩니다.

반면 Dynamic Import는

jsx
const module = await import("./Viewer");

처럼 필요할 때 모듈을 가져올 수 있습니다.

즉,

text
정적 import

import Viewer
    ↓
애플리케이션 초기 의존성


Dynamic import

import("./Viewer")
    ↓
실행 시점에 필요한 모듈 요청

이라는 차이가 있습니다.

빌드 도구는 이러한 Dynamic Import를 기준으로 코드 분할 지점을 만들 수 있습니다.

5. React.lazy

React에서는 Dynamic Import를 조금 더 편하게 사용할 수 있도록 React.lazy를 제공합니다.

예를 들어

jsx
import { lazy } from "react";

const Viewer = lazy(() => import("./Viewer"));

라고 작성할 수 있습니다.

여기서 중요한 부분은

jsx
lazy(() => import("./Viewer"))

입니다.

Viewer 모듈을 일반적인 정적 import처럼 애플리케이션 시작 시 바로 가져오는 것이 아니라 필요한 시점에 로드할 수 있는 구조가 됩니다.

개념적으로 보면

text
애플리케이션 시작
      ↓
Viewer 코드 아직 필요하지 않음
      ↓
Viewer JavaScript 다운로드 X

그리고 Viewer를 실제로 렌더링해야 하는 순간

text
Viewer 필요
      ↓
import("./Viewer")
      ↓
Viewer Chunk 요청
      ↓
다운로드
      ↓
실행
      ↓
Viewer 렌더링

이런 흐름이 됩니다.

6. 그런데 문제가 하나 있다

여기서 한 가지 문제가 생깁니다.

Viewer 코드를 아직 다운로드하지 않았는데 React가 Viewer를 렌더링하려고 한다면 어떻게 될까요?

jsx
<Viewer />

아직 Viewer 코드가 없습니다.

따라서 로딩 시간이 필요합니다.

이때 사용되는 것이 Suspense입니다.

7. Suspense

Suspense는 특정 작업이 준비될 때까지 대체 UI를 보여줄 수 있는 경계(boundary)를 제공합니다.

예를 들어

jsx
import { Suspense, lazy } from "react";

const Viewer = lazy(() => import("./Viewer"));

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

이렇게 작성할 수 있습니다.

흐름을 보면

text
Viewer 렌더링 요청
      ↓
Viewer 코드가 아직 없음
      ↓
Chunk 다운로드
      ↓
그동안 fallback 표시
      ↓
Viewer 코드 준비
      ↓
Viewer 렌더링

따라서 사용자는

text
Loading...

같은 UI를 볼 수 있습니다.

8. React.lazy와 Suspense의 역할은 다르다

둘을 하나의 기능처럼 생각하기 쉽지만 역할은 다릅니다.

React.lazy

text
컴포넌트 코드를 필요할 때 로드

Suspense

text
아직 준비되지 않은 동안 보여줄 UI를 정의

즉,

text
React.lazy
→ "이 컴포넌트는 필요할 때 가져와."

Suspense
→ "가져오는 동안 이 UI를 보여줘."

라고 이해하면 쉽습니다.

9. 실제 라우팅에서 많이 사용하는 이유

Code Splitting은 특히 라우트 단위에서 많이 사용합니다.

예를 들어

text
/login
/worklist
/viewer
/settings
/admin

이라는 페이지가 있다면 사용자가 처음 /worklist에 들어왔을 때

text
WorkList 코드

만 먼저 가져오고

text
Viewer
Settings
Admin

같은 코드는 필요할 때 가져오도록 만들 수 있습니다.

예를 들어 라우트 컴포넌트를

jsx
const WorkList = lazy(() => import("./pages/WorkList"));
const Viewer = lazy(() => import("./pages/Viewer"));
const Settings = lazy(() => import("./pages/Settings"));

처럼 분리할 수 있습니다.

개념적으로

text
/worklist
    ↓
worklist.chunk.js

/viewer
    ↓
viewer.chunk.js

/settings
    ↓
settings.chunk.js

처럼 나뉘게 됩니다.

이 방식은 페이지가 많고 각 페이지의 기능이 무거운 애플리케이션에서 특히 의미가 있습니다.

10. 의료 영상 Viewer에서는 더 중요할 수 있다

실무에서는 기능의 무게가 서로 다른 경우가 많습니다.

예를 들어 일반적인 WorkList보다 DICOM Viewer가 훨씬 많은 코드를 필요로 한다고 생각해봅시다.

text
WorkList
    ↓
일반적인 UI 코드

Viewer
    ↓
DICOM 처리
    ↓
이미지 렌더링
    ↓
영상 관련 라이브러리
    ↓
각종 분석 기능

사용자가 WorkList만 보고 있는데 Viewer에 필요한 코드까지 모두 초기 로딩한다면 불필요한 초기 부담이 생길 수 있습니다.

그래서

text
초기 진입
    ↓
WorkList 관련 코드
    ↓
사용자 화면 표시

Viewer 진입
    ↓
Viewer Chunk 다운로드
    ↓
Viewer 실행

처럼 분리하는 것이 가능합니다.

이것이 Code Splitting이 실무에서 중요한 이유입니다.

11. 하지만 Code Splitting이 무조건 좋은 것은 아니다

여기서 중요한 부분이 있습니다.

코드를 나누면 무조건 빨라질까요?

그렇지는 않습니다.

예를 들어 너무 작은 코드까지 전부 분리하면

text
main.js
chunk-1.js
chunk-2.js
chunk-3.js
chunk-4.js
chunk-5.js
...

처럼 너무 많은 요청이 발생할 수 있습니다.

또한 사용자가 바로 다음 화면으로 이동한다면

text
초기 요청
    ↓
main.js

페이지 이동
    ↓
chunk 요청

다운로드
    ↓
화면 표시

라는 추가 지연이 생길 수 있습니다.

따라서 Code Splitting의 핵심은

코드를 최대한 많이 나누는 것

이 아닙니다.

초기 로딩에 필요하지 않은 코드를 적절한 경계에서 분리하는 것

입니다.

12. Lazy Loading과 Code Splitting은 같은 것일까?

비슷하게 사용되지만 정확히는 구분할 필요가 있습니다.

Code Splitting

코드를 여러 Chunk로 나누는 것

text
main.js
viewer.js
admin.js

Lazy Loading

필요한 순간까지 로딩을 늦추는 것

text
Viewer가 필요해지는 순간
    ↓
viewer.js 요청

React.lazy는 이 둘을 연결해서 사용할 수 있는 대표적인 방법입니다.

text
Dynamic import
      ↓
Code Splitting
      ↓
React.lazy
      ↓
필요한 순간 로드
      ↓
Suspense
      ↓
로딩 UI

13. 초기 로딩 성능은 JavaScript 크기만의 문제가 아니다

여기서 한 단계 더 생각해볼 필요가 있습니다.

초기 로딩이 느리다고 해서 항상

text
JavaScript가 크다

는 의미는 아닙니다.

브라우저의 초기 로딩에는 여러 요소가 관여합니다.

text
Network
   ↓
HTML
   ↓
CSS
   ↓
JavaScript
   ↓
API
   ↓
React 실행
   ↓
Render
   ↓
Browser Rendering

예를 들어 JavaScript를 줄였는데도 화면이 느리다면

text
API 응답이 느린가?
CSS가 늦게 로드되는가?
초기 데이터가 너무 많은가?
렌더링 계산이 무거운가?
이미지가 큰가?

등을 함께 봐야 합니다.

즉 Code Splitting은 초기 로딩 문제를 해결하는 하나의 방법이지 모든 성능 문제를 해결하는 방법은 아닙니다.

14. React.lazy를 사용한다고 JavaScript가 자동으로 빨라지는 것은 아니다

이 부분도 자주 오해합니다.

jsx
const Viewer = lazy(() => import("./Viewer"));

라고 작성했다고 해서 애플리케이션의 전체 JavaScript 용량이 무조건 크게 줄어드는 것은 아닙니다.

핵심은 초기 로딩 경로에서 Viewer 코드를 분리하는 것입니다.

즉

text
전체 JavaScript 양

과

text
초기 로딩 시 필요한 JavaScript 양

을 구분해야 합니다.

Code Splitting의 주요 목적은 후자를 줄이는 것입니다.

15. Prefetch와 함께 생각하기

Code Splitting을 적용하면 사용자가 페이지에 들어가는 순간 Chunk를 다운로드하게 됩니다.

그런데 사용자가 다음에 이동할 페이지를 어느 정도 예측할 수 있다면 미리 가져오는 방법도 생각할 수 있습니다.

예를 들어

text
WorkList 사용 중
      ↓
사용자가 Viewer를 열 가능성이 높음
      ↓
Viewer Chunk 미리 다운로드
      ↓
실제 Viewer 진입
      ↓
이미 다운로드되어 있음

이런 전략을 Prefetch라고 합니다.

즉

text
Lazy Loading
→ 필요할 때 가져온다.

Prefetch
→ 필요할 가능성이 높은 코드를 미리 가져온다.

입니다.

둘은 서로 반대되는 개념이라기보다 상황에 따라 함께 사용할 수 있습니다.

16. Suspense는 Code Splitting만을 위한 기능이 아니다

Suspense를 보면 흔히

text
Suspense = lazy 로딩 UI

라고 생각하기 쉽습니다.

하지만 Suspense는 더 넓은 개념입니다.

React가 어떤 UI를 즉시 준비할 수 없는 상황에서 준비될 때까지 fallback UI를 보여주는 경계로 사용할 수 있습니다.

현재 우리가 다루는 범위에서는 가장 대표적인 사용 사례가

text
React.lazy
+
Suspense

를 이용한 코드 로딩입니다.

나중에 React의 Server Components나 Streaming 같은 개념으로 넘어가면 Suspense가 왜 중요한지 더 넓은 관점에서 볼 수 있습니다.

17. 지금까지 배운 React 성능과 연결해보자

앞의 글에서 우리는

text
Re-render
memo
useMemo
useCallback

을 배웠습니다.

이것들은 주로 애플리케이션이 실행된 이후의 비용을 줄이는 데 초점을 맞춥니다.

반면 이번에 배운 것은

text
Code Splitting
React.lazy
Suspense

입니다.

이것들은 처음부터 모든 코드를 로드하지 않도록 초기 로딩 경로를 나누는 것에 초점이 있습니다.

그래서 둘을 비교하면 다음과 같습니다.

text
React 성능 최적화

실행 이후
    ↓
Re-render 비용 감소
    ↓
memo
useMemo
useCallback


초기 로딩 최적화

실행 전에 필요한 코드 감소
    ↓
Code Splitting
    ↓
Dynamic Import
    ↓
React.lazy
    ↓
Suspense

18. 브라우저 전체 흐름으로 연결하면

처음 우리가 이 시리즈를 시작하면서

text
URL 입력

부터 시작했습니다.

이제 React까지 내려와서 다시 연결해보면 꽤 긴 흐름이 됩니다.

text
URL 입력
   ↓
DNS
   ↓
IP
   ↓
TCP / QUIC
   ↓
TLS
   ↓
HTTP
   ↓
HTML / CSS / JavaScript
   ↓
Browser Rendering
   ↓
JavaScript 실행
   ↓
React 실행
   ↓
React Render
   ↓
Commit
   ↓
DOM Update
   ↓
Browser Rendering
   ↓
화면

그리고 React 애플리케이션이 커지면

text
JavaScript Bundle 증가
        ↓
초기 로딩 부담
        ↓
Code Splitting
        ↓
필요한 Chunk만 로드
        ↓
React.lazy
        ↓
Suspense

라는 최적화가 들어갑니다.

19. 결국 성능 문제는 어디에서 발생하는가?

지금까지 배운 내용을 기준으로 보면 성능 문제를 상당히 넓은 범위에서 볼 수 있습니다.

text
Network
    ↓
DNS / TCP / TLS / HTTP
    ↓
응답 속도

Browser
    ↓
HTML / CSS / JavaScript
    ↓
Parsing / Compilation / Execution

React
    ↓
Render / Re-render
    ↓
불필요한 계산

DOM
    ↓
Layout / Paint / Composite
    ↓
화면 렌더링 비용

Data
    ↓
대용량 데이터
    ↓
메모리 / CPU 비용

따라서 프론트엔드 성능 최적화는 특정 기술 하나를 사용하는 것이 아닙니다.

어느 단계에서 병목이 발생했는지를 찾는 과정에 가깝습니다.

마무리

이번 글에서 가장 중요한 개념은 다음 흐름입니다.

text
애플리케이션이 커짐
      ↓
JavaScript Bundle 증가
      ↓
초기 로딩 부담 증가
      ↓
Code Splitting
      ↓
Dynamic Import
      ↓
필요한 시점에 Chunk 로드
      ↓
React.lazy
      ↓
Suspense로 Loading UI 처리

그리고 memo, useMemo, useCallback과는 목적이 조금 다릅니다.

text
memo
→ 이미 실행되는 React의 불필요한 렌더링을 줄임

useMemo
→ 비싼 계산 결과를 재사용

useCallback
→ 함수 참조를 안정적으로 유지

Code Splitting
→ 초기부터 필요하지 않은 JavaScript를 분리

React.lazy
→ 컴포넌트를 필요할 때 로드

Suspense
→ 준비되는 동안 fallback UI를 보여줌

결국 중요한 것은

“성능을 개선한다”는 말을 들었을 때, 어느 단계의 비용을 줄이는 것인지 구분하는 것

입니다.

이제 React의 실행과 초기 로딩까지 살펴봤습니다.

다음에는 브라우저와 React가 사용하는 메모리로 넘어가겠습니다.

text
Heap
→ Reference
→ Garbage Collection
→ Closure
→ Memory Leak

특히 JavaScript에서 객체가 어떻게 참조되고, 왜 어떤 데이터는 계속 메모리에 남아 있고 어떤 데이터는 GC에 의해 정리되는지를 이해하면 앞에서 배운 Closure와 대용량 데이터 처리 문제까지 하나로 연결할 수 있습니다.