website: 매직 링크·이메일 발송 HTTP E2E 커버리지 갭 메움
이번 커밋에서는 백엔드에서 관리자 초대 및 비밀번호 재설정과 관련된 이메일 발송 기능에 대한 End-to-End (E2E) 테스트 커버리지를 크게 확장했습니다. 이전에는 이메일 서비스 자체의 동작이나 API 호출 성공 여부만 검증했다면, 이제는 실제 발송된 이메일에 포함된 매직 링크가 제대로 작동하는지, 그리고 이메일 발송 실패 시에도 정확한 로그가 ...
요약
이번 작업은 주로 백엔드 애플리케이션의 이메일 관련 E2E 테스트를 강화하는 데 중점을 두었습니다. 기존에는 이메일 발송 성공 여부만 테스트했다면, 이제는 실제 발송된 이메일에 포함된 매직 링크를 추출하여 해당 링크를 통해 관리자 초대 수락 및 비밀번호 재설정 과정이 정상적으로 완료되는지 검증합니다. 또한, SMTP 서버 연결 실패와 같은 이메일 발송 실패 상황에서도 FAILURE 상태의 이메일 로그가 올바르게 기록되는지 확인하는 테스트를 추가했습니다. 이를 통해 이메일 발송 기능의 안정성과 신뢰도를 크게 향상시켰습니다.
배경 및 목적
기존 이메일 관련 테스트는 주로 EmailService 자체의 로직이나 API 호출이 성공적으로 이루어졌는지에 집중되었습니다. 예를 들어, 관리자 초대 API를 호출했을 때, 해당 API가 성공 응답을 반환하고 EmailService가 이메일을 보내려고 시도했다는 점까지만 검증했습니다. 하지만 실제 서비스 운영 환경에서는 이메일에 포함된 매직 링크(토큰)가 유효한지, 이를 통해 사용자가 정상적으로 초대 수락이나 비밀번호 재설정을 완료할 수 있는지, 그리고 예상치 못한 네트워크 문제로 이메일 발송이 실패했을 때 어떻게 처리되는지에 대한 검증이 부족했습니다.
이러한 커버리지 갭은 실제 서비스 장애 발생 시 디버깅의 어려움으로 이어질 수 있으며, 사용자 경험 저하의 원인이 될 수 있습니다. 따라서 이번 작업은 다음과 같은 문제를 해결하고자 했습니다:
- 매직 링크의 실질적인 동작 검증 부재: 이메일에 포함된 URL이 실제로 유효하며, 이를 통해 사용자가 다음 단계로 나아갈 수 있는지 확인.
- 이메일 발송 실패 시 로깅의 불확실성: 네트워크 문제 등으로 이메일 발송이 실패했을 때,
FAILURE상태로email_log에 기록되어야 하는 부분이 제대로 작동하는지 검증. - E2E 흐름에서의 안정성 확보: 관리자 초대 및 비밀번호 재설정이라는 민감한 기능에 대해, 실제 HTTP 요청-응답 흐름 전체를 검증하여 시스템의 안정성 확보.
구현 내용
이번 커밋에서는 기존 테스트 구조를 리팩토링하고 새로운 E2E 테스트 케이스를 추가하여 이메일 기능의 신뢰성을 높였습니다.
주요 변경사항
AuthEmailHttpEndToEndIntegrationTest:- 관리자 초대 API(
/api/admin/invitations) 호출 후, Mailpit에서 수신된 이메일을 파싱하여 실제 매직 링크에서 토큰을 추출합니다. - 추출된 토큰을 사용하여
/api/admin/invitations/{token}/verify및/api/admin/invitations/{token}/acceptAPI를 실제 HTTP 요청으로 호출하여 초대 수락 과정을 완주합니다. - 비밀번호 재설정 API(
/api/admin/password/forgot)에 대해서도 유사한 방식으로, 메일에서 토큰을 추출하여/api/admin/password/reset/{token}/verify및/api/admin/password/reset/{token}API를 호출한 후, 새로 설정된 비밀번호로 로그인을 시도하여 재설정 및 로그인 흐름을 검증합니다. - 각 성공적인 흐름 후
email_log에SUCCESS상태로 기록되었는지 확인합니다.
- 관리자 초대 API(
AuthEmailHttpFailureIntegrationTest:- Mailpit 컨테이너를 의도적으로 중지시킨 상태에서 이메일 발송 API를 호출하여 SMTP 서버 연결 실패 시나리오를 재현합니다.
- 초대 API 호출 시,
502 Bad Gateway응답을 받고EMAIL_SEND_FAILED코드가 반환되는지, 그리고email_log에FAILURE상태로 기록되는지 확인합니다. 또한, 이 과정에서 초대 정보가 롤백되는지도 검증합니다. - 비밀번호 재설정 API 호출 시,
200 OK응답을 받지만 내부적으로는FAILURE상태로email_log에 기록되고 비밀번호 재설정 토큰이 생성되지 않는 것을 검증합니다. 이는 계정 열거 공격을 방지하기 위한 스펙을 준수하는 것입니다.
MailpitContainerSupport(신규 클래스):- 이전에는 각 테스트 클래스마다 Mailpit Docker 컨테이너 설정 및
DynamicPropertySource정의가 중복되어 있었습니다. 이를MailpitContainerSupport라는 추상 클래스로 추출하여 코드 중복을 제거했습니다. - 이 클래스는 TLS를 지원하는 Mailpit 컨테이너를 설정하고, Spring Mail 프로퍼티를 동적으로 설정하는 역할을 합니다.
- 또한, SMTP 연결 실패를 빠르게 감지하기 위한
fastFailureTimeouts메서드를 제공하여, 실패 시나리오를 테스트하는 클래스에서 이를 활용할 수 있도록 했습니다.
- 이전에는 각 테스트 클래스마다 Mailpit Docker 컨테이너 설정 및
- 기존 테스트 클래스 리팩토링:
EmailServiceIntegrationTest,EmailServiceFailureIntegrationTest,EmailServiceFailureTransactionBoundaryIntegrationTest등 기존 테스트 클래스들이MailpitContainerSupport를 상속하도록 수정하여 컨테이너 설정 중복을 제거했습니다.
변경된 파일 목록
backend/docs/email-module.mdbackend/src/main/java/likelion/khu/website/admin/password/AdminPasswordResetService.javabackend/src/main/java/likelion/khu/website/common/GlobalExceptionHandler.javabackend/src/test/java/likelion/khu/website/email/AuthEmailHttpEndToEndIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/AuthEmailHttpFailureIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/EmailServiceFailureIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/EmailServiceFailureTransactionBoundaryIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/EmailServiceIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/EmailServiceStageProfileIntegrationTest.javabackend/src/test/java/likelion/khu/website/email/MailpitContainerSupport.javabackend/src/test/java/likelion/khu/website/feed/post/PostControllerTest.java(이 파일은 실제 변경 내용은 제공되지 않았으나, 목록에 포함되어 있어 명시합니다.)
코드 라인 수
- 총 추가 라인: 422
- 총 삭제 라인: 141
핵심 코드 설명
1. 매직 링크 추출 및 검증 (AuthEmailHttpEndToEndIntegrationTest)
private String extractTokenFromMessage(String messageId, String linkPathPrefix) throws Exception {
JsonNode detail = fetchMessageDetail(messageId);
String html = detail.get("HTML").asText();
Matcher matcher = Pattern.compile(Pattern.quote(linkPathPrefix) + "([^\\\"\\\\s<]+)").matcher(html);
if (!matcher.find()) {
throw new AssertionError("메일 HTML에서 " + linkPathPrefix + " 링크를 찾지 못했어요");
}
return matcher.group(1);
}
// ... 테스트 메서드 내에서 ...
mockMvc.perform(post("/api/admin/invitations")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\": \"" + to + "\"}"))
.andExpect(status().isCreated());
JsonNode message = awaitMessageTo(to);
String token = extractTokenFromMessage(message.get("ID").asText(), "/admin/invite/");
mockMvc.perform(get("/api/admin/invitations/{token}/verify", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value(to));
mockMvc.perform(post("/api/admin/invitations/{token}/accept", token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"메일링크QA\", \"password\": \"password1\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email").value(to));
이 코드는 Mailpit API를 통해 실제 수신된 이메일의 HTML 내용에서 /admin/invite/ 또는 /admin/reset-password/ 와 같은 접두사 뒤에 오는 토큰을 정규식으로 추출합니다. 이렇게 추출된 토큰을 사용하여 실제 /verify 및 /accept(또는 /reset) API를 호출함으로써, 이메일에 포함된 링크가 제대로 작동하는지를 E2E로 검증합니다.
2. SMTP 서버 연결 실패 테스트 (AuthEmailHttpFailureIntegrationTest)
mailpit.stop(); // SMTP 서버 중지
mockMvc.perform(post("/api/admin/invitations")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\": \"" + to + "\"}"))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.code").value("EMAIL_SEND_FAILED"));
List<EmailLog> logs = awaitEmailLogFor(to);
assertThat(logs).hasSize(1);
assertThat(logs.get(0).getStatus()).isEqualTo(EmailStatus.FAILURE);
이 코드는 mailpit.stop()을 호출하여 SMTP 서버와의 연결을 의도적으로 끊습니다. 이 상태에서 이메일 발송 API를 호출하면, EmailService는 SMTP 서버에 연결하지 못하게 되고, 이는 502 Bad Gateway 오류로 클라이언트에게 반환됩니다. 또한, 중요한 것은 email_log 테이블에 FAILURE 상태로 해당 발송 시도가 기록되는지 확인하는 것입니다. 이는 이메일 발송 실패 시 알림 시스템 등 후속 처리를 위한 핵심 근거가 됩니다.
3. 컨테이너 설정 중복 제거 (MailpitContainerSupport)
@Testcontainers
abstract class MailpitContainerSupport {
@Container
static final GenericContainer<?> mailpit =
new GenericContainer<>(DockerImageName.parse("axllent/mailpit:v1.21"))
.withExposedPorts(1025, 8025)
.withCopyFileToContainer(MountableFile.forClasspathResource("mailpit-tls/cert.pem"), "/mailpit-tls/cert.pem")
.withCopyFileToContainer(MountableFile.forClasspathResource("mailpit-tls/key.pem"), "/mailpit-tls/key.pem")
.withCommand(
"--smtp-tls-cert", "/mailpit-tls/cert.pem",
"--smtp-tls-key", "/mailpit-tls/key.pem",
"--smtp-require-starttls",
"--smtp-auth-accept-any"
);
@DynamicPropertySource
static void mailpitProperties(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", mailpit::getHost);
registry.add("spring.mail.port", () -> mailpit.getMappedPort(1025));
// ... (username, password, auth, starttls 설정) ...
}
static void fastFailureTimeouts(DynamicPropertyRegistry registry) {
registry.add("spring.mail.properties.mail.smtp.connectiontimeout", () -> "3000");
registry.add("spring.mail.properties.mail.smtp.timeout", () -> "3000");
}
}
이 abstract 클래스는 Mailpit 컨테이너 설정과 Spring Mail 프로퍼티 설정을 중앙 집중화합니다. AuthEmailHttpEndToEndIntegrationTest나 AuthEmailHttpFailureIntegrationTest와 같은 클래스들이 이 MailpitContainerSupport를 상속받음으로써, 동일한 컨테이너 설정 로직을 재사용할 수 있습니다. 또한, 실패 시나리오 테스트에 필요한 빠른 타임아웃 설정을 fastFailureTimeouts 메서드를 통해 제공합니다.
기술적 의사결정
이번 작업에서 특별히 새로운 기술이나 라이브러리를 도입하기보다는, 기존의 Spring Boot 테스트 프레임워크와 Testcontainers를 활용하여 E2E 테스트 커버리지를 강화하는 방향으로 진행되었습니다.
- Testcontainers 활용: Mailpit과 같은 외부 의존성을 Docker 컨테이너로 관리함으로써, 개발 환경과 CI/CD 환경에서 일관된 테스트 환경을 구축했습니다. 특히 TLS를 적용한 Mailpit 컨테이너 설정을 통해 실제 운영 환경과 유사한 SMTP 통신을 검증할 수 있었습니다.
@DynamicPropertySource활용: Testcontainers가 동적으로 할당하는 포트 번호와 같은 환경 설정을 Spring Boot 애플리케이션 컨텍스트에 동적으로 주입하기 위해@DynamicPropertySource를 사용했습니다. 이는 외부 서비스와의 연동 테스트에서 필수적인 부분입니다.MailpitContainerSupport추상 클래스 도입: 여러 통합 테스트 클래스에서 Mailpit 컨테이너 설정 및 프로퍼티 주입 로직이 반복되는 것을 발견하고, 이를abstract클래스로 분리하여 코드의 중복을 제거하고 유지보수성을 향상시켰습니다. 이는 디자인 패턴 중 '템플릿 메서드 패턴'과 유사하게, 공통 로직은 부모 클래스에 두고 구체적인 부분(예: 타임아웃 설정)은 자식 클래스에서 오버라이드하는 방식입니다.
배운 점 및 개선점
이번 커밋을 통해 이메일 발송 기능에 대한 E2E 테스트의 중요성을 다시 한번 느꼈습니다. 단순히 API가 성공하는 것을 넘어, 실제 사용자가 경험하는 시나리오 전체를 검증하는 것이 얼마나 중요한지를 깨달았습니다.
배운 점:
- E2E 테스트는 애플리케이션의 실제 동작 흐름을 가장 잘 반영하며, 잠재적인 통합 문제를 미리 발견하는 데 효과적입니다.
- 매직 링크를 포함한 이메일 기반의 워크플로우는 링크의 유효성 검증이 필수적이며, 이는 실제 HTTP 요청을 통해 검증해야 합니다.
- 이메일 발송 실패 시의 예외 처리 및 로깅 전략은 서비스의 안정성을 보장하는 데 매우 중요하며, 이를 위한 테스트 커버리지를 확보해야 합니다.
- 테스트 코드의 중복을 줄이기 위해 추상 클래스나 헬퍼 클래스를 활용하는 것이 코드 품질 향상에 기여합니다.
개선점 및 다음 단계:
- 다양한 실패 시나리오 추가: 현재는 SMTP 서버 연결 실패만을 테스트했는데, 향후에는 유효하지 않은 수신자 주소, SPF/DKIM/DMARC 정책 위반으로 인한 메일 거부 등 더 다양한 이메일 발송 실패 시나리오에 대한 테스트를 추가할 수 있습니다.
- 보안 관련 테스트 강화: 매직 링크의 만료, 재사용 불가 등 보안 관련 기능에 대한 테스트를 추가하여 더욱 견고한 시스템을 구축할 수 있습니다.
- 성능 테스트: 대량의 이메일 발송 시나리오에 대한 성능 테스트를 고려하여, 시스템의 확장성을 확보할 수 있습니다.
- 코드 가독성 개선:
awaitEmailLogFor와 같은 메서드에서 사용되는Thread.sleep은 테스트 실행 시간을 길게 만들 수 있으므로, 더 효율적인 대기 메커니즘을 고려해 볼 수 있습니다 (예: Polling 전략 개선).
참고 자료
- Testcontainers 공식 문서: https://www.testcontainers.org/
- Spring Boot Test 문서: https://docs.spring.io/spring-boot/docs/current/reference/html/testing.html