왜 필요했나
PorkLog는 글을 쓰는 순간 바로 전체공개다. createPost가 끝나면 홈 목록, RSS, 사이트맵에 즉시 반영된다. 처음엔 문제가 안 됐는데, 글을 여러 세션에 걸쳐 다듬는 습관이 생기면서 걸리기 시작했다. 초안 수준의 글이 검색엔진에 잡히거나, 새벽에 급하게 쓴 글이 그대로 RSS 구독자에게 뿌려지는 게 부담스러웠다. 포트폴리오용 블로그라 "아직 완성 안 된 글"과 "완성됐지만 굳이 남들한테 보여줄 필요는 없는 글"을 구분할 방법이 아예 없었던 셈이다.
posts 테이블에는 발행 여부를 나타내는 컬럼 자체가 없었다. createdAt이 곧 "작성 시각"이자 사실상 "공개 시각"이었고, 관리자 세션(isAdmin) 체크는 수정·삭제 버튼을 보여줄지 결정하는 데만 쓰이고 있었다. 이번에 예약발행(미래 시각 지정)과 완전비공개(관리자 외 누구도 못 봄) 두 가지를 붙였다.
설계: 완전비공개냐 링크공개냐
비공개를 어떻게 정의할지부터 갈렸다. 하나는 unlisted 방식으로, 목록·검색·피드·사이트맵에서만 빼고 글 URL을 직접 아는 사람에겐 계속 열어두는 것. 다른 하나는 완전비공개로, 관리자가 아니면 URL을 알아도 404를 띄우는 것.
티스토리·네이버 블로그의 "비공개"가 후자(작성자만 열람 가능)에 해당해서 그 관례를 따르기로 했다. 이 선택은 파급 범위가 꽤 컸다. unlisted였다면 목록 쿼리에만 조건을 걸면 끝났겠지만, 완전비공개로 가면서 posts/[slug]/page.tsx의 상세 페이지에도 접근 가드를 새로 넣어야 했다.
관리자 화면에서 비공개·예약 글을 어떻게 보여줄지도 결정할 게 있었다. 별도 관리 페이지를 만드는 안도 있었지만, 홈 화면이 이미 목록이자 관리 화면 역할을 겸하고 있어서 기존 카드에 작은 배지(비공개, 예약 8/15 09:00)만 얹는 쪽을 택했다. 변경 범위를 post-card.tsx 한 파일로 묶을 수 있었다.
구현: 세 단계
-
스키마.
posts에is_private(boolean, default false)와published_at(timestamp, default now)을 추가했다.created_at은 건드리지 않고 그대로 "작성 시각"으로 남겨뒀다. 대신 이건 트레이드오프가 하나 생긴다 — 예약글이 실제로 공개되는 순간에도 목록 정렬은 여전히created_at기준이라, 방금 공개된 글이 맨 위로 튀어 오르지 않는다. 정렬 기준을published_at으로 바꾸려면 인접 글 탐색(getNeighbors), 시리즈 순서, 피드pubDate까지 다 손대야 해서 이번 범위에선 그대로 뒀다. -
가시성 게이트. 공개 판정식은
!isPrivate && publishedAt <= now()하나로 통일해서 홈 목록, 상세 페이지,sitemap.ts,feed.xml네 곳에 동일하게 걸었다.
// src/app/page.tsx
const conditions = [
// ...검색·태그·카테고리 조건
isAdmin ? undefined : eq(posts.isPrivate, false),
isAdmin ? undefined : lte(posts.publishedAt, new Date()),
].filter((c): c is SQL => c !== undefined);상세 페이지는 원래 getPost 직후 조회수부터 올리고 그다음에 세션을 확인하는 순서였다. 이 순서를 그대로 두면 비공개 글도 URL만 두드리면 조회수가 올라가버린다. 가드를 조회수 증가보다 앞으로 옮기고, generateMetadata에도 같은 조건을 넣어서 비공개 글의 title/description이 메타태그로 새는 것도 막았다.
- 폼 UI.
datetime-local인풋(발행일시, 기본값은 현재 시각)과 체크박스(비공개)를 추가했다. zod 스키마 쪽은isPrivate/publishedAt을.default()로 처리했는데, 이건 단순히 편의 때문이 아니라 두 개의 기존 경로를 안 건드리려는 목적이었다. 주간 Tech Digest 크론(createDigestPost)은 이 두 필드 없이postInputSchema.parse()를 호출하고 있었고, 기존 테스트 12개도 새 필드 없이 객체를 만들고 있었다. 필드를 필수로 만들면 둘 다 깨진다.
트러블슈팅: 테스트 DB가 스키마 드리프트 상태였다
가장 오래 걸린 부분. 코드는 다 끝났는데 pnpm test가 column "is_private" of relation "posts" does not exist로 전부 실패했다. 원인은 뻔했다 — 테스트 브랜치 DB에 drizzle-kit push를 아직 안 돌렸으니까. 그런데 여기서 한 번 꼬였다.
drizzle.config.ts가 dotenv로 .env.local을 무조건 로드하게 짜여 있어서, DATABASE_URL=$(테스트용 URL) pnpm db:push처럼 셸에서 override를 시도해도 drizzle-kit push는 "Pulling schema from database..." 스피너에서 멈춰버렸다. 스피너가 실제 에러 메시지를 화면에서 지워버려서 원인 파악이 한 번 더 늦어졌다 — > log.txt 2>&1로 리다이렉트해서 다시 돌려봐도 스피너 프레임만 남고 스택트레이스가 안 잡혔다.
결국 원인은 연결 문자열 자체였다. 프로젝트에 이미 파둔 테스트 브랜치가 프로덕션과 스키마가 벌어진 채로 방치돼 있었던 것으로 보이고, Neon 콘솔에서 그 브랜치를 "Reset from parent"로 프로덕션과 동기화하고 나서야 drizzle-kit push가 정상적으로 스키마를 읽어왔다. 그다음 pnpm test는 24개 전부 통과했다.
엣지케이스: 이미 공개된 글에 예약발행을 걸면
배포하고 나서 "기존 글을 수정하면서 발행일시를 미래로 바꾸면 무슨 일이 일어나느냐"는 질문을 받고서야 문제를 알아챘다. updatePost는 같은 row를 그대로 UPDATE하는 구조라, 이미 공개돼 있던 글을 수정하면서 발행일시를 미래로 찍으면 저장 즉시 공개 판정식(!isPrivate && publishedAt <= now())을 다시 못 통과한다. 즉 "예전 내용을 보여주다가 미래 시점에 새 내용으로 교체"되는 게 아니라, 저장하는 순간 글이 그냥 사라졌다가 설정한 미래 시각에 새 내용으로 재등장하는 것이었다. 이미 독자들이 본 적 있는 글이 수정 한 번으로 없어지는 셈이라 위험한 부작용이었다.
신규 글 예약발행에는 자연스러운 동작이지만, 이미 발행된 글에는 이 기능이 아예 동작하지 않아야 한다고 판단했다. updatePost에서 수정 전 publishedAt을 먼저 조회해서, 그 값이 이미 now()를 지났으면(=한 번이라도 공개된 적 있으면) 폼에서 넘어온 publishedAt을 무시하고 기존 값을 그대로 유지하게 했다.
// src/lib/post-actions.ts
const alreadyPublished = !!current && current.publishedAt <= new Date();
const effectivePublishedAt = alreadyPublished ? current.publishedAt : publishedAt;isPrivate은 이 잠금과 무관하게 계속 자유롭게 토글할 수 있다 — "이미 공개된 글을 지금 당장 내리고 싶다"는 요구는 여전히 유효하고, 그건 비공개 체크박스의 역할이지 발행일시의 역할이 아니라고 봤다.
서버가 어차피 무시하더라도 폼에서 바뀌지도 않을 값을 바꿀 수 있는 것처럼 보이면 헷갈리니, post-form.tsx에서도 수정 대상 글이 이미 발행된 상태면 발행일시 입력을 비활성화하고 "이미 발행된 글은 발행일시를 변경할 수 없습니다" 안내를 띄웠다. 이미 발행된 글에 미래 publishedAt을 보내도 무시되는지, 아직 미발행인 글은 예약 시각을 재조정할 수 있는지 테스트 두 개도 추가했다.
남은 것
sitemap.ts/feed.xml은 1시간 캐시(revalidate = 3600)를 그대로 쓰고 있어서, 예약 시각이 지나도 최대 1시간은 늦게 반영될 수 있다. 홈/상세는 매 요청 DB 조회라 즉시 반영되지만 이 둘은 다르다.
관리자용 배지는 홈 목록 카드(post-card.tsx)에만 붙였고 상세 페이지 헤더엔 안 넣었다. 필요해지면 같은 패턴으로 붙이면 된다.
가시성 필터링(홈/상세/사이트맵/피드 네 곳의 쿼리 조건) 자체에 대한 자동 테스트는 없다. 이번엔 post-schema.ts/post-actions.ts 레벨까지만 테스트를 붙였고, 페이지 컴포넌트 단의 필터링은 수동으로 확인했다.