본문 바로가기
next.js

ServerAction, useFormStatus, useFormState (Next.js 회원가입 구현 + use-server와 server-only의 차이)

by DevPearl 2024. 8. 15.

짧은 지식으로 인해 글의 신뢰성이 높지 않습니다. 정확한 내용은 공식문서를 참고해 주세요.

 

server action과 useFormState, useFormState를 이용한 로그인 회원가입을 구현했다. 
레퍼런스는 맨 밑에 첨부하고 간단한 설명을 덧붙였다.

코드는 강의를 보고 거의 따라한 거지만 useFormState 등의 훅에 대해 찾아보고 느낀 점과 타입스크립트로 변환하며 겪은 어려움에 대해서도 정리해 보았다.

 

목차) 

1. 서버액션, useFormState, useFormStatus의 장점

2. 코드 설명

3. 타입스크립트를 적용하며 힘들었던 점

(번외) use-server(auth.ts)와 server-only(authTools.ts)의 차이

 

1. 서버액션, useFormState의 장점

 

서버액션으로 서버컴포넌트에서 로직을 실행할 때의 장점은 민감한 로직을 감추고, 서버에서 자유롭게 데이터를 extract 하고 조작하며 validation 할 수 있다.

useFormState는 클라이언트에서 서버액션을 트리거할 수 있게 해 주고 formState를 useState로 관리할 필요 없이 바로 서버로 넘겨주고,서버에서는 FormData를 object로 받아서 manipulate 할 수 있다.

여기에 useFormStatus 훅을 이용해 form의 submit 상태를 추적해 pending상태를 알 수 있다.

 

정식 버전이 아닌 canary release고 useFormState도 예전 canary version에서 쓰던 이름이라고 한다.

https://react.dev/reference/react/useActionState#my-action-can-no-longer-read-the-submitted-form-data 

 

useActionState – React

The library for web and native user interfaces

react.dev

https://react.dev/reference/react-dom/hooks/useFormStatus

 

useFormStatus – React

The library for web and native user interfaces

react.dev

 

 

2. 코드 설명

 

SignupForm.tsx

 

회원가입 폼 부분이다. useFormState의 첫 번째 인자로 서버액션을 전달해 주고 두번째 인자는 초기 상태값을 넘겨준다.  그리고 form 태그의 action에 훅의 두 번째 리턴값인 action을 입력해 주면 form이 제출될 때 action(server action)이 시행된다.

나의 경우 action이 registerUser라는 서버 액션인데 wrappedRegisterUser로 감싸서 보내준 이유는 타입오류 때문이다.
개념적으로는 간단히 registerUser를 트리거했다고 보면 되고 감싼 이유는 목차 3번에서 설명할 예정이다.

"use client";

import { registerUser } from "@/actions/auth";
import { Input } from "@nextui-org/react";
import Link from "next/link";
import { useFormState } from "react-dom";
import Submit from "./Submit";

const initState = { message: null };

type FormState = {
  message: string | null;
};

const SignupForm = () => {
  const wrappedRegisterUser = async (state: FormState): Promise<FormState> => {
    const formData = new FormData(
      document.querySelector("form") as HTMLFormElement
    );

    return registerUser({ prevState: state, formData });
  };

  const [formState, action] = useFormState<{ message: string | null }>(
    wrappedRegisterUser,
    initState
  );
  return (
    <form
      action={action}
      className="bg-content1 border border-default-100 shadow-lg rounded-md p-3 flex flex-col gap-2 "
    >
      <h3 className="my-4">Sign up</h3>
      <Input fullWidth size="lg" placeholder="Email" name="email" required />
      <Input
        name="password"
        fullWidth
        size="lg"
        type="password"
        placeholder="Password"
        required
      />
      <Submit label={"Sign up"} />
      {formState?.message && (
        <p className="text-red-400">{formState.message}</p>
      )}
      <div>
        <Link href="/signin">{`Already have an account?`}</Link>
      </div>
    </form>
  );
};

export default SignupForm;

 

auth.ts

 

auth.ts에서는 registerUser로직이 있다.
서버에서 zod를 이용해 formValidation을 하고 formData를 추출해서 가지고 온다.

userId로 토큰을 발급받아 쿠키에 저장한다.

보안에 더 유의해야 하는 토큰 발급과 user insert로직은 'server-only'컴포넌트인 authTools.ts에 있다.

"use server";
import { cookies } from "next/headers";
import { signin, signup } from "@/utils/authTools";
import { z } from "zod";
import { redirect } from "next/navigation";
import { COOKIE_NAME } from "@/utils/constants";

const authSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

type FormState = {
  message: string | null;
};

type RegisterUserArgs = {
  prevState: FormState;
  formData: FormData;
};

export const registerUser = async ({
  prevState,
  formData,
}: RegisterUserArgs): Promise<FormState> => {
  try {
    const data = authSchema.parse({
      email: formData.get("email"),
      password: formData.get("password"),
    });

    const { token } = await signup(data);
    cookies().set(COOKIE_NAME, token);
  } catch (e) {
    if (e instanceof Error) {
      console.error(e);
      if (e.message === "db insert error") {
        return {
          message: "이미 가입된 이메일입니다.",
        };
      }
      return { message: "회원가입에 실패했습니다. 잠시 후 시도해주세요." };
    }
  }
  redirect("/dashboard");
};

 

Submit.tsx

 

useFormStatus로 pending상태를 추출해서 isLoading=isPending 설정

'use client';

import { Button, ButtonProps } from '@nextui-org/react';
import { useFormStatus } from 'react-dom';

interface SubmitProps extends ButtonProps {
  label: string;
}

const Submit: React.FC<SubmitProps> = ({ label, ...btnProps }) => {
  const { pending } = useFormStatus();

  return (
    <Button {...btnProps} type='submit' isLoading={pending}>
      {label}
    </Button>
  );
};

export default Submit;

 

authTools.ts

 

'server-only'로 클라이언트에 번들링 되지 않는 중요 로직을 두었다.

bcrypto로 비밀번호 해시 후 DB에 user Insert.
user id로 jwt토큰 발급

import 'server-only';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import connectDB from '@/db/db';
import { User } from '@/db/schema';

const SECRET = process.env.JWT_SECRET as string;

export const createTokenForUser = (userId: string) => {
  const token = jwt.sign({ id: userId }, SECRET);
  return token;
};

export const getUserFromToken = async (token: {
  name: string;
  value: string;
}) => {
  try {
    const payload = jwt.verify(token.value, SECRET) as { id: string };
    await connectDB();
    const user = User.findById(payload.id).select('id email createdAt');
    return user;
  } catch (error) {
    console.error('Error getting user from token:', error);
    throw new Error('Invalid token');
  }
};

export const signup = async ({
  email,
  password,
}: {
  email: string;
  password: string;
}) => {
  try {
    const hashedPW = await hashPW(password);
    await connectDB();
    const newUser = new User({ email, password: hashedPW });
    const user = await newUser.save();

    const token = createTokenForUser(user.id);

    return { user, token };
  } catch (error) {
    console.error(error);
    throw new Error('db insert error');
  }
};

export const hashPW = (password: string) => {
  return bcrypt.hash(password, 10);
};

export const comparePW = (password: string, hashedPW: string) => {
  return bcrypt.compare(password, hashedPW);
};

 

 

사담) 며칠 전 docs.js를 통해 코드 설명을 정리하는 방법이 있다는 걸 배웠는데 
짧게 설명하고 보니 왜 docs가 필요한지 알겠다.
회원가입 로직이야 워낙 보편적인 코드니까 이런 주석설명으로도 이해할 수 있을지 몰라도

다른 코드들은 이런 주석과 설명을 봐서는 이해를 할 수가 없겠다는 생각이 든다.

 


3. 타입스크립트를 적용하며 힘들었던 점

 

SignupForm.tsx 에서 action함수로 registerUser를 바로 쓰지 않고 wrappedRegisterUser를 쓴 이유는 typing을 위해서였다.
useFormState의 action함수 타입을 보면
매개변수로 state를 보내는 경우와 state와 payload를 보내는 경우가 있다. (아래 캡처 사진 참고)
나의 경우 registerUser에서 state와 formData를 받기 때문에 
state와 payload를 같이 보내도록 typing 했지만 타입이 맞지 않다는 오류가 났고,


구글링 해보고 타입참고(위의 캡쳐본)를 보고 state만 parameter로 보내는 wrapper 함수를 만들고 wrapper함수에서 다시 formData를 추가해 registerUser라는 진짜 action함수로 보내는 방법을 택했다.

 

헷갈렸던 부분은 typeScript를 안 쓰면 js에서는 action함수에 따로 파라미터를 넣지 않아도 자동으로 parameter로 state와 formData가 전달이 되었어서, 타입스크립트로 전환하는 부분에서 헤멧 지만, 
클릭해서 들어가면 볼 수 있는 타입힌트를 잘 참고하면 
생소한 hook의 경우도 무사히 typing을 할 수 있다는 것을 배웠다.

  const wrappedRegisterUser = async (state: FormState): Promise<FormState> => {
    const formData = new FormData(
      document.querySelector("form") as HTMLFormElement
    );

    return registerUser({ prevState: state, formData });
  };

  const [formState, action] = useFormState<{ message: string | null }>(
    wrappedRegisterUser,
    initState
  );

 

 

(번외) use-server(auth.ts)와 server-only(authTools.ts)의 차이

 

  • use-server: 서버에서 실행되는 것을 의미. 즉 js 런타임인 노드에서 실행됨
  • server-only: 클라이언트 번들에 포함되지 않으며, 클라이언트에서 렌더링 될 경우 에러를 던짐

둘 다 서버에서 실행되는 코드지만 use-server는 클라이언트에서 호출을 할 수 있고 (서버 액션 등을 호출하는 경우)
server-only는 클라이언트 번들에 포함되지 않고 아예 클라이언트에서 호출도 할 수 없는 경우라,
보안이 중요한 로직의 경우 (나의 경우 jwt토큰 생성과 시크릿키, 비밀번호 해시, DB 인서트)등은 server-only 를사용했다. 

 

레퍼런스: https://frontendmasters.com/courses/intermediate-next-js/

 

Advanced Next.js: Server Actions, Routing & Data Fetching | React 18+

Dive deeper into Next.js concepts including server actions, route protection, caching strategies, and advanced data fetching. Build a full-stack app with React 18+ features and optimized performance.

frontendmasters.com