리크루트 문자 발송 실패..(with 스프링 트랜잭션)

Bellmin·2025년 3월 29일

Econovation

목록 보기
3/3
post-thumbnail

문제 상황

이번 리크루트에서 합/불 결과 이메일 발송 및 문자 발송을 자동화하려고 새로운 기능을 추가했다.
스케줄러로 이메일을 발송 작업을 예약해놨으나, 이메일 발송은 성공했지만, 최종 결과 공지 문자 발송이 제대로 되지 않았다.

원인

로그에 뜨는 원인은 다음과 같았다.

2025-03-24 09:04:31.459 ERROR 1 --- [ueryProcessor-7] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 30000ms.

위 예외는 Connection Timeout 예외로, 데이터베이스 커넥션을 획득하기를 대기하다가, 정해진 시간 동안 커넥션을 획득 할 수 없어서 발생한 예외이다.

즉, JDBC 커넥션을 획득할 수 없다는 뜻이었다. 왜 커넥션을 획득할 수 없었을까?

커넥션 풀은 Hikari CP를 사용하고 있다.
(참고 : 스프링부트 2.0 이전에는 tomcat-jdbc 를 사용하다 2.0 이후부터는 hicari cp를 사용한다고 한다.)

현재 리크루트 프로젝트의 커넥션 풀 설정은 사진과 같다.

▶ HikariCP Max Pool Size: 20
▶ HikariCP Minimum Idle: 20
▶ HikariCP Idle Connections: 20
▶ HikariCP Active Connections: 0

(참고 : Hikari CP의 기본 커넥션 수는 10이다.)

분석

이메일 발송 스케줄러의 코드는 아래와 같다.
정해진 시간에 스케줄러가 동작하여, 메일 수신 대상에게 이메일을 보낸 후 메일 발송이 성공하면 이벤트를 발행한다.

@Component
@Slf4j
@RequiredArgsConstructor
public class FinalEmailDiscussionEmailScheduler {

    @Async
    @Scheduled(cron = "${finalDiscussionCron}", zone = "Asia/Seoul")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handle() {
     ... 로직...
     이메일 발송();
    }

    // 이메일 발송 및 실패 처리 메서드
    private boolean 이메일 발송() {
        boolean result = emailService.sendEmail(applicant);
        
        if(result){
        	// 발송 성공 시에만 이벤트 발행
        	Events.raise(EmailSendEvent.of(applicantId, passState, ""));
        }

        return result;
    }
}

EmailSendEvent 를 소비하는 이벤트 핸들러는 아래와 같다.

public class EmailSendEventHandler {

    private final ApplicantSmsService smsService;

    @Async
    @TransactionalEventListener(
            classes = EmailSendEvent.class,
            phase = TransactionPhase.AFTER_COMMIT)
    public void handle(EmailSendEvent emailSendEvent) {
        String applicantId = emailSendEvent.getApplicantId();

        smsService.sendSms(applicantId);
    }
}

모든 메일이 다 전송된 후에 문자 발송을 하고 싶어서, AFTER_COMMIT 속성을 사용했다.
(다시 생각해보니, 모든 메일이 전송된 후 sms가 발송되어야 하나? 라는 생각이 든다.)

위에서 sms 발송 실패의 원인은 Connection을 획득하지 못해서라고 했다.
(Connection은 애플리케이션이 데이터베이스의 접근을 도와주는 객체이다.)

근본 원인

정말 근본 원인은 @TransactionalEventListener 한테 있었다.

해당 어노테이션은 상위 트랜잭션 내부에서 이벤트가 발행되었을 때, 상위 트랜잭션이 완전히 커밋된 후에 이벤트를 처리하는 이벤트 리스너이다.

상위 트랜잭션(=이메일 발송 스케줄러)에서 loop 문으로 1개씩 메일을 발송하고, 성공하면 이벤트 1개를 발행한다.

이 스케줄러의 트랜잭션이 종료가 되려면 약 30개의 이메일 발송이 모두 처리되어야 한다.

30개의 메일이 발송되는 중에도 이벤트는 1개씩 발행되고 있을 것이다..
하지만 이벤트가 발행된다고 해서 무조건 처리되는 것은 아니다.

그 이유는 위에서 말했던 것처럼 @TrnasactionalEventListener 이기 때문에, 상위 트랜잭션이 완전히 종료된 후에 (=이메일 30개 발송이 모두 끝난 후에)야 이벤트 처리가 시작되기 때문이다.

그러면 이미 발행되는 이벤트는?
그냥 처리되기를 기다리고 있을 뿐이다. (아래 사진처럼 말이다..)

상위 트랜잭션이 모두 종료되면, 그 순간 30개의 이벤트가 한 번에 처리되려고 하므로 내부적으로 DB에 접근하는 로직이 있었기 때문에 커넥션을 사용한다.

그래서 커넥션이 순간적으로 부족한 상황이 생기는 것이다.

해결책

@TransactionalEventListener 를 사용한 이유는 트랜잭션이 완전히 커밋된 후에 수행되는 작업이라고 생각했기 때문이다.

하지만 다시 생각해보니, 1명에게 이메일 발송을 성공하면 해당 지원자에게 sms 발송을 해주면 된다.
굳이 이메일 발송 30개가 완료된 후에야, sms 전송 작업을 시작하지 않아도 된다.

그래서 @TransactionalEventListener가 아닌 @EventListener를 사용한다.

더 나아가서, 메일 발송 과 문자 발송을 연계하는 방식을 다시 생각해봐야 할 것 같다.
이벤트 처리를 한다거나, 동기로 처리를 한다거나 등등..

아직 많이 부족함을 느낀다.


참고자료
https://yonghwankim-dev.tistory.com/615

2개의 댓글

comment-user-thumbnail
2025년 3월 29일

궁금한게 있습니다.

  1. 해당 변경으로 인해 이메일 발송 실패 시 문자 발송은 처리가 될 것 같은데,
    이메일 발송 실패에 대한 대응 방안은 어떻게 해주고 있나요?

  2. 다음과 같은 코드에서:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handle() {
    ... 로직...
    이메일 발송();
}

이메일 발송은 트랜잭션이 필요하지 않은 작업이라고 생각합니다.
특히 외부 API가 에러날 경우 오랜 시간 동안 데이터베이스 커넥션을 점유할 수 있는 문제가 발생할 거라고 생각됩니다.
혹시 이 부분에 트랜잭션이 필요한 특별한 이유가 있을까요?
( 이 부분으로 인한 커넥션 부족 문제도 있을 것 같아서요!)

1개의 답글