본문 바로가기
SPA/Next

React Server Action, API Route 없이 서버 함수를 호출하는 방법

by J4J 2026. 7. 5.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 React Server Action이 무엇이고, 실무에서 어떻게 활용할 수 있는지에 대해 알아보는 시간을 가져보려고 합니다.

 

 

 

Server Action이란?

 

Server Action은 서버에서 실행되는 비동기 함수를 클라이언트에서 직접 호출할 수 있게 해주는 기능입니다.

 

React 19에서 공식 API로 안정화되었으며, Next.js App Router 환경에서 가장 자연스럽게 사용할 수 있습니다.

 

그러면 Server Action이 왜 필요할까요?

 

기존에는 클라이언트에서 서버의 데이터를 변경하기 위해 다음과 같은 과정이 필요했습니다.

 

  1. API 엔드포인트(Route Handler) 작성
  2. 클라이언트에서 fetch로 해당 엔드포인트 호출
  3. 응답 처리 및 상태 업데이트

 

Server Action을 사용하면 별도의 API 엔드포인트 없이 서버 함수를 직접 호출할 수 있어 코드가 간결해지고 관리 포인트가 줄어드는 것을 경험하게 됩니다.

 

또한 Server Action은 서버 환경에서 실행되기 때문에 데이터베이스를 직접 호출하거나, 다른 서버와의 API 통신(server-to-server)도 함께 수행할 수 있습니다.

 

다음으로는 Server Action이 특히 유용한 상황을 살펴보겠습니다.

 

 

반응형

 

 

Server Action을 사용하면 좋은 상황

 

Server Action을 사용하면 클라이언트에서 API를 직접 호출하는 것과 어떤 차이가 있는지 살펴보겠습니다.

 

클라이언트 컴포넌트에서 외부 API를 직접 호출하면, API 키 등 민감한 정보가 브라우저 네트워크 탭에 노출될 수 있습니다.

 

// ❌ API 키가 클라이언트에 노출되는 경우
// app/create-post-form.tsx
'use client'

export default function CreatePostForm() {
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    await fetch('https://api.example.com/posts', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,  // 브라우저 개발자 도구 네트워크 탭에 노출됨
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title }),
    })
  }
  // ...
}

 

이렇게 작성하면 API_KEY가 브라우저 개발자 도구에서 누구나 확인할 수 있습니다.

 

// ✅ Server Action으로 API 키를 서버에서만 접근하는 경우
// app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.API_KEY}`,  // 서버에서만 접근
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ title }),
  })
}

 

 

 

Server Action을 통해 호출하면 API 키는 서버 환경에서만 사용되고 클라이언트에 전달되지 않습니다. 서버 간 통신이므로 CORS 제한도 발생하지 않습니다.

 

다만 Server Action은 공개 HTTP 엔드포인트로 동작합니다. 외부에서 직접 호출될 수 있으므로, 인자를 신뢰할 수 없는 입력으로 취급하고 인가 검증을 Server Action 내부에서 직접 수행해야 합니다.

 

개인적으로 외부 API 키를 다루는 요청은 대부분 Server Action으로 처리하게 됩니다.

 

아래와 같은 상황에서 Server Action을 사용하는 것이 좋습니다.

 

  • API 키, 인증 토큰 등 민감한 정보를 클라이언트에 노출하지 않아야 할 때
  • CORS 제한이 있는 외부 API를 호출해야 할 때
  • 폼 제출 후 데이터 변경, 캐시 초기화, 리다이렉트를 한 번에 처리해야 할 때
  • 서버 사이드에서만 접근 가능한 내부 서비스 API를 호출해야 할 때

 

그러면 실제로 Server Action을 어떻게 선언하고 사용하는지 살펴보겠습니다.

 

 

 

 

Server Action 선언 방식과 기본 사용법

 

Server Action을 만들기 위해서는 함수 또는 파일 상단에 'use server' 지시어를 선언해야 합니다.

 

선언 방식은 두 가지입니다.

 

[ 인라인 선언 ]

 

Server Component 내부에서 함수 상단에 'use server'를 작성하는 방식입니다.

 

// app/page.tsx
export default function Page() {
  async function createPost(formData: FormData) {
    'use server'
    const title = formData.get('title') as string
    await fetch('https://api.example.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    })
  }

  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">작성</button>
    </form>
  )
}

 

 

 

[ 파일 레벨 선언 ]

 

파일 최상단에 'use server'를 선언하면 해당 파일에서 export 되는 모든 함수가 Server Action이 됩니다.

 

// app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
}

export async function deletePost(id: string) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'DELETE',
  })
}

 

 

 

여러 Server Action을 한 곳에서 관리할 때는 파일 레벨 선언 방식이 더 편리합니다.

 

선언한 Server Action은 Server Component와 Client Component 모두에서 사용할 수 있습니다.

 

Server Component에서는 form의 action prop에 Server Action을 직접 전달합니다.

 

// app/page.tsx
import { createPost } from './actions'

export default function Page() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목을 입력하세요" />
      <button type="submit">작성</button>
    </form>
  )
}

 

 

 

Client Component에서 Server Action을 사용하려면 'use server'로 선언된 파일에서 import 해와야 합니다.

 

// ❌ Client Component 내부에서 'use server' 인라인 선언 시
// app/create-post-form.tsx
'use client'

export default function CreatePostForm() {
  async function createPost(formData: FormData) {
    'use server'  // 'use client'와 'use server'는 같은 파일에 공존 불가
    // ...
  }
  // ...
}

 

이렇게 작성하면 빌드 시 에러가 발생합니다. 'use client'와 'use server'는 같은 파일에 공존할 수 없습니다.

 

// ✅ 별도 서버 파일에서 import 해오는 방식
// app/create-post-form.tsx
'use client'

import { createPost } from './actions'

export default function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목을 입력하세요" />
      <button type="submit">작성</button>
    </form>
  )
}

 

 

 

form의 action prop 외에도 onClick 등 일반 이벤트 핸들러에서 Server Action을 직접 호출하는 것도 가능합니다.

 

// app/delete-button.tsx
'use client'

import { deletePost } from './actions'

export default function DeleteButton({ id }: { id: string }) {
  return (
    <button onClick={() => deletePost(id)}>
      삭제
    </button>
  )
}

 

저의 경우 여러 Server Action을 관리하다 보면 파일 레벨 선언 방식이 코드 정리에 훨씬 편리하다고 느끼게 됩니다.

 

다음으로는 Server Action의 실행 결과를 상태로 관리하는 방법을 알아보겠습니다.

 

 

 

 

useActionState와 useFormStatus

 

그러면 Server Action의 실행 결과를 화면에 반영하려면 어떻게 할까요?

 

useActionState는 Server Action의 실행 결과를 상태로 관리할 수 있게 해주는 훅입니다.

 

const [state, formAction, isPending] = useActionState(action, initialState)

 

 

 

각 반환값의 의미는 다음과 같습니다.

 

  • state: Server Action이 반환한 최신 값 (초기값은 initialState)
  • formAction: form의 action prop에 전달할 함수
  • isPending: Server Action이 실행 중인지 여부

 

useActionState는 react에서, useFormStatus는 react-dom에서 import합니다. 두 훅은 패키지 경로가 다르므로 주의가 필요합니다.

 

useActionState와 함께 사용할 때 Server Action의 첫 번째 인자는 이전 상태값(prevState)이 되고, 두 번째 인자부터 실제 데이터가 들어옵니다.

 

// app/actions.ts
'use server'

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

  if (!title) {
    return { error: '제목을 입력해 주세요.' }
  }

  const res = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })

  if (!res.ok) {
    return { error: '포스트 작성에 실패했습니다.' }
  }

  return { success: true }
}

 

// app/create-post-form.tsx
'use client'

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

export default function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null)

  return (
    <form action={formAction}>
      <input name="title" placeholder="제목을 입력하세요" />
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '작성 중...' : '작성'}
      </button>
    </form>
  )
}

 

 

 

그러면 isPending을 form 안에 중첩된 다른 컴포넌트에서 사용하려면 어떻게 할까요?

 

useFormStatus는 가장 가까운 부모 form의 제출 상태를 읽을 수 있게 해주는 훅입니다. prop drilling 없이 form 하위의 어느 컴포넌트에서든 pending 상태에 접근할 수 있습니다.

 

// ❌ form을 렌더링하는 같은 컴포넌트에서 useFormStatus 호출 시
// app/create-post-form.tsx
'use client'

import { useFormStatus } from 'react-dom'

export default function CreatePostForm() {
  const { pending } = useFormStatus()  // 항상 false를 반환합니다

  return (
    <form action={...}>
      <button type="submit" disabled={pending}>작성</button>
    </form>
  )
}

 

이렇게 작성하면 pending이 항상 false를 반환합니다. useFormStatus는 부모 form을 추적하기 때문에 form을 렌더링하는 컴포넌트 자신에서는 동작하지 않습니다.

 

// ✅ form의 자식 컴포넌트로 분리하는 방식
// app/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

export default function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '작성 중...' : '작성'}
    </button>
  )
}

 

// app/create-post-form.tsx
'use client'

import { useActionState } from 'react'
import { createPost } from './actions'
import SubmitButton from './submit-button'

export default function CreatePostForm() {
  const [state, formAction] = useActionState(createPost, null)

  return (
    <form action={formAction}>
      <input name="title" placeholder="제목을 입력하세요" />
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      <SubmitButton />
    </form>
  )
}

 

 

 

SubmitButton을 별도 컴포넌트로 분리하면 form 구조가 복잡해지더라도 로딩 상태를 prop 없이 관리할 수 있어 재사용성도 높아집니다.

 

이 훅들을 함께 사용하다 보면 폼 처리 코드가 훨씬 간결해지는 것을 체감할 수 있습니다.

 

다음으로는 에러 처리와 페이지 이동 방법에 대해 알아보겠습니다.

 

 

 

 

Server Action 에러 처리와 redirect 페이지 이동

 

Server Action 내부에서 에러가 발생했을 때 throw를 사용하면 가장 가까운 error.tsx(Error Boundary)에서 처리됩니다.

 

따라서 사용자에게 에러 메시지를 보여주어야 할 때는 throw 대신 에러 정보를 반환값에 담는 방식을 사용하는 것이 좋습니다.

 

작업이 성공적으로 완료된 후 다른 페이지로 이동할 때는 redirect를 사용합니다.

 

// ❌ try/catch 안에서 redirect 호출 시
// app/actions.ts
'use server'

import { redirect } from 'next/navigation'

export async function createPost(prevState: unknown, formData: FormData) {
  try {
    // ...작업 수행
    redirect('/posts')  // NEXT_REDIRECT 신호가 catch에 의해 삼켜집니다
  } catch {
    return { error: '포스트 작성에 실패했습니다.' }
  }
}

 

이렇게 작성하면 redirect가 throw하는 NEXT_REDIRECT 신호를 catch가 삼켜 페이지 이동이 발생하지 않습니다.

 

// ✅ try/catch 외부에서 redirect 호출하는 방식
// app/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

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

  if (!title) {
    return { error: '제목을 입력해 주세요.' }
  }

  try {
    const res = await fetch('https://api.example.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    })
    if (!res.ok) {
      return { error: '포스트 작성에 실패했습니다.' }
    }
  } catch {
    return { error: '포스트 작성에 실패했습니다.' }
  }

  revalidatePath('/posts')
  redirect('/posts')
}

 

 

 

redirect는 내부적으로 NEXT_REDIRECT 에러를 throw하는 방식으로 동작합니다. 따라서 try/catch 블록 안에서 호출하면 catch가 이 신호를 에러로 처리하여 페이지 이동이 발생하지 않습니다.

 

그러므로 redirect를 원한다면 try/catch 블록 밖에 작성하여 동작하도록 해주는 것이 중요합니다.

 

그리고 데이터를 변경이 이루어지는 것이면 redirect 전에 revalidatePath로 해당 경로의 캐시를 무효화하여 이동한 페이지에서 최신 데이터를 바로 확인할 수 있습니다.

 

 

 

 

정리

 

지금까지 살펴본 내용을 정리하면 다음과 같습니다.

 

구분 기존 방식 (API Route) Server Action
엔드포인트 파일 별도 route.ts 작성 필요 불필요
API 키 보안 클라이언트 노출 위험 서버에서만 접근
CORS 외부 도메인 시 설정 필요 서버 간 통신 — 해당 없음
폼 제출 처리 fetch + 상태 관리 분리 action prop에 직접 전달
실행 결과 상태 관리 useState + 별도 처리 useActionState로 통합

 

 

 

 

 

 

 

이상으로 React Server Action에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글