Skip to main content

이메일 시스템 (Email System)

문서 생성일: 2026-01-30
최종 업데이트: 2026-05-06

1. 개요

vivid-ai 서비스의 계정 보안, 결제 신뢰성, 운영 커뮤니케이션을 위한 이메일 발송 시스템입니다.

초기 구현 우선순위는 다음과 같습니다.

  1. 회원가입 이메일 본인인증
  2. 비밀번호 찾기/재설정/변경
  3. 결제 성공 알림
  4. 추후 공지사항, 패치노트, 마케팅성 메일

이메일 모듈은 단순히 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
DLQEmailWorker용 DLQ 사용
최종 상태 기준SQS가 아니라 email_outbox 테이블
기존 가입자 백필isEmailVerified=true
미인증 사용자 정책로그인 자체 차단
이메일 인증 토큰 TTL30분
비밀번호 재설정 토큰 TTL15분
토큰 저장 방식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 SendMessageBatchoutboxId 발행
  • 발행 성공 시 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-verification
    • password-reset
    • payment-receipt

EmailProvider

환경별 실제 발송 구현입니다.

  • LogEmailProvider: local/dev 기본값. 메일 내용을 로그로 출력.
  • SesEmailProvider: prod에서 AWS SES로 실제 발송.

5. 데이터 모델

5.1 User Entity 변경

기존 users 테이블에 이메일 인증 상태와 비밀번호 변경 시점을 추가합니다.

컬럼명타입기본값설명
isEmailVerifiedbooleanfalse이메일 인증 완료 여부
emailVerifiedAttimestamptznull이메일 인증 완료 시각
passwordChangedAttimestamptznull비밀번호 최종 변경 시각

마이그레이션에서는 기존 가입자를 다음과 같이 백필합니다.

  • isEmailVerified=true
  • emailVerifiedAt=now()

신규 가입자는 기본적으로 isEmailVerified=false 상태로 생성됩니다.

5.2 EmailOutbox Entity

컬럼명타입설명
idUUIDoutbox 고유 ID
typeenumEMAIL_VERIFICATION, PASSWORD_RESET, PAYMENT_RECEIPT
userIdUUID nullable대상 사용자 ID
recipientEmailvarchar수신 이메일
templateKeyvarchar사용할 템플릿 key
payloadjsonb템플릿 렌더링 데이터
statusenumoutbox 상태
dedupeKeyvarchar nullable unique중복 작업 방지 key
publishAttemptsintSQS 발행 시도 횟수
sendAttemptsint실제 발송 시도 횟수
nextAttemptAttimestamptz nullable다음 재시도 시각
lockedUntiltimestamptz nullabledispatcher/worker lease 만료 시각
publishedAttimestamptz nullableSQS 발행 완료 시각
sentAttimestamptz nullable발송 완료 시각
failedAttimestamptz nullable최종 실패 시각
lastErrortext nullable마지막 실패 사유
providerMessageIdvarchar nullableSES message id
createdAttimestamptz생성 시각
updatedAttimestamptz수정 시각

상태 enum:

  • PENDING
  • PUBLISHING
  • PUBLISHED
  • SENDING
  • SENT
  • RETRY
  • FAILED_NEEDS_RESEND
  • FAILED_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

동작:

  1. 사용자 생성
  2. isEmailVerified=false 저장
  3. 인증 토큰 생성
  4. 인증 메일 outbox 생성
  5. 메일 확인 필요 응답 반환

응답 예시:

{
"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"
}

동작:

  1. token hash 계산
  2. Redis에서 인증 토큰 조회
  3. 유효하면 isEmailVerified=true, emailVerifiedAt=now() 업데이트
  4. 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"
}

동작:

  1. reset token 검증
  2. 비밀번호 해시 업데이트
  3. passwordChangedAt=now() 업데이트
  4. 기존 refresh token 무효화
  5. 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 sizetick당 최대 batch
local/dev10초105
prod3초105

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가 메시지를 받지 못했으므로 publishAttemptsnextAttemptAt을 기준으로 재시도합니다.

실패 횟수다음 시도
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. 구현 순서

  1. SES 도메인 준비: vivid.place, DKIM/SPF/DMARC 설정, sandbox 해제
  2. SQS Standard Queue + DLQ + IAM 권한 준비
  3. 환경 변수와 배포 Secret 항목 정의
  4. users, email_outbox 마이그레이션 작성
  5. EmailModule 골격과 LogEmailProvider 구현
  6. EmailTokenService 구현
  7. EmailOutboxService 구현
  8. EmailDispatcherService 구현
  9. EmailWorkerService 구현
  10. SesEmailProvider 구현
  11. 회원가입 이메일 인증 플로우 연결
  12. 인증 메일 재발송 플로우 연결
  13. 로그인 차단 정책 적용
  14. 비밀번호 재설정/변경 플로우 연결
  15. 결제 성공 메일 outbox 연결
  16. 프론트엔드 인증/재설정 화면 구현
  17. local/dev log mode 검증
  18. 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 처리