대기열 없이 정확한 시간에 알림 보내기: Spring Quartz TaskDeadlineJob 적용기
대기열 없이 정확한 시간에 알림 보내기: Spring Quartz TaskDeadlineJob 적용기
들어가며
저희 알림 시스템의 기본 축은 pub/sub 기반 이벤트 아키텍처입니다.
supporters-service는 알림을 직접 전송하지 않고, notification-service가 구독하는 토픽으로 메시지를 발행합니다.
즉시 알림은 이 구조로 충분했지만, 예약 알림은 별도 문제가 있었습니다.
- 특정 시각에 정확히 실행되어야 함
- 상태 전환에 따라 기존 예약을 취소하고 다시 등록해야 함
- 멀티 인스턴스 환경에서도 한 번만 실행되어야 함

quartz
아키텍처 요약: Pub/Sub + Quartz 역할 분리
즉시 알림과 예약 알림의 책임을 다음처럼 분리했습니다.
- 즉시 알림: 서비스 로직에서 바로
WebPushNotificationPort발행 - 예약 알림: Quartz Job/Trigger 등록 후 시점 도래 시 발행
예약 알림 흐름은 아래처럼 동작합니다.
다이어그램 로딩 중...
예약 알림 설계: 4종 Quartz Job 분리
supporters-service는 예약 알림을 단일 Job으로 처리하지 않고 목적별로 분리했습니다.
TaskDeadlineJob- SLA 마감 시점 초과 처리 (
SELECTED,PENDING_TEACHER_REVIEW,IN_PROGRESS)
- SLA 마감 시점 초과 처리 (
TaskDeadlineRemindJob- 처리 마감(
deadlineAt) N시간 전 임박 알림 (현재 48h 전)
- 처리 마감(
TaskTeacherRemindJob- 12시간 무액션 리마인드 (
SELECTED,PENDING_TEACHER_REVIEW)
- 12시간 무액션 리마인드 (
TaskDueAtRemindJob- 실제 제출 기한(
dueAt) 기준 48/24/12/8/4/1시간 전 알림
- 실제 제출 기한(
이렇게 나누니 각 Job의 조건이 단순해지고, 취소/재등록도 의도가 명확해졌습니다.
예약 등록 구현: TaskService 상태 전환과 동시 스케줄링
핵심은 "상태를 바꾸는 시점에 예약도 같이 바꾼다"입니다.
예를 들어 TaskService.createTask()에서는 다음을 한 트랜잭션 흐름에서 처리합니다.
- 과제 생성 + 자동 매칭
TaskDeadlineJob등록 (TaskDeadlineType.SELECTED)TaskDeadlineRemindJob등록 (48h)TaskTeacherRemindJob등록 (12h)dueAt가 있으면TaskDueAtRemindJob6개 등록
TaskTopicRecommendService, updateTaskStatus, updateTaskByStudent에서도 상태 전환마다 같은 패턴으로 cancel/schedule을 수행합니다.
Quartz Adapter 구현 포인트 (JobDataMap, Trigger, 재등록)
실제 등록은 Port/Adapter로 분리했습니다.
TaskDeadlineQuartzAdapterTaskDeadlineRemindQuartzAdapterTaskTeacherRemindQuartzAdapterTaskDueAtRemindQuartzAdapter
공통 패턴은 동일합니다.
JobDataMap에taskId, 타입(deadlineType,remindType,hoursLeft) 주입JobKey/TriggerKey생성startAt(Date.from(...))+SimpleScheduleBuilder.simpleSchedule().withRepeatCount(0)- 동일 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_PROGRESS는overdueNotifiedAt존재 시 중복 발송 스킵- 조건 충족 시
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=jdbcspring.quartz.properties.org.quartz.jobStore.isClustered=truespring.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냐"를 고르는 게 아니라 역할을 분리한 것이었습니다.
- 실행 시점 제어는 Quartz
- 알림 전달은 기존 Pub/Sub
정리하면, supporters-service의 예약 알림은 다음 공식으로 동작합니다.
즉시 알림 = 이벤트 발행
예약 알림 = 상태 전환 + Quartz 등록 + 시점 도래 후 이벤트 발행
Polling을 없애면서도 기존 이벤트 기반 아키텍처를 유지할 수 있었고, 실제 운영에서는 이 조합이 가장 안정적이었습니다.