Next.js 데이터 페칭과 Server Actions

2026. 4. 9. 11:45·Next.js

Next.js 데이터 페칭과 Server Actions

이 글에서는 Next.js에서 제공하는 데이터 페칭 방식과 Server Actions를 중심으로,
실무에서 어떻게 활용해야 하는지까지 함께 정리해보겠습니다.

 

React 개발을 처음 시작했을 때, 대부분의 데이터 페칭은 이런 형태였습니다.

useEffect(() => {
  fetch('/api/posts')
    .then(res => res.json())
    .then(data => setPosts(data));
}, []);

컴포넌트가 마운트된 뒤에야 요청을 보내고, 로딩 상태를 따로 관리하고, 에러 처리도 별도로 해야 했습니다.

이 패턴 자체가 나쁜 것은 아니지만, 한 가지 근본적인 한계가 있었습니다.

 

바로 데이터 페칭이 클라이언트에 종속되어 있다는 점입니다.

 

Next.js App Router와 React Server Components는 이 전제를 바꿨습니다.

데이터를 "클라이언트가 받아서 가져오는" 것이 아니라, "서버가 그려서 전달하는" 방식으로 전환한 것입니다.

이 글에서는 그 변화의 핵심인 데이터 페칭 전략을 처음부터 차근히 살펴봅니다.


React Server Components - 서버에서 바로 그린다

위에서 말씀드렸듯이, 기존 React에서는 데이터를 가져오기 위해 useEffect를 사용했습니다.

 

컴포넌트가 먼저 렌더링되고, 브라우저에서 마운트된 이후에야 데이터 요청이 시작되는 구조입니다.

사용자는 잠깐이라도 빈 화면이나 스켈레톤을 보게 되고, 클라이언트-서버 간 요청 왕복(round trip)이 발생합니다.

 

Server Components에서는 이 방식이 필요하지 않습니다.

컴포넌트 자체를 async로 선언하고, 서버에서 직접 데이터를 가져올 수 있습니다.

// app/posts/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

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

이 방식의 흐름은 다음과 같습니다.

서버에서 데이터 fetch → HTML 생성 → 클라이언트 전달

 

클라이언트는 이미 완성된 UI를 받기 때문에 초기 렌더링 속도가 크게 개선됩니다.

또한 Server Components는 브라우저에 자바스크립트 번들로 전송되지 않습니다.

데이터베이스 접근 코드나 API 키 같은 민감한 로직을 클라이언트에 노출하지 않아도 된다는 점도 중요한 장점입니다.


fetch란 무엇인가 - Web API에서 Next.js 내장 기능으로

기본 개념

fetch는 원래 브라우저가 제공하는 Web API입니다.

HTTP 요청을 보내고 응답을 받는 표준 인터페이스로, XMLHttpRequest를 대체하기 위해 등장했습니다.

Promise 기반으로 설계되어 async/await와 자연스럽게 연동됩니다.

const res = await fetch('https://api.example.com/posts');

if (!res.ok) {
  throw new Error(`HTTP error: ${res.status}`);
}

const data = await res.json();

 

fetch는 Response 객체를 반환합니다. 응답 본문을 읽으려면 .json(), .text(), .blob() 등의 메서드를 명시적으로 호출해야 합니다.

 

한 가지 중요한 점은, fetch는 네트워크 오류가 아닌 이상 HTTP 에러(4xx, 5xx)에서도 Promise를 reject하지 않는다는 것입니다.

 

따라서 res.ok 또는 res.status를 직접 확인해 에러를 처리하는 습관이 필요합니다.

Next.js의 fetch - 캐싱 시스템과 함께 동작한다

Next.js는 기존 fetch Web API를 그대로 사용하되, 서버 환경에서 동작할 수 있도록 확장했습니다.

Node.js 18 이전에는 서버 환경에서 fetch가 기본 제공되지 않아 node-fetch나 axios 같은 별도 라이브러리가 필요했지만, 이제는 클라이언트와 서버 모두에서 동일한 fetch API를 사용할 수 있습니다.

 

더 중요한 차이는 Next.js의 fetch가 내장 캐싱 시스템과 연결되어 있다는 점입니다. 단순히 데이터를 가져오는 것을 넘어, 그 데이터를 언제까지 보관하고 언제 다시 가져올지를 선언적으로 제어할 수 있습니다.

// 캐시 전략을 명시적으로 지정
const res = await fetch('https://api.example.com/posts', {
  cache: 'no-store',        // 항상 최신 데이터
  // cache: 'force-cache',  // 캐시 최대 활용
  // next: { revalidate: 60 } // 60초마다 갱신
});

요청 중복 제거 (Deduplication)

Server Components에서 여러 컴포넌트가 동일한 URL로 fetch를 호출하더라도, Next.js는 동일한 요청 주기 내에서 이를 자동으로 중복 제거합니다.

같은 렌더링 트리 안에서 같은 URL을 여러 번 호출해도 실제 네트워크 요청은 한 번만 발생한다는 의미입니다.

덕분에 각 컴포넌트가 필요한 데이터를 독립적으로 요청하도록 설계해도 불필요한 성능 낭비 없이 구조를 깔끔하게 유지할 수 있습니다.


fetch API 캐싱 전략 - 데이터는 언제 새로 가져와야 하는가

Next.js의 fetch에서 가장 중요한 개념 중 하나는 캐싱 전략입니다.

같은 URL이라도 어떤 옵션을 지정하느냐에 따라 전혀 다른 동작을 합니다.

no-store - 항상 최신 데이터

fetch(url, { cache: 'no-store' })

매 요청마다 서버에서 새 데이터를 가져옵니다.

캐시를 전혀 사용하지 않기 때문에 항상 최신 상태를 보장하지만, 그만큼 서버 부하와 응답 시간이 늘어납니다.

실시간 대시보드, 주식 시세, 채팅 메시지처럼 데이터 변경이 잦은 상황에 적합합니다.

 

Next.js 15부터는 이 옵션이 fetch의 기본값으로 변경되었습니다.

force-cache - 최대한 캐시 사용

fetch(url, { cache: 'force-cache' })

한 번 가져온 데이터를 계속 재사용합니다.

블로그 글, 정적 문서처럼 데이터 변경이 드문 콘텐츠에 적합합니다.

 

데이터를 매번 새로 요청하지 않기 때문에 서버 부하를 줄이고 응답 속도를 높일 수 있지만,

데이터가 바뀌어도 캐시가 갱신되기 전까지는 오래된 내용이 표시될 수 있다는 점을 감안해야 합니다.

revalidate - ISR 기반 갱신

fetch(url, { next: { revalidate: 60 } })

60초마다 데이터를 재검증합니다.

 

캐시를 활용하면서도 주기적으로 최신 데이터를 반영할 수 있어, 성능과 최신성 사이의 균형을 맞추는 전략입니다.

상품 목록, 뉴스 피드처럼 실시간성이 완벽하게 필요하진 않지만 어느 정도 최신 상태를 유지해야 하는 데이터에 잘 맞습니다.

 

이 방식은 Next.js의 ISR(Incremental Static Regeneration)을 fetch 단위로 적용하는 것과 동일합니다.

태그 기반 캐싱

fetch(url, { next: { tags: ['posts'] } })

특정 태그를 붙여두면, 나중에 그 태그를 기준으로 캐시를 무효화할 수 있습니다.

시간 기반 갱신이 아니라 특정 이벤트(글 작성, 수정 등)가 발생했을 때 정확히 해당 데이터만 다시 불러오고 싶을 때 유용합니다.

 

뒤에서 설명할 revalidateTag와 함께 사용하면 데이터 단위의 정밀한 캐시 제어가 가능합니다.


Server Actions - 서버에서 직접 처리하는 로직

Server Actions는 클라이언트에서 서버 함수를 직접 호출할 수 있는 기능입니다.

파일 상단 또는 함수 내부에 'use server' 지시어를 선언하는 것만으로 해당 함수는 서버에서만 실행되며,

클라이언트에서는 마치 일반 함수를 호출하듯 사용할 수 있습니다.

1. Next.js 내부에서 DB를 직접 사용하는 경우

서버에서 직접 DB에 접근하는 구조입니다.

게시물 작성 예시

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

import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;

  await db.post.create({
    data: { title },
  });

  // 데이터 변경 후 화면 갱신
  revalidatePath('/posts');
}
 

동작 흐름

  • 사용자가 폼 제출
  • Next.js가 자동으로 서버 요청 생성
  • 서버에서 createPost 실행
  • DB에 데이터 저장
  • revalidatePath로 캐시 무효화
  • 페이지가 최신 데이터로 다시 렌더링

2. 외부 백엔드 API를 사용하는 경우

Server Actions는 DB뿐만 아니라 외부 API를 감싸는 서버 레이어(BFF, BackEnd-For-FrontEnd) 로도 사용할 수 있습니다.

댓글 작성 예시

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

import { revalidatePath } from 'next/cache';

export async function createComment(formData: FormData) {
  const postId = formData.get('postId');
  const content = formData.get('content');

  await fetch(`${process.env.API_URL}/comments`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      postId,
      content,
    }),
  });

  // 댓글 작성 후 해당 게시글 페이지 갱신
  revalidatePath(`/posts/${postId}`);
}

 

클라이언트에서는 공통적으로 다음과 같이 사용할 수 있습니다.

<form action={createPost}>
  <input name="title" />
  <button type="submit">등록</button>
</form>

useActionState - 폼 상태 관리

React 19에서는 useActionState를 통해 Server Action의 상태를 직접 관리할 수 있습니다. 기존에는 폼 제출 결과를 처리하기 위해 별도의 useState와 try/catch 로직을 작성해야 했지만, 이 훅을 사용하면 서버 응답 상태를 선언적으로 다룰 수 있습니다.

'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

const initialState = { message: '', success: false };

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" />
      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
      <button disabled={isPending}>
        {isPending ? '등록 중...' : '등록'}
      </button>
    </form>
  );
}

첫 번째 반환값 state는 Server Action이 반환한 최신 상태이고, 두 번째 formAction은 폼에 연결할 액션 함수입니다. 세 번째 isPending은 액션이 진행 중인지를 나타내며, 이를 통해 제출 버튼 비활성화나 로딩 UI를 별도의 상태 없이 간단히 구현할 수 있습니다.


useFormStatus - 로딩 상태 제어

useFormStatus는 가장 가까운 상위 <form>의 제출 상태를 읽어오는 훅입니다. useActionState의 isPending과 역할이 비슷해 보이지만, 용도가 다릅니다.

useFormStatus는 폼과 분리된 자식 컴포넌트에서 제출 상태를 읽어야 할 때 유용합니다.

버튼 컴포넌트를 별도로 분리하거나, 폼 내부의 여러 요소가 제출 상태에 반응해야 하는 상황에서 특히 깔끔한 해법이 됩니다.

'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending ? '등록 중...' : '등록'}
    </button>
  );
}

SubmitButton은 폼의 내부 구조를 전혀 알 필요가 없습니다. 상위에 <form>이 있다면 그 폼의 제출 상태를 자동으로 구독하기 때문에, 폼 로직과 버튼 UI를 완전히 분리하면서도 중복 제출 방지와 사용자 피드백을 자연스럽게 처리할 수 있습니다.


캐시 무효화 - 데이터를 언제 다시 그릴 것인가

Server Actions로 데이터를 변경한 뒤에는 화면을 최신 상태로 갱신해야 합니다.

Next.js는 이를 위한 캐시 무효화 API를 제공합니다.

revalidatePath

import { revalidatePath } from 'next/cache';

revalidatePath('/posts');

특정 경로에 해당하는 페이지 캐시를 무효화합니다.

다음 요청 시 서버에서 해당 페이지를 다시 렌더링합니다.

글을 새로 작성한 뒤 목록 페이지를 갱신하거나, 설정을 변경한 뒤 해당 페이지를 최신 상태로 보여줘야 할 때 사용합니다.

revalidateTag

import { revalidateTag } from 'next/cache';

revalidateTag('posts');

fetch 옵션에서 tags로 지정한 태그를 기준으로 캐시를 무효화합니다. 페이지 단위가 아니라 데이터 단위로 캐시를 제어할 수 있어, 여러 페이지에서 동일한 데이터를 공유하는 구조에서 특히 효과적입니다.

 

예를 들어 글 목록과 글 상세 페이지가 모두 'posts' 태그를 사용하고 있다면, revalidateTag('posts') 한 번으로 두 페이지의 캐시를 동시에 무효화할 수 있습니다.

 

revalidatePath는 특정 URL 경로를 알고 있을 때, revalidateTag는 여러 경로에 걸쳐 있는 데이터를 한 번에 무효화하고 싶을 때 각각 사용하면 됩니다.

 

두 API를 조합하면 데이터 단위와 페이지 단위 모두에서 정밀하게 캐시를 제어할 수 있습니다.


Route Handlers vs Server Actions - 무엇을 선택해야 할까

두 가지 방식은 역할이 다릅니다. 선택 기준은 "누가 이 로직을 호출하는가"로 단순하게 정리할 수 있습니다.

 

Route Handlers는 app/api 디렉토리 아래에 위치하며, HTTP 엔드포인트를 만드는 방식입니다. 외부 시스템이나 서드파티 서비스가 호출해야 하는 API, REST 또는 GraphQL 구조가 필요한 경우, 웹훅 수신, 복잡한 인증 미들웨어 처리 등이 해당됩니다. 외부에 공개되는 인터페이스가 필요할 때 선택합니다.

 

Server Actions는 앱 내부의 UI와 강하게 결합된 로직에 적합합니다. 폼 제출, 간단한 데이터 생성·수정·삭제, 같은 앱 안에서만 사용하는 비즈니스 로직이 이에 해당합니다. API 엔드포인트 없이 서버 함수를 직접 호출하기 때문에 코드가 간결해지고, 타입 안정성도 자연스럽게 유지됩니다.

정리하면, 외부에 공개하는 API라면 Route Handler를, 내부 UI 로직이라면 Server Actions를 선택하는 것이 명확한 기준입니다.


Streaming & Suspense - 느린 데이터도 빠르게 보여주기

데이터 페칭에서 가장 흔한 UX 문제는 "느린 데이터 때문에 전체 페이지가 지연되는 것"입니다.

댓글 목록처럼 무거운 데이터가 로딩될 때까지 페이지 전체가 블로킹되면,
빠르게 렌더링될 수 있는 본문 영역까지 함께 지연됩니다.

 

Next.js는 Streaming을 통해 이 문제를 해결합니다.

React의 Suspense와 결합하면, 느린 데이터를 기다리는 동안 빠른 부분은 즉시 렌더링하고,

느린 부분은 나중에 스트리밍으로 채워 넣을 수 있습니다.

 

Streaming이란?

Streaming은 페이지를 한 번에 완성해서 보내는 것이 아니라, 준비된 부분부터 먼저 클라이언트에 전달하는 방식입니다.
이를 통해 서버는 빠르게 준비된 UI부터 먼저 전송하고, 느리게 도착하는 데이터는 나중에 이어서 전달할 수 있습니다.

결과적으로 사용자는 페이지의 일부를 이미 확인할 수 있는 상태에서 나머지 콘텐츠가 점진적으로 채워지는 경험을 하게 됩니다.

 

Suspense란?

Suspense는 React에서 아직 준비되지 않은 컴포넌트를 기다리는 동안 fallback UI를 보여주는 기능입니다.

 

다음과 같이 사용할 수 있습니다.

import { Suspense } from 'react';

<Suspense fallback={<Loading />}>
  <Component />
</Suspense>

 

Suspense로 감싸진 컴포넌트가 데이터를 아직 가져오지 못한 상태라면,

React는 해당 컴포넌트의 렌더링을 잠시 멈추고 fallback UI를 대신 보여줍니다.

 

그리고 데이터가 준비되는 순간, 해당 부분만 다시 렌더링하여 화면을 교체합니다.

Suspense와 Streaming을 함께 활용하기

이제 Streaming과 Suspense를 함께 사용하는 예시를 살펴보겠습니다.

async function Comments() {
  const res = await fetch("https://api.example.com/comments");
  const comments = await res.json();

  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>{c.content}</li>
      ))}
    </ul>
  );
}
import { Suspense } from 'react';
import Comments from './Comments';

export default function PostPage() {
  return (
    <article>
      <h1>게시글 제목</h1>
      <p>본문 내용...</p>

      {/* 댓글은 느리게 로딩될 수 있으므로 Suspense로 감싼다 */}
      <Suspense fallback={<div>댓글을 불러오는 중...</div>}>
        <Comments />
      </Suspense>
    </article>
  );
}

먼저 게시글 제목과 본문은 즉시 렌더링되어 사용자에게 전달됩니다.
이와 동시에 Comments 컴포넌트는 데이터를 요청하게 되지만, 아직 응답이 도착하지 않았기 때문에
Suspense가 이를 감지하고 fallback UI를 대신 렌더링합니다.

 

이 상태에서 사용자는 이미 본문을 읽고 있는 상황이 되고,
댓글 영역에는 “댓글을 불러오는 중...”이라는 메시지가 표시됩니다.

 

이후 댓글 데이터가 도착하면, 해당 부분만 다시 렌더링되어
fallback UI가 실제 댓글 목록으로 자연스럽게 교체됩니다.

 

이 모든 과정은 페이지 전체를 다시 그리는 것이 아니라
필요한 부분만 업데이트되며, 그 사이의 데이터 전달은 Streaming을 통해 이루어집니다.

 

이 구조의 핵심은 “전체를 기다리지 않는다”는 점입니다.

기존 방식에서는 모든 데이터가 준비될 때까지 화면이 지연되었지만,

 

Streaming과 Suspense를 사용하면 준비된 UI부터 먼저 사용자에게 보여줄 수 있습니다.

그 결과 초기 렌더링 속도와 사용자 체감 속도가 동시에 개선됩니다.


또한 이 구조는 Server Actions와도 자연스럽게 연결됩니다.

 

예를 들어 댓글을 작성한 뒤 revalidatePath를 통해 페이지 캐시를 무효화하면,

페이지는 다시 렌더링되면서 댓글 영역은 다시 Suspense 상태로 들어가게 됩니다.

 

이때 fallback UI가 잠시 표시되고, 이후 최신 댓글 데이터가 Streaming을 통해 채워지게 됩니다.

 

데이터 변경 -> 캐시 무효화 -> 부분 렌더링 -> Streaming 업데이트

라는 흐름을 구현할 수 있게 됩니다.


마무리

fetch를 어떤 옵션으로 사용하느냐에 따라 페이지가 정적이 될 수도, 동적이 될 수도, 주기적으로 갱신될 수도 있습니다.

 

이 글에서 다룬 개념들을 바탕으로, 실제 프로젝트에서 "어디서 데이터를 가져오고, 어디서 처리하고, 언제 갱신할지"를 직접 고민해보시면 그 차이를 바로 체감하실 수 있을 것입니다.

 

읽어주셔서 감사합니다!

'Next.js' 카테고리의 다른 글

Next.js 스타일링 전략: Tailwind CSS, shadcn/ui, 그리고 CSS-in-JS  (0) 2026.05.17
Next.js에서의 인증 전략과 쿠키 vs 토큰  (0) 2026.05.10
렌더링 전략 완전 이해 - SSG, ISR, PPR, Cache Components  (0) 2026.03.29
Next.js App Router 라우팅 & Proxy 완벽 이해하기  (0) 2026.03.17
Next.js에서 SSR vs CSR 제대로 이해하기  (0) 2026.03.12
'Next.js' 카테고리의 다른 글
  • Next.js 스타일링 전략: Tailwind CSS, shadcn/ui, 그리고 CSS-in-JS
  • Next.js에서의 인증 전략과 쿠키 vs 토큰
  • 렌더링 전략 완전 이해 - SSG, ISR, PPR, Cache Components
  • Next.js App Router 라우팅 & Proxy 완벽 이해하기
수달군
수달군
  • 수달군
    수달 코딩 공장
    수달군
  • 전체
    오늘
    어제
    • 분류 전체보기 (21)
      • React (10)
      • Next.js (7)
      • TypeScript 딥 다이브! (1)
      • 웹 기초 이론 (0)
      • 코딩 테스트 준비 (1)
        • Python 기본 (1)
      • AI 도구들 (0)
      • 프로젝트 회고 (0)
      • 자료구조 (0)
      • 일상 (0)
      • 해외 여행 (0)
      • 국내 여행 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    input
    it
    python
    파이썬
    파이썬 초보
    입력받기
    입력
    입력값
    코딩
    coding
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
수달군
Next.js 데이터 페칭과 Server Actions
상단으로

티스토리툴바