우선 기존의 서비스는 실제 메일을 굳이 확일할 필요성이 없었다. 그냥 복잡한 id의 용도라고하면 될것 같다. 하지만 서비스를 구현함에 있어서 이메일을 받는데 이메일을 아무거나 집어넣는 것부터 완성도가 떨어진다.
추가적으로 현재 약속을 잡는 기능에서 어떤 방식으로 신청이 왔고 신청 결과를 사용자에게 보여줄 방식이 없었다. 그래서 일반적인 예약 시스템이 있는 서비스에서는 메일로 예약내역을 보내주기에 메일 인증이 더욱 필요해졌다.
그래서 메일을 보내주는 방식으로 nodemailer를 사용하기로 했다. nodejs에서 가장 보편적으로 사용하는 메일 전달 모듈이다. 그리고 typescript까지 지원하기때문에 사용하기에도 적합하다고 생각했다.
내 개인 메일로 메일을 보내줄수 없기에 공식 메일 계정을 하나 개설해줬다.

$npm install nodemailer
우선 설치를 해주고 공식 사이트에서 제공하는 예시 방식대로 진행을 해주면 된다.
먼저 해야할 것은 SMTP 및 auth 설정이다.
SMTP: Simple Mail Transfer Protocol의 약자로 메일을 보내는 프로토콜이다. 우리는 구글의 SMTP를 사용할 것이다. 직접 메일을 전송하는 것이 아니라 서버에서 전송을 해줄 것이기 때문에 SMTP를 사용한다.
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
pass: process.env.NEXT_PUBLIC_EMAIL_KEY,
},
});
gmail의 SMTP host값과 포트 값을 넣어주면 된다. 나도 자세히 공부해보지는 않았지만 465포트는 다른 포트와 달리 암호화를 해주기에 secure옵션도 켜줄수 있다고 한다.
그리고 auth부분에는 위에서 생성한 email주소와 email 앱 키를 넣어준다. 그러면 SMTP를 통해 메일을 보낼수 있게 된다. 이렇게 생성산 transporter를 이용해서 메일을 보내주면 된다.
export const sendEmail = async (email: string) => {
try {
await transporter.sendMail({
from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
to: email,
subject: 'Gila 이메일 인증 번호 입니다.',
html: `<h1>Gila 이메일 인증 번호입니다.</h1>
<p>본 메일은 보안을 위해 확인 후 삭제 하시기 바랍니다.</p>
`,
});
return { message: '메일 전송에 성공했습니다.'};
} catch (error) {
throw new Error('메일 전송에 실패했습니다.');
}
};
이렇게 sendMail()안에는 여러 옵션들이 들어간다. 우선 보내는 메일은 위에서 설정해준 서비스 메일로 모든 메일이 전송될 것이다. 그리고 받는 메일을 그때그때 상황에 맞춰 전달해주면 된다. subject는 메일의 제목, html은 메일의 내용이다. 그래서 html로 직접 메일내용을 작성해주면 된다.
메일을 보내는 법은 크게 어렵지가 않았다. 그럼 이제 메일 인증을 한번 구현해보겠다.
우선 대부분의 인증은 무작위수를 보내주는 방식으로 구현되어 있다. 그래서 메일을 보내는 함수에
const randomKey = `${Math.floor(100000 + Math.random() * 900000)}`;
진짜 무작위 수를 생성해주는 로직을 넣어서 인증 번호를 만들어줬다. 이제 이 값을 메일에 포함해서 같이 보내주는 것이다.
이제 페이지에서 해당 번호를 확인해주는 과정을 거쳐야한다. 그래서 랜덤 key값을 return값에 추가해줘서 페이지로 넘겨준다.
export const sendEmail = async (email: string) => {
const randomKey = `${Math.floor(100000 + Math.random() * 900000)}`;
try {
await transporter.sendMail({
from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
to: email,
subject: 'Gila 이메일 인증 번호 입니다.',
html: `<h1>Gila 이메일 인증 번호입니다.</h1>
<h2>인증번호 : ${randomKey}</h2>
<p>본 메일은 보안을 위해 확인 후 삭제 하시기 바랍니다.</p>
`,
});
return { message: '메일 전송에 성공했습니다.', key: randomKey };
} catch (error) {
throw new Error('메일 전송에 실패했습니다.');
}
};
이제 회원가입 폼을 수정해준다.
const [isCheck, setIsCheck] = useState(false);
const [emailKey, setEmailKey] = useState('');
const [validEmail, setValidEmail] = useState(false);
우선 3개의 값을 생성해줬다. isCheck는 이메일 인증을 시도했는지에 대한 여부이다. emailKey는 위에서 return한 인증 번호를 저장할 것이다. validEmail은 유효한 메일인지에 대한 여부이다.

우선 회원가입 형식은 이렇게 되어있다. 우선 메일을 입력하고 인증 버튼을 누르면 아래 로직이 동작한다.
const requsetKey = async () => {
setIsCheck(true);
const result = await sendEmail(form.getValues('email'));
setEmailKey(result.key);
toast.message('이메일을 확인해주세요.(스팸메일함도 확인해주세요.)');
};
동작과 동시에 isCheck이 true로 변경되면서 인증을 시도했다는 것을 확인할 수 있다. 해당 값이 변경되면서 인증번호 input이 활성화 된다. 그리고 메일을 보내면서 생성된 key값이 페이지에 전달되어 state값으로 세팅한다.

이렇게 메일로 전달된 값과 페이지로 전달된 값을 비교해주면 된다.
const checkKey = () => {
if (emailKey !== form.getValues('emailCheck')) {
setValidEmail(false);
form.setError('emailCheck', {
type: 'manual',
message: '인증번호가 일치하지 않습니다.',
});
} else {
setValidEmail(true);
toast.message('이메일 인증에 성공했습니다.');
}
};
인증번호 인풋의 확인 버튼을 누르면 해당 로직이 동작한다. 저장한 emailKey값과 form에 입력된 값을 확인한다. 같다면 인증에 성공하고, 같지 않다면 인증에 실패한다.
<Button
disabled={isPending || !form.formState.isValid || !validEmail}
type="submit"
className="w-full py-3 text-lg font-semibold text-white disabled:bg-primary_dark"
>
회원가입
</Button>
이렇게 확인한 인증 여부를 이용해 하단의 회원가입 버튼의 disabled를 설정해준다.
실제 동작을 살펴보면

처음 상태이다.

이메일이 입력되어야하고 react-hook-form에 의해 isvalid상태도 문제가 없어야한다.

인증을 누르면 재인증하지 못하도록 버튼이 disabled처리되고 toast가 생성된다.

메일에서 인증번호를 확인할 수 있고

인증번호 인풋도 schema정의를 해서 같은 오류를 확인할 수 있다.

다른 번호를 입력하면 에러 메세지가 발생하고

올바른 번호를 입력하면 성공했다는 메세지가 확인된다.

그리고 모든 값을 입력해야지 회원가입 버튼이 활성화된다.
이제 회원가입 유저는 메일 인증을 해야지 회원가입이 가능해진다.
이제 나머지는 정말 쉽다. 누구에게 보낼건지 메일 수신자를 받고 결과 메일의 경우 결과를 전달해주면 된다.
export const requestMail = async (activity: ActivityWithUserAndFavorite) => {
const currentUser = await getCurrentUser();
const date = formatDateRange({
startDateString: activity.startDate,
endDateString: activity.endDate,
});
const owner = await getUserProfileWithIntroducedInfos(activity.userId);
await transporter.sendMail({
from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
to: owner.user.email,
subject: `"${activity.title}" 활동 신청 요청이 있습니다.`,
html: `<h1>${activity.title}</h1>
<h2>세부 일정: ${date}</h2>
<p>신청자: ${currentUser.nickname}</p>
<a href="${process.env.NEXT_PUBLIC_BASE_URL}/dashboard/promised-list">확인하러 가기</a>
`,
});
return { message: '길라에게 메일을 전송했습니다.' };
};
간단하게 요청 메일 로직이다. 간단하게 어떤 활동인지에 대한 요약과 누가 신청했는지에 대한 정보를 메일로 보내주었다. 기존에 작성된 타입과 로직을 활용하려다보니 약간 불필요한 코드들이 많이 보이지만 우선 나중에 리팩토링을 진행해보는걸로 해야겠다.
export const responseMail = async (
activity: Activity,
requsetUser: User,
result: 'approve' | 'reject',
) => {
const currentUser = await getCurrentUser();
const date = formatDateRange({
startDateString: activity.startDate,
endDateString: activity.endDate,
});
await transporter.sendMail({
from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
to: requsetUser.email,
subject: `"${activity.title}" 활동 신청 결과입니다.`,
html: `<h1>${activity.title}</h1>
<h2>세부 일정 : ${date}</h2>
<p>길라 : ${currentUser.nickname}</p>
<p>결과 : ${result === 'approve' ? '수락됨' : '거절됨'}</p>
<a href="${process.env.NEXT_PUBLIC_BASE_URL}/dashboard/promise-list">확인하러 가기</a>
`,
});
return { message: '길라에게 메일을 전송했습니다.' };
};
결과도 동일하다. 여기에서는 요청한 user의 값과 결과를 받아서 메일을 보내준다.

그러면 이렇게 확인이 가능하다.
메일 인증과 함께 메일을 보내는 기능까지 구현이 되었다. 기능 자체는 구현되었지만 코드 자체가 깔끔하지가 않다. 왜냐하면 해당 기능은 기획단계에서 고려되어 있지 않던 기능이였기 때문에 기존의 것을 전부 수정하는 것이 아니라면 당연히 복잡해질수 밖에 없다. 아마 이 기능을 리팩토링하려면 대공사가 될것만 같은 예감이다. 그래도 그런걸 하는게 프론트엔드 개발자아닌가? 그럼 해야지...
다음은 신청 여부에 대한 피드백을 추가해줄 것이다. 지금은 신청 결과와 여부를 대시보드에 들어가야만 확인이 가능하다. 하지만 너무 번거롭고 불필요한 이벤트가 발생할 가능성이 높아서 기존의 코드를 최대한 활용해서 구현해보겠다.