Post Detail Page

tech

Server Actions x Prisma x zod - 회원가입 구현하기

Client Components

server-actions-prisma-image
회원가입

시안을 보면 사용자의 입력 값에 따른 에러를 표시하고, 데이터를 전송할 때 상태에 따라 변하는 컴포넌트들은 사용자와 상호작용하는 클라이언트 컴포넌트다.

nextjs 13은 일반적으로 app 디렉터리 내의 컴포넌트는 서버 컴포넌트로 간주되는데 이처럼 클라이언트 컴포넌트로 만들 경우 'use client'를 최상단에 작성하여 해당 파일을 클라이언트 측에서 렌더링 되도록 처리해야 한다.

🚨 클라이언트 컴포넌트를 이해하지 못한 에러

🔗 NEXT 공식문서

컴포넌트의 각 입력 값의 유효성 검사 에러를 받아오려면 useFormState() 훅을 적용해야 하는데, 서버 컴포넌트는 리액트 훅을 사용할 수 없고 사용자와 상호작용하는 컴포넌트인 클라이언트 컴포넌트로 변경해야 한다.

작업 중간에 이를 인지하고 'use client'를 선언하게 되었는데 이로 인해 서버 환경에서 실행되어야 하는 함수와 충돌하는 에러가 발생했다.

☹︎ Failed to compile

클라이언트 구성 요소에서 서버 작업 주석이 달린 인라인 "use server"을 정의하는 것은 허용되지 않습니다. 클라이언트 구성 요소에서 서버 작업을 사용하려면 상단에 "use server"이 있는 별도의 파일에서 이를 내보내거나 서버 구성 요소의 props를 통해 전달할 수 있습니다.

📂 디렉터리 구조 변경

|-- create-account/
|   |-- actions.ts  // 서버에서 실행되는 함수 분리
|   |-- page.tsx    // 회원가입 페이지 컴포넌트
|   |-- schema.ts   // zod schema
|
|-- login/
|   |-- actions.ts  // 서버에서 실행되는 함수 분리
|   |-- page.tsx    // 로그인 페이지 컴포넌트
|   |-- schema.ts   // zod schema
|
|-- components     // 재사용 가능한 UI 컴포넌트들
|-- lib            // 유틸리티 함수 또는 라이브러리
|-- prisma         // ORM 관련 파일
|-- public         // 이미지 모음

📝 actions.ts
서버에서 실행되는 함수들을 관리하는 모듈

  • 입력 값의 유효성 검사
    사용자가 입력한 값들을 zod 라이브러리를 사용하여 유효성 검사
  • DB 중복 검사
    사용자가 입력한 값들과 데이터베이스의 데이터를 비교하여 중복된 값이 있는지 확인
  • 비밀번호 해싱
    사용자가 제출한 비밀번호를 안전하게 해싱하여 저장
  • DB 데이터 생성
    사용자 정보를 데이터베이스에 저장하고 새로운 사용자 생성
  • 쿠키 저장
    로그인 상태 유지를 위해 사용자 정보를 쿠키에 저장

Prisma

Node.js, TypeScript ORM으로 Prisma schema로 선언적인 모델을 정의해서 복잡한 모델 인스턴스를 관리하는데 안전하게 데이터를 읽고 쓸 수 있다.

  • 스키마를 통해 데이터베이스를 간단히 조작할 수 있다.
  • 직접 SQL문을 작성하지 않고 데이터베이스를 쉽게 다룰 수 있도록 돕기 때문에 개발 생산성을 높여 준다.
  • 선언적인 구문을 사용하여 개발자가 더 쉽게 데이터베이스를 조작할 수 있도록 돕는다.
  • SQL 쿼리 작성의 복잡성을 줄이고, 안전한 쿼리 작성을 보장할 수 있다.
  • Prisma Client / Prisma Studio 제공

📌 User model 생성하기

💡 처음에는 email, password 모두 required 설정했는데 이후 소셜 로그인 시 해당 값들이 필요 없다는 것을 인지하고 옵셔널한 값으로 수정했다.

일반 로그인과 소셜 로그인의 공통적으로 충족되는 값을 먼저 생각하고, 각 조건별로 받아오는 방법과 저장하는 과정을 고려하는 부분이 부족했다. 고려할 항목들을 다시 작성하고 대입하여 모델을 개선해보자.

  • 필수 조건과 선택 조건을 고려해서 분류하기
  • 각 조건들의 값은 사용자에게서 받아올 수 있는 값인지 확인하기
  • 기본으로 제공되어야 하는 값은 무엇인지 확인하기(아바타 이미지)

📌 사용자 입력값

  • 로그인 : email, password
  • 회원가입: nickname, email, password
  • 깃허브 로그인: 깃허브 프로필(id, avatar_url, login)

📂 prisma > schema.prisma

model User {
  id         Int      @id @default(autoincrement())
  username   String   @unique
  email      String?  @unique
  password   String?
  github_id  Int?
  avatar     String?  @default("https//CDN 경로")
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
}
  • id: 사용자 고유 번호(자동으로 증가하는 정수)
  • username: 필수/ 중복 값 ❌
  • email: 이메일 로그인 및 회원가입에 필수/중복 값 ❌/ 깃허브 로그인 시 ❌
  • password: 이메일 로그인 및 회원가입에 필수/ 깃허브 로그인 시 ❌
  • github_id: 깃허브 로그인 사용자에 대해 필수/ 일반 로그인 시 ❌
  • avatar: 기본 프로필 이미지 URL 제공으로 선택 조건입니다
  • created_at: 사용자 계정 생성 시간(자동으로 현재 시간 설정)
  • updated_at: 사용자 계정 정보가 마지막으로 수정된 시간

회원가입 - 입력 값 받아오기

💡 앞선 에러를 해결하기 위해 actions.ts, page.tsx 두 파일로 분리 후 클라이언트 컴포넌트에 서버 액션을 import하여 form에 전달해 주는 방법으로 적용했다.

📂 create-account > actions.ts

'use server';

export default function createAccount(prevState: any, formData: FormData) {
  const data = {
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
    confirm_password: formData.get('confirm_password'),
  };

  console.log('data', data);
}

get() 메서드를 통해 입력 값을 가져올 수 있다.

📌 주의할 점
입력 필드의 name 속성get() 메서드의 인자를 동일하게 작성해야 한다.
input의 name 속성이 없을 경우 데이터가 들어오지 않는다 ❌❌❌

📂 create-account > page.tsx

'use client';

import createAccount from './actions';
import { useFormState } from 'react-dom';

export default function Signup() {
  const [state, dispatch] = useFormState(createAccount, null);

  return (
    <main className='flex h-screen items-center justify-center'>
      <div className='shadow-content-shadow flex w-[464px] flex-col items-center rounded-[30px] bg-white px-8 py-[60px]'>
        <h1 className='mb-6 w-full text-2xl font-bold'>Signup</h1>
        <form action={dispatch} className='flex w-full flex-col gap-6'>
          <InputField
            label='Nickname'
            name='username'
            type='text'
            errors={[]}
            placeholder='Nickname'
            maxLength={10}
            required
          />
          // ...
        </form>
      </div>
    </main>
  );
}

🌟 input의 name 속성과 form 태그의 action을 이용하면 폼 데이터를 서버로 제출하기 위해 onSubmit, onChange 이벤트를 사용하지 않고도 폼을 제출할 수 있다.

zod

타입스크립트 코드에서 타입 에러는 컴파일 과정에서만 발생할 수 있다. 따라서 zod 와 같은 라이브러리의 도움을 받아서 예측 불가능한 런타임 환경에서도 타입 검사(유효성 검증)를 가능하게 한다.

1️⃣ 회원가입 유효성 검사

📂 create-account > schema.ts

import { z } from 'zod';

const formDataSchema = z
  .object({
    username: z.string().min(MINIMUM_USERNAME_LENGTH, username.minLength).toLowerCase(),
    email: z.string().email().toLowerCase(),
    password: z.string().regex(PASSWORD_REGEX, password.invalid),
    confirm_password: z.string(),
  })
  .refine(checkPassword, {
    message: confirm_password.invalid,
    path: ['confirm_password'],
  });

export default formDataSchema;
  • username : 4자리 이상 / DB 중복 여부 확인
  • email : DB 중복 여부 확인
  • password : 8자리 이상 / 대소문자, 숫자, 특수문자 포함하는 정규식 검사
  • confirm_password : 비밀번호 일치 여부 확인

📂 create-account > actions.ts

'use server';

import formDataSchema from './schema'; // zod 스키마 파일

export async function createAccount(prevState: any, formData: FormData) {
  const data = {
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
    confirm_password: formData.get('confirm_password'),
  };

  const result = formDataSchema.safeParse(data);
}

💡 safeParse
UI에 강제로 에러를 던지지 않고 try catch문을 사용하지 않아도 되기 때문에 parse가 아닌 safeParse로 데이터 파싱


2️⃣ 에러 메시지 props 전달하기

if (!result.success) {
  return result.error.flatten();
}

☻ flatten
내가 필요한 부분은 필드에 대한 에러 메시지이므로, 각 필드에 대한 에러 메시지를 단순화하고 구조화된 형태로 반환하는 flatten() 메서드를 사용했다.

📂 create-account > page.tsx

💡 각 input의 에러 props로 state?.fieldErrors.('fieldName')을 넘겨주면 특정 필드에 대한 에러를 가져올 수 있다.

'use client';

import createAccount from './actions';
import { useFormState } from 'react-dom';

export default function Signup() {
  const [state, dispatch] = useFormState(createAccount, null);

  return (
    <main className='flex h-screen items-center justify-center'>
      <div className='shadow-content-shadow flex w-[464px] flex-col items-center rounded-[30px] bg-white px-8 py-[60px]'>
        <h1 className='mb-6 w-full text-2xl font-bold'>Signup</h1>
        <form action={dispatch} className='flex w-full flex-col gap-6'>
          <InputField
            label='Nickname'
            name='username'
            type='text'
            errors={state?.fieldErrors.username}  // error 추가
            placeholder='Nickname'
            maxLength={10}
            required
          />
          // ...
        </form>
      </div>
    </main>
  );
}

비밀번호 해싱 및 데이터베이스 저장하기

bcrypt

npm i bcrypt
npm i @types/bcrypt
const hashedPassword = await bcrypt.hash(result.data.password, 12);
  • 단방향 해시: 해시 알고리즘은 단방향으로 이루어지기 때문에 복호화가 불가능하다.
  • result.data.password: 사용자로부터 입력받은 비밀번호
  • 12: 해싱 강도를 나타내는 솔트 라운드의 수/ 숫자가 클수록 해싱 과정이 느려지지만, 보안성이 높아진다.
  • bcrypt.hash: 비밀번호를 해싱하는 함수로 솔트가 자동으로 생성되고, 해싱된 비밀번호가 반환된다.

📂 create-account > actions.ts

import bcrypt from 'bcrypt';

const hashedPassword = await bcrypt.hash(result.data.password, 12);

const user = await db.user.create({
  data: {
    username: result.data.username,
    email: result.data.email,
    password: hashedPassword,
  },
});

데이터베이스 중복 검사

💡 새로운 계정으로 회원가입 시 이미 등록된 username, email을 작성하면 에러가 발생하기 때문에 추가로 DB 중복검사를 진행해야 한다.

📂 create-account > schema.ts

const checkUsername = async (username: string) => {
  const exists = await db.user.findUnique({
    where: {
      username,
    },
    select: {
      id: true,
    },
  });

  return !Boolean(exists);
};

const checkEmail = async (email: string) => {
  const exists = await db.user.findUnique({
    where: {
      email,
    },
    select: {
      id: true,
    },
  });

  return !Boolean(exists);
};

// 생략...
const result = await fromSchema.safeParseAsync(data); // await 반드시 추가!

☻ safeParseAsync
데이터베이스 조회와 같이 비동기적인 작업이 필요한 경우에는 기존의 동기적인 유효성 검사로는 해결할 수 없기 때문에 safeParseAsync를 사용하여 비동기 함수를 Zod 스키마에 적용한다.

superRefine

.superRefine((우리가 비교할 데이터, ctx) => {})

zod 스키마에 중복 검사 로직을 추가하면 하나의 유효성 검사를 위해 데이터베이스에 2번의 요청이 발생한다.
이를 개선하기 위한 방법으로 superRefine을 사용하여 각 유효성 검사가 순차적으로 진행되도록 할 수 있다.

  .superRefine(async ({ username }, ctx) => {
    const user = await db.user.findUnique({
      where: {
        username,
      },
      select: {
        id: true,
      },
    });
    if (user) {
      ctx.addIssue({
        code: 'custom',
        message: 'This username is taken',  // 중복된 username 에러 알림
      });
    }
  });
server-actions-prisma-image
formErrors | username 중복 에러

위와 같이 작성하면 에러가 form으로 나타나는데, Zod는 어떤 필드가 이 에러를 발생시켰는지 모르기 때문에 일반적인 formError로 표시된다. 따라서 특정 필드의 에러로 나타나게 하려면 path를 지정해야 한다.

📌 필드 지정 에러 처리

if (user) {
  ctx.addIssue({
    code: 'custom',
    message: 'This username is taken',
    path: ['username'], // username path 추가
  });
}
server-actions-prisma-image
fieldErrors | username 중복 에러

📌 추가 유효성 검사 방지

검사 실패 이후 다른 유효성 검사가 실행되지 않도록 fatal: true, z.NEVER 조건을 추가해준다.

const user = await db.user.findUnique({
  where: {
    username,
  },
  select: {
    id: true,
  },
});
if (user) {
  ctx.addIssue({
    code: 'custom',
    message: 'This username is taken',
    path: ['username'],
    fatal: true, // 추가
  });
  return z.NEVER; // 추가
}

이렇게 하면 중복된 username이 확인되면 다른 유효성 검사는 실행되지 않기 때문에 데이터베이스 요청 수를 줄일 수 있게 된다.