[비동기 처리 예외] 스케줄러 & 이메일 발송 실패 대응기

이명규·2024년 8월 31일
0

문제 인식


회사 서비스에는 외부 API와 연동되어 데이터를 동기화하는 작업이 있고, 이 작업은 스케줄러로 처리되고 있다.
그런데 문득, "이 스케줄러가 실패하면 디버깅이 어렵고, 우리가 그걸 인지하지 못하면 어떻게 하지?"라는 생각이 들었다.

사실 이 문제는 이미 가끔 발생 중이었고,
그래서 “이슈가 터지기 전에 알 수 있도록 경고 시스템을 넣자”는 결론에 도달했다.


1. Slack 알림 시스템 도입

가장 먼저 떠오른 해결책은 Slack 알림이었다.
회사 동료들이 작성한 레퍼런스를 참고해 Slack API를 기반으로 Webhook 연동을 구현했다.

구성한 내용은 아래와 같다

  • Slack App 생성 및 Webhook 발급
  • Spring yml에 Webhook URL 추가
  • SlackNotifier 서비스 추가
slack:
    webhook:
        url: "https://hooks.slack.com/services/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"
@Service
class SlackNotifier(
    @Value("\${slack.webhook.url}")
    private val webhookUrl: String,
) {

    fun sendMessage(message: String) {
        restTemplate.postForEntity(webhookUrl, requestEntity, String::class.java)
        ....
    }

}
  • 테스트 결과는 아래와 같다.



2. 스케줄러 실패 대응 전략

Slack 알림 외에도, 실패 케이스를 다각도로 대응하기 위해 여러 방안을 정리해보았다

  • Spring Retry (재시도 매커니즘)
  • Exponential Backoff (지수 백오프)
    (재시도 간격을 지수적으로 늘려가는 방식)
  • Circuit Breaker
    • 재시도가 반복적으로 실패할 경우, 일정 시간 동안 해당 작업을 멈추고 시스템을 보호하는 방법, 무의미한 재시도를 방지
  • 실패 이력 저장 및 로깅 강화

이 중 내가 택한 방법은 실패 로그 저장 + 슬랙 알림이었다.
선택 이유는 다음과 같다

  1. 대부분의 코드가 외부 API 요청이므로 재시도 로직이 크게 의미 없을 수 있다.
  2. 데이터 동기화 작업이므로 실패할 경우 빠른 대응이 필요하다



3. 실패 로그 저장 (히스토리 테이블)

  • SchedulerFailedLog 로깅 관련 엔티티 추가
@Entity
@Table(name = "scheduler_failed_log")
@SQLDelete(sql = "UPDATE routing SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
class SchedulerFailedLog() : AutoIncrementIdEntity() {
    constructor(
        exceptionSource: String,
        exceptionMessage: String,
        requestedUrl: String? = null,
    ) : this() {
        this.exceptionSource = exceptionSource
        this.exceptionMessage = exceptionMessage
        this.requestedUrl = requestedUrl
    }

    @Comment("예외 발생 이름")
    @Column(name = "exception_source", nullable = false)
    private var exceptionSource: String = ""

    @Comment("예외 메시지")
    @Column(name = "exception_message", nullable = false)
    private var exceptionMessage: String = ""

    @Comment("외부 요청 url")
    @Column(name = "requested_url", nullable = true)
    private var requestedUrl: String? = ""
}
  • 예외에 대한 더 상세한 컬럼을 추가할 수도 있지만 당장에는 예외 메시지와 요청 URL 을 보고 싶었다, 추후 추가가 필요할 경우 고려해야할 사항이다
  • 스케줄러 실패 시 새로운 트랜잭션으로 에러 핸들링 및 저장되도록 SchedulerFailedHandler 를 구현했다
@Component
class SchedulerFailedHandler(
    private val schedulerFailedLogRepository: SchedulerFailedLogRepository,
    private val schedulerFailedNotifier: SchedulerFailedNotifier,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun httpFailed(failedLog: SchedulerFailedLog) {
        log.info("동기화 스케줄러 실패 : {}", failedLog.getExceptionMessage())

        schedulerFailedLogRepository.save(failedLog)

        schedulerFailedNotifier.alert(failedLog.getExceptionMessage())
    }
}

결과적으로, 슬랙 알림과 함께 실패 로그가 잘 쌓이도록 구성되었고,

향후 이 로그를 기반으로 모니터링 지표나 리포트 형태로 확장할 계획이다.




4. 이메일 발송 실패

이메일 발송 실패는 스케줄러 실패보다 더 문제였다.
이유는 명확하다

“스케줄러는 내부의 실패지만, 이메일은 사용자와의 접점이다.”

현재 이메일은 다음과 같은 방식으로 처리되고 있었다:

비즈니스 로직 (예: 임시 비밀번호 발급)이 완료된 후

Spring Event + @Async 조합으로 이메일 발송

즉, 트랜잭션이 커밋된 후 비동기적으로 발송되는 구조였고,
이 구조에선 실패 시 대응이 어렵다.



5. 이메일 실패 대응 전략

검토한 방법들

  • Fallback 패턴 적용
    • @HystrixCommand(fallbackMethod = "sendEmailFallback")
  • 메시지 큐 기반 재시도 구조
    • 실패시 메시지 Produce, 이후 메시지 Consume
  • 유저 SMS 를 통해 알림
  • 보상 트랜잭션
    • 커밋한 트랜잭션에 대한 롤백
  • 상태 플래그를 통한 롤백

실제 적용한 방식

  • 이메일 실패 시 실패 로그 저장 + 슬랙 알림
  • 상태 플래그를 저장하여 실패 여부 추적 가능하도록 처리

이유는 다음과 같다

"비즈니스 로직은 성공했지만, 이메일 발송이 실패했다고 그걸 되돌리는 건 신중해야 한다."

다만, 발송 실패를 원자적 단위로 본다면, 오히려 비동기가 아닌 동일 트랜잭션에서 처리하는 게 맞지 않나? 라는 고민도 들었다.
이 부분은 추후 리팩토링에서 더 깊이 검토할 예정이다.



마무리

이번 작업은 단순히 실패를 잡는 것에서 끝나지 않았다.
오히려 이 과정을 통해 기존 아키텍처와 처리 방식이 적절했는지를 되돌아보게 해주는 계기가 되었다.

스케줄러와 이메일 발송처럼 실패 시점이 사용자 혹은 외부 시스템과 연결되는 경우엔,
단순한 로그 이상의 대응이 필요하다.

이번 개선으로 '문제가 발생하면 알게 된다' → '문제가 생기기 전에 인지할 수 있다'로 한 단계 나아갔다고 생각한다.
다음 리팩토링에선 모니터링, 시각화, 재시도 큐 구조 등을 포함해 더 개선해볼 예정이다.

profile
개발자

0개의 댓글

관련 채용 정보