새벽 3시에 발행 버튼은 누가 누르나 — 예약 발행 만들기
"발행 버튼을 미래에 누르는 기능"이 그대로 사양이 됐어요. 남은 문제는 미래에는 누를 손가락이 없다는 것. 예약 발행의 실체는 타이머가 아니라 권한 모델이었습니다.

새벽 3시에 글이 올라가려면, 새벽 3시에 누가 발행 버튼을 눌러야 합니다.
예약 발행을 만들기로 하고 사양을 한 줄로 적었어요. "발행 버튼을 미래에 누르는 기능."
그리고 그 한 줄이 그대로 남은 작업 전부가 됐습니다. 문제는 미래에는 누를 손가락이 없다는 거였어요.
타이머 문제가 아니었어요
처음엔 이게 스케줄러 문제라고 생각했습니다. 시간 되면 실행하는 무언가를 만들면 되는 줄 알았죠.
그런데 실제로 막힌 곳은 다른 데였어요. 발행은 권한이 필요한 명령이거든요.
| 평소 발행 | 예약 발행 |
|---|---|
| 사람이 로그인한 상태 | 아무도 로그인 안 함 |
| 그 사람의 권한으로 실행 | 누구의 권한으로? |
| 실패하면 화면에 보임 | 실패해도 볼 사람이 없음 |
| 한 번 누르면 한 번 실행 | 타이머가 두 번 돌면? |
오른쪽 열이 전부 새로운 문제예요. 그리고 이 중 어느 것도 타이머로는 안 풀립니다.
두 번째 열쇠를 만들지 않기
제일 쉬운 답은 관리자 권한을 가진 시스템 계정을 하나 만드는 거예요. 크론이 그 계정으로 로그인해서 발행하면 되죠.
그런데 그러면 아무 글이나 발행할 수 있는 열쇠가 시스템 어딘가에 상주하게 됩니다. 그 열쇠가 새면 전부가 열려요.
그래서 반대로 갔어요. 크론에게는 딱 하나의 능력만 줬습니다.
composition.publication.schedule.due.publish({ now })이게 크론이 부를 수 있는 전부예요. "지금 시각 기준으로 예약 시간이 지난 것들을 발행해라."
무엇을 발행할지 크론이 못 정합니다. 예약을 건 사람이 이미 정해둔 것만 실행돼요. 크론은 시계일 뿐이지 권한자가 아닙니다.
(권한을 주는 대신, 권한이 필요 없는 모양으로 명령을 만든 거예요.)
그럼 실제로 누가 누른 게 되나
예약을 걸 때 이미 그 사람이 로그인해 있잖아요. 그 시점에 권한 검사가 끝납니다.
그러니까 순서가 이래요.
[예약을 걸 때] 사람 로그인 → 권한 검사 → "이 글을 3시에 발행" 저장
[3시] 크론 → 예약 시간이 지난 것 조회 → 발행 실행
(여기서는 권한 검사가 아니라, 이미 통과한 예약을 집행)발행 버튼을 미래에 누르는 게 아니라, 지금 누른 버튼이 미래에 효력을 갖는 구조예요. 사양 한 줄의 진짜 의미가 이거였습니다.
예약을 어디에 적어 두나 — 컬럼 대신 payload
예약 시각을 저장할 자리를 정해야 했어요. 전용 컬럼을 파는 게 정석처럼 보이는데, 그러지 않았습니다.
| 저장 위치 | 장점 | 안 고른 이유 |
|---|---|---|
| 전용 테이블 | 예약만 따로 관리 | draft와 예약이 갈라져서 동기화 문제가 생김 |
| draft 테이블에 컬럼 추가 | 조회가 빠름 | 콘텐츠 타입마다 다른 예약 필드를 못 담음 |
| draft 리비전 payload의 필드 | 타입마다 자유롭게 정의 가능 | JSON 안이라 인덱스가 덜 효율적 |
세 번째를 골랐어요. 이 플랫폼은 콘텐츠 타입이 늘어나는 구조라, 타입마다 컬럼을 추가하는 방식은 애초에 안 맞았습니다.
그래서 조회하는 쪽이 조금 수고롭습니다. 어떤 필드가 예약 시각인지를 조회할 때 인자로 넘겨요.
SELECT draft.resource_id,
draft.current_revision,
draft.version,
revision.payload->>$3 AS scheduled_at
FROM content_draft_projection draft
JOIN content_revision revision
ON revision.resource_id = draft.resource_id
AND revision.revision = draft.current_revision
WHERE draft.type_id = $2
AND draft.status <> 'archived'
AND (revision.payload->>$3) ~ $4 -- 형식 검증
AND (revision.payload->>$3) <= $1 -- 예약 시각이 지났는가
ORDER BY (revision.payload->>$3) ASC
LIMIT $5여기서 재미있는 건 시각 비교를 문자열로 한다는 거예요. 타임스탬프로 변환해서 비교하지 않습니다.
ISO 8601 문자열은 사전순 정렬이 곧 시간순 정렬이거든요. 형식만 고정돼 있으면 문자열 비교로 충분하고, 그러면 JSON 안의 값을 매번 캐스팅하지 않아도 됩니다. 대신 형식이 고정돼 있다는 걸 보장해야 하는데, 그게 다음 이야기예요.
두 번 실행되면 안 됩니다
크론은 1분마다 돕니다. 그런데 실행이 1분 넘게 걸리면 다음 틱이 겹칠 수 있어요.
triggers: { crons: ["* * * * *"] }, // 1분마다겹치면 같은 글을 두 번 발행하려 하겠죠. 다행히 이 부분은 앞서 만든 구조가 이미 막고 있었어요.
발행은 릴리스를 새로 만들고 활성 포인터를 바꾸는 연산인데, 이 연산에 버전 대조가 붙어 있거든요. 두 번째 시도는 "내가 알던 버전이 아니다"로 걸립니다.
그래서 예약 발행을 위해 중복 방지를 따로 만들지 않았어요. 이미 있는 안전장치가 새 기능도 지켜준 경우입니다.
예약이라는 상태를 어디에 두나
설계에서 은근히 오래 고민한 부분이에요. "3시에 발행"이라는 사실을 어디에 저장할까요.
| 후보 | 문제 |
|---|---|
| 별도 예약 테이블 | 글과 예약이 따로 놀아서, 글을 지웠는데 예약이 남음 |
| 글 상태를 scheduled로 | 발행 전 상태가 두 종류가 되어 모든 쿼리가 복잡해짐 |
| 글에 예약 시각 필드만 | 상태는 그대로 draft, 시각만 추가 ← 선택 |
세 번째를 골랐어요. 예약은 새로운 상태가 아니라 draft에 붙은 부가 정보로 본 겁니다.
그래서 공개 화면 쪽 코드는 한 줄도 안 바뀌었어요. 예약된 글은 여전히 그냥 draft거든요. 발행 전에는 공개 화면이 안 바뀐다는 약속이 그대로 지켜집니다.
(상태를 늘리면 모든 분기가 늘어나요. 늘리지 않고 표현할 방법이 있으면 그쪽이 대개 낫습니다.)
형식을 안 지키면 조용히 사라집니다
예약 시각을 문자열로 비교하기로 한 순간, 형식이 틀린 값은 재앙이 돼요. 비교가 실패하는 게 아니라 엉뚱한 결과를 내거든요.
// "2026-07-29T03:00:00Z" 와 "2026-07-29 03:00" 은
// 문자열로 비교하면 전혀 다른 순서를 갖습니다
// 그래서 정규화된 ISO 형식만 통과시킵니다
function isCanonicalIso(value) {
if (typeof value !== "string") return false;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return false;
// 파싱했다 다시 문자열로 만들었을 때 원본과 같아야 합니다
return new Date(timestamp).toISOString() === value;
}마지막 줄이 핵심이에요. 왕복 비교입니다. `Date.parse`는 관대해서 이상한 형식도 받아주는데, 다시 `toISOString()`으로 만들면 정규 형식이 나오거든요. 원본과 다르면 원본이 정규 형식이 아니었다는 뜻이에요.
그리고 이 검사를 SQL에도, 조회 결과를 읽는 쪽에도, 발행 직전 워커에도 뒀습니다. 세 번 검사하는 게 과해 보이는데, 이건 새벽 3시에 아무도 안 보는 상태에서 도는 코드거든요. 여기서의 관대함은 친절이 아니라 위험입니다.
시간을 다루는 함정 두 개
1. 서버 시각과 사용자 시각
사용자는 "새벽 3시"를 자기 시간대로 생각합니다. 서버는 UTC로 살고요.
그래서 저장은 절대 시각으로 하고, 표시는 사용자 시간대로 합니다. 여기까지는 흔한 이야기인데, 실제로 문제가 된 건 다른 곳이었어요.
개발 환경과 배포 환경의 표시 형식이 달라서 같은 시각이 다르게 보였습니다. 12시간제와 24시간제가 섞였거든요. 결국 표시 형식을 24시간제로 못 박아 통일했어요.
2. 지나간 예약
서버가 잠깐 멈췄다가 살아나면, 이미 시간이 지난 예약들이 한꺼번에 걸립니다.
이걸 어떻게 처리할까요. "너무 늦었으니 건너뛴다"와 "늦었어도 지금 발행한다" 중에서요.
후자를 골랐어요. 예약 발행의 목적은 정확한 타이밍이 아니라 내가 없을 때 대신 눌러주는 것이거든요. 늦게라도 눌리는 게 안 눌리는 것보다 낫습니다.
대신 조회 조건이 "예약 시각 = 지금"이 아니라 "예약 시각 ≤ 지금"이 됩니다. 이 부등호 하나가 복구 동작을 결정해요.
한 번에 몇 개까지
조회에 `LIMIT`과 `ORDER BY`가 붙어 있는 것도 이유가 있어요.
| 설계 | 없으면 생기는 일 |
|---|---|
| LIMIT 100 | 서버가 며칠 멈췄다 살아나면 수백 건이 한 틱에 몰림 |
| 예약 시각 오름차순 | 늦게 예약한 글이 먼저 발행될 수 있음 |
| 1분 주기 | 다음 틱에 나머지가 처리되니 상한이 있어도 결국 다 나감 |
세 번째 줄이 상한을 마음 편히 걸 수 있게 해 준 근거예요. 못 한 건 사라지는 게 아니라 다음 분에 다시 걸립니다. 조회 조건이 "예약 시각 ≤ 지금"이니까요.
그래서 밀린 예약이 100건이면 1분에 100건씩, 몇 분에 걸쳐 조용히 소화됩니다. 한 번에 다 처리하려고 애쓰지 않아요.
환경마다 시계가 다릅니다
작은 문제 하나가 더 있었어요. 개발 환경과 배포 환경의 시계 모양이 다릅니다.
// 로컬 개발 서버
setInterval(() => {
void composition.publication.schedule.due.publish();
}, 30_000);
// Cloudflare Worker
scheduled(controller, env, context) {
context.waitUntil(
handlerInput.composition.publication.schedule.due.publish({
now: new Date(controller.scheduledTime), // ← 플랫폼이 준 시각
}),
);
}차이가 보이시나요. Worker에서는 시각을 인자로 받습니다. 자기가 Date.now()를 부르지 않아요.
이유가 두 개예요. 하나는 테스트 — 시간을 주입할 수 있으면 "3시가 지났을 때"를 테스트에서 만들 수 있습니다. 또 하나는 정확성 — 실행이 지연됐을 때 "예정된 시각"과 "실제 실행 시각" 중 어느 쪽으로 판단할지가 명확해야 하거든요.
그리고 waitUntil이 붙어 있죠. 응답을 돌려준 뒤에도 작업이 끝날 때까지 Worker가 살아 있게 하는 장치예요. 이게 없으면 발행이 중간에 잘립니다.
하나가 실패해도 나머지는 갑니다
실행 루프에서 제일 신경 쓴 게 실패 격리예요.
let published = 0;
let failed = 0;
for (const item of items) {
try {
const didPublish = await worker.publication.publish({ item, now });
if (didPublish) published += 1;
} catch (error) {
failed += 1;
console.error("[scheduled-publication] publish failed", error);
// 던지지 않습니다 — 다음 항목으로 넘어갑니다
}
}
return { published, failed };| 설계 | 한 건이 실패하면 |
|---|---|
| 루프 전체를 하나의 try로 | 뒤에 있던 글들이 통째로 안 나감 |
| 항목마다 try | 그 글만 실패하고 나머지는 발행됨 |
| 실패를 던져서 크론 실패로 | 다음 틱에 전부 재시도 — 성공한 것까지 다시 |
두 번째를 골랐어요. 그리고 실패한 건은 예약 정보가 그대로 남으니까 다음 틱에 다시 시도됩니다. 별도의 재시도 큐를 만들지 않아도 되는 거죠.
`didPublish`가 불리언인 것도 의도예요. 발행 직전에 워커가 조건을 다시 확인하는데, 그 사이 사람이 예약을 취소했거나 이미 발행됐으면 그냥 false를 돌려줍니다. 실패가 아니라 "할 일이 없었음"이니까요.
그래서 집계가 셋으로 갈려요. 발행됨, 실패함, 그리고 아무것도 아님. 세 번째를 실패로 세면 대시보드가 매일 빨갛게 됩니다.
그리고 아무도 안 볼 실패
제일 신경 쓰인 게 이거였어요. 새벽 3시에 발행이 실패하면 누가 알까요?
아무도 모릅니다. 아침에 일어나서 "왜 안 올라갔지"로 알게 되죠.
그래서 대시보드에 예약 건수를 노출했어요. 예약이 걸린 것과 발행된 것을 눈으로 볼 수 있게요.
완벽한 답은 아니에요. 알림까지 가려면 더 만들어야 합니다. 다만 실패가 조용히 묻히지는 않는 수준까지는 왔어요.
(자동화의 진짜 비용은 만드는 게 아니라, 자동화가 실패했을 때 알아채는 걸 만드는 거더라고요.)
안 만든 것들
정직하게 안 한 것도 적어 둘게요. 전부 알면서 안 한 것들이에요.
| 안 만든 것 | 왜 | 대신 |
|---|---|---|
| 발행 실패 알림 | 알림 채널 자체가 아직 없음 | 대시보드에 예약·발행 건수를 노출 |
| 예약 취소 이력 | 누가 언제 예약을 바꿨는지 | 리비전 이력에 남으니 추적은 가능 |
| 반복 예약 | 매주 같은 시각 같은 요구가 없었음 | 필요해지면 그때 |
| 예약 시각 충돌 검사 | 같은 시각에 여러 글이 나가도 문제없음 | — |
첫 줄이 제일 아쉬운 부분이에요. 자동화의 진짜 비용은 만드는 게 아니라, 자동화가 실패했을 때 알아채는 걸 만드는 거더라고요. 지금은 실패가 조용히 묻히지 않는 수준까지만 왔습니다.
그래도 "몰라서 안 한 것"과 "알면서 미룬 것"은 다르니까, 이렇게 적어 두는 것 자체가 다음 작업의 시작점이에요.
돌아보면
예약 발행에서 실제로 만든 코드는 얼마 안 돼요. 크론 등록하고, 조회하고, 발행 부르고.
시간이 든 건 질문들이었습니다. 누구의 권한으로 실행되나, 두 번 돌면 어떻게 되나, 실패를 누가 보나.
"미래에 실행되는 기능"은 대부분 스케줄링 문제가 아니라 권한과 실패 처리 문제예요. 타이머는 제일 쉬운 부분이었습니다.
다음 편은 글끼리 잇는 이야기예요. "지난 글에서 썼듯이"를 쓰다가 URL이 이미 깨져 있는 걸 발견한 날부터 시작합니다.


