프로젝트/풀스택 프로젝트

5. [React + Express + MongoDB] Perfect Boiler - 이메일 인증을 하는 로컬(자체 로직) 회원가입 구현

찰리-누나 2023. 1. 20.

 

 

 

이번에는 제대로된 ! 보일러 플레이트를 만들기 위해, 이메일 인증을 하는 자체 회원가입 로직을 작성하였다. 저번 코드를 요모조모 뜯어보니 엉망진창 . . 인 것 같아서, 해당 프로젝트를 보완할 겸 새 프로젝트를 만들어 시작했다. 이번 글에서 약간 수정하기는 했지만, 프런트엔드는 먼저 완성하였다 : https://make-somthing.tistory.com/84

 

이 과정을 진행하면 굳이 게시판을 만들지 않아도, 회원가입 하나만으로 CRUD를 한번에 경험할 수 있다. 임시 데이터를 생성했다가 수정했다가 지웠다가 읽었다가 해야하기 때문이다... 

 

 

 

* express와 mongodb를 사용하는 글이기 때문에, express와 몽고db 연결이 되어있다고 가정하고 진행합니다. 

 


 

 



소요 기간 - 1일

Frontend - React
Backend - Node.js Express
DB - MongoDB

 

 

 

Node.js에서 메일 기능을 구현하려면, 세 개의 라이브러리가 필요하다. (bcryptjs는 bcrypt를 설치해도 되는데 이상하게 오류가 많아서...  bcryptjs로 설치했다.)

 

 

nodemailer : Node.js로 메일 전송 기능을 구현할 때 사용하는 라이브러리

 bcryptjs : 사용자가 입력한 비밀번호를 암호화 할 때 사용하는 라이브러리

 ejs : ejs 파일을 사용하기 위해 필요한 라이브러리

 

 

npm install bcryptjs nodemailer ejs --save

 

 

 

나는 mongoDB를 선택했기 때문에 db 라이브러리로 몽구스를 선택하고 추가로 설치해 주었다. 만일 MySQL 등의 다른 db를 사용하고 있다면, 시퀄라이즈같은 필요한 라이브러리를 선택해 설치하면 된다.

npm install mongoose

 

 

 

 

디렉토리 구조는 다음 형태이다. 루트 디렉토리에 .env 파일을 만들고, 백엔드 디렉토리에 config, models, routes, template 네 개의 폴더를 생성해야 한다.

 

 

 

 

 

1. index.js 설정하기_________________________________

 

 

 

index.js

서버의 메인 파일, index.js를 설정해준다. client 디렉토리에 생성한 리액트 프런트 프로젝트와 연결해 주었다. 만일 Node.js Express와 리액트를 연결한 보일러가 필요하다면: https://make-somthing.tistory.com/8 

 

const express = require("express");
const app = express();
app.use(express.json());
const path = require("path");
const cors = require("cors");
app.use(cors());
const dotenv = require("dotenv");
dotenv.config();


// ===bodyparser===
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// ===MongoDB===

const config = require("./config/key");
const mongoose = require("mongoose");

mongoose
  .connect(config.mongoURI)
  .then(() => console.log("몽고DB Connected..."))
  .catch((err) => console.log("몽고디비 에러", err));

// ------------------------------ user Routes ------------------------------

app.use("/api/user", require("./routes/users"));


// Serve static assets if in production
if (process.env.NODE_ENV === "production") {
  app.use(morgan("combined")); //배포환경이면
  // Set static folder
  // All the javascript and css files will be read and served from this folder
  app.use(express.static("client/build"));

  // index.html for all page routes    html or routing and naviagtion
  app.get("*", (req, res) => {
    res.sendFile(path.resolve(__dirname, "../client", "build", "index.html"));
  });
} else {
  app.use(morgan("dev")); //개발환경이면
}

// 리액트 등의 별도 프런트엔드를 사용중이라면 아래 코드 추가
app.get("*", function (req, res) {
  res.sendFile(path.join(__dirname, "../client/build/index.html"));
});

 

 

 

 

 

2. 메일 설정하기_________________________________

 

 

 

nodemailer를 사용하려면 회원가입할 유저들에게 이메일을 "발송해줄" 메일을 설정해 주어야 한다. 네이버, 구글 등 여러 선택지가 있으며 나는 구글로 진행하였다. 둘 다 사전 준비가 필요하다.

 

 

네이버 이메일로 진행하고자 한다면 아래 방법으로 사전 준비를 해 준다.

네이버 메일 -> 내 메일함 -> 메일함 관리(톱니바퀴 버튼) -> POP3/IMAP 설정 -> IMAP/SMTP 설정 -> 사용함, 1000통 -> 저장

 

 

 

 

구글 이메일을 사용할 경우

내 계정 -> 보안 -> 2단계 인증 을 통해 2단계 인증 설정

 

 

 

다시 내 계정 -> 보안 으로 돌아와 [앱 비밀번호] 설정

 

 

 

 

[메일] , [기타(맞춤 이름] 을 선택해 내가 개발하는 앱 이름 작성하고 [생성] 버튼 클릭

 

 

 

 

'기기용 앱 비밀번호' 에 뜬 내용을 복사하여, env 파일의 NODEMAILER_PASS에 등록

 

 

 

 

 

3. .env 파일 설정하기_________________________________

 

 

설정을 완료했다면 루트 폴더에 .env 파일을 생성하여, 아래 내용을 입력한다.

 

.env
NODEMAILER_USER=메일을 발송할 이메일
NODEMAILER_PASS=그 이메일의 비밀번호(구글의 경우는 발급받은 앱 비밀번호을 입력)

 

 

 

 

4. nodemailer 전용 파일 설정하기_________________________________

 

config / email.js

config 폴더에 email.js 파일을 만들고, 아래 내용을 입력한다.

const nodemailer = require("nodemailer");

const smtpTransport = nodemailer.createTransport({
  service: "Gmail", //네이버를 사용할 경우에는 "Naver"
  port: 587,
  secure: false,
  auth: {
    user: process.env.NODEMAILER_USER,
    pass: process.env.NODEMAILER_PASS,
  },
  tls: {
    rejectUnauthorized: false,
  },
});

module.exports = {
  smtpTransport,
};

 

 

 

 

 

5. user 데이터베이스 모델 설정하기_________________________________

 

 

 

models / User.js

User 모델을 만든다. 나는 몽고db와 몽구스를 사용하였다. 아이디, 이메일, 이메일 인증번호, 유저 권한, 프로필 사진, 가입 진행 상태, 토큰을 저장하도록 설계해 주었다. statusCode 필드가 바로 '가입 진행 상태' 를 나타낸다. 0은 가입 진행이며, 1은 가입이 완료된 유저를 나타낸다. 

const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
const saltRounds = 10;
const jwt = require("jsonwebtoken");
const moment = require("moment");

const userSchema = mongoose.Schema({
  // 아이디, 이메일, 이메일 인증번호, 유저 권한, 프로필 사진, 가입 진행 상태,  토큰(로그인 상태 관리)
  id: {
    type: String,
    maxlength: 50,
    unique: 1,
  },
  email: {
    type: String,
    trim: true,
    unique: 1,
  },
  auth: {
    type: String,
    trim: true,
  },
  password: {
    type: String,
    minglength: 5,
  },
  nikname: {
    type: String,
    maxlength: 50,
  },
  role: {
    type: Number,
    default: 0,
  },
  image: {
    type: String,
    default: null,
  },
  statusCode: {
    type: Number,
  },
  accessToken: {
    type: String,
  },
  refreshToken: {
    type: String,
  },
  tokenExp: {
    type: Number,
  },
});

userSchema.pre("save", function (next) {
  var user = this;

  if (user.isModified("password")) {
    bcrypt.genSalt(saltRounds, function (err, salt) {
      if (err) return next(err);

      // hash화된 비밀번호 저장
      bcrypt.hash(user.password, salt, function (err, hash) {
        if (err) return next(err);
        user.password = hash;
        next();
      });
    });
  } else {
    next();
  }
});

const User = mongoose.model("User", userSchema);

module.exports = { User };

 

 

위 코드에서 아래 부분은,처음 보일러 플레이트를 만들 때 작성했던 코드를 재사용한 부분이다. 비밀번호를 bcrypt를 이용해 해쉬화하고, 데이터베이스에 저장하도록 도와준다. pre('save' 부분은 몽고db의 'save'라는 함수를 실행하기 전(pre) 에 이 함수를 실행해 주세요~ 라는 뜻이다. 즉, 유저 저장 단계에서 실행되는 미들웨어이다!

userSchema.pre("save", function (next) {
  var user = this;

  if (user.isModified("password")) {
    bcrypt.genSalt(saltRounds, function (err, salt) {
      if (err) return next(err);

      // hash화된 비밀번호 저장
      bcrypt.hash(user.password, salt, function (err, hash) {
        if (err) return next(err);
        console.log("해쉬화된 비밀번호:", hash);
        user.password = hash;
        next();
      });
    });
  } else {
    next();
  }
});

 

 

 

 

 

5. 보낼 이메일 html 파일인 ejs 설정하기_________________________________

 

 

/sever/template/authMail.ejs

template 폴더에 nodemailer를 통해 보낼 이메일의 html 디자인, 즉 ejs 파일을 만든다.

<html>
  <body>
    <div>
      <p style="color: black">회원 가입을 위한 인증번호 입니다.</p>
      <p style="color: black">
        아래의 인증 번호를 입력하여 인증을 완료해주세요.
      </p>
      <h2><%= authCode %></h2>
    </div>
  </body>
</html>

 

 

 

 

프런트엔드는 다음과 같이 동작한다.

 

 

 

 

 

 

 

 

6. 아이디 중복 검사 라우터 만들기 _________________________________

 

 

server/routes/user.js

 

이제 user전용 라우터를 만들면 된다. email.js와 User모델, 미리 작성한 ejs 파일을 연결할 ejs와 path 라이브러리를 불러와 주었다. 나중에 여기에 미들웨어를 추가해주어야 한다.

 

 

우선 첫번째로 '아이디 중복 검사' 라우터를 만들고, 프런트에서 실행하도록 해준다. inpectId 라우터는 클라이언트로부터 아이디 중복 검사 요청을 받으면, db에서 req.body로 받아온 id를 이용해 검색하고 결과를 반환한다. true를 반환해야 하는 경우는 두 가지 경우이다.

 


1. 아예 db에 id가 존재하지 않는, 처음 가입하는 id일 때.

2. 이미 id가 db에 존재하지만 'statusCode', 즉 '가입 진행 상태' 를 나타내는 필드의 값이 0일 때. (=가입을 진행하다가 말아서 가입을 완료하지 않은 상태)


const express = require("express");
const router = express.Router();

// ----------------------nodemailer----------------------
const ejs = require("ejs");
const path = require("path");
var appDir = path.dirname(require.main.filename);
const { smtpTransport } = require("../config/email.js");

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


router.post("/inspectId", function (req, res) {
  User.findOne({ id: req.body.id }, (err, user) => {
    if (!user) {
      return res.json({
        joinable: true,
        message: "가입 가능한 아이디입니다.",
      });
    } else if (user) {
      if (user.statusCode === 0) {
        User.deleteOne({ id: req.body.id }).exec((err, result) => {
          if (err) {
            console.log("id검사에서 임시데이터를 지우지 못하고 에러", err);
            return res.status(400).send(err);
          } else {
            return res.json({
              joinable: true,
              message: "가입 가능한 아이디입니다.",
            });
          }
        });
      } else {
        return res.json({
          joinable: false,
          message: "이미 존재하는 아이디입니다.",
        });
      }
    }
  });
});

 

 

 

이미 존재하는 아이디일 경우 이미 존재하는 아이디라는 알림창을 띄운다. 

 

 

 

가입이 가능한 아이디일 경우 가입이 가능하다는 알림창을 띄우고, 버튼의 문구를 변경한다.

 

 

 

 

 

 

7. 이메일 발송 라우터 만들기_________________________________

 

 

 

 

server/routes/user.js

 

이제 이메일 발송 라우터를 작성한다. 이메일의 정규식 검사는 프런트엔드에서 처리했다. 백엔드는 프런트로부터 이메일을 전송받아, 해당 이메일에 인증 코드를 발송해준다. 인증 코드를 받을 수 있는 이메일은 두 가지 경우가 있다.


1. 해당 이메일이 db에 등록되어 있지 않은 '신규 가입 이메일' 일 경우

2. 이미 db에 이메일이 존재하나, 가입을 진행하다가 완료하지 않고 도중에 하차한, statusCode가 0인 이메일일 경우


 

즉 로직은 다음과 같다.

 


1. 위의 두 가지 경우에 해당하는 이메일인지 확인하고
2. 이메일에 인증코드를 발송한다.
3. 발송한 인증코드를 인증 과정에 사용할 수 있도록 db에 임시 데이터를 만들어 저장한다.
4. 일정 시간이 지나면 인증코드가 유효하지 않도록 db에서 삭제해준다.


 

 

 

7-1. 이메일 발송 라우터를 위한 미들웨어 만들기_________________________________

 

 

 

server/routes/midlewares.js

 

이를 완수하려면 미들웨어를 만들어야 한다. routes 폴더에 midlewares.js를 만들고, 1번과 2번 과정을 검사하는 미들웨어를 작성해 주었다.

 

// User 모델을 불러온다.

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

// 이메일 인증을 위한 미들웨어
exports.inspectEmail = (req, res, next) => {
  User.findOne({ email: req.body.email }, (err, user) => {
    // 유저가 없다면 바로 이메일 인증 번호를 발송해준다.
    if (!user) {
      next();
    }  
    else if (user) { //그러나 만일 유저 정보가 존재한다면, statusCode를 검사하여
    //현재 가입이 완료된 유저인지(statusCode==1), 
    //완료되지 않은 유저인지(statusCode==0) 를 검사한다.
      if (user.statusCode === 0) { //만일 가입이 완료되지 않은 유저라면,
        User.deleteOne({ email: req.body.email }).exec((err, result) => { //해당 유저는 임시 데이터인 상태이므로 해당 유저를 db에서 삭제해준다.
          if (err) {
            //에러처리...
            return res.status(400).send(err);
          } else {
          //db에서 무사히 삭제되면 next()를 통해 다음으로 넘어간다.
            next();
          }
        });
      } else if (user.statusCode === 1) { //만일 가입이 완료된 유저라면, 가입할 수 없도록 클라이언트에 정보를 전달한다.
        return res.json({
          joinable: false,
          message: "이미 존재하는 이메일입니다.",
        });
      }
    }
  });
};

 

 

 

 

7-2. 이메일 발송 라우터 작성하기_________________________________

 

 

server/routes/user.js

미들웨어를 작성하였으니, 메일을 발송하는 라우터를 만들 수 있다.

//미들웨어를 import 해 준다.
const { inspectEmail } = require("./middlewares.js");


// 이메일 인증 라우터
// 미들웨어를 사용해 이메일검사를 진행한다..

router.post("/authEmail", inspectEmail, async (req, res) => {
  let authNum = Math.random().toString().substr(2, 6);
  let emailTemplete;

  const sendEmail = req.body.email;

  ejs.renderFile(
    appDir + "/template/authMail.ejs",
    { authCode: authNum },
    function (err, data) {
      if (err) {
        // ejs 에러구문 처리...
      }
      emailTemplete = data;
    }
  );

  let mailOptions = await smtpTransport.sendMail({
    from: `chalie-nuna`,
    to: sendEmail,
    subject: "회원가입을 위해 인증번호를 입력해주세요.",
    html: emailTemplete,
  });

  smtpTransport.sendMail(mailOptions, function (error, info) {
    if (error) {
      // 이메일 전송 중 발생한 에러 처리...
    }
    console.log("이메일을 발송하였습니다 : " + info.response);
    res.send(authNum);
    smtpTransport.close();
  });

  await User.create({
    id: req.body.id,
    email: req.body.email,
    nikname: "test",
    //  임시데이터의 auth를 authNum으로 설정해준다.(이메일 인증번호)
    auth: authNum,
    //  임시데이터의 statusCode는 0이다.
    statusCode: 0,
    avatarUrl: null,
  });

  //  3분 후에는 데이터를 파괴한다.
  setTimeout(async () => {
    User.findOneAndUpdate(
      { email: req.body.email },
      { auth: "" },
      (err, doc) => {
        if (err) {
          return res.json({ success: false, err });
        } else {
          console.log("3분 지나서 auth만 지움");
        }
      }
    );
  }, 3 * 60 * 1000);
});

 

 

 

아래는 방금 만든 미들웨어를 불러온 구문이다.

const { inspectEmail } = require("./middlewares.js");

 

 

 

/authEmail 경로로 라우터를 요청받으면, 먼저 임의의 숫자 문자열 6자리를 생성한다.

router.post("/authEmail", inspectEmail, async (req, res) => {
  let authNum = Math.random().toString().substr(2, 6);

 

 

 

sendEmail에는 클라이언트에서 보내준 email을 저장해, '인증번호 이메일을 받을' 이메일 주소를 저장한다. emailTemplete는 '실제로 발송되는 이메일' 의 내용을 담을 변수이다. ejs 라이브러리를 활용해 작성한 ejs 파일을 렌더하고, 변수에 넣어준다.

  let emailTemplete;
  const sendEmail = req.body.email;

  ejs.renderFile(
    appDir + "/template/authMail.ejs",
    { authCode: authNum },
    function (err, data) {
      if (err) {
        // ejs 에러구문 처리...
      }
      emailTemplete = data;
    }
  );

 

 

 

email.js를 불러온 smtpTransport를 활용해, nodemailer로 메일을 보낼 준비를 한다. from에는 보내는 사람의 이름을, to에는 받을 사람의 이메일을, subject에는 발송할 메일의 제목을 입력한다. 나는 ejs 파일 형식으로 html을 전송하였으므로 html 옵션을 사용해 주었다. html에 위에서 작성한 이메일 템플릿을 담아주면 된다.

  let mailOptions = await smtpTransport.sendMail({
    from: `chalie-nuna`,
    to: sendEmail,
    subject: "회원가입을 위해 인증번호를 입력해주세요.",
    html: emailTemplete,
  });

 

 

 

실제로 메일이 전송되는 구문이다. sendMail을 통해 위에서 설정한 메일 옵션을 연결해주면, nodemailer가 이메일을 발송해준다. close()를 통하여 해당 노드메일러를 종료시켜줄 수 있다.

  smtpTransport.sendMail(mailOptions, function (error, info) {
    if (error) {
      // 이메일 전송 중 발생한 에러 처리...
    }
    console.log("이메일을 발송하였습니다 : " + info.response);
    res.send(authNum);
    smtpTransport.close();
  });

 

 

 

그런데 메일을 전송하기만 해서는 유저가 받은 인증번호와, 서버가 보낸 인증번호가 올바른지 비교할 수 있는 길이 없다. 따라서 임시 데이터를 db에 만들어 방금 발송한 인증번호를 저장해두고, 해당 db의 인증번호와 유저가 입력한 인증번호가 같은지를 검사해야 한다. create문을 이용해 유저가 입력한 id, email 및 발송한 인증번호인 authNum을 db에 저장해준다.

await User.create({
    id: req.body.id,
    email: req.body.email,
    nikname: "test",
    //  임시데이터의 auth를 authNum으로 설정해준다.(이멜인증번호)
    auth: authNum,
    //  임시데이터의 statusCode는 0이다.
    statusCode: 0,
    avatarUrl: null,
  });

 

 

 

하지만 인증번호가 천년만년 유지되어서는 보안이 그리 좋지 못할 것이다. 따라서 3분이 지나면, db에 저장했던 임시 데이터 중 auth 필드, 즉 '인증번호' 필드의 값만 제거하여 해당 인증 번호로는 더이상 인증을 진행할 수 없도록 한다. 

 //  3분 후에는 데이터를 파괴한다.
  setTimeout(async () => {
    User.findOneAndUpdate(
      { email: req.body.email },
      { auth: "" },
      (err, doc) => {
        if (err) {
          return res.json({ success: false, err });
        } else {
          console.log("3분 지나서 auth만 지움");
        }
      }
    );
  }, 3 * 60 * 1000);

 

 

 

여기까지 하면, 아래와 같은 모습이 된다.

이메일이 잘 도착한 모습

 

 

 

 

 

 

8. 이메일 인증 라우터 만들기 _________________________________

 

 

 

이제 이메일 인증번호를, 실제 발송한 인증번호와 같은지 검사하기만 하면 로컬 회원가입이 완료된다! (야호)

현재 db에는 아이디와 이메일, 발송한 인증번호를 이용한 임시 데이터가 생성되어 있는 상태이다.

 

프런트에서 인증을 위해 메일로 전송받은 인증번호를 입력하여 백엔드로 전송하면, 백엔드는 임시 데이터에 저장되어 있는 email과 방금 요청을 날린 email이 같은 데이터의 auth 필드를 비교한다.

 

번호가 일치하면 true, 일치하지 않으면 false를 반환한다.

router.post("/getAuth", function (req, res) {
  // 이메일을 매개로 하여 데이터베이스의 auth와 비교해 참이면 success:true 보내주기
  User.findOne({ email: req.body.email }, (err, user) => {
    if (user.auth === req.body.auth) {
      res.json({
        success: true,
        message: "인증번호가 일치합니다.",
      });
    } else {
      return res.json({
        success: false,
        message: "인증번호가 일치하지 않습니다.",
      });
    }
  });
});

 

 

 

 

 

9. 완성_________________________________

 

 

완성된 페이지의 모습! 인증번호를 확인하기 전까지는 비밀번호를 입력할 수 없도록 비밀번호 필드를 막았다가 풀어주는 방식으로 프런트의 회원가입 로직을 수정했다. (이후 인증번호 옆에 3분짜리 타이머도 추가할 예정..)

 

 

 

 

 

 

콘솔을 통해 비밀번호의 해쉬화가 잘 진행되었고, statusCode도 1로 저장되었으며 나머지 정보도 성공적으로 저장된것을 확인할 수 있다.

끝!

댓글