태그
목차

서버 액션 & 뮤테이션

생성일: 2024-02-03

수정일: 2024-02-03

서버 액션은 서버에서 실행되는 비동기 함수다. 서버 및 클라이언트 컴포넌트에서 Next.js 애플리케이션의 폼 제출 및 데이터 뮤테이션을 처리하는 데 사용할 수 있다.

컨벤션

서버 액션은 React "use server" 지시문으로 정의할 수 있다. 지시문을 비동기 함수의 맨 위에 배치하여 해당 함수를 서버 액션으로 표시하거나, 별도의 파일 맨 위에 배치하여 해당 파일의 모든 내보내기를 서버 액션으로 표시할 수 있다.

서버 컴포넌트

서버 컴포넌트는 함수 수준 또는 모듈 수준에서 "use server" 지시문을 인라인으로 사용할 수 있다. 서버 액션을 인라인하려면 함수 본문 상단에 "use server" 를 추가한다:

// app/page.tsx

// 서버 컴포넌트
export default function Page() {
  // 서버 액션
  async function create() {
    'use server'

    // ...
  }

  return (
    // ...
  )
}

클라이언트 컴포넌트

클라이언트 컴포넌트는 모듈 수준의 "use server" 지시문을 사용하는 액션만 가져올 수 있다.

클라이언트 컴포넌트에서 서버 액션을 호출하려면 새 파일을 만들고 그 파일 상단에 "use server" 지시문을 추가하면 된다. 파일 내의 모든 함수는 클라이언트 컴포넌트와 서버 컴포넌트 모두에서 재사용할 수 있는 서버 액션이 된다:

// app/actions.ts

'use server';

export async function create() {
  // ...
}
// app/ui/button.tsx

import { create } from '@/app/actions'

export function Button() {
  return (
    // ...
  )
}

서버 액션을 클라이언트 컴포넌트에 프로퍼티로 전달할 수도 있다:

<ClientComponent updateItem={updateItem} />
// app/client-component.jsx

'use client';

export default function ClientComponent({ updateItem }) {
  return <form action={updateItem}>{/* ... */}</form>;
}

행동

예제

React는 HTML <form> 엘리먼트를 확장하여 action 프로퍼티로 서버 액션을 호출한다.

폼에서 호출되면 액션은 자동으로 FormData 객체를 수신한다. 필드를 관리하기 위해 React useState 를 사용할 필요가 없으며, 대신 네이티브 FormData 메서드를 사용하여 데이터를 추출할 수 있다:

// app/invoices/page.tsx

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'),
    };

    // mutate data
    // revalidate cache
  }

  return <form action={createInvoice}>...</form>;
}

Tip

  • 예시: 로딩 및 에러 상태와 폼
  • 필드가 많은 폼으로 작업할 때는 JavaScript의 Object.fromEntries() 와 함께 entries() 메서드를 사용하는 것을 고려할 수 있다. 예시: const rawFormData = Object.fromEntries(formData.entries())

자세한 내용은 React <form> 문서를 참조한다.

추가 인자 전달하기

JavaScript bind 메서드를 사용하여 서버 액션에 추가 인자를 전달할 수 있다.

// app/client-component.tsx

'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>
  );
}

서버 액션은 폼 데이터와 함께 userId 인자를 받는다:

// app/actions.js

'use server';

export async function updateUser(userId, formData) {
  // ...
}

Tip

  • 다른 방법은 폼에서 인자를 숨겨진 인풋 필드로 전달하는 것이다(예: <input type="hidden" name="userId" value={userId} />). 그러나 이 값은 렌더링된 HTML의 일부가 되며 인코딩되지 않는다.
  • .bind 는 서버와 클라이언트 컴포넌트 모두에서 작동한다. 또한 점진적 향상도 지원한다.

보류 상태

React useFormStatus Hook을 사용하여 폼이 제출되는 동안 보류(pending) 중인 상태를 표시할 수 있다.

// app/submit-button.tsx

'use client';

import { useFormStatus } from 'react-dom';

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

  return (
    <button type="submit" aria-disabled={pending}>
      Add
    </button>
  );
}

<SubmitButton /> 은 어떤 폼에서도 중첩할 수 있다:

// app/page.tsx

import { SubmitButton } from '@/app/submit-button';
import { createItem } from '@/app/actions';

// Server Component
export default async function Home() {
  return (
    <form action={createItem}>
      <input type="text" name="field-name" />
      <SubmitButton />
    </form>
  );
}

서버 사이드 유효성 검사 & 에러 핸들링

클라이언트 사이드 폼 유효성 검사를 위해서 requiredtype="email" 과 같은 HTML 유효성 검사를 사용하는 것이 좋다.

서버 사이드 유효성 검사의 경우, 데이터를 변경하기 전에 zod 와 같은 라이브러리를 사용하여 폼 필드의 유효성을 검사할 수 있다:

// app/actions.ts

'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,
    };
  }

  // Mutate data
}

서버에서 필드의 유효성을 검사하고 나면 액션에서 직렬화 가능한 객체를 반환하고 React useFormState Hook을 사용하여 사용자에게 메시지를 표시할 수 있다.

// app/actions.ts

'use server';

export async function createUser(prevState: any, formData: FormData) {
  // ...
  return {
    message: 'Please enter a valid email',
  };
}

그런 다음 동작을 useFormState Hook에 전달하고 반환된 상태를 사용하여 오류 메시지를 표시할 수 있다.

// app/ui/signup.tsx

'use client';

import { useFormState } from 'react-dom';
import { createUser } from '@/app/actions';

const initialState = {
  message: '',
};

export function Signup() {
  const [state, formAction] = useFormState(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>Sign up</button>
    </form>
  );
}

Tip

데이터를 수정하기 전에 항상 사용자에게 해당 작업을 수행할 수 있는 권한이 있는지 확인해야 한다.

낙관적 업데이트

서버 액션이 완료될 때까지 응답을 기다리지 않고 React useOptimistic Hook을 사용하여 UI를 낙관적 업데이트할 수 있다:

// app/page.tsx

'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[]>(
    messages,
    (state: Message[], newMessage: string) => [
      ...state,
      { message: newMessage },
    ],
  );

  return (
    <div>
      {optimisticMessages.map((m, k) => (
        <div key={k}>{m.message}</div>
      ))}
      <form
        action={async (formData: FormData) => {
          const message = formData.get('message');
          addOptimisticMessage(message);
          await send(message);
        }}
      >
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

중첩된 엘리먼트

<button>, <input type="submit">, <input type="image"> 와 같이 <form> 안에 중첩된 요소에서 서버 액션을 호출할 수 있다. 이러한 요소는 formAction 프로퍼티 또는 이벤트 핸들러를 받는다.

이 기능은 폼 내에서 여러 서버 액션을 호출하려는 경우에 유용하다. 예를 들어, 글 초안을 게시하는 것 외에 임시 저장을 위한 특정 <button> 엘리먼트를 만들 수 있다.

프로그래밍 방식으로 폼 제출하기

requestSubmit() 메서드를 사용하여 폼 제출을 트리거할 수 있다. 예를 들어 사용자가 + Enter 를 누르면 onKeyDown 이벤트를 수신할 수 있다:

// app/entry.tsx

'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>
  );
}

그러면 가장 가까운 <form> 의 제출이 트리거되어 서버 액션이 호출된다.

다른 엘리먼트들

<form> 엘리먼트 내에서 서버 액션을 사용하는 것이 일반적이지만, 이벤트 핸들러 및 useEffect 와 같은 코드의 다른 부분에서도 서버 액션을 호출할 수 있다.

이벤트 핸들러

onClick 과 같은 이벤트 핸들러에서 서버 액션을 호출할 수 있다.

예를 들어 다음과 같이 좋아요 수를 늘리는 코드를 작성할 수 있다:

// app/like-button.tsx

'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>
    </>
  );
}

사용자 경험을 개선하려면 서버 액션이 서버에서 실행을 완료하기 전에 UI를 업데이트하거나 보류 중인 상태를 표시하기 위해 useOptimisticuseTransition 과 같은 다른 React API를 사용하면 좋다.

폼 엘리먼트에 이벤트 핸들러를 추가하여 필드를 저장할 수도 있다(예: onChange):

// app/ui/edit-post.tsx

'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>
  );
}

이와 같이 여러 이벤트가 연속적으로 빠르게 실행될 수 있는 경우에는 디바운싱(debouncing) 을 통해 불필요한 서버 액션 호출을 방지하는 것이 좋다.

useEffect

컴포넌트가 마운트되거나 종속성이 변경될 때 서버 액션을 호출하기 위해 React useEffect Hook을 사용할 수 있다. 이는 전역 이벤트에 의존하거나 자동으로 트리거되어야 하는 뮤테이션에 유용하다. 예를 들어, 단축키를 위한 onKeyDown, 무한 스크롤을 위한 옵저버 Hook, 또는 컴포넌트가 마운트되어 뷰 수를 업데이트할 때 등이 있다:

// app/view-count.tsx

'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.js 또는 <Suspense> 바운더리에서 포착된다. try/catch 를 사용하여 UI에서 처리할 에러를 리턴하는 것이 좋다.

예를 들어 서버 액션은 메시지를 반환하여 새 항목을 만들 때 발생하는 오류를 처리할 수 있다:

// app/action.ts

'use server';

export async function createTodo(prevState: any, formData: FormData) {
  try {
    // Mutate data
  } catch (e) {
    throw new Error('Failed to create task');
  }
}

Tip

오류를 던지는 것 외에도 useFormState 에서 처리하도록 객체를 리턴할 수도 있다. 서버 사이드 유효성 검사 및 오류 처리 항목을 참조한다.

데이터 재검증

revalidatePath API를 사용하여 서버 액션 내에서 Next.js 캐시의 유효성을 재검증할 수 있다:

// app/actions.ts

'use server';

import { revalidatePath } from 'next/cache';

export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidatePath('/posts');
}

또는 revalidateTag 를 사용하여 캐시 태그가 있는 특정 데이터 페치를 무효화한다:

// app/actions.ts

'use server';

import { revalidateTag } from 'next/cache';

export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts');
}

리다이렉팅

서버 액션이 완료된 후 사용자를 다른 라우트로 리디렉션하려면 redirect API를 사용하면 된다. redirecttry/catch 블록 외부에서 호출해야 한다:

// app/actions.ts

'use server';

import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';

export async function createPost(id: string) {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts'); // `posts` 캐시 업데이트
  redirect(`/post/${id}`); // 새 포스트 페이지로 이동
}

쿠키

cookies API를 사용하여 서버 액션 내에서 쿠키를 get, set , delete 할 수 있다:

// app/actions.ts

'use server';

import { cookies } from 'next/headers';

export async function exampleAction() {
  // Get cookie
  const value = cookies().get('name')?.value;

  // Set cookie
  cookies().set('name', 'Delba');

  // Delete cookie
  cookies().delete('name');
}

보안

인증 & 인가

서버 액션은 공개 API 엔드포인트로 취급해야 하며 사용자가 해당 작업을 수행할 권한이 있는지 확인해야 한다. 예를 들어:

// app/actions.ts

'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');
  }

  // ...
}

클로저 & 암호화

컴포넌트 내부에서 서버 액션을 정의하면 액션이 외부 함수의 스코프에 액세스할 수 있는 클로저가 생성된다. 예를 들어, publish 액션은 publishVersion 변수에 액세스할 수 있다:

// app/page.tsx

export default function Page() {
  const publishVersion = await getLatestVersion();

  async function publish(formData: FormData) {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('The version has changed since pressing publish');
    }
    ...
  }

  return <button action={publish}>Publish</button>;
}

클로저는 나중에 액션이 호출될 때 사용할 수 있도록 렌더링 시점에 데이터(예: publishVersion)의 스냅샷을 캡처해야 할 때 유용하다.

그러나 이를 위해 캡처된 변수는 액션이 호출될 때 클라이언트로 전송되고 다시 서버로 전송된다. 민감한 데이터가 클라이언트에 노출되는 것을 방지하기 위해 Next.js는 자동으로 클로저 변수를 암호화한다. Next.js 애플리케이션이 빌드될 때 각 액션마다 새로운 개인 키가 생성된다. 즉, 특정 빌드에 대해서만 액션을 호출할 수 있다.

Tip

민감한 값이 클라이언트에 노출되는 것을 방지하기 위해 암호화에만 의존하는 것은 좋지 않다. 대신 React taint API를 사용하여 특정 데이터가 클라이언트로 전송되는 것을 사전에 방지해야 한다.

암호화 키 덮어쓰기

여러 서버에서 Next.js 애플리케이션을 셀프 호스팅하는 경우 각 서버 인스턴스가 서로 다른 암호화 키를 사용하게 되어 잠재적인 불일치가 발생할 수 있다.

이 문제를 완화하려면 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 환경 변수를 사용하여 암호화 키를 덮어쓰면 된다. 이 변수를 지정하면 암호화 키가 빌드 간에 영구적으로 유지되고 모든 서버 인스턴스가 동일한 키를 사용하게 된다.

이는 여러 배포 간에 일관된 암호화 동작이 필요한 애플리케이션에서 사용되는 고급 사용 사례다. 키 로테이션 및 서명과 같은 표준 보안 관행을 고려해야 한다.

허용된 origin

서버 액션은 <form> 엘리먼트에서 호출할 수 있으므로 CSRF 공격에 노출될 수 있다.

이면에서 서버 액션은 POST 메서드를 사용하며, 오직 POST 메서드만 호출할 수 있다. 이렇게 하면 최신 브라우저, 특히 기본값으로 설정된 SameSite 쿠키로 인한 대부분의 CSRF 취약점을 방지할 수 있다.

추가적인 보안을 위해 Next.js의 서버 액션은 Origin 헤더Host 헤더(또는 X-Forwarded-Host)도 비교한다. 두 헤더가 일치하지 않으면 요청이 중단된다. 즉, 서버 액션은 해당 액션을 호스팅하는 페이지와 동일한 호스트에서만 호출할 수 있다.

리버스 프록시 또는 다계층 백엔드 아키텍처(서버 API가 프로덕션 도메인과 다른 경우)를 사용하는 대규모 애플리케이션의 경우 구성 옵션 serverActions.allowedOrigins 옵션을 사용하여 안전한 오리진 목록을 지정하는 것이 좋다. 이 옵션은 문자열 배열을 허용한다.

// next.config.js

/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
    },
  },
};