nodemailer로 메일 발송하기

2026.08.19

Next.js에서 Gmail SMTP + nodemailer로 메일 발송하기

1. 이 접근법의 핵심

Gmail 계정 하나 + 앱 비밀번호 하나로 SMTP 발송이 끝난다. EmailJS 같은 서드파티 SDK도, 자체 SMTP 서버 구축도, SendGrid/Resend 같은 외부 서비스 가입도 필요 없다.

성립하는 이유는 세 가지다.

  1. Gmail은 그 자체가 완성된 SMTP 서버다. smtp.gmail.com은 이미 SPF/DKIM/DMARC가 잡혀 있고 IP 평판도 확보돼 있다. 자체 SMTP를 띄웠을 때 가장 고통스러운 "메일이 스팸함으로 간다" 문제를 Google이 대신 해결해 준 상태에서 시작한다.
  2. 앱 비밀번호가 OAuth를 대체한다. Gmail API를 쓰면 OAuth 동의 화면, 리프레시 토큰 관리, 토큰 만료 처리가 따라온다. 앱 비밀번호는 그냥 16자리 문자열이라 .env에 넣으면 끝이다.
  3. Next.js Server Action / Route Handler는 Node.js 런타임이다. 브라우저에서 SMTP를 열 수는 없지만 서버 코드에서는 그냥 TCP 소켓을 연다. 별도 백엔드 서버를 세울 이유가 없다.

"백엔드 없는 백엔드 메일 발송" 이고, 발송량이 하루 수백 통 수준인 신청폼/문의폼/알림 메일에는 이 조합이 사실상 최적이다.

언제 이걸 쓰면 안 되는가

상황판단
신청폼, 문의폼, 관리자 알림 (하루 수십~수백 통)✅ 최적
발신 도메인이 Gmail/Workspace 계정 도메인✅ 최적
뉴스레터, 마케팅 대량 발송❌ 일일 한도에 걸린다
발송 로그·오픈율·바운스 추적이 제품 요구사항❌ 전용 서비스(Resend, SES 등)로
임의의 from 주소로 위장 발송❌ Gmail이 계정 주소로 덮어쓴다

일일 한도(2026 기준): 무료 Gmail 계정 약 500 수신자/일, Google Workspace 약 2,000 수신자/일. 초과하면 그날 남은 시간 동안 발송이 막힌다.


2. 사전 준비 (5분)

2-1. 2단계 인증 활성화

발송에 쓸 Google 계정(https://myaccount.google.com/security)에서 2단계 인증을 켠다. 앱 비밀번호는 2단계 인증이 켜져 있어야만 메뉴가 나타난다. 이게 유일한 귀찮은 단계다.

2-2. 앱 비밀번호 발급

https://myaccount.google.com/apppasswords 에서 앱 이름을 적고 생성하면 abcd efgh ijkl mnop 형태의 16자리가 나온다.

  • 공백은 제거하고 환경변수에 넣는다 → abcdefghijklmnop
  • 화면을 닫으면 다시 볼 수 없다. 바로 .env.local에 붙여넣을 것
  • 계정 비밀번호가 아니다. 유출돼도 앱 비밀번호만 폐기하면 계정은 안전하다

조직 계정(Workspace)이라면 관리자가 "보안 수준이 낮은 앱" 정책이나 앱 비밀번호를 막아뒀을 수 있다. 메뉴 자체가 안 보이면 관리 콘솔 설정을 먼저 확인한다.

2-3. 패키지

pnpm add nodemailer
pnpm add -D @types/nodemailer

3. 환경변수 — 타입 검증까지 묶기

// src/lib/common/env.ts
export const env = createEnv({
  server: {
    GMAIL_SMTP_USER: z.email(),          // 발송 계정 주소
    GMAIL_SMTP_APP_PASSWORD: z.string().min(1), // 앱 비밀번호 16자리(공백 제거)
  },
  // ...
  emptyStringAsUndefined: true,
});

@t3-oss/env-nextjs로 감싸는 이유는 단순 편의가 아니다.

  • 부팅 시점에 터진다. 값이 빠진 채 배포되면 첫 메일 발송에서 런타임 500이 나는 대신, 앱이 뜰 때 검증 에러로 잡힌다.
  • env.GMAIL_SMTP_USERstring 타입이다. process.env.Xstring | undefined라 논옵셔널 단언(!)이 곳곳에 박히게 되는데, 그 단언들이 전부 사라진다.
  • 클라이언트 번들 유출을 막는다. server 블록의 값은 클라이언트 코드에서 import하면 빌드가 실패한다.

.env.local (절대 커밋 금지, .gitignore 확인):

[email protected]
GMAIL_SMTP_APP_PASSWORD=abcdefghijklmnop

Vercel 배포 시에는 Project Settings → Environment Variables에 동일하게 등록한다.


4. Transporter — 싱글턴 + 타임아웃

// src/lib/register/mail.ts
import "server-only";
import nodemailer, { type Transporter } from "nodemailer";
import { env } from "@/lib/common/env";

let transporter: Transporter | null = null;

function getTransporter(): Transporter {
  if (!transporter) {
    transporter = nodemailer.createTransport({
      host: "smtp.gmail.com",
      port: 465,
      secure: true,
      auth: {
        user: env.GMAIL_SMTP_USER,
        pass: env.GMAIL_SMTP_APP_PASSWORD,
      },
      connectionTimeout: 10_000,
      greetingTimeout: 10_000,
      socketTimeout: 20_000,
    });
  }
  return transporter;
}

여기 담긴 결정 네 가지.

import "server-only"

이 모듈이 클라이언트 컴포넌트 트리로 실수로 딸려 들어가면 빌드 타임에 에러가 난다. SMTP 자격증명이 들어 있는 파일에는 반드시 붙인다. 이 한 줄이 자격증명 유출에 대한 가장 값싼 보험이다.

모듈 스코프 싱글턴 (lazy)

createTransport는 커넥션 풀을 들고 있다. 요청마다 새로 만들면 매번 TCP + TLS 핸드셰이크를 다시 한다. 다만 모듈 최상단에서 즉시 생성하지 않고 lazy로 두는 게 중요한데, 그래야 이 모듈을 import하는 것만으로 SMTP 설정이 평가되지 않는다(테스트에서 특히 편하다).

port: 465, secure: true

  • 465 / SSL(implicit TLS) — 연결 순간부터 암호화. secure: true와 짝
  • 587 / STARTTLS — 평문으로 연결 후 업그레이드. secure: false와 짝

둘 다 동작한다. 465는 "평문 구간이 아예 없다"는 점에서 서버 환경 기본값으로 두기 편하다. 만약 호스팅 환경이 465를 막고 있다면 587 + secure: false로 바꾼다.

타임아웃 3종 — 이게 진짜 핵심

기본값 상태로 두면 SMTP가 멎었을 때 nodemailer가 몇 분씩 매달린다.

10초/10초/20초로 끊으면 최악의 경우에도 20초 안에 실패로 떨어지고, email_logfailed로 남은 채 신청은 성공 응답을 받는다. ** 매달리는 것보다 실패하는 게 낫다**는 판단이다.

옵션의미
connectionTimeoutTCP 연결 수립까지
greetingTimeout연결 후 서버 인사(220) 수신까지
socketTimeout연결 후 소켓 무응답 허용 시간

5. 발송 - 레이어 나누기

여기서부터가 "그냥 sendMail 호출"과 실제로 쓸 만한 코드를 가르는 부분이다. 4개 레이어로 나눈다.

본문 빌더(순수함수)  →  buildMail(페이로드 조립)  →  sendAndLog(발송+기록)  →  오케스트레이터(정책)

5-1. 본문 빌더는 순수함수로

export function buildHqMailText(app: Application, openSessionDateLabel: string): string {
  return [
    `신규 신청이 접수되었습니다.`,
    ``,
    `타입: ${sessionLabel(app)}`,
    `일자: ${dateLabel(app, openSessionDateLabel)}`,
    // ...
  ].join("\n");
}

DB도 SMTP도 건드리지 않고 인자만 받아 문자열을 내놓는다. 덕분에 테스트가 expect(text).toContain("브랜드A") 한 줄로 끝난다.

특히 DB에서 읽어오는 값(openSessionDateLabel)을 함수 안에서 조회하지 않고 인자로 주입받는 점에 주목. 빌더 안에서 getSetting을 부르면 이 함수는 더 이상 순수하지 않고, 테스트마다 DB 목이 필요해진다.

5-2. 페이로드 조립은 단 한 곳에서

function buildMail(app, kind, openSessionDateLabel): {
  to: string; subject: string; text: string; replyTo?: string;
} {
  if (kind === "hq_notify") {
    return {
      to: HQ_MAIL_TO,
      subject: `${sessionLabel(app)} 신청 — ${app.company}`,
      text: buildHqMailText(app, openSessionDateLabel),
      replyTo: app.managerEmail,
    };
  }
  return { to: app.managerEmail, /* ... */ };
}

최초 발송 경로와 어드민 수동 재발송 경로가 각자 제목·수신처를 만들면, 재발송이 원본과 미묘하게 다른 메일이 된다. 두 경로 모두 buildMail만 거치게 해서 갈라질 여지를 구조적으로 없앤다. 테스트로도 못 박아 둔다.

it("재발송 메일은 최초 발송과 완전히 동일하다", async () => {
  expect(hqResent).toEqual(hqFirst);
});

5-3. replyTo 트릭

fromto가 둘 다 자기자신인 알림 메일에서, 담당자가 그냥 "답장"을 누르면 자기 자신에게 메일이 간다. replyTo를 신청자 주소로 돌려두면 답장 버튼이 곧바로 신청자에게 연결된다.

반대로 신청자에게 가는 접수확인 메일에는 replyTo를 넣지 않는다. 넣으면 신청자가 자기 자신에게 답장하게 되기 때문. 어느 방향의 메일이냐에 따라 다르다는 게 요점이다.

5-4. 발송 + 로깅

async function sendAndLog(db, app, kind, label): Promise<boolean> {
  try {
    await getTransporter().sendMail({
      from: `<${env.GMAIL_SMTP_USER}>`,
      ...buildMail(app, kind, label),
    });
    await logEmail(db, { applicationId: app.id, kind, status: "sent" });
    return true;
  } catch (e) {
    await logEmail(db, { applicationId: app.id, kind, status: "failed", error: String(e) });
    return false;
  }
}

두 가지가 중요하다.

from은 반드시 인증 계정과 같은 주소여야 한다. "표시이름" <주소> 형식으로 표시 이름은 자유롭게 붙일 수 있지만, 주소 자체를 다른 걸로 바꾸면 Gmail이 인증 계정 주소로 덮어쓰거나 거부한다. (Workspace에서 별칭을 "다른 주소로 메일 보내기"에 등록해 뒀다면 그 별칭은 가능하다.)

logEmail이 내부에서 예외를 삼키기 때문에, catch 블록에 도달하는 건 오직 발송 실패뿐이다. 로그 기록이 실패했다고 해서 이미 나간 메일이 failed로 잘못 기록되는 일이 없다.

5-5. 오케스트레이터 - 절대 throw하지 않는다

export async function sendApplicationMails(db, app): Promise<void> {
  try {
    const label = await readOpenSessionDateLabel(db);
    await sendAndLog(db, app, "hq_notify", label);

    const confirmOn = (await readSettingSafely(db, SETTING_KEYS.applicantConfirmEnabled)) === "true";
    if (confirmOn) {
      await sendAndLog(db, app, "applicant_confirm", label);
    }
  } catch (e) {
    console.error(`[mail] 발송 오케스트레이션 실패 (application ${app.id})`, e);
  }
}

호출부는 이렇게 생겼다.

// src/app/register/actions.ts
const outcome = await submitApplicationService(db, input);   // DB 커밋
if (!outcome.ok) return { ok: false, error: outcome.error };

await sendApplicationMails(db, outcome.application);          // 메일
return { ok: true };

설계 원칙: DB가 source of truth이고 메일은 알림일 뿐이다.

신청 데이터는 이미 커밋됐다. 이 시점에 메일이 실패했다고 사용자에게 "신청 실패"를 돌려주면 거짓말이 된다. 그래서 이 함수는 어떤 경로에서도 예외를 밖으로 내보내지 않는다. 방어선이 3겹이다.

  1. logEmail 내부 try — 로그 기록 실패 흡수
  2. sendAndLog 내부 try — 발송 실패 + 본문 빌더가 던지는 경우까지 흡수 (예: attendees가 배열이 아닌 JSON이면 .mapTypeError)
  3. 오케스트레이터 바깥 try — 그 밖에서 새는 모든 것

readSettingSafely도 같은 정신이다. 설정 조회는 메일 문구를 고르는 부수 정보일 뿐이라, DB가 흔들려도 시드 기본값으로 흡수하고 발송은 계속한다.


6. email_log — 실패를 보이게 만들기

메일 실패를 삼키기로 했다면, 삼킨 것이 어딘가에는 남아야 한다.

enum EmailKind   { hq_notify  applicant_confirm  @@map("email_kind") }
enum EmailStatus { sent       failed             @@map("email_status") }

model EmailLog {
  id            String      @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  applicationId String      @map("application_id") @db.Uuid
  application   Application @relation(fields: [applicationId], references: [id])
  kind          EmailKind
  status        EmailStatus
  error         String?
  createdAt     DateTime    @default(now()) @map("created_at") @db.Timestamptz(6)

  @@map("email_log")
}

이 테이블이 있으면 어드민 화면에서 "이 신청, 메일 나갔나?"를 확인할 수 있고, failed 행에 대해 수동 재발송 버튼을 붙일 수 있다.

export async function resendApplicationMail(db, applicationId, kind): Promise<boolean> {
  const app = await db.application.findUnique({ where: { id: applicationId } });
  if (!app) return false;
  return await sendAndLog(db, app, kind, await readOpenSessionDateLabel(db));
}

재발송은 토글 설정과 무관하게 보낸다. 어드민이 그 버튼을 직접 누른 것 자체가 발송 의사이기 때문이다. boolean을 반환하는 이유도 여기 있다. 자동 발송과 달리 재발송은 결과를 화면에 알려줘야 한다.

error 컬럼에 String(e)를 넣어도 자격증명은 담기지 않는다. nodemailer 에러 메시지는 SMTP 서버 응답 문자열이다. 다만 커스텀 로깅을 추가할 때는 transporter 설정 객체를 통째로 덤프하지 않도록 주의.


7. 테스트 - SMTP 없이 전부 검증하기

vi.mock으로 nodemailer 자체를 갈아끼우면 실제 메일 발송 없이 페이로드를 전량 검증할 수 있다.

// vi.mock은 파일 최상단으로 호이스팅된다 — 팩토리가 참조하는 변수는 vi.hoisted로 함께 끌어올린다
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn() }));
vi.mock("nodemailer", () => ({
  default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock })) },
}));

vi.hoisted가 포인트다. vi.mock의 팩토리는 파일 최상단으로 끌어올려지기 때문에, 평범하게 선언한 const sendMailMock을 참조하면 TDZ 에러가 난다. vi.hoisted로 감싸면 변수 선언도 같이 올라간다.

DB는 fake 객체로 대체한다.

function makeFakeDb(settings, fail = {}, found = null) {
  const logs: unknown[] = [];
  const db = {
    application: { findUnique: vi.fn(async () => found) },
    appSetting: { findUnique: vi.fn(async ({ where }) => { 
	    /* settings 조회 or throw */ }) },
    emailLog: { create: 
	    vi.fn(async ({ data }) => { 
		    logs.push(data); return data; 
			}) 
		},
  };
  return { db: db as never, logs };
}

이 세팅으로 커버되는 것들:

  • 페이로드 검증 — sendMailMock.mock.calls[0][0].to, .subject, .replyTo, .text
  • 토글 정책 — 접수확인 on/off에 따른 발송 횟수
  • 실패 경로 전부 — SMTP 다운, email_log 쓰기 실패, 설정 조회 실패, 둘 다 실패, 깨진 JSON 입력. 전부 resolves.not.toThrow()로 못 박는다
  • 최초 발송 ≡ 재발송 동치성

실패 경로 테스트는 console.error를 뿜으므로 출력을 삼켜준다.

function silenceConsoleError() {
  return vi.spyOn(console, "error").mockImplementation(() => {});
}

호출부(Server Action) 테스트에서는 메일 모듈 자체를 목으로 대체해, "메일이 호출됐는가"만 확인하고 SMTP 관심사를 완전히 격리한다.


8. 체크리스트

새 프로젝트에 옮겨 심을 때 순서대로:

  • 발송 계정에 2단계 인증 켜기
  • 앱 비밀번호 발급 → 공백 제거해서 보관
  • pnpm add nodemailer + @types/nodemailer
  • .env.localGMAIL_SMTP_USER, GMAIL_SMTP_APP_PASSWORD (+ .gitignore 확인)
  • env 스키마에 두 값 추가 (t3-env 등)
  • 배포 플랫폼 환경변수에도 등록
  • 메일 모듈 최상단에 import "server-only"
  • transporter는 lazy 싱글턴 + 타임아웃 3종
  • from은 인증 계정 주소로 (표시 이름만 커스텀)
  • 본문 빌더는 순수함수, 외부 값은 인자로 주입
  • 페이로드 조립은 buildMail 한 곳에서
  • 발송 실패가 사용자 요청을 죽이지 않게 (throw 금지)
  • 발송 결과를 email_log에 기록 + 재발송 경로 확보
  • vi.mock("nodemailer") + vi.hoisted로 테스트

9. 알아둘 함정

서버리스 콜드 스타트. 인스턴스가 새로 뜰 때마다 transporter도 새로 만들어진다. 싱글턴의 이득은 같은 인스턴스가 살아있는 동안만 유효하다. 하하 그래도 만드는 게 낫다.

Edge Runtime에서는 안 된다. nodemailer는 Node.js net/tls 모듈이 필요하다. 해당 Route Handler / Server Action이 Edge로 가지 않도록 주의한다 (Next.js Server Action 기본값은 이제 Node.js 런타임이므로 보통 문제없다).

await를 빠뜨리지 말 것. 서버리스에서 sendMailawait 없이 호출하면 응답 반환과 함께 인스턴스가 얼어붙어 메일이 그냥 안 나간다. "가끔 안 가요"의 대부분이 이 문제다.

앱 비밀번호는 계정 비밀번호 변경 시 무효화된다. 발송이 갑자기 Invalid login으로 죽으면 이걸 먼저 의심한다.

HTML 메일이 필요하면 sendMailhtml 필드를 추가하되 text도 같이 넣는다. 텍스트 대체본이 없으면 스팸 점수가 올라간다.

첫 발송 전 transporter.verify() 로 자격증명을 확인해볼 수 있다. 운영 코드에 매번 넣을 필요는 없고, 로컬에서 설정을 확인할 때 유용하다.


참고