website: 이메일 발송 모듈 — email_log 트래킹 + 초대·재설정 템플릿 구현기
멋사 경희대 사이트 백엔드에 어드민 초대·비밀번호 재설정 메일을 실제로 만들고 보내고 기록하는 EmailService 모듈을 추가한 PR입니다. 2026년 7월 8일에 열려 7월 10일에 dev 브랜치로 병합됐고, 그 사이 트랜잭션 경계 문제로 세 번이나 접근 방식을 갈아엎는 과정이 있었습니다.
요약
이 PR은 컨트롤러 없이도 독립적으로 검증 가능한 이메일 발송 모듈을 완성하는 게 목표였습니다. EmailType, EmailStatus, EmailLog, EmailService 같은 핵심 클래스를 새로 만들고, 성공이든 실패든 무조건 email_log 테이블에 기록을 남기는 구조로 짰습니다. 초반엔 순조롭게 흘러가다가, 리뷰 중 "이 메서드가 다른 트랜잭션 안에서 호출되면 로그가 롤백에 휩쓸려 사라진다"는 문제가 발견되면서 세 차례의 시행착오 끝에 @Async 기반 이벤트 발행 방식으로 마무리됐습니다. 최종적으로 백엔드 전체 테스트 85개가 로컬 Docker 환경에서 통과했고, dev에 머지됐습니다.
배경 및 목적
이 레포에는 #74(어드민 초대·비밀번호 재설정 기능)라는 이슈가 있는데, 이게 동작하려면 "초대/재설정 링크를 메일로 보내는 기능"이 먼저 있어야 합니다. 마침 #75(OCI Email Delivery 발송 기반 — Email Domain, DKIM, SMTP 자격증명)가 인프라 쪽에서 이미 준비돼 있었기 때문에, 그 위에서 실제로 메일을 만들고 보내고 기록하는 백엔드 모듈을 먼저 만들어두자는 게 이 PR의 목적이었습니다. #74의 컨트롤러가 붙기 전에 이 모듈만으로 독립적으로 검증할 수 있는 형태로 완성해두는 것이 핵심이었습니다.
구현 내용
기본 구조
likelion.khu.website.email 패키지를 새로 만들고 다음 요소들을 채웠습니다.
EmailType: 메일 종류(INVITE, PASSWORD_RESET)별 템플릿 이름과 고정 제목 매핑EmailStatus: SUCCESS / FAILUREEmailLog:email_log테이블 엔티티 — recipient, emailType, subject, status, errorMessage, messageId, sentAt (본문과 토큰은 노출 위험 때문에 저장 안 함)EmailLogRepository: JPA 리포지토리EmailService: 수신자 주소 검증 → Thymeleaf 렌더링 → SMTP 발송 → 로그 기록의 전체 흐름을 담당EmailSendException: 발송 실패 시 던지는 unchecked 예외invite.html,password-reset.html템플릿
EmailService.send()의 핵심 흐름은 이렇습니다.
private void send(String to, EmailType type, Context context) {
String subject = subjectFor(type);
MimeMessage message = null;
try {
message = mailSender.createMimeMessage();
String html = templateEngine.process(type.getTemplateName(), context);
InternetAddress toAddress = new InternetAddress(to);
toAddress.validate();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
helper.setTo(toAddress);
helper.setFrom(from);
helper.setSubject(subject);
helper.setText(html, true);
mailSender.send(message);
recordSuccess(to, type, subject, messageIdOf(message));
} catch (Exception e) {
recordFailureSafely(to, type, subject, message, e);
throw new EmailSendException(type, to, e);
}
}
주소 검증 부분(InternetAddress.validate())은 테스트를 작성하다가 발견한 실제 버그를 고친 지점입니다. 처음엔 MimeMessageHelper.setTo(String)만으로 충분하다고 생각했는데, 이 메서드가 느슨한 파싱만 해서 "not-an-email-address" 같은 값도 그냥 통과시켜버렸습니다. 테스트를 통과시키려고 입력값을 바꾸는 대신 "이 값이 걸러지는 게 맞는가"를 확인했고, 맞다는 결론이 나와서 .validate() 호출을 추가했습니다.
테스트: 단위와 통합을 1:1로 매칭
EmailServiceTest에서 9개 단위 테스트를 짰고, EmailServiceIntegrationTest 등에서 Testcontainers + Mailpit으로 실제 SMTP 프로토콜 왕복을 검증하는 통합 테스트 3개를 추가했습니다. 목(mock)만으로는 "실제 SMTP 프로토콜로 나가는지"와 "email_log에 진짜 저장되는지"를 확인할 수 없었기 때문입니다.
통합 테스트를 짜다가 리뷰에서 지적받은 부분도 있었습니다. 초기 버전은 Mailpit을 auth=false, starttls.enable=false로 띄웠는데, 이건 실제 OCI 설정(auth=true, starttls=true)과 다른 조건이라 AUTH LOGIN·STARTTLS 협상 코드 경로가 테스트에서 한 번도 실행된 적이 없었던 겁니다. 이를 고쳐서 Mailpit에 자체서명 TLS 인증서를 물리고 --smtp-require-starttls --smtp-auth-accept-any 옵션으로 실제 협상 경로를 타도록 맞췄습니다.
트랜잭션 경계 문제 — 세 번의 시도
가장 까다로웠던 부분은 리뷰 과정에서 나온 지적이었습니다. EmailService.send()는 자체 트랜잭션을 열지 않기 때문에, 미래의 #74가 아래처럼 짤 가능성이 있었습니다.
@Transactional
public void inviteAdmin(String email) {
InviteToken token = inviteTokenRepository.save(new InviteToken(email, ...));
emailService.sendInviteEmail(email, buildInviteUrl(token), token.getExpiresAt());
}
이 경우 SMTP 실패로 EmailSendException이 던져지면 바깥 트랜잭션이 rollback-only로 마킹되면서, 방금 저장한 실패 로그까지 함께 사라집니다. "발송 결과는 무조건 email_log에 남아야 한다"는 이 모듈의 불변식이 깨지는 지점이었습니다.
1차 시도는 email_log 저장을 별도 빈으로 분리하고 @Transactional(REQUIRES_NEW)를 붙이는 방식이었습니다. 그럴듯해 보였지만 CI에서 실패했습니다. 원인은 application.yml의 hikari.maximum-pool-size: 1 설정 — SQLite가 single-writer라 커넥션 풀이 1개로 고정돼 있는데, REQUIRES_NEW는 같은 스레드에서 새 커넥션을 요청하다 보니 바깥 트랜잭션이 하나뿐인 커넥션을 쥐고 있는 동안 순환 대기가 발생해 타임아웃이 났습니다.
2차 시도는 활성 트랜잭션 안에서 호출되면 즉시 IllegalStateException을 던지는 가드였습니다. 하지만 이건 "로그가 조용히 사라지는 버그"를 "발송 자체가 안 되는 것"으로 바꾼 것일 뿐, 원래 요구사항인 "발송 결과는 무조건 남아야 한다"를 달성한 게 아니라 회피한 것이었습니다.
최종 수정은 @Async + @TransactionalEventListener(AFTER_COMPLETION) 조합이었습니다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION, fallbackExecution = true)
void onEmailLogEvent(EmailLogEvent event) {
emailLogRepository.save(event.toEmailLog());
}
REQUIRES_NEW가 안 되는 근본 이유는 "같은 스레드에서" 새 커넥션을 요청하기 때문이었는데, @Async를 얹으면 콜백이 별도 스레드에서 실행되므로 원래 스레드는 콜백을 기다리지 않고 바로 커넥션을 반납합니다. 그러면 순환 대기가 사라집니다. EmailService는 활성 트랜잭션 여부로 분기해서, 트랜잭션이 없으면(지금까지의 모든 실제 호출 경로) 기존처럼 즉시 저장하고, 트랜잭션이 있을 때만 이벤트를 발행하는 구조로 정리했습니다.
이후 (트랜잭션 유무) × (발송 성공/실패) × (커밋/롤백) 전체 조합을 표로 펼쳐서 점검했고, 빠진 조합 5개(실패 시 이벤트 발행 여부, 정상 커밋 경로, 리스너 자체 단위테스트, 리스너 내부 저장 실패 방어, 실패했지만 호출자가 예외를 삼켜 롤백 없이 커밋되는 경우)를 추가로 채웠습니다.
그 밖의 자잘한 정리
- SMTP
connectiontimeout/timeout/writetimeout을 5초로 명시 — 이전엔 미설정이라 OCI가 응답 없이 멈추면 요청 스레드가 무한정 블로킹될 수 있었습니다. - stage 제목 접두어를
[STAGE 테스트]에서[stage]로 변경. EmailLog필드명type을emailType으로 리네임(DB 컬럼도 함께 변경 — 배포 전 테이블이라 안전하게 처리).- gitleaks가 테스트용 자체서명 키를 진짜 비밀키로 오탐하는 문제, 문서에 남아있던 개인 이메일 노출 문제를 별도 커밋으로 정리.
- 커밋 시점에 시크릿을 선차단하는 pre-commit 훅도 이 김에 추가.
기술적 의사결정
가장 큰 결정은 트랜잭션 경계 문제를 REQUIRES_NEW가 아니라 비동기 이벤트로 풀기로 한 것입니다. SQLite + 커넥션 풀 1개라는 이 프로젝트의 구조적 제약 때문에 "독립 트랜잭션으로 커밋"이라는 일반적인 해법이 통하지 않았고, 대신 "트랜잭션이 끝난 뒤 별도 스레드에서 저장"하는 최종적 일관성(eventually consistent) 모델을 택했습니다. 그 대가로 트랜잭션 안에서 호출되는 경로에 한해 send() 반환 직후엔 로그가 아직 안 보일 수 있다는 트레이드오프가 생겼는데, 이건 테스트에서 짧은 폴링으로 흡수하도록 처리했습니다.
배운 점 및 개선점
이번 PR에서 가장 크게 배운 건 "테스트를 통과시키기 위해 입력값을 바꾸는 것"과 "테스트가 드러낸 진짜 버그를 고치는 것"을 구분하는 감각이었습니다. 주소 검증 문제도, 통합 테스트의 SMTP 협상 조건 문제도 모두 후자였습니다.
또 하나는 인프라 제약(SQLite 단일 커넥션 풀)이 애플리케이션 레이어의 설계 선택지를 얼마나 좁히는지 실감한 부분입니다. REQUIRES_NEW라는 흔한 해법이 이 프로젝트에서는 통하지 않는다는 걸 CI 실패로 직접 겪고 나서야 근본 원인을 이해할 수 있었습니다.
앞으로 다룰 부분은 PR 설명에도 정리해뒀는데, #74 컨트롤러가 이 EmailService를 실제로 호출하는 연결 작업, 발송 실패 시 재시도(단 멱등성 보장이 선행돼야 함), 비동기 큐 처리, EmailSendException의 원인별 세분화 등이 남아 있습니다.