회원가입 시스템 구현 A-Z [사용자인증2]

hoon·2023년 7월 16일
0
post-thumbnail

메모

지난 포스팅에서는 로그인과 회원가입 시스템이 왜 필요한지, 그리고 이메일 기반 인증이 어떤 것인지 알아보았다. 이번에는 실제로 이 회원가입 시스템을 어떻게 구현하는지 알아보자.

1. 클라이언트가 회원가입 정보를 제출

사용자가 웹페이지에서 이메일 주소와 비밀번호, 그리고 필요한 다른 정보들을 입력하고 '회원가입' 버튼을 누르면 이 정보들이 서버로 전송되는 단계이다.

먼저, api 폴더의 auth.js에 회원가입에 대한 api 함수를 정의하자.

// src/api/auth.js

import axiosInstance from '../axios';

export const signupUser = async (
  nickname,
  email,
  password,
  passwordConfirm
) => {
  try {
    const response = await axiosInstance.post('/auth/signup', {
      nickname,
      email,
      password,
      passwordConfirm,
    });
    return response;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

여기서 axiosInstanceaxios 라이브러리를 활용해 생성된 객체를 의미하며, 서버로 HTTP 요청을 보내기 위해 사용된다. 이 인스턴스는 특정 설정을 가질 수 있으며, 인스턴스를 사용하여 요청을 보낼 때마다 적용된다. axiosInstance를 사용하여 실제 HTTP 요청을 서버로 보내면 이메일, 비밀번호, 비밀번호 확인, 닉네임으로 회원가입을 시도하는 POST 요청을 보낸다.

회원가입에 대한 api 함수 정의가 끝났다면 사용자의 회원가입 정보를 입력 받고 서버에 전송하는 역할을 하는 SignupModal.jsx 컴포넌트에 대해서 알아보자.

// src/components/SignupModal/SignupModal.jsx

import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';

import {
  hideSignupModal,
  signupModalToLoginModal,
  signupModalToAddProfileImgModal,
} from '../../../store/modalSlice.js';
import { login } from '../../../store/authSlice.js';
import BaseModal from '../../common/BaseModal.jsx';
import WarningMsg from '../../warningMsg/WarningMsg.jsx';
import {
  validateSignupPasswordConfirm,
  validateSignupEmail,
  validateSignupNickname,
  validateSignupPassword,
} from '../../../utils/validation.js';
import { signupUser } from '../../../api/auth.js';

import {
  SignupModalContent,
  EmailInput,
  PasswordInput,
  SignupBtn,
  ConfirmPasswordInput,
  EmailLabel,
  PasswordLabel,
  ConfirmPasswordLabel,
  NicknameLabel,
  NicknameInput,
} from './SignupModalStyle';

const SignupModal = () => {
  const isSignupModalVisible = useSelector(
    state => state.modal.isSignupModalVisible
  );

  const dispatch = useDispatch();

  // 액션 디스패치하는 핸들러 함수 정의
  const handleHideSignupModal = () => {
    dispatch(hideSignupModal());
  };

  const handleSignupModalToLoginModal = () => {
    dispatch(signupModalToLoginModal());
  };

  const handleSignupModalToAddProfileImgModal = () => {
    console.log('모달전환');
    dispatch(signupModalToAddProfileImgModal());
  };

  // 각 입력창의 상태와 에러 메시지를 관리할 상태 정의
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [passwordConfirm, setPasswordConfirm] = useState('');
  const [nickname, setNickname] = useState('');

  const [emailErrors, setEmailErrors] = useState([]);
  const [passwordErrors, setPasswordErrors] = useState('');
  const [passwordConfirmErrors, setPasswordConfirmErrors] = useState('');
  const [nicknameErrors, setNicknameErrors] = useState([]);
  const [serverEmailError, setServerEmailError] = useState('');
  const [serverNicknameError, setServerNicknameError] = useState('');

  // 이메일 핸들러
  const handleChangeEmail = event => {
    const emailValidationErrors = validateSignupEmail(event.target.value);
    setEmail(event.target.value);
    setEmailErrors(
      emailValidationErrors.length > 0 ? emailValidationErrors : []
    );
    setServerEmailError('');
  };

  // 비밀번호 핸들러
  const handleChangePassword = event => {
    const passwordValidationErrors = validateSignupPassword(event.target.value);
    setPassword(event.target.value);
    setPasswordErrors(
      passwordValidationErrors.length > 0 ? passwordValidationErrors : []
    );
  };

  // 비밀번호 확인 핸들러
  const handleChangePasswordConfirm = event => {
    const passwordConfirmValidationErrors = validateSignupPasswordConfirm(
      password,
      event.target.value
    );
    setPasswordConfirm(event.target.value);
    setPasswordConfirmErrors(
      passwordConfirmValidationErrors.length > 0
        ? passwordConfirmValidationErrors
        : []
    );
  };

  // 닉네임 핸들러
  const handleChangeNickname = event => {
    const nicknameValidationErrors = validateSignupNickname(event.target.value);
    setNickname(event.target.value);
    setNicknameErrors(
      nicknameValidationErrors.length > 0 ? nicknameValidationErrors : []
    );
    setServerNicknameError('');
  };

  // 회원가입 폼 제출 이벤트 핸들러
  const handleSignupSubmit = async event => {
    event.preventDefault();

    // 모든 입력 값에 대해 유효성 검사 진행 및 검사 결과 상태에 반영
    const emailValidationErrors = validateSignupEmail(email);
    const passwordValidationErrors = validateSignupPassword(password);
    const passwordConfirmValidationErrors = validateSignupPasswordConfirm(
      password,
      passwordConfirm
    );
    const nicknameValidationErrors = validateSignupNickname(nickname);

    setEmailErrors(
      emailValidationErrors.length > 0 ? emailValidationErrors : []
    );
    setPasswordErrors(
      passwordValidationErrors.length > 0 ? passwordValidationErrors : []
    );
    setPasswordConfirmErrors(passwordConfirmValidationErrors);
    setNicknameErrors(
      nicknameValidationErrors.length > 0 ? nicknameValidationErrors : []
    );

    setPasswordConfirmErrors(
      passwordConfirmValidationErrors.length > 0
        ? passwordConfirmValidationErrors
        : ''
    );

    // 모든 입력 값이 유효하지 않으면 함수를 종료
    if (
      emailValidationErrors.length > 0 ||
      passwordValidationErrors.length > 0 ||
      passwordConfirmValidationErrors.length > 0 ||
      nicknameValidationErrors.length > 0
    ) {
      return;
    }

    // 모든 입력 값이 유효하면 서버에 회원가입 요청
    try {
      const response = await signupUser(
        nickname,
        email,
        password,
        passwordConfirm
      );
      const user = response.data.user;
      console.log('회원가입 성공', user);

      // 회원가입에 성공하면 바로 로그인 상태로 전환
      dispatch(login(user));

      // 회원가입에 성공하면 프로필 이미지 설정 모달로 전환
      handleSignupModalToAddProfileImgModal();
    } catch (errors) {
      // 서버에서 에러 메시지를 받으면 해당 메시지를 상태에 반영
      if (errors.response && errors.response.data) {
        console.log(errors.response.data);

        const serverErrorMessages = errors.response.data.errors;

        if (errors.response.status === 409) {
          serverErrorMessages.forEach(errorMessage => {
            if (errorMessage === '이미 사용 중인 이메일입니다.') {
              setServerEmailError(errorMessage);
            }
            if (errorMessage === '이미 사용 중인 닉네임입니다.') {
              setServerNicknameError(errorMessage);
            }
          });
          return;
        }
      }
    }
  };

  return (
    <BaseModal
      isVisible={isSignupModalVisible}
      onClose={handleHideSignupModal}
      onBack={handleSignupModalToLoginModal}
      title='회원가입 완료하기'
    >
      <SignupModalContent>
        <EmailLabel htmlFor='user-email'>이메일</EmailLabel>
        <EmailInput
          type='text'
          id='user-email'
          name='user-email'
          placeholder='이메일을 입력해주세요.'
          value={email}
          onChange={handleChangeEmail}
          errors={emailErrors.length > 0 ? emailErrors : serverEmailError}
        />
        <WarningMsg
          show={emailErrors.length > 0}
          messages={emailErrors}
        ></WarningMsg>
        {serverEmailError && (
          <WarningMsg show={true} messages={[serverEmailError]} />
        )}
        <PasswordLabel htmlFor='user-pw'>비밀번호</PasswordLabel>
        <PasswordInput
          type='password'
          id='user-pw'
          name='user-pw'
          placeholder='특수문자 포함 10 ~ 20자 이내로 입력해 주세요.'
          value={password}
          onChange={handleChangePassword}
          errors={passwordErrors}
        />
        <WarningMsg
          show={passwordErrors.length > 0}
          messages={passwordErrors}
        ></WarningMsg>
        <ConfirmPasswordLabel htmlFor='user-pw-check'>
          비밀번호 재확인
        </ConfirmPasswordLabel>
        <ConfirmPasswordInput
          type='password'
          id='user-pw-check'
          name='user-pw-check'
          placeholder='비밀번호를 한번 더 입력해주세요.'
          value={passwordConfirm}
          onChange={handleChangePasswordConfirm}
          errors={passwordConfirmErrors}
        />
        <WarningMsg
          show={passwordConfirmErrors.length > 0}
          messages={passwordConfirmErrors}
        ></WarningMsg>
        <NicknameLabel htmlFor='user-nickname'>닉네임</NicknameLabel>
        <NicknameInput
          type='text'
          id='user-nickname'
          name='user-nickname'
          placeholder='2 ~ 20자로 입력해 주세요.'
          value={nickname}
          onChange={handleChangeNickname}
          errors={
            nicknameErrors.length > 0 ? nicknameErrors : serverNicknameError
          }
        />
        <WarningMsg
          show={nicknameErrors.length > 0}
          messages={nicknameErrors}
        ></WarningMsg>
        {serverNicknameError && (
          <WarningMsg show={true} messages={[serverNicknameError]} />
        )}
        <SignupBtn
          type='submit'
          onClick={handleSignupSubmit}
          disabled={
            emailErrors.length > 0 ||
            passwordErrors.length > 0 ||
            passwordConfirmErrors.length > 0 ||
            nicknameErrors.length > 0
          }
        >
          가입하기
        </SignupBtn>
      </SignupModalContent>
    </BaseModal>
  );
};

export default SignupModal;

SignupModal.jsx 는 다음과 같은 단계로 클라이언트에서 서버로 회원가입 요청을 보낸다.

1-1. 입력 양식을 채워나가는 과정

먼저, 사용자는 회원가입 폼에 이메일 주소, 비밀번호, 닉네임 등의 정보를 입력한다. 각각의 입력창은 React의 useState를 이용하여 상태를 관리하며, 각 입력창에는 onChange 이벤트가 설정되어 있다. 사용자가 정보를 입력하면 이 이벤트가 발생하고, 입력값이 해당 상태에 저장된다.

이때, 각 입력 값에는 유효성 검사가 적용된다.. 예를 들어, 이메일은 특정 패턴을 가진 문자열이어야 하고, 비밀번호는 특수 문자를 포함해야 하며, 닉네임은 특정 길이를 충족해야 합니다. 유효하지 않은 값이 입력되면 에러메시지가 출력된다.

1-2. '회원가입' 버튼을 누르는 순간

사용자가 모든 정보를 입력한 후 '회원가입' 버튼을 누르면, 'handleSignupSubmit' 함수가 호출된다. 이 함수는 이벤트 객체를 받아 form의 기본 제출 이벤트를 막는다(event.preventDefault()).

그 다음, 사용자가 입력한 각 정보에 대해 다시 한 번 유효성 검사를 진행한다. 이는 사용자가 필수 입력 필드를 누락하거나, 유효하지 않은 값을 입력하였을 때를 대비한 것으로 만약 유효성 검사에서 오류가 발견되면, 함수는 종료되고 사용자에게 오류 메시지가 출력된다. 만약 모든 입력 값이 유효하다면, 사용자의 정보는 서버에 회원가입 요청(signupUser)으로 전송된다.

1-3. 서버로 회원가입 요청 보내기

회원가입 요청이 성공적으로 처리되면, 서버는 회원 정보를 데이터베이스에 저장하고, 해당 회원 정보를 포함한 응답을 클라이언트에 보낸다. 클라이언트는 이 응답을 받아 Redux의 login 액션을 dispatch하여 사용자를 로그인 상태로 전환한다.

이후 프로필 이미지 설정 모달로 전환하는 handleSignupModalToAddProfileImgModal 함수가 호출되며, 이를 통해 사용자는 바로 프로필 이미지를 설정할 수 있다.

만약 서버가 요청을 처리하는 도중 문제가 발생하면 에러 메시지를 포함한 응답을 클라이언트에 보낸다. 이를 통해클라이언트는 이 메시지를 화면에 표시하여 사용자가 문제를 인식하고 수정할 수 있다.

2. 서버에서 정보 검증 및 저장

이렇게 클라이언트가 회원가입 정보를 성공적으로 제출했다면 서버에서는 클라이언트로부터 받은 정보가 올바른지 확인해야 한다. 이메일 주소 형식이 맞는지, 비밀번호가 충분히 안전한지 등을 검증한다. 정보 검증이 끝나면 이제 사용자 정보를 데이터베이스에 저장하게 된다. 비밀번호는 단순히 텍스트 형태로 저장하지 않고, bcrypt 라이브러리를 통해서 해시 알고리즘을 사용해 암호화된 형태로 저장한다.

먼저 클라이언트의 회원가입 요청에 대한 서버의 정보검증 및 저장에 관한 코드를 살펴보자

// controllers/authController.js

const bcrypt = require("bcrypt");
const passport = require("passport");
const { User } = require("../models");

const {
  validateSignupEmail,
  validateSignupPassword,
  validateSignupNickname,
  validateSignupPasswordConfirm,
  validateLoginEmail,
  validateLoginPassword,
} = require("../validations/validation.js");

// 회원가입 처리
exports.signup = async (req, res, next) => {
  const { email, nickname, password, passwordConfirm, profileImage } = req.body;

  // 회원가입 입력 유효성 검사
  const emailErrors = validateSignupEmail(email);
  const passwordErrors = validateSignupPassword(password);
  const passwordConfirmErrors = validateSignupPasswordConfirm(
    password,
    passwordConfirm
  );
  const nicknameErrors = validateSignupNickname(nickname);

  if (
    emailErrors.length > 0 ||
    passwordErrors.length > 0 ||
    nicknameErrors.length > 0 ||
    passwordConfirmErrors.length > 0
  ) {
    return res.status(400).json({
      errors: {
        email: emailErrors,
        password: passwordErrors,
        passwordConfirm: passwordConfirmErrors,
        nickname: nicknameErrors,
      },
    });
  }

  try {
    // 이메일과 닉네임이 이미 등록되어 있는지 확인
    const exUserWithEmail = await User.findOne({ where: { email } });
    const exUserWithNickname = await User.findOne({ where: { nickname } });

    // 에러 메시지를 저장할 배열
    const errors = [];

    if (exUserWithEmail) {
      // 이미 등록된 이메일의 경우 에러 메시지 추가
      errors.push("이미 사용 중인 이메일입니다.");
    }
    if (exUserWithNickname) {
      // 이미 등록된 닉네임의 경우 에러 메시지 추가
      errors.push("이미 사용 중인 닉네임입니다.");
    }

    // 이미 등록된 이메일 또는 닉네임이 있을 경우 에러 메시지 반환
    if (errors.length > 0) {
      return res.status(409).json({ errors });
    }

    // 비밀번호를 해시 처리
    const hash = await bcrypt.hash(password, 12);
    // 새로운 사용자 생성
    const newUser = await User.create({
      email,
      nickname,
      password: hash,
      profileImage,
    });

    // Passport login
    req.login(newUser, (loginErr) => {
      if (loginErr) {
        console.error(loginErr);
        return next(loginErr);
      }

      // 세션에 사용자 정보 저장
      req.session.user = {
        id: newUser.id,
        email: newUser.email,
        nickname: newUser.nickname,
        profileImage: newUser.profileImage,
      };

      return res.status(201).json({
        message: "회원가입이 성공적으로 완료되었습니다.",
        user: req.session.user,
      });
    });
  } catch (error) {
    console.error(error);
    return next(error);
  }
};

해당 코드는 길고 복잡하게 보이지만 다음과 같은 단계를 통해서 살펴보면 다소 명확하게 이해할 수 있다.

2-1. 먼저 필요한 모듈들을 임포트 한다.

User는 Sequelize 모델로, 데이터베이스와의 상호작용에 사용된다.

const bcrypt = require("bcrypt");
const passport = require("passport");
const { User } = require("../models");

const {
  validateSignupEmail,
  validateSignupPassword,
  validateSignupNickname,
  validateSignupPasswordConfirm,
  validateLoginEmail,
  validateLoginPassword,
} = require("../validations/validation.js");

2-2. 클라이언트로부터 전송된 정보를 받아 처리한다.

exports.signup = async (req, res, next) => {
  const { email, nickname, password, passwordConfirm, profileImage } = req.body;

2-3. 클라이언트에서 받은 사용자 정보에 대한 유효성 검사를 진행한다.

const emailErrors = validateSignupEmail(email);
const passwordErrors = validateSignupPassword(password);
const passwordConfirmErrors = validateSignupPasswordConfirm(
  password,
  passwordConfirm
);
const nicknameErrors = validateSignupNickname(nickname);

각 검사 함수는 해당 입력 값이 유효한지 확인하고, 유효하지 않은 경우에는 오류 메시지를 반환한다.

2-4. 데이터베이스에 이미 존재하는 이메일이나 닉네임이 있는지 검사한다.

try {
  const exUserWithEmail = await User.findOne({ where: { email } });
  const exUserWithNickname = await User.findOne({ where: { nickname } });

User 모델의 'findOne' 메서드를 사용하여 검사하고 있다.

2-5. 비밀번호를 해싱한다.

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

해싱은 원래의 비밀번호를 복구할 수 없는 문자열로 변환하는 과정으로, 이 과정을 통해 사용자의 비밀번호가 데이터베이스에 안전하게 저장된다.

2-6. 새로운 사용자를 생성한다.

const newUser = await User.create({
  email,
  nickname,
  password: hash,
  profileImage,
});

User 모델의 'create' 메서드를 사용하여 새로운 사용자를 생성하고 있다. 이러한 단계를 거쳐 클라이언트의 회원가입 요청에 대한 사용자의 정보를 검증하고 검증을 통과하였을 경우 새로운 사용자의 정보를 저장한다.

3. 응답 전송

3-1 응답 성공시

마지막으로, 사용자 정보가 성공적으로 저장되었다는 응답을 클라이언트에게 전송한다. 이제 사용자는 로그인 할 수 있게 된다.

클라이언트에 응답을 전송하는 부분은 주로 다음과 같은 코드에서 이루어진다.

return res.status(201).json({
  message: "회원가입이 성공적으로 완료되었습니다.",
  user: req.session.user,
});

위 코드에서, res.status(201)는 HTTP 응답 코드를 설정하는 부분으로, 201 은 "Created"를 의미하며, 새로운 리소스(여기서는 새로운 사용자)가 성공적으로 생성되었음을 나타낸다.

그 다음으로, json 메소드를 통해 JSON 형식의 응답을 보낸다. 이 메소드를 사용하여 전송하는 객체는 두 개의 속성을 갖는다.

  • message: 이는 사용자에게 전달되는 메시지로, 회원가입이 성공적으로 완료되었음을 알려주는 문자열을 사용하였다.
  • user: 이는 로그인한 사용자의 정보를 담은 객체로, 이 정보는 클라이언트 측에서 사용자가 로그인 상태에 있는지를 판별하는 데 사용될 수 있다.

따라서, 이 코드를 통해 서버는 사용자에게 회원가입이 성공적으로 이루어졌음을 알리고, 이에 따른 사용자의 정보를 함께 전달함으로써 클라이언트가 로그인 상태를 유지할 수 있도록 한다. 이제 클라이언트는 이 정보를 바탕으로 사용자에게 로그인이 되었음을 알릴 수 있다.

3-2 응답 실패시

응답이 실패했을 때는 주로 두 가지 경우에 해당한다. 사용자 입력 유효성 검사에서 에러가 발생한 경우와 서버 내부에서 에러가 발생한 경우이다.

먼저, 사용자 유효성 검사에서 에러가 발생한 경우이다.

if (
    emailErrors.length > 0 ||
    passwordErrors.length > 0 ||
    nicknameErrors.length > 0 ||
    passwordConfirmErrors.length > 0
  ) {
    return res.status(400).json({
      errors: {
        email: emailErrors,
        password: passwordErrors,
        passwordConfirm: passwordConfirmErrors,
        nickname: nicknameErrors,
      },
    });
  }

위 코드에서, 각각의 입력 필드(email, password, nickname, passwordConfirm)에 대한 유효성
검사를 진행하고 그 결과를 에러 배열에 저장한다. 만약 어느 하나라도 에러가 있다면, HTTP 응답 코드 400으로 클라이언트에게 응답을 보내며, 에러 메시지를 함께 전달한다. (HTTP 상태 코드 400은 클라이언트의 요청이 잘못되었음을 나타낸다.)

두 번째 경우는 서버 내부에서 에러가 발생한 경우이다.

} catch (error) {
    console.error(error);
    return next(error);
  }

이 코드는 서버에서 에러가 발생했을 때 실행된다. 예를 들어, 데이터베이스에 연결하는 도중 문제가 발생하거나, 필요한 정보를 찾지 못했을 때 이런 에러가 발생할 수 있다. 이 경우, 에러 정보를 콘솔에 출력하고, next(error)를 통해 에러 핸들링 미들웨어로 에러를 전달한다. 이 때 사용하는 next
함수는 Express에서 제공하는 미들웨어 함수로, 이 함수를 호출하면, Express는 현재의 미들웨어를 종료하고 다음 미들웨어를 실행한다.

이 경우에는 에러를 처리하는 미들웨어로 제어를 넘기게 되며, 에러 핸들링 미들웨어에서는 적절한 HTTP
응답 코드와 함께 클라이언트에게 에러 메시지를 전달하게 된다.

4. 테스트

이제 회원가입 시스템을 모두 구현하였으므로 실제 프로젝트에서 테스트 해보도록 하자.

회원가입 모달에서 각 입력창에 입력값을 입력한다.

각 입력창 값에 대한 유효성 검사를 하고 있기 때문에 유효성 검사를 통과하지 못했을 경우 다음과 같이 동적으로 에러메시지를 출력한다.

이제 각 입력 창에 대한 유효성 검사를 모두 통과 했다면 더이상 하단에 에러메시지가 출력되지 않는다. 이제 ‘가입하기’ 버튼을 클릭해보자

하지만 데이터베이스에 ‘asdasd@naver.com' 이라는 이메일을 가진 사용자와 ‘닉네임’이라는 닉네임을 가진 사용자의 정보가 이미 존재하는 것을 알 수 있다.

이제 중복되지 않는 이메일과 닉네임을 통해서 회원가입을 해보자

회원가입이 성공적으로 완료되고 다음단계인 프로필 이미지 등록 모달이 나타난 것을 알 수 있다. 이제 데이터베이스에 새로운 사용자에 대한 정보를 찾아보자

해당 이메일과 닉네임을 가진 사용자의 정보가 성공적으로 데이터베이스에 저장된 것을 알 수 있다. 또한, 이메일 옆에 있는 값은 사용자의 비밀번호인데 사용자의 비밀번호가 문자열의 조합으로 성공적으로 암호화가 된 것을 알 수 있다.

profile
프론트엔드 학습 과정을 기록하고 있습니다.

1개의 댓글

comment-user-thumbnail
2023년 7월 17일

저도 개발자인데 같이 교류 많이 해봐요 ㅎㅎ! 서로 화이팅합시다!

답글 달기