7단계
7단계 — 이메일 인증과 OTP
0회 조회
7단계 — 이메일 인증과 OTP
회원가입 · 비밀번호 재설정 · 민감한 작업 확인. 이메일로 일회용 코드를 보내 "이 주소의 소유자가 맞다" 를 증명하는 흐름은 어디서나 쓰입니다.
이번 단계는 SMTP 설정부터 OTP 생성·검증·rate-limit 까지 손으로 따라하며 한 줄씩 만들어 봅니다. 보안의 핵심은 화려한 코드가 아니라 코드 누출 · 재사용 · 무한 발송 세 가지를 막는 작은 습관입니다.
1. SMTP 설정 — 앱 비밀번호
먼저 메일을 보낼 통로가 필요합니다. Gmail 을 발신 서버로 쓴다고 가정하면 접속 정보는 다음과 같습니다.
| 항목 | 값 |
|---|---|
| host | smtp.gmail.com |
| port | 587 |
| 암호화 | STARTTLS |
| 사용자 | 보내는 Gmail 주소 |
| 비밀번호 | 앱 비밀번호 (계정 일반 비밀번호 아님) |
여기서 자주 막힙니다. Gmail 은 일반 로그인 비밀번호로 SMTP 접속을 허용하지 않습니다. 다음 순서로 앱 비밀번호를 발급받습니다.
- Google 계정 → 보안 → 2단계 인증 켜기 (이게 켜져 있어야 다음 메뉴가 나옵니다)
- 보안 → 앱 비밀번호 진입
- 앱 이름 입력 후 생성 → 16자리 문자열 발급
- 이 16자리를 SMTP 비밀번호로 사용
자격증명은 코드에 절대 하드코딩하지 않습니다. 환경변수로 분리합니다.
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=no-reply@example.com
SMTP_PASS=xxxxxxxxxxxxxxxx
EMAIL_FROM="My Service <no-reply@example.com>"
코드에 박아두면 저장소 히스토리에 영구히 남고, 키를 바꿀 때마다 재배포가 필요합니다.
2. 메일 발송 — nodemailer / JavaMailSender
통로가 생겼으니 실제로 한 통 보내봅니다. Node 에서는 nodemailer 를 씁니다.
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false, // 587 → STARTTLS, secure 는 false
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendOtpMail(to: string, code: string) {
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject: "인증 코드",
text: `인증 코드는 ${code} 입니다. 5분 안에 입력해 주세요.`, // plain-text fallback
html: `<p>인증 코드는 <strong>${code}</strong> 입니다.</p>
<p>5분 안에 입력해 주세요.</p>`,
});
}
text 와 html 을 함께 넣는 이유는, 일부 메일 클라이언트나 스크린리더가 HTML 본문을 제대로 못 읽기 때문입니다. plain-text fallback 이 있으면 어디서나 코드를 볼 수 있습니다.
Java(Spring) 라면 JavaMailSender 로 같은 일을 합니다.
@Service
public class MailService {
private final JavaMailSender sender;
public void sendOtp(String to, String code) throws MessagingException {
MimeMessage msg = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(msg, true, "UTF-8");
helper.setTo(to);
helper.setSubject("인증 코드");
// 세 번째 인자가 plain-text, 네 번째가 HTML
helper.setText(
"인증 코드는 " + code + " 입니다.",
"<p>인증 코드는 <strong>" + code + "</strong> 입니다.</p>");
sender.send(msg);
}
}
3. OTP 코드 생성 · 만료
이제 보낼 코드를 만듭니다. Math.random() 은 쓰지 않습니다. 예측 가능한 의사난수라서, 시드를 추정하면 다음 코드를 맞출 수 있습니다. 암호학적으로 안전한 난수를 씁니다.
import { randomInt } from "crypto";
function generateOtp(): string {
// 000000 ~ 999999, 앞자리 0 보존
return String(randomInt(0, 1_000_000)).padStart(6, "0");
}
Java 라면 SecureRandom 입니다.
private static final SecureRandom RANDOM = new SecureRandom();
private String generateOtp() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
생성한 코드는 만료 시각과 함께 저장합니다. Redis 가 있으면 TTL 이 곧 만료라 가장 깔끔합니다.
// 키: otp:<이메일>, 값: 코드, 5분 후 자동 삭제
await redis.set(`otp:${email}`, code, "EX", 300);
Redis 가 없으면 DB 컬럼에 만료 시각을 둡니다.
CREATE TABLE email_otps (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
만료는 보통 5분이면 충분합니다. 너무 길면 노출된 코드의 유효 시간이 길어집니다.
4. OTP 검증
사용자가 코드를 입력하면 세 가지를 확인합니다 — 일치 · 미만료 · 미사용.
async function verifyOtp(email: string, input: string): Promise<boolean> {
const stored = await redis.get(`otp:${email}`);
if (!stored) return false; // 없거나 이미 만료(TTL 소멸)
if (stored !== input) {
// 틀린 시도 카운트 — 과다 시 차단
const tries = await redis.incr(`otp:${email}:tries`);
await redis.expire(`otp:${email}:tries`, 300); // OTP 와 같은 수명 — 만료 후 카운터 초과 누적 방지
if (tries >= 5) await redis.del(`otp:${email}`);
return false;
}
// 성공 — 일회성: 즉시 삭제해 재사용 차단
await redis.del(`otp:${email}`, `otp:${email}:tries`);
return true;
}
핵심은 검증 성공 직후 코드를 삭제 하는 것입니다. 안 지우면 같은 코드를 여러 번 쓸 수 있어 일회용의 의미가 사라집니다. 또 틀린 시도가 일정 횟수(예: 5회) 를 넘으면 코드를 폐기합니다. 6자리는 100만 분의 1 이지만, 무한 시도를 허용하면 자동화로 뚫립니다. 시도 카운터에도 같은 만료를 걸어 다음 OTP 가 깨끗한 상태에서 시작하도록 합니다.
5. rate-limit — 재발송 제한
검증 못지않게 발송 쪽 남용 도 막아야 합니다. 재발송 버튼을 무한히 누르면 메일함이 폭주하고, 발신 계정이 스팸으로 차단될 수 있습니다. 동일 이메일에 분당 1회만 허용합니다.
async function canResend(email: string): Promise<boolean> {
const key = `otp:resend:${email}`;
// SET NX — 키가 없을 때만 생성, 60초 TTL
const ok = await redis.set(key, "1", "EX", 60, "NX");
return ok === "OK"; // null 이면 아직 60초 안 지남
}
// 발송 핸들러
if (!(await canResend(email))) {
return Response.json(
{ error: "잠시 후 다시 시도해 주세요." },
{ status: 429 }
);
}
SET ... NX 는 "키가 없을 때만 쓰기" 라서, 재발송 잠금에 딱 맞습니다. 60초 안에 다시 누르면 키가 이미 있어 null 이 돌아오고, 거기서 막습니다.
6. 발송 비활성 모드
개발 · 테스트 환경에서 매번 실제 메일을 쏘면, 받은편지함이 지저분해지고 발신 한도를 갉아먹습니다. 플래그 하나로 발송을 끄고 콘솔에 코드를 찍습니다.
const EMAIL_ENABLED = process.env.EMAIL_ENABLED === "true";
export async function sendOtpMail(to: string, code: string) {
if (!EMAIL_ENABLED) {
console.info(`[mail:dev] to=${to} code=${code}`);
return; // 실제 발송 생략
}
await transporter.sendMail({ /* ... */ });
}
개발 환경 .env 에는 EMAIL_ENABLED=false, 운영에는 true 를 둡니다. 테스트는 콘솔 로그에서 코드를 읽어 그대로 진행할 수 있습니다.
발송이 실패할 수도 있습니다 (SMTP 일시 장애 등). 이때 메일 발송 실패가 사용자 요청 전체를 깨뜨리지 않게 분리합니다. 가입 자체는 처리하고, "코드 재전송" 버튼을 따로 제공하는 식입니다.
try {
await sendOtpMail(email, code);
} catch (e) {
// 발송만 실패 — 가입 흐름은 유지, 재전송 버튼 노출
logger.warn("otp mail send failed", e);
}
자주 걸리는 자리
- OTP 평문 저장 후 방치 — 일회성·만료가 핵심. 만료·삭제 없이 두면 코드가 영구 유효
- rate-limit 부재 — 재발송 버튼 무한 클릭으로 메일함 폭주, 발신 계정 스팸 차단
- 동기 발송으로 요청 블로킹 — SMTP 응답을 기다리는 동안 사용자 요청이 멈춤. 큐 또는 비동기로 분리
- From 도메인 · 계정 불일치 —
EMAIL_FROM주소와 인증한 SMTP 계정이 다르면 스팸 처리되거나 발송 거부 - 테스트 환경에서 실제 발송 —
EMAIL_ENABLED같은 플래그 없이 돌리면 실제 메일함으로 코드가 날아감
하고픈 말
이메일 인증은 코드 양은 적지만, "코드는 짧게 살고, 한 번 쓰면 죽고, 무한히 보낼 수 없다" 는 세 규칙을 지키는지가 전부입니다. 세 가지만 챙기면 나머지는 평범한 메일 발송입니다.
Next
- security/01-jwt-rotation
- backend/14-email-otp
🎉 웹 보안의 기초 — JWT · OAuth · OWASP 완주를 축하해요
이어서 어떤 걸 배워 볼까요?