이메일 시스템 (Email System)
문서 생성일: 2026-01-30
최종 업데이트: 2026-05-06
1. 개요
vivid-ai 서비스의 계정 보안, 결제 신뢰성, 운영 커뮤니케이션을 위한 이메일 발송 시스템입니다.
초기 구현 우선순위는 다음과 같습니다.
- 회원가입 이메일 본인인증
- 비밀번호 찾기/재설정/변경
- 결제 성공 알림
- 추후 공지사항, 패치노트, 마케팅성 메일
이메일 모듈은 단순히 SES를 호출하는 모듈이 아니라, DB Outbox + SQS + EmailWorker 구조를 사용해 도메인 변경과 메일 발송 작업 기록의 정합성을 보장합니다.
2. 확정 정책
| 항목 | 정책 |
|---|---|
| 발신 주소 | noreply@vivid.place |
| 이메일 제공자 | AWS SES |
| SES 리전 | 기존 AWS 리전 사용 |
| 로컬/Dev 발송 모드 | EMAIL_DELIVERY_MODE=log |
| Prod 발송 모드 | EMAIL_DELIVERY_MODE=ses |
| SQS 타입 | Standard Queue |
| DLQ | EmailWorker용 DLQ 사용 |
| 최종 상태 기준 | SQS가 아니라 email_outbox 테이블 |
| 기존 가입자 백필 | isEmailVerified=true |
| 미인증 사용자 정책 | 로그인 자체 차단 |
| 이메일 인증 토큰 TTL | 30분 |
| 비밀번호 재설정 토큰 TTL | 15분 |
| 토큰 저장 방식 | Redis에 토큰 원문이 아닌 해시 저장 |
| 인증 방식 | 링크 클릭 방식 |
3. 아키텍처
3.1 전체 흐름
3.2 Outbox를 사용하는 이유
회원가입, 비밀번호 재설정, 결제 성공 같은 도메인 이벤트에서 SQS에 직접 발행하면 DB 트랜잭션과 SQS 발행이 원자적으로 묶이지 않습니다.
- DB 저장 성공, SQS 발행 실패: 사용자는 생성됐지만 인증 메일 작업이 사라짐.
- SQS 발행 성공, DB 트랜잭션 롤백: 존재하지 않는 사용자에 대한 메일 작업 발생.
따라서 도메인 변경과 email_outbox 저장을 같은 DB 트랜잭션 안에 넣고, 별도 Dispatcher가 outbox를 SQS로 발행합니다.
3.3 SQS와 EmailWorker의 역할
- Outbox: 메일 발송 작업이 존재해야 한다는 정합성 보장.
- SQS: 비동기 전달, worker 분리, 재처리, DLQ 연동.
- EmailWorker: SQS 메시지를 소비하고 SES 또는 log provider로 실제 발송.
SQS는 at-least-once 전달 모델이므로 EmailWorker는 반드시 outboxId 기준으로 멱등 처리합니다.
4. 서브모듈 구성
EmailModule
이메일 기능의 루트 모듈입니다. 아래 서비스를 조립하고 외부 모듈에는 고수준 유스케이스만 노출합니다.
EmailOutboxService
email_outbox생성- 상태 전이 관리
dedupeKey기반 중복 작업 방지- 발행/발송 시도 횟수와 실패 사유 기록
EmailDispatcherService
- 주기적으로
email_outbox에서PENDING또는RETRY작업 조회 - SQS
SendMessageBatch로outboxId발행 - 발행 성공 시
PUBLISHED기록 - 발행 실패 시 publish retry 정책 적용
EmailWorkerService
- SQS에서
{ outboxId }메시지 수신 - outbox 상태 조회 및 lease 획득
EMAIL_DELIVERY_MODE에 따라 log 또는 SES provider 호출- 성공 시
SENT, 실패 시 타입별 retry/failed 상태 기록
EmailTokenService
- 이메일 인증 토큰 생성/검증/폐기
- 비밀번호 재설정 토큰 생성/검증/폐기
- Redis에 token hash만 저장
- 토큰 단회 사용 보장
- 재발송 시 이전 인증 토큰 무효화
EmailTemplateService
- 템플릿 key와 payload를 받아 subject/html/text 생성
- 초기 템플릿:
email-verificationpassword-resetpayment-receipt
EmailProvider
환경별 실제 발송 구현입니다.
LogEmailProvider: local/dev 기본값. 메일 내용을 로그로 출력.SesEmailProvider: prod에서 AWS SES로 실제 발송.
5. 데이터 모델
5.1 User Entity 변경
기존 users 테이블에 이메일 인증 상태와 비밀번호 변경 시점을 추가합니다.
| 컬럼명 | 타입 | 기본값 | 설명 |
|---|---|---|---|
isEmailVerified | boolean | false | 이메일 인증 완료 여부 |
emailVerifiedAt | timestamptz | null | 이메일 인증 완료 시각 |
passwordChangedAt | timestamptz | null | 비밀번호 최종 변경 시각 |
마이그레이션에서는 기존 가입자를 다음과 같이 백필합니다.
isEmailVerified=trueemailVerifiedAt=now()
신규 가입자는 기본적으로 isEmailVerified=false 상태로 생성됩니다.
5.2 EmailOutbox Entity
| 컬럼명 | 타입 | 설명 |
|---|---|---|
id | UUID | outbox 고유 ID |
type | enum | EMAIL_VERIFICATION, PASSWORD_RESET, PAYMENT_RECEIPT |
userId | UUID nullable | 대상 사용자 ID |
recipientEmail | varchar | 수신 이메일 |
templateKey | varchar | 사용할 템플릿 key |
payload | jsonb | 템플릿 렌더링 데이터 |
status | enum | outbox 상태 |
dedupeKey | varchar nullable unique | 중복 작업 방지 key |
publishAttempts | int | SQS 발행 시도 횟수 |
sendAttempts | int | 실제 발송 시도 횟수 |
nextAttemptAt | timestamptz nullable | 다음 재시도 시각 |
lockedUntil | timestamptz nullable | dispatcher/worker lease 만료 시각 |
publishedAt | timestamptz nullable | SQS 발행 완료 시각 |
sentAt | timestamptz nullable | 발송 완료 시각 |
failedAt | timestamptz nullable | 최종 실패 시각 |
lastError | text nullable | 마지막 실패 사유 |
providerMessageId | varchar nullable | SES message id |
createdAt | timestamptz | 생성 시각 |
updatedAt | timestamptz | 수정 시각 |
상태 enum:
PENDINGPUBLISHINGPUBLISHEDSENDINGSENTRETRYFAILED_NEEDS_RESENDFAILED_PERMANENT
6. Redis 토큰 모델
6.1 이메일 인증
email:verification:{tokenHash}
-> { userId, email, purpose, expiresAt }
email:verification:user:{userId}
-> { tokenHash, expiresAt }
- TTL: 30분
- 토큰 원문은 저장하지 않습니다.
- 재발송 시
email:verification:user:{userId}로 기존 token hash를 찾아 삭제한 뒤 새 토큰을 발급합니다. - 인증 성공 시 Redis token key와 user index key를 모두 삭제합니다.
6.2 비밀번호 재설정
email:password-reset:{tokenHash}
-> { userId, email, purpose, expiresAt }
- TTL: 15분
- 검증 성공 시 즉시 삭제해 단회 사용만 허용합니다.
7. API 명세
회원가입
POST /api/v1/auth/register
동작:
- 사용자 생성
isEmailVerified=false저장- 인증 토큰 생성
- 인증 메일 outbox 생성
- 메일 확인 필요 응답 반환
응답 예시:
{
"user": {
"id": "uuid",
"email": "user@example.com",
"isEmailVerified": false
},
"emailVerificationRequired": true
}
로그인
POST /api/v1/auth/login
동작:
- 이메일/비밀번호 검증 성공 후에도
isEmailVerified=false면 로그인 차단. - 프론트엔드가 분기할 수 있도록 에러 코드는
EMAIL_NOT_VERIFIED를 사용합니다.
에러 예시:
{
"code": "EMAIL_NOT_VERIFIED",
"message": "Email verification is required."
}
이메일 인증 확인
POST /api/v1/auth/email-verifications/confirm
요청:
{
"token": "raw-token-from-link"
}
동작:
- token hash 계산
- Redis에서 인증 토큰 조회
- 유효하면
isEmailVerified=true,emailVerifiedAt=now()업데이트 - Redis 토큰 삭제
인증 메일 재발송
POST /api/v1/auth/email-verifications/resend
요청:
{
"email": "user@example.com"
}
보안 정책:
- 계정 존재 여부를 응답에서 노출하지 않습니다.
- 이미 인증된 계정이어도 동일한 성공 메시지를 반환할 수 있습니다.
- IP + email 기준 rate limit을 적용합니다.
비밀번호 재설정 요청
POST /api/v1/auth/password-resets/request
요청:
{
"email": "user@example.com"
}
보안 정책:
- 계정 존재 여부와 무관하게 항상 같은 응답을 반환합니다.
- 존재하는 계정이면 비밀번호 재설정 토큰과 outbox를 생성합니다.
비밀번호 재설정 확정
POST /api/v1/auth/password-resets/confirm
요청:
{
"token": "raw-token-from-link",
"newPassword": "new-password"
}
동작:
- reset token 검증
- 비밀번호 해시 업데이트
passwordChangedAt=now()업데이트- 기존 refresh token 무효화
- reset token 삭제
비밀번호 변경
POST /api/v1/auth/change-password
로그인한 사용자가 현재 비밀번호를 알고 있을 때 사용하는 API입니다.
요청:
{
"currentPassword": "current-password",
"newPassword": "new-password"
}
8. SQS 및 Worker 정책
8.1 큐 구성
- Queue:
vivid-ai-email-{env} - DLQ:
vivid-ai-email-dlq-{env}
SQS 메시지는 최소 정보만 포함합니다.
{
"outboxId": "uuid"
}
수신 이메일, 템플릿, payload는 worker가 DB에서 outbox를 다시 조회해 사용합니다.
8.2 Dispatcher 주기
| 환경 | 주기 | batch size | tick당 최대 batch |
|---|---|---|---|
| local/dev | 10초 | 10 | 5 |
| prod | 3초 | 10 | 5 |
SQS SendMessageBatch가 최대 10개이므로 batch size는 10을 기본값으로 둡니다.
8.3 동시성 제어
여러 백엔드 인스턴스에서 Dispatcher가 동시에 실행될 수 있으므로 다음 중 하나를 사용합니다.
- PostgreSQL
FOR UPDATE SKIP LOCKED lockedUntil기반 lease
초기 구현에서는 TypeORM QueryBuilder로 제어하기 쉬운 lockedUntil 방식을 우선 고려합니다.
9. 실패 및 재시도 정책
9.1 SQS 발행 실패
Dispatcher가 email_outbox를 SQS로 발행하지 못한 경우입니다. 이 단계에서는 아직 EmailWorker가 메시지를 받지 못했으므로 publishAttempts와 nextAttemptAt을 기준으로 재시도합니다.
| 실패 횟수 | 다음 시도 |
|---|---|
| 1회 | 5초 후 |
| 2회 | 15초 후 |
| 3회 | 30초 후 |
| 4회 | 1분 후 |
| 5회 | 3분 후 |
| 이후 | FAILED_NEEDS_RESEND 또는 운영자 확인 필요 상태 |
인증/비밀번호 메일은 토큰 TTL 안에서만 재시도합니다. SQS URL 누락, IAM 권한 오류, 메시지 스키마 오류처럼 설정 또는 코드 수정 없이는 회복되지 않는 오류는 빠르게 FAILED_PERMANENT로 전환하고 운영 알림 대상으로 분류합니다.
SQS 발행은 성공했지만 DB 상태 업데이트가 실패할 수 있습니다. 이 경우 Dispatcher가 같은 outbox를 다시 발행할 수 있으므로, SQS 메시지에는 항상 outboxId만 넣고 EmailWorker가 outboxId 기준으로 멱등 처리해야 합니다.
9.2 인증/비밀번호 메일
사용자가 즉시 기다리는 메일이므로 긴 백오프를 사용하지 않습니다.
| 실패 횟수 | 다음 시도 |
|---|---|
| 1회 | 5초 후 |
| 2회 | 15초 후 |
| 3회 | 30초 후 |
| 4회 | 1분 후 |
| 5회 | 3분 후 |
| 이후 | FAILED_NEEDS_RESEND |
토큰 TTL이 지난 작업은 재시도하지 않습니다. 사용자는 재발송 API를 통해 새 토큰과 새 outbox를 생성해야 합니다.
9.3 결제 알림 메일
결제 자체는 이미 성공한 상태이므로 메일 발송 실패가 결제 처리 결과를 롤백하지 않습니다. 단, 결제 영수증 성격이 있으므로 인증 메일보다 긴 재시도를 허용합니다.
| 실패 횟수 | 다음 시도 |
|---|---|
| 1회 | 10초 후 |
| 2회 | 30초 후 |
| 3회 | 2분 후 |
| 4회 | 5분 후 |
| 5회 | 15분 후 |
| 6회 | 1시간 후 |
| 7회 | 6시간 후 |
| 이후 | 운영자 확인 필요 상태 |
9.4 영구 실패
아래 오류는 재시도하지 않고 FAILED_PERMANENT로 전환합니다.
- 템플릿 없음
- payload 스키마 불량
- 수신 이메일 형식 불량
- 필수 환경 변수 누락
- SES/IAM/SQS 설정 오류처럼 코드 재시도로 해결되지 않는 설정 문제
10. 결제 알림 연동
PaymentsService.verifyAndProcessPayment()에서 온체인 검증, 결제 내역 저장, 크레딧 충전이 모두 성공한 뒤 결제 성공 메일 outbox를 생성합니다.
권장 dedupeKey:
payment-receipt:{paymentId}
동일 결제에 대해 중복 메일 outbox가 생기지 않도록 unique 제약을 둡니다.
11. 프론트엔드 구현 범위
회원가입
- 회원가입 성공 후 "인증 메일을 확인해주세요" 화면 표시
- 재발송 버튼 제공
- 로그인 화면으로 즉시 보내더라도 미인증 로그인 차단 메시지와 재발송 CTA 제공
이메일 인증 결과 페이지
경로 예시:
/auth/verify-email?token=...
상태:
- 인증 성공
- 토큰 만료
- 이미 사용된 토큰
- 잘못된 토큰
- 서버 오류
비밀번호 재설정
- 재설정 요청 화면
- 재설정 메일 발송 안내 화면
- 새 비밀번호 입력 화면
- 성공 후 로그인 유도
12. 환경 변수
# Email delivery
EMAIL_FROM=noreply@vivid.place
EMAIL_DELIVERY_MODE=log # log | ses
PUBLIC_FRONTEND_URL=http://localhost:4000
# SES
AWS_REGION=ap-northeast-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# SQS email queue
SQS_EMAIL_QUEUE_URL=
SQS_EMAIL_DLQ_URL=
# Email dispatcher
EMAIL_DISPATCH_INTERVAL_MS=3000
EMAIL_DISPATCH_BATCH_SIZE=10
EMAIL_DISPATCH_MAX_BATCHES_PER_TICK=5
AWS_REGION은 예시이며 실제 값은 기존 AWS 리전을 따릅니다.
13. 구현 순서
- SES 도메인 준비:
vivid.place, DKIM/SPF/DMARC 설정, sandbox 해제 - SQS Standard Queue + DLQ + IAM 권한 준비
- 환경 변수와 배포 Secret 항목 정의
users,email_outbox마이그레이션 작성EmailModule골격과LogEmailProvider구현EmailTokenService구현EmailOutboxService구현EmailDispatcherService구현EmailWorkerService구현SesEmailProvider구현- 회원가입 이메일 인증 플로우 연결
- 인증 메일 재발송 플로우 연결
- 로그인 차단 정책 적용
- 비밀번호 재설정/변경 플로우 연결
- 결제 성공 메일 outbox 연결
- 프론트엔드 인증/재설정 화면 구현
- local/dev log mode 검증
- prod SES mode 전환
14. 테스트 기준
백엔드 단위 테스트
- 이메일 토큰 생성 시 Redis에 hash만 저장되는지 확인
- 인증 성공 후 토큰이 단회 사용되는지 확인
- 재발송 시 이전 인증 토큰이 무효화되는지 확인
- 미인증 계정 로그인 차단 확인
- 비밀번호 재설정 후 refresh token 무효화 확인
- outbox
dedupeKey중복 방지 확인 - dispatcher가 재시도 가능 항목만 발행하는지 확인
- worker가 이미
SENT인 outbox를 중복 발송하지 않는지 확인
통합 테스트
- 신규 가입 -> 인증 메일 outbox 생성 -> SQS 발행 -> log/SES 발송
- 인증 링크 클릭 -> 로그인 가능
- 만료 토큰 실패
- 잘못된 토큰 실패
- 비밀번호 재설정 요청은 계정 존재 여부와 무관하게 같은 응답 반환
- 결제 성공 시 payment receipt outbox 생성
운영 검증
- SES sandbox 해제 여부
noreply@vivid.place발신 인증- DKIM/SPF/DMARC 설정
- SQS DLQ redrive 정책
- outbox 실패 항목 운영 조회 방식
- local/dev에서 실제 메일이 발송되지 않는지 확인
15. 후속 확장
이메일 모듈이 안정화된 뒤 다음 기능을 별도 모듈로 확장합니다.
NotificationModule: 서비스 내 알림함, 생성 완료 알림, 중요 공지 알림AnnouncementsModule: 공지사항, 패치노트, 중요도/노출 기간 관리- 사용자별 알림 설정: in-app/email 채널 수신 여부
- 마케팅성 메일 opt-in/opt-out 및 unsubscribe 처리