[Docs] Next.js Getting Started from 'Fetching Data' to last

백상휘·2025년 4월 23일
0

FE

목록 보기
2/5

오늘은 Getting Started 의 마지막까지 다룹니다. 생각보다 Getting Started 쓸 내용들이 많네요.

Fetching Data

Sever / Client Components 에서 데이터를 fetch 하는 방법을 알아본다. 그리고 데이터에 따라 컨텐츠를 stream 하는 법을 알아보자.

Fetching data - Server Components

방법은 2개다.

  1. fetch API
  2. ORM or database

fetch API 는 async 함수에서 사용해야 한다.

export default async function Page() { // async 주목
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

서버 컴포넌트를 서버에서 만들 때 ORM 혹은 데이터베이스에서 쿼리를 실행해서 가져올 수 있다.

import { db, posts } from '@/lib/db'
 
export default async function Page() {
  const allPosts = await db.select().from(posts)
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Fetching data - Client Components

방법은 2개다.

  1. React 의 use 훅
  2. SWR, React Query(Tanstack Query) 사용

use 훅을 이용해 서버에서 데이터를 가져오는 과정을 stream 해야 한다. Promise 를 서버 컴포넌트에서 prop 으로 전달한다.

// 'use server'
import Posts from '@/app/ui/posts
import { Suspense } from 'react'
 
export default function Page() {
  // Don't await the data fetching function
  const posts = getPosts()
 
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Posts posts={posts} />
    </Suspense>
  )
}

그리고 Client Component 에서 use 훅에 promise 를 이용한다.

'use client'
import { use } from 'react'

type Prop = { posts: Promise<{ id: string; title: string }[]> }

export default function Posts({ posts }: Props) {
  const allPosts = use(posts)
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

여기서 중요한 점은 Posts 컴포넌트가 Suspense 에 싸여져 있어야 한다는 것이다. Suspense 에 의해 fallback 이 렌더링 되었다가 Posts 가 렌더링 된다.

SWR, React-Query(TanStack Query) 을 사용하는 방법은 아래와 같다.

'use client'
import useSWR from 'swr'
 
const fetcher = (url) => fetch(url).then((r) => r.json())
 
export default function BlogPage() {
  const { data, error, isLoading } = useSWR(
    'https://api.vercel.app/blog',
    fetcher
  )
 
  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
 
  return (
    <ul>
      {data.map((post: { id: string; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

이런 라이브러리들은 자신만의 caching, streaming 등의 유용한 기능이 있다.

Streaming

dynamicIO 설정 옵션이 적용되어 있어야 한다(Next.js 15 이상).

Next.js 의 서버 컴포넌트 내 async/await 는 프레임워크 레벨에서 dynamic rendering 에 최적화 되어 있다. 최적화 방법은 서버가 데이터를 fetch 하고 서버에서 컴포넌트를 렌더링하는 것이다. 만약 다수 fetch 중 하나만 느리더라도 전체 route 가 영향을 받을 수 있다.

초기 load 시간과 UX 를 최적화하기 위해서는 streaming 을 이용해서 HTML 을 작은 조각으로 나눌 수 있다.

streaming 을 구현하는 방법은 2개다.

  1. loading.js 파일
  2. Suspense 컴포넌트

loading.tsx 파일은 page.tsx 의 전체 페이지 stream 을 담당한다. app/blog/page.tsx 를 stream 하기 위해서는 app/blog/loading.tsx 를 이용하는 것이다.

export default function Loading() {
  // Define the Loading UI here
  return <div>Loading...</div>
}

처음 라우트가 되면 layout 에 loading 컴포넌트가 보이고 page 는 렌더링 시작한다. page 렌더링이 끝나면 자동으로 스왑된다.

Layout 의 시점에서 볼 때 loading.tsx 는 layout.tsx 내에 page.tsx 를 자동으로 감싼다.

그러므로 자세한 Suspense 는 직접 page.tsx 내에서 사용해야 한다.

import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
import BlogListSkeleton from '@/components/BlogListSkeleton'
 
export default function BlogPage() {
  return (
    <div>
      {/* This content will be sent to the client immediately */}
      <header>
        <h1>Welcome to the Blog</h1>
        <p>Read the latest posts below.</p>
      </header>
      <main>
        {/* Any content wrapped in a <Suspense> boundary will be streamed */}
        <Suspense fallback={<BlogListSkeleton />}>
          <BlogList />
        </Suspense>
      </main>
    </div>
  )
}

React Devtools 를 이용하면 로딩 상태값을 테스트해볼 수 있다.

Updating Data

Server Functions 를 이용하면 데이터를 업데이트할 수 있다.

Creating Server Functions

'use server' 디렉티브를 사용하면 Server Function 을 만들 수 있다. asynchronous 함수 맨 위에 'use server' 를 넣어서 Server Function 을 만들거나 파일 맨 위에 넣으면 된다.

export async function createPost(formData: FormData) {
  'use server'
  const title = formData.get('title')
  const content = formData.get('content')
 
  // Update data
  // Revalidate cache
}
 
export async function deletePost(formData: FormData) {
  'use server'
  const id = formData.get('id')
 
  // Update data
  // Revalidate cache
}

export default function Page() {
  // Server Action
  async function createPost(formData: FormData) {
    'use server'
    // ...
  }
 
  return <></>
}

하지만 Client Component 에서는 Server Function 을 정의할 수 없다. 그러나, Server Function 은 import 할 수 있다.

// app/actions.ts
'use server'
 
export async function createPost() {}

// app/ui/button.tsx
'use client'
 
import { createPost } from '@/app/actions'
 
export function Button() {
  return <button formAction={createPost}>Create</button>
}

Invoking Server Functions

Server Function 을 선언하는 방법은 2가지다.

  1. Client Component 내에 Forms
  2. Client Component 내에 Event Handlers

HTML form 태그를 확장한 Next.js 의 Form 태그에서 action 프롭을 이용할 수 있다. form 의 action 프롭은 Server Function 이며 FormData 객체를 받아 서버 기능을 사용할 수 있다.

// app/ui/form.tsx
import { createPost } from '@/app/actions'
 
export function Form() {
  return (
    <form action={createPost}>
      <input type="text" name="title" />
      <input type="text" name="content" />
      <button type="submit">Create</button>
    </form>
  )
}

// app/actions.ts
'use server'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')
 
  // Update data
  // Revalidate cache
}

Event handlers 는 아래와 같이 onClick 을 사용할 수 있다.

'use client'
 
import { incrementLike } from './actions'
import { useState } from 'react'
type Props = { initialLikes: number };
 
export function LikeButton({ initialLikes }: Props) {
  const [likes, setLikes] = useState(initialLikes)
 
  return (
    <>
      <p>Total Likes: {likes}</p>
      <button
        onClick={async () => { // 여기가 Server Function
          const updatedLikes = await incrementLike()
          setLikes(updatedLikes)
        }}
      >
        Like
      </button>
    </>
  )
}

Examples

Server Function 을 실행할 때 pending 상태를 보여주기 위해 loading indicator 를 사용하는 예시다. React 의 useActionState 를 사용한다.

'use client'
 
import { useActionState } from 'react'
import { createPost } from '@/app/actions' // Server Function
import { LoadingSpinner } from '@/app/ui/loading-spinner'
 
export function Button() {
  const [state, action, pending] = useActionState(createPost, false)
 
  return (
    <button onClick={async () => action()}>
      {pending ? <LoadingSpinner /> : 'Create Post'}
    </button>
  )
}

Update 수행 후 Next.js 캐시를 초기화하고 업데이트 된 데이터를 보여주기 위해 revalidatePath 혹은 revalidateTag 를 Server Function 내에서 사용한다.

import { revalidatePath } from 'next/cache'
 
export async function createPost(formData: FormData) {
  'use server'
  // Update data
  // ...
 
  revalidatePath('/posts')
}

Server Function 에서 update 뒤에 redirect 로 다른 페이지로 간다.

'use server'
 
import { redirect } from 'next/navigation'
 
export async function createPost(formData: FormData) {
  // Update data
  // ...
 
  redirect('/posts')
}

Error Handling

에러는 예상 하였는지 여부에 따라 나뉘어진다.

Handling expected errors

가장 대표적인 예시는 server-side form validation 이나 failed requests 이다. 이런 에러는 핸들링 한 뒤 클라이언트에 리턴하면 된다.

Server Function 에서는 useActionState 훅이 효과적이다. 이 경우에는 try/catch 블록 혹은 throw error 를 사용하지 말고 에러를 값으로 리턴해야 한다.

'use server'
 
export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')
 
  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: { title, content },
  })
  const json = await res.json()
 
  if (!res.ok) { // throw 대신 에러를 반환
    return { message: 'Failed to create post' }
  }
}

서버 컴포넌트 안에서 데이터 fetch 할 땐 response 에 따라 다른 컴포넌트를 렌더링 한다.

export default async function Page() {
  const res = await fetch(`https://...`)
  const data = await res.json()
 
  if (!res.ok) { // response 를 이용
    return 'There was an error.'
  }
 
  return '...'
}

notFound 함수를 이용하면 404 에러를 핸들링할 수 있다.

// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts'
 
export default async function Page({ params }: { params: { slug: string } }) {
  const { slug } = await params
  const post = getPostBySlug(slug)
 
  if (!post) {
    notFound()
  }
 
  return <div>{post.title}</div>
}

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <div>404 - Page Not Found</div>
}

Handling uncaught exceptions

예상하지 못한 이슈는 throw error 로 인한 것이기 때문에 error.tsx 를 사용해야 한다.

'use client' // Error boundaries must be Client Components
 
import { useEffect } from 'react'
type Props = {
  error: Error & { digest?: string }
  reset: () => void
}
 
export default function Error({ error, reset }: Props) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])
 
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  )
}

Error boundary 는 Layout 바로 아래 컴포넌트로 렌더링 한다.

Root Layout 은 루트 앱 디렉토리의 global-error.tsx 파일을 사용한다. global-error.tsx 에는 html, body 태그가 포함되어 있어야 한다.

'use client' // Error boundaries must be Client Components

type Props = {
  error: Error & { digest?: string }
  reset: () => void
}
  
export default function GlobalError({ error, reset }: Props) {
  return (
    // global-error must include html and body tags
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  )
}

Metadata and OG images

Metadata API 는 SEO 향상과 웹 공유성과 다음을 제공한다.

  1. static metadata 객체
  2. dynamic generateMetadata 함수
  3. static / dynamic favicon 과 OG image 들에 대한 파일 컨벤션

위에 옵션에 대해 Next.js 는 자동으로 head 태그에 이를 반영한다.

Default fields

Next.js 에서는 라우팅에 관계없이 아래 2개의 메타 태그가 기본으로 들어간다.

<meta charset="utf-8" /> // character encoding
<meta name="viewport" content="width=device-width, initial-scale=1" /> // viewport width, scale

나머지 요소들은 Metadata 객체 (static metadata) 혹은 generatedMetadata 함수 (generated metadata) 를 사용한다.

Static metadata

Metadata 객체를 layout.tsx 혹은 page.tsx 에서 사용한다.

import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'My Blog',
  description: '...',
}
 
export default function Page() {}

generate metadata documentation 참고

Generated metadata

generatedMetadata 함수로 data 에 의존성이 있는 메타데이터를 fetch 할 수 있다. 아래 예시는 특정 블로그 포스트에 대해 제목과 설명을 fetch 한다.

import type { Metadata, ResolvingMetadata } from 'next'
 
type Props = {
  params: Promise<{ id: string }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
 
export async function generateMetadata(
  { params, searchParams }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const slug = (await params).slug
 
  // fetch post information
  const post = await fetch(`https://api.vercel.app/blog/${slug}`).then((res) =>
    res.json()
  )
 
  return {
    title: post.title,
    description: post.description,
  }
}
 
export default function Page({ params, searchParams }: Props) {}

내부적으로 Next.js 는 메타 데이터를 fetch 하여 UI 와는 비동기적으로 메타 데이터를 html 에 넣는다.

만약 Metadata 가 같은 데이터를 갖고 있는 경우를 대비한다면 React 의 catch 함수를 사용하여 리턴 데이터를 memoize 한다.

// app/lib/data.ts
import { cache } from 'react'
import { db } from '@/app/lib/db'
 
// getPost will be used twice, but execute only once
export const getPost = cache(async (slug: string) => {
  const res = await db.query.posts.findFirst({ where: eq(posts.slug, slug) })
  return res
})

// app/blog/[slug]/page.tsx
import { getPost } from '@/app/lib/data'
 
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  // fetch 를 대체
  const post = await getPost(params.slug)
  return {
    title: post.title,
    description: post.description,
  }
}
 
export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  return <div>{post.title}</div>
}

Favicons

app 디렉토리에 favicon.ico 를 넣는다.

Static Open Graph images

OG image 는 SNS 에 노출되는 이미지이다. app 디렉토리 혹은 특정 라우트 디렉터리에 opengraph-image.jpg 파일을 넣는다.

Generated Open Graph images

dynamic OG image 를 사용하고 싶을 경우 ImageResponse 생성자를 이용한다.

import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/data'
 
// Image metadata
export const size = {
  width: 1200,
  height: 630,
}
 
export const contentType = 'image/png'
 
// Image generation
export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
 
  return new ImageResponse(
    (
      // ImageResponse JSX element
      <div
        style={{
          fontSize: 128,
          background: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        {post.title}
      </div>
    )
  )
}

Deploying

Next.js 앱이 준비되었다? managed infrastructure provider 혹은 self-host 로 웹 애플리케이션을 배포한다.

Managed infrastructure providers

Vercel 에서 했으면 좋겠다고 한다.

Self-Hosting

실제 서버를 구축하는 것을 말한다.

Upgrading

Latest version

npx @next/codemod@canary upgrade latest

이렇게도 활용할 수 있다.

npm i next@latest react@latest react-dom@latest eslint-config-next@latest

2025-04-23 기준 현재 최신 버전은 Next.js 15 canary 이다.

npm i next@canary
profile
plug-compatible programming unit

0개의 댓글