NextJS 14에서 Partial Prerendering이라는 새로운 렌더링 기법이 소개되었다. CSR부터 PPR까지 프론트엔드 렌더링 최적화 과정에 대해 알아보자~
틀린 부분이 있다면 알려주세요~☺️
클라이언트 측에서 화면을 구성하고 렌더링을 진행한다.
React App Scaffolding을 생성하면 public/index.html
과 index.js
파일을 볼 수 있다.
public/index.html
: 컨텐츠가 없는 빈 html 파일<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- 생략 -->
<title>React App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
index.js
: React root를 생성하고 JSX를 html 요소로 변환// index.js
const root = ReactDOM.createRoot(document.getElementById('root')) // React root 생성
root.render(
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>,
)
위 사진은 React를 기반으로 구현된 웹페이지로, 응답으로 온 HTML 파일을 보면 컨텐츠가 없다. 두번째 이미지에서 Response를 보면 script
태그가 있는데 src
로 지정된 JS 파일을 다운로드하고 실행되어야 컨텐츠가 채워진다.
만약 JS 파일을 다운로드하고 실행하는데 시간이 걸리면 사용자는 그동안 컨텐츠가 없는 빈 화면을 봐야한다는 문제점이 있다.
클라이언트 사이드 렌더링(CSR)의 초기 콘텐츠 로딩 속도 문제를 해결하기 위해 등장했다.
SSR은 서버 측에서 컨텐츠가 담긴 html 파일을 구성하고 브라우저 측에 전달하여 렌더링하는 방법이다.
서버에서 생성된 HTML이 사용자의 상호작용을 처리할 수 있도록 만드는 과정을 말한다. 즉, 상호작용 가능한 페이지를 만들기 위해서는 클라이언트 단에서 JS 번들을 다운로드하고 실행해야 한다.
NextJS의 Page Router에서는 getServerSideProps
함수를 이용해 SSR를 이용할 수 있다. 매 요청마다 getServerSideProps
함수를 호출하고 반환값을 이용해 HTML 파일을 구성한다.
export default function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
위 사진들은 NextJS의 Page Router을 통해 구현된 웹페이지이다. 프리뷰를 보면 HTML 파일에 컨텐츠가 채워진 것을 볼 수 있다. 두번째 이미지에서 Response를 보면 __NEXT_DATA__
아이디를 가진 스크립트 태그 내에 JSON 데이터가 있다. 이 데이터는 라우트 및 페이지 정보뿐만 아니라 getServerSideProps
함수의 반환값을 포함한다. 이 JSON 데이터를 통해 서버 사이드와 클라이언트 사이드 간의 데이터 교환이 가능해진다.
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": { ... } // 서버에서 전달한 props (getServerSideProps 반환값) -->
},
"page": "/about", // 현재 페이지 경로
"query": { ... }, // URL 쿼리 매개변수
"buildId": "some-build-id", // 빌드 ID
"assetPrefix": "", // 정적 파일의 경로
"isFallback": false // Fallback 여부 (SSG 관련)
}
</script>
데이터 교환이 필요한 이유?
useState
와 같은 훅은 클라이언트 사이드에서 실행되는데 초기값으로 getServerSideProps
함수로 주입된 데이터를 사용한다고 했을 때 서버에서 생성된 데이터를 클라이언트 측에서 알아야 한다.export default function Page({ initialData }) {
const [data, setData] = useState(initialData);
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const initialData = await res.json()
// Pass data to the page via props
return { props: { initialData } }
}
런타임에 동적으로 HTML을 렌더링하는 것과 달리, Static Site Generation은 빌드 타임에 HTML을 렌더링하는 전략이다. 미리 만들어진 HTML 파일은 사용자 요청마다 재사용된다.
외부 데이터를 가져오지 않는다면 기본적으로 NextJS의 Page Router는 해당 페이지를 빌드 타임에 렌더링한다.
function About() {
return <div>About</div>
}
export default About
외부 데이터를 가져와야 하는 경우 다음 두가지 방법으로 SSG를 이용할 수 있다.
getStaticProps
export default function Blog({ posts }) {
// Render posts...
}
// This function gets called at build time
export async function getStaticProps() {
// Call an external API endpoint to get posts
const res = await fetch('https://.../posts')
const posts = await res.json()
// By returning { props: { posts } }, the Blog component
// will receive `posts` as a prop at build time
return {
props: {
posts,
},
}
}
getStaticPaths
(보통 getStaticProps
와 함께 사용한다)export default function Post({ post }) {
// Render post...
}
// This function gets called at build time
export async function getStaticPaths() {
// Call an external API endpoint to get posts
const res = await fetch('https://.../posts')
const posts = await res.json()
// Get the paths we want to pre-render based on posts
const paths = posts.map((post) => ({
params: { id: post.id },
}))
// We'll pre-render only these paths at build time.
// { fallback: false } means other routes should 404.
return { paths, fallback: false }
}
// This also gets called at build time
export async function getStaticProps({ params }) {
// params contains the post `id`.
// If the route is like /posts/1, then params.id is 1
const res = await fetch(`https://.../posts/${params.id}`)
const post = await res.json()
// Pass post data to the page via props
return { props: { post } }
}
NextJS는 Client-side navigation을 지원한다. 사용자가 애플리케이션 내에서 페이지를 전환할 때, 전체 HTML 문서를 다시 요청하는 대신 JS를 사용해 필요한 데이터와 자원을 가져와 화면을 업데이트하는 방식이다.
<Link>
컴포넌트 또는 router.push()
를 통해 다른 페이지로 이동을 요청한다.getStaticProps
, getServerSideProps
로 준비된 데이터)를 가져온다. 전체 페이지를 다시 요청하는 대신 필요한 리소스만 가져온다.영화 상세 페이지로 이동했을 때 JS 파일과 JSON 데이터(__NEXT_DATA__
)를 서버로부터 가져오는 것을 볼 수 있다.
react-dom/server
API를 사용해서 구현할 수 있는데 렌더링을 담당할 서버는 직접 구현해야 한다. ㅎㅎ
⭐ Page router에서는 페이지 렌더링 방식(SSR, SSG)을 개발자가 직접 결정해야 했다. App router를 사용한다면 개발자는 컴포넌트 단위에서의 렌더링 환경(RSC or RCC)을 결정해야 하고, 페이지 단위에서 어떤 방식으로 렌더링할 것인지는 NextJS가 알아서 최적화해준다.
Next.js는 기본적으로 클라이언트 및 서버 컴포넌트 모두에 대해 서버에서 정적 HTML 미리보기를 렌더링한다.
Server | Client | |
---|---|---|
RSC | ✅ | ❌ |
RCC | ✅ | ✅ |
useState
등과 같이 각종 hook 를 실제로 서버에서 실행은 못하고 초기 값만 가져올수 있음)사진 출처: https://blog.kmong.com/react-server-component로-프론트엔드-개발-혁신하기-part-2-5cf0bf4416b0
RSC & RCC 작동 방식을 더 자세히 알고 싶다면, 아래 튜토리얼을 방문해보는 것을 추천드립니다!
https://demystifying-rsc.vercel.app/
SSR은 페이지 렌더링 기법 중 하나이고, RSC는 “서버의 컴포넌트화”이다. 전혀 다른 개념이다.
SSR | RSC | |
---|---|---|
주요 목적 | 초기 컨텐츠가 채워진 HTML 제공 | 컴포넌트의 일부를 서버에서 처리하고 JSON으로 클라이언트에 전달 |
결과물 | HTML 파일 | RSC Payload |
Hydration 필요 여부 | 필요 | 불필요 |
번들 크기 | 클라이언트에 모든 JavaScript 번들이 전달 | JS 번들에서 서버 컴포넌트 번들 제외 |
만약 한 페이지를 렌더링하기 위해 서버 단에서 여러 API를 호출하는 경우를 생각해보자.
Suspense
와 RSC
를 활용하면 된다. 이 방식을 NextJS 공식문서에서는 Streaming 이라고 소개한다.크몽 개발 블로그에서 사용한 비유를 인용하자면
- SSR: 전체 물탱크를 채우고 한 번에 붓는 것
- Streaming SSR: 기본적인 뼈대를 먼저 브라우저로 던지고 수도꼭지를 열어둔 뒤, 데이터가 응답이 오는대로 수도꼭지로 데이터(chunk)를 내려줌. 다 내리면 수도꼭지는 닫힘
- HTML을 점진적으로 클라이언트로 전송하기 때문에, 사용자는 전체 데이터가 준비될 때까지 기다릴 필요 없이 부분적으로 로드된 콘텐츠를 볼 수 있다. (SSR 단점 해소)
- 초기 페이지 로드 시간 단축 + 준비된 데이터 먼저 사용자에게 빠르게 보여준다.
Server Side rendering (사진 출처: NextJS 공식문서)
Streaming (사진 출처: NextJS 공식문서)
출처: NextJS 튜토리얼 - Partial Prerendering
오늘날 대부분의 웹페이지는 정적 렌더링과 동적 렌더링 중 하나를 선택한다. 만약 어떤 특정 라우트 내에서 캐싱되지 않은 데이터 또는 dynamic API를 발견하면, 라우트 전체가 요청 시 동적으로 렌더링된다.
하지만 대부분의 라우트는 완전히 정적이거나 동적인 것은 아니다. 예를 들어 쇼핑몰 사이트의 제품 정보 페이지를 생각해 보면, 제품 설명을 정적으로 렌더링할 수도 있지만, 추천 제품 목록 데이터를 가져와 개인화된 콘텐츠를 표시할 수도 있다.
페이지 내의 정적인 부분과 동적인 부분을 분리해 렌더링한다.
예를 들어, 사용자가 특정 라우트를 방문할 때
기존 Streaming SSR과의 차이점은 페이지의 정적인 부분을 빌드 타임 동안 사전 렌더링한다는 것이다.
최적화에 미친 사람들인 것 같다
PPR은 React의 Suspense를 사용해 특정 조건이 충족될 때까지 애플리케이션 일부분의 렌더링을 지연시킨다.
next.config.js
에 ppr
옵션을 켠다./** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default nextConfig;
Suspense
를 사용해 동적 콘텐츠를 감싼다.Vercel에서 만든 예시 애플리케이션을 참고하면 좋을 것 같다.
📌 CSR부터 PPR까지, 빠른 TTFB/FCP/TTI를 달성하기 위해 프론트엔드 렌더링 최적화는 계속 진행되고 있다. 컴포넌트 분리 전략과 렌더링 환경(서버/클라이언트)의 적절한 선택이 중요해졌으며, 효과적인 컴포넌트 설계를 위한 역량 강화가 필수적이다.