무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
콘텐츠 타입 분리 /blog → 3개 — generative 키 비주얼
museck 만들기
2026. 2. 19.

콘텐츠 타입 분리: /blog 하나를 세 개로 쪼갠 대수술

파일 29개를 수정했다. /blog 하나에 기술 블로그, 에세이, 연구 노트가 뒤섞여 있던 구조를 /tech-blog, /essay, /research 세 개로 쪼갰다. 대규모 리팩토링을 무사히 끝내려면 순서가 중요하다.

블로그 글이 늘어나면서 문제가 생겼다. 깊은 기술 포스트를 읽으러 온 사람이 에세이를 보게 되고, 에세이를 찾는 사람이 코드 블록 가득한 글을 마주했다. 콘텐츠 타입이 다르면 독자도 다르다. 각각에 맞는 URL과 네비게이션이 필요했다.

Before → After

기존 /blog URL은 301 리다이렉트로 유지해서 SEO 점수를 보존했다. 검색엔진에 이미 인덱싱된 URL이 깨지지 않는다.

설계: contentType 필드와 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 공통 컴포넌트로 중복 제거

세 개의 라우트에 각각 상세 페이지를 만들면 중복이 생긴다. 포스트 제목, 본문, 메타 정보, 시리즈 네비게이션 — 구조는 동일하고 콘텐츠 타입만 다르다. 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 컴포넌트도 이때 추가했다. 같은 시리즈의 이전/다음 글로 이동할 수 있는 네비게이션이다. 시리즈 내에서 글을 순서대로 읽는 경험이 중요했기 때문이다.

warm-cache: ISR 콜드 스타트 해결

배포 직후 첫 방문자가 빈 페이지를 볼 수 있다. 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개 파일을 수정하는 작업에서 가장 중요한 건 순서다. 의존성 방향을 거스르면 중간 상태에서 빌드가 깨지고, 깨진 상태에서 추가 수정을 하다 보면 원래 문제가 뭐였는지 잊어버린다.

  1. 공유 유틸리티/타입 — urls.ts, payload-types 재생성
  2. 데이터 레이어 — 컬렉션 스키마에 contentType 필드 추가
  3. 신규 페이지/컴포넌트 — /tech-blog, /essay, /research 라우트 생성, PostDetail, SeriesNavigation
  4. 기존 컴포넌트 수정 — 네비게이션, 사이트맵, /blog 리다이렉트
  5. 설정/인프라 — warm-cache.sh, Dockerfile 수정
  6. 코드 검증 — lint + 타입 체크 통과
  7. 데이터 변경 — 기존 포스트에 contentType 값 일괄 업데이트

코드 변경과 데이터 변경을 분리한 것도 중요하다. 코드가 먼저 배포되고 데이터가 나중에 바뀌어야 롤백이 깔끔하다. 반대로 하면 데이터는 바뀌었는데 코드가 그걸 처리 못 하는 상황이 생긴다.

배운 것들

  • URL 유틸리티는 첫날에 만들어야 한다. 하드코딩된 경로가 퍼지고 나면 수정 비용이 기하급수적으로 늘어난다.
  • ISR의 2-pass warm-cache를 잊지 말자. 첫 요청은 stale, 두 번째 요청이 fresh다. 1차만 하고 끝내면 의미가 없다.
  • 301 리다이렉트로 SEO를 보존하자. URL 변경 시 기존 URL을 그냥 버리면 검색 순위가 떨어진다. 영구 리다이렉트를 걸어두면 검색엔진이 새 URL로 점수를 이전한다.
  • 대규모 변경은 플랜이 절반이다. 29개 파일을 고치는데 플랜 없이 뛰어들면 중간에 길을 잃는다. 변경 파일 목록과 실행 순서를 먼저 확정하자.

콘텐츠 타입 분리는 글이 10개 넘어가면 반드시 해야 하는 작업이다. 미룰수록 이전할 URL이 많아지고, 수정해야 할 파일이 늘어난다. 차라리 일찍 쪼개는 게 낫다.

자주 묻는 질문

기존 /blog URL은 어떻게 처리했나요?

301 영구 리다이렉트를 설정했다. /blog 목록 페이지는 /tech-blog로, /blog/[slug]은 해당 포스트의 contentType에 따라 적절한 경로로 리다이렉트된다. 검색엔진이 이미 인덱싱한 URL의 SEO 점수가 새 URL로 이전된다.

warm-cache 스크립트는 언제 실행되나요?

Docker 컨테이너가 시작되고 Next.js 서버가 ready 상태가 된 직후에 실행된다. K8s의 startupProbe가 통과한 시점에 맞춰 돌리는 게 이상적이다. Dockerfile에 스크립트를 포함시키고 컨테이너 시작 시 자동 실행되도록 구성했다.

콘텐츠 타입을 추가하려면 어떻게 하나요?

urls.ts의 CONTENT_TYPES에 새 타입을 추가하고, App Router에 해당 라우트를 만들면 된다. PostDetail 컴포넌트를 공유하므로 상세 페이지는 라우트만 추가하면 자동으로 동작한다. 컬렉션 스키마의 contentType 필드에도 새 옵션을 추가해야 한다.

자주 묻는 질문

기존 /blog URL은 어떻게 처리했나요?
301 영구 리다이렉트를 설정했다. /blog 목록 페이지는 /tech-blog로, /blog/[slug]은 해당 포스트의 contentType에 따라 적절한 경로로 리다이렉트된다.
warm-cache 스크립트는 언제 실행되나요?
Docker 컨테이너가 시작되고 Next.js 서버가 ready 상태가 된 직후에 실행된다. K8s의 startupProbe가 통과한 시점에 맞춰 돌리는 게 이상적이다.
콘텐츠 타입을 추가하려면 어떻게 하나요?
urls.ts의 CONTENT_TYPES에 새 타입을 추가하고, App Router에 해당 라우트를 만들면 된다. PostDetail 컴포넌트를 공유하므로 상세 페이지는 라우트만 추가하면 자동으로 동작한다.
museck 만들기(16/22)
Prev

Next.js 16 캐싱 삽질 오디세이: 하루에 12커밋, 세 번의 전략 전환

Next

Next.js 16 ISR 지뢰밭: searchParams, loading.tsx, health route 세 가지 함정