본문으로 건너뛰기

서버 컴포넌트 데이터 조회와 Server Function

서버에서 데이터를 다루는 일은 세 단계로 나뉩니다. 데이터를 읽고, 준비될 때까지 로딩을 보여주고, 필요하면 바꿉니다. 이 순서대로 봅니다. 코드는 Next.js 16 App Router 기준입니다.

Server Component와 Server Function은 다릅니다

본론에 들어가기 전에 이름이 비슷한 두 가지를 갈라 둡니다. 하는 일이 반대입니다.

Server Component는 서버에서 실행되어 데이터를 읽고 화면을 만들고, Server Function은 브라우저가 호출해서 서버에서 데이터를 바꾸는 것을 나란히 비교한 그림

Server ComponentServer Function
정의서버에서 실행되는 컴포넌트서버에서 실행되는 async 함수
역할데이터를 읽어 화면을 만듭니다데이터를 바꿉니다
실행 방식렌더링될 때 서버가 알아서 실행합니다브라우저가 네트워크로 호출합니다
표시기본값이라 지시어가 없습니다'use server'

공식 문서의 정의는 이렇습니다.

A Server Function is an asynchronous function that runs on the server. You can call them from the client through a network request, which is why they must be asynchronous.

브라우저가 네트워크로 부르기 때문에 반드시 async여야 한다는 점이 성격을 잘 말해 줍니다.

Server Function과 Server Action

같은 것을 가리킵니다. Server Function 중에서 폼 제출이나 데이터 변경에 쓰는 경우를 Server Action이라고 부릅니다. Server Function이 더 넓은 말이라서 이 글에서는 Server Function으로 통일합니다.

데이터 읽기

서버 컴포넌트는 async 함수로 선언하고 그 안에서 await을 씁니다. 데이터를 받아오려고 훅이나 상태를 따로 만들 필요가 없습니다.

fetch로 가져오기

export default async function ProductListPage() {
const response = await fetch('https://example.com/api/products');
const products: Product[] = await response.json();

return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}

메모이제이션과 데이터 캐시는 유효 범위가 다릅니다

fetch에는 결과를 재사용하는 장치가 두 개 붙어 있습니다. 둘 다 캐싱이라는 점은 같고 얼마나 오래 유효한지가 다릅니다.

메모이제이션데이터 캐시
유효 범위한 번의 렌더링 안요청과 요청 사이
막아 주는 것같은 요청을 여러 번 보내는 것매번 원본까지 다시 가는 것
16의 기본값켜져 있습니다꺼져 있습니다

메모이제이션은 한 화면을 그리는 동안만 유효합니다. 같은 URL과 옵션으로 부른 fetch는 자동으로 한 번만 나갑니다. 헤더와 사이드바가 각각 로그인 사용자를 불러도 실제 요청은 한 번이라서, 데이터가 필요한 컴포넌트마다 각자 부르면 되고 props로 내려보내지 않아도 됩니다.

데이터 캐시는 이번 요청이 끝난 뒤에도 응답을 남겨 둡니다. 서버에 있어서 사용자를 가리지 않습니다. A가 상품 목록을 열고 1분 뒤 B가 열었을 때 원본 서버까지 다시 가지 않는 쪽입니다. 16에서는 기본으로 남기지 않아서 남기려면 use cachecache: 'force-cache'를 직접 지정해야 합니다.

DB를 직접 읽기

서버 컴포넌트는 브라우저로 전송되지 않습니다. 접속 정보나 쿼리가 클라이언트 번들에 들어가지 않으니 API를 따로 만들지 않고 DB를 바로 읽어도 됩니다.

import { db } from '@/lib/db';

export default async function OrderListPage() {
const orders = await db.orders.findMany();

return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.title}</li>
))}
</ul>
);
}

번들에 안 들어간다고 해서 아무나 봐도 되는 데이터가 되는 것은 아닙니다. 권한 확인은 그대로 해야 합니다.

fetch가 아니면 React.cache로 메모이제이션합니다

앞의 메모이제이션은 fetch에만 자동으로 붙습니다. DB 조회는 해당하지 않아서 헤더와 본문에서 각각 로그인 사용자를 찾으면 쿼리가 두 번 나갑니다. React.cache로 감싸면 같은 효과를 얻습니다.

import { cache } from 'react';

export const getUser = cache(async () => {
return db.users.getCurrent();
});

이제 여러 컴포넌트가 getUser()를 각자 불러도 실제 조회는 한 번입니다. 이것도 한 번의 요청 안에서만 유효하고 다음 요청과는 공유하지 않습니다.

순차 조회와 병렬 조회

await을 연달아 쓰면 앞의 요청이 끝나야 다음 요청이 출발합니다. 기다릴 이유가 있을 때만 그렇게 두면 됩니다.

순차 조회는 주문 조회가 끝난 뒤 배송 조회가 시작되어 총 2배 시간이 걸리고, 병렬 조회는 상품 정보와 리뷰가 동시에 출발해 총 1배 시간만 걸리는 것을 막대로 비교한 그림

뒤의 요청이 앞의 결과를 써야 한다면 순서대로 갈 수밖에 없습니다.

export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
const { id } = await params;
const order = await db.orders.get(id);
// 배송 조회는 order.shipmentId가 나와야 시작할 수 있다
const shipment = await db.shipments.get(order.shipmentId);

return <OrderDetail order={order} shipment={shipment} />;
}

서로 의존하지 않는데 이렇게 쓰면 기다릴 필요가 없는 시간을 기다립니다. 먼저 호출해 두고 Promise.all로 함께 기다리면 동시에 출발합니다.

export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;

// await 없이 먼저 호출해 두면 이 시점에 요청이 나간다
const productPromise = getProduct(id);
const reviewsPromise = getReviews(id);

const [product, reviews] = await Promise.all([productPromise, reviewsPromise]);

return <ProductDetail product={product} reviews={reviews} />;
}
참고

Promise.all은 하나라도 실패하면 전체가 실패합니다. 리뷰를 못 불러와도 상품은 보여주고 싶다면 Promise.allSettled를 씁니다.

기다리는 동안 로딩 보여주기

데이터가 준비될 때까지 화면을 비워 둘 수는 없습니다. 방법은 두 가지인데 둘 다 Suspense 기반이고 감싸는 범위만 다릅니다. loading.tsx는 페이지 전체를 감싸고, <Suspense>는 감쌀 영역을 직접 고릅니다.

loading.tsx가 별개 기능처럼 보이지만 그렇지 않습니다. Next.js는 이 파일을 layout.tsx 안쪽에 중첩해 두고, page.tsx와 그 아래 자식들을 Suspense 경계로 감싸 줍니다.

loading.tsx는 내비게이션만 남기고 상품 목록 전체를 스켈레톤으로 덮고, Suspense는 상품 정보를 곧바로 보여주면서 추천 상품 영역만 스켈레톤으로 두는 것을 비교한 그림

loading.tsx<Suspense>
범위페이지 전체감싼 영역만
방법파일만 만들어 두면 됩니다직접 감쌉니다
결과본문이 통째로 기다립니다나머지는 곧바로 보입니다

loading.tsx로 페이지 전체

페이지와 같은 폴더에 loading.tsx를 두면 끝입니다. 연결하는 코드는 없습니다.

// app/products/loading.tsx
export default function Loading() {
return <ProductListSkeleton />;
}

/products로 이동하는 순간 레이아웃과 스켈레톤이 바로 보입니다. 상품 목록이 준비되면 그 자리에 채워집니다.

Suspense로 원하는 영역만

페이지 안에서도 빠른 부분과 느린 부분이 갈립니다. 상품 상세라면 상품 정보는 금방 나오는데 추천 상품이 느릴 수 있습니다. 페이지 전체를 기다리게 하면 이미 준비된 것까지 못 보여줍니다.

import { Suspense } from 'react';

export default function ProductDetailPage() {
return (
<div>
{/* 이 영역은 곧바로 보인다 */}
<ProductInfo />

<Suspense fallback={<RecommendationSkeleton />}>
<Recommendations />
</Suspense>
</div>
);
}

loading.tsx가 로딩 UI를 못 보여주는 경우

원인부터 보면, loading.tsx가 페이지를 감싸는 자리가 레이아웃 안쪽입니다. 그래서 레이아웃 렌더링이 끝나야 그 안의 Suspense 경계에 도달합니다.

그 결과, 레이아웃이 쿠키나 헤더를 읽거나 캐시되지 않은 데이터를 가져오면 스켈레톤이 뜨지 않습니다. 레이아웃이 끝날 때까지 이동 자체가 막힙니다.

해결은 두 가지입니다. 레이아웃에서 그 데이터를 읽는 부분만 따로 <Suspense>로 감싸거나, 조회를 페이지 쪽으로 내리면 loading.tsx가 다시 제 역할을 합니다.

공식 문서의 권고도 같은 맥락입니다.

while loading.js works well for streaming route segments, using <Suspense> closer to the runtime or uncached data access is recommended.

느린 데이터를 읽는 자리 가까이에 경계를 두라는 뜻입니다.

데이터 바꾸기

여기까지가 읽는 쪽이었습니다. 장바구니에 담거나 주문을 취소하는 것처럼 데이터를 바꾸는 일은 Server Function이 맡습니다.

만들기

async 함수 안이나 파일 맨 위에 'use server'를 적습니다. 파일 맨 위에 적으면 그 파일에서 내보내는 함수가 전부 Server Function이 됩니다.

// app/actions.ts
'use server';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function addToCart(formData: FormData) {
// 이 확인을 빼면 안 된다. 아래 주의 참고
const session = await auth();
if (!session?.user) throw new Error('로그인이 필요합니다');

const productId = formData.get('productId') as string;
await db.carts.add(session.user.id, productId);
}
주의

Server Function은 그 페이지 주소로 열린 POST 엔드포인트입니다. 브라우저로 화면을 열지 않고도 curl 같은 것으로 요청을 직접 보낼 수 있습니다.

그래서 로그인한 사람에게만 버튼을 보여주는 방식으로는 막지 못합니다. 화면에 버튼이 없어도 함수는 그대로 호출됩니다. 로그인 여부와 권한은 함수 안에서 매번 확인해야 합니다.

Server Functions are reachable via direct POST requests, not just through your application's UI. Always verify authentication and authorization inside every Server Function.

폼으로 호출하기

<form>action에 함수를 그대로 넘기면 됩니다. FormData는 자동으로 전달됩니다.

import { addToCart } from '@/app/actions';

export default function AddToCartForm({ productId }: AddToCartFormProps) {
return (
<form action={addToCart}>
<input type="hidden" name="productId" value={productId} />
<button type="submit">장바구니에 담기</button>
</form>
);
}

입력값을 받아 제출하는 화면이면 이 방식이 기본입니다. JavaScript가 아직 로드되지 않았거나 꺼져 있어도 폼이 제출되기 때문입니다.

이벤트 핸들러로 호출하기

클라이언트 컴포넌트에서는 onClick 같은 핸들러로도 부릅니다.

'use client';

import { useState } from 'react';
import { addToCartById } from '@/app/actions';

export default function AddToCartButton({ productId }: AddToCartButtonProps) {
const [added, setAdded] = useState(false);

return (
<button
onClick={async () => {
await addToCartById(productId);
setAdded(true);
}}
>
{added ? '장바구니에 담김' : '장바구니에 담기'}
</button>
);
}

입력 폼 없이 버튼 하나로 끝나거나, 반환값을 받아 화면 상태를 바꿔야 할 때 이 방식을 씁니다. 위 예시처럼 담은 뒤 버튼 글자를 바꾸는 경우입니다.

호출부터 화면이 갱신되기까지는 이렇게 흘러갑니다.

브라우저에서 담기 버튼을 누르면 POST 요청으로 서버에 넘어가 권한 확인과 DB 저장과 캐시 갱신을 거친 뒤 갱신된 UI가 브라우저로 돌아오는 흐름을 브라우저와 서버 두 레인으로 나눠 그린 그림

진행 중 표시하기

서버를 다녀오는 동안 버튼이 아무 반응이 없으면 사용자는 눌렸는지 알 수 없습니다. 여기에 두 가지가 필요합니다.

useActionState는 함수가 실행 중인지를 pending으로 알려 줍니다. 로딩 상태를 useState로 직접 만들지 않아도 됩니다. startTransition은 폼이 아닌 곳에서 부를 때 필요합니다. 폼의 action에 넘기면 React가 알아서 감싸 주지만 onClick으로 직접 부를 때는 이렇게 감싸야 pending이 잡힙니다.

'use client';

import { useActionState, startTransition } from 'react';
import { addToCartWithState } from '@/app/actions';

export default function AddToCartButton() {
// 두 번째 인자는 초기 상태다. 액션이 끝나면 그 반환값으로 바뀐다
const [message, action, pending] = useActionState(addToCartWithState, null);

return (
<>
<button onClick={() => startTransition(action)} disabled={pending}>
{pending ? '담는 중' : '장바구니에 담기'}
</button>
{message && <p>{message}</p>}
</>
);
}
주의

useActionState로 감싼 액션은 첫 번째 인자로 이전 상태를 받습니다. 그래서 앞에서 만든 addToCart(formData)를 그대로 넘기면 formData 자리에 초기 상태가 들어옵니다. 인자를 하나 더 받도록 따로 만들어야 합니다.

// app/actions.ts
'use server';

export async function addToCartWithState(prevMessage: string | null, formData: FormData) {
const session = await auth();
if (!session?.user) return '로그인이 필요합니다';

await db.carts.add(session.user.id, formData.get('productId') as string);
return '장바구니에 담았습니다';
}

바꾼 뒤 화면 갱신하기

데이터를 바꿨는데 화면이 그대로면 사용자는 실패한 줄 압니다. 함수 안에서 revalidatePath를 부르면 해당 경로의 캐시가 무효화되어 새 데이터로 다시 그려집니다.

'use server';

import { revalidatePath } from 'next/cache';

export async function addToCart(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('로그인이 필요합니다');

await db.carts.add(session.user.id, formData.get('productId') as string);

revalidatePath('/cart'); // 장바구니 화면을 새 데이터로 다시 그린다
}

담은 뒤 장바구니로 보내고 싶으면 redirect를 이어서 부릅니다. redirect는 호출 즉시 흐름을 끊으니 갱신이 필요하면 그 앞에 둬야 합니다.

revalidatePath('/cart');
redirect('/cart');

정리

데이터를 읽을 때

  • 서버 컴포넌트를 async로 만들고 await으로 읽습니다. DB도 바로 읽을 수 있습니다.
  • 같은 fetch는 한 번의 렌더링 안에서 자동으로 합쳐집니다(메모이제이션). fetch가 아니면 React.cache로 같은 효과를 냅니다.
  • 메모이제이션은 렌더링 한 번까지만 유효합니다. 요청과 요청 사이에 응답을 남기려면 데이터 캐시를 따로 지정해야 합니다.
  • 서로 의존하지 않는 조회는 Promise.all로 동시에 출발시킵니다.

데이터를 기다릴 때

  • 두 방법 모두 Suspense 기반이고 감싸는 범위만 다릅니다. loading.tsx는 페이지 전체, <Suspense>는 고른 영역입니다.
  • 레이아웃이 요청 시점 데이터를 읽으면 loading.tsx가 걸리지 않습니다. 그때는 데이터 가까이에 <Suspense>를 둡니다.

데이터를 바꿀 때

  • 'use server'를 붙인 Server Function이 맡습니다. 함수 안에서 권한을 반드시 확인합니다.
  • 입력값을 제출하면 <form action>, 버튼 하나로 끝나면 onClickstartTransition을 씁니다.
  • 바꾼 뒤에는 revalidatePath로 화면을 갱신합니다.

참고 자료