본문으로 건너뛰기

서버 렌더링의 대기 시간을 줄이는 방법

Next.js의 App Router에서는 페이지가 기본적으로 서버에서 렌더링됩니다.

새로운 페이지로 이동하려면 서버가 Server Component를 실행하고 그 결과를 클라이언트에 전달해야 합니다. 데이터 조회가 오래 걸리면 그만큼 사용자는 새로운 화면을 기다려야 합니다.

Next.js는 이 대기 시간을 줄이고 화면이 빠르게 반응하도록 Prefetching, Loading, Streaming을 함께 사용합니다.

Prefetching
→ 이동하기 전에 다음 화면을 미리 준비합니다.

Loading
→ 아직 준비되지 않은 화면 대신 즉시 보여줄 UI를 제공합니다.

Streaming
→ 모든 작업이 끝날 때까지 기다리지 않고 준비된 결과부터 전달합니다.

세 기능은 각각 따로 동작하기보다 하나의 페이지 이동 과정에서 연결됩니다.


Prefetch: 이동하기 전에 다음 화면을 준비합니다

<Link>는 사용자가 클릭하기 전에 연결된 Route를 자동으로 Prefetch할 수 있습니다.

import Link from 'next/link';

export default function Navigation() {
return <Link href="/posts">Posts</Link>;
}

링크가 viewport에 들어오면 Next.js는 다음 Route로 이동하는 데 필요한 정보를 미리 가져올 수 있습니다.

<Link href="/posts">

Prefetch

다음 Route를 렌더링하는 데
필요한 데이터 준비

여기서 다음 페이지의 완성된 HTML 문서를 통째로 받아 두는 것은 아닙니다.

App Router에서 <Link>를 이용해 페이지를 이동하면 새로운 HTML 문서를 다시 로드하지 않고 RSC Payload를 이용해 현재 React Tree를 갱신합니다.

RSC Payload는 Server Component의 렌더링 결과를 클라이언트가 이해할 수 있는 형태로 직렬화한 데이터입니다.

현재 화면

RootLayout
└─ HomePage



/posts의 RSC Payload



RootLayout
└─ PostsPage

따라서 Client-side Navigation에서의 Prefetch는 쉽게 말하면 다음 Route를 그리기 위해 필요한 RSC Payload를 미리 준비하는 과정이라고 볼 수 있습니다.


모든 Route를 똑같이 Prefetch하지 않습니다

Route가 어떻게 렌더링되는지에 따라 Prefetch 범위도 달라집니다.

RoutePrefetch
Static RouteRoute 전체를 Prefetch
Dynamic Route기본적으로 Prefetch하지 않음
Dynamic Route + loading.tsx일부만 Prefetch

Static Route는 이미 렌더링 결과가 준비되어 있으므로 다음 Route 전체를 미리 가져올 수 있습니다.

반면 Dynamic Route는 요청이 들어온 뒤 서버에서 데이터를 조회하고 렌더링해야 할 수 있습니다.

/posts/1
/posts/2
/posts/3
...

이런 Route를 사용자가 방문하기도 전에 모두 렌더링하는 것은 불필요한 서버 작업이 될 수 있기 때문에 기본적으로 전체 Prefetch를 하지 않습니다.

문제는 사용자가 클릭한 뒤입니다.

사용자 클릭

Server Component 실행

데이터 조회

RSC Payload 생성

새 Page 표시

서버 처리가 오래 걸리면 사용자는 링크를 눌렀는데 아무 일도 일어나지 않는 것처럼 느낄 수 있습니다.

이때 loading.tsx가 사용됩니다.


loading.tsx로 먼저 화면을 전환합니다

다음과 같은 Dynamic Route가 있다고 해보겠습니다.

app/
└─ posts/
└─ [id]/
├─ loading.tsx
└─ page.tsx

loading.tsx에는 실제 Page가 준비되기 전에 보여줄 UI를 작성합니다.

// app/posts/[id]/loading.tsx

export default function Loading() {
return <PostSkeleton />;
}

그러면 사용자가 Route를 이동했을 때 실제 Page를 기다리는 대신 Loading UI를 먼저 보여줄 수 있습니다.

loading.tsx 없음

클릭 ───── 서버 렌더링 ───── Page


loading.tsx 있음

클릭 → Loading ─ 서버 렌더링 ─ Page

서버에서 실제 Page를 만드는 시간 자체가 줄어든 것은 아닙니다.

같은 대기 시간을 빈 화면이나 이전 화면으로 보내는 대신 Loading UI를 보여주는 것입니다.

또한 공유 Layout은 그대로 유지되기 때문에 Loading 중에도 Header나 Sidebar 같은 UI는 계속 사용할 수 있습니다.

Layout

└─ Loading

Page

loading.tsx는 Suspense를 자동으로 만듭니다

loading.tsx는 별도의 Next.js 로딩 시스템이 아니라 React의 Suspense를 기반으로 동작합니다.

app/
└─ dashboard/
├─ loading.tsx
└─ page.tsx

이렇게 파일을 두면 Next.js가 개념적으로 다음과 같은 Suspense Boundary를 만들어 줍니다.

<Suspense fallback={<Loading />}>
<Page />
</Suspense>

Page가 아직 준비되지 않았다면 fallback을 보여줍니다.

Page 준비 중

loading.tsx

그리고 Page가 준비되면 fallback을 실제 콘텐츠로 교체합니다.

loading.tsx

실제 Page

그래서 loading.tsxRoute 단위의 Suspense Boundary라고 이해해도 좋습니다.


loading.tsx가 있으면 부분 Prefetch가 가능합니다

loading.tsx의 역할은 Loading UI를 보여주는 것에서 끝나지 않습니다.

Dynamic Route에 loading.tsx가 있다면 Next.js는 실제 동적 Page 전체를 렌더링하지 않고도 미리 준비할 수 있는 부분까지만 Prefetch할 수 있습니다.

Dynamic Route

/posts/123

Prefetch

공유 Layout
+
loading.tsx와 같은
미리 준비 가능한 Route Shell

따라서 사용자가 클릭하면:

사용자 클릭

미리 Prefetch한
Loading UI로 즉시 이동

실제 Page 서버 렌더링

RSC Payload 전달

Page로 교체

할 수 있습니다.

loading.tsx는 두 가지 역할을 연결합니다.

즉시 보여줄 fallback 제공

+

Dynamic Route의 부분 Prefetch 가능

이 때문에 Dynamic Route에서는 loading.tsx가 화면 전환의 체감 속도에 큰 영향을 줍니다.


준비되는 결과부터 Streaming합니다

Loading UI를 보여주는 동안 서버에서는 실제 Page를 렌더링합니다.

여기서 모든 Server Component가 준비될 때까지 기다렸다가 한꺼번에 결과를 보내면, 느린 작업 하나가 전체 화면을 막게 됩니다.

Profile 준비 완료
Orders 준비 완료
Chart 아직 조회 중...



Chart 완료까지 기다림



전체 결과 전달

Streaming은 이를 준비된 부분부터 먼저 전달하는 방식으로 바꿉니다.

Profile 준비

먼저 전달

Orders 준비

먼저 전달

Chart 준비

나중에 전달

사용자는 전체 Page가 완성될 때까지 기다리지 않고 먼저 준비된 UI부터 볼 수 있습니다.

Client-side Navigation에서는 이렇게 만들어지는 RSC Payload가 점진적으로 전달되어 현재 React Tree에 반영됩니다.

여기서 최초 접속과 Client-side Navigation을 구분해 두는 것이 중요합니다.

Initial Visit
→ HTML + RSC Payload

Client-side Navigation
→ RSC Payload

최초 접속에서는 브라우저가 바로 화면을 표시할 수 있도록 HTML을 사용하지만, 이미 애플리케이션이 실행되고 있는 상태에서 <Link>로 이동할 때는 새로운 HTML 문서를 다시 받지 않습니다.

대신 서버에서 받은 RSC Payload를 이용해 필요한 부분만 갱신합니다.


Suspense를 사용하면 Streaming 범위를 나눌 수 있습니다

loading.tsx는 Route 전체에 대한 Loading Boundary를 자동으로 만들어 줍니다.

Page 안에서도 일부 영역만 따로 기다리게 하고 싶다면 직접 <Suspense>를 사용할 수 있습니다.

import { Suspense } from 'react';

export default function Page() {
return (
<>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>

<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
</>
);
}

Profile이 먼저 준비된다면 Chart를 기다리지 않고 Profile부터 보여줄 수 있습니다.

처음

Profile Skeleton
Chart Skeleton



Profile 준비

Profile
Chart Skeleton



Chart 준비

Profile
Chart

따라서 둘의 차이는 Loading 범위입니다.

loading.tsx
→ Route 단위 Suspense

<Suspense>
→ Component 단위 Suspense

모든 컴포넌트를 잘게 나누는 것이 항상 좋은 것은 아닙니다. 서로 함께 보여주는 것이 자연스러운 UI는 같은 Boundary로 묶고, 독립적으로 보여줘도 되는 느린 영역을 분리하는 것이 좋습니다.


전체 흐름으로 이해합니다

Prefetching, Loading, Streaming을 각각 외우기보다 하나의 Navigation 과정으로 보면 이해하기 쉽습니다.

<Link>가 viewport에 들어옴


Prefetching


다음 Route에서 미리
준비할 수 있는 부분 저장


사용자가 클릭

Client-side Transition


loading.tsx 표시


Server Component 렌더링


RSC Payload 생성


Streaming


준비된 실제 UI부터 반영

각 기능의 역할은 다음과 같습니다.

개념역할
Prefetching사용자가 클릭하기 전에 다음 Route를 미리 준비합니다
loading.tsx실제 Page가 준비될 때까지 보여줄 fallback입니다
Streaming준비된 Server Component 결과부터 순서대로 전달합니다
<Suspense>어느 영역까지 함께 기다릴지 Streaming 경계를 만듭니다

결국 Next.js의 화면 전환은 다음 흐름으로 정리할 수 있습니다.

미리 준비하고
→ Prefetching

기다리는 동안 화면을 보여주고
→ loading.tsx

준비되는 실제 결과부터 채웁니다
→ Streaming

서버 렌더링 자체를 없애는 것이 아니라 서버를 기다려야 하는 시간을 최대한 미리 처리하고, 기다려야 한다면 즉시 피드백을 보여주며, 모든 결과를 기다리지 않고 준비되는 UI부터 전달하는 것이 핵심입니다.

참고 자료