Next.js 13 master course - Static / Dynamic Rendering

dante Yoon·2023년 6월 17일
12

nextjs13-master-course

목록 보기
8/11
post-thumbnail

안녕하세요, 단테입니다.

Next.js Master Course에 오신 여러분 환영합니다.
오늘은 아래 내용에 대해 배워보겠습니다.

서버사이드 렌더링은 왜 필요할까?

정적 / 동적 사이트에 대해 이야기하기 전에 서버사이드 렌더링을 사용하는 이유에 대해 먼저 이야기해보겠습니다.

Next.js를 사용하는 주 이유는 서버사이드 렌더링을 사용하기 위해서입니다.

서버 사이드 렌더링은 왜 필요할까요?

서버 사이드 렌더링은 렌더링을 서버에서 한다는 의미입니다.
서버 사이드 렌더링을 사용하는 주 이유는 html 코드에 포함될 주요한 콘텐츠들이 서버에서 만들어져서 오는 것에 있습니다.

SSR(Server Side Rendering)을 통해 만들어진 html 파일은 아래와 같이 작성되어 있습니다.

// HTTP/1.1 200 OK
// Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <div>
      <header>
        <nav>
          <ul>
            <li><a href="/">Home</a></li>
            <li><a href="/about">About</a></li>
            <li><a href="/contact">Contact</a></li>
          </ul>
        </nav>
      </header>
      <div class="container">
        <h1>Welcome to my website!</h1>
        <h2>Contents</h2>
        <ul class="content-list">
          <li>Content 1</li>
          <li>Content 2</li>
          <li>Content 3</li>
        </ul>
        <h2>User Profiles</h2>
        <div class="profile-card">
          <h2>John Doe</h2>
          <p>Age: 30</p>
          <p>Location: New York</p>
        </div>
        <div class="profile-card">
          <h2>Jane Smith</h2>
          <p>Age: 28</p>
          <p>Location: London</p>
        </div>
      </div>
      <footer>
        <p>&copy; 2023 Dante. All rights reserved.</p>
      </footer>
    </div>
<script src="/_next/static/chunks/app/c594c0eaffc.js"></script>
  </body>
</html>

파일 내부에 여러 리스트들과 유저 정보들이 들어있습니다. 유저에게 최종적으로 보여져야 하는 콘텐츠들이 만들어진 상태로 전달되기 떄문에 JS가 화면을 그릴 때까지 유저가 흰 화면을 보고 있지 않아도 됩니다.

그와 반면에 클라이언트 사이드 렌더링으로 생성된 html 코드들은 SSR html 파일과 비교해 매우 단출합니다.

CSR(Client Side Rendering)을 통해 만들어진 html 파일은 반면 상당히 간단합니다.

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/javascript" src="app.js?hash=1a2b3c"></script>
  </body>
</html>

SSR로 생성된 html은 유저 뿐만 아니라 검색 사이트들에도 더 좋은 정보를 제공하여 좋은 SEO 지표를 발생시킵니다.

Static Rendering

Next.js는 app route의 page에서 data fetching시 별도 캐시 설정을 하지 않아도 기본적으로 캐시를 사용합니다. 이를 통해 불필요한 데이터 패칭을 하지 않아도 되며 서버는 이미 만들어진 파일들을 사용자에게 응답하기만 하면 되기 때문에 유저는 더 빠른 응답 속도를 기대할 수 있습니다.

이미 생성된 정적 파일을 전달하는 정적 렌더링

리퀘스트 타임에 html을 다시 가공하여 전달하는 동적 렌더링

이미 만들어진 파일을 사용한다는 것은 페이지의 생성이 사용자의 리퀘스트 타임에 되는 것이 아니며 특정 시간마다 한번, 혹은 빌드 타임에 실행되는 것을 의미합니다.

Next.js 12의 page 디렉토리의 경우 getStaticProps의 사용하여 빌드 타임에 정적 파일들을 생성했습니다.

// nextjs의 `pages` directory
 
export async function getStaticProps() {
  const res = await fetch(`https://...`)
  const projects = await res.json()
 
  return { props: { projects } }
}
 
export default function Index({ projects }) {
  return projects.map((project) => <div>{project.name}</div>)
}

app directory에서는 getStaticProps와 같은 특수 함수를 사용하지 않고 서버 컴포넌트 내부에서 간단하게 api를 호출하기만 해도 동작합니다.

// `app` directory
 
// This function can be named anything
async function getProjects() {
  const res = await fetch(`https://...`)
  const projects = await res.json()
 
  return projects
}
 
export default async function Index() {
  const projects = await getProjects()
 
  return projects.map((project) => <div>{project.name}</div>)
}

Static data fetch

Next.js에서 사용하는 fetch 함수는 Web Api를 래핑해 기능을 추가한 함수입니다. 아래에서 next.js fetch함수에 대해 좀 더 알아볼 것입니다.

앞서 봤었던 예제 코드에서도 이 fetch함수를 사용해 getProjects 함수 내부에서 데이터를 가져왔었습니다.

next.js fetch

조심! next.js의 세부 구현에 관심이 없으면 읽지 않으셔도 됩니다.

래핑했다는 말은 fetch 함수의 시그니처를 그대로 사용하며 nextjs에서 제공하기 원하는 기능을 추가했다는 말입니다.

globalThis에 초기화된 async function 내부 인자를 보면

아래와 같이 typescript에서 정의한 fetch api의 시그니처와 동일함을 확인할 수 있습니다.

// typescript/lib.dom.ts
declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;

이 때 인터페이스 RequestInit은 nextjs에서 확장되는데요


interface NextFetchRequestConfig {
  revalidate?: number | false // 초 단위
  tags?: string[]
}

interface RequestInit {
  next?: NextFetchRequestConfig | undefined
}

RequestInit은 optional한 키 값인 next를 받습니다.

자칫 너무 심각해질 수 있는 이야기에서 벗어나
다시 사용 방법을 알아보겠습니다.

// `app` directory
 
// This function can be named anything
export default async function Page() {
  // This request should be cached until manually invalidated.
  // Similar to `getStaticProps`.
  // `force-cache` is the default and can be omitted.
  const staticData = await fetch(`https://...`, { cache: 'force-cache' })
 
  // This request should be refetched on every request.
  // Similar to `getServerSideProps`.
  const dynamicData = await fetch(`https://...`, { cache: 'no-store' })
 
  // This request should be cached with a lifetime of 10 seconds.
  // Similar to `getStaticProps` with the `revalidate` option.
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  })
 
  return <div>...</div>
}

nextjs의 fetch api는 별도 설정을 해주지 않는다면 force-cache 설정이 자동으로 적용됩니다. 아래와 같이 명시적으로 'force-cache'가 적용되지 않아도 캐시가 적용된다는 뜻입니다.

const staticData = await fetch(`https://...`, { cache: 'force-cache' })

캐시가 필요 없다면 다음과 같이 no-store을 설정하여 리퀘스트 요청마다 cache miss를 일으키거나

// This request should be refetched on every request.
// Similar to `getServerSideProps`.
const dynamicData = await fetch(`https://...`, { cache: 'no-store' })

다음과 같이 revalidate 시간(초 단위)을 적용시킬 수 있습니다.

 // This request should be cached with a lifetime of 10 seconds.
  // Similar to `getStaticProps` with the `revalidate` option.
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  })

revalidate

fetch(`https://...`, { next: { revalidate: false | 0 | number } })

revalidate설정은 false, 0, 그리고 초단위의 숫자를 입력할 수 있습니다.

  • false: 영구적으로 캐시에 저장합니다.
  • 0: 캐시에 저장되는 것을 방지합니다.
  • number: 캐시에 저장된 시간을 초 단위로 설정합니다.

revalidate 옵션은 static rendering과 dynamic rendering을 가르는 중요한 부분입니다! 꼭 기억해두세요!

nextjs app directory의 페이지는 기본적으로 dynamic function을 사용하지 않는다면 static 렌더링을 채택합니다.

dynamic function이 발견되지 않으면 static 렌더링을 하는 next.js

이제 동적 렌더링으로 넘어가 dynamic function에 대해 알아보겠습니다.

Dynamic Rendering

정적 렌더링을 하는 동안 dynamic function 이나 dynamic fetch 요청이 발견된다면
Next.js는 동적렌더링으로 전환하여 사용자 요청이 들어올때마다 렌더링을 실행합니다.

캐시된 데이터는 동적 렌더링을 하는 동안에도 재사용할 수 있습니다.

next.js 공식 문서에서 제공하는 위 도표를 보면 data fetching시 캐시를 사용하지 않는 경우에는 모두 동적 렌더링을, dynamic function을 사용하는 경우에도 동적 렌더링을 사용합니다.

dynamic function

서버 컴포넌트 내부에서는 쿠키 값과 헤더 값을 조회하는 함수를 사용할 수 있습니다.
next/server 모듈에 선언되어 있는 cookies, headers api를 사용합니다.

headers

클라이언트(보통 웹 브라우저를 칭함)에서 서버로 요청하는 것을 outgoing 이라는 용어로 웹 개발에서는 사용합니다. outgoing header는 클라이언트에서 서버로 요청했을때 담기는 request header를 의미합니다.

next.js에서는 next/server 모듈을 통해 outgoing 헤더를 읽을 수 있는 headers 유틸 함수를 제공합니다.

서버 컴포넌트에서 헤더 읽기

app/post/post.tsx 파일에서 header 함수를 임포트 해 아래와 같이 콘솔을 찍어보겠습니다.

import { headers } from "next/headers"
import { getPosts } from "../api/post/getPosts"

export default async function PostPage() {
  const header = headers()
  const posts = await getPosts()
  // request header
  console.log("middleware request-time: ", header.get("request-time"))
  // ... 이하 생략 
  
}

아래에서 참조되는 헤더 값 중 middleware request-time은 middleware의 request header에서 붙여지는 값입니다.

// request header
  console.log("middleware request-time: ", header.get("request-time"))
  

미들웨어 헤더 값 추가하기

프로젝트 루트에 있는 middleware.ts를 수정하겠습니다.

import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"

export function middleware(request: NextRequest) {
  // Assume a "Cookie:nextjs=fast" header to be present on the incoming request
  // Getting cookies from the request using the `RequestCookies` API
  let cookie = request.cookies.get("nextjs")?.value
  const allCookies = request.cookies.getAll()
  const newRequestHeaders = new Headers(request.headers)
  newRequestHeaders.set("some-thing", "something from headers")
  newRequestHeaders.set("request-time", new Date().getTime().toString())
  // .. 중략
  const response = NextResponse.next({
    // Clone the request headers and set a new header `x-hello-from-middleware1`
    request: {
      headers: newRequestHeaders,
    },
  })
  
  return response
}

newRequestHeaders 변수를 선언한 후 request-time 헤더 값을 선언합니다.

선언한 헤더는 NextResponse.next의 request 키로 전달합니다.

response 객체를 생성한 후 헤더 값을 선언해주었습니다.

이후 우리 페이지 localhost:3000/post로 접속시 아래 처럼 header 값을 읽을 수 있습니다.

authorization header값 넘기기

저희 프로젝트에서는 없지만 예제코드를 하나 보겠습니다.

아래 예제에서는 앞서 알아봤던 headers를 호출해 header 리스트를 가져온 후 outgoing request header 값 중 인증에 필요한 authorization 값만 추출하여 외부 api 호출시 전달합니다.

 
async function getUser() {
  const headersInstance = headers()
  const authorization = headersInstance.get('authorization')
  // Forward the authorization header
  const res = await fetch('...', {
    headers: { authorization },
  })
  return res.json()
}

fetch without cache

fetch 함수에서 사용하는 cache 옵션은 아래와 같이 매우 다양합니다.

const response = await fetch(`${getBaseUrl()}/api/post`, {
    next: {
      revalidate: 0,
    },
    cache: 'default'
  })

force-cache

이 중 기본적으로 fetch 함수에 적용되는 캐시 옵션은 force-cache이며 이는 캐시된 데이터가 있고 만료시간이 경과되지 않았을 경우 원격 서버에서 데이터를 다시 가져오지 않고 캐시를 사용합니다.

no-store

매 request마다 원격 저장소에서 데이터를 가져옵니다.

static rendering 모습

아래와 같이 먼저 static rendering을 실습하겠습니다. 페이지를 url을 통해 다시 접속하더라도 캐시가 HIT 되어

화면에 표기되는 시간이 변경되지 않습니다.

아래 GIF의 우측 서버 로그를 보시면 캐시 HIT되는 것을 볼 수 있습니다.

우리가 실습에 사용하는 post page 코드는 아래와 같습니다.

post/page.tsx


import { getPosts } from "../api/post/getPosts"
// export const validate = 0

export default async function PostPage() {
  const posts = await getPosts()

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Posts</h1>
      {posts.map((post) => (
        <div key={post.id} className="bg-white p-4 rounded shadow mb-4">
          <h2 className="text-2xl font-bold mb-2">{post.title}</h2>
          <p className="text-gray-600 mb-2">
            By {post.author} | {post.date}
          </p>
          <p className="mb-4">{post.content}</p>
          <div className="bg-gray-100 p-2 rounded">
            {post.comments.map((comment, idx) => (
              <div
                key={comment.id}
                className="mb-2"
                // onClick={handleClick}
              >
                <p className="text-gray-600 mb-1">{comment.author}</p>
                <p>{comment.text}</p>
                <div>{post.time}</div>
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

나중 강의에서 알아볼 export const validate 선언과 같이 페이지 캐시와 관련된 부분들은 코멘트 처리하고 단순히 서버 컴포넌트 내부에서 getPosts를 호출합니다.

app/api/post/getPosts.ts

앞선 page.tsx와 동일하게 캐시와 관련된 옵션인 revalidate는 코멘트 처리했습니다. 이 getPosts 함수는 이제 우리가 이전강의에서 만들었던 라우트 핸들러를 호출합니다.

import { getBaseUrl } from "~/app/lib/getBaseUrl"

export const getPosts = async () => {
  const response = await fetch(`${getBaseUrl()}/api/post`, {
    // next: {
    //   revalidate: 0,
    // },
  })

  if (!response.ok) {
    throw new Error("something went to wrong")
  }

  return (await response.json()) as Post[]
}

마지막으로 라우트 핸들러가 어떻게 생겼는지 살펴보겠습니다.

app/api/post/route.ts

동일하게 revalidate와 관련된 옵션은 코멘트 처리했습니다.

우리가 일전에 만들었던 라우트 핸들러는 posts 변수를 핸들러 외부에서 선언했는데요, 이렇게 되면 서버 처음 시작 때 time 값이 결정되므로 요청 시마다 time 필드가 업데이트될 수 있도록 posts를 핸들러 내부에 선언했습니다.

거추장 스럽다면 외부에 선언해서 time 필드만 map으로 추가해주셔도 되겠습니다.

// export const revalidate = 0
export async function GET(request: NextRequest) {
  const posts = [
    {
      id: 1,
      title: "First Post",
      content: "This is the first post.",
      author: "John Doe",
      date: "May 1, 2023",
      comments: [
        { id: 1, text: "Great post!", author: "Jane Smith" },
        { id: 2, text: "I totally agree.", author: "Mike Johnson" },
      ],
      time: new Date().getTime(),
    },
    {
      id: 2,
      title: "Second Post",
      content: "This is the second post.",
      author: "Jane Smith",
      date: "May 5, 2023",
      comments: [
        { id: 1, text: "Thanks for sharing!", author: "John Doe" },
        { id: 2, text: "Looking forward to more.", author: "Mike Johnson" },
      ],
      time: new Date().getTime(),
    },
    {
      id: 3,
      title: "Third Post",
      content: "This is the third post.",
      author: "Mike Johnson",
      date: "May 10, 2023",
      comments: [
        { id: 1, text: "Amazing content!", author: "Jane Smith" },
        { id: 2, text: "Keep up the good work.", author: "John Doe" },
      ],
      time: new Date().getTime(),
    },
    // Add more posts as needed
  ]

  const response = new NextResponse(JSON.stringify(posts), { status: 200 })

  try {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(response)
      }, 500)
    })
  } catch (e) {
    return new NextResponse(null, { status: 500 })
  }
}

dynamic rendering 모습

앞서서 도표를 봤듯이 dynamic 함수를 사용하거나 dynamic data fetching을 할 경우 해당 페이지가 dynamic rendering을 수행합니다.

우리가 앞선 예제에서 dyanmic fetching을 설정할 수 있는 곳이 세 가지로 나뉘어졌습니다.

어떻게 설정할 수 있는지 확인해보겠습니다.

post/page.tsx

//.. 생략

// 새로 추가된 부분
export const validate = 0

export default async function PostPage() {
  const posts = await getPosts()

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Posts</h1>
      <div> // 새로 추가된 부분 
        this is not from data fetching: {new Date().getTime().toString()}
      </div>
      // ... 중략
    )

먼저 export const validate = 0 을 통해 서버 컴포넌트 렌더링을 다이나믹 렌더링으로 변경했습니다.

그리고 data fetching 부분은 변경하지 않고 jsx 내부에 현재 타임을 출력하도록 했습니다.

<div> // 새로 추가된 부분 
  this is not from data fetching: {new Date().getTime().toString()}
</div>

이 경우 어떻게 페이지가 변경되는지 확인해보겠습니다.

새로 고침시 api 호출로 받아온 데이터는 변경되지 않지만 새로 추가된 영역은 매번 타임 텍스트가 변경되고 있습니다.

현재까지 변경한 캐싱 옵션

api/post/route.ts

이제 받아오는 데이터도 요청마다 갱신될 수 있도록 수정하려고 합니다.

export const revalidate = 0
export async function GET(request: NextRequest) {
  const posts = [
    {

라우트 핸들러의 revalidate 옵션도 0으로 설정합니다.

그런데 이상합니다. 왜 api호출로 받아오는 데이터가 갱신이 되지 않을까요?


page.tsx 아래에서 다음처럼 호출을 합니다.

const posts = await getPosts()

getPosts 내부에서는 fetch 함수를 호출하는데 여기서 revalidate 옵션을 설정해주지 않았기 때문에 우리 앱에 있는 라우트 핸들러를 호출해주지 않는 것입니다.

export const getPosts = async () => {
  const response = await fetch(`${getBaseUrl()}/api/post`, {
    // next: {
    //   revalidate: 0,
    // },
  })
  // ... 중략
}

api/post/getPosts.ts

위 앞서 봤었던 getPosts 내부의 fetch 함수의 next 옵션을 주석 해제하고 다시 페이지 동작을 관찰하겠습니다.

이전 강의에서 설정했던 Loading UI가 보이는 모습

데이터가 갱신되는 것을 확인했습니다.

수고하셨습니다.

오늘은 nextjs13 app directory의 정적 렌더링과 동적 렌더링에 대해 알아보았습니다.

에제코드 보시고 한번 어떤 원리로 동작하는지 고민해보시고 복습하시길 바랍니다.

다음 강의에서 뵙겠습니다.

감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

2개의 댓글

comment-user-thumbnail
2023년 10월 9일

덕분에 Next에 대해 많이 알 수 있었습니다 👍👍👍

답글 달기
comment-user-thumbnail
2023년 11월 4일

좋은 정리 글 감사합니다 👍🏻
https://velog.io/@jay/Next.js-13-master-course-Static-Dynamic-Rendering#postpagetsx-1 에 validate => revalidate 로 변경되어야 할 것 같네요!

답글 달기