codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›backend

이메일 발송과 OTP — SMTP

2026-05-18 게시·0회 조회

이메일 발송과 OTP — SMTP

회원 가입 확인, 비밀번호 재설정, 일회용 인증 코드 — 서비스가 사용자에게 직접 무언가를 보내야 할 때 가장 먼저 닿는 채널이 이메일입니다. 푸시 알림과 달리 별도 권한 동의가 필요 없고, 사실상 모든 사용자가 주소를 하나씩 가지고 있기 때문입니다.

다만 이메일은 "보내면 도착한다" 가 보장되지 않는 채널이기도 합니다. 발송 자체는 한 줄로 끝나지만, 그 메일이 스팸함이 아니라 받은편지함에 들어가게 만드는 일은 발신자 인증·발송 분리·실패 처리까지 함께 봐야 하는 주제입니다.

1. SMTP 와 이메일 전송 경로

SMTP (Simple Mail Transfer Protocol) 는 메일 서버 사이에서 이메일을 주고받는 표준 프로토콜입니다. 1982 년 RFC 821 로 처음 정의됐고, 현재는 RFC 5321 이 기준입니다. "메일을 보낸다" 는 동작의 실제 전송 규약이 SMTP 입니다 (받은편지함을 읽는 쪽은 IMAP·POP3 로 역할이 다릅니다).

메일 한 통이 도착하기까지의 경로는 대략 이렇습니다.

작성   메일 클라이언트 · 애플리케이션이 메일 본문 작성
  │
제출   SMTP 로 발신(submission) 서버에 제출        ← 포트 587
  │
중계   발신 서버가 수신자 도메인의 MX 레코드 조회
  │
전달   수신 메일 서버로 SMTP 전송                  ← 포트 25
  │
보관   수신 서버가 사서함에 저장
  │
열람   사용자가 IMAP · POP3 · 웹메일로 읽음

포트는 역할이 나뉩니다.

포트 용도 암호화
25 서버 간 메일 전달 (MTA-to-MTA) 보통 평문, 기회적 STARTTLS
465 클라이언트 제출 (submission) 연결 시작부터 TLS (SMTPS)
587 클라이언트 제출 (submission) 평문 연결 후 STARTTLS 로 승격

애플리케이션이 메일을 보낼 때 쓰는 포트는 25 가 아니라 587 또는 465 입니다. 포트 25 는 스팸 차단을 위해 대부분의 ISP·클라우드가 아웃바운드를 막아 두기 때문입니다.

STARTTLS vs SSL/TLS — 두 가지는 암호화를 언제 시작하느냐가 다릅니다. SSL/TLS (포트 465) 는 연결을 여는 순간부터 TLS 핸드셰이크로 시작합니다. STARTTLS (포트 587) 는 평문으로 연결을 연 뒤 STARTTLS 명령으로 같은 연결을 암호화로 승격합니다. 보안 수준은 둘 다 충분하며, 라이브러리 설정에서 587 은 secure: false + STARTTLS, 465 는 secure: true 로 구분되는 경우가 많습니다.

2. Gmail SMTP 와 앱 비밀번호

소규모 서비스나 개발 단계에서는 별도 메일 서버 없이 Gmail 의 SMTP 를 빌려 쓰는 일이 흔합니다. 접속 정보는 smtp.gmail.com:587 (STARTTLS) 입니다.

여기서 한 가지 함정이 있습니다. 계정의 일반 로그인 비밀번호로는 SMTP 인증이 되지 않습니다. Google 은 2022 년부터 "보안 수준이 낮은 앱" 의 비밀번호 직접 사용을 막았습니다. SMTP 로 메일을 보내려면 다음 절차가 필요합니다.

  1. 계정에 2단계 인증 (2FA) 을 활성화한다.
  2. 계정 보안 설정에서 "앱 비밀번호" (App Password) 를 발급한다 — 16자리 문자열.
  3. SMTP 인증에는 일반 비밀번호 대신 이 앱 비밀번호를 쓴다.

앱 비밀번호는 한 용도(이 서비스의 메일 발송)에만 쓰이는 별도 자격증명이므로, 유출 시 그 비밀번호만 폐기하면 됩니다. 일반 비밀번호를 코드·환경 변수에 넣지 않는다는 점에서도 안전합니다.

또 하나 기억할 것은 발송 한도 입니다. 무료 Gmail 계정의 SMTP 발송은 하루 약 500 통 수준으로 제한됩니다 (Workspace 유료 계정은 약 2,000 통). 이 한도를 넘기면 일시적으로 발송이 차단되므로, Gmail SMTP 는 어디까지나 소량 트랜잭션 메일용이라고 생각하는 편이 안전합니다. 대량 발송이 필요하면 8 절의 매니지드 서비스로 넘어갑니다.

3. 라이브러리 — nodemailer 와 JavaMailSender

SMTP 의 명령(EHLO·AUTH·MAIL FROM·RCPT TO·DATA)을 직접 다루는 일은 거의 없습니다. 언어별 라이브러리가 이를 감싸 줍니다.

Node.js — nodemailer. 사실상 표준입니다. transporter 를 한 번 만들고 재사용합니다.

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'smtp.gmail.com',
  port: 587,
  secure: false,                 // 587 = STARTTLS, true 면 465
  auth: { user: SMTP_USER, pass: SMTP_PASS },
});

await transporter.sendMail({
  from: '"My Service" <noreply@example.com>',
  to: 'user@example.com',
  subject: '인증 코드',
  text: '인증 코드는 482915 입니다. 5분 안에 입력해 주세요.',
  html: '<p>인증 코드는 <b>482915</b> 입니다. 5분 안에 입력해 주세요.</p>',
});

Java / Spring — JavaMailSender. Spring 은 spring-boot-starter-mail 이 JavaMailSender 빈을 제공합니다.

@Autowired
private JavaMailSender mailSender;

public void sendOtp(String to, String code) {
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
    helper.setFrom("noreply@example.com");
    helper.setTo(to);
    helper.setSubject("인증 코드");
    helper.setText(
        "인증 코드는 " + code + " 입니다.",                        // plain-text
        "<p>인증 코드는 <b>" + code + "</b> 입니다.</p>");          // HTML
    mailSender.send(message);
}

본문은 가능하면 HTML 본문과 plain-text fallback 을 함께 담는 멀티파트로 보냅니다. 텍스트 전용 클라이언트나 미리보기 환경에서는 plain-text 가 쓰이고, plain-text 가 있으면 일부 스팸 필터에서 점수가 유리해지기도 합니다. 한쪽만 보낼 거라면 plain-text 를 우선합니다.

4. OTP 코드 생성과 검증

OTP (One-Time Password) 는 한 번만 쓰고 버리는 단기 인증 코드입니다. 이메일·SMS 인증, 2단계 인증의 코드가 여기에 해당합니다. 핵심은 추측 불가능 하고 수명이 짧다 는 두 가지입니다.

먼저 생성. 코드는 반드시 암호학적 난수 생성기 로 만듭니다.

import { randomInt } from 'node:crypto';
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');  // '000000'~'999999'
import java.security.SecureRandom;
String code = String.format("%06d", new SecureRandom().nextInt(1_000_000));

Math.random() 이나 java.util.Random 은 쓰지 않습니다. 이들은 시드가 알려지면 다음 값을 예측할 수 있는 의사난수입니다. 인증 코드처럼 보안이 걸린 값은 crypto.randomInt() · SecureRandom 같은 CSPRNG 만 사용합니다.

다음은 저장과 수명. 생성한 코드는 유효기간(예: 5분)과 함께 저장합니다. Redis 라면 TTL 이 자연스럽습니다.

SET otp:user@example.com 482915 EX 300        # 5분 후 자동 만료

DB 에 저장한다면 expires_at 컬럼을 두고 검증 시 비교합니다. 검증 단계는 세 가지를 확인합니다.

  1. 해당 이메일의 코드가 존재 하는가 (없으면 만료됐거나 발급된 적 없음).
  2. 입력값이 저장값과 일치 하는가.
  3. 아직 만료 전 인가.

검증에 성공하면 그 코드는 즉시 삭제 합니다. 한 번 쓴 코드가 살아 있으면 "일회용" 이 아니게 됩니다. 또한 틀린 입력이 일정 횟수(예: 5회)를 넘으면 그 코드를 폐기해 무차별 대입을 막습니다.

마지막으로 rate-limit. 발급 요청 자체에 제한을 둡니다 — 같은 이메일로 분당 1회, 시간당 5회 같은 식입니다. 제한이 없으면 한 명의 받은편지함을 코드 메일로 도배하는 악용이 가능하고, 발송 한도도 금세 소진됩니다.

5. 발신자 신뢰 — SPF·DKIM·DMARC

메일을 보냈는데 받은편지함이 아니라 스팸함에 들어가는 가장 흔한 원인은 본문이 아니라 발신자 인증 입니다. 받는 메일 서버는 "이 메일이 정말 그 도메인이 보낸 게 맞는가" 를 세 가지 DNS 기반 장치로 검사합니다.

장치 한 줄 정의 DNS 레코드
SPF 이 도메인의 메일을 보낼 수 있는 서버 IP 목록 TXT (v=spf1 ...)
DKIM 메일에 도메인의 개인키 전자서명 을 붙임 TXT (공개키 게시)
DMARC SPF·DKIM 이 실패하면 어떻게 처리할지 정책 + 리포트 TXT (v=DMARC1 ...)

SPF (Sender Policy Framework) 는 "내 도메인의 메일은 이 IP 들에서만 나간다" 를 DNS 에 선언합니다. 받는 서버는 메일이 온 서버 IP 가 그 목록에 있는지 확인합니다.

DKIM (DomainKeys Identified Mail) 은 발신 서버가 메일 헤더·본문에 개인키로 서명을 붙이고, 받는 서버는 DNS 에 게시된 공개키로 그 서명을 검증합니다. 메일이 중간에 변조되지 않았고 그 도메인이 서명했음을 보장합니다.

DMARC (Domain-based Message Authentication, Reporting and Conformance) 는 SPF·DKIM 검사가 실패했을 때의 정책을 정합니다 — none (관측만), quarantine (스팸함), reject (수신 거부). 더불어 인증 실패 통계를 발신 도메인 관리자에게 리포트로 보냅니다.

이 셋이 설정돼 있지 않으면 메일이 스팸으로 분류될 위험이 큽니다. 특히 주의할 점은 From 주소의 도메인과 SMTP 인증 계정의 도메인이 정렬(alignment) 돼야 한다는 것입니다. noreply@example.com 으로 보내면서 인증은 전혀 다른 도메인 계정으로 하면 DMARC 정렬 검사를 통과하지 못합니다.

6. 트랜잭션 메일 vs 마케팅 메일

이메일은 성격에 따라 둘로 나뉘고, 다루는 방식도 달라야 합니다.

구분 트랜잭션 메일 마케팅 메일
발송 계기 사용자의 행동에 대한 응답 서비스가 능동적으로 발송
예 가입 확인, OTP, 비밀번호 재설정, 주문 알림 뉴스레터, 프로모션, 공지
수신 대상 행동을 한 단일 사용자 다수 구독자
수신거부 보통 불필요 (사용자가 직접 요청한 것) 필수 — 수신거부 링크 의무
기대 도달 시점 즉시 (초 단위) 지연 허용

둘을 섞으면 문제가 생깁니다. 마케팅 메일은 수신거부·스팸 신고가 잦아 발송 도메인·IP 의 평판(reputation)을 떨어뜨립니다. 그 평판 하락이 같은 인프라로 나가는 트랜잭션 메일까지 끌어내리면, 사용자가 정작 기다리던 OTP 메일이 스팸함으로 갑니다.

그래서 일정 규모 이상이면 발송 인프라(도메인·IP·서비스)를 트랜잭션용과 마케팅용으로 분리 하는 것이 권장됩니다. 트랜잭션 메일의 도달률을 마케팅 발송의 부작용으로부터 격리하는 것입니다.

7. 발송 비활성 모드와 실패 처리

개발·테스트 환경에서는 실제 메일을 보내지 않습니다. 개발 중 매번 진짜 메일이 나가면 받은편지함이 더러워지고, 발송 한도가 줄고, 테스트 코드가 외부 SMTP 에 의존하게 됩니다. 흔한 패턴은 EMAIL_ENABLED 같은 플래그를 두고, 비활성일 때는 발송 대신 코드·본문을 콘솔에 로그로 찍는 것입니다.

async function sendEmail(opts) {
  if (!EMAIL_ENABLED) {
    logger.info('[email disabled] would send', { to: opts.to, subject: opts.subject });
    return;                       // 실제 전송 생략
  }
  await transporter.sendMail(opts);
}

이렇게 하면 개발 환경에서 OTP 코드를 콘솔에서 바로 확인할 수 있어 테스트가 빨라집니다.

실패 처리는 사용자 요청 처리와 분리합니다. 메일 발송은 네트워크 너머의 외부 작업이라 언제든 실패할 수 있습니다(타임아웃, SMTP 오류, 한도 초과). 그런데 회원 가입의 마지막 단계가 "환영 메일 발송" 이라면, 메일 발송 실패가 가입 자체를 실패시켜서는 안 됩니다. 가입은 성공 처리하고, 메일은 별도로 재시도합니다.

또한 메일 발송을 요청 스레드 안에서 동기로 호출하지 않습니다. SMTP 왕복은 수백 밀리초에서 수 초까지 걸릴 수 있어, 동기로 부르면 그 시간만큼 사용자 응답이 지연됩니다. 발송은 비동기로 떼어내거나 큐(메시지 큐·작업 큐)에 넣고, 사용자에게는 즉시 응답합니다. 큐를 쓰면 실패한 메일의 재시도(backoff)도 자연스럽게 얹을 수 있습니다. 사용자 쪽에는 "메일이 안 왔어요" 를 위한 재전송 버튼 을 함께 둡니다.

8. 매니지드 이메일 서비스

Gmail SMTP 의 발송 한도, 직접 운영하는 메일 서버의 평판 관리·IP 워밍업 부담을 넘어서면 매니지드 이메일 서비스 로 옮기게 됩니다. 이들은 SMTP 또는 HTTP API 로 메일을 받아, 전달성·재시도·평판 관리를 대신해 줍니다.

서비스 특징 비고
SendGrid 오랜 업력, 폭넓은 기능, 무료 티어 존재 트랜잭션 + 마케팅
Amazon SES 매우 저렴, AWS 통합, 발송량 확장 용이 초기 샌드박스·발신 검증 필요
Postmark 트랜잭션 메일 특화, 빠른 도달 평판 마케팅 발송은 별도
Mailgun API 중심, 개발자 친화 로그·검증 도구
Resend 비교적 신생, 간결한 API·DX 트랜잭션 위주

매니지드 서비스의 장점은 전달성·분석·발송량 입니다. 도메인 인증(SPF·DKIM)을 설정하면 평판 관리·바운스 처리·열람/클릭 통계를 대신해 주고, 발송량도 한도 신청으로 늘릴 수 있습니다. 트레이드오프는 외부 의존과 비용 입니다. 발송 경로에 외부 업체가 끼어들고, 발송량이 늘면 비용도 따라 늡니다. 대부분의 서비스가 SMTP 인터페이스도 제공하므로, 처음에는 nodemailer·JavaMailSender 의 SMTP 설정만 바꿔 끼우는 식으로 마이그레이션할 수 있습니다.

자주 걸리는 자리

SPF·DKIM 미설정으로 스팸 분류 — 본문을 아무리 다듬어도 발신자 인증이 비어 있으면 받은편지함에 들어가기 어렵습니다. 도메인을 쓴다면 SPF·DKIM·DMARC 설정이 첫 단계입니다.

동기 발송으로 요청 블로킹 — 메일 발송을 요청 스레드 안에서 기다리면 SMTP 왕복 시간만큼 사용자 응답이 늦습니다. 비동기·큐로 떼어냅니다.

OTP 무한 재발송 — 발급 요청에 rate-limit 이 없으면 한 사람의 받은편지함을 코드 메일로 도배할 수 있고 발송 한도도 소진됩니다. 분당·시간당 횟수 제한을 둡니다.

OTP 평문 저장 — 인증 코드를 DB·로그에 평문으로 남기면, 그 저장소에 접근 가능한 누군가가 코드를 그대로 봅니다. 짧은 TTL 로 두거나 해시해서 저장합니다.

사용자 입력을 escape 없이 본문 삽입 — 사용자가 넣은 이름·메시지를 HTML 본문에 그대로 끼우면, 악의적 입력이 가짜 링크·내용을 심어 피싱에 악용될 수 있습니다. 메일 본문에 들어가는 사용자 입력은 HTML escape 합니다.

From 도메인과 SMTP 계정 불일치 — From 주소의 도메인과 인증한 SMTP 계정의 도메인이 다르면 DMARC 정렬 검사를 통과하지 못해 차단·스팸 처리됩니다.

Gmail 일일 한도 초과 — 무료 Gmail SMTP 는 하루 약 500 통입니다. 한도를 넘기면 발송이 일시 차단되므로, 발송량이 늘면 매니지드 서비스로 옮깁니다.

하고픈 말

이메일 발송 코드는 한 줄이지만, 그 한 줄을 신뢰할 수 있게 만드는 일은 코드 바깥에 있습니다. SPF·DKIM 같은 DNS 설정, 트랜잭션과 마케팅의 분리, 비동기 발송과 재시도 — 메일이 "보냈는데 안 왔다" 가 되는 자리는 거의 다 여기입니다. 발송 자체보다 도달과 실패 처리에 시간을 더 쓰는 편이, 사용자가 OTP 를 기다리다 이탈하는 일을 줄입니다.

Next

  • audit-log-pattern
  • api-handler-pattern

nodemailer 공식 · Gmail SMTP 설정 안내 · Spring Email 레퍼런스 · OWASP Cheat Sheet Series · Cloudflare — SPF·DKIM·DMARC 를 참고합니다.

backend 카테고리의 다른 글

카테고리 전체 보기 →
  • 공공 OpenAPI 는 자체 BFF 로 한 번 감싼다
  • 감사로그 — logAdminAction 패턴
  • WebSocket · SSE — 실시간 통신
  • REST API 입문
  • OpenAPI 사양
  • 크롤러 윤리와 도구