배포와 인프라9 분 읽기

부모 layout 한 줄이 날짜 페이지 전체를 404로 만들고 있었다

Next.js에서 dynamicParams는 하위 세그먼트로 상속된다. 부모가 false면 자식이 true라고 선언해도 진다. 매일 발행하던 날짜 URL이 사흘 뒤 죽고 있었다.

#nextjs#app-router#seo#routing#debugging
개념 도식: 부모 세그먼트의 dynamicParams가 자식 설정을 덮는 구조
글 내용을 요약한 개념 도식.

유입 로그를 훑다가 이상한 걸 봤습니다. 사람들이 실제로 방문한 주소인데 지금 열어보면 404인 URL이 있었습니다.

/ko/date/2026-08-29   404
/ko/date/2026-09-01   404
/ko/date/2026-09-06   200
/ko/date/2026-09-08   200

날짜만 다른 같은 라우트인데 어떤 건 살아 있고 어떤 건 죽어 있었습니다.

여러분의 라우트 중에 "어제까지 되던" 주소가 있습니까?

생존 창이 정확히 일치했다

경계를 하나씩 찍어보니 살아 있는 범위가 나왔습니다. 9월 6일부터 9월 21일까지. 그날은 9월 8일이었습니다.

어제부터 13일 뒤까지입니다. 그리고 이 앱의 사이트맵 생성 코드가 정확히 그 범위를 발행하고 있었습니다.

// 롤링 윈도우: 어제 ~ +13일. sitemap이 매일 새 URL을 발행 → freshness 신호.
for (let d = -2; d <= 13; d++) { ... }

의도는 좋았습니다. 매일 새 URL을 사이트맵에 올려 검색엔진에 신선도 신호를 준다는 것이었습니다. 문제는 그렇게 색인된 URL이 사흘 뒤 404가 된다는 것이었습니다. 검색엔진 입장에서는 매일 새 404가 쌓이는 셈입니다.

코드는 그러면 안 되는 코드였다

라우트 코드를 보면 창 밖 날짜도 받아야 정상입니다.

export const dynamicParams = true;   // 창 밖도 온디맨드 생성
 
export function generateStaticParams() {
  return rollingDates().map(...)     // 창 안만 프리렌더
}

검증 로직도 넉넉했습니다. 2020~2035년이면 통과, 일진 계산은 순수 함수라 어떤 날짜든 값을 돌려줍니다. notFound()가 호출될 이유가 없었습니다.

배포를 의심했고, 틀렸다

여기서 제가 처음 세운 가설은 "배포본이 소스보다 낡았다"였습니다. 소스는 모든 날짜를 허용하는데 라이브는 창 밖을 막으니 자연스러운 추측입니다.

배포 이력을 확인했습니다. 5시간 전 배포가 최신이었습니다. 가설 기각.

그래서 로컬에서 프로덕션 빌드를 돌리고 같은 요청을 보냈습니다.

2026-09-01  404
2026-09-05  404
2026-09-06  200

동일하게 재현됐습니다. 배포 문제가 아니라 소스 문제였습니다. 이 한 단계가 없었으면 저는 계속 배포 쪽을 파고 있었을 겁니다. 개발 서버로는 이 버그가 보이지 않습니다.

범인은 부모 layout이었다

notFound() 호출처를 전부 grep했습니다. 라우트 안의 검증은 전부 통과하는데 404가 납니다. 그러면 남은 건 상위 세그먼트뿐입니다.

// app/[locale]/layout.tsx
// 지원 로케일만 프리렌더 — 미지원 경로는 404로 처리(500 방지).
export const dynamicParams = false;

이겁니다. dynamicParams는 세그먼트 설정이고 하위로 상속됩니다. 부모가 false면 자식의 dynamicParams = true는 무시됩니다. 자식이 부모를 이길 수 없습니다.

의도는 죽은 /ja 링크가 500이 되지 않게 막는 것이었습니다. 그 의도는 정당했고, 부작용이 하위 라우트 전체였습니다.

당신이라면 어디를 고치겠습니까?

자식 쪽에서 우회하겠습니까? 날짜 라우트를 [locale] 밖으로 빼거나, 창을 1년으로 늘리거나요.

저는 부모의 그 줄을 지웠습니다. 지우고 나서 확인해보니, 미지원 로케일을 막는 일은 이미 같은 layout 안의 다른 코드가 하고 있었습니다.

const parsed = localeSchema.safeParse(raw);
if (!parsed.success) notFound();

/xx/zz/date/2026-09-08도 여전히 404입니다. 500도 아닙니다. 즉 dynamicParams = false처음부터 필요 없는 줄이었습니다. 같은 목적의 가드가 둘이었고, 그중 하나가 의도하지 않은 부수효과를 갖고 있었던 겁니다.

배포 후 라이브에서 과거 날짜가 200으로 돌아왔고, 미지원 로케일은 404를 유지합니다.

회귀는 테스트로 막았다

이런 종류의 버그는 조용히 돌아옵니다. 누군가 "미지원 로케일이 안 막히는 것 같은데"라며 그 줄을 다시 넣으면 끝입니다.

it('상위 layout이 dynamicParams를 false로 두지 않는다', () => {
  expect(localeLayout).not.toMatch(/dynamicParams\s*=\s*false/);
});
 
it('미지원 로케일 차단은 notFound()가 담당한다', () => {
  expect(localeLayout).toMatch(/notFound\(\)/);
});

두 번째 테스트가 중요합니다. 첫 번째만 있으면 "그럼 로케일 차단은 어떻게 하지"라는 질문에 답이 없어서, 다음 사람이 그 줄을 되돌립니다. 지운 근거를 테스트로 같이 박아둬야 합니다.

실제로 false로 되돌려서 테스트가 깨지는 것까지 확인했습니다. 깨지지 않는 가드는 가드가 아닙니다.

자가진단 세 가지

  1. 라우트 세그먼트 설정을 부모와 자식 양쪽에 쓰고 있는가? dynamicParams revalidate dynamic 전부 상속됩니다. 자식이 선언했다고 이긴다고 가정하지 마세요.
  2. 개발 서버에서만 확인하고 있는가? 이 버그는 next build && next start에서만 보입니다.
  3. 날짜·시간 기반 URL을 발행하고 있는가? 그 URL의 수명이 색인 수명보다 짧으면 404를 계속 만들어내는 중입니다.

솔직한 부분

규모는 크지 않았습니다. 28일 동안 이 404에 걸린 방문이 8건입니다. 그것만 보면 고칠 값어치가 애매합니다.

고칠 값어치는 다른 데 있었습니다. 이 앱은 신선도 신호를 만들려고 매일 새 날짜 URL을 사이트맵에 올리고 있었고, 그 URL들이 계속 죽고 있었습니다. 방문 8건은 증상이지 규모가 아닙니다.

그리고 같은 패턴을 쓰는 앱이 제 저장소에 31개 있습니다. 확인해보니 실제 피해는 이 앱 하나였습니다. 하위 세그먼트가 유한 집합인 앱은 generateStaticParams가 전부 생성하므로 오히려 무한 생성을 막는 안전장치로 동작합니다. 같은 설정이 어떤 라우트에서는 보호막이고 어떤 라우트에서는 지뢰입니다.

지금 여러분의 부모 layout을 한 번 열어보세요. 거기 있는 설정이 하위 라우트에 무슨 일을 하고 있는지 알고 계십니까?


관련 글: 앱 5개를 색인에서 날릴 뻔한 Next.js 설정 한 줄 · Search Console의 404는 노이즈였다

관련 글