이 블로그는 Eleventy로 빌드해서 Vercel에 올리는 순수 정적 사이트입니다. 요청이 들어올 때 돌아가는 코드가 한 줄도 없습니다. 그런데 글은 미리 써두고 정해진 날에 공개하고 싶었습니다.
동적 사이트에서라면 고민할 것도 없습니다. 요청이 올 때마다 서버가 현재 시각과 발행일을 비교하면 됩니다. 정적 사이트에는 비교할 주체가 없습니다. HTML은 빌드 시점에 전부 만들어져 CDN에 얹히고, 그 뒤로는 아무도 시간을 확인하지 않습니다.
그래서 문제를 두 조각으로 나눴습니다.
1번이 없으면 미래 글이 그대로 공개됩니다. 2번이 없으면 발행일이 지나도 사이트는 어제 그대로입니다. 둘 다 있어야 예약 게시가 됩니다. 하나씩 보겠습니다.
1. 판정을 어디서 할 것인가 — 자리는 넷뿐이다
구현에 들어가기 전에, 예약 게시를 붙일 수 있는 자리가 몇 개인지 먼저 봤습니다. "발행일이 지났는가"를 누가 언제 판단하느냐로 나누면 네 가지밖에 없습니다.
세 번째는 예약 게시가 아닙니다. 미래 글의 본문이 HTML 안에 그대로 실려 나가고 JS 가 감추기만 합니다. curl 한 번이면 보이고, 검색엔진은 JS 를 실행하기 전 원문을 먼저 읽습니다. "보이지 않는다"와 "존재하지 않는다"는 다릅니다. 예약 게시가 지켜야 하는 건 후자입니다.
네 번째는 이 규모에 과합니다. 글이 마흔 편인 사이트에 CMS 를 붙이면 글보다 CMS 운영이 커집니다. 두 번째는 정적 사이트를 정적으로 두고 싶다는 전제와 정면으로 부딪힙니다. 그래서 첫 번째를 골랐습니다 — 대신 "재빌드를 안 밀면 영영 안 나옴"이라는 약점을 통째로 떠안는 선택입니다. 뒤에서 재빌드를 다루는 절이 그 값을 치르는 이야기입니다.
2. 빌드에서 미래 글을 빼기
Eleventy에는 디렉터리 데이터 파일이 있습니다. posts/ 폴더 옆에 posts.11tydata.js를 두면 그 폴더 안 모든 글에 공통 데이터가 적용됩니다. 여기에 eleventyComputed를 쓰면 글마다 계산된 값을 넣을 수 있습니다.
핵심은 permalink를 false로 만들면 파일 자체가 생성되지 않는다는 점입니다.
두 줄이 각각 다른 일을 합니다.
permalink: false— 글 페이지 HTML이 아예 안 만들어집니다. URL을 직접 쳐도 404입니다.eleventyExcludeFromCollections: true— 컬렉션에서 빠집니다. 목록 페이지에도,sitemap.xml에도 안 들어갑니다.
컬렉션에서만 빼면 목록에는 안 보이는데 URL은 살아 있습니다. 파일만 안 만들면 목록과 sitemap에 링크는 남는데 클릭하면 404입니다. 후자가 특히 나쁩니다 — 검색엔진에 죽은 URL을 직접 제출하는 셈이라서요.
네 가지 조합이 각각 무엇을 만드는지 적어두면 판단이 빨라집니다.
가운데 두 줄이 실수로 도달하는 상태입니다. 세 번째 줄은 특히 안심하기 쉽습니다 — 목록에 없으니 공개 안 된 것처럼 보이지만, RSS 리더·소셜 미리보기·이전 배포의 캐시·주소 유추 어느 쪽으로든 열립니다. 목록에서 감추는 것은 접근 제어가 아닙니다.
3. 로컬에서는 미래 글이 보여야 한다
위 코드에서 눈에 띄는 조건이 하나 있습니다.
Eleventy는 실행 모드를 이 환경 변수로 알려줍니다. eleventy는 build, eleventy --serve는 serve입니다. 그래서 npm run dev에서는 미래 글이 그대로 보이고, npm run build에서만 사라집니다.
일부러 이렇게 뒀습니다. 예약 게시의 가장 큰 위험은 발행 당일에 오타를 발견하는 것입니다. 로컬에서까지 글이 숨어버리면 공개되기 전에 확인할 방법이 없습니다.
대신 이 비대칭은 반드시 문서에 적어야 합니다. 적어두지 않으면 "dev에서는 보이는데 build 결과물에는 없다"가 버그로 보입니다. 정상 동작인데도요. 이런 종류의 규칙은 저장소 규칙 문서에 한 줄로 박아두는 편이 낫습니다 — 문서를 어디에 두느냐가 곧 규칙이 되는 이유가 여기에 있습니다. 사람이든 에이전트든, 이걸 모르고 build 산출물을 열어본 쪽은 반드시 버그 리포트를 씁니다.
dev, 판정은 build.
발행 전 원고는 npm run dev 로 렌더를 보고, "오늘 무엇이 공개 상태인가"는 npm run build 산출물로만 판단합니다. 두 명령의 결과가 다른 게 정상이므로, 둘을 섞어서 확인하면 매번 헷갈립니다.
4. 날짜는 UTC로 파싱된다 — 한국시간 아침 9시
YAML front matter의 날짜는 UTC 자정으로 파싱됩니다.
날짜만 쓰면 한국 독자 기준으로 그날 아침 9시에 공개됩니다. 우연히도 나쁘지 않은 시각이라 굳이 시:분을 붙이지 않고 그대로 씁니다. 하지만 이걸 모르면 "8월 22일로 걸었는데 자정에 안 올라왔다"고 한참 헤매게 됩니다.
같은 한 줄이 지역마다 다른 시각이 됩니다. date: 2026-08-22 하나가 만드는 실제 공개 시각입니다.
UTC 서쪽 독자에게는 전날 저녁에 공개됩니다. 날짜를 "이 글이 뜨는 날"로 읽고 있었다면 하루가 어긋납니다. 대부분의 블로그에서는 상관없지만, 특정 날짜에 맞춘 글(이벤트 공지, 연말 회고)이라면 그때는 시각을 명시하는 편이 낫습니다.
오프셋을 붙이면 파싱은 정확해집니다. 하지만 파싱이 정확해져도 발행 시각이 정확해지지는 않습니다 — 실제로 언제 나오느냐는 다음 절의 재빌드 주기가 정합니다. 한국 자정에 맞춰 오프셋을 붙여도, 재빌드가 09:05 에 돌면 글은 09:05 에 나옵니다.
5. 진짜 문제 — 정적 사이트에는 아무 일도 일어나지 않는다
여기까지가 흔히 소개되는 방법입니다. 그리고 여기서 멈추면 예약 게시는 동작하지 않습니다.
빌드는 무언가를 푸시할 때 돕니다. 8월 22일이 되었다는 사실은 저장소에 아무 커밋도 만들지 않습니다. 커밋이 없으면 빌드도 없고, 빌드가 없으면 CDN에 얹힌 HTML은 마지막 배포 그대로입니다. 글은 영원히 안 나옵니다.
그래서 시간이 흐른다는 사실 자체를 누군가 알려줘야 합니다. GitHub Actions 크론이 매일 Vercel 배포 훅을 때리는 방식으로 해결했습니다.
배포 훅은 Vercel 프로젝트 설정에서 만드는 URL입니다. POST 한 번이면 지정한 브랜치로 배포가 돕니다. 크론 시각을 00:05 UTC로 둔 건 앞서의 UTC 자정 파싱과 맞추기 위해서입니다 — 자정에 발행 조건이 참이 되고, 5분 뒤 빌드가 그 사실을 반영합니다.
스케줄은 최선 노력 기반이라 러너가 붐비면 수십 분씩 밀립니다. 예약 게시에는 상관없습니다 — 몇 분 늦게 올라오는 건 아무도 모릅니다. 하지만 분 단위 정확도가 필요한 일에 이 크론을 쓰면 안 됩니다.
실제 발행 시각은 구간이지 시점이 아니다
"9시에 나온다"고 말하지만 실제로는 여러 지연이 쌓인 결과입니다. 각각을 따로 세어보면 이렇습니다.
그래서 "몇 시 몇 분에 나온다"는 약속은 이 구조로 지킬 수 없습니다. 지킬 수 있는 약속은 "그날 오전 중에 나온다" 정도입니다. 분 단위가 필요하면 재빌드를 미는 쪽을 크론이 아닌 다른 것으로 바꿔야 합니다 — 그건 이미 정적 사이트의 단순함을 포기하는 선택입니다.
재빌드가 실패하면 아무 소리도 안 난다
이 구조에서 제일 조용한 실패가 여기입니다. 배포 훅 URL 이 만료됐거나, 시크릿이 비었거나, Vercel 쪽 빌드가 깨졌을 때 사이트는 어제 그대로 잘 돌아갑니다. 500 도 안 나고 404 도 안 납니다. 그냥 새 글이 안 나올 뿐입니다. 그리고 새 글이 안 나온 걸 알아채는 사람은 글을 쓴 사람뿐입니다.
그래서 curl 에 -f 를 붙였습니다. 이 플래그가 없으면 훅이 404 를 돌려줘도 curl 은 성공으로 끝나고 워크플로는 초록색이 됩니다. 초록색 크론이 매일 아무 일도 안 하는 상태가 최악입니다 — 감시하고 있다는 착각까지 같이 주기 때문입니다.
여기에 더해, 발행 예정일이 있는 주에는 사이트에서 직접 확인하는 편이 안전합니다. 워크플로 성공은 "훅을 눌렀다"까지만 보장하고, 빌드가 실제로 끝났는지는 배포 쪽 기록에 있습니다.
6. 밟고 나서 알게 된 함정들
로케일이 둘이면 날짜가 어긋난다
이 사이트는 모든 글이 한국어·영어 한 쌍입니다. 그런데 발행 판정은 파일별 date로 하니, 두 파일의 날짜가 다르면 한쪽만 먼저 공개됩니다.
그러면 공개된 쪽의 hreflang은 아직 존재하지 않는 URL을 가리킵니다. canonical과 hreflang이 조용히 깨지는데, 브라우저로 보면 멀쩡해 보입니다. 검색엔진만 이상하게 봅니다.
그래서 두 파일의 date가 다르면 빌드를 실패시키는 검사를 넣었습니다. 사람이 매번 기억하는 대신 빌드가 막게 하는 편이 훨씬 안전합니다.
글끼리 거는 링크가 미래를 향할 수 있다
이게 더 미묘합니다. 8월 22일 글에서 8월 30일 글로 링크를 걸면, 22일부터 30일까지 그 링크는 404입니다. 링크를 걸 때는 대상 글 파일이 저장소에 멀쩡히 있으니 아무 문제 없어 보입니다. 8일 뒤에 스스로 나으니 흔적도 잘 안 남습니다.
규칙은 단순합니다. 글끼리 거는 링크는 항상 과거 방향이어야 합니다. 이것도 빌드에서 검사합니다 — 본문의 글 링크를 전부 긁어서 대상이 존재하는지, 그리고 대상의 날짜가 나보다 이르거나 같은지 확인합니다. 날짜가 YYYY-MM-DD 문자열이라 사전순 비교가 곧 날짜 비교입니다.
발행일을 미래로 되돌리면 글이 내려간다
이미 공개된 글의 date를 미래로 바꾸면, 다음 빌드에서 그 글은 사이트에서 사라집니다. 오탈자를 고치면서 날짜를 같이 손대면 URL이 통째로 죽습니다. 발행일 수정은 항상 조심해야 합니다.
고쳐 쓴 날짜를 남기고 싶으면 date 가 아니라 sitemapLastmod 를 씁니다. 발행일은 그대로 두고 sitemap 의 lastmod 만 갱신되므로 글이 내려가지 않으면서 "이 페이지가 바뀌었다"는 신호만 나갑니다. 이 글도 그렇게 갱신했습니다.
미리보기 배포는 필터를 그대로 적용한다
PR 미리보기 배포는 eleventy 를 build 모드로 돌립니다. 그래서 미리보기에서도 미래 글은 빠집니다. "PR 에서 확인하고 머지하자"는 흐름이 예약 글에는 통하지 않습니다. 발행 전 원고 확인은 로컬 dev 로만 됩니다.
7. 글이 안 나올 때 — 진단 순서
"오늘 나왔어야 하는데 안 나왔다"의 원인은 매번 같은 자리들입니다. 위에서부터 순서대로 봅니다.
실제로 겪은 것은 1번과 2번이 대부분이었습니다. 1번은 시크릿이 비어 있던 경우, 2번은 UTC 를 잊고 한국 날짜로 계산한 경우입니다. 한국시간 오전 8시에 "왜 안 나오지"를 시작하면 한 시간을 기다려야 합니다.
8. 자주 묻는 것
하루 한 번 재빌드면 충분한가요?
글 단위 예약이라면 충분합니다. 발행 정밀도가 곧 크론 주기이므로, 하루 한 번이면 최대 하루 늦게 나옵니다 — 그런데 발행일이 날짜 단위라 실제로는 그날 안에 나옵니다. 시각 단위로 예약하고 싶다면 크론을 시간마다 돌리면 되지만, 그만큼 내용이 하나도 안 바뀐 배포가 매일 스물세 번 쌓입니다. 저희는 하루 한 번으로 뒀습니다.
재빌드가 매일 도는데 배포 기록이 지저분해지지 않나요?
지저분해집니다. 소스가 그대로여도 배포는 새로 생깁니다. 대신 "오늘 빌드가 돌았는가"를 배포 목록에서 바로 볼 수 있다는 이점이 생겼습니다 — 앞의 진단 순서에서 1번이 그 기록에 기대고 있습니다. 조용한 실패를 잡는 값으로는 싸다고 봅니다.
미래 글이 저장소에는 있는데, 그건 공개된 것 아닌가요?
공개 저장소라면 그렇습니다. 이 구조가 막는 것은 사이트에 게시되는 것이지 소스가 보이는 것이 아닙니다. 초안이 남에게 보이면 곤란한 종류의 글이라면 저장소를 비공개로 두거나, 발행 직전에 커밋하는 편이 맞습니다. 블로그 글에는 과한 걱정이지만, 공지·발표 자료라면 실제 문제입니다.
RSS 나 소셜 미리보기로 새어 나가지는 않나요?
새지 않습니다. 미래 글은 컬렉션에서 빠지므로 RSS·목록·sitemap 어디에도 안 들어가고, 페이지 파일 자체가 없으니 크롤러가 가져갈 HTML 도 없습니다. 새는 경우는 앞의 조합표에서 permalink 만 남겨둔 상태 하나뿐입니다.
Next.js 나 Astro 에서도 같은 방법이 되나요?
원리는 같습니다. "빌드 시각과 발행일을 비교해 미래 글을 라우트 목록에서 뺀다"를 각 프레임워크의 정적 경로 생성 지점에 넣으면 됩니다. 프레임워크마다 다른 건 어디에 넣느냐뿐이고, 재빌드를 외부에서 밀어야 한다는 점은 완전히 동일합니다. 서버 렌더나 ISR 을 쓴다면 애초에 이 글의 문제가 없어집니다 — 요청 시점에 판단하면 되니까요.
발행일이 지난 뒤에 글을 고치면 어떻게 되나요?
그냥 반영됩니다. 발행 판정은 date 만 보고, 이미 과거인 날짜는 계속 과거입니다. 주의할 것은 하나뿐입니다 — 수정하면서 date 를 건드리지 않는 것. 고친 날짜를 남기고 싶으면 sitemapLastmod 를 씁니다.
9. 정리
전부 합쳐서 애플리케이션 코드 40줄 남짓입니다. 정적 사이트에 데이터베이스나 서버리스 함수를 끌어들이지 않고도 예약 게시가 돕니다.
구조를 다시 요약하면 이렇습니다.
- 빌드 시각을 유일한 판정 기준으로 삼는다. 요청 시각이라는 개념이 없으므로 다른 기준은 없습니다.
- 미래 글은 파일과 컬렉션 양쪽에서 뺀다. 한쪽만 빼면 404 링크나 살아 있는 URL이 남습니다.
- 재빌드를 외부에서 밀어준다. 이게 빠지면 나머지가 전부 맞아도 글은 안 나옵니다.
- 로컬에서는 미래 글이 보이게 둔다. 발행 전에 확인할 방법이 필요합니다.
- 재빌드가 실패하면 소리가 나게 한다. 이 구조의 실패는 전부 조용합니다 — 사이트는 어제 그대로 잘 돌아갑니다.
정적 사이트로 블로그를 운영하면서 "쓸 때 몰아 쓰고 나눠서 내보내고 싶다"는 생각이 들었다면, 생각보다 훨씬 적은 코드로 됩니다. 대신 재빌드를 밀어주는 쪽을 잊지 마세요. 처음 만들면 거의 반드시 그걸 빠뜨립니다.