오타 하나에 배포까지 돌던 것을 끊고

2025.12 ~ 2026.04 (운영 중)

본문을 API에서 받아 렌더링하고 저장 즉시 캐시를 갱신하는 기술 블로그입니다.

과제

  • 글이 저장소 안 MDX라 오타 수정에도 커밋·CI·재배포가 필요
  • 시간 기반 ISR로는 수정 내용의 반영 시점을 확인할 수 없음

담당 범위

  • 목록·상세·검색·TOC 화면: Next.js 16 App Router (1인)
  • MDX 파일을 API 기반 런타임 렌더링으로 전환
  • 태그·경로 선택 무효화 갱신 경로, 다국어 About, 미리보기 라우트

설계 판단

  • MDX를 API로 옮기고 저장 웹훅으로만 캐시 무효화 → 무엇이 어떤 캐시를 깨는지 코드로 명시
  • 한글 slug 캐시 태그를 ASCII 해시로 변환 → 헤더 인코딩 오류 해소
  • 미리보기를 별도 라우트로 분리하고 토큰 검증 → 미발행 본문의 공개 캐시 유입 차단

결과 지표

  • 검색 유입(최근 3개월) 클릭 481, 평균 CTR 8.8%, 평균 게재순위 6.9위
  • 발행된 글 42건 (2026-09-04 측정)
  • 단일 커밋에서 MDX 36건 일괄 삭제
원문 자세히 보기

과제

블로그 글이 저장소 안 MDX 파일이라 오타 하나 고치는 데도 커밋·CI·재배포 사이클이 통째로 붙었고, 시간 기반 ISR로는 '지금 고친 게 언제 반영되는지'를 확인할 수 없었다. 글쓰기와 배포를 떼어내되, 무엇을 고치면 어떤 캐시가 깨지는지가 코드에 명시적으로 남는 갱신 경로를 만드는 것이 목표였다.

담당 범위

발행 글 42건 · 라우트 5개 · 소스 3,876줄

1인 개발. (1) Next.js 16 App Router 기반 목록/상세/검색/TOC 화면 구현, (2) 저장소 안 MDX 파일 36건을 걷어내고 백엔드 API에서 본문을 받아 next-mdx-remote v6로 런타임 렌더링하는 구조로 전환, (3) 시간 기반 ISR을 끄고 어드민 저장 웹훅이 호출하는 /api/revalidate Route Handler(시크릿 검증 + 태그·경로 선택 무효화)로 갱신 경로를 바꿈, (4) 캐시 태그가 한글 slug에서 깨지는 문제를 ASCII 해시 태그로 해소, (5) ko/en/jp About 페이지를 [locale] 세그먼트로 분리하고 generateStaticParams·alternates.languages로 다국어 메타데이터 구성, (6) 미발행 글 미리보기 라우트(/preview/[slug])를 토큰 검증 뒤에 분리 배치, (7) next-themes 다크모드·cmdk 검색 팔레트 구성.

보기

선택안 · 대안 · 근거 · 비용

저장소 안 파일 기반 MDX 36건을 전부 걷어내 별도 콘텐츠 API로 옮기고(커밋 d32ced5), 시간 기반 ISR을 끈 채(DEFAULT_REVALIDATE = false) 어드민 저장 시 웹훅이 /api/revalidate를 호출해 revalidateTag/revalidatePath로만 갱신하도록 전환했다

대안MDX 파일을 그대로 두고 Git 기반 CMS를 얹거나, ISR 60초 같은 시간 기반 재검증을 유지하는 안근거글 하나 수정에 커밋·CI·재배포가 붙는 게 실제 병목이었고, 시간 기반 ISR은 반영 시점이 불확실해 고친 것을 확인할 방법이 없었다. 태그 단위 무효화로 바꾸니 어떤 글을 고치면 어떤 캐시가 깨지는지가 CACHE_TAGS에 코드로 남는다.비용빌드와 렌더가 백엔드 가용성에 묶였다. apiFetch가 실패 시 예외 대신 null을 반환해 빈 목록으로 degrade하는데, 이건 장애를 조용히 만든다는 뜻이기도 하다. 글 이력이 더 이상 git diff에 남지 않아 버전 추적이 백엔드 DB로 넘어갔고, 프리뷰 토큰·revalidate 시크릿·API 키라는 운영 표면이 새로 생겼다. 참고로 이 저장소의 Lighthouse CI는 배포본이 아니라 블로그 앱을 로컬에서 띄워 재는 구성이라(.github/lighthouse/lighthouserc.json의 startServerCommand), CI가 초록이어도 실제 배포본 점수를 보증하지 않는다.

캐시 태그에 한글 slug를 그대로 쓰지 않고 ASCII 해시로 변환해 CACHE_TAGS.BLOG_POST를 만들었다 (커밋 88d0a44)

대안slug를 encodeURIComponent로 감싸거나, 글 식별자를 숫자 id로 바꿔 태그에 쓰는 안근거revalidateTag에 넘긴 값이 x-next-cache-tags 헤더로 나가는데, 비ASCII 문자가 들어가면 헤더 인코딩 단계에서 ERR_INVALID_CHAR로 터진다. 인코딩으로 덧칠하면 이번엔 태그 문자열이 무효화 호출부와 어긋나 404/500을 오간다. 해시는 어느 쪽에서 만들어도 같은 값이 나오는 게 보장된다.비용태그만 보고 어떤 글인지 알 수 없어 디버깅 때 역추적이 안 된다. 해시 함수를 바꾸면 기존 캐시 태그가 전부 고아가 되므로 사실상 고정 상수가 됐다.

미발행 글 미리보기를 본문 라우트에 플래그로 얹지 않고 /preview/[slug] 별도 라우트로 분리하고, searchParams의 토큰이 없으면 notFound()로 끊었다

대안/blog/[slug]에 ?preview=1을 붙여 같은 라우트에서 분기하거나, 어드민 안에 미리보기 화면을 따로 만드는 안근거발행 라우트에 미리보기 분기를 넣으면 캐시 대상 경로에 '캐시하면 안 되는 응답'이 섞인다. 라우트를 갈라 두면 미발행 본문이 공개 캐시에 얹힐 경로 자체가 없어지고, 미리보기는 실제 렌더러(PostBody)를 그대로 태워 발행 후 모습과 어긋나지 않는다.비용라우트가 하나 늘어 레이아웃·메타데이터를 두 곳에서 관리하게 됐고, 토큰이 URL 쿼리에 실려 브라우저 히스토리와 리퍼러에 남는다. 토큰 만료·폐기는 백엔드 책임으로 넘어가 이 앱에서는 존재 여부만 검증한다.

사용 기술

TypeScriptNext.jsReactTailwind CSSshadcn/uinext-mdx-remote