예약 발행을 처음 붙일 때 가장 쉬운 생각은 상태값 하나를 추가하는 것이다. 글 테이블에 scheduled를 넣고 관리자 화면에서 고르면 끝날 것처럼 보인다. 실제로는 그 순간부터 새는 경로가 생긴다. 홈 목록에는 안 보이는데 상세 URL은 열릴 수 있고, 검색에서는 숨겼는데 사이트맵에는 들어갈 수 있다. 댓글 작성 경로가 글을 공개 글로 착각할 수도 있다.

상태 하나로는 부족했다. 예약 발행은 글 저장 기능이 아니라 공개성 판단 기능이다. 공개성은 "이 글이 독자에게 보여도 되는가"라는 질문이고, 그 질문은 모든 읽기 경로에서 같은 답을 내야 한다. 홈, 상세, 카테고리, 태그, 검색, 피드, 사이트맵이 각자 다른 조건을 쓰면 언젠가 하나는 틀린다.

내가 선호하는 모델은 세 가지를 분리한다. 작성 상태, 공개 예정 시각, 공개 조회 조건. 작성 상태는 운영자가 지금 글을 어떻게 다루는지 말한다. 공개 예정 시각은 독자에게 보여도 되는 시간을 말한다. 공개 조회 조건은 이 둘과 삭제 여부를 합쳐 최종 판단을 내린다.

status만 바꾸면 예약이 아니다

draft, scheduled, published 같은 값은 사람에게 친절하다. 관리자 화면에서 한눈에 보인다. 하지만 이 값만으로 공개 여부를 판단하면 예약 시간이 사라진다. scheduled 글이 언제 올라갈지 알 수 없고, published 글에 미래 시간이 들어갔을 때 어떻게 처리할지도 모호해진다.

예약 발행에서 제일 먼저 정해야 할 질문은 "어떤 값이 공개 시각인가"다. 보통 published_at이나 scheduled_at 같은 숫자 필드를 둔다. 둘을 따로 둘 수도 있지만 작은 블로그나 단순 CMS라면 published_at 하나로 충분한 경우가 많다. 이미 공개된 글은 과거 시각, 예약 글은 미래 시각, 초안은 null로 둔다.

이렇게 두면 상태와 시간이 서로 다른 일을 한다. status는 운영 상태를 말하고, published_at은 독자 공개 시각을 말한다. 겹치지 않는다. 겹치지 않아야 테스트도 선명해진다.

draft, scheduled, published 상태와 published_at 공개 시간 게이트를 분리한 다이어그램
직접 생성한 오리지널 다이어그램. 예약 글은 상태와 공개 시각을 모두 통과해야 독자에게 보인다.

상태와 시간을 따로 저장한다

가장 작은 테이블은 아래 네 필드만 있어도 시작할 수 있다.

필드역할주의할 점
status운영 상태draft, scheduled, published처럼 닫힌 값만 허용
published_at독자 공개 시각초안은 null, 예약은 미래 시각, 즉시 공개는 현재 시각
deleted_at소프트 삭제삭제 글은 어떤 상태여도 공개 조회에서 제외
updated_at관리자 편집 시각공개 순서 판단에 쓰지 않는다

여기서 흔한 실수는 updated_at이나 created_at을 공개 기준으로 쓰는 것이다. 편집했다고 발행 시간이 바뀌면 글 순서가 흔들린다. 예약 글을 미리 고쳐도 공개 시간이 앞당겨지면 안 된다. 생성 시각은 글이 만들어진 시간일 뿐, 독자가 봐야 하는 시간이 아니다.

소프트 삭제도 같이 봐야 한다. 예약 발행과 삭제는 서로 무관해 보이지만 공개 조회에서는 한 문장으로 합쳐진다. 삭제된 글은 과거에 공개됐든, 미래에 예약됐든, 지금은 보이면 안 된다. 복구 기능이 있다면 관리자 목록에서는 보여야 하지만 공개 목록에서는 숨겨야 한다. 이 경계를 놓치면 삭제한 글이 사이트맵이나 태그 페이지에 남는다.

공개 조회 조건은 전부 같은 문장이어야 한다

예약 발행의 핵심은 조회 조건이다. 글을 저장하는 코드는 한 곳이어도, 글을 읽는 코드는 여러 곳으로 퍼진다. 홈 최신 글, 상세 페이지, 카테고리 목록, 태그 목록, 검색 결과, RSS, 사이트맵. 하나만 빼먹어도 독자는 예약 전 글을 볼 수 있다.

빨간불과 초록불이 켜진 신호등형 게이트가 문서 아이콘들의 흐름을 통제해 일부는 막고 일부만 통과시키는 모습
공개 조회 조건은 모든 읽기 경로 앞에 세운 신호등 게이트와 같다. 조건을 통과한 글만 독자에게 흘러간다.

그래서 공개 조회 조건은 함수나 상수로 묶어 둔다. 문장으로 쓰면 대략 이렇다. 삭제되지 않았고, 상태가 공개이며 공개 시간이 지금보다 과거이거나, 상태가 예약이고 예약 시간이 지금보다 과거인 글만 공개한다. 미래 예약은 404다. 초안도 404다. 삭제 글도 404다.

상세 페이지도 목록과 같은 기준을 써야 한다. 목록에서 안 보이니 안전하다고 믿으면 안 된다. URL을 아는 사람은 상세 페이지를 직접 열 수 있다. 댓글 작성 경로도 같은 글을 다시 조회한다면 같은 공개성 판단을 써야 한다. 공개 전 예약 글에 댓글이 달리는 것도 이상한 상태다.

홈, 상세, 카테고리, 검색, 태그, 사이트맵이 같은 공개 조회 조건을 공유하는 구조
직접 생성한 오리지널 다이어그램. 공개 조회 조건은 글을 읽는 모든 경로의 공통 게이트가 되어야 한다.

API는 예약 시간을 검증해야 한다

게시 API는 상태값을 느슨하게 받으면 안 된다. 오타가 들어왔는데 기본값으로 공개 처리하면 사고다. schedueld 같은 값은 실패해야 한다. 예약 상태인데 시간이 없으면 실패해야 한다. 시간 문자열이 파싱되지 않아도 실패해야 한다.

입력은 좁게 받는 편이 낫다. 즉시 공개는 published, 초안은 draft, 예약은 scheduled로만 받는다. 예약 시간은 ISO 문자열이나 밀리초 숫자처럼 서버가 확실히 해석할 수 있는 형식만 허용한다. "내일 아침" 같은 자연어는 관리자 UI에서는 편해 보이지만 API 계약으로는 위험하다. 타임존과 해석 기준이 섞인다.

타임존은 저장 전에 숫자로 바꾼다. 사용자가 보는 화면은 KST처럼 지역 시간을 보여도, DB에는 epoch milliseconds 같은 단일 기준을 넣는 편이 검증하기 쉽다. 서버가 비교하는 값은 결국 Date.now()와 저장된 숫자다. 비교 기준이 단순해야 오류도 줄어든다.

테스트는 미래 예약과 도래 예약을 나눠야 한다

예약 발행 테스트는 한 줄로 끝나지 않는다. 최소 세 가지가 필요하다. 미래 예약은 저장되지만 공개 상세가 404여야 한다. 과거 시간이 된 예약은 공개 상세가 200이어야 한다. 사이트맵과 검색은 미래 예약을 빼고 도래 예약만 넣어야 한다.

이 테스트가 없으면 코드가 보기 좋게 합쳐져도 믿기 어렵다. 특히 사이트맵은 자주 놓친다. 사람 눈에는 보이지 않지만 검색 엔진은 본다. 공개 전 글이 사이트맵에 들어가면 예약의 의미가 약해진다. RSS도 비슷하다. 공개성 판단은 화면뿐 아니라 기계가 읽는 경로에도 들어가야 한다.

관리자 편집 테스트도 따로 둔다. 예약 글을 수정해도 공개 시간이 유지되는지, 초안을 공개로 바꿀 때 공개 시간이 비어 있으면 현재 시각이 들어가는지, 삭제한 글을 재발행해도 삭제 상태가 보존되는지 같은 불변식을 잡는다. 예약 기능은 날짜 UI보다 이런 불변식이 더 오래 간다.

실무 체크리스트

예약 발행을 붙일 때 아래 항목을 먼저 확인한다. 작은 블로그라도 이 정도는 필요했다.

확인 항목통과 기준
상태 enumAPI와 DB가 같은 닫힌 값만 허용한다
예약 시간예약 상태에는 공개 시각이 반드시 있다
미래 예약상세 URL, 목록, 검색, 사이트맵에서 모두 숨는다
도래 예약별도 수동 작업 없이 공개 조회에서 보인다
소프트 삭제상태와 시간보다 삭제 여부가 먼저 차단한다
테스트미래 404, 도래 200, 사이트맵 제외·포함을 나눠 검증한다

cron 작업으로 상태를 scheduled에서 published로 바꾸는 방식도 가능하다. 다만 그 방식만 믿으면 cron이 실패한 순간 예약 글이 제때 열리지 않는다. 작은 시스템에서는 공개 조회 조건이 도래 예약을 바로 보여주게 만들고, cron은 나중에 상태 정리용으로 두는 편이 더 단단했다. 조회가 진실을 만들고, cron은 청소한다.

비슷한 백엔드 실패는 버그를 좁혀 고치는 습관에도 정리했다. 예약 발행도 같은 문제다. "상태 하나 추가"라는 넓은 설명을 믿지 말고, 어느 경로에서 어떤 조건으로 공개되는지 좁혀 본다.

다음에 예약 기능을 만들면 날짜 선택 UI부터 만들지 않을 생각이다. 먼저 미래 예약이 404인지 확인한다. 그다음 도래 예약이 200인지 본다. 그 두 테스트가 없으면 버튼은 예뻐도 예약은 아직 없다.