[Next.js] Server Action 사용하기

thru·2024년 7월 7일
3

Server ⨉ Action


Server Action

서버액션은 서버에서 실행되는 비동기 함수로 클라이언트 컴포넌트에서 접근할 수 있는 특징을 가진다. 기존 Route handler(API route)를 일부 대체한다고 볼 수 있는데, 보통 form에서 제출을 처리하는 용도로 사용한다. 서버액션은 App Router와 함께 도입된 이후, 14 버전에 stable이 되었다. Next.js 14 업데이트 블로그에서 소개에 꽤 긴 비중을 차지하는 것을 보면 Next.js에서 중요한 기능이라는 걸 추측할 수 있다.

이점

점진적 향상

Next.js의 공식 문서에서는 서버액션의 장점으로 Progressive Enhancement를 언급한다. 번역하면 점진적 향상인데 이는 유저의 실행 환경에 따른 서비스 제공 수준을 점진적으로 최적에 맞추는 것을 의미한다. 느린 네트워크나 구형 브라우저를 사용하는 유저에게도 필수적인 요소와 서비스는 노출하고, 빠른 네트워크와 최신 브라우저를 사용하는 유저에겐 모든 서비스를 제공하는 것이 바람직하다.

서버액션에서 점진적 향상을 달성하는 방법은 HMTL form의 action 속성과 연관되어 있다. 기존 form의 action 속성은 URL만 설정이 가능한데, 메서드에 따라 request body나 query string으로 폼 아이템의 키-값을 해당 URL에 전달한다.

React의 <form />action에 함수도 지정할 수 있도록 canary 버전에서 기능이 추가되었다. 일반 함수가 지정되었다면 폼 제출 시 함수를 실행한다. use server로 지정된 서버액션이 지정되었다면 내부적으로 생성된 참조값을 서버에 보내 함수를 실행한다. 이는 서버액션이 사용될 때는 함수에 대한 참조값을 가진 객체이기 때문에 가능하다.

서버에서 서버액션을 지정하여 렌더링된 form의 HTML을 확인해보면 action 속성은 비어있고 hidden input이 추가되어있는 것을 볼 수 있다.

<form action="" encType="multipart/form-data" method="POST">
   <input type="hidden" name="$ACTION_REF_1"/>
   <input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;65639<!-- 생략 -->}"/>
   <input type="hidden" name="$ACTION_1:1" value="[{&quot;status&quot;:null}]"/>
   <input type="hidden" name="$ACTION_KEY" value="<!-- 생략 -->"/>

폼을 제출해보면 현재 페이지의 URL로 POST 요청이 간다. action 속성이 비어있기 때문이다. 대신 요청 헤더에 서버액션 id가 포함되어 있다.

서버액션이 초기 HTML에 포함되어 있으므로 클라이언트에서 자바스크립트 로딩이 완료되지 않은 상태여도 폼 제출이 가능하다. 때문에 열악한 네트워크 환경에 있는 유저들도 폼 제출이라는 기본적인 기능은 큰 기다림 없이 사용할 수 있다.

폼을 회원가입이나 글 작성같은 경우에만 사용한다고 생각하면 어쩌피 제출 늦게 하니까 큰 문제 아니라고 생각할 수도 있다. 하지만 폼은 찜 버튼같이 빠르게 수행되는 인터렉션에도 사용되므로 UX에 큰 영향을 줄 수 있다.

서버에서 실행

서버액션은 근본적으로 서버에 존재하는 코드이다. 따라서 서버 컴포넌트가 가지는 이점을 동일하게 가진다.

먼저 보안에 강점이 있다. 폼 제출의 경우 유저 토큰이나 비밀번호처럼 민감한 정보가 포함되는 경우가 많은데, 서버액션은 서버에서 실행되므로 브라우저를 통해 유출될 걱정은 하지 않아도 된다.

다음으로 클라이언트 번들 크기를 줄일 가능성이 생긴다. 원래 클라이언트에서 사용하던 라이브러리를 서버액션으로 옮길 수 있다면 클라이언트 번들에서 제거할 수 있다. 예시로 zod를 들 수 있다.

zod는 런타임에서 타입 검증을 돕는 툴이다. 보통 폼을 제출하기 전에 입력값을 검증하고 에러메세지를 연결하는 용도로 쓰인다. Next.js같은 풀스택 환경에서는 클라이언트에서 검증을 마쳤더라도 예상치 못한 경우를 대비해 서버에서도 검증을 진행한다. 그런데 아예 검증은 서버에서만 하고 useActionState로 에러메세지를 전달하는 방향으로도 구현이 가능하다. 이 경우 검증 및 에러메세지 확인에 네트워크 통신이 필요하다는 것이 단점이지만, 클라이언트 번들 크기가 줄어 로딩 시간과 트래픽을 개선할 수 있다는 것이 장점이므로 상황에 따라 선택할 수 있다.

API 연결 과정 삭제

API단과 프론트엔드의 개발환경이 통합되어 더 편리한 DX을 경험할 수 있다. API를 URL로 따로 생성할 필요가 없을 뿐더러, IDE에서의 간편 소스 이동 및 타입 추론이 작동한다. 기존 URL 형식의 API는 프론트엔드와 직접적인 연결이 아닌 네트워크로 연결되므로 API의 반환 타입이 변경되면 직접 사용 코드를 찾아서 수정해줘야 했다. 서버액션은 직접 연결되므로 에러를 통해 확인할 수 있다.

Next.js의 인프라 시스템과도 연계가 용이하다. 특히 캐시를 강조하는데, 기존에는 revalidate가 클라이언트 컴포넌트에선 사용할 수 없어서 폼을 제출할 때 Route handler를 통해야했다. 서버액션을 사용하면 좀 더 세팅이 간단하고 자연스럽게 캐시 초기화 기능을 추가할 수 있다.

설정법

서버액션을 선언하는 방법은 두 가지가 있다. 첫 번째는 함수 최상단에 use server를 작성하는 방법으로 서버 컴포넌트에서만 사용할 수 있다.

// Server Component
import Button from './Button';

function EmptyNote () {
  async function createNoteAction(formData) {
    // Server Action
    'use server';
    
    await db.notes.create(formData.get('id'));
  }

  return <Button onClick={createNoteAction}/>;
}

대신 위 코드처럼 인자로 넘겨주면 클라이언트 컴포넌트에서도 사용할 수 있다. 원래 서버에서 클라이언트 컴포넌트로 함수 인자를 넘겨주는 것은 직렬화 때문에 불가능하다. 서버액션은 인수와 반환값이 직렬화되어 객체로 변환되므로 가능하다. 다만, 인수와 반환값에 직렬화가 불가능한 값이 포함되어있으면 에러가 발생해 사용할 수 없다.

함수 뿐만 아니라 클래스로 생성된 인스턴스도 서버액션에선 직렬화가 불가능하다. 자바스크립트에서 말하는 serializable 값과 서버액션에서 사용 가능한 값은 약간 차이가 있는 것으로 보인다. 자세한 항목은 React 공식문서를 참조.

두 번째는 별개의 파일 최상단에 use server를 작성해서 action을 선언하고 export 하는 방식이다. 클라이언트 컴포넌트에서도 바로 import해서 사용할 수 있다. 이 경우 모듈 전체에 서버액션 코드만 있어야 한다.

useActionState

최신 React canary 버전에서 이름이 useActionState로 변경되었다. 과거에는 useFormState였는데, Next.js 14 버전은 이전 네이밍을 사용하고 있다.

서버액션 내부에는 form 데이터 및 DB 조작 로직과 캐시 초기화 코드를 작성하면 된다. 이때 반환값은 useActionState와의 연계를 위해 형식을 맞추면 폼 상태 관리를 편하게 할 수 있다. 앞서 말했듯 zod 검증 결과인 에러메세지를 전달해 UI에 표시할 수도 있다.

const formSchema = z
  .object({
    tag: z.nativeEnum(TAG_ID),
    content: z.string().min(30, ERROR.SHORT_CONTENT),
    images: z.array(z.object({ id: z.string() })).nullish(),
  });

export async function createPostAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const input = formSchema.safeParse({
    tag: formData.get("tag"),
    images: formData.get("images"),
    content: formData.get("content"),
  });

  if (!input.success) {
    const { fieldErrors } = input.error.flatten();
    return {
      status: "ERROR_VALIDATE",
      fieldErrors,
    };
  }

  try {
    var result = await db
      .insertInto("Post")
      .values(input.data)
      .returning("id")
      .executeTakeFirstOrThrow();
  } catch (error) {
    console.error(error);
    if (error instanceof NoResultError) {
      return {
        status: "ERROR_DATABASE",
        message: ERROR.NO_RESULT_DB,
      };
    } else {
      return {
        status: "ERROR_INTERNAL",
        error: (error as { message: string })?.message ?? "",
      };
    }
  }

  return {
    status: "SUCCESS",
    message: "포스트 생성 완료",
    resultId: result.id,
  };
}
const [state, formAction] = useFormState(createPostAction, { status: null });

/**@ 중간 생략 **/

{state.status === "ERROR_VALIDATE" && <p>state.fieldErrors?.content</p>}

위 코드는 zod.safeParse의 결과를 반환값에 포함해서 보내 에러메세지를 표시한다. 성공 시에도 결과물 ID 등을 전달할 수 있다.

위 코드에선 기타 에러 시 error 객체 대신 메세지를 보내고 있다. 이는 error 객체가 서버액션에서 직렬화 불가능한 인스턴스이기 때문이다.


Storybook

스토리북에서 폼의 인터랙션 테스트를 작성하려다가 잠시 고민했었다. API 형식으로 폼 제출을 처리한다면 MSW를 이용해 API를 모킹해서 테스트하면 된다. 그런데 서버액션에 DB 객체를 연결해서 접근하는 방식은 어떻게 모킹을 수행해야 할까? 바로 함수를 모킹하면 된다.

Mocking modules

스토리북 문서에는 모듈을 모킹하는 방법을 가이드해주고 있다. mock 전용 파일을 만들어서 fn으로 wrapping하는 방법이다. fn은 함수의 실행을 트래킹하는 Vitest의 기능으로, 메서드를 대상으로 하는 spyOn도 존재한다. 좀 더 복잡한 모킹을 원한다면 Vitest 문서를 보면 된다.

현재 예시에서 사용하고 있던 DB 객체는 클래스로 생성한 인스턴스이므로 모킹하려면 전체 메서드와 함수형 관계를 본떠야 타입 에러가 발생하지 않는다. 대신 DB 로직을 함수로 감싸 추상화해서 모킹을 간편하게 수행할 수 있다.

/**@ posts.ts **/
export type NewPostData = Omit<
  Database["Post"],
  "id" | "status" | "createdAt" | "updatedAt"
>;

export function createPost(newPostData: NewPostData) {
  return db
    .insertInto("Post")
    .values(newPostData)
    .returning("id")
    .executeTakeFirstOrThrow();
}
/**@ createFormAction 내부 **/
  try {
    var result = await createPost(newPostData);
  } catch (error) {

모킹이 작동하려면 mock 전용 파일을 원본파일과 분리해서 작성해야 한다.

/**@ posts.mock.ts **/
import { fn } from "@storybook/test";

import * as actual from "./posts";

export type NewPostData = actual.NewPostData;
export const createPost = fn(actual.createPost).mockName("createPost");

모킹에 사용할 함수를 fn으로 감싸고 mockName을 지정해준다. 모킹하지 않을 요소는 그대로 다시 export하면 된다. 이렇게 새로 생성한 Mocking module을 스토리북에서 import하고 반환값을 beforeEach 메서드 안에서 설정해 테스트 중에 모킹한 값을 사용할 수 있도록 한다.

import { createPost } from "#lib/database/posts.mock.js";

const meta = {
  title: "form/PostCreateForm",
  component: PostCreateForm,
  async beforeEach() {
    const mockResult = new Promise<{ id: string }>((resolve) => {
      resolve({ id: "postId" });
    });
    createPost.mockReturnValue(mockResult);
  },
} satisfies Meta<typeof PostCreateForm>;

개별 스토리마다 다른 값을 모킹하고 싶다면 스토리에 beforeEach를 선언한다.

export const NonSession: Story = {
  beforeEach: async () => {
    const mockAuth = new Promise<null>((resolve) => {
      resolve(null);
    });
    auth.mockReturnValue(mockAuth);
  },
  play: async ({ canvasElement, step }) => {

여기까지 설정을 완료하고 테스트를 실행하면 에러가 발생한다.

Subpath imports

에러의 이유는 실제 컴포넌트 파일 내부에서 mock module이 아니라 기존 모듈을 import하고있기 때문이다. 위 과정에서 mock module 생성 및 반환값 설정은 마쳤지만 정작 내부에서는 기존 모듈을 사용하고 있으니 전혀 모킹이 진행되지 않은 것이다. 스토리북 환경에서 실행될 때만 mock module로 import를 대체해주는 기능이 필요하다. Subpath imports가 이 역할로 권장된다.

Subpath imports는 기존 타입스크립트에서 제공하던 path alias와 비슷하면서도 분기 옵션이 추가된 node.js의 기능이다. 따라서 설정도 tsconfig가 아니라 package.json에서 수행한다.

"imports": {
    "#lib/database/posts": {
      "storybook": "./app/lib/database/posts.mock.ts",
      "default": "./app/lib/database/posts.ts"
    },
    "#auth": {
      "storybook": "./auth.mock.ts",
      "default": "./auth.ts"
    },
    "#lib/*": [
      "./app/lib/*",
      "./app/lib/*.ts",
      "./app/lib/*.tsx"
    ],
    "#ui/*": [
      "./app/ui/*",
      "./app/ui/*.ts",
      "./app/ui/*.tsx"
    ],
    "#public/*": [
      "./public/*",
      "./public/*.svg"
    ],
    "#*": [
      "./*",
      "./*.ts",
      "./*.tsx"
    ]
  }

#을 붙여 기존의 path alias와 구분하는 것이 관례라고 한다.

"#lib/database/posts"에서 지정한 객체 값의 키는 import의 실행을 주도하는 모듈의 이름이다. 스토리북 환경에서 컴포넌트 파일이 실행될 때 import를 mock module로 바꾸길 원하므로 "storybook"posts.mock.ts를 지정한다. 일반 환경에서는 기존 파일을 사용할 수 있도록 "default"엔 기존 posts.ts를 지정한다.

아래 배열로 값이 지정된 path들은 alias 만을 위한 것으로 매칭하고 싶은 타입들을 지정한다.

*.을 제외한 모든 문자를 의미한다고 한다.

Webpack

Subpath imports를 설정하고 path alias를 사용할 때처럼 자동완성을 이용하면 런타임 오류가 발생한다. 잘 살펴보면 Subpath imports의 자동완성엔 .js라는 확장자를 vscode가 붙인다. 이를 번들러가 타입스크립트 파일로 해석하지 못해서 런타임 오류가 나타난 것이다.

esm은 원래 확장자가 필수다. Next.js 기본 설정으로 tsconfig"moduleResolution": "bundler"가 지정되어 있는데, 이는 import에서 확장자가 필요없음을 IDE에 알려주는 역할을 한다. package.json에서 import를 관리하면서 이 설정이 무시된 것으로 보인다.

따로 turbopack 설정을 해둔게 아니라면 Next.js와 Storybook 모두 Webpack을 사용한다. 각자 config 파일에 웹팩 설정을 추가해서 .js 확장자를 .ts로 인식하도록 할 수 있다.

/**@ next.config.mjs **/
const nextConfig = {
  webpack: (config, options) => {
    config.resolve.extensionAlias = {
      ".js": [".ts", ".js"],
      ".jsx": [".tsx", ".jsx"],
    };
    return config;
  },
};
/**@ .storybook/main.ts **/
const config: StorybookConfig = {
  /**@ 생략 **/
  
  webpackFinal: async (config) => {
    if (config.resolve) {
      config.resolve.extensionAlias = {
        ".js": [".ts", ".js"],
        ".jsx": [".tsx", ".jsx"],
      };
    }

    return config;
  },
  
  /**@ 생략 **/

참조

profile
프론트 공부 중

0개의 댓글