VibeCoding 365 로고 VibeCoding 365

DB만 바꾸고 발행하기 | revalidate가 실제로 하는 일과 안 하는 일

DB만 바꿔서 Next.js App Router의 revalidate로 공개 화면을 갱신하는 루프를 설명합니다. 200 응답의 의미, stale 구간 smoke, 임베디드 리플리카와 인스턴스 스큐까지 함께 다룹니다.

심층 학습 가이드

DB만 바꾸고 발행하기 | revalidate가 실제로 하는 일과 안 하는 일

심층 학습 가이드

DB만 바꾸고 발행하기 | revalidate가 실제로 하는 일과 안 하는 일

200이 떴는데 화면이 안 바뀌는 다섯 가지 이유

핵심 주제
DB Runtime Publishing
예상 시간
18분
업데이트
2026.04.30
키워드
DB 업로드 revalidate · 무배포 포스팅 · Next.js ISR

핵심 명제는 하나입니다. 초안 작성(AI + 사람)과 운영 공개(검증, DB, 캐시, smoke)를 분리한다는 것입니다. 이 분리를 하면 초안 품질 문제가 곧바로 라이브 사고로 이어지는 경로를 끊을 수 있습니다.

다만 DB-only 루프는 빠르기 때문에 채택하면 위험합니다. 배포 파이프라인이 사라지는 만큼, 거기 딸려 있던 안전장치도 같이 사라집니다. Git 히스토리, PR 리뷰, 롤백 버튼이 없습니다. 이 글의 절반은 그걸 운영 체크리스트와 검증 절차로 다시 만드는 이야기입니다.

또한 “revalidate가 됐으면 화면도 즉시 바뀐다”라는 가정이 가장 자주 깨집니다. revalidate는 재생성 완료를 보장하지 않고, 무효화 신호만 전달합니다. stale-while-revalidate 구간이나 캐시 소유권(리플리카, 인스턴스, CDN)이 다르면, DB에는 새 내용이 있어도 화면은 옛 내용이 잠깐 고정될 수 있습니다. 그래서 이 글은 200 응답을 통과 기준으로 삼지 않고, 라이브 URL smoke를 성공 기준으로 두는 방법까지 포함합니다.

한 줄 고치려고 빌드를 돌릴 필요는 없습니다

일반적인 Vercel 배포는 코드, 의존성, 환경 변수를 함께 다시 컴파일합니다. 작은 문구 수정도 전체 릴리스가 됩니다. 반면 렌더러는 이미 배포된 상태이고, DB-only 발행은 글 데이터만 바꾼 뒤 캐시 무효화 신호만 보냅니다.

여기서 핵심 명제는 “데이터 레인만 움직이는 발행 루프”입니다. ISR과 revalidatePath, revalidateTag의 역할을 정확히 이해한 뒤, 읽기 경로까지 같이 확인해야 “200은 성공, 화면은 갱신”이 성립합니다.

세 레인

[로컬 원천]      [DB 런타임]        [공개 화면]
local JSON ──▶ Turso row ──▶ Next.js page
     ▲              │                │
     │              ▼                ▼
[ Git 보관 ]   [ revalidate API ]  [ live smoke ]

왼쪽은 사람이 쓰는 원본, 가운데는 시스템이 쓰는 저장소, 오른쪽은 독자가 보는 캐시된 페이지입니다. 코드 레인은 그대로 두고 가운데만 움직이는 것이 핵심입니다.

revalidate가 실제로 하는 일과 안 하는 일

여기서 대부분의 오해가 생깁니다. 정확히 정리하면 이렇습니다.

함수어디서 호출 가능즉시 갱신?언제 쓰나
revalidatePath(path)Server Action, Route Handler아니오특정 페이지
revalidateTag(tag)Server Action, Route Handler아니오목록, 홈, sitemap처럼 여러 페이지가 공유하는 데이터
updateTag(tag)Server Action 전용예, 다음 요청이 기다림read-your-own-writes

revalidate는 빌드가 아닙니다. 200 응답은 “무효화 표시가 전달됨”이지, “즉시 재생성이 끝남”이 아닙니다. stale-while-revalidate 구간에서는 첫 smoke 요청이 옛 화면을 받을 수도 있고, 그 자체가 실패로 단정할 수 없습니다.

revalidatePath 인자 규칙

  • 리터럴 경로(/vibe-coding/my-post)를 넘길 때는 type을 생략합니다. 그 한 페이지만 무효화됩니다.
  • 패턴(/vibe-coding/[slug])을 넘길 때는 type: 'page'가 필수입니다. 그리고 매칭되는 전부가 무효화됩니다.
  • /page/layout을 문자열 끝에 붙이지 않습니다. 그것은 type으로 표현합니다.
💡 Tip: 성공 기준은 curl 응답이 아니라 라이브 URL에서 새 제목이 보이는지입니다. 상세와 목록 양쪽을 각각 확인하고, 필요하면 2~5회 재시도 smoke로 stale 구간을 넘깁니다.

데이터 변경과 코드 변경 구분

원본의 표는 그대로 유효합니다. 작업 시작 전에 사람에게도 AI에게도 먼저 보여주는 용도입니다.

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

전제 하나를 명시해 둡니다. 렌더러가 이미 안정적일 때만 이 루프가 성립합니다. 표가 깨지거나 코드블록이 안 나오면 JSON이 아니라 컴포넌트를 고쳐야 합니다.

⚠️ 주의: AI에게 “이 글 고쳐줘”만 던지면 renderer까지 PR이 나올 수 있습니다. 프롬프트에 “content JSON만 다룬다”를 고정하세요.

revalidate 엔드포인트

App Router 기준 예시입니다. app/api/revalidate/route.ts에 둡니다.

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',
  });
}

REVALIDATE_SECRETNEXT_PUBLIC_ 접두사를 붙이지 마세요. 붙는 순간 빌드 결과물에 노출됩니다. 또한 성공 판단은 200이 아니라 라이브 smoke 결과로 합니다.

그래서 검증은 이렇게 한다

읽을 값이 두 가지입니다. 본문에 “새 제목” 마커가 있는지, 그리고 캐시 헤더가 stale 구간을 통과했는지입니다. revalidateTag는 기본적으로 stale-while-revalidate 구간을 남기기 때문에, 첫 방문이 옛 화면을 주는 것은 정상 동작일 수 있습니다.

#!/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

검증은 반드시 상세와 목록 양쪽을 봅니다.

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

조용히 실패하는 세 가지

모든 단계가 성공처럼 보여도 화면만 옛날인 경우는 보통 세 가지입니다. 가장 어려운 유형입니다.

임베디드 리플리카가 옛 데이터를 캐시로 굳힌다

Turso embedded replicas를 쓰면 쓰기는 원격으로 가고, 읽기는 로컬 복제본에서 옵니다. 복제본은 syncInterval 주기로 따라잡습니다. 발행 루프는 upsert 후 곧바로 revalidate를 보내고, 다음 방문 때 렌더러가 로컬 복제본을 읽습니다.

결과적으로 캐시는 “신선한 것처럼” 굳고, 다음 동기화가 끝나도 저절로 갱신되지 않습니다. 다음 발행 때까지 옛 글이 걸려 있는 현상이 나옵니다.

해결은 발행 스크립트가 revalidate 전에 “재생성 요청이 읽는 경로에서 새 값이 보이는지”를 확인하는 것입니다. 쓰기 경로와 읽기 경로가 다르면 항상 생기는 현상입니다.

인스턴스가 둘이면 한 대만 갱신된다

기본 Next.js 캐시는 인스턴스별로 로컬에 붙습니다. revalidateTag는 호출을 받은 인스턴스에서만 무효화됩니다. 로드밸런서 뒤에서 요청이 분산되면 새 글과 옛 글이 번갈아 보일 수 있습니다.

자체 호스팅에서 인스턴스가 여러 개라면 shared cache를 쓰거나, 커스텀 캐시 핸들러로 refreshTags 같은 동기화를 붙여야 합니다.

CDN은 revalidate를 모른다

ISR은 s-maxagestale-while-revalidate를 응답합니다. 앞단 CDN이 이 지시자를 존중해 HTML을 캐시하고 있다면, Next.js 내부 캐시 무효화만으로는 HTML이 바로 바뀌지 않습니다.

CDN이 앞단에 있으면 purge가 발행 루프의 필수 단계입니다. revalidate 직후 해당 URL만 골라 purge하고 smoke를 다시 돌리세요.

발행 루프

원본의 6단계에 위 내용을 반영하면 8단계가 됩니다.

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

완료 기준은 curl 성공이 아니라 라이브 URL에서 “새 제목이 보이는지”입니다.

되돌리기는 Git revert가 아니다

DB만 바꾸면 Git 히스토리에 흔적이 없습니다. 응급 조치는 상태 토글로 합니다.

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

이후 revalidate를 호출하면 공개 화면에서 사라집니다. 글 row는 DB에 남아 조사 후 재공개할 수 있습니다. 되돌린 뒤에는 목록과 sitemap도 함께 smoke로 다시 확인하세요.

⚠️ 주의: 되돌릴 때는 상세만 내리지 말고 목록과 sitemap도 함께 무효화하세요. 그렇지 않으면 죽은 링크가 남습니다.

AI에게 맡길 때는 경계를 문장으로 쓴다

나쁜 예는 “AI 뉴스 1개 올려줘”처럼 범위와 검증 경계를 빼먹는 지시입니다. 이 루프에서는 AI를 작성자가 아니라 운영자 절차를 따라 실행하는 도구로 둡니다.

1. content/vibe/<slug>.json에만 파일을 작성한다.
2. validation과 금지어 grep을 통과할 때까지 고친다.
3. publish 스크립트는 승인 후에만 실행한다.
4. 상세 URL과 목록 URL 양쪽에 live smoke를 돌린다.
5. 실패 시 보류 보고하고 다음 수정으로 넘어가지 않는다.

금지: app/, components/, lib/ 이하 파일 수정. DB 직접 접속 금지. 불확실하면 진행 중지와 근거 보고.

체크리스트

작업 전에는 데이터 변경인지 코드 변경인지 구분하고, 로컬 JSON 원본과 되돌리기 방법이 있는지 확인합니다.

검증 중에는 스키마 검증, 금지어 grep, slug/id 일치, 본문 실행 가치가 있어야 합니다.

공개 후에는 revalidate 경로(상세, 목록, 홈, sitemap), live smoke(상세와 목록), 콘솔 오류를 같이 확인합니다.

출처와 링크

조사 기준: 2026년 7월. Next.js 14+ App Router와 Turso CLI 기준입니다. Pages Router나 다른 DB면 API와 스크립트 경로를 바꿔야 합니다.

자주 묻는 질문

revalidate만으로 SEO 메타가 바로 바뀌나요?

generateMetadata가 DB를 읽도록 이미 배포돼 있다면 revalidate 후 다음 요청부터 반영됩니다. 메타 생성 로직 자체를 바꾸려면 코드 배포가 필요합니다. 상세 path와 목록 tag를 같이 revalidate하세요.

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

Next.js와 CDN이 이전 HTML을 캐시하고 있기 때문입니다. upsert 직후 revalidate API를 호출하고, live smoke로 URL에서 새 제목 마커를 확인해야 합니다. Cloudflare를 쓰면 purge도 검토하세요.

Git revert로 발행을 취소할 수 있나요?

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

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

렌더러가 FAQ JSON을 이미 표시하도록 배포돼 있다면 DB 루프만으로 충분합니다. FAQ UI 컴포넌트나 스키마 검증 로직을 바꿔야 하면 코드 배포가 필요합니다. 작업 전 데이터 vs 코드 표로 구분하세요.

revalidate API를 공개 URL에 두어도 되나요?

URL은 공개되어도 x-revalidate-auth 같은 시크릿 헤더 검증은 필수입니다. 검증 없이 열어 두면 누구나 캐시를 깨 DoS에 가까운 부하를 줄 수 있습니다. publish 스크립트와 CI에만 시크릿을 둡니다.

AI에게 글 수정을 맡길 때 꼭 적을 한 줄은?

코드 파일(app/, components/, lib/)은 수정하지 않고 content/ JSON만 다룬다는 문장입니다. validation, publish, smoke 순서와 실패 시 보류 보고도 함께 적으면 renderer PR 사고를 줄일 수 있습니다.