AWS SES 메일 발송과 이메일 인증 흐름
이 문서는 예전에 simple email service라고 대충 적어둔 메모를 다시 풀어쓴 버전입니다..
이름만 보면 너무 막연한데, 실제로는 AWS SES + 메일 어댑터 + 인증/재설정 플로우를 어떻게 붙일지 정리해둔 문서에 가깝습니다.
핵심은 이겁니다.
- 인증번호 메일 보내기
- 이메일 찾기 링크 보내기
- 비밀번호 재설정 링크 보내기
- 이 흐름을
controller -> usecase -> adapter구조 안에 넣기
왜 이걸 정리했나
이메일 기능은 생각보다 금방 지저분해집니다.
- 인증번호만 보낼지
- 재설정 링크도 같이 보낼지
- 링크 유효시간은 어떻게 둘지
- 가입 여부를 숨겨야 하는 요청은 어떻게 응답할지
- 메일 발송 실패를 어디서 잡을지
이런 게 한 번에 붙기 때문입니다. 그래서 SES 붙이는 법만 보는 것보다, 전체 플로우를 같이 보는 게 훨씬 덜 헷갈립니다.
AWS SES에서 먼저 필요한 것
우선 SES를 쓰려면 SMTP 자격 증명이나 SDK용 접근 키가 필요합니다.
이 부분은 원문에 실제 값이 들어 있었는데, 당연히 그건 전부 걷어냈습니다.
정리하면 준비물은 대략 이 정도입니다.
- SES 콘솔에서 발신용 설정 확인
- SMTP credentials 또는 SDK 자격 증명 생성
- 애플리케이션 런타임에서 환경 변수로 주입
예를 들면 애플리케이션 설정은 이런 식으로 잡을 수 있습니다.
aws:
accessKeyId: ${AWS_ACCESS_KEY_ID}
secretKey: ${AWS_SECRET_ACCESS_KEY}
컨테이너에서는 보통 이렇게 넘기게 됩니다.
version: "3.1"
services:
app:
build: .
environment:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
무적권 코드에 박아두면 안 되고, 환경 변수나 시크릿 스토어로 빼는 게 맞습니다.
메일 기능이 실제로 하는 일
이 메모에서 다루는 시나리오는 크게 3개입니다.
1. 회원가입 인증
- 프론트에서 이메일 인증 요청
- 백엔드에서 6자리 인증번호 생성
- 메일 발송
- 사용자가 인증번호 입력
- 백엔드에서 검증 후 완료 처리
2. 이메일 찾기
- 사용자가 정보를 넣고 이메일 찾기 요청
- 가입된 정보가 맞으면 메일 발송
- 메일 안에는 찾기 링크 또는 안내 정보 포함
3. 비밀번호 재설정
- 사용자가 재설정 요청
- 실제 가입 여부와 무관하게 응답 메시지는 최대한 비슷하게 유지
- 백엔드에서 1회성 링크 생성
- 메일에 링크 포함
- 사용자가 링크 타고 들어와서 새 비밀번호 입력
여기서 중요한 건 이메일 찾기랑 비밀번호 재설정은 링크를 타고 들어오는 시점의 검증이 꼭 필요하다는 점입니다.
그냥 링크만 만들고 끝내면 별로 안 안전합니다..
계층은 어떻게 나누는 게 편한가
원문도 결국 이 구조를 밀고 있었습니다.
graph TD
A[프론트엔드] --> B[AuthController]
B --> C[AuthUseCase]
C --> D[MemberPersistenceAdapter]
C --> E[MailAdapter]
D --> F[데이터베이스]
E --> G[Amazon SES]
이렇게 두면 역할이 좀 분명해집니다.
Controller요청/응답 계약UseCase인증번호 생성, 링크 생성, 만료시간 정책, 예외 흐름Persistence Adapter인증 정보 저장/조회Mail Adapter실제 메일 발송
즉 SES를 직접 컨트롤러에서 부르면 안 되고, 메일 발송은 아웃바운드 어댑터로 내리는 게 제일 덜 꼬입니다.
인터페이스는 이렇게 시작하면 편하다
애플리케이션 레이어에서는 일단 메일 포트를 잡아두고,
public interface MailAdapter {
void sendVerificationEmail(String to, String verificationLink);
void sendPasswordResetLink(String to, String resetLink);
}
인프라에서는 SES 구현체를 붙입니다.
@Component
public class MailAdapterImpl implements MailAdapter {
private final AmazonSimpleEmailService amazonSES;
public MailAdapterImpl(AmazonSimpleEmailService amazonSES) {
this.amazonSES = amazonSES;
}
@Override
public void sendVerificationEmail(String to, String verificationLink) {
SendEmailRequest request = new SendEmailRequest()
.withDestination(new Destination().withToAddresses(to))
.withMessage(new Message()
.withBody(new Body().withHtml(new Content().withCharset("UTF-8").withData(
"회원가입을 완료하려면 다음 링크를 클릭하세요: <a href='" + verificationLink + "'>인증하기</a>"
)))
.withSubject(new Content().withCharset("UTF-8").withData("회원가입 인증 메일")));
amazonSES.sendEmail(request);
}
}
진짜 핵심은 메일을 보냈다가 아니라, 유즈케이스가 어떤 메일을 어떤 정책으로 보내기로 했는가입니다.
어댑터는 그걸 실행만 해주면 됩니다.
링크 메일은 어떻게 다루는 게 낫나
원문에는 이메일 찾기 링크랑 비밀번호 재설정 링크를 같이 고민한 흔적이 있었는데, 방향은 나쁘지 않았습니다.
- 링크는 1회성으로 본다
- 만료시간을 둔다
- DB에 토큰 또는 검증 상태를 저장한다
- 요청이 끝나면 인증 정보를 정리한다
- 재요청이 오면 이전 정보는 무효화한다
이 기준만 있어도 나중에 사고가 많이 줄어듭니다.
예를 들면 이런 흐름입니다.
@Override
public void sendPasswordResetLink(String email) {
if (memberPersistenceAdapter.existsByEmail(email)) {
String token = tokenGenerator.generate();
memberPersistenceAdapter.saveResetToken(email, token);
mailAdapter.sendPasswordResetLink(email, token);
}
}
여기서 실제 서비스에서는 더 봐야 할 게 있습니다.
- 너무 자주 요청하면 rate limit 걸기
- 이미 사용한 토큰 재사용 막기
- 만료시간 체크
- 성공/실패 응답 메시지에서 정보 노출 줄이기
구현할 때 체크해둘 것
원문 메모의 TODO를 정리하면 결국 이 정도입니다.
- 인증 요청 시 이전 인증 정보 삭제
- 인증 완료 시 인증 정보 정리
- 재인증 요청 시 메일 재발송 + 기존 코드 무효화
- 이메일 찾기 / 재설정 링크 진입 시 유효성 검증
- 메일 형식과 링크 동작 확인
- SES 예외를 애플리케이션 예외로 감싸기
이건 나중에 보면 별거 아닌 것 같아도, 실제로는 여기서 많이 새더라고요.
한 줄로 정리하면
AWS SES 붙였다에서 끝나는 문제가 아니고,
실제로는 인증 정책 + 링크 만료 + usecase/adapter 경계 + 예외 처리를 같이 봐야 메일 기능이 덜 불안정해집니다.
SES는 그냥 발송 수단이고, 진짜 중요한 건 그 위에 얹는 인증 흐름입니다.