Next.js 13.4 Server Actions에 대해서 ("use server")

기운찬곰·2023년 7월 7일
7

Next.js 이모저모

목록 보기
4/8
post-thumbnail

Overview

얼마 전에 개발 유튜브 영상을 보다가 특이한 문법을 보았습니다. 그것은 바로 "use server"... 입니다. 아니 "use client"는 알겠는데 저건 또 무엇인가 싶었죠. 애초에 "use client"도 사용하기 어색한데 "use server"도 생겼다니... 별로인거 같기도 합니다.

아무튼 이에 대해 알아보다가 Next.js 13.4 버전에서 새로운 기능 Server Actions가 알파 버전으로 공개되었다는 사실을 알게 되었습니다. 해외 개발 유튜버도 새로운 기능에 대해 소개를 하고 있더군요. 이번 시간에는 그래서 Server Actions가 무엇인지에 대해 간단하게 글을 작성해보도록 하겠습니다.


Next.js 13.4 Server Actions 공식문서

참고 : https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions

먼저 공식 문서부터 살펴보겠습니다.

Server Actions은 React Actions 위에 구축된 Next.js의 알파 기능입니다. 참고로, Actions는 리액트의 실험적인 기능으로, 사용자 상호 작용에 대한 응답으로 async 코드를 실행할 수 있습니다. 아하. Next.js 단독으로 생긴 기능이 아니라 React 실험적인 기능을 사용해서 만든 기능이군요. 새로운 기능이 많이 생기는거 같습니다...하하..

이를 통해 서버 측 data mutations, 클라이언트 측 자바스크립트 감소, 점진적으로 향상된 forms을 가능하게 해줍니다. 서버 컴포넌트 내에서 정의하거나 클라이언트 컴포넌트에서 호출할 수 있습니다:

with server component:

함수 본문의 맨 위에 "use server" 지시어를 사용하여 비동기 함수를 정의하여 Server Actions을 작성합니다. 이 함수에는 React Server Components 규약을 기반으로 직렬화 가능한 인수직렬화 가능한 반환 값이 있어야 합니다.

import { cookies } from 'next/headers'
 
// Server action defined inside a Server Component
export default function AddToCart({ productId }) {
  async function addItem(data) {
    'use server'
 
    const cartId = cookies().get('cartId')?.value
    await saveToDb({ cartId, data })
  }
 
  return (
    <form action={addItem}>
      <button type="submit">Add to Cart</button>
    </form>
  )
}

with client components:

클라이언트 컴포넌트 내에서 Server Actions을 사용하는 경우, 파일 상단에 "use server" 지시사항이 있는 별도의 파일에 action을 작성합니다. 그런 다음 Server Actions을 클라이언트 컴포넌트로 가져옵니다:

///////////////////////////////
// app/actions.js
'use server'
 
export async function addItem(data) {
  const cartId = cookies().get('cartId')?.value
  await saveToDb({ cartId, data })
}

///////////////////////////////
// app/add-to-cart.js
'use client'
 
import { addItem } from './actions.js'
 
// Server Action being called inside a Client Component
export default function AddToCart({ productId }) {
  return (
    <form action={addItem}>
      <button type="submit">Add to Cart</button>
    </form>
  )
}

next.config.js 실험적인 기능 활성화

아직 실험적인 기능으므로 experimental serverActions 플래그를 활성화하여 Next.js 프로젝트에서 Server Actions을 활성화할 수 있습니다.

module.exports = {
  experimental: {
    serverActions: true,
  },
}

Invocation

다음 방법을 사용하여 서버 작업을 호출할 수 있습니다:

  • Using action : 리액트의 action prop은 form 요소에 대한 Server Action을 호출할 수 있습니다.
  • Using formAction : React의 formAction prop은 form에 <button>, <input type=”submit”> 그리고 <input type=”image”> 요소에서 처리할 수 있도록 합니다.
  • startTransition을 사용한 사용자 지정 호출 : action or formAction을 사용하지 않고 useTransition 후크에서 제공하는 startTranstion을 사용하여 Server Action을 호출합니다. 이 메서드는 Progressive Enhancement를 비활성화합니다.

이에 대한 예시는 실습을 통해 알아보도록 하겠습니다. 공식문서에 예시가 잘 나와있습니다.


[실습] 간단하게 Server Actions 사용해보기

참고 : Server Actions: NextJS 13.4's Best New Feature

Introduction

Next.js 13 App Router가 공식적으로 릴리즈되었고, Server Actions 이라는게 새로 추가되었습니다.

Server Actions는 애플리케이션에서 정말 쉽게 프론트엔드 API 용 백엔드를 구축하는 훌륭한 방법입니다. 더 이상 API Routes 를 사용하지 않아도 됩니다. Server Actions를 사용하면 서버에 추가할 데이터를 fetch하거나 서버에서 mutations 하는 것이 훨씬 쉬워집니다.

함수에 “use server”를 추가하기만 하면 이제 함수를 호출하고 결과를 기다리는 것만큼 클라이언트에서 쉽게 호출할 수 있는 서버 함수입니다. 이제 컴포넌트에 바로 내장된 이러한 함수를 통해 서버 작업을 사용할 수 있습니다.

Creating The Project, Server Actions For Forms

간단하게 기본 Todos를 만들어봅니다.

const todos: string[] = ["Learn React"];

export default function Home() {
  return (
    <main className="p-5">
      <h1 className="text-4xl font-bold mb-4">Todos</h1>
      <ul className="mb-4">
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
      <form>
        <input
          type="text"
          name="todo"
          className="h-[48px] border-[1px] border-gray-40 rounded-[4px] px-[16px] outline-none placeholder:text-gray-60 placeholder:text-[14px]"
        />
        <button
          type="submit"
          className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3"
        >
          Add Todo
        </button>
      </form>
    </main>
  );
}

Server Actions Form Forms

addTodo 라는 비동기 함수를 추가해주었습니다. 여기에 “use server”;라고 작성해주면 이게 server actions이 됩니다. 그리고 이를 form에 action을 통해 Server Actions을 호출할 수 있습니다.

export default function Home() {
  async function addTodo(data: FormData) {
    "use server";
    const todo = data.get("todo") as string;
    todos.push(todo);
    revalidatePath("/");
  }

  return (
    <main className="p-5">
      <h1 className="text-4xl font-bold mb-4">Todos</h1>
      <ul className="mb-4">
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
      <form action={addTodo}>
        <input
          type="text"
          name="todo"
          className="h-[48px] border-[1px] border-gray-40 rounded-[4px] px-[16px] outline-none placeholder:text-gray-60 placeholder:text-[14px]"
        />
        <button
          type="submit"
          className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3"
        >
          Add Todo
        </button>
      </form>
    </main>
  );
}

참고로 revalidatePath("/"); 를 사용해야 새로고침하지 않아도 데이터 업데이트를 바로 확인할 수 있습니다.

✅ “revalidatePath를 사용하면 특정 경로와 연결된 데이터를 다시 확인할 수 있습니다. 이 기능은 재검증 기간이 만료될 때까지 기다리지 않고 캐시된 데이터를 업데이트하려는 시나리오에 유용합니다.” - 참고

아. 그리고 실행 전에 next.config.js에서 실험적인 기능 사용 설정하는거 있지 마시고요. 실행 해보면 잘 추가되는 것을 볼 수 있습니다.

요청 내용을 보니 POST 요청을 한 거 같고, Payload를 보니 Action_id랑 todo에 대한 정보가 있네요.

여기서 신기한 점은 다른 브라우저에서 해당 페이지를 열어도 그대로 데이터가 남아있는 것을 볼 수 있습니다. 이는 서버 메모리에 해당 할 일 목록이 저장되므로 그렇습니다.

파일을 사용해서 저장하고 불러오기

영상에는 없지만 Todo 내용이 사라지지 않도록 파일을 사용해서 저장하고 불러오는 것을 해볼까요? 흠. 잘 되네요. 재밌군요. 이처럼 함수 내에서 직접 서버 작업을 할 수 있는게 바로 Server Actions의 최대 장점인거 같습니다.

import { revalidatePath } from "next/cache";
import fs from "fs/promises";

export default async function Home() {
  const todos = await fs
    .readFile("public/todos.txt", "utf-8")
    .then((data) => data.split("\n"));

  async function addTodo(data: FormData) {
    "use server";

    const todo = ("\n" + data.get("todo")) as string;
    await fs.writeFile("public/todos.txt", todo, { flag: "a" });

    revalidatePath("/");
  }

  return (
    <main className="p-5">
      <h1 className="text-4xl font-bold mb-4">Todos</h1>
      <ul className="mb-4">
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
      <form action={addTodo}>
        <input
          type="text"
          name="todo"
          className="h-[48px] border-[1px] border-gray-40 rounded-[4px] px-[16px] outline-none placeholder:text-gray-60 placeholder:text-[14px]"
        />
        <button
          type="submit"
          className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3"
        >
          Add Todo
        </button>
      </form>
    </main>
  );
}

React Without JavaScript

위 내용은 자바스크립트 사용없이 실행된다는 것을 보여드리기 위해 크롬에서 JavaScript를 비활성화 시킬 수 있습니다. command + shift + p를 누른 다음 ‘Disalbe JavaScript’를 하면 됩니다.

오. 이건 처음 알게 된 사실인데 꽤 유용해 보입니다. 아무튼 자바스크립트를 비활성화하고 Add Todo를 해보면 그래도 여전히 작동합니다. 아니면 noscript를 직접 추가해주고 실험해도 동일한 결과를 볼 수 있습니다.

결국 Server Actions은 서버에서 실행되기 때문에 클라이언트 JS 코드는 전혀 영향을 주지 않는다는 것을 알 수 있습니다.

Monitoring The Form Post - useFormStatus

데이터가 저장되는데 걸리는 시간을 가정해보면서 임의로 3초정도 지연을 시켜보겠습니다. 그리고 이때 버튼을 연속해서 클릭하는 것을 방지해보도록 하겠습니다. 이것은 일부 자바스크립트를 사용해야 한다는 것을 의미합니다.

export default function Home() {
  async function addTodo(data: FormData) {
    "use server";

    await new Promise((resolve) => setTimeout(resolve, 3000));

    const todo = data.get("todo") as string;
    todos.push(todo);
    revalidatePath("/");
  }

useFormStatus라는 실험적인 hook을 사용해서 pending을 받아와서 사용할 수 있습니다.

"use client";

import { experimental_useFormStatus as useFormStatus } from "react-dom";

export default function AddButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3 disabled:bg-[#d1d5db]"
      disabled={pending}
    >
      Add Todo
    </button>
  );
}

Using Transitions With Server Action

Server Action을 호출하는 방법으로는 form action prop 말고도 단순히 아래처럼 사용하는 방법도 있습니다. 오우...

"use client";
import { useRef } from "react";

export default function AddButton({
  addTodo,
}: {
  addTodo: (todo: string) => Promise<void>;
}) {
  const todoRef = useRef<HTMLInputElement>(null);

  return (
    <div>
      <input
        ref={todoRef}
        type="text"
        name="todo"
        className="h-[48px] border-[1px] border-gray-40 rounded-[4px] px-[16px] outline-none placeholder:text-gray-60 placeholder:text-[14px]"
      />
      <button
        className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3 disabled:bg-[#d1d5db]"
        onClick={async () => {
          await addTodo(todoRef.current!.value);
        }}
      >
        Add Todo
      </button>
    </div>
  );
}

그리고 마찬가지로 pending 상태일때 disable 되도록 하려면 useTransition을 사용할 수 있습니다. 참고로 useTransition은 React 18에서 도입된 새로운 훅입니다.

“useTransition은 UI를 차단하지 않고 상태를 업데이트할 수 있는 React Hook입니다.” - 참고

  • isPending : pending transition이 있는지 여부를 알려주는 플래그입니다. pending 중이면 true, 아니면 false를 반환합니다.
  • startTransition : 상태 업데이트를 transition으로 표시할 수 있게 해줍니다. 여기다가 함수를 넣어주면 그 함수가 실행되는 동안은 pending 상태인 것입니다. 우선순위가 낮기 때문에 다른 state 업데이트에 의해 중단될 수 있습니다.

즉, useTransition를 이용해서 isPending 여부를 판단할 수 있고, 다른 것보다 우선순위가 낮기 때문에 급한 작업이 있으면 먼저 처리할 수 있으므로 효율적이겠네요.

export default function AddButton({
  addTodo,
}: {
  addTodo: (todo: string) => Promise<void>;
}) {
  let [isPending, startTransition] = useTransition();

  const todoRef = useRef<HTMLInputElement>(null);

  return (
    <div>
      <input
        ref={todoRef}
        type="text"
        name="todo"
        className="h-[48px] border-[1px] border-gray-40 rounded-[4px] px-[16px] outline-none placeholder:text-gray-60 placeholder:text-[14px]"
      />
      <button
        disabled={isPending}
        className="bg-[#00CCAA] text-white rounded-[6px] px-4 py-3 disabled:bg-[#d1d5db]"
        onClick={async () => {
          startTransition(async () => {
            await addTodo(todoRef.current!.value);
          });
        }}
      >
        Add Todo
      </button>
    </div>
  );
}

마치면서

Server Actions를 바라보는 사람들 반응은 어떨지 댓글을 한번 살펴보니 다음과 같았습니다.

  • “최근 몇 달 동안 NextJS(그리고 JS 생태계 전체)가 엄청난 발전을 이룬 것 같고, 학습 속도를 따라갈 수가 없습니다. 나는 TS와 NextJS에 대한 이해도가 높은 개발자라고 생각하곤 했다. 새로운 개발에 대해 이해하고 흥분했음에도 불구하고, 저는 매일 점점 더 적게 아는 것처럼 다시 주니어 개발자가 된 기분입니다.”
  • “15년 이상 웹 개발자로서 앱을 구축하는 가장 좋은 방법은 분리된 API를 먼저 구축하는 것이라고 확신한다. 프론트 엔드 또는 백엔드를 독립적으로 교환할 수 있습니다.”
  • “서버 작업은 알파 단계이므로 생산에 적합하지 않으며, 현재 작업을 폐쇄로 전달할 경우 발생하는 보안 취약성이 있습니다. 이와 같은 것:”
  • “거짓말을 하지 않을 거예요. 서버 코드가 사용자/클라이언트 상호 작용에 반응하여 실행되는 것을 보는 것은 일종의 마법과도 같습니다. 이것은 당신의 머리를 감싸기 어려워지는 유형이고 나는 다음 팀이 좋은 문서와 일관된 패턴으로 그것을 둘러싼 혼란을 제거할 수 있기를 바란다.”

아무래도 우려 섞인 목소리와 회의적인 반응이 꽤 있는거 같습니다. 어느 순간 CSR 방식이 확 뜨더니 요즘에는 SSR로 다시 돌아가는 모습이고, 최근에는 PHP와 유사하다는 의견도 많이 보이는거 같습니다. 유행은 돌고돌아 다시 제자리로...?

아무튼 좀 더 지켜봐야 되겠지만 저도 사실 무엇이 정답인지 잘 모르겠네요... 다음에는 시야를 좀 넓혀서 Next.js 말고 다른 프레임워크 Remix나 Sveltekit 혹은 Qwik 을 사용해보면서 비교해보는 것도 좋을 거 같다는 생각이 듭니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글