
Spring Boot로 운영 중인 프로젝트에서 대규모 이메일 발송 성능 테스트를 수행하다가, 두 가지 문제를 겪고 원인 분석 및 해결까지 진행한 경험을 공유합니다.
프로젝트에 구독자에게 일본어 학습 이메일을 매일 발송하는 기능이 있습니다.
실제로 수천 명의 유저에게 동시에 이메일을 보낼 수 있도록 하기 위해, 단일 스레드 기반으로 성능 테스트 코드를 작성했습니다.
public PerformanceResult testSingleThreadPerformance(int targetCount) {
List<Subscriber> testSubscribers = createTestSubscribers(targetCount);
String subject = "마이니치 니홍고 - 성능 테스트";
String content = contentService.generateDailyContent(); // 매일 학습 콘텐츠 생성
for (Subscriber subscriber : testSubscribers) {
emailService.sendEmail(subscriber.getEmail(), subject, content);
...
}
}
generateDailyContent()는 오늘 날짜 기준의 콘텐츠를 생성하고 저장하며, emailService.sendEmail()은 JavaMailSender를 통해 실제 메일을 발송합니다.
하지만 1만명 단위의 구독자 테스트를 수행하던 중, 아래와 같은 경고 로그가 계속 출력되었습니다.
HikariPool-1 - Connection leak detection triggered for ...
테스트 중 일정 수 이상의 구독자에게 메일을 발송하면 위와 같은 커넥션 누수 경고가 반복적으로 출력되고, 전체 테스트 속도도 현저히 느려졌습니다.
generateDailyContent() 내부 코드를 살펴본 결과, 문제의 원인을 찾을 수 있었습니다.
@Transactional
public String generateDailyContent() {
Optional<EmailContent> todayContent = emailContentRepository.findByCreatedDate(...);
...
String htmlContent = geminiService.generateContent(...); // 외부 API 호출
...
emailContentRepository.save(...);
...
}
이 메서드는 @Transactional이 붙어 있기 때문에, 전체 블록이 하나의 트랜잭션으로 묶입니다.
즉, 내부에서 사용하는 DB 커넥션은 트랜잭션이 끝날 때까지 반환되지 않습니다.
그런데 문제는 geminiService.generateContent()가 외부 AI API 호출이기 때문에 지연이 수 초 이상 발생하는 경우가 잦았습니다. 그 시간 동안 커넥션이 반환되지 않기 때문에, 수천 명에게 메일을 보내면 커넥션 풀을 모두 소진하게 됩니다.
결과적으로 커넥션 누수 경고가 발생한 것입니다.
핵심은 외부 API 호출을 트랜잭션 밖으로 분리하여, DB 커넥션을 점유하지 않도록 하는 것입니다.
public String generateDailyContent() {
Optional<EmailContent> todayContent = emailContentRepository.findByCreatedDate(...);
if (todayContent.isPresent()) {
return applyEmailTemplate(todayContent.get().getHtmlContent());
}
ContentTheme theme = getOrCreateTheme();
String htmlContent = geminiService.generateContent(theme.getJLPTLevel(), theme.getTopic());
return saveContentWithTransaction(theme, htmlContent); // 트랜잭션은 여기서 시작
}
@Transactional
public String saveContentWithTransaction(ContentTheme theme, String htmlContent) {
EmailContent emailContent = new EmailContent(theme, htmlContent);
emailContentRepository.save(emailContent);
theme.markAsUsed();
contentThemeRepository.save(theme);
return applyEmailTemplate(htmlContent);
}
이렇게 분리하면 트랜잭션이 필요한 DB 작업만 최소 범위로 분리되어, 커넥션 점유 시간이 줄어듭니다.
성능 테스트 중 갑자기 콘솔에 다음과 같은 오류가 발생했습니다.
org.eclipse.angus.mail.smtp.SMTPSendFailedException:
550-5.4.5 Daily user sending limit exceeded.
For more information on Gmail 550-5.4.5 sending limits go to ...
이메일 전송 실패: Failed messages: org.eclipse.angus.mail.smtp.SMTPSendFailedException: 550-5.4.5 Daily user sending limit exceeded.
이는 JavaMailSender나 SMTP 설정의 문제가 아니라, Gmail SMTP 서버가 제공하는 일일 발송량 한도를 초과했기 때문입니다.
Gmail 공식 가이드에 따르면, SMTP를 통한 대량 발송은 제한되며, 비즈니스용 Google Workspace에서도 하루 2,000건 내외로 제한됩니다.
이번 테스트는 개발 환경에서 Gmail SMTP로 메일 발송을 설정한 상태였기 때문에, 실제 대량 발송에 적합한 메일 서비스로 전환이 필요했습니다.
다음과 같은 방법을 고려하고 있습니다.
Amazon SES, Mailgun, Sendgrid 등 외부 이메일 서비스 사용
메일 발송 큐 구성