예약 발행을 처음 붙일 때 가장 쉬운 생각은 상태값 하나를 추가하는 것이다. 글 테이블에 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인지 본다. 그 두 테스트가 없으면 버튼은 예뻐도 예약은 아직 없다.

공개 누수를 재현하는 순서

구현을 확인할 때는 서로 다른 글을 여러 개 만드는 것보다, 식별하기 쉬운 제목과 URL을 가진 글 하나의 상태와 시간만 바꿔 가며 보는 편이 낫다. 같은 레코드를 쓰면 결과가 달라진 이유가 콘텐츠 차이인지 공개 조건 차이인지 섞이지 않는다. 확인하는 동안에는 관리자 화면에 표시된 지역 시각과 DB에 저장된 숫자를 함께 기록한다. 화면에는 KST가 보이고 서버는 epoch milliseconds를 비교한다면, 두 값이 같은 순간을 가리키는지부터 맞아야 그다음 404와 200을 믿을 수 있다.

  1. 초안 기준점을 만든다. statusdraft, published_atnull로 저장한다. 관리자 화면에서는 보여도 홈·상세·카테고리·태그·검색·RSS·사이트맵에서는 없어야 한다.
  2. 같은 글을 미래로 예약한다. 상태를 scheduled로 바꾸고 현재보다 뒤의 공개 시각을 넣는다. 목록에서 제목을 검색하는 것만으로 끝내지 않고 상세 URL을 직접 요청해 404인지 확인한다. 사이트맵과 RSS 원문에서도 제목이나 URL이 나오지 않아야 한다.
  3. 시간만 과거로 옮긴다. 상태는 그대로 두고 published_at만 현재보다 앞선 값으로 바꾼다. 이제 상세는 200이고 공개 목록과 기계가 읽는 경로에도 같은 글이 나타나야 한다. 이 단계가 통과하면 cron 없이도 조회 조건이 도래 예약을 공개한다는 사실을 확인할 수 있다.
  4. 삭제 게이트를 겹쳐 본다. 공개된 같은 글에 deleted_at을 넣는다. 상태와 공개 시각은 통과하더라도 모든 공개 경로에서 다시 사라져야 한다. 관리자 복구 목록까지 숨는지를 보는 테스트가 아니라, 독자 경로가 삭제를 최우선으로 차단하는지를 보는 테스트다.

경로마다 아래 표를 한 줄씩 채우면 “홈에서는 됐으니 끝”이라는 착각을 피할 수 있다. 응답 코드가 없는 RSS나 사이트맵은 본문에 URL이 포함됐는지로 판정하고, 화면 목록은 제목이 실제 렌더 결과에 있는지로 판정한다.

조회 경로미래 예약도래 예약삭제 뒤
상세 URL404200404
홈·카테고리·태그제외포함제외
검색 결과제외포함제외
RSS·사이트맵URL 제외URL 포함URL 제외
댓글 대상 글 조회작성 불가작성 가능작성 불가

결과가 어긋났을 때는 전체 예약 기능을 다시 고치기보다, 실패한 경로가 어떤 공개 조건을 빠뜨렸는지부터 좁힌다. 같은 글과 같은 시각을 썼기 때문에 아래처럼 관찰 결과를 조회 지점에 바로 연결할 수 있다.

관찰한 실패먼저 확인할 곳빠졌을 가능성이 큰 조건
미래 예약이 목록에서는 없지만 상세는 200상세 페이지의 글 조회상세 경로가 공통 공개 조건을 쓰지 않음
미래 예약 URL이 사이트맵에만 나옴사이트맵 생성 조회사람이 보는 목록과 별도 조건을 사용함
삭제 글이 태그 페이지에 남음태그 목록의 글 조회deleted_at 차단이 빠짐
도래 예약이 모든 경로에서 계속 숨음공통 공개 조건과 cron 의존 여부시간보다 scheduled 상태 이름만 보고 차단함
초안이 검색 결과에 나타남검색 인덱스 또는 검색 조회독자 공개 상태 확인이 빠짐

이 진단은 실제 응답과 저장값을 같이 남길 때만 쓸 수 있다. “안 보였다”는 메모만으로는 404였는지, 빈 목록이었는지, 사이트맵에서 URL만 빠졌는지 구분할 수 없다. 각 행에 입력한 status, published_at, deleted_at, 확인한 경로, 기대 결과, 실제 결과를 기록하면 다음 수정 뒤에도 같은 조건으로 재검사할 수 있다.

서로 모순되는 입력도 정상 경로와 분리해 확인한다. scheduled인데 시간이 없거나 파싱할 수 없는 값이면 저장 단계에서 실패해야 한다. published인데 시간이 미래라면 상태 이름만 믿고 미리 열어 주지 말아야 한다. 반대로 scheduled이지만 시간이 이미 지났다면 공개 조회는 보여 주고, 상태 이름을 published로 정리하는 일만 cron에 남긴다. 이 구분을 해두면 cron 장애는 관리자에게 보이는 상태 정리가 늦어지는 문제로 좁아지고, 독자 공개 시각 자체를 놓치는 사고로 번지지 않는다.

이 방법에도 경계는 있다. 여기서 확인하는 것은 글 한 건의 공개성이지, 관리자 화면의 날짜 입력 경험이나 대량 조회 성능이 아니다. 또 같은 공개 조건을 함수나 상수로 묶었다고 해서 모든 경로가 자동으로 그 조건을 쓰는 것도 아니다. 새 검색 엔드포인트나 새 피드를 추가할 때마다 위 표에 행을 추가하고, 미래·도래·삭제 세 상태를 다시 통과시켜야 한다. 재사용 가능한 조건과 실제 호출 경로 검사는 둘 다 필요하다.

자주 묻는 질문

예약 시간이 지났다면 cron이 상태를 바꿔야만 글이 공개되나요?

아니다. 공개 조회 조건이 도래한 예약을 직접 포함하면 별도 수동 작업 없이 보인다. cronscheduledpublished로 정리하는 보조 수단으로 두고, 실패해도 공개 여부가 흔들리지 않게 한다.

목록에서 숨긴 미래 예약 글은 상세 URL로 열어도 괜찮나요?

안 된다. 홈 목록만 거르는 것으로는 부족하다. 미래 예약은 상세 URL에서도 404여야 하고, 카테고리·태그·검색·RSS·사이트맵과 댓글 작성 경로도 모두 같은 공개성 판단을 써야 한다.

초안·예약·즉시 공개를 published_at 하나로 구분할 수 있나요?

작은 블로그나 단순 CMS라면 가능하다. 초안은 published_atnull, 예약 글은 미래 시각, 즉시 공개 글은 현재 시각으로 두고, status는 별도의 운영 상태로 관리한다.

사용자 지역 시간과 서버의 공개 시각 비교는 어떻게 맞추나요?

화면에는 KST 같은 지역 시간을 보여도 저장 전에는 epoch milliseconds 같은 단일 숫자 기준으로 바꾼다. 서버는 저장된 값과 Date.now()를 비교해 타임존 해석이 섞이지 않게 한다.