[교내 공지사항 알림 서비스] 개발일기 12편 [서비스 중 오류발생 및 해결방법]

갑자기 1주일 넘게 잘 운영되던 메일 서비스가 오류가 발생했다. ㅜㅜ

오류는 다음과 같이 gmail smtp 서버에서 421 error를 던져준 것이다.

421 ERROR

 

정확히는 421-4.3.0이라는 에러인데 google support page에서 해당 정보를 찾을 수 있었다.

421-4.3.0

일시적인 시스템 장애가 발생했다는 뜻인데 좀 더 자세히 알아보고자 링크를 타고 더 들어가 보았다.

SMTP 400 오류 코드

 

아마 수신서버에서 같은 곳에서 한 번에 많은 양의 메일을 수신하여 생기는 문제 같다.

현재 내 서비스 로직은 1개의 메일을 보내다가 421 에러를 마주치면 후속 작업들이 전부 끝나게 되므로 치명적이다.

오늘도 대략 뒤에 10명 정도가 받지 못하여 뒤에 10명만 따로 보내는 작업을 해줬다.

 

참고로 에러가 발생하는지 매일 확인하는 것은 아니고 내가 제일 마지막 순서에 받게끔 하여 나에게 메일이 안 온다면 에러가 발생한 것을 알 수 있도록 작업을 해놓았다. (order by id desc)

 

아마 최근 사용자가 급격히 늘어 200명을 돌파하여 이런 문제가 생긴 것 같다. 

때문에 위와 같은 오류를 최대한 방지하고자 다음과 같은 아이디어를 생각해 내었다.

우선 기존 코드는 다음과 같다.

private void notifyNoticeMembers(String noticeInfo) {
    List<Member> mainNoticeMembers = memberRepository.findByNoticeFlagOrderByIdDesc(true);

    // CompletableFuture 리스트를 만들어 모든 작업이 완료될 때까지 기다림
    List<CompletableFuture<Void>> futures = mainNoticeMembers.stream()
        .map(member -> CompletableFuture.runAsync(() -> {
            try {
                emailService.sendEmail(member, noticeInfo);
                log.info("[이메일 발송] {} {}", member.getEmail(), member.getId());
            } catch (MessagingException e) {
                log.error("[이메일 발송 실패] {}", member.getEmail(), e);
            }
        }, emailExecutor))
        .toList();

    // 모든 작업이 끝날 때까지 기다림
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}

 

수정 후 코드

private void notifyNoticeMembers(String noticeInfo) {
    List<Member> mainNoticeMembers = memberRepository.findByNoticeFlagOrderByIdDesc(true);
    // CompletableFuture 리스트를 만들어 모든 작업이 완료될 때까지 기다림
    List<CompletableFuture<Void>> futures = mainNoticeMembers.stream()
        .map(member -> CompletableFuture.runAsync(() -> {
            try {
                retrySendEmail(member, noticeInfo, 3); // 최대 3회 재시도
            } catch (MessagingException e) {
                log.error("[이메일 발송 실패] {} {}", member.getEmail(), e);
            }
        }, emailExecutor))
        .toList();

    // 모든 작업이 끝날 때까지 기다림
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}


private void retrySendEmail(Member member, String noticeInfo, int maxRetries) throws MessagingException {
    int attempt = 0;
    while (attempt < maxRetries) {
        try {
            emailService.sendEmail(member, noticeInfo);
            log.info("[이메일 발송 성공] {} {}", member.getEmail(), member.getId());
            return; // 성공 시 재시도 종료
        } catch (MessagingException e) {
            if (e.getMessage().contains("421")) { // 421 에러에 대해 재시도
                attempt++;
                log.warn("[이메일 발송 재시도] {} {} - 시도 {}회", member.getEmail(), member.getId(), attempt);
                try {
                    Thread.sleep(15000); // 15초 대기 후 재시도 (간격 조절 가능)
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new MessagingException("재시도 중 인터럽트 발생", ie);
                }
            } else {
                throw e; // 다른 에러는 즉시 예외 처리
            }
        }
    }
    throw new MessagingException("421 오류로 인해 이메일 발송 실패: " + member.getEmail());
}

 

기존에는 notifyNoticeMembers라는 함수만 가지고 mail을 보내는 작업을 하였는데 retrySendEmail 함수를 실행하는 형식으로 421 error가 발생한 곳에서 최대 3번까지 재시도를 하도록 코드를 수정하였다. 또한 바로 재시도하는 것이 아닌 15초 후에 재시도하였다. 에러 메세지에 tryagain(10) 이라는 문구가 있기 때문에 최소 10초 이상 기다렸다가 재시도를 해야하기 떄문이다.

 

위 코드를 테스트해보고 싶지만 서비스 로직 상 실제 gsmtp 서버를 이용해야 하므로 실제 서비스되는 과정을 지켜봐야 한다.

다음 글에선 해당 코드로 잘 작동이 되는지 쓸 예정이다..!