@Scheduled 대신 Quartz — 동적 스케줄과 취소가 필요했다
Commission이 배정된 후 선생님이 아무 반응이 없으면 어떻게 해야 할까? 72시간의 마감이 있고, 그 안에 여러 번 리마인더를 보내야 한다. 그리고 선생님이 응답하면 예정된 알림을 모두 취소해야 한다.
처음엔 @Scheduled로 구현하는 방법을 떠올렸다. 하지만 금방 한계가 보였다.
@Scheduled 방식의 문제
Spring의 @Scheduled는 고정된 주기로 메서드를 반복 실행한다. 리마인더를 구현하려면 이렇게 해야 한다.
@Scheduled(fixedDelay = 60_000) // 매 1분마다 실행
fun checkAndSendNoRespReminder() {
val threshold = LocalDateTime.now().minusHours(8)
val commissions = commissionAdaptor.findUnrespondedBefore(threshold)
for (commission in commissions) {
if (!commission.noRespFirstReminderSent) {
alimtalkSender.send(...)
commission.markFirstReminderSent()
}
}
}
더 큰 문제는 취소다. "8시간 리마인더는 보냈지만, 60시간 리마인더는 취소해야 한다"는 상태를 DB 컬럼으로 관리해야 한다. noRespFirstReminderSent, noRespSecondReminderSent… Commission 엔티티가 알림 상태 추적 테이블이 되어버린다.
필요한 것: 생성 시점에 스케줄 등록, 응답 시 즉시 취소
Commission이 생성될 때 4개의 리마인더 시점이 이미 확정된다.
선생님이 응답하는 순간 (주제 제안, 티켓 생성, 추가 자료 요청 등) 아직 발화되지 않은 모든 트리거를 삭제한다. Quartz는 이것을 Job 삭제 하나로 해결한다.
구현: ReminderScheduler 추상화
Infrastructure 모듈에 Quartz 래퍼를 만들었다. Commission 도메인 로직과 Quartz API 세부 사항을 분리하기 위해서다.
// SClass-Infrastructure
@Component
class ReminderScheduler(private val scheduler: Scheduler) {
// 단일 트리거
fun schedule(
jobKey: String, group: String,
jobClass: Class<out Job>,
triggerAt: LocalDateTime,
jobData: JobDataMap = JobDataMap(),
) { ... }
// 다중 트리거 — 1 Job, N Triggers
fun scheduleMultiple(
jobKey: String, group: String,
jobClass: Class<out Job>,
triggers: List<Pair<LocalDateTime, JobDataMap>>,
) { ... }
// Job 삭제 (연결된 모든 Trigger 포함)
fun cancel(jobKey: String, group: String) {
scheduler.deleteJob(JobKey.jobKey(jobKey, group))
}
}
Commission 도메인의 스케줄러는 이 위에서 Commission에 특화된 로직만 담당한다.
// SClass-Api-Supporters
@Component
class CommissionReminderScheduler(
private val reminderScheduler: ReminderScheduler,
) {
fun scheduleNoRespReminders(commissionId: Long, createdAt: LocalDateTime) {
val triggers = listOf(
createdAt.plusHours(8) to jobData("8시간"),
createdAt.plusHours(60) to jobData("60시간 (마감 12h 전)"),
createdAt.plusHours(64) to jobData("64시간 (마감 8h 전)"),
createdAt.plusHours(71) to jobData("71시간 (마감 1h 전)"),
)
reminderScheduler.scheduleMultiple(
jobKey = "noResp_$commissionId",
group = "COMM_NO_RESP",
jobClass = CommissionNoResponseReminderJob::class.java,
triggers = triggers,
)
}
fun cancelNoRespReminders(commissionId: Long) {
reminderScheduler.cancel("noResp_$commissionId", "COMM_NO_RESP")
}
fun resetInactiveReminder(commissionId: Long) {
// 기존 job 삭제 후 7일 후로 재등록
reminderScheduler.schedule(
jobKey = "inactive_$commissionId",
group = "COMM_INACTIVE",
jobClass = CommissionInactiveReminderJob::class.java,
triggerAt = LocalDateTime.now().plusDays(7),
jobData = JobDataMap(mapOf("commissionId" to commissionId, ...)),
)
}
}
활동 리마인더는 매 활동마다 리셋
비활동 리마인더는 "마지막 활동으로부터 7일"이다. 선생님이 주제를 제안하거나, 학생이 메시지를 보내거나, 티켓이 생성되는 순간마다 resetInactiveReminder()가 호출되어 7일 카운트가 처음부터 다시 시작된다.
Job 클래스: Spring Bean 주입이 되는 Quartz Job
Quartz는 기본적으로 JobFactory가 Job 인스턴스를 new로 생성하기 때문에 @Autowired가 동작하지 않는다. Spring Boot의 QuartzJobBean을 상속하면 Spring ApplicationContext에서 Bean을 주입받을 수 있다.
class CommissionNoResponseReminderJob : QuartzJobBean() {
@Autowired lateinit var commissionAdaptor: CommissionAdaptor
@Autowired lateinit var userAdaptor: UserAdaptor
@Autowired lateinit var commissionNotificationSender: CommissionNotificationSender
override fun executeInternal(context: JobExecutionContext) {
val map = context.mergedJobDataMap
val commissionId = map.getLong("commissionId")
val elapsedTime = map.getString("elapsedTime")
val commission = commissionAdaptor.findById(commissionId)
val teacher = userAdaptor.findById(commission.teacherUserId)
val student = userAdaptor.findById(commission.studentUserId)
val phoneNumber = teacher.phoneNumber ?: return
commissionNotificationSender.sendNoResponseReminder(
phoneNumber = phoneNumber,
teacherName = teacher.name,
studentName = student.name,
elapsedTime = elapsedTime,
commissionId = commissionId.toString(),
)
}
}
JDBC Store: 서버 재시작에도 살아있는 스케줄
In-memory Job Store를 쓰면 서버가 재시작될 때 등록된 스케줄이 모두 사라진다. T+60h 리마인더가 등록돼 있었는데 T+40h에 배포가 나가면 그 알림은 영원히 발화되지 않는다.
JDBC Store는 QRTZ_* 테이블에 Job과 Trigger를 저장한다. 서버가 재시작되어도 등록된 스케줄은 DB에 남아 있고, 재시작 후 Quartz가 다시 읽어 들인다.
# application.yaml
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always # QRTZ_* 테이블 자동 생성
properties:
org.quartz.threadPool.threadCount: 5
never로 설정하고 Flyway/Liquibase로 직접 관리하는 것을 권장한다. 실수로 테이블이 재생성되면 등록된 모든 Job이 날아간다.
@Scheduled vs Quartz 비교
| @Scheduled (polling) | Quartz (push) | |
|---|---|---|
| DB 조회 | 매 분 전체 조회 | 생성 시 1회 등록 |
| 개별 취소 | 상태 컬럼 필요 | scheduler.deleteJob() |
| 정확한 발화 시간 | polling interval 오차 | 등록한 시간 그대로 |
| 서버 재시작 | 상태 컬럼 기반으로 재처리 | JDBC Store 자동 유지 |
| 복잡도 | 낮음 (어노테이션 하나) | 의존성 + 설정 필요 |
단순한 배치성 작업이라면 @Scheduled로 충분하다. 하지만 개별 엔티티마다 다른 시간에 발화하고, 조건에 따라 취소가 필요한 경우는 Quartz가 맞다.
@Scheduled를 선택했다면 Commission 엔티티에 알림 상태 컬럼이 6개 이상 생겼을 것이다. Quartz JDBC Store로 스케줄 상태를 외부화하고 도메인 모델을 깔끔하게 유지했다.