아키텍처 DevOps

@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()
        }
    }
}
🔴
문제점 1분마다 미응답 의뢰 전체를 DB에서 조회한다. 의뢰가 많아질수록 쿼리 비용이 선형 증가하고, 이미 처리된 건도 매번 조회에 포함된다.

더 큰 문제는 취소다. "8시간 리마인더는 보냈지만, 60시간 리마인더는 취소해야 한다"는 상태를 DB 컬럼으로 관리해야 한다. noRespFirstReminderSent, noRespSecondReminderSent… Commission 엔티티가 알림 상태 추적 테이블이 되어버린다.

필요한 것: 생성 시점에 스케줄 등록, 응답 시 즉시 취소

Commission이 생성될 때 4개의 리마인더 시점이 이미 확정된다.

T + 8h
첫 번째 리마인더 — 배정 8시간 후
T + 60h
마감 12시간 전 (72h 마감 기준)
T + 64h
마감 8시간 전
T + 71h
마감 1시간 전 — 마지막 알림

선생님이 응답하는 순간 (주제 제안, 티켓 생성, 추가 자료 요청 등) 아직 발화되지 않은 모든 트리거를 삭제한다. 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
💡
initialize-schema: always dev 환경에서는 편리하지만 prod에서는 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로 스케줄 상태를 외부화하고 도메인 모델을 깔끔하게 유지했다.
← 알림톡 비동기 설계 목록으로 →