TIL

Next.js - Pages Router(1)

reference: https://nextjs.org/learn/pages-router최근 며칠간, 위 링크를 참고하여 Pages Router 방식으로 간단한 형태의 블로그를 만들어 봤습니다.Pages Router는 pages 폴더 안에 파일을 만드는 것만으로

2025년 12월 23일9min read

0. Intro 🎯

reference: https://nextjs.org/learn/pages-router

최근 며칠간, 위 링크를 참고하여 Pages Router 방식으로 간단한 형태의 블로그를 만들어 봤습니다.

Pages Router는 pages 폴더 안에 파일을 만드는 것만으로도 자동으로 URL 경로가 생성되는 방식입니다. 페이지별로 CSR, SSR, SSG, ISR과 같은 특정 렌더링 방식을 적용할 수 있습니다.

Pages Router 방식 역시 Routing, Rendering, Data Fetching, Optimizing 등 다양한 요소를 고려합니다. 오늘은 그중에서도 Rendering 방식에 관한 이야기를 진행하고자 합니다.


reference: https://nextjs.org/docs/14/pages/building-your-application/rendering reference: https://roy-jung.github.io/250323-react-server-components reference: https://www.youtube.com/watch?v=GswzHF5UpHA&t=144s

Pages Router 14.2.35 버전의 공식 문서, 그리고 프론트엔드 개발자라면 모를 수 없는 코어 자바스크립트의 저자인 정재남 님이 번역한 글, 마지막으로 구독자 14명 유튜버 시금치님의 영상을 참고해서 글을 작성했습니다.

1. Pre-Rendering 🎯

Pre-Rendering은 말 그대로 HTML을 미리 생성해두고, 사용자에게 완성된 페이지를 제공하는 방식입니다.

오늘 다룰 CSR, SSR, SSG, ISR은 Pre-Rendering 방식의 사용 여부로 구분할 수 있습니다. CSR은 Pre-Rendering을 사용하지 않는 반면, SSR, SSG, ISR은 모두 Pre-Rendering 방식을 활용합니다.

이제 본격적으로 Client-Side Rendering부터 살펴보겠습니다.

2. Client-Side Rendering(CSR) 🎯

클라이언트가 서버에 접속하여 GET 요청을 보내면, 서버는 빈 HTML 파일과 JS 번들을 내려줍니다. 이때 브라우저는 실제 콘텐츠가 없는 상태이므로, 사용자는 로딩 스피너나 빈 화면을 보게 됩니다. 좌측 상단의 Loading Page가 이 시점을 나타내죠.

다음으로 브라우저에 로드된 자바스크립트가 실행되면서, 필요한 데이터를 가져오기 위해 API 서버에 추가적인 요청을 보냅니다. 이미지 중간에 위치한 박스는 여러 요청이 동시에 발생하거나 순차적으로 일어나는 상황을 표현한 것이고, 이 과정에서 Waterfall 현상이 발생할 수 있습니다.

API 서버로부터 데이터를 성공적으로 받아오면, 자바스크립트가 해당 데이터를 기반으로 HTML 요소를 동적으로 생성하여 화면에 추가합니다. 비로소 사용자는 완성된 UI와 데이터를 확인할 수 있는데요, 좌측 하단에 위치한 Content loaded는 최종적으로 렌더링이 완료된 모습을 의미합니다.

이러한 Client-Side Rendering 방식은 두 가지 관점에서 문제가 있습니다.

사용자 경험(UX) 관점 ⚠️

CSR 방식은, 서버로부터 텅 빈 HTML과 큰 용량의 JS 파일을 먼저 받아야 합니다. 그 후에 브라우저가 JS를 해석하고 실행하여 API 서버로부터 데이터를 가져오기 전까지는 사용자에게 어떠한 정보도 보여줄 수 없습니다. 인터넷 속도가 느리거나 저사양 기기를 사용하는 사용자는 실제 콘텐츠를 보기까지 긴 시간 동안 빈 화면만 마주하게 되어 서비스를 이탈할 확률이 높아집니다.

검색 엔진 최적화(SEO) 관점 ⚠️

구글이나 네이버 같은 검색 엔진의 크롤러가 웹 사이트에 방문했을 때, CSR 방식은 처음에 텍스트 정보가 거의 없는 빈 HTML만 전달합니다. 크롤러는 페이지의 내용을 읽어 인덱싱해야 하는데, 읽을 내용이 없으니 해당 사이트가 무엇을 하는 곳인지 알기 어렵습니다. 검색 결과 상단에 노출되기 매우 어려운 것이죠.

3. Server-Side Rendering(SSR) 🎯

클라이언트가 서버로 GET 요청을 보내면, 서버는 즉시 DB에서 필요한 데이터를 조회합니다. 서버는 가져온 데이터를 HTML에 미리 결합하여 완성된 HTML을 렌더링 합니다. 이때까지는 여전히 브라우저는 Blank Page 상태를 유지합니다.

서버가 완성된 HTML을 브라우저로 전달하면, 브라우저는 이를 즉시 화면에 그립니다. 이 시점에 사용자는 곧바로 콘텐츠를 볼 수는 있습니다만, 아직 자바스크립트가 로드되거나 실행되기 전이므로 버튼 클릭 등의 상호작용은 불가능한 상태입니다.

브라우저는 서버로부터 자바스크립트 파일을 가져온 뒤, 기존 HTML에 자바스크립트 로직을 연결하는 Hydration 과정을 거칩니다. 이 과정까지 끝나야 페이지는 모든 기능을 정상적으로 수행할 수 있는 완전한 상태가 됩니다.

참고로 특정 페이지에 Server-Side Rendering을 적용하려면, 아래 코드와 같이 getServerSideProps라는 이름의 비동기 함수를 export 해야 합니다.

js
export default function Page({ data }) {
  return (
    <div>
      {/* 렌더링 로직 */}
    </div>
  );
}

export async function getServerSideProps() {
  const res = await fetch(`https://.../data`)
  const data = await res.json()
 
  return { props: { data } }
}

getServerSideProps는 getStaticProps와 비슷하지만, 빌드 시점이 아닌 매 요청 시마다 실행된다는 차이가 있습니다. 이에 대해서는 Data Fetching 편에서 더 자세히 다루도록 하겠습니다.

Server-Side Rendering 방식 역시 두 가지 관점에서 문제가 있습니다.

TTFB(Time to First Byte) 관점 ⚠️

일반적으로 SSR 도입은 FCP(First Contentful Paint) 성능 지표를 개선한다고 합니다. 하지만 TTFB 성능 지표는 그렇지 않습니다. 브라우저는, 서버가 필요한 데이터를 페칭하고 초기 HTML을 생성하여 첫 바이트를 전송할 때까지 마냥 기다려야 합니다. TTFB가 핵심 웹 성능이라고 보기는 어렵지만, 다른 성능 지표에 영향을 주는 것이 사실입니다. 즉 TTFB가 좋지 않다면 다른 핵심 웹 성능 지표가 하락합니다.

Interactive 관점 ⚠️

클라이언트 측의 리액트가 Hydration을 완료하기 전까지는 전체 페이지가 제대로 동작하지 않는다는 단점도 있습니다. 가령, 리액트가 버튼에 대한 이벤트 리스너를 부착하는 과정을 수행하기 전까지는 Interactive 요소들이 사용자 상호작용에 대해 반응하지 않습니다. Hydration은 빠르게 이루어지긴 하지만, 사용 중인 기기의 인터넷 연결 상태나 하드웨어의 성능에 따라 속도가 눈에 띄게 느려질 수 있습니다.

CSR과 SSR이 서로의 한계를 보완하려는 시도였다면 이제는 CSR과 SSR의 한계를 줄이기 위해, SSR을 다시 세 가지 방식으로 분화한 하이브리드 접근 방식을 취하고 있습니다.

4. Static Site Generation(SSG) 🎯

Static Site Generation 방식은, '매 요청마다 서버가 일을 해야 한다는 SSR의 근본적인 비용 문제'와 '사용자가 빈 화면을 봐야 한다는 CSR의 경험 문제'를 동시에 해결하며 한계를 줄입니다. 다만 SSG는 콘텐츠가 크게 변하지 않는 소규모 프로젝트(마케팅 사이트, 개인 블로그)에는 적합하지만, 콘텐츠가 자주 변경되는 대규모 프로젝트(이커머스 사이트)에는 적합하지 않겠다고 예상됩니다.

Static Site Generation 방식은 요청마다 HTML 코드를 생성하는 것이 아니라, 빌드 타임에 전체 앱을 컴파일하고 구축하여 정적인 순수 HTML과 CSS 파일을 생성합니다. 이렇게 만들어진 정적 파일을 전 세계 곳곳의 CDN에 호스팅 할 수 있겠죠?

CSR처럼 빈 HTML이 아니라 빌드 시점에 데이터가 이미 채워진 HTML을 생성합니다. 동시에 크롤러는 모든 텍스트 콘텐츠가 포함된 HTML을 받게 되므로, CSR의 치명적인 약점인 SEO 문제 역시 해결할 수 있습니다.

페이지 콘텐츠가 외부 데이터에 의존하는 경우에는 다음과 같이 getStaticProps를 사용합니다.

js
export default function Blog({ posts }) {
  // 게시물 렌더링...
}
 
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
 
  return {
    props: {
      posts,
    },
  }
}

만약 페이지 경로가 외부 데이터에 의존하는 경우라면 보통 getStaticProps와 함께 getStaticPaths를 사용합니다.

js
export default function Post({ post }) {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  )
}

export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  const paths = posts.map((post) => ({
    params: { id: post.id.toString() },
  }))

  return { paths, fallback: false }
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()

  return { props: { post } }
}

코드에 대해서는 Data Fetching 편에서 더 자세히 다룰 예정입니다.

5. Incremental Static Regeneration(ISR) 🎯

다음으로는 증분 정적 재생성(Incremental Static Regeneration)입니다.

SSG는 콘텐츠의 변경이 필요할 때, 앱의 모든 코드를 다시 빌드 해야 한다는 단점이 있습니다. '정적'이라는 특성상 콘텐츠가 고정되어 있기에, 전체를 다시 빌드 하지 않고 일부만 변경할 수는 없습니다. SSG의 단점을 해결하기 위해 ISR이라는 두 번째 하이브리드 방식이 등장하게 되었습니다.

ISR 방식도 초기 빌드 시에는 SSG와 마찬가지로 페이지의 '초기 버전'을 정적으로 생성합니다. 이후 사용자가 특정 페이지에 접근하여 서버 요청이 발생하면, 데이터를 확인하여 오래된 데이터가 포함된 경우 해당 페이지를 다시 빌드 합니다.

그렇지만 사용자가 새 버전의 콘텐츠가 생성되기 전에 페이지를 방문하면 여전히 오래된 콘텐츠를 보게 됩니다. 또한 ISR 방식은 SSG 방식과 달리 개별 페이지를 재생성 하기 위해 실제 서버가 필요합니다. 최적화된 자산 전달을 위해 CDN에 앱을 배포한 의미가 퇴색되겠네요.

기본적인 사용법은 다음과 같습니다.

js
function Blog({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

// 정적 페이지 생성 + ISR 재생성 설정
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // 첫 요청 이후 백그라운드에서 최대 10초 간격으로 페이지 재생성
    revalidate: 10,
  }
}

// 사전 생성할 동적 경로 정의 + 없는 경로는 최초 요청 시 생성
export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))

  return {
    paths,
    // 미생성 페이지는 요청 시 서버에서 생성 후 캐싱
    fallback: 'blocking',
  }
}

export default Blog

SSG는 빌드 타임에 정적 파일을 완성하는 방식이었고, SSR은 사용자의 요청마다 서버에서 정적 파일을 렌더링 하는 방식이었습니다.

ISR은, 정적 페이지를 먼저 보여준다는 점에서 SSG의 성격을, 필요에 따라 서버에서 페이지를 재생성한다는 점에서 SSR의 성격을 모두 갖고 있습니다.

6. React Server Component(RSC) 🎯

주의 ⚠️

RSC는 Pages Router가 아니라 Next.js App Router(13+) 환경에서 본격적으로 도입된 개념입니다. 이 글에서는 렌더링 패러다임의 흐름을 이해하기 위해 Pages Router 맥락에서 함께 다룹니다.

필요에 따라 CSR, SSR, SSG, ISR과 같은 방식을 사용해 왔지만, 각각은 일종의 트레이드오프가 있었습니다. RSC는 개발자가 개별 리액트 컴포넌트마다 적합한 렌더링 전략을 선택할 수 있도록 합니다.

회색으로 되어있는 서버 컴포넌트는 서버에서 실행됩니다. 해당 코드는 브라우저로는 절대 전송되지 않습니다. 서버 컴포넌트는 오직 HTML 출력물과 컴포넌트가 받을 props만을 제공합니다.

서버 컴포넌트는 대용량 라이브러리와 민감한 로직을 서버에 배치해 클라이언트의 자바스크립트 번들 크기를 획기적으로 줄이고 Hydration 과정을 생략하며, 데이터 소스와 가까운 위치에서 연산을 수행해 워터폴 현상과 네트워크 왕복 횟수를 최소화합니다. 또한 보안이 중요한 API 키 등을 안전하게 격리하고 렌더링 결과를 캐싱하여 재사용함으로써 데이터 전송량 감소와 성능 최적화를 동시에 달성하는 강력한 이점을 제공합니다.

클라이언트 컴포넌트는 특별하거나 새로운 개념이 아닌, 우리가 이미 잘 알고 있는 컴포넌트입니다. 클라이언트 컴포넌트는 파일 상단에 "use client" 지시어로 정의합니다.

js
"use client"
export default function LikeButton() {
  const likePost = () => {
    // ...
  }
  return (
    <button onClick={likePost}>Like</button>
  )
}

Next.js는 기본적으로 모든 컴포넌트를 서버 컴포넌트로 취급합니다. 따라서 클라이언트 컴포넌트는 명시적으로 "use client"로 정의해야 합니다.

클라이언트 컴포넌트는 오직 클라이언트에서 렌더링 된다고 생각할 수 있습니다. 그러나 Next.js는 초기 HTML 생성을 위해 서버에서 클라이언트 컴포넌트를 렌더링 합니다. 그 결과로 브라우저는 이를 즉시 렌더링 할 수 있으며, 이후 Hydration 과정을 거칩니다.

이미지의 좌측 영역은 초기 페이지 로드에 대한 흐름입니다.

사용자가 처음 웹사이트에 접속하면, 브라우저는 Next.js 서버에 GET 요청을 보냅니다. 이때 화면은 아직 완전히 비어있는 상태입니다. Next.js는 React에게 해당 라우트의 서버 컴포넌트를 찾아달라고 요청하고, React가 서버에서 컴포넌트들을 렌더링 한 결과를 받아옵니다.

그다음 Next.js는 페이지의 기본 뼈대가 되는 HTML과 React가 필요로 하는 직렬화된 컴포넌트 데이터인 RSC 페이로드를 브라우저로 보내기 시작합니다. 이 과정은 마치 물이 흐르듯이 조금씩 전송되는데, 이를 스트리밍이라고 부릅니다. 브라우저는 이 데이터를 받자마자 페이지의 기본 구조를 화면에 먼저 표시합니다.

마지막으로, Suspense로 감싼 컴포넌트들이 서버에서 하나씩 완성됩니다. 완성될 때마다 해당 부분의 HTML과 RSC 페이로드가 스트리밍으로 계속 전송되고, 브라우저는 이를 받아서 페이지에 점진적으로 채워 넣습니다. 그래서 사용자는 전체 페이지가 완성될 때까지 기다리지 않고, 준비된 부분부터 먼저 볼 수 있습니다.


이미지의 우측 영역은 초기 페이지 로드 후, 클라이언트가 다른 페이지로 이동할 때의 과정입니다.

이미 페이지가 로드된 상태에서 사용자가 다른 페이지로 이동하면, Next.js는 서버에서 해당 페이지의 컴포넌트 트리를 재귀적이고 비동기적으로 렌더링 하기 시작합니다. 이 과정은 컴포넌트를 하나씩 깊이 탐색하면서 서버 컴포넌트들을 실행하고, 최종적으로 렌더링 된 컴포넌트 트리를 만들어냅니다.

렌더링 중에 클라이언트 컴포넌트를 만나면, Next.js는 해당 컴포넌트를 직접 렌더링 하지 않고 브라우저가 어떻게 렌더링 해야 하는지에 대한 지침만 준비합니다. 또한 Suspense로 감싸진 느린 서버 컴포넌트를 만나면, 일단 대체 UI인 폴백을 반환하고 실제 컴포넌트는 백그라운드에서 병렬로 실행시킵니다. 이렇게 하면 느린 부분 때문에 전체 페이지 로딩이 막히지 않습니다.

모든 준비가 끝나면 Next.js는 RSC 페이로드만 브라우저로 전송하고, 이를 기반으로 최종 HTML을 생성합니다. 초기 페이지 로드와 달리 이번에는 HTML을 함께 보내지 않고 RSC 페이로드만 보내기 때문에 전송되는 데이터 양이 훨씬 적습니다. 브라우저의 React는 이 페이로드를 받아서 기존 페이지를 새로운 내용으로 부드럽게 업데이트합니다.

7. Outro 🎯

CSR, SSR, SSG, ISR, 그리고 RSC까지. 모든 방식은 등장할 당시 해결해야 했던 문제와 전제가 있었고, 그 상황 속에서는 충분히 합리적인 선택이었습니다. 시간이 지나면서 환경이 달라지고 요구사항이 변하자 새로운 접근이 나왔을 뿐입니다.

정답은 없습니다. 다만 각자 처한 상황과 목표에 맞춰 선택할 수 있는 여러 해답이 있을 뿐이고, 그중에서 그리고 그 시점에서 가장 적절한 것을 고르면 된다고 생각합니다. 다양한 렌더링 방식들을 살펴봤습니다.