
파일 29개를 수정했다. /blog 하나에 기술 블로그, 에세이, 연구 노트가 뒤섞여 있던 구조를 /tech-blog, /essay, /research 세 개로 쪼갰다. 대규모 리팩토링을 무사히 끝내려면 순서가 중요하다.
블로그 글이 늘어나면서 문제가 생겼다. 깊은 기술 포스트를 읽으러 온 사람이 에세이를 보게 되고, 에세이를 찾는 사람이 코드 블록 가득한 글을 마주했다. 콘텐츠 타입이 다르면 독자도 다르다. 각각에 맞는 URL과 네비게이션이 필요했다.
기존 /blog URL은 301 리다이렉트로 유지해서 SEO 점수를 보존했다. 검색엔진에 이미 인덱싱된 URL이 깨지지 않는다.
Posts 컬렉션에 contentType 필드를 추가했다. tech-blog, essay, research 세 가지 값을 갖는다. URL은 이 필드에 따라 결정된다.
// src/lib/urls.ts
export const CONTENT_TYPES = {
'tech-blog': { path: '/tech-blog', label: '기술블로그', title: '기술 블로그' },
essay: { path: '/essay', label: '에세이', title: '에세이' },
research: { path: '/research', label: '연구', title: '연구' },
} as const
export function getPostUrl(post: Pick<Post, 'slug' | 'contentType'>): string {
const ct = post.contentType as ContentType
return `${CONTENT_TYPES[ct].path}/${post.slug}`
}URL 생성을 하나의 유틸리티로 모았다. 컴포넌트에서 하드코딩된 경로 대신 getPostUrl(post)를 호출하면 콘텐츠 타입에 맞는 URL이 나온다. 나중에 경로가 바뀌어도 이 파일 하나만 수정하면 된다.
세 개의 라우트에 각각 상세 페이지를 만들면 중복이 생긴다. 포스트 제목, 본문, 메타 정보, 시리즈 네비게이션 — 구조는 동일하고 콘텐츠 타입만 다르다. PostDetail 공통 컴포넌트를 만들어서 세 라우트가 공유하도록 했다.
// src/components/PostDetail.tsx
export function PostDetail({ post }: { post: Post }) {
const image = post.featuredImage as Media | undefined
return (
<article className="max-w-4xl mx-auto">
{/* Hero 이미지, 메타 정보, 본문 */}
<SeriesNavigation post={post} />
</article>
)
}SeriesNavigation 컴포넌트도 이때 추가했다. 같은 시리즈의 이전/다음 글로 이동할 수 있는 네비게이션이다. 시리즈 내에서 글을 순서대로 읽는 경험이 중요했기 때문이다.
배포 직후 첫 방문자가 빈 페이지를 볼 수 있다. ISR 캐시가 아직 생성되지 않은 상태에서 NEXT_PHASE 가드가 빈 결과를 반환하기 때문이다. 이걸 해결하기 위해 배포 후 캐시를 미리 생성하는 warm-cache 스크립트를 만들었다.
#!/bin/sh
BASE_URL="${BASE_URL:-http://localhost:3000}"
# 1차: ISR 캐시 생성 (stale 콘텐츠 + revalidation 트리거)
node -e "fetch('$BASE_URL/').then(r => console.log('/', r.status))"
node -e "fetch('$BASE_URL/tech-blog').then(r => console.log('/tech-blog', r.status))"
# 2차: revalidation 완료 후 fresh 콘텐츠 캐시
sleep 5
node -e "fetch('$BASE_URL/').then(r => console.log('/ (warm)', r.status))"여기서 2차 요청이 핵심이다. ISR은 첫 요청에서 stale 콘텐츠를 서빙하면서 백그라운드에서 revalidation을 트리거한다. 두 번째 요청이 있어야 fresh 콘텐츠가 캐시에 들어간다. 처음에 이걸 몰라서 1차만 실행하고 여전히 빈 페이지를 봤다.
Alpine에서 wget이 없을 수 있다는 것도 발견했다. 처음에 wget으로 작성했다가 Node.js 내장 fetch로 교체했다. Docker Alpine 이미지에서는 가정을 줄이는 게 좋다.
29개 파일을 수정하는 작업에서 가장 중요한 건 순서다. 의존성 방향을 거스르면 중간 상태에서 빌드가 깨지고, 깨진 상태에서 추가 수정을 하다 보면 원래 문제가 뭐였는지 잊어버린다.
코드 변경과 데이터 변경을 분리한 것도 중요하다. 코드가 먼저 배포되고 데이터가 나중에 바뀌어야 롤백이 깔끔하다. 반대로 하면 데이터는 바뀌었는데 코드가 그걸 처리 못 하는 상황이 생긴다.
콘텐츠 타입 분리는 글이 10개 넘어가면 반드시 해야 하는 작업이다. 미룰수록 이전할 URL이 많아지고, 수정해야 할 파일이 늘어난다. 차라리 일찍 쪼개는 게 낫다.
301 영구 리다이렉트를 설정했다. /blog 목록 페이지는 /tech-blog로, /blog/[slug]은 해당 포스트의 contentType에 따라 적절한 경로로 리다이렉트된다. 검색엔진이 이미 인덱싱한 URL의 SEO 점수가 새 URL로 이전된다.
Docker 컨테이너가 시작되고 Next.js 서버가 ready 상태가 된 직후에 실행된다. K8s의 startupProbe가 통과한 시점에 맞춰 돌리는 게 이상적이다. Dockerfile에 스크립트를 포함시키고 컨테이너 시작 시 자동 실행되도록 구성했다.
urls.ts의 CONTENT_TYPES에 새 타입을 추가하고, App Router에 해당 라우트를 만들면 된다. PostDetail 컴포넌트를 공유하므로 상세 페이지는 라우트만 추가하면 자동으로 동작한다. 컬렉션 스키마의 contentType 필드에도 새 옵션을 추가해야 한다.