
Todo를 지정한 시간에 알림을 보내주는 기능을 구현하려고 한다.
이전에 먼저 DB에 등록한 Todo를 서버단에서 Todo 예정 등록 시간에 맞춰 잘 추출해내는 지를 확인한다.
@Component
class ReminderScheduler(
private val todoRepository: TodoRepository
) {
@Scheduled(fixedRate = 60_000)
fun checkTodosForReminder() {
val now = LocalDateTime.now()
val todosToRemind = todoRepository.findTodosToRemind(now)
todosToRemind.forEach { todo ->
println("🔔 [알림] 할 일 '${todo.title}' (유저 ID: ${todo.userId}) 의 시간이 되었습니다.")
}
}
}
override fun findTodosToRemind(now: LocalDateTime): List<Todo> {
val sql = """
SELECT * FROM todos
WHERE scheduled_time <= ? AND is_done = false
ORDER BY scheduled_time
""".trimIndent()
return jdbcTemplate.query(sql, todoRowMapper, Timestamp.valueOf(now))
}
이렇게 가볍게 구현할 수 있다.
1분마다 스케줄러를 돌리면서 DB에 기록되어있는 Todo 중 완료되지 않았고 예정시간보다 적은 것들을 불러온다.
정확한 시각에 Todo 알림을 전송하기 위해 Quartz Scheduler를 사용한 알림 시스템 구현
- 기본 @Scheduled에서 정확 시각 트리거 기반으로의 전환 기록
@Component
class ReminderJobScheduler(
private val scheduler: Scheduler
) {
fun scheduleReminder(todo: Todo) {
println("💡 Quartz Job 예약: ${todo.title} at ${todo.scheduledTime}")
val jobDetail = JobBuilder.newJob(TodoReminderJob::class.java)
.withIdentity(todo.id.toString())
.usingJobData("todoId", todo.id.toString())
.build()
val trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger-${todo.id}")
// 한국 시간대로 변경
.startAt(Date.from(todo.scheduledTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()))
//.startAt(Date.from(todo.scheduledTime.atZone(ZoneId.of("UTC")).toInstant()))
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withMisfireHandlingInstructionFireNow())
.build()
scheduler.scheduleJob(jobDetail, trigger)
}
fun cancelReminder(todoId: UUID) {
scheduler.deleteJob(JobKey.jobKey(todoId.toString()))
}
}
@Component: Spring이 이 Class를 Bean으로 등록Scheduler: Quartz의 핵심 스케줄러, Job 등록/제거/제어 역할 수행QuartzConfig에 설정된 Bean).val jobDetail = JobBuilder.newJob(TodoReminderJob::class.java)
.withIdentity(todo.id.toString())
.usingJobData("todoId", todo.id.toString())
.build()
JobBuilder.newJob: 실행할 Quartz Job 클래스 지정 (TodoReminderJob).withIdentity: Job의 고유 식별자 지정. 중복 방지 목적.usingJobData: Job 내부에서 사용할 데이터 (Map 형태)val trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger-${todo.id}")
.startAt(Date.from(todo.scheduledTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()))
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withMisfireHandlingInstructionFireNow())
.build()
simpleSchedule()은 단발성 Job에 적합scheduler.scheduleJob(jobDetail, trigger)
위에서 만든 JobDetail과 Trigger를 Quartz Scheduler에 등록.
@Component
class TodoReminderJob : Job {
@Autowired
private lateinit var todoRepository: TodoRepository
override fun execute(context: JobExecutionContext) {
println("🚀 [Quartz 실행됨] 알림 Job 트리거됨")
val jobDataMap = context.mergedJobDataMap
val todoId = UUID.fromString(jobDataMap.getString("todoId"))
val todo = todoRepository.findById(todoId)
if (todo != null && !todo.isDone) {
println("🔔 [Quartz 알림] '${todo.title}' (유저 ID: ${todo.userId}) 정확히 ${todo.scheduledTime}에 실행")
} else {
println("⚠️ 알림 대상 Todo가 없음 또는 이미 완료됨")
}
}
}
@Component: Spring이 이 클래스를 Bean으로 등록Job: Quartz에서 반드시 구현해야 하는 인터페이스. execute() 메서드가 실행 시 호출됨@Autowired
private lateinit var todoRepository: TodoRepository
lateinit: 객체 생성 시 초기화하지 않지만, 나중에 주입됨을 보장override fun execute(context: JobExecutionContext) {
val jobDataMap = context.mergedJobDataMap
val todoId = UUID.fromString(jobDataMap.getString("todoId"))
JobDataMap: Job 등록 시 ReminderJobScheduler에서 .usingJobData(...)로 전달한 데이터todoId를 key로 전달했기 때문에, 그걸 꺼내서 UUID로 변환val todo = todoRepository.findById(todoId)
todoId로 DB에서 Todo를 조회if (todo != null && !todo.isDone) {
println("🔔 [Quartz 알림] '${todo.title}' (유저 ID: ${todo.userId}) 정확히 ${todo.scheduledTime}에 실행")
} else {
println("⚠️ 알림 대상 Todo가 없음 또는 이미 완료됨")
}
todo != null: 실제로 Todo가 DB에 존재하는지 확인!todo.isDone: 완료된 할 일은 알림 대상이 아님현재는 콘솔에 출력하는 용도로 학습하고 있지만, FCM이나 Kafka를 통해서 메세지를 발행할 예정
@Configuration
class QuartzConfig(
private val beanFactory: AutowireCapableBeanFactory
) {
@Bean
fun jobFactory(): SpringBeanJobFactory {
return object : SpringBeanJobFactory() {
override fun createJobInstance(bundle: TriggerFiredBundle): Any {
val job = super.createJobInstance(bundle)
beanFactory.autowireBean(job)
return job
}
}
}
@Bean
fun schedulerFactoryBean(jobFactory: SpringBeanJobFactory): SchedulerFactoryBean {
val factory = SchedulerFactoryBean()
factory.setJobFactory(jobFactory)
return factory
}
}
AutowireCapableBeanFactory: Spring 내부 Bean 주입 기능을 수행하는 도구
@Autowired를 강제로 적용할 수 있음@Bean
fun jobFactory(): SpringBeanJobFactory {
return object : SpringBeanJobFactory() {
override fun createJobInstance(bundle: TriggerFiredBundle): Any {
val job = super.createJobInstance(bundle)
beanFactory.autowireBean(job)
return job
}
}
SpringBeanJobFactory: 기본적으로 Quartz Job을 생성할 때 사용하는 팩토리createJobInstance: Job 인스턴스를 만들고 나서 Spring DI를 수동으로 적용beanFactory.autowireBean(job): Job 클래스에 @Autowired된 필드를 채워줌 (todoRepository, 등)@Bean
fun schedulerFactoryBean(jobFactory: SpringBeanJobFactory): SchedulerFactoryBean {
val factory = SchedulerFactoryBean()
factory.setJobFactory(jobFactory)
return factory
}
SchedulerFactoryBean: Spring이 Quartz Scheduler를 관리하게 해주는 BeansetJobFactory(...): 위에서 만든 jobfactory()를 설정하여 job 생성 시 DI 연동@Autowired가 붙은 Job도 정상적으로 생성이 QuartzConfig.kt는 현재 Quartz Job이 @Autowired가 가능하도록 만드는 가장 중요한 설정이다.
없으면 Job 클래스 내에서 todoRepository 같은 빈이 null이 되어 실행되지 않는다.
💡 Quartz Job 예약: Quartz Test at 2025-05-11T20:11:30
🚀 [Quartz 실행됨] 알림 Job 트리거됨
🔔 [Quartz 알림] 'Quartz Test' (유저 ID: da4d9626-7868-4426-866b-e83f96b7832a) 정확히 2025-05-11T20:11:30에 실행