Nodemailer
서비스를 운영하다 보면 이메일을 보내야 하는 상황이 자주 생긴다. 회원가입 인증, 비밀번호 재설정, 알림 발송 등. 직접 SMTP 프로토콜을 구현하려면 소켓 연결부터 인증 핸드셰이크, MIME 메시지 포맷팅까지 신경 써야 할 게 한두 가지가 아니다. Nodemailer는 Node.js 환경에서 이메일 발송을 추상화해주는 라이브러리로, 복잡한 SMTP 통신을 간단한 API로 감싸준다.
SMTP란
Nodemailer를 이해하려면 먼저 SMTP(Simple Mail Transfer Protocol)를 알아야 한다. SMTP는 이메일을 보내는 표준 프로토콜이다. 클라이언트가 SMTP 서버에 접속해서 "이 메일을 이 주소로 보내줘"라고 요청하면, 서버가 수신자의 메일 서버로 전달하는 구조다.
일반적인 흐름은 이렇다:
- 클라이언트가 SMTP 서버에 TCP 연결 (보통 포트 587 또는 465)
- EHLO 명령으로 핸드셰이크
- AUTH 명령으로 인증 (사용자명 + 비밀번호)
- MAIL FROM, RCPT TO로 발신자/수신자 지정
- DATA 명령으로 메일 본문 전송
- QUIT으로 연결 종료
이 과정을 직접 구현하면 코드가 복잡해지고, TLS 암호화, 연결 풀링, 에러 핸들링 등을 모두 직접 처리해야 한다. Nodemailer가 이 모든 걸 대신 해준다.
설치 및 기본 구조
npm install nodemailer
# TypeScript라면 타입 정의도 설치
npm install -D @types/nodemailer
Nodemailer의 핵심 개념은 Transporter다. Transporter는 SMTP 서버와의 연결을 관리하는 객체로, 한 번 생성하면 여러 번 재사용할 수 있다. 매번 연결을 새로 만들지 않아도 된다.
import * as nodemailer from 'nodemailer';
// 1. Transporter 생성
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: 'user@gmail.com',
pass: 'app-password',
},
});
// 2. 메일 발송
await transporter.sendMail({
from: '"My Service" <user@gmail.com>',
to: 'recipient@example.com',
subject: '안녕하세요',
text: '텍스트 본문',
html: '<h1>HTML 본문</h1>',
});
이게 Nodemailer의 전부라고 해도 과언이 아니다. createTransport로 연결 설정을 정의하고, sendMail로 메일을 보낸다.
createTransport 옵션 상세
기본 SMTP 설정
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', // SMTP 서버 주소
port: 587, // 포트 번호
secure: false, // true면 465 포트에 TLS 직접 연결
auth: {
user: 'user@gmail.com',
pass: 'app-password',
},
});
secure 옵션이 혼란스러울 수 있다. secure: false라고 해서 암호화가 안 되는 게 아니다. 587 포트에서는 처음에 평문으로 연결한 뒤 STARTTLS 명령으로 암호화를 시작한다. secure: true는 465 포트에서 처음부터 TLS로 연결하는 방식이다. 현재 표준은 587 + STARTTLS를 권장한다.
| 포트 | secure | 암호화 방식 | 비고 |
|---|---|---|---|
| 587 | false | STARTTLS (연결 후 암호화 시작) | 권장 |
| 465 | true | 처음부터 TLS | 레거시지만 여전히 지원 |
| 25 | false | 선택적 STARTTLS | 서버 간 릴레이용, 인증 없이 사용하기도 함 |
연결 풀링
대량의 메일을 보내야 한다면 풀링을 활성화할 수 있다.
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
pool: true, // 커넥션 풀 사용
maxConnections: 5, // 최대 동시 연결 수
maxMessages: 100, // 연결당 최대 메시지 수
auth: { user: '...', pass: '...' },
});
풀링을 쓰면 SMTP 연결을 매번 새로 맺지 않고 재사용한다. TCP 핸드셰이크와 TLS 협상이 비용이 크기 때문에, 대량 발송 시 성능 차이가 크다. maxMessages에 도달하면 연결을 닫고 새 연결을 만든다 — SMTP 서버가 하나의 연결에서 무한히 메일을 보내는 걸 허용하지 않는 경우가 많기 때문이다.
타임아웃 설정
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
secure: false,
connectionTimeout: 5000, // 연결 타임아웃 (ms)
greetingTimeout: 5000, // 서버 인사 응답 대기
socketTimeout: 10000, // 소켓 비활성 타임아웃
auth: { user: '...', pass: '...' },
});
프로덕션 환경에서는 타임아웃을 반드시 설정해야 한다. SMTP 서버가 응답하지 않으면 연결이 무한히 대기하면서 리소스를 점유할 수 있다.
sendMail 옵션 상세
const info = await transporter.sendMail({
from: '"서비스명" <noreply@example.com>',
to: 'user@example.com',
cc: 'manager@example.com', // 참조
bcc: 'admin@example.com', // 숨은 참조
replyTo: 'support@example.com', // 답장 받을 주소
subject: '메일 제목',
text: '텍스트 버전', // 텍스트 전용 클라이언트용
html: '<h1>HTML 버전</h1>', // HTML을 지원하는 클라이언트용
});
console.log('Message ID:', info.messageId);
from 주소 포맷
from 필드는 여러 형식을 지원한다.
// 이메일만
from: 'user@example.com'
// 이름 + 이메일
from: '"My Service" <user@example.com>'
// 객체 형태
from: { name: 'My Service', address: 'user@example.com' }
다중 수신자
// 쉼표로 구분
to: 'user1@example.com, user2@example.com'
// 배열
to: ['user1@example.com', 'user2@example.com']
// 이름 포함
to: [
'"User One" <user1@example.com>',
'"User Two" <user2@example.com>',
]
text vs html
text와 html을 동시에 지정하면, 이메일 클라이언트가 HTML을 지원하면 HTML을 보여주고, 지원하지 않으면 텍스트를 보여준다. MIME의 multipart/alternative 구조가 자동으로 만들어진다. 되도록 둘 다 제공하는 게 좋다 — 텍스트 전용 클라이언트나 스크린 리더를 위해.
HTML 이메일 작성
이메일 HTML은 웹 HTML과 다르다. 메일 클라이언트마다 지원하는 CSS가 다르고, <style> 태그를 무시하는 클라이언트도 있다. 기본 원칙은 인라인 스타일을 사용하고, 테이블 레이아웃을 쓰는 것이다.
const html = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<div style="background-color: #4CAF50; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">이메일 인증</h1>
</div>
<div style="padding: 30px; background-color: #ffffff;">
<p style="font-size: 16px; color: #333;">안녕하세요, <strong>${userName}</strong>님!</p>
<p style="font-size: 14px; color: #666;">
아래 버튼을 클릭하여 이메일을 인증해주세요.
</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verifyUrl}"
style="background-color: #4CAF50; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 4px; font-size: 16px;">
이메일 인증하기
</a>
</div>
</div>
<div style="padding: 15px; text-align: center; font-size: 12px; color: #999;">
<p>이 메일은 발신 전용입니다. 문의사항은 ${supportEmail}로 연락해주세요.</p>
</div>
</div>
`;
이메일 HTML에서 피해야 할 것들:
- 외부 CSS 파일 — 대부분의 클라이언트가 무시한다
<style>태그 — Gmail은 지원하지만 Outlook은 일부만 지원- Flexbox, Grid — 지원하지 않는 클라이언트가 많다
position: absolute/fixed— 메일 클라이언트에서 무시됨- JavaScript — 보안상 완전히 차단됨
첨부 파일
await transporter.sendMail({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: '첨부 파일 테스트',
text: '첨부 파일을 확인해주세요.',
attachments: [
// 파일 경로
{
filename: 'report.pdf',
path: '/tmp/report.pdf',
},
// 버퍼
{
filename: 'data.csv',
content: Buffer.from('id,name\n1,Alice\n2,Bob'),
},
// 문자열
{
filename: 'hello.txt',
content: 'Hello, World!',
},
// URL (Nodemailer가 다운로드해서 첨부)
{
filename: 'image.png',
path: 'https://example.com/image.png',
},
],
});
인라인 이미지 (CID)
HTML 이메일에 이미지를 임베딩하려면 CID(Content-ID) 방식을 사용한다. 외부 URL 대신 이미지를 메일 자체에 포함시키는 방법이다.
await transporter.sendMail({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: '인라인 이미지',
html: '<img src="cid:logo" style="width: 200px;" />',
attachments: [
{
filename: 'logo.png',
path: '/assets/logo.png',
cid: 'logo', // html에서 cid:logo로 참조
},
],
});
CID 방식의 장점은 수신자가 이미지를 별도로 다운로드하지 않아도 된다는 것이다. 단점은 메일 크기가 커진다는 것. 로고 같은 작은 이미지에 적합하고, 큰 이미지는 외부 URL이 낫다.
Gmail SMTP 사용
Gmail을 SMTP 서버로 사용하는 경우가 많다. 무료이고 안정적이기 때문이다. 단, 일반 비밀번호로는 로그인할 수 없고 앱 비밀번호를 발급받아야 한다.
- Google 계정 → 보안 → 2단계 인증 활성화
- 앱 비밀번호 생성 (Mail 선택)
- 발급된 16자리 비밀번호를 사용
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD, // 앱 비밀번호
},
});
Gmail SMTP의 제한 사항:
- 일일 발송 한도: 500통 (무료 계정) / 2,000통 (Google Workspace)
- 수신자 한도: 메일당 최대 500명
- 발송 속도 제한이 있어서 대량 발송에는 부적합
대량 발송이 필요하면 Amazon SES, SendGrid, Mailgun 같은 전문 서비스를 쓰는 게 낫다.
에러 핸들링
SMTP 발송은 네트워크 I/O이므로 다양한 에러가 발생할 수 있다.
async function sendMailWithRetry(
transporter: nodemailer.Transporter,
mailOptions: nodemailer.SendMailOptions,
maxRetries: number = 3,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const info = await transporter.sendMail(mailOptions);
console.log(`메일 발송 성공: ${info.messageId}`);
return;
} catch (error) {
console.error(`발송 실패 (시도 ${attempt}/${maxRetries}):`, error.message);
// 인증 오류는 재시도해도 소용없다
if (error.responseCode === 535) {
throw new Error('SMTP 인증 실패 — 자격증명을 확인하세요');
}
// 수신자 주소 오류도 재시도 불필요
if (error.responseCode === 550) {
throw new Error(`유효하지 않은 수신자: ${mailOptions.to}`);
}
if (attempt === maxRetries) {
throw error;
}
// 네트워크 오류는 잠시 기다렸다가 재시도
await new Promise(resolve =>
setTimeout(resolve, 1000 * attempt)
);
}
}
}
주요 SMTP 에러 코드:
| 코드 | 의미 | 재시도 가능 |
|---|---|---|
| 421 | 서비스 일시 중단 | ✅ |
| 450 | 메일박스 사용 불가 (일시적) | ✅ |
| 451 | 서버 처리 에러 | ✅ |
| 535 | 인증 실패 | ❌ |
| 550 | 메일박스 존재하지 않음 | ❌ |
| 552 | 저장 공간 초과 | ❌ |
| 553 | 잘못된 메일 주소 | ❌ |
일시적 에러(4xx)는 재시도 가능하지만, 영구적 에러(5xx)는 재시도해도 해결되지 않는다. 에러 코드를 구분해서 불필요한 재시도를 방지하는 게 중요하다.
연결 검증
서비스 시작 시 SMTP 연결이 정상인지 미리 확인할 수 있다.
const transporter = nodemailer.createTransport({ /* ... */ });
try {
await transporter.verify();
console.log('SMTP 연결 정상');
} catch (error) {
console.error('SMTP 연결 실패:', error.message);
process.exit(1);
}
verify()는 실제로 SMTP 서버에 접속해서 인증까지 수행한다. 서버 부팅 시 한 번 호출해서 설정이 올바른지 확인하면, 나중에 메일 발송 시 설정 문제로 실패하는 걸 방지할 수 있다.
서비스 클래스 패턴
실제 프로젝트에서는 Transporter를 클래스로 감싸서 사용하는 경우가 많다. 메일 종류별로 메서드를 분리하고, 공통 로직(에러 핸들링, 로깅)을 한 곳에 모은다.
import * as nodemailer from 'nodemailer';
class EmailService {
private transporter: nodemailer.Transporter;
private senderAddress: string;
constructor() {
this.senderAddress = process.env.EMAIL_USER;
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: this.senderAddress,
pass: process.env.EMAIL_PASSWORD,
},
});
}
private async send(options: nodemailer.SendMailOptions): Promise<void> {
try {
await this.transporter.sendMail(options);
console.log(`이메일 발송 성공: ${options.to}`);
} catch (error) {
console.error(`이메일 발송 실패: ${options.to} - ${error.message}`);
throw error;
}
}
async sendVerification(email: string, name: string, token: string): Promise<void> {
const verifyUrl = `https://example.com/verify?token=${token}`;
await this.send({
from: `"My App" <${this.senderAddress}>`,
to: email,
subject: '이메일 인증',
html: `
<p>안녕하세요, ${name}님!</p>
<p><a href="${verifyUrl}">여기를 클릭</a>하여 이메일을 인증해주세요.</p>
`,
});
}
async sendPasswordReset(email: string, name: string, token: string): Promise<void> {
const resetUrl = `https://example.com/reset-password?token=${token}`;
await this.send({
from: `"My App" <${this.senderAddress}>`,
to: email,
subject: '비밀번호 재설정',
html: `
<p>안녕하세요, ${name}님!</p>
<p><a href="${resetUrl}">여기를 클릭</a>하여 비밀번호를 재설정하세요.</p>
<p>이 링크는 1시간 후 만료됩니다.</p>
`,
});
}
}
이 패턴의 장점:
send()메서드에 에러 핸들링과 로깅을 집중시켜 중복 제거- 메일 종류별 메서드가 분리되어 있어서 호출하는 쪽이 깔끔
- 환경 변수를 생성자에서 한 번만 읽고, 유효성 검사도 한 곳에서 처리
테스트
Ethereal 테스트 계정
Nodemailer는 테스트용 SMTP 서비스인 Ethereal을 제공한다. 실제로 메일이 발송되지 않고, 웹에서 발송된 메일을 확인할 수 있다.
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
const info = await transporter.sendMail({
from: '"Test" <test@example.com>',
to: 'recipient@example.com',
subject: '테스트',
text: 'Hello!',
});
// 브라우저에서 메일 확인 가능한 URL
console.log(nodemailer.getTestMessageUrl(info));
// => https://ethereal.email/message/...
단위 테스트에서 모킹
실제 테스트에서는 transporter를 모킹해서 SMTP 연결 없이 테스트한다.
import { jest } from '@jest/globals';
// sendMail을 모킹
const mockSendMail = jest.fn().mockResolvedValue({
messageId: '<test-id@example.com>',
});
jest.mock('nodemailer', () => ({
createTransport: jest.fn().mockReturnValue({
sendMail: mockSendMail,
}),
}));
// 테스트
it('인증 메일을 발송한다', async () => {
const service = new EmailService();
await service.sendVerification('user@example.com', 'Alice', 'token-123');
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
subject: expect.stringContaining('인증'),
}),
);
});
대안 비교
| 특성 | Nodemailer | SendGrid SDK | Amazon SES SDK |
|---|---|---|---|
| 프로토콜 | SMTP | HTTP API | HTTP API |
| 설치 크기 | 가벼움 | 가벼움 | AWS SDK 전체 필요 |
| 가격 | SMTP 서버에 따라 다름 | 100통/일 무료 | 월 62,000통 무료 (EC2) |
| 설정 난이도 | 낮음 | 중간 | 높음 (DNS, IAM 설정) |
| 대량 발송 | 풀링으로 가능하나 한계 있음 | 최적화됨 | 최적화됨 |
| 발송 추적 | 없음 | 오픈/클릭 추적 | CloudWatch 연동 |
Nodemailer는 소규모 프로젝트나 직접 SMTP 서버를 운영하는 경우에 적합하다. 대량 발송, 발송 추적, 바운스 처리 등이 필요하면 전문 서비스의 SDK를 사용하는 게 낫다. 다만 Nodemailer도 SendGrid나 SES의 SMTP 인터페이스를 통해 연결할 수 있어서, 코드를 크게 변경하지 않고 인프라만 교체하는 것도 가능하다.
관련 개념
- SMTP — 이메일 전송 프로토콜. Nodemailer가 내부적으로 사용
- MIME — 이메일의 멀티파트 구조 (텍스트 + HTML + 첨부파일)
- SPF/DKIM/DMARC — 이메일 인증 메커니즘. 스팸 방지를 위해 DNS에 설정 필요
- 메시지 큐 — 이메일 발송을 비동기로 처리할 때 RabbitMQ 등과 함께 사용