티스토리 뷰
Gmail SMTP로 무료 이메일 인증 구현하기
안녕하세요, 개발자 여러분! 오늘은 제가 최근에 구현한 Gmail SMTP를 활용한 무료 이메일 인증 시스템에 대해 공유하려고 합니다. 메일건(Mailgun)같은 서비스는 월 $15부터 시작해서 간단한 인증 기능에 사용하기엔 부담스러운 가격이더라고요.
그래서 완전 무료로 사용할 수 있는 Gmail SMTP를 활용한 방법을 찾아보게 되었습니다.
왜 Gmail SMTP인가?
- 완전 무료
- 하루 500건까지 이메일 전송 가능 (개인 계정 기준)
- 대부분의 개발자가 이미 Gmail 계정을 가지고 있음
- 높은 배달 성공률
준비물
- Gmail 계정
- 2단계 인증이 활성화된 Google 계정
- NestJS 프로젝트 (다른 프레임워크도 비슷하게 적용 가능)
- Nodemailer 패키지
1. Gmail SMTP 설정하기
먼저 Google 계정에서 앱 비밀번호를 생성해야 합니다:
- Google 계정 보안 설정으로 이동
- 2단계 인증이 활성화되어 있는지 확인 (필수!)
- '앱 비밀번호' 옵션으로 이동
- 앱 선택 → "메일" / 기기 선택 → "Windows 컴퓨터" 선택
- 생성된 16자리 앱 비밀번호를 안전하게 저장해두세요!
Google 계정
myaccount.google.com
2. Nodemailer 설치하기
npm install nodemailer
3. NestJS에서 이메일 서비스 구현하기
모듈 구성
먼저 이메일 모듈과 서비스를 만들어 봅시다:
// mail.interfaces.ts
export interface MailModuleOptions {
user: string;
pass: string;
}
// mail.module.ts
import { DynamicModule, Global, Module } from '@nestjs/common';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import { MailModuleOptions } from './mail.interfaces';
import { MailService } from './mail.service';
@Global()
@Module({
providers: [MailService],
})
export class MailModule {
static forRoot(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
providers: [
{
provide: CONFIG_OPTIONS,
useValue: options,
},
],
exports: [MailService],
};
}
}
이메일 서비스 구현
import { Inject, Injectable } from '@nestjs/common';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import { MailModuleOptions } from './mail.interfaces';
import * as nodemailer from 'nodemailer';
@Injectable()
export class MailService {
private transporter;
constructor(
@Inject(CONFIG_OPTIONS) private readonly options: MailModuleOptions,
) {
this.transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: this.options.user,
pass: this.options.pass,
},
});
}
async sendGmail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text?: string;
html?: string;
}) {
try {
const info = await this.transporter.sendMail({
from: `"Interview App" <${this.options.user}>`, // 보내는 사람
to, // 받는 사람
subject, // 제목
text, // 일반 텍스트 (html이 없을 경우)
html, // HTML 내용
});
console.log('Email sent:', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Error sending email:', error);
return { success: false, error };
}
}
async sendVerificationEmail({
email,
code,
}: {
email: string;
code: string;
}) {
const emailSubject = '이메일 인증을 완료해주세요';
// HTML 템플릿
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이메일 인증</title>
</head>
<body style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
<div style="background-color: #ffffff; border-radius: 8px; padding: 30px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; padding-bottom: 20px; border-bottom: 1px solid #eee;">
<div style="font-size: 24px; font-weight: bold; color: #4F46E5;">Interview App</div>
</div>
<div style="padding: 30px 0;">
<h2 style="text-align: center;">이메일 인증을 완료해주세요</h2>
<p style="text-align: center;">안녕하세요! Interview App에 가입해주셔서 감사합니다.<br>아래의 인증 코드를 입력하여 이메일 인증을 완료해주세요.</p>
<div style="background-color: #F3F4F6; padding: 15px; border-radius: 6px; margin: 20px auto; text-align: center; width: 80%;">
<div style="font-size: 32px; font-weight: bold; letter-spacing: 5px; color: #4F46E5;">${code}</div>
</div>
<p style="text-align: center;">인증 코드는 30분 동안 유효합니다.<br>만약 본인이 요청하지 않았다면 이 이메일을 무시하셔도 됩니다.</p>
<p style="text-align: center;">감사합니다.<br><strong>Interview App 팀</strong></p>
</div>
<div style="margin-top: 30px; text-align: center; font-size: 12px; color: #6B7280;">
<p>© 2025 Interview App. All rights reserved.</p>
<p>본 메일은 발신 전용이므로 회신되지 않습니다.</p>
</div>
</div>
</body>
</html>
`;
return this.sendGmail({
to: email,
subject: emailSubject,
html: htmlContent,
});
}
}
4. 앱에 이메일 모듈 등록하기
app.module.ts에 Mail 모듈을 등록합니다:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MailModule } from './mail/mail.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
MailModule.forRoot({
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_PASS,
}),
// 기타 모듈들...
],
})
export class AppModule {}
5. 이메일 인증 서비스 구현하기
이제 실제로 이메일 인증을 처리할 서비스를 만들어 봅시다:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Verification } from './entities/verification.entity';
import { MailService } from '../mail/mail.service';
@Injectable()
export class VerificationService {
constructor(
@InjectRepository(Verification)
private readonly verifications: Repository<Verification>,
private readonly mailService: MailService,
) {}
async sendVerifyEmail({
email,
}: {
email: string;
}): Promise<{ ok: boolean; error?: string }> {
try {
// 기존 사용자 확인 로직은 생략 (실제로는 필요)
// 기존 인증 정보가 있다면 삭제
const existVerification = await this.verifications.findOne({
where: { email },
});
if (existVerification) {
await this.verifications.delete(existVerification.id);
}
// 새 인증 정보 생성
const verification = this.verifications.create({ email });
await this.verifications.save(verification);
// 이메일 전송
await this.mailService.sendVerificationEmail({
email: email,
code: verification.code,
});
return { ok: true };
} catch (error) {
console.error(error);
return {
ok: false,
error: '이메일 인증 메일 보내기에 실패했습니다.',
};
}
}
async verifyEmail({
code,
email,
}: VerifyEmailInput): Promise<VerifyEmailOutput> {
try {
const verification = await this.verifications.findOne({
where: { email },
});
if (!verification) {
return { ok: false, error: '이메일 인증 정보가 없습니다.' };
}
const now = new Date();
if (verification.expiresAt < now) {
await this.verifications.delete(verification.id);
return { ok: false, error: '인증 코드가 만료되었습니다.' };
}
if (verification.code === code) {
await this.verifications.update(verification.id, { verified: true });
return { ok: true };
}
await this.verifications.update(verification.id, {
attempts: verification.attempts + 1,
});
if (verification.attempts + 1 >= 3) {
await this.verifications.delete(verification.id);
return {
ok: false,
error: '인증 코드가 3회 틀려서 삭제되었습니다. 다시 요청해주세요.',
};
}
return { ok: false, error: '이메일 검증에 실패했습니다.' };
} catch (error) {
return { ok: false, error };
}
}
}
6. Verification 엔터티 만들기
인증 코드를 저장할 엔터티를 만듭시다:
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { CoreEntity } from 'src/common/entities/core.entity';
import { BeforeInsert, Column, Entity } from 'typeorm';
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class Verification extends CoreEntity {
@Column()
@Field(() => String)
code: string;
@Column()
@Field(() => String)
email: string;
@Column({ default: false })
@Field(() => Boolean)
verified: boolean;
@Column({ default: 0 })
@Field(() => Number)
attempts: number; // 인증 실패 횟수 저장
@Column()
@Field(() => Date)
expiresAt: Date;
@BeforeInsert()
setExpiryDate() {
const currentDate = new Date();
this.expiresAt = new Date(currentDate.getTime() + 1800 * 1000); // 30분 뒤 만료
}
@BeforeInsert()
createCode() {
this.code = Math.floor(100000 + Math.random() * 900000).toString();
}
}
7. 보안을 위한 환경 변수 설정
.env 파일을 만들어 민감한 정보를 저장합니다:
# .env
GMAIL_USER=your-email@gmail.com
GMAIL_PASS=your-app-password # Google에서 생성한 앱 비밀번호
8. REST API 엔드포인트 구현하기
마지막으로 사용자가 이메일 인증을 요청하고 확인할 수 있는 API를 만듭니다:
import { Body, Controller, Post } from '@nestjs/common';
import { VerificationService } from './verification.service';
@Controller('verification')
export class VerificationController {
constructor(private readonly verificationService: VerificationService) {}
@Post('send')
async sendVerificationEmail(
@Body('email') email: string,
) {
return this.verificationService.sendVerifyEmail({ email });
}
@Post('verify')
async verifyEmail(
@Body('code') code: string,
) {
return this.verificationService.verifyEmail(code);
}
}
이메일 인증 과정 요약
- 사용자가 회원가입 시 이메일 입력
- 서버에서 6자리 인증코드 생성 및 DB 저장
- Gmail SMTP를 통해 HTML 형식의 이메일 발송
- 사용자가 이메일에서 인증코드 확인 후 입력
- 서버에서 코드 검증 후 회원가입 완료
주의 사항
- Gmail SMTP는 하루 500건으로 제한되어 있어서 대규모 서비스에는 적합하지 않습니다.
- 앱 비밀번호는 절대 소스 코드에 하드코딩하지 말고 환경 변수로 관리해야 합니다.
- 인증 코드는 일정 시간(예: 30분) 후에 만료되도록 설정하는 것이 보안상 좋습니다.
- 인증 시도 횟수를 제한하여 무차별 대입 공격을 방지하세요.
- 프로덕션 환경에서는 보안을 위해 SSL/TLS 설정을 확실히 해야 합니다.
테스트하기
이제 Postman이나 curl을 사용해서 API를 테스트해볼 수 있습니다:
# 인증 이메일 발송
curl -X POST http://localhost:3000/verification/send \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# 인증 코드 확인
curl -X POST http://localhost:3000/verification/verify \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
HTML 이메일 템플릿 커스터마이징
앞서 구현한 HTML 이메일은 기본적인 스타일링이 적용되어 있지만, 더 멋진 디자인을 원한다면 다음과 같은 추가 요소를 고려해보세요:
- 회사/서비스 로고 이미지 추가 (데이터 URI 형식으로 임베딩)
- 브랜드 컬러 일관성 있게 적용
- 모바일 환경에서도 잘 보이는 반응형 디자인
- 로그인 직접 연결 버튼 추가 (보안 고려 필요)
확장 가능성
이 시스템은 다음과 같이 확장할 수 있습니다:
- 단순 코드 인증 대신 "이메일에 포함된 링크 클릭" 방식으로 변경
- 비밀번호 재설정 기능에도 동일한 시스템 활용
- 이메일 템플릿 엔진(Handlebars, EJS 등) 도입하여 템플릿 관리 용이하게
- 서비스 규모가 커지면 메일건과 같은 전문 이메일 서비스로 전환
결론
이번 포스팅에서는 Gmail SMTP를 활용해 무료로 이메일 인증 시스템을 구현하는 방법을 알아보았습니다. 소규모 프로젝트나 개인 사이드 프로젝트에서는 이런 방식이 비용 효율적이고 충분히 실용적입니다.
하지만 서비스 규모가 커진다면 메일건과 같은 전문 서비스로 마이그레이션하는 것을 고려해보세요. 여러분의 프로젝트에서도 이 방법이 도움이 되었으면 좋겠습니다! 궁금한 점이나 개선 사항이 있으면 댓글로 알려주세요. 감사합니다! 👋
참고 자료
'자바스크립트' 카테고리의 다른 글
프론트엔드 개발자를 위한 음성 인식: Google STT에서 OpenAI Whisper로 전환기 (0) | 2025.04.01 |
---|---|
husky를 이용하여 push, commit 전 테스트 자동화 (1) | 2023.12.02 |
Dialog 태그 위로 토스트 보이도록 하기 (feat.TopLayer, createPortal) (2) | 2023.11.09 |
Fetch로 추상화한 유틸 함수를 Axios 패키지로 마이그레이션 해보기 (0) | 2023.11.06 |
사용자가 업로드 한 이미지 압축하여 서버로 보내기 (feat.Browser Image Compression, Upload Images Converter, webp) (0) | 2023.10.04 |
- Total
- Today
- Yesterday
- 북클럽
- 프론트앤드
- 초보
- nodejs
- 프리온보딩
- 노마드코더
- Storybook
- error
- electron
- C언어
- 원티드
- 스토리 북
- React
- import/order
- javascript
- 아차산
- jest
- CLASS
- 윤성우 열혈C프로그래밍
- nextjs
- 우아한테크코스
- TopLayer
- env
- 위코드
- NextRequest
- WSL2
- 노개북
- NextApiRequest
- createPortal
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |