펫시터 프로젝트를 진행하면서 이메일 인증을 통한 회원가입을 구현했다.
심심한 회원가입을 좀 더 특별하게 만들어주고 싶었기에 시작했다.
먼저 사용자에게 mail을 보내야하기에 Nodemailer 모듈을 사용했다.
Nodemailer :: Nodemailer
Nodemailer Nodemailer is a module for Node.js applications to allow easy as cake email sending. The project got started back in 2010 when there was no sane option to send email messages, today it is the solution most Node.js users turn to by default. npm i
nodemailer.com
Nodemailer is a module for Node.js applications to allow easy as cake email sending. The project got started back in 2010 when there was no sane option to send email messages, today it is the solution most Node.js users turn to by default.
Nodemailer는 Node.js 어플리케이션이 쉽게 이메일을 보낼 수 있도록 해주는 모듈이라 나와있다.
한번 해보자!
1. 설치하기
npm install nodemailer
2. 예제를 살펴보며 실제 메일을 보내는 로직을 작성하자.
"use strict";
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: "smtp.forwardemail.net",
port: 465,
secure: true,
auth: {
user: "REPLACE-WITH-YOUR-ALIAS@YOURDOMAIN.COM",
pass: "REPLACE-WITH-YOUR-GENERATED-PASSWORD",
},
});
async function main() {
const info = await transporter.sendMail({
from: '"Fred Foo 👻" <foo@example.com>', // sender address
to: "bar@example.com, baz@example.com", // list of receivers
subject: "Hello ✔", // Subject line
text: "Hello world?", // plain text body
html: "<b>Hello world?</b>", // html body
});
console.log("Message sent: %s", info.messageId);
}
main().catch(console.error);
위 예시코드를 하나씩 분석해보자.
메일을 보내기 위해선 transporter 객체를 생성해야 하는데, 공식문서를 통해 알아보자.
let transporter = nodemailer.createTransport(transport[, defaults])
- transporter 란 메일을 보낼 수 있는 객체이다.
- transport 란 설정이 담긴 객체이고, 연결 url 또는 transport 플러그인의 역할을 한다.
- defaults 란 메일 옵션의 기본들이 정의된 객체이다.
const transporter = nodemailer.createTransport({
host: "smtp.forwardemail.net",
port: 465,
secure: true,
auth: {
user: "REPLACE-WITH-YOUR-ALIAS@YOURDOMAIN.COM",
pass: "REPLACE-WITH-YOUR-GENERATED-PASSWORD",
},
});
위 코드를 보면 이해가 좀 갈 것이다.
메일을 보내는 로직은 다음과 같다.
transporter.sendMail(data[, callback])
- data 는 메일 내용을 의미한다.
- callback 은 옵셔널한 콜백 함수인데, 메세지가 전송되거나 전송에 실패했을 때 실행되는 함수이다.
- err 란 메세지 전송에 실패했을 경우의 에러 객체이다.
- info 결과를 담고있는 객체이자, 정확한 포맷은 전송한 메커니즘에 따라 달라진다.
- info.messageId : 대부분의 trasport들은 최종 Message-Id 값을 messageId 프로퍼티에 넣어 반환한다.
- info.envelope : 메시지의 envelope된 객체를 담고있다.
- info.accepted : SMTP transport를 통해 반환 된 array이다. (서버가 허가한 수신자 주소를 포함하고 있다.)
- info.rejected : SMTP transport를 통해 반환 된 array이다. (서버가 거절한 수신자 주소를 포함하고 있다.)
- info.pending : Direct SMPT transport를 통해 반환 된 array이다.(서버의 응답과 함께 일시적으로 거절된 수신자 주소를 포함하고 있다.)
- response : SMTP transport에 의해 반환된 스트링이면서 서버로부터 받은 마지막 SMTP response를 포함하고 있다.
(여러명에게 메시지를 보낼 시 단 한 명이라도 메시지를 받는다면 서버는 메시지가 전송된 것이라 판단한다.)
async function main() {
const info = await transporter.sendMail({
from: '"Fred Foo 👻" <foo@example.com>', // sender address
to: "bar@example.com, baz@example.com", // list of receivers
subject: "Hello ✔", // Subject line
text: "Hello world?", // plain text body
html: "<b>Hello world?</b>", // html body
});
console.log("Message sent: %s", info.messageId);
}
위 코드 처럼 전송을 하면 된다.
실제 프로젝트에서 작성했던 코드를 살펴보자.
emailCheck = async (email) => {
let authNum = Math.random().toString().substring(2, 6);
let smtpConfig = {
service: "gmail",
auth: {
user: process.env.NODEMAILER_USER,
pass: process.env.NODEMAILER_PASS
}
};
const transporter = nodemailer.createTransport(smtpConfig);
transporter.verify((error, success) => {
if (error) console.error(error);
console.log("Your config is correct");
});
let message = {
from: '"Fred Foo 👻" <foo@example.com>', // sender address
to: email, // list of receivers
subject: "회원가입을 위한 인증번호를 입력해주세요", // Subject line
// text: "Bye", // plain text body
html: `<b>${authNum}</b>` // html body
};
transporter.sendMail(message, (error, info) => {
if (error) {
console.log("Error occurred");
console.log(error.message);
throw new customError(
400,
"Bad Request",
"이메일 전송에 실패했습니다."
);
}
transporter.close();
});
...
};
createTransport 를 위해 smtpConfig를 설정한다.
let smtpConfig = {
service: "gmail",
auth: {
user: process.env.NODEMAILER_USER,
pass: process.env.NODEMAILER_PASS
}
};
const transporter = nodemailer.createTransport(smtpConfig);
emailCheck = async (email) => {
// 인증번호 생성.
let authNum = Math.random().toString().substring(2, 6);
let smtpConfig = {
service: "gmail",
auth: {
user: process.env.NODEMAILER_USER,
pass: process.env.NODEMAILER_PASS
}
};
const transporter = nodemailer.createTransport(smtpConfig);
// 내가 설정한 transporter가 올바르게 설정됐는지 검사하기 위한 로직
transporter.verify((error, success) => {
if (error) console.error(error);
console.log("Your config is correct");
});
// 내가 보낼 message 객체
let message = {
from: '"Fred Foo 👻" <foo@example.com>', // sender address
to: email, // list of receivers
subject: "회원가입을 위한 인증번호를 입력해주세요", // Subject line
// text: "Bye", // plain text body
html: `<b>${authNum}</b>` // html body
};
// mail을 보내는 로직
transporter.sendMail(message, (error, info) => {
if (error) {
console.log("Error occurred");
console.log(error.message);
throw new customError(
400,
"Bad Request",
"이메일 전송에 실패했습니다."
);
}
// 생성한 transporter를 다시 닫아준다.
transporter.close();
});
...
};
위 코드를 통해 유저에게 메일을 보낼 수 있게 되었다.
etc) nodemailer를 사용할 땐 smtpConfig에 user와 pass를 적어야 한다.
이 때 user는 본인이 사용하는 gmail을 적으면 된다.
그러나 pass의 경우 본인이 사용하는 실제 비밀번호가 아닌 앱 비밀번호를 생성해서 적어줘야 한다.
앱 비밀번호로 로그인 - Google 계정 고객센터
도움말: 앱 비밀번호는 권장되지 않으며 대부분의 경우 필요하지 않습니다. 계정을 안전하게 보호하려면 'Google 계정으로 로그인'을 사용하여 앱을 Google 계정에 연결하세요. 앱 비밀번호란 보안
support.google.com
가입하려는 이메일을 향해 인증번호를 보내줄 수 있게 되었다.
그러나 여기서 또 문제가 생겼다.
현재 내가 보낸 인증번호와 유저가 가입 시 입력한 인증번호가 같다는 것을 어떻게 증명할 수 있을까?
인증번호를 잠시 저장해 둘 공간이 필요했다.
DB를 생각해봤지만, 인증번호란 계속 사용되는 것이 아닌 한번 사용되고 사라지는 값이기에 DB에 저장하기에 낭비란 생각이 들었다.
그래서 대신 사용할 수 있는 저장소를 찾아보다 Redis를 많이 사용한다는 것을 알게되었다.
Redis란 기술을 들어봤지만, 사용해본 적은 없었기에 이번 기회를 통해 Redis를 사용하게 되었다.
Redis에 대해 짧게 알아보자.
Redis란 Remote Dictionary Server의 줄임말로, dictionary 구조의 key-value 값을 저장하고 관리하는 서버를 의미한다.
또 In-memory DB이므로 RAM에 저장이 된다. RAM에 저장이 되므로 접근이 매우 빠르다. RAM에 저장하므로 용량에 대한 문제가 있을 수 있지만, 우리의 사용 목적은 인증번호를 저장하고 만료시간이 지난 뒤 데이터를 삭제할 예정이므로 Redis의 단점이 커버될 것이라 생각했다.
Redis 설치하기 (Mac 기준)
1. Homebrew가 설치되어있는지 확인
2. redis 설치
3. redis 설치 확인
설치가 된 것을 볼 수 있다.
redis-server를 치면 다음과 같이 redis-server가 실행된 터미널을 볼 수 있다.
이제 Redis를 설치했으므로 redis-cli도 함께 설치된다.
실행해보자
이제 설치가 됐으므로, 우리의 인증번호를 저장해보자.
// src/utils/redis/index.js
const redis = require("redis");
const redisClient = redis.createClient({
legacyMode: true
});
redisClient.on("connect", () => {
console.info("Redis connected!");
});
redisClient.on("error", (err) => {
console.error("Redis Client Error", err);
});
redisClient.connect().then();
export default redisClient;
redis와 우리 서버를 연결시키는 코드이다.
위 코드를 app.js(서버가 처음 시작되는 파일)에 import하면 된다.
// app.js
import "dotenv/config";
import "./src/utils/redis";
...
위에서 nodemailer를 통해 인증번호를 전송한 코드이다.
emailCheck = async (email) => {
let authNum = Math.random().toString().substring(2, 6);
let smtpConfig = {
service: "gmail",
auth: {
user: process.env.NODEMAILER_USER,
pass: process.env.NODEMAILER_PASS
}
};
const transporter = nodemailer.createTransport(smtpConfig);
transporter.verify((error, success) => {
if (error) console.error(error);
console.log("Your config is correct");
});
let message = {
from: '"Fred Foo 👻" <foo@example.com>', // sender address
to: email, // list of receivers
subject: "회원가입을 위한 인증번호를 입력해주세요", // Subject line
// text: "Bye", // plain text body
html: `<b>${authNum}</b>` // html body
};
transporter.sendMail(message, (error, info) => {
if (error) {
console.log("Error occurred");
console.log(error.message);
throw new customError(
400,
"Bad Request",
"이메일 전송에 실패했습니다."
);
}
transporter.close();
});
await redisClient.set(email, authNum);
await redisClient.expire(email, 60 * 3); // 5분간 유효함
console.log("authNum in Email Authentication", authNum);
return response({
status: 200,
message: "이메일 전송에 성공했습니다.",
data: authNum
});
};
아래 부분에
await redisClient.set(email, authNum);
await redisClient.expire(email, 60 * 3); // 3분간 유효함
위 로직을 넣어 redis에 현재 사용자로부터 입력받은 값을 key로 하고, 사용자에게 보낸 인증코드를 value로 해 key-value 형식으로 저장을 했다.
또 key에 대한 expire 시간도 3분으로 정해줬다.
실제로 동작하는지 보자.
우리가 받은 이메일과 보낸 인증번호가 키-값으로 저장되어 있는 것을 볼 수 있다.
이제부턴 쉽다.
1. 회원가입 시 사용자에게 데이터를 받는다.
2. 받은 데이터 속 인증번호와 redis에 저장된 인증번호가 동일한지 확인한다.
signup = async (email, name, password, auth) => {
const duplicatedId = await this.authRepository.findByEmail(email);
if (duplicatedId) {
throw new customError(409, "Conflict", "이미 존재하는 아이디입니다.");
}
const getValue = await getAsync(email);
if (getValue !== auth) {
throw new customError(
400,
"Bad Request",
"인증번호가 일치하지 않습니다."
);
}
...
위의 getValue를 통해 redis에서 갖고 온 값과 사용자에게 받은 auth값을 비교하면 인증번호를 통한 이메일 인증이 끝난다.
***
import { promisify } from "util";
import redisClient from "../utils/redis/index.js";
const getAsync = promisify(redisClient.get).bind(redisClient);
promisify 함수는 콜백 기반의 함수를 프로미스로 변환해준다.
redisClient의 get 메서드를 프로미스 형식으로 변환했고, redisClient에 bind 해서 key에 해당하는 value를 가져오는 함수를 만들었다.
redisClient.get 을 할 시 항상 우리가 생성한 redisClient에서 값을 가져올 수 있도록 bind 해주었다.
생각보다 긴 여정이었다.
nodemailer와 redis를 처음 사용해보면서 학습할 수 있었다.
nodemailer를 통해 인증번호를 주는 방식으로 인증을 했는데 이를 링크를 보내줘, 링크를 클릭하면 인증이 완료되는 형식으로 구현을 하면 사용자 편의성이 더 높아질 것 같다. 다음엔 링크를 주는 방식으로 구현해야겠다.
redis 역시 사용처가 무궁무진할 것이라 생각된다.
현재는 매우 간단하게 사용을 했지만, 장점과 단점을 제대로 알고 사용한 느낌은 아니었기에 더 깊게 공부할 필요를 느낀다.