blegram(1) - 회원가입 구현 ( Prisma )
포스트
취소

blegram(1) - 회원가입 구현 ( Prisma )

해당 프로젝트는 Next.js + TypeScript를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.

📤 FE

0️⃣ react-hook-form

회원가입만이 아니라 해당 프로젝트에서 사용하는 form을 관리하기 위해서 선택한 라이브러리입니다.

form에서 사용할 타입만 정의하고 정해진 방식대로 속성만 넣어준다면, form에 대한 데이터 관리, 감시, 유효성 검사 등의 많은 기능들을 쉽게 사용하도록 도와주기 때문에 유용하게 사용했습니다.
해당 포스트에서는 어떻게 활용했는지에 대해서만 작성하고 어떻게 사용하는지에 대해서 궁금하다면 공식 문서를 참고해주세요!

1. 설치

1
npm i react-hook-form

2. 구현한 코드

구현에 사용한 모든 코드를 작성하기에는 너무 많아서 상세한 코드는 깃헙 링크로 대체하겠습니다.

대부분의 기능은 react-hook-form에서 제공해주는 기능을 그대로 사용했고, 이번에 사용할 형태에 따라서 커스텀 컴포넌트를 제작해서 사용했습니다.

<input />을 사용할 때 자주 사용하는 기능들을 모두 묶어서 하나의 컴포넌트로 만들었습니다.

  1. label: id 속성에 맞게 라벨을 좌측 상단에 작게 생성
  2. subText: 유효성 검사를 통과하지 못하면 좌측 하단에 작게 에러 문구 생성 ( react-hook-formerror 객체 사용 )

register()를 이용하면 ref를 전달해야하기 때문에 React.forwardRef()를 사용했고, 그에 맞는 props 타입을 지정해줬습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import React from "react";

// style
import StyledInput from "./style";

// type
import type { UseFormRegister } from "react-hook-form";
import type { LogInForm, SignUpForm } from "@src/types";

interface Props {
  id: string;
  type: React.HTMLInputTypeAttribute;
  placeholder: string;
  subText?: string;
  [index: string]: any;
}

/** 2023/03/25 - Input 컴포넌트 - by 1-blue */
const Input = React.forwardRef<
  HTMLInputElement,
  Props &
    (
      | ReturnType<UseFormRegister<SignUpForm>>
      | ReturnType<UseFormRegister<LogInForm>>
    )
>(({ id, type, placeholder, subText, ...props }, ref) => (
  <StyledInput>
    <label htmlFor={id}>{id}</label>
    <input
      id={id}
      type={type}
      placeholder={placeholder}
      autoComplete="current-password"
      {...props}
      ref={ref}
    />
    <span>{subText}</span>
  </StyledInput>
));

Input.displayName = "Input";

export default Input;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useForm } from "react-hook-form";

// style
import StyledLogInForm from "./style";

// type
import type { LogInForm } from "@src/types";

/** 2023/03/24 - 로그인 페이지 - by 1-blue */
const LogInPage = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LogInForm>();

  /** 2023/03/25 - 로그인 수행 핸들러 - by 1-blue */
  const onLogIn: React.FormEventHandler<HTMLFormElement> = handleSubmit(
    useCallback(
      async (body) => {
        try {
          // ... 로그인 요청 처리
        } catch (error) {
          console.error(error);
        }
      },
      [router]
    )
  );

  return (
    <StyledLogInForm onSubmit={onLogIn}>
      {/* 나머지 생략 ... */}

      <FormToolkit.Input
        id="id"
        type="text"
        placeholder="아이디를 입력해주세요."
        subText={errors.id?.message}
        {...register("id", {
          required: "아이디를 입력해주세요!",
          pattern: {
            value: /(?=.*\d)(?=.*[a-zA-ZS]).{6,16}/,
            message: "숫자와 영어가 최소 한 글자 이상 포함되고, 최소 6자리, 최대 16자리여야 합니다!"
          },
        })}
      />
      <FormToolkit.Input
        id="password"
        type="password"
        placeholder="비밀번호를 입력해주세요."
        subText={errors.password?.message}
        {...register("password", { required: "비밀번호를 입력해주세요!" })}
      />
      
      {/* 나머지 생략 ... */}
    </StyledLogInForm>
  );
};

export default LogInPage;

📥 BE

0️⃣ prisma

데이터베이스를 쉽게 관리하기 위해 사용한 ORM 라이브러리입니다.

설치와 사용에 대한 구체적인 내용은 prisma를 참고해주시고 해당 포스트에서는 기본적인 내용을 제외하고 작성하겠습니다.

또한 아직 회원가입 부분이라 User에 대한 모델 정의만 작성할 것이라 내용이 별로 없습니다.
계속 기능을 추가하면서 다음 포스트들을 통해서 다른 모델의 정의와 모델간의 관계에 대해 작성하겠습니다.

1. 설치

1
npm i prisma @prisma/client

2. User 모델

대부분 문자열들은 해당 문자열을 입력받을 때 글자수 제한을 걸었기 때문에 그 글자수만큼의 크기만 차지하도록 만들었습니다.
password는 해싱을 통해서 길이가 늘어나기 때문에 더 많은 공간을 할당했고, avatarS3에 넣으면서 URL이 추가되고 중복되지 않게 하기 위해 시간 값을 부여할 것이기 때문에 더 많은 공간을 할당했습니다.
나머지는 회원가입에 필요한 데이터들에 대해 정의를 했습니다.
( idx는 해당 유저를 식별하는 고유의 식별자 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  idx Int @id @unique @default(autoincrement())
  id String @unique @db.VarChar(16)
  password String @db.VarChar(100)
  name String @unique @db.VarChar(20)
  email String @unique @db.VarChar(30)
  phone String @unique @db.VarChar(11)
  birthday String @db.VarChar(8)
  introduction String @db.VarChar(100)
  avatar String? @db.VarChar(160)
}

3. 타입 활용

prisma를 이용해서 모델을 정의하고 npx prisma db push or npx prisma migrate dev 같은 명령어를 이용해서 DB에 동기화하면 해당 모델에 대한 타입을 만들어서 제공해줍니다.

위의 User 모델을 예시로 만들어준 타입을 보면 아래와 같이 정의되어 있습니다.
이대로 그냥 가져와서 사용하면 되기 때문에 매우 편합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import type { User } from "@prisma/client";

/**
 * Model User
 * 
 */
export type User = {
  idx: number
  id: string
  password: string
  name: string
  email: string
  phone: string
  birthday: string
  introduction: string
  avatar: string | null
}

하지만 혹시 기존 타입에서 커스터마이징이 필요하면 새로운 타입을 아예 새로 작성하기 보다는 유틸리티 타입을 이용해서 해당 타입에 의존하는 타입으로 작성하면 좋습니다.

1
export type SimpleUser = Pick<User, "idx" | "name" | "avatar">;

🕯️ 회원가입 로직

회원가입 로직은 다른 것들에 비해서 단순합니다.
회원가입할 유저의 정보를 받아서 중복 불가능한 정보는 DB에서 확인 후 중복되면 중복되었다면 409로 응답합니다.
만약 중복되지 않았다면 유저를 생성하고 201로 응답합니다.

prisma를 이용해서 중복 여부를 확인하고, 동시에 처리해도 되기 때문에 Promise.all()을 이용해서 병렬적으로 처리해서 시간을 최소화했습니다.

생각해보면 특정 데이터에 대한 중복을 확인하는 것이라 한 번의 확인으로 체크하면 반복을 줄일 수 있을 것 같은데 정확한 방법을 못 찾아서 일단 각각 탐색하면서 중복된 데이터가 찾도록 만들었습니다… 🥲 나중에 좋은 방법을 찾으면 다시 기록하도록 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import bcrypt from "bcrypt";

interface HashingHandler {
  (password: string, saltRound?: number): Promise<string>;
}
/** 2023/03/25 - 비밀번호 암호화 - by 1-blue */
export const hashing: HashingHandler = async (password, saltRound = 10) => {
  // 해싱할 때 사용할 값 생성
  const salt = await bcrypt.genSalt(saltRound);

  // 비밀번호 해싱
  const hashedPassword = await bcrypt.hash(password, salt); // password 해쉬화 완성

  // 해싱한 비밀번호 반환
  return hashedPassword;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// prisma
import { prisma } from "@src/prisma";

// lib
import withAuthMiddleware from "@src/lib/middleware";
import { hashing } from "@src/lib/auth";

// type
import type { NextApiHandler } from "next";
import type { SignUpForm } from "@src/types";
import type { ApiSignUpResponse } from "@src/types/api";
type SignUpBody = SignUpForm;

/** 2023/03/26 - 회원가입 - by 1-blue */
const handler: NextApiHandler<ApiSignUpResponse> = async (req, res) => {
  try {
    // 회원가입
    if (req.method === "POST") {
      // 회원가입하는 유저의 데이터
      const { password, ...body } = req.body as SignUpBody;

      // 아이디, 이름, 이메일, 휴대폰 번호 중복 검사 ( DB )
      const exUserList = await Promise.all([
        prisma.user.findUnique({ where: { id: body.id } }),
        prisma.user.findUnique({ where: { name: body.name } }),
        prisma.user.findUnique({ where: { email: body.email } }),
        prisma.user.findUnique({ where: { phone: body.phone } }),
      ]);

      // 아이디, 이름, 이메일, 휴대폰 번호 중복 검사 ( 응답 )
      if (exUserList[0])
        return res.status(409).json({ message: "아이디가 이미 존재합니다." });
      if (exUserList[1])
        return res.status(409).json({ message: "이름이 이미 존재합니다." });
      if (exUserList[2])
        return res.status(409).json({ message: "이메일이 이미 존재합니다." });
      if (exUserList[3])
        return res.status(409).json({ message: "폰번호가 이미 존재합니다." });

      // 비밀번호 암호화
      const hashedPassword = await hashing(password);

      // 유저 생성
      await prisma.user.create({ data: { ...body, password: hashedPassword } });

      return res.status(201).json({
        message: "회원가입을 성공했습니다.\n로그인 페이지로 이동됩니다.",
      });
    }
  } catch (error) {
    console.error("/api/signup error >> ", error);

    return res
      .status(500)
      .json({ message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!" });
  }
};

export default withAuthMiddleware({
  methods: ["POST"],
  handler,
  isAuth: false,
});

📮 레퍼런스

  1. react-hook-form
  2. prisma

  3. 1-blue - prisma
  4. 1-blue - Promise.all()
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.