Pingu
영차영차! Backend

대기열 없이 정확한 시간에 알림 보내기: Spring Quartz TaskDeadlineJob 적용기

2026년 3월 9일

대기열 없이 정확한 시간에 알림 보내기: Spring Quartz TaskDeadlineJob 적용기

들어가며

저희 알림 시스템의 기본 축은 pub/sub 기반 이벤트 아키텍처입니다.
supporters-service는 알림을 직접 전송하지 않고, notification-service가 구독하는 토픽으로 메시지를 발행합니다.

즉시 알림은 이 구조로 충분했지만, 예약 알림은 별도 문제가 있었습니다.

  • 특정 시각에 정확히 실행되어야 함
  • 상태 전환에 따라 기존 예약을 취소하고 다시 등록해야 함
  • 멀티 인스턴스 환경에서도 한 번만 실행되어야 함
이번 글은 전체 아키텍처 소개는 짧게 하고, supporters-service에서 Quartz를 어떻게 붙였는지를 실제 클래스 기준으로 정리해보겠습니다!
quartz

quartz

아키텍처 요약: Pub/Sub + Quartz 역할 분리

즉시 알림과 예약 알림의 책임을 다음처럼 분리했습니다.

  • 즉시 알림: 서비스 로직에서 바로 WebPushNotificationPort 발행
  • 예약 알림: Quartz Job/Trigger 등록 후 시점 도래 시 발행

예약 알림 흐름은 아래처럼 동작합니다.

다이어그램 로딩 중...

예약 알림 설계: 4종 Quartz Job 분리

supporters-service는 예약 알림을 단일 Job으로 처리하지 않고 목적별로 분리했습니다.

  1. TaskDeadlineJob
    • SLA 마감 시점 초과 처리 (SELECTED, PENDING_TEACHER_REVIEW, IN_PROGRESS)
  2. TaskDeadlineRemindJob
    • 처리 마감(deadlineAt) N시간 전 임박 알림 (현재 48h 전)
  3. TaskTeacherRemindJob
    • 12시간 무액션 리마인드 (SELECTED, PENDING_TEACHER_REVIEW)
  4. TaskDueAtRemindJob
    • 실제 제출 기한(dueAt) 기준 48/24/12/8/4/1시간 전 알림

이렇게 나누니 각 Job의 조건이 단순해지고, 취소/재등록도 의도가 명확해졌습니다.

예약 등록 구현: TaskService 상태 전환과 동시 스케줄링

핵심은 "상태를 바꾸는 시점에 예약도 같이 바꾼다"입니다.

예를 들어 TaskService.createTask()에서는 다음을 한 트랜잭션 흐름에서 처리합니다.

  1. 과제 생성 + 자동 매칭
  2. TaskDeadlineJob 등록 (TaskDeadlineType.SELECTED)
  3. TaskDeadlineRemindJob 등록 (48h)
  4. TaskTeacherRemindJob 등록 (12h)
  5. dueAt가 있으면 TaskDueAtRemindJob 6개 등록

TaskTopicRecommendService, updateTaskStatus, updateTaskByStudent에서도 상태 전환마다 같은 패턴으로 cancel/schedule을 수행합니다.

Quartz Adapter 구현 포인트 (JobDataMap, Trigger, 재등록)

실제 등록은 Port/Adapter로 분리했습니다.

  • TaskDeadlineQuartzAdapter
  • TaskDeadlineRemindQuartzAdapter
  • TaskTeacherRemindQuartzAdapter
  • TaskDueAtRemindQuartzAdapter

공통 패턴은 동일합니다.

  1. JobDataMaptaskId, 타입(deadlineType, remindType, hoursLeft) 주입
  2. JobKey/TriggerKey 생성
  3. startAt(Date.from(...)) + SimpleScheduleBuilder.simpleSchedule().withRepeatCount(0)
  4. 동일 Job 존재 시 삭제 후 재등록 (최신 시각 보장)

특히 TaskDueAtRemindQuartzAdapter는 과거 시점 알림은 등록하지 않고 스킵합니다.

실제 등록 코드는 아래와 같은 형태입니다.

override fun scheduleTaskDeadlineCheck(taskId: String, deadlineAt: Instant, type: TaskDeadlineType) {
    val jobKey = TaskDeadlineJob.jobKey(taskId, type)

    val jobDataMap = JobDataMap().apply {
        put(TaskDeadlineJob.KEY_TASK_ID, taskId)
        put(TaskDeadlineJob.KEY_DEADLINE_TYPE, type.name)
    }

    val jobDetail = JobBuilder.newJob(TaskDeadlineJob::class.java)
        .withIdentity(jobKey)
        .usingJobData(jobDataMap)
        .storeDurably(false)
        .build()

    val trigger = TriggerBuilder.newTrigger()
        .forJob(jobKey)
        .withIdentity("t-$taskId-${type.name.lowercase().first()}", JOB_GROUP)
        .startAt(Date.from(deadlineAt))
        .withSchedule(SimpleScheduleBuilder.simpleSchedule().withRepeatCount(0))
        .build()

    if (scheduler.checkExists(jobKey)) {
        scheduler.deleteJob(jobKey)
    }
    scheduler.scheduleJob(jobDetail, trigger)
}

TaskDeadlineJob 실행 로직: 상태 검증과 중복 방지

Quartz가 트리거를 쏜다고 바로 발송하지 않습니다.
각 Job은 실행 시점에 현재 Task 상태를 재확인하고, 조건이 맞을 때만 발송합니다.

TaskDeadlineJob

  • deadlineType별 허용 상태가 아니면 스킵
  • IN_PROGRESSoverdueNotifiedAt 존재 시 중복 발송 스킵
  • 조건 충족 시 TaskDeadlineNotificationPort.notifyOverdue(task) 호출

실행 시점 보호 로직은 아래처럼 구현되어 있습니다.

override fun execute(context: JobExecutionContext) {
    val taskId = context.mergedJobDataMap.getString(KEY_TASK_ID) ?: return
    val typeStr = context.mergedJobDataMap.getString(KEY_DEADLINE_TYPE)
    val type = runCatching { TaskDeadlineType.valueOf(typeStr ?: "") }.getOrNull() ?: return

    val task = taskRepository.findById(taskId) ?: return

    when (type) {
        TaskDeadlineType.SELECTED -> {
            if (task.status != TaskStatus.SELECTED) return
            notificationPort.notifyOverdue(task)
        }
        TaskDeadlineType.PENDING_TEACHER_REVIEW -> {
            if (task.status != TaskStatus.PENDING_TEACHER_REVIEW) return
            notificationPort.notifyOverdue(task)
        }
        TaskDeadlineType.IN_PROGRESS -> {
            if (task.status != TaskStatus.IN_PROGRESS) return
            if (task.overdueNotifiedAt != null) return
            notificationPort.notifyOverdue(task)
        }
    }
}

TaskTeacherRemindJob

  • remindType에 맞는 상태인지 검사
  • 매칭 취소/미매칭이면 스킵
  • 조건 충족 시 publishTaskTeacherRemindNoAction(...)

TaskDueAtRemindJob

  • 완료 과제/dueAt 없음/취소 매칭/미매칭이면 스킵
  • 남은 시간(hoursLeft)별 메시지로 publishTaskDueAtRemind(...) 발행

TaskDeadlineRemindJob

  • SELECTED, PENDING_TEACHER_REVIEW, IN_PROGRESS 상태에서만 발송
  • 처리 마감 임박 푸시 publishTaskDeadlineRemind(...) 발행

알림 발행 경로: WebPushNotificationPublisher -> Pub/Sub

Job이 직접 외부 푸시를 호출하지 않고, WebPushNotificationPort를 통해 메시지를 발행합니다.

  • 구현체: WebPushNotificationPublisher
  • 채널: NotificationChannel.WEB_PUSH
  • 토픽: app.notification.topics.web-push
  • sourceService: supporters-service

즉, Quartz는 "언제 실행할지"만 책임지고, 실제 알림 전달은 기존 이벤트 파이프라인(notification-service)로 흘려보냅니다.

실제 발행 코드는 다음처럼 단순합니다.

private fun publish(request: NotificationRequest) {
    val topicName = topicConfig.webPush
    if (topicName.isBlank()) {
        logger.debug("app.notification.topics.web-push 미설정으로 발행 스킵: notificationId={}", request.notificationId)
        return
    }
    val message = objectMapper.writeValueAsString(request)
    pubSubTemplate.publish(topicName, message)
    logger.info("웹 푸시 알림 발행 완료: notificationId={}, purpose={}", request.notificationId, request.purpose)
}

스케줄 취소/재등록 전략 (cancel + schedule)

지원한 시나리오는 생각보다 많았습니다.

  • 과제 완료 시: 관련 예약 Job 일괄 취소
  • 상태 전환 시: 이전 상태용 예약 취소 + 새 상태용 예약 등록
  • 재배정(rescheduleSlaForReassignment) 시: 모든 예약 정리 후 SLA 재계산/재등록
  • 관리자 환불로 매칭 취소 시: SLA/임박 예약 취소
  • 진행 중 활동 기록(recordProgressActivity) 시: 마감 연장 후 재등록

이 패턴이 없으면 "이미 의미 없는 알림"이 나가거나, 같은 과제에 서로 다른 예약이 충돌하기 쉽습니다.

운영 포인트: Quartz JDBC JobStore + Cluster + DataSource 분리

운영에서 가장 크게 도움 된 부분은 Quartz 저장소 분리입니다.

  • primaryDataSource: 비즈니스 데이터(tasks 등)
  • schedulerDataSource (@QuartzDataSource): Quartz 테이블(QRTZ_*)

그리고 Quartz는 JDBC JobStore + 클러스터 모드로 설정했습니다.

  • spring.quartz.job-store-type=jdbc
  • spring.quartz.properties.org.quartz.jobStore.isClustered=true
  • spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO

덕분에 멀티 인스턴스에서도 동일 트리거가 중복 실행되지 않고, 재시작 이후에도 예약 정보가 DB에 남아 복구됩니다.

Before vs After

항목Polling 기반Quartz 예약 기반
실행 정확도polling 간격 의존startAt 기준 1회 실행
구현 복잡도조회/필터/락 로직 필요상태 전환 시 cancel/schedule로 명확
멀티 인스턴스중복 실행 방지 별도 구현 필요Quartz JDBC cluster로 처리
기존 알림 체계 연동별도 파이프라인 필요기존 Pub/Sub 발행 경로 재사용

마무리

이번 작업의 핵심은 "pub/sub냐, quartz냐"를 고르는 게 아니라 역할을 분리한 것이었습니다.

  1. 실행 시점 제어는 Quartz
  2. 알림 전달은 기존 Pub/Sub

정리하면, supporters-service의 예약 알림은 다음 공식으로 동작합니다.

즉시 알림 = 이벤트 발행
예약 알림 = 상태 전환 + Quartz 등록 + 시점 도래 후 이벤트 발행

Polling을 없애면서도 기존 이벤트 기반 아키텍처를 유지할 수 있었고, 실제 운영에서는 이 조합이 가장 안정적이었습니다.

댓글

?