본문으로 건너뛰기

이메일 인증 구현 가이드

1. 개요

1.1 목적

본 문서는 사용자 인증 과정 중 이메일 주소의 유효성을 검증하기 위한 인증 코드 발송 및 확인 기능의 상세 구현 가이드라인을 제공합니다.

1.2 범위

  • 이메일 인증 코드 발송 API (POST /auth/email/verification-code) 구현 상세
  • 이메일 인증 코드 확인 API (POST /auth/email/verify) 구현 상세
  • 관련 서비스(EmailService) 로직 상세 (OTP 생성, 임시 저장, 이메일 발송, 코드 검증)
  • 데이터 저장소(Redis) 활용 방안
  • 보안 고려 사항 및 오류 처리

2. 아키텍처 및 의존성

이메일 인증 기능은 주로 Auth 도메인 내에서 처리되며, 다음과 같은 주요 컴포넌트와 의존성을 가집니다.

  • RegistrationController (또는 AuthController): API 요청 수신 및 EmailService 호출
  • EmailService: 핵심 비즈니스 로직 수행
    • 의존성:
      • RedisService: 인증 코드 임시 저장 및 조회
      • @sendgrid/mail 클라이언트 (또는 SendGrid 래퍼 서비스): 실제 이메일 발송 처리
      • UserRepository (선택적): 이메일 중복 확인 등
      • ConfigService: 코드 유효 시간, 재시도 횟수, 발신자 주소 등 설정값 조회 (@core/configConfigService 사용)
      • LoggerService: 로깅
  • RedisService: Core 인프라 모듈의 Redis 클라이언트 래퍼

3. API 구현 상세

3.1 이메일 인증 코드 발송 (POST /auth/email/verification-code)

3.1.1 DTO 정의

// 파일 경로: libs/shared/contracts-auth/dto/src/lib/auth/email-verification.dto.ts

import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

// 요청 DTO
export class SendVerificationCodeDto {
@ApiProperty({ description: '인증 코드를 받을 이메일 주소', example: 'user@example.com' })
@IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요.' })
@IsNotEmpty({ message: '이메일 주소는 필수입니다.' })
email: string;
}

// 응답 DTO
export interface EmailVerificationResultDto {
success: boolean;
email: string;
expiresIn: number; // 코드 유효 시간(초)
requestId: string; // 요청 추적 ID
}

3.1.2 Controller 구현

Controller는 요청 유효성 검사 후 EmailService를 호출하는 역할을 합니다.

// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
// (또는 terms-and-consents.controller.ts)

import { Controller, Post, Body, UseGuards, Inject } from '@nestjs/common';
import { AppTokenGuard } from '@core/auth/guards'; // AppTokenGuard 경로 확인
import { SendVerificationCodeDto, EmailVerificationResultDto } from '@shared-contracts/auth/dto'; // DTO 경로 확인
import { EmailService } from '@feature/auth-core'; // EmailService 경로 확인
import { ApiResponse } from '@shared/api-types'; // ApiResponse 타입 확인

@Controller('auth')
export class RegistrationController {
constructor(
@Inject(EmailService) private readonly emailService: EmailService // EmailService 주입
) {}

@Post('email/verification-code')
@UseGuards(AppTokenGuard) // 앱 토큰 인증 필요
async sendVerificationCode(
@Body() dto: SendVerificationCodeDto
): Promise<ApiResponse<EmailVerificationResultDto>> {
// EmailService의 메서드 호출하여 로직 수행
const result = await this.emailService.sendVerificationCode(dto.email);

// 성공 응답 반환
return {
data: {
success: true,
email: dto.email,
expiresIn: result.expiresIn, // 서비스에서 반환된 유효 시간 사용
requestId: result.requestId, // 요청 ID 반환
},
};
}

// ... 기타 메서드 ...
}

3.1.3 EmailService.sendVerificationCode 상세 로직

이 메서드는 이메일 인증 코드 생성, 저장, 발송의 핵심 로직을 담당합니다.

// 파일 경로: libs/feature/auth/src/lib/domain/services/email.service.ts
import { Injectable, BadRequestException, InternalServerErrorException, Inject } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { ConfigService } from '@core/config';
import { LoggerService } from '@core/logging';
import * as crypto from 'crypto';
import * as sgMail from '@sendgrid/mail';
import { AuthErrorFactory, AuthErrorCode } from '@feature/auth-core';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class EmailService {
private readonly VERIFICATION_CODE_TTL_SECONDS: number;
private readonly VERIFICATION_CODE_KEY_PREFIX = 'email-verify:';

constructor(
@Inject(RedisService) private readonly redisService: RedisService,
private readonly configService: ConfigService,
@Inject(LoggerService) private readonly logger: LoggerService,
) {
this.VERIFICATION_CODE_TTL_SECONDS = this.configService.getConfig<number>('auth', 'emailVerification.codeTtlSeconds', 300); // 네임스페이스 사용 예시
}

/**
* 지정된 이메일로 인증 코드를 생성하고 SendGrid를 통해 발송합니다.
* @param email 대상 이메일 주소
* @returns 코드 유효 시간(초)
*/
async sendVerificationCode(email: string): Promise<{ expiresIn: number; requestId: string }> {
this.logger.log(`Attempting to send verification code to ${email}`, EmailService.name);

const verificationCode = this.generateOtp();
const requestId = uuidv4();
const redisKey = `${this.VERIFICATION_CODE_KEY_PREFIX}${email}`;

const verificationData = {
code: verificationCode,
attempts: 0,
requestId: requestId
};

try {
await this.redisService.setex(redisKey, this.VERIFICATION_CODE_TTL_SECONDS, JSON.stringify(verificationData));
this.logger.debug(`Verification data for ${email} (requestId: ${requestId}) stored in Redis with TTL ${this.VERIFICATION_CODE_TTL_SECONDS}s`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to store verification data in Redis for ${email}: ${error.message}`, error.stack, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, 'Redis 저장 실패');
}

// 3. SendGrid를 사용하여 이메일 발송
const msg = {
to: email,
// CoreConfigModule의 ConfigService를 사용하여 설정값 조회
from: this.configService.getConfig<string>('externalApi', 'sendgrid.fromEmail', 'noreply@sleepq.ai'),
subject: '[SleepQ] 이메일 인증 코드 안내',
html: this.generateVerificationEmailHtml(verificationCode),
};

try {
// CoreConfigModule의 ConfigService를 사용하여 API 키 설정 여부 확인
if (!this.configService.get<string>('SENDGRID_API_KEY')) {
this.logger.error('SendGrid API Key is not configured. Cannot send email.', EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, '이메일 서비스 설정 오류');
}
await sgMail.send(msg);
this.logger.log(`Verification code successfully sent to ${email} via SendGrid`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to send verification email to ${email} via SendGrid: ${error.message}`, error.stack, EmailService.name);
// SendGrid 오류 응답 본문 로깅 (오류 객체 구조 확인 필요)
if (error.response) {
this.logger.error(`SendGrid error response body: ${JSON.stringify(error.response.body)}`, EmailService.name);
}
// 이메일 발송 실패 시, 사용자에게 코드가 전달되지 않았으므로 Redis에 저장된 코드를 삭제하는 것을 권장합니다.
await this.redisService.del(redisKey);
this.logger.log(`Rolled back verification code from Redis for ${email} due to send failure.`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.EMAIL_SEND_FAILED, '이메일 발송 실패', { sendgridError: error.message });
}

return { expiresIn: this.VERIFICATION_CODE_TTL_SECONDS, requestId: requestId };
}

// 6자리 숫자 OTP 생성 헬퍼
private generateOtp(): string {
return crypto.randomInt(100000, 999999).toString();
}

// 인증 이메일 HTML 내용 생성 헬퍼 (예시)
private generateVerificationEmailHtml(code: string): string {
const expiresInMinutes = Math.floor(this.VERIFICATION_CODE_TTL_SECONDS / 60);
// 실제 운영에서는 보다 정교한 HTML 템플릿 엔진 사용 권장
return `
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 5px;">
<h2 style="color: #333;">DTA-WIDE 이메일 인증</h2>
<p>안녕하세요,</p>
<p>요청하신 이메일 인증 코드는 다음과 같습니다. 이 코드는 ${expiresInMinutes}분 동안 유효합니다.</p>
<p style="font-size: 24px; font-weight: bold; color: #007bff; letter-spacing: 2px; margin: 20px 0;">
${code}
</p>
<p>이 코드를 인증 화면에 입력해주세요.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #888;">본인이 요청하지 않은 경우 이 이메일을 무시해주세요.</p>
</div>
`;
}

// ... verifyCode, isEmailVerified 등 다른 메서드 ...
}

3.2 이메일 인증 코드 확인 (POST /auth/email/registration-verify)

3.2.1 DTO 정의

// 파일 경로: libs/shared/contracts-auth/dto/src/lib/auth/email-verification.dto.ts

import { IsEmail, IsNotEmpty, IsString, Length, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

// 요청 DTO
export class VerifyEmailCodeDto {
@ApiProperty({ description: '인증 코드를 받은 이메일 주소', example: 'user@example.com' })
@IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요.' })
@IsNotEmpty({ message: '이메일 주소는 필수입니다.' })
email: string;

@ApiProperty({ description: '이메일로 받은 6자리 인증 코드', example: '123456' })
@IsString()
@Length(6, 6, { message: '인증 코드는 6자리여야 합니다.' })
@IsNotEmpty({ message: '인증 코드는 필수입니다.' })
code: string;

@ApiProperty({ description: '코드 발송 요청 시 받은 요청 ID', example: 'req_123' })
@IsNotEmpty({ message: '요청 ID는 필수입니다.' })
@IsString()
requestId: string;
}

// 응답 DTO
export interface EmailVerificationStatusDto {
verified: boolean;
email: string;
// verificationToken?: string; // 상태 저장 대신 토큰 반환 방식 사용 시
}

3.2.2 Controller 구현

// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
// (또는 terms-and-consents.controller.ts)

import { Controller, Post, Body, UseGuards, BadRequestException, Inject } from '@nestjs/common';
import { AppTokenGuard } from '@core/auth/guards';
import { VerifyEmailCodeDto, EmailVerificationStatusDto } from '@shared-contracts/auth/dto';
import { EmailService } from '@feature/auth-core';
import { ApiResponse } from '@shared/api-types';

@Controller('auth')
export class RegistrationController {
constructor(
@Inject(EmailService) private readonly emailService: EmailService
) {}

@Post('email/registration-verify')
@UseGuards(AppTokenGuard)
async verifyEmailCode(
@Body() dto: VerifyEmailCodeDto
): Promise<ApiResponse<EmailVerificationStatusDto>> {
// EmailService의 메서드 호출하여 코드 검증
const isValid = await this.emailService.verifyCode(dto.email, dto.code, dto.requestId);

if (!isValid) {
// 서비스 내부에서 던져진 특정 예외를 그대로 반환하거나, 여기서 BadRequestException 발생
throw new BadRequestException({
code: 1055, // 또는 서비스에서 정의된 구체적인 오류 코드
message: 'INVALID_VERIFICATION_CODE',
detail: '유효하지 않거나 만료된 인증 코드입니다.'
});
}

// (선택적) 인증 완료 상태 저장
// await this.emailService.markEmailAsVerified(dto.email);

return {
data: {
verified: true,
email: dto.email,
},
};
}

// ... 기타 메서드 ...
}

3.2.3 EmailService.verifyCode 상세 로직

이 메서드는 Redis에서 코드를 조회하고 사용자가 입력한 코드와 비교하며, 재시도 횟수 제한을 적용합니다.

// 파일 경로: libs/feature/auth/src/lib/domain/services/email.service.ts
import { Injectable, BadRequestException, NotFoundException, Inject, InternalServerErrorException } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { AuthErrorFactory, AuthErrorCode } from '@feature/auth-core';
import { ConfigService } from '@core/config';
import { LoggerService } from '@core/logging';

@Injectable()
export class EmailService {
private readonly VERIFICATION_CODE_KEY_PREFIX = 'email-verify:';
private readonly VERIFIED_EMAIL_KEY_PREFIX = 'email-verified:';
private readonly VERIFIED_EMAIL_TTL_SECONDS: number;
private readonly MAX_VERIFICATION_ATTEMPTS = 10; // 최대 시도 횟수 정의

constructor(
@Inject(RedisService) private readonly redisService: RedisService,
private readonly configService: ConfigService,
@Inject(LoggerService) private readonly logger: LoggerService,
) {
this.VERIFIED_EMAIL_TTL_SECONDS = this.configService.get<number>('auth.emailVerification.verifiedTtlSeconds', 1800); // 기본 30분
}

/**
* 제공된 이메일, 코드, 요청 ID가 유효한지 검증하고, 시도 횟수를 제한합니다.
* @param email 검증할 이메일 주소
* @param code 사용자가 입력한 인증 코드
* @param requestId 코드 발송 시 받은 요청 ID
* @returns 코드가 유효하면 true, 아니면 false 또는 예외 발생
*/
async verifyCode(email: string, code: string, requestId: string): Promise<boolean> {
this.logger.log(`Attempting to verify code for ${email} with requestId ${requestId}`, EmailService.name);
const redisKey = `${this.VERIFICATION_CODE_KEY_PREFIX}${email}`;

try {
const storedDataString = await this.redisService.get(redisKey);

if (!storedDataString) {
this.logger.warn(`Verification data not found or expired for ${email}`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
}

const storedData: VerificationData & { requestId?: string } = JSON.parse(storedDataString);

// 1. Request ID 검증 (선택적이지만 권장)
if (storedData.requestId && storedData.requestId !== requestId) {
this.logger.warn(`Request ID mismatch for ${email}. Expected ${storedData.requestId}, got ${requestId}.`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.INVALID_VERIFICATION_CODE, 'Invalid request ID.');
}

// 2. 저장된 코드와 사용자 입력 코드 비교
if (storedData.code === code) {
this.logger.log(`Verification code matched for ${email}`, EmailService.name);
// 성공 시 Redis에서 코드 삭제 (일회용)
await this.redisService.del(redisKey);
// 인증 완료 상태 저장
await this.markEmailAsVerified(email);
return true;
} else {
// 3. 코드 불일치 시 시도 횟수 증가 및 처리
storedData.attempts += 1;
const remainingAttempts = this.MAX_VERIFICATION_ATTEMPTS - storedData.attempts;
this.logger.warn(`Verification code mismatch for ${email}. Attempt ${storedData.attempts}/${this.MAX_VERIFICATION_ATTEMPTS}. Remaining: ${remainingAttempts}`, EmailService.name);

if (storedData.attempts >= this.MAX_VERIFICATION_ATTEMPTS) {
// 최대 시도 횟수 초과 시 코드 삭제 및 특정 오류 발생
this.logger.warn(`Max verification attempts reached for ${email}. Deleting code.`, EmailService.name);
await this.redisService.del(redisKey);
// meta에 남은 시도 횟수(0) 포함
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED, undefined, { remainingAttempts: 0 });
} else {
// 시도 횟수 업데이트하여 Redis에 다시 저장
const remainingTtl = await this.redisService.ttl(redisKey);
if (remainingTtl > 0) {
await this.redisService.setex(redisKey, remainingTtl, JSON.stringify(storedData));
} else {
this.logger.warn(`Verification code expired just before attempt update for ${email}`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
}
// meta에 남은 시도 횟수 포함
throw AuthErrorFactory.create(AuthErrorCode.INVALID_VERIFICATION_CODE, undefined, { remainingAttempts });
}
}
} catch (error) {
// Redis 오류 등 예외 처리
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error; // 특정 예외는 그대로 전달 (AuthError 포함)
}
this.logger.error(`Error verifying code for ${email}: ${error.message}`, error.stack, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, error.message, { originalError: error.stack });
}
}

/**
* 이메일 인증이 완료되었음을 표시합니다. (회원가입 단계에서 사용)
* @param email 인증된 이메일 주소
*/
async markEmailAsVerified(email: string): Promise<void> {
const redisKey = `${this.VERIFIED_EMAIL_KEY_PREFIX}${email}`;
try {
// 일정 시간 동안만 유효한 상태 저장 (예: 회원가입 완료 시점까지)
await this.redisService.setex(redisKey, this.VERIFIED_EMAIL_TTL_SECONDS, 'true');
this.logger.log(`Marked email ${email} as verified`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to mark email ${email} as verified: ${error.message}`, error.stack, EmailService.name);
// 이 오류가 치명적이지 않다면 로깅만 하고 넘어갈 수 있음
}
}

/**
* 특정 이메일이 인증되었는지 확인합니다. (회원가입 최종 단계에서 사용)
* @param email 확인할 이메일 주소
* @returns 인증되었으면 true, 아니면 false
*/
async isEmailVerified(email: string): Promise<boolean> {
const redisKey = `${this.VERIFIED_EMAIL_KEY_PREFIX}${email}`;
try {
const result = await this.redisService.get(redisKey);
return result === 'true';
} catch (error) {
this.logger.error(`Failed to check verification status for ${email}: ${error.message}`, error.stack, EmailService.name);
return false; // 오류 발생 시 false 반환
}
}

// ... sendVerificationCode, generateOtp 등 다른 메서드 ...
}

4. 데이터 저장소 (Redis)

  • 인증 코드 저장:
    • 키 형식: email-verify:{email} (예: email-verify:user@example.com)
    • : 인증 코드와 시도 횟수를 포함하는 JSON 문자열. 예: '{ "code": "123456", "attempts": 0 }'
    • TTL: 설정된 유효 시간 (예: 300초 = 5분) (SETEX 명령어 사용)
  • 인증 완료 상태 저장:
    • 키 형식: email-verified:{email} (예: email-verified:user@example.com)
    • : 'true' (문자열)
    • TTL: 설정된 유효 시간 (예: 1800초 = 30분, 회원가입 완료 시점까지 유효해야 함) (SETEX 명령어 사용)

5. 보안 고려 사항

  • Rate Limiting 구현:
    • 목표: 서비스 거부 공격(DoS) 방지 및 시스템 가용성 보장을 위해 이메일 인증 코드 발송 API(POST /auth/email/verification-code)에 대한 요청 빈도를 제한합니다.
    • 도구: @nestjs/throttler 모듈을 활용하여 구현합니다. Redis를 저장소로 사용하여 여러 인스턴스 간에 상태를 공유합니다. (@nestjs/throttler가 Redis를 사용하도록 설정 필요)
    • 정책:
      • IP 주소 기준: 동일 IP 주소에서 1분당 60회 요청으로 제한합니다.
      • 이메일 주소 기준: 동일 이메일 주소로 10분당 5회 요청으로 제한합니다.
      • 제한 초과 시: 429 Too Many Requests 오류를 반환하고, 응답 헤더에 Retry-After 정보를 포함하여 클라이언트에게 재시도 가능 시간을 안내합니다.
    • 구현 방안 (권장: 커스텀 가드에서 통합 처리):
      1. 커스텀 EmailThrottlerGuard 구현: ThrottlerGuard를 상속받아 handleRequest 메서드 내에서 IP 기준과 이메일 기준 제한을 모두 확인합니다.

        • IP 추적기: super.getTracker(req) 또는 직접 구현 (예: req.ip)
        • 이메일 추적기: IP:email 형태 (예: 127.0.0.1:user@example.com)
        • storageService.increment()를 각 추적기에 대해 호출하여 제한을 검사합니다.
        • 제한 초과 시 ThrottlerException을 발생시키고 Retry-After 헤더를 설정합니다.
        // 예시: libs/core/auth/src/lib/guards/email-throttler.guard.ts
        // (이전 예시 코드와 유사하나, IP/Email 제한 통합 처리 강조)
        import { ThrottlerGuard, ThrottlerOptions, ThrottlerException } from '@nestjs/throttler';
        import { Injectable, ExecutionContext, Logger, Inject } from '@nestjs/common'; // Inject 추가
        import { Reflector } from '@nestjs/core';
        import { ThrottlerStorage } from '@nestjs/throttler/dist/throttler-storage.interface';
        import { THROTTLER_OPTIONS } from '@nestjs/throttler/dist/throttler.constants'; // 실제 경로 확인 필요

        @Injectable()
        export class EmailThrottlerGuard extends ThrottlerGuard {
        private readonly logger = new Logger(EmailThrottlerGuard.name);

        // 생성자에서 기본 limit/ttl 주입 또는 직접 설정 가능
        constructor(
        @Inject(THROTTLER_OPTIONS) protected readonly options: ThrottlerOptions | ThrottlerOptions[],
        protected readonly storageService: ThrottlerStorage,
        protected readonly reflector: Reflector,
        ) {
        super(options, storageService, reflector);
        }

        protected async handleRequest(
        context: ExecutionContext,
        // @SetMetadata 또는 기본값에서 limit/ttl 가져오기
        limit: number = 60, // IP 기본 limit
        ttl: number = 60000, // IP 기본 ttl (ms)
        ): Promise<boolean> {
        const { req, res } = this.getRequestResponse(context);
        const trackerIp = this.getTracker(req); // IP 주소

        // IP 기준 제한 확인
        const ipLimit = limit;
        const ipTtlSec = ttl / 1000;
        const ipTtlResult = await this.storageService.increment(trackerIp, ipTtlSec);
        this.logger.debug(`IP Rate Limit: ${trackerIp} - Hits: ${ipTtlResult.totalHits}/${ipLimit}, TTL: ${ipTtlResult.timeToExpire}s`);
        if (ipTtlResult.totalHits > ipLimit) {
        const retryAfter = Math.ceil(ipTtlResult.timeToExpire);
        res.header('Retry-After', retryAfter); // 응답 객체 직접 사용
        throw new ThrottlerException();
        }

        // 이메일 기준 제한 확인
        const emailLimit = 5;
        const emailTtlSec = 600; // 10분 (초)
        const email = req.body?.email;
        if (email) {
        const emailTracker = `${trackerIp}:${email}`;
        const emailTtlResult = await this.storageService.increment(emailTracker, emailTtlSec);
        this.logger.debug(`Email Rate Limit: ${emailTracker} - Hits: ${emailTtlResult.totalHits}/${emailLimit}, TTL: ${emailTtlResult.timeToExpire}s`);
        if (emailTtlResult.totalHits > emailLimit) {
        const retryAfter = Math.ceil(emailTtlResult.timeToExpire);
        res.header('Retry-After', retryAfter);
        throw new ThrottlerException('Too many email verification requests for this email address.');
        }
        } else {
        this.logger.warn('Email field not found in request body for email throttling.');
        }

        return true; // 모든 제한 통과
        }
        }

        주의: 위 코드는 여전히 개념 예시입니다. NestJS 버전, @nestjs/throttler 버전에 따라 THROTTLER_OPTIONS 주입, handleRequest 시그니처 등이 다를 수 있습니다. res.header() 사용을 위해 getRequestResponse()를 사용했습니다.

      2. 컨트롤러 적용: RegistrationControllersendVerificationCode 메서드에 @UseGuards()만 적용하고 @Throttle() 데코레이터는 제거합니다.

        // apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
        import { UseGuards } from '@nestjs/common';
        import { EmailThrottlerGuard } from '@core/auth/guards'; // 경로 확인

        // ...

        @Post('email/verification-code')
        @UseGuards(AppTokenGuard, EmailThrottlerGuard) // 커스텀 가드만 사용
        async sendVerificationCode(
        @Body() dto: SendVerificationCodeDto
        ): Promise<ApiResponse<EmailVerificationResultDto>> {
        const result = await this.emailService.sendVerificationCode(dto.email);
        // ...
        }
    • 참고: 상세 요구사항은 인증 도메인 요구사항 - 보안 섹션을 참조하세요.
  • 코드 유효 시간: 요구사항에 따라 5분으로 설정됩니다.
  • 재시도 제한:
    • 목표: 무차별 대입 공격(Brute-force)을 방지하기 위해 코드 확인 API(POST /auth/email/registration-verify)에 대한 시도 횟수를 제한합니다.
    • 정책: 동일 이메일에 대해 최대 10회까지 코드 확인을 시도할 수 있습니다. 10회 실패 시 해당 코드는 즉시 무효화됩니다.
    • 구현 방안:
      1. Redis 데이터 구조 변경: 인증 코드 저장 시 단순 문자열 대신 JSON 객체 사용 (위 4. 데이터 저장소 섹션 참조).
        • 키: email-verify:{email}
        • 값: '{ "code": "123456", "attempts": 0 }'
      2. EmailService.verifyCode 로직 수정:
        • Redis에서 값을 가져와 JSON 파싱.
        • 코드 불일치 시 attempts 값을 1 증가.
        • attemptsMAX_VERIFICATION_ATTEMPTS(예: 10) 이상이면 Redis 키를 삭제하고 특정 오류(AuthErrorCode.INVALID_VERIFICATION_CODE 또는 전용 코드) 발생.
        • attempts가 제한 미만이면, 업데이트된 attempts 값으로 JSON 객체를 다시 Redis에 저장 (기존 TTL 유지 필요 - ttl() 명령어 등으로 남은 시간 확인 후 setex() 사용).
      • 참고: 위 3.2.3 EmailService.verifyCode 상세 로직 예시 코드에 해당 로직이 반영되어 있습니다.
  • 이메일 발송 서비스 보안: API 키 등 민감 정보 안전하게 관리.
  • 코드 확인 API Rate Limiting: 코드 확인 API(POST /auth/email/registration-verify)에 대해서도 과도한 추측 공격을 방지하기 위해 IP 주소 기준 Rate Limiting 적용을 고려합니다. (예: @Throttle({ default: { limit: 10, ttl: 60000 } }) - 1분당 10회)

6. 오류 코드 관리

이메일 인증 관련 주요 오류 코드는 다음과 같습니다. (Auth 도메인 공통 오류 코드 관리 방식 따름)

오류 코드메시지HTTP 상태 코드설명
2000SERVER_ERROR500서버 내부 오류 (Redis 실패 등)
2020EMAIL_ALREADY_EXISTS409이미 가입된 이메일 주소 (선택적 검증)
2017INVALID_VERIFICATION_CODE400인증 코드가 일치하지 않음
2018VERIFICATION_ATTEMPTS_EXCEEDED400인증 코드 확인 시도 횟수 초과
2014VERIFICATION_CODE_EXPIRED400/404인증 코드가 존재하지 않거나 만료됨
2040RATE_LIMIT_EXCEEDED429요청 횟수 제한 초과 (Rate Limiting)
2000EMAIL_SEND_FAILED500이메일 발송 서비스 실패 (SendGrid 오류 포함)

오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.

7. 변경 이력

버전날짜작성자변경 내용
0.1.02025-04-17bok@weltcorp.com최초 작성
0.2.02025-08-01bok@weltcorp.com오류 코드를 중앙 관리 가이드와 일치하도록 수정 (1055 -> 2014, 2017, 429 -> 2040)
0.3.02025-08-01bok@weltcorp.comNotificationService 대신 SendGrid 직접 사용하도록 수정
0.4.02025-08-01bok@weltcorp.comSendGrid API 키 설정을 CoreConfigModule 책임으로 변경
0.5.02025-08-01bok@weltcorp.comCoreConfigModule의 ConfigService 사용 명시