sohyeon kim

[Next] Next.js 공식문서 정리 2 : 데이터 가져오기 Data Fetching, 캐싱, 서버 액션, 뮤테이션, 클로저, 컨벤션, 알아두면 좋은 점 본문

Next

[Next] Next.js 공식문서 정리 2 : 데이터 가져오기 Data Fetching, 캐싱, 서버 액션, 뮤테이션, 클로저, 컨벤션, 알아두면 좋은 점

aotoyae 2025. 2. 7. 16:01
728x90
반응형

 

 

💡 Next.js 공식문서 정리 - 데이터 가져오기 Data Fetching

1. 데이터 가져오기 Data Fetching

데이터 가져오기는 모든 애플리케이션의 핵심 부분이다. 선호하는 방법을 사용해 데이터를 가져오는 모범 사례를 살펴보자.

 

데이터를 서버에서 가져올까, 클라이언트에서 가져올까?

이는 구축 중인 UI 의 유형에 따라 다르다. 실시간 데이터(e.g. 폴링)가 필요하지 않은 경우, 서버 컴포넌트를 사용해 서버에서 데이터를 가져올 수 있다. 이렇게 하면 몇 가지 이점이 있다.

  • 단일 서버 라운드 트립으로 데이터를 가져올 수 있어 네트워크 요청 수와 클라이언트-서버 워터폴을 줄일 수 있다.
  • 클아이언트에 노출되면 안되는 민감한 정보(e.g. 액세스 토큰 및 API 키)를 보할 수 있다.
  • 애플리케이션 코드와 DB 가 동일한 지역에 있는 경우 데이터 소스에 가까운 곳에서 데이터를 가져옴으로써 지연 시간을 줄일 수 있다.
  • 데이터 요청은 캐시되고 재검증될 수 있다.

그러나 서버 측 데이터 가져오기는 전체 페이지가 서버에서 다시 렌더링되게 한다. 작은 UI 조각을 변형/재검증하거나 실시간 데이터를 지속적으로 가져와야 하는 경우(e.g. 실시간 뷰) 클라이언트 측 데이터 가져오기가 더 적합할 수 있으며, 이는 클라이언트에서 특정 UI 조각을 다시 렌더링할 수 있게 해준다.

 

Next.js 에서 데이터를 가져오는 네 가지 방법

  • 서버의 fetch API
  • 서버의 ORM 및 데이터베이스 클라이언트
  • 클라이언트의 데이터 가져오기 라이브러리
  • 클라이언트를 통한 서버의 라우트 핸들러

먼저, Fetch API

Next.js 는 서버에서 각 fetch 요청의 캐싱 및 재검증 동작을 구성할 수 있도록 네이티브 fetch Web API fetch 를 확장한다.

fetch 를 서버 컴포넌트, 라우트 핸들러, 서버 액션에서 사용할 수 있다.

export default async function Page() {
  const data = await fetch('https://api.example.com/...').then((res) =>
    res.json(),
  )
 
  return '...'
}
  • 기본적으로 fetch 요청은 새로운 데이터를 검색한다. 이를 사용하면 전체 경로가 동적으로 렌더링되며 데이터는 캐싱되지 않는다.
  • fetch 요청을 캐시하려면 cache 옵션을 force-cache 로 설정한다. 그럼 데이터가 캐시되며, 해당 구성 요소가 정적으로 렌더링된다.
  • Next.js 14 및 이전 버전에선 fetch 요청이 기본적으로 캐시되었다.
fetch('https://...', { cache: 'force-cache' })
  • PPR을 사용한다면, fetch 요청을 사용하는 구성 요소를 Suspense 경계로 감싸는 것을 권장한다.
  • 이렇게 하면 전체 페이지 대신 fetch 를 사용하는 구성 요소만 동적으로 렌더링되고 스트리밍된다.
import { Suspense } from 'react'
 
 
export default async function Cart() {
  const res = await fetch('https://api.example.com/...')
 
  return '...'
}
 
export default function Navigation() {
  return (
    <>
      <Suspense fallback={<LoadingIcon />}>
        <Cart />
      </Suspense>
    <>
  )
}

 

메모이제이션 요청 Request memoization

  • 트리의 여러 구성 요소에서 동일한 데이터를 가져와야 하는 경우, 데이터를 전역적으로 가져와서 props 를 전달할 필요가 없다.
  • 대신, 데이터를 필요로 하는 구성 요소 내에서 데이터를 가져와도 동일한 데이터에 대한 여러 요청을 수행하는 성능 문제를 걱정할 필요가 없다.
  • 이는 동일한 URL 및 옵션을 사용하는 fetch 요청이 React 렌더링 패스 동안 자동으로 메모이제이션 되기 때문에 가능하다.

서버의 ORM 및 데이터베이스 클라이언트 ORMs & Database Clients
서버 컴포넌트, 라우트 핸들러, 서버엑션에서 ORM 또는 DB 클라이언트를 호출할 수 잇다.

  • React cache 를 사용해 React 렌더링 패스 동안 데이터 요청을 메모이제이션할 수 있다.
  • 예를 들어, getItem 함수가 레이아웃과 페이지에서 호출되더라도 DB 에 대한 쿼리는 한 번만 수행된다.
// app/utils.ts
import { cache } from 'react'
 
export const getItem = cache(async (id: string) => {
  const item = await db.item.findUnique({ id })
  return item
})

// app/item/[id]/layout.tsx
import { getItem } from '@/utils/get-item'
 
export default async function Layout({
  params: { id },
}: {
  params: { id: string }
}) {
  const item = await getItem(id)
  // ...
}

// app/item/[id]/page.tsx
import { getItem } from '@/utils/get-item'
 
export default async function Page({
  params: { id },
}: {
  params: { id: string }
}) {
  const item = await getItem(id)
  // ...
}

 

데이터 가져오기 라이브러리 Data Fetching Libraries

SWR 또는 React Query 와 같은 데이터 가져오기 라이브러리를 사용해 클라이언트 구성 요소에서 데이터를 가져올 수 있다.
이러한 라이브러리는 데이터의 캐싱, 재검증 및 변형을 위한 자체 API 를 제공한다.

  • 예를 들어, SWR 을 사용해 클라이언트에서 주기적으로 데이터를 가져오는 방법은 아래와 같다.
"use client"
 
import useSWR from 'swr'
import fetcher from '@/utils/fetcher'
 
export default function PollingComponent {
  // 폴링 간격을 2000 밀리초로 설정
  const { data } = useSWR('/api/data', fetcher, { refreshInterval: 2000 });
 
  return '...'
}

 

라우트 핸들러 Route Handlers

API 엔드포인트를 만들어야 하는 경우, Next.js 는 라우트 핸들러를 지원한다.

라우트 핸들러는 서버에서 실행되며 민감한 정보(e.g. API 자격 증명)가 클라이언트에 노출되지 않도록 한다.

  • 라우트 핸들러를 호출하기 위해 SWR 을 사용하는 방법은 아래와 같다.
  • 서버 컴포넌트는 서버에서 렌더링되므로, 서버 컴포넌트에서 라우트 핸들러를 호출할 필요가 없다. 데이터를 직접 액세스할 수 있다.
// app/ui/message.tsx
'use client'
 
import useSWR from 'swr'
import fetcher from '@/utils/fetcher'
 
export default function Message() {
  const { data } = useSWR('/api/messages', fetcher)
 
  return '...'
}

// app/api/message/routs.ts
export async function GET() {
  const data = await fetch('https://...', {
    headers: {
      'Content-Type': 'application/json',
      'API-Key': process.env.DATA_API_KEY,
    },
  }).then((res) => res.json())
 
  return Response.json({ data })
}

 

패턴 Patterns

구성 요소 내에서 데이터를 가져올 땐 두 가지 데이터 가져오기 패턴을 인지해야 한다.

  • 순차 : 구성 요소 트리의 요청이 서로 종속적이다. 이는 더 긴 로딩 시간을 초래할 수 있다.
  • 병렬 : 경로의 요청이 미리 시작되어 동시에 데이터를 로드한다. 이렇게 하면 데이터를 로드하는 데 걸리는 총 시간이 줄어든다.

 

순차적 데이터 가져오기 Sequential data fetching

  • 중첩된 구성 요소가 있고 각 구성 요소가 자체 데이터를 가져오는 경우,
    이러한 데이터 요청이 메모이제이션되지 않은 경우 데이터 가져오기는 순차적으로 발생한다.
  • 하나의 fetch 가 다른 fetch 의 결과에 의존하는 경우 이러한 패턴이 필요할 수 있다.
  • 예시로, playLists 구성 요소는 artistID prop 에 의존하기 때문에 Artist 구성 요소가 데이터를 가져온 후에만 데이터를 가져오기 시작한다.
export default async function Page({
  params: { username },
}: {
  params: { username: string }
}) {
  // 아티스트 정보를 가져옵니다.
  const artist = await getArtist(username)
 
  return (
    <>
      <h1>{artist.name}</h1>
      {/* Playlists 구성 요소가 로딩되는 동안 대체 UI를 표시합니다 */}
      <Suspense fallback={<div>Loading...</div>}>
        {/* artist ID를 Playlists 구성 요소에 전달합니다 */}
        <Playlists artistID={artist.id} />
      </Suspense>
    </>
  )
}
 
async function Playlists({ artistID }: { artistID: string }) {
  // 아티스트 ID를 사용하여 플레이리스트를 가져옵니다.
  const playlists = await getArtistPlaylists(artistID)
 
  return (
    <ul>
      {playlists.map((playlist) => (
        <li key={playlist.id}>{playlist.name}</li>
      ))}
    </ul>
  )
}
  • 경로 세그먼트에 대해 loading.js 또는 중첩된 구성 요소에 대해 React <Suspense> 를 사용해
    즉시 로딩 상태를 표시하면서 React 가 결과를 스트리밍하도록 할 수 있다.
  • 이렇게 하면 전체 경로가 데이터 요청에 의해 차단되지 않으며, 사용자는 준비된 페이지 부분과 상호작용할 수 있다.

병렬 데이터 가져오기 Parallel Data Fetching

  • 기본적으로 레이아웃 및 페이지 세그먼트는 병렬로 렌더링된다. 이는 요청이 병렬로 시작된다는 것을 의미.
  • 그러나 async/await 의 특성상 동일한 세그먼트 또는 구성 요소 내에서 기다리는 요청은 아래의 모든 요청을 차단한다.
  • 데이터를 병렬로 가져오려면 데이터를 사용하는 구성 요소 외부에서 요청을 미리 정의해 요청을 미리 시작할 수 있다.
  • 이렇게 하면 두 요청을 병렬로 시작해 시간을 절약할 수 있지만, 두 프로미스가 모두 해결될 때까지 렌더링된 결과를 볼 수 없다.
  • 또한 Suspense 경계를 추가해 렌더링 작업을 나누고 가능한 빨리 결과의 일부를 표시할 수 있다.

아래 예제에선 getArtist 및 getAlbums 함수가 Page 구성 요소 외부에서 정의되고 Promise.all 을 사용해 구성 요서 내부에서 시작된다.

import Albums from './albums'
 
async function getArtist(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}`)
  return res.json()
}
 
async function getAlbums(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`)
  return res.json()
}
 
export default async function Page({
  params: { username },
}: {
  params: { username: string }
}) {
  const artistData = getArtist(username)
  const albumsData = getAlbums(username)
 
  // 두 요청을 병렬로 시작합니다.
  const [artist, albums] = await Promise.all([artistData, albumsData])
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  )
}

 

데이터 미리 로딩하기 Preloading Data

  • 워터폴을 방지하는 또 다른 방법은 블로킹 요청 위에 있는 유틸리티 함수를 만들어 미리 호출하는 preload 패턴을 사용하는 것이다.
  • 예시로, checkIsAvailable() 가 <Item/> 의 렌더링을 차단하므로, preload() 를 호출해 <Item/> 데이터 종속성을 미리 시작할 수 있다. <Item/> 이 렌더링될 대 이미 데이터가 가져와진다.
  • preload 함수는 checkIsAvailable() 의 실행을 차단하지 않는다.
  • preload 함수는 패턴이지 API 가 아니므로, 다른 이름을 가질 수 있다.
// componnennts/Item.tsx
import { getItem } from '@/utils/get-item'
 
export const preload = (id: string) => {
  // void는 주어진 표현식을 평가하고 undefined를 반환합니다.
  // https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void
  void getItem(id)
}
export default async function Item({ id }: { id: string }) {
  const result = await getItem(id)
  // ...
}

// app/item/[id]/page.tsx
import Item, { preload, checkIsAvailable } from '@/components/Item'
 
export default async function Page({
  params: { id },
}: {
  params: { id: string }
}) {
  // 아이템 데이터 로딩 시작
  preload(id)
  // 다른 비동기 작업 수행
  const isAvailable = await checkIsAvailable()
 
  return isAvailable ? <Item id={id} /> : null
}

 

React Cache 및 server-only 를 Preload 패턴과 함께 사용하기

  • cache 함수, preload 패턴 및 server-only 패키지를 결합해
    애플리케이션 전체에서 사용할 수 있는 데이터 가져오기 유틸리티를 만들 수 있다.
  • 이 접근 방식을 사용하면 데이터를 미리 가져오고 응답을 캐시하며, 이 데이터 가져오기가 서버에서만 발생한다는 보장을 할 수 있다.
  • utils/get-item 내보내기는 레이아웃, 페이지 도는 다른 구성 요소에 의해 사용되어 아이템 데이터를 언제 가져올지에 대한 제어를 제공한다.
  • 서버 데이터 가져오기 함수가 클라이언트에 절대 사용되지 않도록 하기 위해 server-only 패키지를 사용하는 것이 좋다.
import { cache } from 'react'
import 'server-only'
 
export const preload = (id: string) => {
  void getItem(id)
}
 
export const getItem = cache(async (id: string) => {
  // ...
})

 

클라이언트에 민감한 데이터가 노출되지 않도록 하기

  • React 의 taint API, taintObjectReference 및 taintUniqueValue 를 사용해 전체 객체 인스턴스 또는 민감한 값을 클라이언트에 전달하지 않도록 하는 것을 권장한다.
  • 애플리케이션에서 tainting 을 활성화하려면 Next.js Config experimental.taint 옵션을 true 로 설정한다.
module.exports = {
  experimental: {
    taint: true,
  },
}
  • 그런 다음, experimental-taintObjectReference 또는 Experimental_taintUniqueValue 함수에 taint 하려는 객체 또는 값을 전달한다.
// app/utils.ts
import { queryDataFromDB } from './api'
import {
  experimental_taintObjectReference,
  experimental_taintUniqueValue,
} from 'react'
 
export async function getUserData() {
  const data = await queryDataFromDB()
  experimental_taintObjectReference(
    '전체 사용자 객체를 클라이언트에 전달하지 마십시오',
    data,
  )
  experimental_taintUniqueValue(
    '사용자의 주소를 클라이언트에 전달하지 마십시오',
    data,
    data.address,
  )
  return data
}

// app/page.tsx
import { getUserData } from './data'
 
export async function Page() {
  const userData = getUserData()
  return (
    <ClientComponent
      user={userData} // taintObjectReference로 인해 오류가 발생합니다
      address={userData.address} // taintUniqueValue로 인해 오류가 발생합니다
    />
  )
}
  )
  return data
}

 

2. 캐싱하기 Caching

캐싱은 서버에 요청하는 횟수를 줄이기 위해 데이터를 저장하는 과정이다.

Next.js 는 개별 데이터 요청을 위한 Data Cache 를 내장하고 있어, 캐싱 동작을 세밀하게 제어할 수 있다.

 

fetch 요청 requests

  • 기본적으로, fetch 요청은 런타임에 새로운 데이터를 가져온다.
  • 개별 fetch 요청을 캐시하려면, cache: 'force-cache' 옵션을 사용할 수 있다.
  • 개별 fetch 요청에 대한 캐싱을 사용하지 않으려면, cache: 'no-store' 옵션을 사용할 수 있다.

➕ 레이아웃이나 페이지 세그먼트에 여러 개의 fetch 요청이 있는 경우, const dynamic = 'force-dynamic' 또는 const fetchCache = 'force-no-store' Segment Config Options 를 사용해 세그먼트 내의 모든 데이터 요청에 대한 캐싱 동작을 구성할 수 있다.

// 'force-cache'는 기본값이며, 생략할 수 있습니다
fetch('https://...', { cache: 'force-cache' })
fetch('https://...', { cache: 'no-store' })

 

데이터 가져오기 라이브러리 및 ORM Data fetching librarioes & ORMS

  • 데이터 요청이 캐시될지 여부는 데이터 패칭 라이브러리, 데이터베이스 클라이언트, ORM 의 기본 동작에 따라 달라진다.
  • 특정 요청을 캐시하려면, unstable_cache API 를, 캐시에서 제외하려면 unstable_noStore API 를 사용할 수 있다.
import { unstable_cache as cache } from 'next/cache'
 
export async function getPosts() {
  cache()
 
  try {
    // 데이터 페치
  } catch (error) {}
}

import { unstable_noStore as noStore } from 'next/cache'
 
export async function getTransactions() {
  // 응답을 캐시하지 않도록 설정합니다.
  // 이는 fetch(..., {cache: 'no-store'})와 동일합니다.
  noStore()
 
  try {
    // 데이터 페치
  } catch (error) {}
}

 

데이터 재검증 Revalidating data

Revalidation 은 데이터 캐시를 비우고 최신 데이터를 다시 가져오는 과정이다.

이는 데이터가 변경될 때 최신 정보를 표시하면서도 정적 렌더링의 속도 이점을 유지하는 데 유용하다.

 

캐시된 데이터는 두 가지 방법으로 revalidate 할 수 있다.

  • 시간 기반(Time-based) revalidation : 일정 시간이 지난 후 자동으로 데이터를 revalidate 한다. 이는 데이터가 자주 변경되지 않으며, 신선도가 그렇게 중요하지 않은 경우에 유용하다.
  • 온디맨드(On-demand 주문형) revalidation : 이벤트(e.g. 폼 제출)를 기반으로 수동으로 데이터를 revalidate 한다. 온디맨드 revalidation 은 태그 기반 또는 경로 기반 접근 방식을 사용하여 데이터 그룹을 한 번에 revalidate 할 수 있다. 이는 헤드리스 CMS 의 콘텐츠가 업데이트될 때 가능한 한 빨리 최신 데이터를 표시하려는 경우에 유용하다.

 

시간 기반 Time-based revalidation 🔗

  • 정해진 시간 간격으로 데이터를 revalidate 하려면, fetch 의 next.revalidate 옵션을 사용해 리소스의 캐시 수명을 설정할 수 있다.
fetch('https://...', { next: { revalidate: 3600 } }) // 초단위이며, 최대 1시간마다 revalidate
  • 또는, 라우트 세그먼트의 모든 요청을 revalidate 하기 위해 Segment Config Options 를 사용할 수 있다.
export const revalidate = 3600 // 최대 1시간마다 revalidate
  • 정적으로 렌더링된 라우트에서 여러 fetch 요청이 있고 각 요청의 revalidation 빈도가 다른 경우, 가장 낮은 시간이 모든 요청에 사용된다.
  • 동적으로 렌더링된 라우트에서는 각 fetch 요청이 독립적으로 revalidate 된다.
  • 서버 리소스를 절약하기 위해, 가능한 높은 revalidation 시간을 설정하는 것이 좋다. 예를 들어, 1초 대신 1시간. 실시간 데이터가 필요한 경우, dynamic rendering 또는 클라이언트 측 데이터 페칭으로 전환하는 것을 고려하라.

온디맨드(주문형) On-demand revalidation 🔗

  • 데이터는 RevalidatePath 및 revalidateTag API 를 사용해 온디맨드로 revalidate 할 수 있다.
  • Server Actions 또는 라우트 핸들러에서 revalidatePath 를 사용해 특정 라우트의 데이터를 revalidate 하자.
import { revalidatePath } from 'next/cache'
 
export default async createPost() {
  try {
    // 데이터 수정
    revalidatePath('/posts')
  } catch(error) {}
}
  • revalidateTag 를 사용해 여러 라우트에 걸쳐 있는 fetch 요청을 revalidate 할 수 있다.
    1. fetch 를 사용할 때, 하나 이상의 태그로 캐시 항복을 태그할 수 있는 옵션이 있다.
    2. 그런 다음, revalidateTag 를 호출해 해당 태그와 연관된 모든 항목을 revalidate 할 수 있다.
  • 예를 들어, 다음 fetch 요청은 collection 캐시 태그를 추가한다.
export default async function Page() {
  const res = await fetch('https://...', { next: { tags: ['collection'] } })
  const data = await res.json()
  // ...
}
  • 그런 다음, revalidateTag 를 호출해 collection 으로 태그된 이 fetch 요청을 revalidate 할 수 있다.
'use server'
 
import { revalidateTag } from 'next/cache'
 
export default async function action() {
  revalidateTag('collection')
}

 

재검증 에러 핸들링 Error handling & revalidation

데이터를 revalidate 하려는 시도 중 오류가 발생한다면, 마지막으로 성공적으로 생성된 데이터가 캐시에서 계속 제공된다.

다음 후속 요청에서 Next.js 는 데이터를 다시 revalidate 하려고 시도한다.

 

3. 서버 액션 및 뮤테이션 Server Actions & Mutations

Sever Actinos 는 Next.js 애플리케이션에서 폼 제출 및 데이터 변조를 처리하기 위해 서버에서 실행되는 비동기 함수이다.

서버 및 클라이언트 컴포넌트에서 호출될 수 있다.

 

컨벤션 Convention

Server Action 은 React "use server" 지시어로 정의할 수 있다. async 함수 상단에 지시어를 추가해 해당 함수를 Server Action 으로 표시하거나, 별도의 파일 상단에 지시어를 추가해 해당 파일의 모든 내보내기를 Server Action 으로 표시할 수 있다.

 

서버 컴포넌트 Server Components

  • 서버 컴포넌트는 인라인 함수 레벨 또는 모듈 레벨의 "use server" 지시어를 사용할 수 있다.
  • 서버 액션을 인라인으로 추가하려면, 함수 본문 상단에 "use server"를 추가하라.
export default function Page() {
  // Server Action
  async function create() {
    'use server'
    // 데이터 변조
  }
 
  return '...'
}

 

클라이언트 컴포넌트 Client Components

  • 클라이언트 컴포넌트에서 Server Action 을 호출하려면 새 파일을 생성하고 파일 상단에 "use server" 지시어를 추가하라.
  • 파일 내 모든 함수는 서버 및 클라이언트 컴포넌트에서 재사용할 수 있는 Server Actions 로 표시된다.
// app/actions.ts
'use server'
 
export async function create() {}

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

 

액션을 props 로 전달하기 Passing actions as props

  • Server Action 을 클라이언트 컴포넌트에 prop 으로 전달할 수도 있다.
<ClientComponent updateItemAction={updateItem} />
'use client'
 
export default function ClientComponent({
  updateItemAction,
}: {
  updateItemAction: (formData: FormData) => void
}) {
  return <form action={updateItemAction}>{/* ... */}</form>
}
  • 보통 Next.js TypeScript 플러그인은 client-component.tsx 에서 updateItemAction 이 직렬화할 수 없는 함수이기 때문에 이를 플래그로 표시한다.
  • 하지만 action 또는 Action 으로 끝나는 props 는 Server Actions 를 받는 것으로 간주된다.
  • 이것은 TypeScript 플러그인이 실제로 Server Action 인지 일반 함수인지 알지 못하기 때문에 사용하는 추측이다.
  • 런타임 타입 체크는 실수로 클라이언트 컴포넌트에 함수를 전달하지 않도록 보장한다.

 

동작 Behavior

  • Server Action 은 <form> 요소에서 action 속성을 사용해 호출할 수 있다.
    • 서버 컴포넌트는 기본적으로 점진적 향상을 지원하므로 JavaScript가 아직 로드되지 않았거나 비활성화된 경우에도 폼이 제출된다.
    • 클라이언트 컴포넌트에선 Server Action 을 호출하는 폼이 JavaScript 가 아직 로드되지 않은 경우 제출을 대기하여 클라이언트 하이드레이션을 우선시한다.
    • 하이드레이션 후, 폼 제출 시 브라우저가 새로 고침되지 않는다.
    • ** 하이드레이션 : HTML 에 클라이언트 측에서 JavaScript 를 통해 동적으로 활성화하는 것.
  • Server Action 은 <form> 에만 국한되지 않으며, 이벤트 핸들러, useEffect, 서드파티 라이브러리 및 <button> 과 같은 다른 폼 요소에서도 호출이 가능하다.
  • Server Action 은 Next.js 캐싱 및 재검증 아키텍처와 통합된다. 액션이 호출되면 Next.js 는 단일 서버 라운드트립에서 업데이트된 UI 와 새로운 데이터를 반환할 수 있다.
  • 백그라운드에서는 액션이 POST 메서드를 사용하며, 이 HTTP 메서드만 액션을 호출할 수 있다.
  • Server Action 의 인수와 반환 값은 React 에 의해 직렬화할 수 있어야 한다. 직렬화 가능한 인수 및 값을 보려면 🔗
  • Server Action 은 함수이다. 이는 애플리케이션 어디에서나 재사용할 수 있음을 의미.
  • Server Action 은 사용되는 페이지나 레이아웃에서 런타임을 상속받는다.
  • Server Action 은 사용되는 페이지나 레이아웃에서 Route Segment Config 를 상속받아 maxDuration 과 같은 필드를 포함한다.

 

사용 예시 Examples

Forms 🔗

  • React 는 HTML <form> 요소를 확장해 action prop 으로 Server Actions 를 호출할 수 있다.
  • 폼에서 호출될 때, 액션은 자동으로 FormData 객체를 받으며, 필드를 관리하기 위해 React useState 를 사용할 필요 없이, 기본 FormData 메서드를 사용해 데이터를 추출할 수 있다.
export default function Page() {
  async function createInvoice(formData: FormData) {
    'use server'
 
    const rawFormData = {
      customerId: formData.get('customerId'),
      amount: formData.get('amount'),
      status: formData.get('status'),
    }
 
    // 데이터 변조
    // 캐시 재검증
  }
 
  return <form action={createInvoice}>...</form>
}

 

추가 인수 전달 Passing additional arguments

  • JavaScript bind 메서드를 사용해 Server Action 에 추가 인수를 전달할 수 있다.
'use client'
 
import { updateUser } from './actions'
 
export function UserProfile({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId)
 
  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  )
}
  • Server Action 은 폼 데이터 외에 userId 인수를 받는다.
'use server'
 
export async function updateUser(userId, formData) {}
  • 대안으로 폼에 숨겨진 입력 필드로 인수를 전달할 수도 있다. <input type="hidden" name="userId" value={userId} />
    하지만 이 값은 렌더된 HTML 의 일부가 되어 인코딩되지 않는다.
  • .bind 는 서버 및 클라이언트 컴포넌트 모두에서 작동한다. 또한 점진적 향상도 지원한다.

중첩된 폼 요소 Nested form elements

  • <form> 내부에 있는 <button>, <input type="submit">, <input type="image"> 와 같은 요소에 Server Action 을 호출할 수도 있다. 이러한 요소들은 formAction prop 이나 이벤트 핸들러를 받을 수 있다.
  • 이 방법은 폼 내에서 여러 Server Action 을 호출하려는 경우 유용하다. 예시로, 게시물을 저장하는 버튼을 추가로 생성해 게시할 수 있다.

프로그램적 폼 제출 Programmatic form submission

  • requestSubmit() 메서드를 사용해 프로그램적으로 폼 제출을 트리거할 수 있다. 예시로, 사용자가 command + Enter 키보드 단축키를 사용해 폼을 제출할 때 onKeyDown 이벤트를 감지할 수 있다.
  • 이는 Server Action 을 호출하는 가장 가까운 <form> 조상의 제출을 트리거한다.
'use client'
 
export function Entry() {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === 'Enter' || e.key === 'NumpadEnter')
    ) {
      e.preventDefault()
      e.currentTarget.form?.requestSubmit()
    }
  }
 
  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  )
}

 

서버사이드(서버측) 폼 유효성 검사 Server-side form validation

  • 기본 클라이언트 사이드 폼 검증을 위해 required 및 type="email" 과 같은 HTML 검증을 사용하는 것이 좋다.
  • 더 고급 서버 사이드 검증을 위해 zod 와 같은 라이브러리를 사용해 데이터 변조 전 폼 필드를 검증할 수 있다.
'use server'
 
import { z } from 'zod'
 
const schema = z.object({
  email: z.string({
    invalid_type_error: 'Invalid Email',
  }),
})
 
export default async function createUser(formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })
 
  // 폼 데이터가 유효하지 않은 경우 조기 반환
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }
 
  // 데이터 변조
}
  • 서버에서 필드가 검증되면, 액션에서 직렬화 가능한 객체를 반환하고 React useActionState 훅을 사용해 사용자에게 메세지를 표시할 수 있다.
    • 액션을 useActionState 에 전달하면 액션의 함수 시그니처가 새로운 prevState 또는 initialState 매개변수를 첫 번째 인수로 받도록 변경된다.
    • useActionState 는 React 훅이므로 Client Component 에서만 사용해야 한다.
'use server'
 
import { redirect } from 'next/navigation'
 
export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch('https://...')
  const json = await res.json()
 
  if (!res.ok) {
    return { message: 'Please enter a valid email' }
  }
 
  redirect('/dashboard')
}
  • 그런 다음, useActionState 훅에 액션을 전달하고 반환된 state 를 사용해 오류 메시지를 표시할 수 있다.
'use client'
 
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
 
const initialState = {
  message: '',
}
 
export function Signup() {
  const [state, formAction] = useActionState(createUser, initialState)
 
  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button>Sign up</button>
    </form>
  )
}
  • 데이터 변조 전 사용자가 해당 작업을 수행할 권한이 있는지 항상 확인해야 한다. 🔗

보류 상태 Pending states

  • useActionState 훅은 액션이 실행되는 동안 로딩 인디케이터를 표시하는 데 사용할 수 있는 pending 상태를 노출한다.
  • 특정 폼에 대한 대기 상태를 표시하려면 useFormStatus 훅을 사용할 수도 있다.
'use client'
 
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
 
const initialState = {
  message: '',
}
 
export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)
 
  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite" className="sr-only">
        {state?.message}
      </p>
      <button aria-disabled={pending} type="submit">
        {pending ? 'Submitting...' : 'Sign up'}
      </button>
    </form>
  )
}

 

낙관적 업데이트 Optimistic updates

  • 서버에 보낸 요청이 정상적일 것이라 예상하고, 응답이 오기 전 미리 UI 를 변경시키는 것.
  • React useOptimistic 훅을 사용해 Server Action 이 실행되기 전에 UI 를 낙관적으로 업데이트할 수 있다.
'use client'
 
import { useOptimistic } from 'react'
import { send } from './actions'
 
type Message = {
  message: string
}
 
export function Thread({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<
    Message[],
    string
  >(messages, (state, newMessage) => [...state, { message: newMessage }])
 
  const formAction = async (formData) => {
    const message = formData.get('message') as string
    addOptimisticMessage(message)
    await send(message)
  }
 
  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <div key={i}>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

 

이벤트 핸들러 Event Handlers

  • Server Actions 를 <form> 요소 내에서 사용하는 것이 일반적이지만, onClick 과 같은 이벤트 핸들러로도 호출할 수 있다.
    좋아요 수를 증가시키는 예를 살펴보자.
'use client'
 
import { incrementLike } from './actions'
import { useState } from 'react'
 
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes)
 
  return (
    <>
      <p>Total Likes: {likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike()
          setLikes(updatedLikes)
        }}
      >
        Like
      </button>
    </>
  )
}
  • 또는 이벤트 핸들러를 폼 요소에 추가할 수도 있다. 예시로, onChange 이벤트로 폼 필드를 저장하려면 아래와 같이 한다.👇
  • 이와 같이 여러 이벤트가 빠르게 연속으로 발생할 수 있는 경우, 불필요한 Server Action 호출 방지를 위해 디바운싱을 권장한다.
'use client'
 
import { publishPost, saveDraft } from './actions'
 
export default function EditPost() {
  return (
    <form action={publishPost}>
      <textarea
        name="content"
        onChange={async (e) => {
          await saveDraft(e.target.value)
        }}
      />
      <button type="submit">Publish</button>
    </form>
  )
}

 

useEffect

  • React useEffect 훅을 사용해 컴포넌트가 마운트될 때 또는 종속성이 변경될 때 Server Action 을 호출할 수 있다.
  • 이는 전역 이벤트에 의존하거나 자동으로 트리거되어야 하는 변조에 유용하다.
  • 예시로, 앱 단축키를 위한 onKeyDown, 무한 스크롤을 위한 인터섹션 옵저버 훅, 또는 컴포넌트가 마운트될 때 조회수를 업데이트하는 경우가 있다.
'use client'
 
import { incrementViews } from './actions'
import { useState, useEffect } from 'react'
 
export default function ViewCount({ initialViews }: { initialViews: number }) {
  const [views, setViews] = useState(initialViews)
 
  useEffect(() => {
    const updateViews = async () => {
      const updatedViews = await incrementViews()
      setViews(updatedViews)
    }
 
    updateViews()
  }, [])
 
  return <p>Total Views: {views}</p>
}

 

에러 핸들링 Error Handling

  • 오류가 발생하면 클라이언트의 가장 가까운 error.js 나 <Suspense> 경계의 의해 잡힌다.
  • UI 에서 오류를 처리하려면 try/catch 를 사용하는 것이 좋다.
  • 예시로, 새로운 항목을 생성하는 Server Action 이 오류를 처리해 메세지를 반환하는 경우👇
'use server'
 
export async function createTodo(prevState: any, formData: FormData) {
  try {
    // 데이터 변조
  } catch (e) {
    throw new Error('Failed to create task')
  }
}
  • 오류를 던지는 것 외에도 useActionState 에서 처리할 수 있도록 객체를 반환할 수 있다. (서버측 검증 및 오류 처리 🔗)

데이터 재검증 Revalidating data

  • revalidatePath API 를 사용해 Server Action 내에서 Next.js 캐시를 재검증할 수 있다.
'use server'
 
import { revalidatePath } from 'next/cache'
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidatePath('/posts')
}
  • 또는 revalidateTag 를 사용해 특정 데이터 fetch 를 캐시 태그와 함께 무효화한다.
'use server'
 
import { revalidateTag } from 'next/cache'
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag('posts')
}

 

리다이렉팅 Redirecting

  • Server Action 완료 후 사용자를 다른 경로로 리다이렉션 하려면 redirect API 를 사용할 수 있다.
  • redirect 는 try/catch 문 외부에서 호출해야 한다.
'use server'
 
import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
 
export async function createPost(id: string) {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag('posts') // 캐시된 게시물 업데이트
  redirect(`/post/${id}`) // 새로운 게시물 페이지로 이동
}

 

쿠키 Cookies

  • cookies API 를 사용해 Server Actino 내에서 쿠키를 get, set, delete 할 수 있다. 🔗
'use server'
 
import { cookies } from 'next/headers'
 
export async function exampleAction() {
  // 쿠키 가져오기
  const value = cookies().get('name')?.value
 
  // 쿠키 설정
  cookies().set('name', 'Delba')
 
  // 쿠키 삭제
  cookies().delete('name')
}

 

보안 Security

인증 및 권한 부여 Authentication & authorization

  • Server Acitnos 를 공개 API 엔드포인트처럼 취급하고 사용자가 해당 작업을 수행할 권한이 있는지 확인해야 한다.
'use server'
 
import { auth } from './lib'
 
export function addItem() {
  const { user } = auth()
  if (!user) {
    throw new Error('You must be signed in to perform this action')
  }
 
  // ...
}

 

클로저 및 암호화 Closures and encryption

  • 컴포넌트 내에 Serve Action 을 정의하면 클로저가 생성되어 액션이 외부 함수의 스코프에 접근할 수 있다.
  • 예시로, publish 액션은 publishVersion 변수에 접근할 수 있다.
export default async function Page() {
  const publishVersion = await getLatestVersion();
 
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('The version has changed since pressing publish');
    }
    ...
  }
 
  return (
    <form>
      <button formAction={publish}>Publish</button>
    </form>
  );
}
  • 클로저는 렌더링 시점에 데이터(publishVersion)의 스냅샷을 캡쳐해 액션이 호출될 때 나중에 사용할 수 있도록 하는 데 유용하다.
  • 그러나 이를 위해 캡쳐된 변수는 클라이언트로 전송되었다가 액션이 호출될 때 서버로 다시 전송되며, 민감한 데이터가 클라이언트에 노출되지 않도록 Next.js 는 클로저 변수를 자동으로 암호화한다. 새로운 개인 키는 Next.js 애플리케이션이 빌드될 때마다 각 액션에 대해 생성된다. 이는 특정 빌드에 대해서만 액션을 호출할 수 있음을 의미한다.
  • 민감한 값이 클라이언트에 노출되지 않도록 암호화에만 의존하는 것은 권장하지 않는다. 대신 React taint API 를 사용해 특정 데이터가 클라이언트로 전송되지 않도록 사전에 방지하는 것이 좋다. 🔗

암호화 키 덮어쓰기 Overwriting encryption keys (advanced)

  • 여러 서버에 걸쳐 Next.js 애플리케이션을 자체 호스팅할 때 각 서버 인스턴스는 서로 다른 암호화 키를 가지게 되어 잠재적 불일치가 발생할 수 있다.
  • 이를 완화하기 위해 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 환경 변수를 사용해 암호화 키를 덮어쓸 수 있다. 이 변수를 지정하면 암호화 키가 빌드 간에 지속되며 모든 서버 인스턴스가 동일한 키를 사용한다.
  • 이는 여러 배포 간에 일관된 암호화 동작이 중요한 고급 사용 사례이다. 키 회전 및 서명과 같은 표준 보안 관행을 고려해야 한다.
  • Vercel 에 배포된 Next.js 애플리케이션은 이를 자동으로 처리한다.

허용된 출처 Allowed origins (advanced)

  • Server Actions 는 <form> 요소 내에서 호출될 수 있기 떄문에 CSRF 공격에 노출될 수 있다.
  • 백그라운드에서 Server Actions 는 POST 메서드를 사용하며, 이 HTTP 메서드만 액션을 호출할 수 있다. 이는 SameSite 쿠키가 기본값인 현대 브라우저에서 대부분의 CSRF 취약점을 방지한다.
  • 추가적인 보호를 위해, Next.js 의 Server Actions 는 Origin 헤더Host 헤더(or X-Forwarded-Host)와 비교한다. 이들이 일치하지 않으면 요청이 중단된다. 즉 Server ACtions 는 이를 호스팅하는 페이지와 동일한 호스트에서만 호출될 수 있다.
  • 리버스 프록시 또는 다계층 백엔드 아키텍처를 사용하는 대규모 애플리케이션의 경우, 서버 API 가 프로덕션 도메인과 다를 때 serverActions.allowedOrigins 구성 옵션을 사용하여 안전한 출처 목록을 지정하는 것이 좋다. 이 옵션은 문자열 배열을 허용한다.
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
    },
  },
}

 

 

 

🔗 https://nextjs-ko.org/docs/app/building-your-application/data-fetching

 

Data Fetching – Nextjs 한글 문서

Learn how to fetch, cache, revalidate, and mutate data with Next.js.

nextjs-ko.org

 

 

 

728x90
반응형