룰루랄라 코딩기록장

[Node.js] Issue28_설계 변경 후 회원가입 기능 #1 본문

YAMPLI

[Node.js] Issue28_설계 변경 후 회원가입 기능 #1

Jeonnnng 2023. 12. 22. 12:11

요구사항

  • 닉네임 자동 생성
  • 이메일 회원가입 기능 추가
  • 이메일 인증


닉네임 자동 생성

회원 가입시 형용사 및 명사가 작성된 엑셀 데이터와 숫자를 조합하여 닉네임을 자동으로 생성시켜준다.

userService .js→ 닉네임 생성 관련 메서드

path : 상대경로를 절대경로로 만들기 위한 모듈

xlsx : 엑셀 파일을 컨트롤 하기 위한 모듈.

const xlsx = require('xlsx');
const path = require('path'); 

닉네임은 형용사와 동물이름, 0~99999 범위의 숫자 세 가지의 결합으로 만들어진다.

findNickname 메서드를 생성해서 중복된 닉네임이 생성되지 않도록 하고, 오류로 인하여 발생 될 수 있는 무한 반복을 제어하기 위하여 flag를 이용해 특정 횟수 이상 반복하게 되면 에러를 전달하도록 했다.

/**
 * 형용사 + 동물이름 닉네임 생성
 * @returns {String}
 */
const createNickname = () => {
  const ad = excelToJson(createFilePath('../data/ad.xlsx'));
  const animals = excelToJson(createFilePath('../data/animals.xlsx'));

  let nickname;
  let flag = 0;
  do {
    const adTitle = getRandomDataFromJson(ad);
    const animalTitle = getRandomDataFromJson(animals);
    const randomNum = Math.floor(Math.random() * 99999);
    nickname = `${adTitle} ${animalTitle}${randomNum}`;
    flag += 1;
    if (flag == 999999) {
      throw new CustomApiError(STRINGS.ALERT.RETRY_REQ);
    }
  } while (!findNickname(nickname)); // 중복 아닐 때 까지 반복
  return nickname;
};

왜 그런지는 모르겠지만 xlsx를 이용해서 엑셀 파일을 가져올 때 상대경로를 입력하면 경로 탐색을 못한다. 추후 로컬이 아닌 외부 서버 사용을 고려하여 상대경로를 절대경로로 변경하는 메서드를createFilePath를 생성했다.

excelToJson 메서드는 엑셀 0번째 시트의 데이터를 json 형태로 변환하여 반환한다.
만약 한 개의 엑셀 파일을 사용하려면 형용사, 명사가 입력된 두 개의 시트를 생성해서 사용하면 된다.

/**
 * 상대경로를 절대경로로 변경
 * @param {String} xlsPath
 * @returns {String} 절대경로
 */
const createFilePath = (xlsPath) => {
  const filePath = path.join(__dirname, xlsPath);
  return filePath;
};

/**
 * 엑셀 0번 시트 데이터 읽어오기
 * @param {String} filePath
 * @returns {Object} Json 타입으로 변경된 엑셀 데이터
 */
const excelToJson = (filePath) => {
  // 엑셀 읽기
  const workbook = xlsx.readFile(filePath);
  // 0번째 시트 이름 가져오기
  const sheetName = workbook.SheetNames[0];
  // 시트 데이터 가져오기
  const worksheet = workbook.Sheets[sheetName];
  // Json 변환
  const jsonData = xlsx.utils.sheet_to_json(worksheet);

  return jsonData;
};

랜덤 닉네임을 생성하기 위해서 json으로 변환된 데이터에서 무작위 위치에 저장된 값을 가져오면 된다.

0 ~ dataSize-1 범위의 index를 사용하기 위해서 Math 함수로 랜덤 정수 값을 생성하고 하당 위치의 데이터를 가져오는 방식을 이용했다.

  • Math.ramdon() : 0 이상 1 미만 범위의 실수를 랜덤으로 반환한다.
  • Math.floor() : 소수점 이하 버림
/**
 * 무작위 엑셀 데이터 추출
 * @param {Object} jsonData
 * @returns {String}
 */
const getRandomDataFromJson = (jsonData) => {
  const dataSize = jsonData.length;
  const randomIndex = Math.floor(Math.random() * dataSize);
  const title = jsonData[randomIndex].Title;

  return title;
};

회원 가입

클라이언트에게 전달받은 메일로 가입된 데이터가 존재하는지 확인하고, 이메일 인증이 완료되었는지 검증한다.
만약 검증 완료까지 되어있는 상태라면 해당 이메일로 로그인하는 메세지를 담아서 에러를 전송한다.

신규가입 및 메일 인증이 미완료인 유저인 경우 새로운 데이터를 생성하여 추가하거나, 암호 값을 업데이트 한 뒤 인증 메일은 전송하도록 한다.

/**
 * 유저 생성
 * @param {Object} userData
 * @returns {Promise<User>}
 */
const createUserEmail = async (userData) => {
  try {
    const { email, password } = userData;
    const getUser = await findUserByEmail(email);
    let user;
    if (getUser) {
      const authState = await emailAuthCheck(getUser);
      if (authState) {
        throw new ConflictError(STRINGS.ALERT.CHECK_SIGN_EMAIL);
      }
      // 이미 유저가 등록되었고, 인증만 완료되지 않는 상태인 경우
      getUser.password = password;
      getUser.nickname = createNickname();
      user = await getUser.save();
    } else {
      // 신규 가입
      user = await User.create({
        email: email,
        password: password,
        nickname: createNickname(),
      });
    }
    const token = createJWT(email);
    const verificationLink = `${process.env.SERVER_URL}/auth/verify-email?token=${token}`;
    await sendAuthMail(email, verificationLink);

    return false;
  } catch (e) {
    throw e;
  }
};

메일 인증 구현_config/email.js

nodemailer 패키지를 설치해서 인증 메일 전송을 구현했다.
host 메일로 네이버를 사용하였고 네이버 메일 설정에서
POP3/IMAP 설정을 완료해야 메일을 전송 할 수 있다.

패키지의 자세한 사용 방법은 아래 페이지를 확인.

Nodemailer :: Nodemailer
Nodemailer is a module for Node.js to send emails
https://nodemailer.com/

const nodemailer = require('nodemailer');
const { CustomApiError } = require('../utils/errors');
const STRINGS = require('../constants/strings');
/**
 * 유저 데이터 생성 후 인증 이메일 전송
 * @param {String} email 가입유저이메일
 * @param {String} link 인증링크
 * @returns {Promise}
 */
const sendAuthMail = (email, link) => {
  return new Promise((resolve, reject) => {
    const smtpTransport = nodemailer.createTransport({
      pool: true,
      maxConnections: 1,
      service: 'naver',
      host: 'smtp.naver.com',
      port: 587,
      secure: false,
      requireTLS: true,
      auth: {
        user: process.env.EMAIL_ID,
        pass: process.env.EMAIL_PASS,
      },
      tls: {
        rejectUnauthorized: false,
      },
    });

    const fromEmail = process.env.EMAIL_ID;
    const option = {
      from: `${fromEmail}@naver.com`,
      to: email,
      subject: '인증 관련 메일.',
      html: `<h1>링크를 클릭하세요</h1> \b\b <a href="${link}">${link}</a>`,
    };
    smtpTransport.sendMail(option, (err, info) => {
      smtpTransport.close();
      if (err) {
        reject(new CustomApiError(STRINGS.ALERT.AUTH_MAIL_RES_ERR));
      } else {
        resolve(true);
      }
    });
  });
};
module.exports = {
  sendAuthMail,
};

createTransport를 사용하여 메일 전송을 위한 연결 객체를 생성한다.


생성된
sendAuthMailsendMail 메서드를 사용하여 메일을 전달한다. 이 때 첫 번째 매개변수 값으로 전달 받을 메일 주소, 제목, 내용을 담은 객체 option 을 생성하여 할당하면 된다.

메일 내용에 유저 인증을 수행하는 엔드포인트 링크를 전달하면 된다. 이 때 링크에는 유저 이메일로 생성된 JWT 토큰이 쿼리스트링으로 전달되고 있다.

// createUserEmail()
const token = createJWT(email);
const verificationLink = `${process.env.SERVER_URL}/auth/verify-email?token=${token}`;
await sendAuthMail(email, verificationLink);

해당 엔드포인트에서는 토큰을 복호화해서 확인한 메일을 사용하여 DB를 탐색하고 인증 필드를 true로 변경해주면 된다.

현재 인증 링크가 서버 엔드포인트로 설정되어 있다. 추후 클라이언트 링크로 변경하여 서버에 인증 요청하는 방식으로 변경해야한다.


메일 인증 프로세스

  1. 서버에서 이메일 전송: 서버에서는 nodemailer 또는 다른 메일 전송 라이브러리를 사용하여 유저에게 이메일을 보냅니다. 이 이메일에는 인증을 위한 특별한 링크가 포함될 수 있습니다.
  1. 클라이언트에서 이메일 수신 및 링크 클릭: 유저가 이메일을 수신하면, 이메일 내부의 링크를 클릭하여 웹 페이지로 이동할 수 있습니다. 이 링크는 클라이언트 측에서 실행되며, 클라이언트에서는 해당 링크의 주소를 서버에 전달하여 유저의 인증을 요청합니다.
  1. 서버에서 인증 처리: 서버는 클라이언트로부터 받은 인증 요청을 처리하고, 유저의 인증 상태를 변경합니다. 이 때, 클라이언트에게 적절한 응답을 보내어 인증이 성공적으로 처리되었음을 알립니다.

메일 인증 구현 중 문제점 발생

인증 메일이 만료가 된 경우 클라이언트에게 401 상태코드를 전달한다.
에러를 전달받은 클라이언트는
responseInterceptorError 메서드를 수행하고, 401 코드일 시 토큰을 재발급 받는다.

여기서 문제는, 이메일 인증시 발생된 에러로 전달된
401 코드에 대해서 토큰 재발급을 수행하면 안된다는 것이다. 이를 해결하기 위해서 이메일 인증시 발생된 401 에러에 관하여 재발급을 수행하지 않도록 코드를 수정한다.

Server

// unauthenticatedError.js
const { StatusCodes } = require('http-status-codes');
const CustomApiError = require('./customApiError');
/**
 * 401(Unauthorized) : 클라이언트 미인증 상태
 */
class UnauthenticatedError extends CustomApiError {
  constructor(message, isEmail = null) {
    super(message);
    this.statusCode = StatusCodes.UNAUTHORIZED;
    this.isEmail = isEmail;
  }
}

module.exports = UnauthenticatedError;
// user.service.js
const verifyJWT = async (url) => {
  const queryParams = extractQueryParams(url).token;
  try {
    const decoded = jwt.verify(queryParams, process.env.JWT_SECRET);
    const email = decoded.userEmail;
    const user = await findUserByEmail(email);
    const authStatus = await emailAuthCheck(user);
    if (!authStatus) {
      user.emailAuth = !user.emailAuth;
      const updateUser = await user.save();
      return updateUser;
    } else {
      throw new ForbiddenError('만료된 페이지입니다.');
    }
  } catch (err) {
    if (err) {
      throw err;
    } else {
      throw new UnauthenticatedError('회원 가입을 다시 진행해주세요.', true);
    }
  }
};
💡
미완성 코드로서 에러 처리 관련 부분만 확인하자.(추후 인증 관련 메서드들 모두 미들웨어 계층으로 이동)

UnauthenticatedError 객체는 message 한 개의 매개변수만 갖고 있었다. isEmail을 추가하여 true가 전달되면 에러 핸들러에서 적절한 처리를 수행하도록 수정했다.

// errorHandler.js
const logger = require('../../config/logger');
const { StatusCodes } = require('http-status-codes');
const errorHandler = (err, req, res, next) => {
  let customError = {
    statusCode: err.statusCode || StatusCodes.INTERNAL_SERVER_ERROR,
    errMessage: err.message || '다시 시도해 주세요.',
  };
  try {
    if (err.isEmail) {
			return res.status(customError.statusCode).json({ isLoginEmailVerification: true, errMessage: customError.errMessage });
    }
    if (!customError) {
      // 추후 클라이언트에서 토큰 받은 후 사용자 인증 미들웨어 걸쳐서 유저벌 료그 기록
      logger.error(`res : ${req.ip}, ${StatusCodes.INTERNAL_SERVER_ERROR} : ${err.name} - ${err.message}`);

      return res.status(customError.statusCode).json({ errMessage: customError.errMessage });
    } else {
      console.log('커스텀에러');
      logger.error(`res : ${req.ip}, ${StatusCodes.INTERNAL_SERVER_ERROR} : ${err.name} - ${err.message}`);

      return res.status(customError.statusCode).json({ errMessage: customError.errMessage });
    }
  } catch (error) {
    return res.status(customError.statusCode).json({ errMessage: customError.errMessage });
  }
};

module.exports = errorHandler;

만약 isEmail이 전달된다면 응답 본문에 isLoginEmailVerification 값을 추가해서 전달한다.
이제 클라이언트의
responseInterceptorError에서 isLogin 데이터를 확인하여 토큰을 재발급 하거나 바로 다음 작업을 수행하면 된다.

Client(api/axios.js)

  • 수정 전
const responseInterceptorError = async (error, instance) => {
  const {
    config,
    response: { status },
  } = error;

  // 토큰 만료시 재발급, 기존 인스턴스를 그대로 사용하기 위해 플래그 retry 사용
  if (status === 401 && !config.retry) {
    const newToken = await getAccessToken();
    if (newToken) {
      config.headers['Authorization'] = `Bearer ${newToken}`;
      config.retry = true;
      return instance(config);
    }
  }

  const errorMessage = '에러메세지, error.response?.data?.errorMessage';
  console.log(config.message);
  // 성공 메세지 비워두기
  config.message = '서버에서 전달받은 에러메시지';

  return Promise.reject(error);
};
  • 수정 후
const responseInterceptorError = async (error, instance) => {
  const {
    config,
    response: { status },
  } = error;
  const isLoginEmailVerification =
    error.response.data.isLoginEmailVerification || null;

// 토큰 만료시 재발급, 기존 인스턴스를 그대로 사용하기 위해 플래그 retry 사용
  if (isLoginEmailVerification !== true && status === 401 && !config.retry) {
    const newToken = await getAccessToken();
    if (newToken) {
      config.headers['Authorization'] = `Bearer ${newToken}`;
      config.retry = true;
      return instance(config);
    }
  }
  const errorMessage = '에러메세지, error.response?.data?.errorMessage';
  console.log(config.message);
  // 성공 메세지 비워두기
  config.message = '서버에서 전달받은 에러메시지';

  return Promise.reject(error);
};

isLoginEmailVerification이 true가 아닌 경우 토큰 재발급을 수행하도로 수정했다.
이제는 로그인 인증 링크를 통한 요청에 대해서
401 에러가 발생하여도 토큰 재발급 요청을 전달하지 않는다.


Uploaded by N2T

'YAMPLI' 카테고리의 다른 글

[Node.js] Issue28_설계 변경 후 회원가입 기능 #2  (0) 2023.12.22
테이블 설계  (0) 2023.12.08
Comments