V VibeCoding 365
목록으로 DB Runtime Publishing

바이브코딩

DB 런타임 발행 | revalidatePath와 라이브 smoke

200은 무효화 신호일 뿐, 공개 화면은 마커로 확인한다

DB 런타임 발행 루프는 Next.js App Router에 이미 배포된 렌더러를 두고, Turso 같은 DB의 글 row만 바꾼 뒤 revalidatePath와 revalidateTag로 캐시 무효화 신호를 보내는 발행 방식이다. 전제는 Next.js 14 이상 App Router와 안정된 마크다운 렌더러다. Pages Router나 다른 DB면 API와 스크립트 경로가 달라진다.

일반적인 Vercel 배포는 코드, 의존성, 환경 변수를 함께 다시 컴파일한다. 작은 문구 수정도 전체 릴리스가 된다. DB-only 발행은 그 빌드를 건너뛴다. 대신 Git 히스토리, PR 리뷰, 롤백 버튼처럼 배포 파이프라인에 딸려 있던 안전장치도 같이 사라진다. 초안 작성(사람 또는 에이전트)과 운영 공개(검증, DB, 캐시, smoke)를 분리하지 않으면, 초안 품질 문제가 곧바로 라이브 사고로 이어진다.

가장 자주 깨지는 가정은 “revalidate가 됐으면 화면도 즉시 바뀐다”이다. revalidate는 재생성 완료를 보장하지 않고, 무효화 신호만 전달한다. 200 응답은 “무효화 표시가 전달됨”이지 “즉시 재생성이 끝남”이 아니다. stale-while-revalidate 구간이나 캐시 소유권(임베디드 리플리카, 인스턴스, CDN)이 다르면, DB에는 새 내용이 있어도 화면은 옛 내용이 잠깐 고정될 수 있다. 성공 기준은 curl 상태 코드가 아니라 라이브 URL에서 새 제목 마커가 보이는지다.

ISR은 s-maxage와 stale-while-revalidate를 응답한다. 앞단 CDN이 HTML을 붙잡고 있으면 Next.js 내부 무효화만으로는 화면이 안 바뀐다. revalidate 직후 해당 URL만 골라 purge하고 smoke를 다시 돈다. 첫 방문이 옛 화면인 것 자체만으로 실패로 단정하지 않는다. smoke의 2~5회, 2초 간격은 그 stale 구간을 넘기기 위한 것이다.

코드 배포 vs row 변경

작업 시작 전에 사람에게도 에이전트에게도 먼저 보여주는 표다. FAQ JSON 필드 값 추가는 렌더러가 이미 표시하도록 배포돼 있으면 DB 루프다. FAQ UI 컴포넌트나 스키마 검증 로직을 바꾸면 코드 배포다.

작업DB 루프코드 배포
제목, 본문, 태그, 발행일가능
FAQ 같은 JSON 필드 값 추가가능
오탈자, 출처 보강가능
Markdown 렌더러 문법 추가필요
generateMetadata 로직 변경필요
카드 UI, 레이아웃필요
새 카테고리(라우팅, 메뉴)혼합라우팅은 코드
sitemap 생성 로직필요

이 표의 빈칸은 금지 표시가 아니라 이 루프의 대상이 아니라는 뜻이다. 라우팅과 메뉴가 필요한 새 카테고리는 데이터 행만으로는 목록에 안 뜨는 경우가 있다. sitemap 생성 로직이 빌드 산출물이면 글 row를 바꿔도 XML이 안 따라온다.

generateMetadata가 DB를 읽도록 이미 배포돼 있으면, revalidate 후 다음 요청부터 SEO 메타가 따라온다. 메타 생성 로직 자체를 바꾸려면 코드 배포가 필요하다. 상세 path와 목록 tag를 같이 무효화한다.

렌더러가 이미 안정적일 때만 이 루프가 성립한다. 표가 깨지거나 코드블록이 안 나오면 JSON이 아니라 컴포넌트를 고친다. 에이전트에게 “이 글 고쳐 달라”만 던지면 renderer까지 PR이 나올 수 있다. 프롬프트에 content JSON만 다룬다는 문장을 고정한다.

로컬 JSON을 Git에 두면 초안 이력은 남고, Turso row는 공개 상태만 가진다. 배포 파이프라인의 revert 버튼이 없으므로, 사고 복구의 좌표는 이전 JSON을 다시 올리거나 status를 draft로 되돌리는 쪽이다. Draft Mode는 미리보기 렌더용이며, 공개 성공 기준을 대체하지 않는다.

발행 경로는 세 칸이다. 왼쪽은 사람이 쓰는 로컬 JSON 원본과 Git 보관, 가운데는 시스템이 쓰는 Turso row와 revalidate API, 오른쪽은 독자가 보는 캐시된 Next.js 페이지와 live smoke다. 코드 레인은 그대로 두고 가운데만 움직이는 것이 핵심이다.

[로컬 원천]     [DB 런타임]       [공개 화면]
local JSON -> Turso row -> Next.js page
     ^              |                |
     |              v                v
[ Git 보관 ]   [ revalidate API ]  [ live smoke ]

위 그림의 화살표는 데이터가 흐르는 방향이다. Git은 원본을 보관하고, revalidate API는 가운데에서 오른쪽으로 무효화 신호를 보낸다. smoke는 오른쪽 화면이 새 마커를 보여 주는지 읽는다.

발행 세 레인
그림 1. 로컬 원천, DB 런타임, 공개 화면. 코드 레인은 렌더러가 안정된 뒤에만 멈춘다.

revalidatePath

revalidatePath는 특정 경로의 캐시를 무효화 표시한다. Server Action과 Route Handler에서 호출한다. 즉시 재생성이 아니다. stale-while-revalidate 구간에서는 첫 smoke 요청이 옛 화면을 받을 수 있고, 그 자체만으로 실패로 단정할 수 없다.

인자 규칙은 셋이다. 리터럴 경로(/vibe-coding/my-post)를 넘길 때는 type을 생략한다. 그 한 페이지만 무효화된다. 패턴(/vibe-coding/[slug])을 넘길 때는 type: 'page'가 필수다. 매칭되는 전부가 무효화된다. /page나 /layout을 문자열 끝에 붙이지 않는다. 그것은 type으로 표현한다.

리터럴과 패턴을 섞어 쓰면 의도와 다른 범위가 무효화된다. 상세 한 글만 고쳤는데 카테고리 전체가 다시 그려지거나, 반대로 목록만 남고 상세가 옛 HTML인 상태가 그렇게 나온다. Next.js revalidatePath 문서가 이 구간의 1차 출처다.

revalidateTag

revalidateTag는 같은 태그를 달고 fetch한 페이지를 한꺼번에 무효화한다. 목록, 홈, sitemap처럼 여러 페이지가 공유하는 데이터에 쓴다. Server Action과 Route Handler에서 호출하고, 즉시 갱신은 아니다.

updateTag만 Server Action 전용이며, 다음 요청이 새 데이터를 기다린다. read-your-own-writes가 필요할 때 등장한다. 공개 발행 루프의 기본은 revalidate 쪽이다. 저장 직후 같은 세션에서 미리보기를 보여 줘야 하면 updateTag를 본다.

태그를 안 붙인 페이지는 revalidateTag가 지나간다. posts:category 태그와 posts:home 태그는 목록 컴포넌트가 같은 태그를 달고 fetch할 때만 의미가 있다. 상세는 경로 무효화, 목록은 태그 무효화로 나누는 구성이 흔하다. 한쪽만 보내면 목록만 새데 상세가 옛것이거나, 그 반대가 된다.

함수어디서 호출 가능즉시 갱신용도
revalidatePath(path)Server Action, Route Handler아니오특정 페이지
revalidateTag(tag)Server Action, Route Handler아니오목록, 홈, sitemap처럼 여러 페이지가 공유하는 데이터
updateTag(tag)Server Action 전용예, 다음 요청이 기다림read-your-own-writes
핵심 포인트: 200은 무효화 신호가 전달됐다는 뜻이다. 공개 화면의 성공 기준은 상세 URL과 목록 URL에서 새 제목 마커가 보이는지다.

revalidatePath에 패턴 /vibe-coding/[slug]를 넘길 때는 type: 'page'가 필수다. 리터럴 /vibe-coding/my-post에는 type을 생략한다. 경로 끝에 /page나 /layout을 붙이지 않는다. 그것은 type으로 표현한다. 리터럴과 패턴을 섞어 쓰면 의도와 다른 범위가 무효화된다. 상세 한 글만 고쳤는데 카테고리 전체가 다시 그려지거나, 반대로 목록만 남고 상세가 옛 HTML인 상태가 그렇게 나온다. updateTag는 Server Action 전용이다. 다음 요청이 새 데이터를 기다린다. Route Handler에서 updateTag를 쓰면 문서가 가리키는 호출 위치가 아니다.

엔드포인트 시크릿

App Router 기준 예시는 app/api/revalidate/route.ts에 둔다. URL은 공개되어도 x-revalidate-auth 같은 시크릿 헤더 검증은 필수다. 검증 없이 열어 두면 누구나 캐시를 깨 DoS에 가까운 부하를 줄 수 있다. publish 스크립트와 CI에만 시크릿을 둔다. REVALIDATE_SECRET에 NEXT_PUBLIC_ 접두사를 붙이지 않는다. 붙는 순간 빌드 결과물에 노출된다.

타이밍 안전 비교는 문자열을 ===로 비교하지 않는다. 길이가 다르면 바로 false이고, 같으면 timingSafeEqual로 바이트를 비교한다. 경로 주입을 막기 위해 slug와 category는 소문자와 하이픈만 받는다. 핸들러는 다음 블록이다.

import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';

export const runtime = 'nodejs';

function safeEqual(given: string, secret: string) {
  const a = Buffer.from(given, 'utf8');
  const b = Buffer.from(secret, 'utf8');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

export async function POST(req: NextRequest) {
  const secret = process.env.REVALIDATE_SECRET;
  const given = req.headers.get('x-revalidate-auth');
  if (!secret || !given || !safeEqual(given, secret)) {
    return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
  }

  let body: { slug?: string; category?: string };
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ ok: false, error: 'invalid json' }, { status: 400 });
  }

  const { slug, category } = body;
  if (!slug || !category) {
    return NextResponse.json({ ok: false, error: 'missing slug or category' }, { status: 400 });
  }

  if (!/^[a-z0-9-]+$/.test(slug) || !/^[a-z0-9-]+$/.test(category)) {
    return NextResponse.json({ ok: false, error: 'invalid slug or category' }, { status: 400 });
  }

  const paths = [`/${category}/${slug}`, `/${category}`, '/'];

  try {
    for (const p of paths) revalidatePath(p);
    revalidateTag(`posts:${category}`);
    revalidateTag('posts:home');
    revalidatePath('/sitemap.xml');
  } catch (err) {
    return NextResponse.json(
      { ok: false, error: 'revalidate failed', detail: String(err) },
      { status: 500 },
    );
  }

  return NextResponse.json({
    ok: true,
    marked: paths,
    note: 'invalidation applies on next visit; verify with a live fetch',
  });
}

응답 JSON의 note가 말하듯, 무효화는 다음 방문에 적용된다. 경로 배열은 상세와 카테고리 목록과 홈을 한 번에 무효화하기 위한 것이다. sitemap.xml은 경로 무효화로 따로 넣는다.

revalidate 경로와 시크릿 헤더
그림 2. x-revalidate-auth와 timingSafeEqual. NEXT_PUBLIC_ 접두사는 시크릿을 빌드에 넣는다.

smoke 마커

읽을 값은 두 가지다. 본문에 새 제목 마커가 있는지, 캐시 헤더가 stale 구간을 통과했는지. revalidateTag는 기본적으로 stale-while-revalidate 구간을 남기기 때문에, 첫 방문이 옛 화면을 주는 것은 정상 동작일 수 있다. 2~5회, 2초 간격으로 같은 URL을 다시 받는 스크립트 예시는 다음 블록이다.

#!/usr/bin/env bash
set -euo pipefail
URL="$1"
MARKER="$2"
for i in 1 2 3 4 5; do
  BODY="$(curl -sS --compressed -H 'Cache-Control: no-cache' "$URL")"
  if echo "$BODY" | grep -qF "$MARKER"; then
    echo "OK  $URL  (attempt $i)"
    exit 0
  fi
  echo "..  $URL  not yet (attempt $i)"
  sleep 2
done
echo "FAIL $URL  marker not found: $MARKER" >&2
exit 1

$1은 라이브 URL, $2는 본문에 있어야 할 새 제목 문자열이다. -qF는 고정 문자열 검색이라 제목의 정규식 특수문자가 패턴으로 해석되지 않는다. --compressed는 gzip 본문을 풀어 마커를 찾게 한다. Cache-Control: no-cache는 로컬 curl 캐시가 아니라, 중간에 있는 캐시에 재검증을 요청하는 헤더다. 그래도 앞단 CDN이 무시하면 퍼지가 따로 필요하다.

검증은 상세와 목록 양쪽이다. 호출 예시는 다음 블록이다.

./scripts/smoke.sh "https://example.com/vibe-coding/my-post" "새 제목 문자열"
./scripts/smoke.sh "https://example.com/vibe-coding"          "새 제목 문자열"

목록만 새데 상세가 옛것이거나, 그 반대면 태그 무효화와 경로 무효화 중 한쪽이 빠진 것이다.

CDN 리플리카

모든 단계가 성공처럼 보여도 화면만 옛날인 경우는 보통 세 가지다.

Turso embedded replicas를 쓰면 쓰기는 원격으로 가고, 읽기는 로컬 복제본에서 온다. 복제본은 syncInterval 주기로 따라잡는다. 발행 루프는 upsert 후 곧바로 revalidate를 보내고, 다음 방문 때 렌더러가 로컬 복제본을 읽는다. 결과적으로 캐시는 “신선한 것처럼” 굳고, 다음 동기화가 끝나도 저절로 갱신되지 않는다. 다음 발행 때까지 옛 글이 걸려 있는 현상이 나온다. 발행 스크립트가 revalidate 전에, 재생성 요청이 읽는 경로에서 새 값이 보이는지를 확인한다. 쓰기 경로와 읽기 경로가 다르면 항상 생기는 현상이다.

기본 Next.js 캐시는 인스턴스별로 로컬에 붙는다. revalidateTag는 호출을 받은 인스턴스에서만 무효화된다. 로드밸런서 뒤에서 요청이 분산되면 새 글과 옛 글이 번갈아 보일 수 있다. 자체 호스팅에서 인스턴스가 여러 개라면 shared cache를 쓰거나, 커스텀 캐시 핸들러로 refreshTags 같은 동기화를 붙인다.

ISR은 s-maxage와 stale-while-revalidate를 응답한다. 앞단 CDN이 이 지시자를 존중해 HTML을 캐시하고 있다면, Next.js 내부 캐시 무효화만으로는 HTML이 바로 바뀌지 않는다. CDN이 앞단에 있으면 purge가 발행 루프의 필수 단계다. revalidate 직후 해당 URL만 골라 purge하고 smoke를 다시 돈다. Cloudflare Cache Purge 문서가 이 구간의 1차 출처다.

옛 HTML이 남는 세 곳
그림 3. 임베디드 리플리카, 인스턴스 로컬 캐시, 앞단 CDN. 셋 다 revalidate 200과 무관하다.

8단계

로컬 JSON 작성과 스키마 검증, 사람 품질 검토, DB upsert, 읽기 경로 확인, revalidate와 CDN purge, live smoke, 마커 미도달 시 2~5회 재시도가 한 루프다.

단계작업통과 기준
1로컬 JSON 작성slug, category, title, content 존재
2스키마 검증필수 필드, 위험 HTML 없음
3품질 검토(사람)얕은 요약 아님, 출처 보강
4DB upsertid와 slug 일치, 영향 행 수 1
5읽기 경로 확인렌더러가 새 값을 읽는지
6revalidate와 CDN purge상세, 목록, 홈, sitemap
7live smoke상세 URL 마커, 목록 URL 마커
8재시도(선택)marker 미도달이면 2~5회

완료 기준은 curl 성공이 아니라 라이브 URL에서 새 제목이 보이는지다. upsert가 성공했는데 사이트가 옛 글이면, Next.js와 CDN이 이전 HTML을 캐시하고 있는 경우가 많다. 5단계가 빠지면 리플리카가 옛 row를 읽어도 6단계가 “성공”으로 끝난다.

에이전트를 작성자가 아니라 운영 절차를 실행하는 도구로 둘 때의 경계 문장은 다섯이다. content/vibe/<slug>.json에만 파일을 작성한다. validation과 금지어 grep을 통과할 때까지 고친다. publish 스크립트는 승인 후에만 실행한다. 상세 URL과 목록 URL 양쪽에 live smoke를 돈다. 실패 시 보류 보고하고 다음 수정으로 넘어가지 않는다. 금지 범위는 app/, components/, lib/ 이하 파일 수정과 DB 직접 접속이다. 불확실하면 진행 중지와 근거 보고다.

작업 전에는 데이터 변경인지 코드 변경인지 구분하고, 로컬 JSON 원본과 되돌리기 방법이 있는지 본다. 검증 중에는 스키마, 금지어 grep, slug와 id 일치, 본문 실행 가치를 본다. 공개 후에는 revalidate 경로(상세, 목록, 홈, sitemap), live smoke(상세와 목록), 콘솔 오류를 같이 본다.

DB 되돌리기

DB만 바꾸면 Git 히스토리에 흔적이 없다. Git revert로는 발행을 취소할 수 없다. 응급 조치는 상태 토글이다. 공개 화면에서 내리는 SQL은 다음 블록이다.

UPDATE posts SET status = 'draft', published_at = NULL WHERE slug = 'problem-slug';

problem-slug는 사고 난 글의 slug다. status='draft'로 바꾸면 공개 목록 쿼리가 그 행을 빼는 전제다. published_at을 비우는 이유는 예약 발행이나 날짜 정렬이 옛 공개 시각을 붙잡고 있을 수 있어서다. 이후 revalidate를 호출하면 공개 화면에서 사라진다. 글 row는 DB에 남아 조사 후 재공개할 수 있다.

되돌린 뒤에는 상세만 내리지 말고 목록과 sitemap도 함께 무효화한다. 그렇지 않으면 죽은 링크가 남는다. content 폴더를 Git으로 관리하면 복구 기준점이 생긴다. 로컬 JSON 이전 버전을 재업로드한 뒤 revalidate하는 경로도 같다.

페이지 HTML의 og:image까지 런타임에 바뀌는 구조는 동적 OG 이미지와 같이 본다.

태그를 안 붙인 페이지

posts:category 태그와 posts:home 태그는 목록 컴포넌트가 같은 태그를 달고 fetch할 때만 의미가 있다. fetch 호출에 태그가 없으면 revalidateTag는 그 페이지를 지나간다. 상세는 revalidatePath로 리터럴 경로를 보내고, 목록과 홈은 태그로 보내는 구성이 흔하다. 한쪽만 보내면 목록만 새데 상세가 옛것이거나, 그 반대가 된다. sitemap.xml은 태그가 아니라 경로 무효화로 따로 넣는다. sitemap 생성 로직이 빌드 산출물이면 글 row를 바꿔도 XML이 안 따라온다. 그때는 코드 배포다.

generateMetadata가 DB를 읽도록 이미 배포돼 있으면, revalidate 후 다음 요청부터 SEO 메타가 따라온다. 메타 생성 로직 자체를 바꾸려면 코드 배포가 필요하다. FAQ JSON 필드 값 추가는 렌더러가 이미 표시하도록 배포돼 있으면 DB 루프다. FAQ UI 컴포넌트나 스키마 검증 로직을 바꾸면 코드 배포다. 새 카테고리는 데이터 행만으로는 목록에 안 뜨는 경우가 있다. 라우팅과 메뉴는 코드다.

로컬 JSON을 Git에 두면 초안 이력은 남고, Turso row는 공개 상태만 가진다. 가운데 레인이 빨라질수록 왼쪽 원본과 오른쪽 smoke가 없으면 무엇이 나갔는지 재구성하기 어렵다. Draft Mode는 미리보기 렌더용이며, 공개 성공 기준을 대체하지 않는다. 미리보기에서 새 제목이 보여도, 라이브 URL의 마커가 없으면 발행이 끝나지 않은 것이다.

revalidatePath에 패턴 /vibe-coding/[slug]를 넘길 때는 type: 'page'가 필수다. 리터럴 /vibe-coding/my-post에는 type을 생략한다. 경로 끝에 /page나 /layout을 붙이지 않는다. 그것은 type으로 표현한다. 리터럴과 패턴을 섞어 쓰면 의도와 다른 범위가 무효화된다. 상세 한 글만 고쳤는데 카테고리 전체가 다시 그려지거나, 반대로 목록만 남고 상세가 옛 HTML인 상태가 그렇게 나온다.

updateTag는 Server Action 전용이다. 다음 요청이 새 데이터를 기다린다. read-your-own-writes가 필요할 때 등장한다. 공개 발행 루프의 기본은 revalidate 쪽이다. 저장 직후 같은 세션에서 미리보기를 보여 줘야 하면 updateTag를 본다. Route Handler에서 updateTag를 쓰면 문서가 가리키는 호출 위치가 아니다.

시크릿 헤더 x-revalidate-auth는 publish 스크립트와 CI에만 둔다. URL이 공개되어도 헤더가 없으면 401이다. timingSafeEqual은 길이가 다를 때 바로 false이고, 같으면 바이트를 비교한다. ===로 문자열을 비교하면 타이밍으로 길이를 짐작할 여지가 생긴다. REVALIDATE_SECRET에 NEXT_PUBLIC_를 붙이면 빌드 결과물에 노출된다. slug와 category의 정규식 ^[a-z0-9-]+$는 경로 주입을 막는다. JSON이 아니면 400이다. revalidate 호출이 던지면 500이다.

Turso embedded replicas는 쓰기는 원격, 읽기는 로컬 복제본이다. syncInterval 주기로 따라잡는다. upsert 직후 revalidate를 보내면, 다음 방문의 렌더러가 옛 복제본을 읽고 그 결과를 캐시에 굳힌다. 다음 동기화가 끝나도 캐시는 저절로 갱신되지 않는다. 발행 스크립트가 revalidate 전에, 재생성 요청이 읽는 경로에서 새 값이 보이는지를 확인한다. 기본 Next.js 캐시는 인스턴스별로 로컬에 붙는다. revalidateTag는 호출을 받은 인스턴스에서만 무효화된다. 로드밸런서 뒤에서 새 글과 옛 글이 번갈아 보이면 shared cache나 커스텀 캐시 핸들러의 refreshTags가 대상이다.

ISR은 s-maxage와 stale-while-revalidate를 응답한다. 앞단 CDN이 HTML을 붙잡고 있으면 Next.js 내부 무효화만으로는 화면이 안 바뀐다. revalidate 직후 해당 URL만 골라 purge하고 smoke를 다시 돈다. Cloudflare Cache Purge 문서가 이 구간의 1차 출처다. smoke의 2~5회, 2초 간격은 그 stale 구간을 넘기기 위한 것이다. 첫 방문이 옛 화면인 것 자체만으로 실패로 단정하지 않는다.

에이전트 경계 다섯은 content/vibe/<slug>.json에만 파일을 작성하고, validation과 금지어 grep을 통과할 때까지 고치고, publish 스크립트는 승인 후에만 실행하고, 상세와 목록 양쪽에 smoke를 돌리고, 실패 시 보류 보고하는 것이다. app/, components/, lib/ 수정과 DB 직접 접속은 금지다. 표가 깨지거나 코드블록이 안 나오면 JSON이 아니라 컴포넌트를 고친다. 그 순간은 코드 배포다.

smoke 스크립트가 읽는 것

$1은 라이브 URL, $2는 본문에 있어야 할 새 제목 문자열이다. 루프는 1부터 5까지다. 매 시도마다 curl -sS --compressed -H 'Cache-Control: no-cache'로 본문을 받는다. -qF는 고정 문자열 검색이라 제목의 정규식 특수문자가 패턴으로 해석되지 않는다. 마커가 있으면 OK와 시도 횟수를 찍고 0으로 끝난다. 없으면 2초를 자고 다시 받는다. 다섯 번 모두 없으면 FAIL을 stderr에 찍고 1로 끝난다.

상세 URL과 목록 URL을 같은 마커로 두 번 호출한다. 목록만 새데 상세가 옛것이면 경로 무효화가 빠진 것이다. 반대면 태그가 빠진 것이다. 200만 보고 끝내면 stale-while-revalidate 구간의 옛 HTML을 성공으로 읽는다. 응답 JSON의 note가 말하듯 무효화는 다음 방문에 적용된다.

8단계 표의 통과 기준은 slug, category, title, content 존재, 필수 필드와 위험 HTML 없음, 얕은 요약이 아닌 출처 보강, id와 slug 일치와 영향 행 수 1, 렌더러가 새 값을 읽는지, 상세 목록 홈 sitemap 무효화, 상세와 목록 마커, 미도달이면 2~5회다. 4단계 upsert가 성공하고 5단계 읽기 경로가 옛 복제본이면 6단계 revalidate는 옛 값을 캐시에 굳힌다. 그래서 5단계가 빠지면 화면만 옛날인 사고가 난다.

되돌리기 SQL은 status = 'draft'와 published_at = NULL이다. 공개 목록 쿼리가 draft를 빼는 전제다. 날짜 정렬이 옛 공개 시각을 붙잡고 있을 수 있어 published_at을 비운다. 이후 revalidate를 호출해야 공개 화면에서 사라진다. 상세만 내리고 목록과 sitemap을 안 보내면 죽은 링크가 남는다. Git revert로는 발행을 취소할 수 없다. content 폴더의 이전 JSON을 재업로드한 뒤 revalidate하는 경로도 같다. problem-slug는 사고 난 글의 slug다.

엔드포인트 경로 배열은 /${category}/${slug}, /${category}, /다. 태그는 posts:${category}와 posts:home이다. sitemap.xml은 경로로 따로 넣는다. runtime은 nodejs다. 시크릿이 없거나 헤더가 없거나 safeEqual이 false면 401이다. invalid json과 missing slug, invalid slug는 400이다. revalidate가 던지면 500이다.

마무리

앞에서 다룬 DB 런타임 발행 루프의 핵심만 짧게 정리한다.

  • 렌더러가 안정된 뒤에만 데이터 레인을 움직인다. 문법과 레이아웃은 코드 배포다.
  • revalidatePath와 revalidateTag는 즉시 재생성이 아니다. updateTag만 Server Action에서 다음 요청을 기다린다.
  • 리터럴 경로는 type 생략, 패턴은 type: 'page'가 필수다. 경로 끝에 /page를 붙이지 않는다.
  • 엔드포인트는 시크릿 헤더와 timingSafeEqual이 있고, NEXT_PUBLIC_ 시크릿은 빌드에 새어 나간다.
  • smoke는 상세와 목록 양쪽, stale 구간은 2~5회 재시도다.
  • 임베디드 리플리카, 인스턴스 로컬 캐시, 앞단 CDN은 200과 무관하게 옛 HTML을 붙잡는다.
  • DB 발행의 되돌리기는 Git revert가 아니라 status='draft' 토글과 재업로드다.

「무효화 신호와 공개 화면은 같은 사건이 아니다」 upsert 성공과 revalidate 200은 중간 상태다. 라이브 URL의 마커가 보이기 전에는 발행이 끝나지 않은 것으로 본다.

출처와 링크

조사 기준: 2026년 8월. Next.js 14+ App Router와 Turso CLI 기준이다. Draft Mode는 미리보기 렌더용이며, 이 루프의 공개 성공 기준을 대체하지 않는다.

FAQ

자주 묻는 질문

revalidate만으로 SEO 메타가 바로 바뀌는가?

메타 생성 함수가 글 row를 읽도록 이미 나가 있는지가 전제다. 그 전제면 캐시를 깨는 신호 다음의 요청에서 제목과 설명이 따라온다. 함수 본문을 고치는 일은 렌더러 레인이라 코드 배포가 필요하고, 상세와 목록을 한쪽만 깨면 메타가 엇갈린다.

DB upsert는 성공했는데 사이트는 옛 글인 이유는 무엇인가?

Next.js와 앞단 CDN이 이전 HTML을 붙잡고 있는 경우가 많다. upsert 직후 revalidate API를 호출하고, 라이브 URL에서 새 제목 마커가 보이는지가 완료 기준이다. Cloudflare를 쓰면 purge도 같은 루프에 들어간다.

Git revert로 발행을 취소할 수 있는가?

DB만 바꾼 발행은 Git 히스토리에 남지 않아 revert로는 안 된다. status를 draft로 토글하거나 로컬 JSON 이전 버전을 재업로드한 뒤 revalidate한다. content 폴더를 Git으로 관리하면 복구 기준점이 생긴다.

FAQ JSON 필드만 추가해도 코드 배포 없이 되는가?

값이 JSON 칸에만 들어가고 화면이 그 칸을 이미 그리면 데이터 레인이다. 컴포넌트나 스키마 검사가 없으면 공개 화면에 안 나타나거나 검증에서 막힌다. 작업 전에 렌더러가 그 필드를 읽는지부터 가른다.

revalidate API를 공개 URL에 두어도 되는가?

URL은 공개되어도 x-revalidate-auth 같은 시크릿 헤더 검증은 필수다. 검증 없이 열어 두면 누구나 캐시를 깨 부하를 줄 수 있다. 시크릿은 publish 스크립트와 CI에만 두고 NEXT_PUBLIC_ 접두사는 붙이지 않는다.

에이전트에게 글 수정을 맡길 때 빠지면 안 되는 경계 문장은 무엇인가?

app, components, lib 아래는 수정하지 않고 content JSON만 다룬다는 문장이다. validation, publish, smoke 순서와 실패 시 보류 보고를 같이 적으면 renderer PR 사고를 줄일 수 있다. 불확실하면 진행을 멈춘다.