Quartz 기반 정시 알림 시스템 설계 및 구현 가이드

임동혁 Ldhbenecia·2025년 5월 11일

SpringBoot

목록 보기
17/28
post-thumbnail

개요

Todo를 지정한 시간에 알림을 보내주는 기능을 구현하려고 한다.
이전에 먼저 DB에 등록한 Todo를 서버단에서 Todo 예정 등록 시간에 맞춰 잘 추출해내는 지를 확인한다.

Basic Scheduler 방식

@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 중 완료되지 않았고 예정시간보다 적은 것들을 불러온다.

문제점

  1. 과거의 완료 체크를 안한 Todo들도 전부 날아온다.
    → is_remind와 같은 컬럼을 추가해서 1번씩만 알림을 보내는 식으로 처리할 수 있다.
  2. 서버가 실행된 시간으로부터 1분씩 스케줄러가 동작하는 것이기 때문에 15:00에 맞춰놓은 알림이 최대 15:59에 올 수도 있다.

Quartz 기반 정확 시각 알림 시스템

정확한 시각에 Todo 알림을 전송하기 위해 Quartz Scheduler를 사용한 알림 시스템 구현

  • 기본 @Scheduled에서 정확 시각 트리거 기반으로의 전환 기록

ReminderJobScheduler

@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 등록/제거/제어 역할 수행
  • 생성자 주입 방식으로 Spring이 이 Scheduler를 자동 주입함 (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()
  • TriggerBuilder.newTrigger: 이 Job이 언제 실행될 지 트리거 지정
  • .withIdentity: Trigger의 고유 식별자 지정. 중복 방지 목적
  • .startAt: Job이 언제 시작될 지 (한국으로 설정)
  • .withSchedule: 실행 주기 설정, simpleSchedule()은 단발성 Job에 적합
scheduler.scheduleJob(jobDetail, trigger)

위에서 만든 JobDetail과 Trigger를 Quartz Scheduler에 등록.

TodoReminderJob

@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() 메서드가 실행 시 호출됨
  • 이 클래스는 할 일(Todo)의 알림을 실행하는 역할을 수행
@Autowired
private lateinit var todoRepository: TodoRepository
  • DI를 통해 TodoRepository를 불러옴
  • lateinit: 객체 생성 시 초기화하지 않지만, 나중에 주입됨을 보장
override fun execute(context: JobExecutionContext) {
  • Quartz가 지정된 시간에 Job을 실행할 때 호출되는 메서드
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를 통해서 메세지를 발행할 예정

QuartzConfig

@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 주입 기능을 수행하는 도구

  • 이걸 통해 Quartz가 만든 Job 인스턴스에도 @Autowired를 강제로 적용할 수 있음

SpringBeanJobFactory 재정의

@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, 등)

SchedulerFactoryBean 등록

@Bean
fun schedulerFactoryBean(jobFactory: SpringBeanJobFactory): SchedulerFactoryBean {
    val factory = SchedulerFactoryBean()
    factory.setJobFactory(jobFactory)
    return factory
}
  • SchedulerFactoryBean: Spring이 Quartz Scheduler를 관리하게 해주는 Bean
  • setJobFactory(...): 위에서 만든 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에 실행

0개의 댓글