대규모 이메일 발송 테스트 중 겪은 커넥션 누수 문제 해결하기

J_log·2025년 6월 16일
0

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만명 단위의 구독자 테스트를 수행하던 중, 아래와 같은 경고 로그가 계속 출력되었습니다.


문제 1 : 커넥션 누수 (Connection Leak) 경고 발생

문제 발생

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 작업만 최소 범위로 분리되어, 커넥션 점유 시간이 줄어듭니다.


문제 2 : Gmail SMTP 일일 발송량 초과로 인한 전송 실패

문제 발생

성능 테스트 중 갑자기 콘솔에 다음과 같은 오류가 발생했습니다.

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는 일반 계정 기준으로 하루에 최대 500명에게만 메일 전송 가능
  • 테스트 중 1만명 이상의 구독자에게 메일을 전송하려 했기 때문에, Gmail 서버 측에서 이를 차단한 것

Gmail 공식 가이드에 따르면, SMTP를 통한 대량 발송은 제한되며, 비즈니스용 Google Workspace에서도 하루 2,000건 내외로 제한됩니다.

해결 방법

이번 테스트는 개발 환경에서 Gmail SMTP로 메일 발송을 설정한 상태였기 때문에, 실제 대량 발송에 적합한 메일 서비스로 전환이 필요했습니다.

다음과 같은 방법을 고려하고 있습니다.

  1. Amazon SES, Mailgun, Sendgrid 등 외부 이메일 서비스 사용

    • Amazon SES : 저렴하고 AWS 기반이라 확장성도 우수
    • Mailgun/SendGrid : REST API 또는 SMTP를 통해 대량 발송 지원
    • 대부분 SPF, DKIM 설정을 통해 발송 신뢰도도 높일 수 있음
  2. 메일 발송 큐 구성

    • 실제 서비스에서는 Kafka나 RabbitMQ 기반의 비동기 메일 큐 구성을 통해 메일 발송 속도를 제어
    • 일정 시간당 발송량을 제한하여 발송 실패율을 줄일 수 있음

마무리

  • Gmail SMTP 제한을 인지하고.. 메일을 500개 정도로 줄여서 테스트 진행
  • 이후 메일 발송 성공률이 100%로 회복되었고, 장기적으로는 Amazon SES로 전환을 계획 중

0개의 댓글