달력에서 한 날짜를 골라도 cron에는 연도가 없다

Night Owl의 예약 화면은 날짜와 시간을 받아 “1회 실행”을 표시한다. 하지만 저장하는 표현은 minute hour day month *이다. YYYY-MM-DD에서 연도를 버리므로, 그 cron만으로는 특정 연도에 한 번 실행한다는 뜻을 표현할 수 없다. 활성 상태만 확인하던 콜백에는 다음 달력 주기의 실행을 막는 별도 조건이 필요했다.

변경은 날짜형 예약의 트리거에서 실행 이력을 확인하고, 실행을 받아들인 뒤 작업을 비활성화했다. 이 정책은 UI가 만든 구체적인 일·월 예약에 대한 반복 방어다. 내부 분류는 일·월이 둘 다 *가 아니면 일회성으로 보므로, 모든 cron 표현을 일반적인 일회성 일정으로 해석하는 규칙은 아니다.

실행 이력이 하나라도 있으면 예약을 건너뛴다

트리거는 먼저 현재 enabled 값을 다시 읽는다. 날짜형 예약에 이전 실행 행이 있으면 비활성화하고 건너뛴다. 이력이 없다면 runner에 넣고 비활성화한다. runner는 시작하거나 메모리 큐에서 기다리기 전에 pending 실행 행을 동기적으로 만든다. 따라서 반복을 막는 기준은 성공한 결과가 아니라 실행을 받아들인 기록의 존재다.

트리거 시점의 조건예약 콜백의 선택
비활성 또는 삭제실행하지 않음
이전 실행 행 존재비활성화 후 건너뜀 · 성공 상태만 조회하지 않음
이력 없음pending 실행 생성 후 비활성화

pending·실패·취소뿐 아니라 수동 실행의 이력도 같은 조건에 들어간다. 미리 수동 실행했다가 실패한 작업이라도 예약 시각에는 건너뛸 수 있다. 반대로 수동 실행 API는 enabled나 일회성 이력을 확인하지 않고 enqueue하므로, 예약을 끈 뒤 수동으로 다시 실행하는 경로는 열려 있다.

반복을 막는 정책과 한 번의 완료를 보장하는 정책은 다르다

이력 조회·실행 행 생성·비활성화는 하나의 원자적 실행권 획득이 아니다. 여러 프로세스가 이력이 없다고 함께 읽을 수 있고, 실행 행을 만든 뒤 비활성화 전에 중단될 수도 있다. 다음 트리거가 이력을 보고 건너뛰더라도, 재시작 때 pending 메모리 큐를 복원하지 않으므로 그 작업이 반드시 완료되는 것은 아니다. 실패한 실행을 자동으로 재시도하는 정책도 포함하지 않는다.

연도 역시 별도 한계다. 미래 연도 예약을 cron에 보존하지 않고, 만료 판단은 서버의 현재 연도로 날짜를 만든다. CronJob은 Asia/Seoul로 실행하지만 만료 계산은 로컬 Date에서 9시간을 빼므로 호스트 시간대의 영향도 남는다. 이 구현을 연도와 시간대에 독립적인 일회성 스케줄러로 확장해 설명할 수는 없다.

기존 cron 테스트는 반복 요일 표현의 저장과 잘못된 요일 거절을 확인한다. 이력 확인부터 비활성화까지의 스케줄러 실행, 중단과 경합을 검증하는 테스트는 없다. Night Owl에서 이 변경의 의미는 예약 트리거·실행 접수·성공 완료를 구분하고, 접수 이력에 따라 다음 예약을 막는 정책을 코드에 명시한 데 있다.