웹 브라우저는 렌더링 과정을 통해 사용자가 볼 수 있는 실제 웹사이트 화면을 그립니다. 브라우저의 렌더링 엔진이 이 역할을 하고 있습니다. 서버에서 받은 여러 파일을 읽고 해석한 결과가 바로 웹사이트의 화면이라고 할 수 있는데요. 이때 html, css, javascript, 이미지, 폰트 등 다양한 파일을 받습니다. 어떤 파일들을 받는지 확인하고자 한다면 개발자 도구를 열고 Network 탭을 열고 새로고침을 해보세요.
만약 서버에서 불러와야 할 파일이 너무 많고 각각의 파일의 크기도 크다면 사용자는 완성된 화면을 늦게 보게 될 것입니다. 이는 사용자가 서비스를 이탈하는 가장 큰 이유 중 하나이기도 합니다. 때문에 개발자들은 보다 빠르게 렌더링을 할 수 있도록 속도를 개선하는 것을 중요하게 생각합니다.
이번글에서는 NEXT.js에서의 여러 렌더링 방식을 살펴보고 실제로 어떻게 구현을 할 수 있는지 코드를 작성하면서 결과를 확인합니다.
CSR와 SSR에 대한 비교는 이전 블로그에서 포스팅한 SSR & CSR글을 참고해주세요.
1. Server Components, Client Components의 Full page load
Next13에 들어서 Server Components와 Client Components라는 개념이 도입되었습니다. Next는 서버에서 Server Components와 Client Componetns에 대한 정적 HTML preview 렌더링을 실시하여 초기 페이지를 불러올 때, 최적화를 실시합니다. 때문에 사용자는 CSR과는 다르게 처음 웹 사이트에 접속했을 때, 빠르게 페이지의 내용을 볼 수 있습니다. 하지만 자바스크립트 번들을 다운로드하기 전이기 때문에 인터렉션은 불가능합니다.
조금 더 자세히 어떤 과정을 거치며 페이지가 화면에 그려지는 살펴보겠습니다.
1-1. On the server
Server에서는 두 개의 과정을 거칩니다.
첫 번째는 React가 Server Compontens를 React Server Component Payload(RSC Payload)으로 만드는 과정입니다. RSC Payload은 특수한 형태의 데이터로 Client Components에 대한 참조값을 포함하고 있습니다. RSC는 아래에서 조금 더 자세히 다루겠습니다.
두 번째는 Next가 RSC Payload와 Client Components의 Javascript의 명령어(지침)를 사용하여 주소에 대한 HTML를 만드는 과정입니다.
1-2. On the client
Client에서는 세 개의 과정을 거칩니다.
첫 번째는 초기 페이지 로드를 위한 과정입니다. 이때 주소에 해당하는 HTML는 사용자에게 인터렉션을 제공하지 않는 상태로 즉시 사용됩니다.
두 번째는 조정하는 과정입니다. 이 과정에서 React Server Components Payload가 사용되는데 이를 통해 Server Components, Client Components 트리를 조정하고 DOM를 업데이트합니다.
마지막은 사용자와 상호작용을 위한 과정입니다. 이 과정에서 자바스크립트 명령어는 Client Components에 적용시켜 애플리케이션을 인터렉티브 하게 만듭니다. 자바스크립트 명령어를 Client Components에 적용시키는 것을 hydrate이라는 단어로 표현했습니다. 이는 React의 hydrateRoot에서 의미를 가져온 것으로 보입니다.
1-3. React Server Components Payload(RSC Payload)
Server에서 만든 RSC Payload에 대해서 조금 더 살펴보겠습니다.
RSC PayLoad은 렌더링 된 React Server Components 트리의 정보를 간결한 형태로 표현된 것입니다. 이는 클라이언트에서 React가 브라우저의 DOM을 업데이트하는 데 사용됩니다.
RSC PayLoad은 다음과 같은 내용을 포함하고 있습니다.
1. Server Components의 렌더링 결과
2. Client Components를 렌더링 할 자리와 자바스크립트 파일에 대한 참조값
3. Server Components에서 Client Components로 전달된 props
이러한 RSC PayLoad은 Next 프로젝트를 빌드하게 되면 볼 수 있습니다. 다음은 직접 만든 plus경로에 대한 RSC Payload입니다. 경로는 .next/server/app/plus.rsc 입니다.
옆 파일을 더 살펴보면 다른 경로에 대한 .rsc가 보입니다. 또한 Server에서 .html도 생성한 것을 확인할 수 있습니다.
또한 명령어를 통해 빌드된 파일을 실행한 뒤 개발자 도구의 Network 탭에서도 RSC Payload를 확인할 수 있습니다. 신기한 점은 해당 주소로 이동했을 때 보이는 것이 아니라 Next의 Link 컴포넌트를 사용했을 때 볼 수 있다는 점입니다. 이를 토대로 Link 컴포넌트를 바탕으로 사용자가 앞으로 이동할 페이지에 대한 RSC Payload을 미리 불러온다고 짐작할 수 있습니다.
2. Server Side Rendering
SSR(Server Side Rendering)이란 서버에서 미리 html를 렌더링을 한 뒤 클라이언트에게 보내주는 방법을 말합니다. 이때 서버에서는 요청을 받은 즉시 html를 만들기 때문에 페이지를 이동할 때마다 html이 만들어지는 시간만큼 사용자는 이동한 페이지의 화면을 볼 수 없습니다. 이는 사용자에게 좋지 않은 경험을 제공합니다. 이를 해결하기 위해 React의 Suspense를 활용하여 로딩 UI를 보여주곤 합니다.
아래에서는 next13에서 간단한 예제를 통해 SSR를 구현합니다.
2-1. /post
/post 경로로 이동하게 되면 사용자가 모든 post의 목록을 확인할 수 있는 페이지를 만들어 보겠습니다.
// src/app/post/layout.tsx
import { ReactNode, Suspense, useEffect, useState } from 'react';
import Post from './page';
import Link from 'next/link';
import Loading from './[id]/loading';
type Post = { id: number; title: string; description: string };
async function getPosts() {
const res = await fetch('http://localhost:9999/posts', {
cache: 'no-store', // 해당 옵션 적용
});
const data = await res.json();
return data;
}
export default async function PostsLayout({
children,
}: {
children: ReactNode;
}) {
const posts: Post[] = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/post/${post.id}`}>POST{post.id}</Link>
</li>
))}
<Suspense fallback={<Loading />}>{children}</Suspense>
</ul>
);
}
PostsLayout 컴포넌트에서 getPost() 함수를 호출하여 전체 post를 받아옵니다. 이때 cache 옵션이 렌더링 방식을 결정합니다. 위와 같이 'no-store'로 해야 합니다. 다음 파트에서 살펴보겠지만 cache 옵션을 제거하면 SSG 방식으로 바뀝니다.
2-2. /post/[id]
이번에는 post의 상세 페이지로 이동해 보겠습니다. 코드는 다음과 같습니다.
// src/app/post/[id]/page.tsx
async function getPost({ id }: { id: number }) {
const res = await fetch(`http://localhost:9999/posts/${id}`, {
cache: 'no-store',
});
const post = (await res.json()) as {
id: number;
title: string;
description: string;
};
return post;
}
export default async function Post(props: { params: { id: number } }) {
const post = await getPost({ id: props.params.id });
return (
<div>
<h2>{post?.title}</h2>
<p>{post?.description}</p>
</div>
);
}
사용자가 어떤 링크를 눌렀는지에 따라 Post의 props.params.id가 달라집니다. 때문에 다음과 같이 정리할 수 있습니다.
외부 요인에 따라 렌더링 되어 화면에 보이는 UI가 달라진다. 즉, 서버는 어떤 페이지를 만들어야 하는지 모르기 때문에 클라이언트에서 요청이 오는 순간 html를 만들어 보낸다.
위와 같은 경우에는 cache 옵션을 제거해도 여전히 SSR방식으로 렌더링이 됩니다.
2-3. 결과
build를 한 뒤 보여지는 결과입니다.
/post, /post/[id]이 (Server) server-side renders at runtime에 해당하는 것을 확인할 수 있습니다.
3. Static Site Generation
Static Site Generation(SSG)은 빌드된 시점에 미리 html 파일을 만들어 두고 요청이 왔을 때 미리 만들어진 파일을 보내주는 방법을 말합니다. 이러한 점으로 인해 사용자는 SSR보다 더 빠르게 완성된 페이지를 볼 수 있습니다.
그렇다면 어떻게 미리 만들어진 것을 확인할 수 있을까요? 이는 build를 한 뒤 next/server/app 폴더 내에서 확인할 수 있습니다. 다음 사진을 보겠습니다.
위 사진은 이전 파트에서 렌더링 된 결과였습니다. 이를 살펴보면 SSG에 해당하는 주소는 /, /_not-found입니다. 이 주소에 해당하는 html 파일이 next/server/app 폴더에 어떻게 존재하는지 보겠습니다.
build 된 결과 생성된 파일을 살펴보면 _not-found.html, index.html를 찾아볼 수 있습니다. 이는 build 되는 시점에 생성이 되었다는 것입니다.
하지만 반대로 SSR로 렌더링 된 /post, /post/[id]에 해당하는 html파일은 찾아볼 수 없습니다.
다음에서 /post, /post/[id]을 SSG방법으로 렌더링을 할 수 있도록 코드를 수정해 보겠습니다.
3-1. /post
/post는 위에서 설명하는 것처럼 cache의 옵션만 제거하면 됩니다.
3-2. /post/[id]
외부 요청으로 인해 동적으로 변하는 페이지입니다. 여기서는 서버에게 다음과 요청해야 합니다.
여기, 여기, 그리고 여기는 미리 빌드할 때, 만들어줘
위와 같이 서버에 요청하기 위해선 다음과 같은 함수를 추가해야 합니다.
// src/app/post/[id]/page.tsx
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }];
}
async function getPost({ id }: { id: number }) {
// ...
}
export default async function Post(props: { params: { id: number } }) {
// ...
}
generateStaticParams함수로 이 함수는 데이터를 실제로 요청하는 함수(getPost)와 같은 파일에 위치해 있어야 합니다.
3-3. 결과
새롭게 빌드를 하면 다음과 같은 결과를 볼 수 있습니다.
/post와 /post/[id]의 렌더링 방식이 바뀌었습니다. 또한 직접 설정한 id인 1, 2에 해당하는 페이지만 SSG로 렌더링이 되는 것을 확인할 수 있습니다.
폴더도 보겠습니다.
post 폴더 내에 1.html, 2.html이 생성되었습니다. 또한 아래에 post.html도 보입니다.
4. SSR vs SSG 사용자 입장에서의 렌더링 속도 비교
각각의 렌더링 방법으로 빌드한 뒤 실행한 결과를 살펴보겠습니다. Network환경은 Slow 3G입니다.
먼저 SSR 방법으로 빌드한 결과입니다.
다음은 SSG 방법으로 빌드한 결과입니다.
두 영상을 비교했을 때, SSR도 충분히 빠릅니다. 하지만 SSG는 Suspense가 보이지 않을 정도로 빠른 것을 확인할 수 있습니다.
5. Incremental Static Regeneration(ISR)
마지막으로 살펴볼 렌더링 방법은 ISR(Incremental Static Regeneration)입니다. ISR은 SSG처럼 빌드되는 시점에 페이지에 필요한 html을 미리 만들어 두고 요청이 오면 추가적인 렌더링 없이 이미 만들어진 html을 전달합니다. 하지만 SSG는 다시 빌드를 하지 않는 이상 같은 UI가 계속 보입니다. 이런 불편은 ISR의 revalidate 옵션을 통해 해결할 수 있습니다. 이는 다시 빌드되는 시간을 정하는 옵션으로 새롭게 빌드될 때마다 최신의 정보가 만들어집니다.
이를 next13에서는 어떻게 구현할 수 있을까요? 다음과 같이 fetch에 옵션을 추가하면 됩니다.
async function getPosts() {
const res = await fetch('http://localhost:9999/posts', {
next: { revalidate: 3600 }, // 옵션 추가
});
const data = await res.json();
return data;
}
위와 같이 옵션을 추가하여 posts의 정보는 매시간마다 최신화될 수 있습니다. 더욱 자세한 내용은 Next 공식문서의 Data-fatching의 Revalidating Data에서 확인할 수 있습니다.
6. 정리
지금까지 살펴본 렌더링 방식에 CSR을 추가하여 정리하겠습니다.
6-1. CSR
- SEO가 중요하지 않을 때 적합합니다.
- 사용자의 상호작용이 많고 복잡한 애플리케이션일 때 적합합니다.(포토샵)
- 사용자 프로필과 같은 작은 부분에 적합합니다.
- 첫 페이지 로딩 시간은 길지만 그 이후의 로딩 시간은 짧습니다.
6-2. SSR
- SEO가 중요할 때 적합합니다.
- 서버에 요청할 때마다 페이지를 생성하는 경우 사용합니다.
6-3. SSG
- SEO가 중요할 때 적합합니다.
- 컨텐츠가 대부분 정적일 때 사용합니다.
6-4. ISG
- SEO가 중요할 때 적합합니다.
- 자주 변경되지 않지만 정기적으로 업데이트해야 하는 컨텐츠가 있을 때 적합합니다.
다음의 자료를 참고하였습니다.