next-intl 개발기: ISR에서 영문 블로그만 500이 난 원인과 해결
영문 블로그 글이 전부 500을 낸 원인은 루트 레이아웃의 getLocale() 한 줄이었어요. html lang을 주소에서 읽게 바꾸고, 엉뚱한 주소의 500도 404로 바로잡은 과정을 정리했어요.
- 영문 블로그 글이 전부 500을 낸 원인은 루트 레이아웃의 getLocale() 한 줄이었어요.
- html lang은 요청 헤더가 아니라 주소의 [locale] 값으로 정하도록 레이아웃을 나눴어요.
- 엉뚱한 주소의 500은 페이지마다 로케일을 먼저 확인하는 가드로 404로 바꿨어요.
목차
무슨 일이 있었나
인텐트코드 홈페이지는 Next.js App Router와 next-intl로 한국어·영어를 함께 서비스해요. 한국어는 접두사 없이, 영어는 /en 아래에 두는 구조예요. 2026년 10월 21일 사이트 전체를 점검하다가 영문 블로그 쪽에서 이상한 점을 발견했어요.
/en/blog/글주소와 /en/blog/tag/태그 페이지가 모두 500 오류를 돌려주고 있었어요. 사이트맵에 들어 있는 영문 블로그 주소 9개가 전부 그랬고, 없는 글 주소도 404가 아니라 500을 냈어요. 검색엔진이 사이트맵을 따라 들어왔다면 오류 페이지만 만났을 거예요.
이틀 뒤인 10월 23일에는 다른 종류의 500도 찾았어요. /administrator, /api-docs, /iconz처럼 우리 사이트에 없는 주소가 404가 아니라 500을 낸 거예요. 서버 로그에는 Cannot read properties of undefined (reading 'length')라는 오류가 여러 번 남아 있었어요. 두 문제는 겉보기엔 달랐지만, 둘 다 "로케일(locale, 화면 언어)을 어디서 읽느냐"에서 생긴 일이었어요.
첫 번째 500: 헤더를 읽는 레이아웃
블로그 글은 데이터베이스에서 읽어요. 그런데 Docker 이미지를 만드는 빌드 단계에는 데이터베이스가 없어요. 그래서 글 페이지는 빌드 때 미리 만들지 않고, 첫 요청이 왔을 때 만들어 60초 단위로 다시 갱신하는 ISR(Incremental Static Regeneration, 정적 페이지를 요청 시점에 만들고 주기적으로 갱신하는 방식)로 동작해요.
// app/[locale]/blog/[slug]/page.tsx
export const revalidate = 60; // 60초마다 다시 만듦
export const dynamicParams = true; // 미리 안 만든 주소는 첫 요청 때 생성
export async function generateStaticParams() {
// 빌드 때 DB가 없으면 빈 배열 → 모든 글이 첫 요청 때 생성됨
}
문제는 루트 레이아웃에 있었어요. 영문 페이지의 <html lang>이 "ko"로 나가던 것을 고치려고, 2026년 5월에 루트 레이아웃에서 getLocale()로 현재 언어를 읽게 바꿨거든요. next-intl은 따로 알려 주지 않으면 미들웨어가 붙여 둔 요청 헤더에서 언어를 읽어요. next-intl 문서도 헤더를 읽으면 그 경로가 동적 렌더링으로 바뀐다고 설명해요.
정적으로 만들어야 하는 ISR 페이지 안에서 요청 헤더를 읽으니, Next.js가 DYNAMIC_SERVER_USAGE 오류를 냈어요. Next.js 문서에 따르면 정적 페이지를 만드는 중에 headers 같은 동적 함수를 쓰면 이 오류가 던져지고, 제대로 처리되지 않으면 그대로 실패해요. 로컬에서 운영과 똑같이 데이터베이스 주소 없이 프로덕션 빌드를 하자 같은 오류가 그대로 재현됐어요.
해결: html을 로케일 레이아웃으로 내리기
해결 방향은 next-intl이 권하는 구조를 따르는 것이었어요. 언어는 헤더가 아니라 주소의 [locale] 값에서 읽으면 돼요. 그러려면 <html>을 루트가 아니라 [locale] 레이아웃에서 그려야 해요.
app/root-document.tsx를 새로 만들어<html>·<body>, 폰트, 전역 CSS, 회사 구조화 데이터를 한곳에 모았어요.app/[locale]/layout.tsx는 주소의 로케일을lang으로 넘겨 이 문서를 그려요.- 관리자 화면(
app/admin/layout.tsx)과 404 페이지는 한국어 전용이라lang="ko"로 그려요. - 루트
app/layout.tsx에는 사이트 공통 메타데이터만 남기고children을 그대로 돌려줘요.
// app/[locale]/layout.tsx (요약)
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale); // 헤더 대신 이 값으로 정적 렌더링
return <RootDocument lang={locale}>{children}</RootDocument>;
}
setRequestLocale은 next-intl이 헤더 대신 params에서 언어를 알 수 있게 해 주는 함수예요. 고친 뒤 같은 조건(데이터베이스 주소 없는 빌드)에서 영문 블로그 글과 태그 페이지가 200을 돌려주고 lang="en"으로 나오는 것을 확인했어요. 없는 영문 글 주소도 이제 404가 나와요. 페이지마다 <html>이 정확히 하나씩만 그려지는지도 함께 봤어요.
두 번째 500: 앞부분만 비교하는 매처
두 번째 문제는 미들웨어의 matcher(미들웨어를 실행할 주소 규칙)에서 시작됐어요. 관리자 화면, API, 아이콘 같은 경로는 언어 처리를 하지 않으려고 정규식으로 제외하고 있었어요. 그런데 제외 규칙이 admin|api|icon처럼 단어의 앞부분만 비교했어요.
그래서 /administrator는 "admin"으로 시작한다는 이유만으로 언어 처리를 건너뛰었어요. 그대로 app/[locale] 아래로 들어가면서 로케일이 'administrator'가 됐죠. 레이아웃은 이 값을 보고 notFound()를 불렀지만, 우리가 관찰한 바로는 레이아웃과 페이지가 동시에 렌더되면서 페이지도 계속 그려졌어요. 홈 페이지가 highlights[locale].length를 읽다가 undefined를 만나 500으로 끝난 거예요.
해결: 페이지마다 로케일 가드
가장 먼저 떠오른 방법은 matcher 정규식을 고치는 거였어요. 하지만 그렇게 하면 /icon, /apple-icon, 공유 이미지 경로처럼 지금 잘 동작하는 주소의 라우팅까지 함께 바뀌어요. 그래서 라우팅은 그대로 두고 페이지 단계에서 막기로 했어요.
// i18n/require-locale.ts
export function requireLocale(locale: string): void {
if (!hasLocale(routing.locales, locale)) notFound();
}
[locale] 아래 페이지 11개 모두에서 await params 바로 다음 줄에 이 함수를 불러요. 페이지 본문뿐 아니라 generateMetadata에도 넣었어요. 메타데이터를 만들다가 같은 이유로 터질 수 있기 때문이에요.
프로덕션 빌드로 잘못된 주소 9개를 확인했어요. /administrator/blog처럼 두 단계 주소까지 모두 404 페이지를 보여 줬어요. 반대로 홈, 블로그, 제품, 관리자 로그인, 아이콘, 공유 이미지, 사이트맵 같은 정상 주소는 200을 그대로 지켰어요. 테스트하는 동안 서버 오류 로그는 0줄이었어요.
다시 겪지 않으려고 정한 것
이번 일로 몇 가지 원칙을 정했어요. 핵심은 언어는 주소에서 읽고, 헤더에서 읽지 않는다는 거예요.
- 레이아웃과 페이지에서는
getLocale()대신 params의locale을 쓰고,setRequestLocale을 먼저 불러요. [locale]페이지는 레이아웃의 검사에 기대지 않고 스스로 로케일을 확인해요.- 검증은 운영과 같은 조건에서 해요. 우리 경우엔 데이터베이스 주소 없이 빌드하는 것이 그 조건이었어요.
- 정상 주소와 잘못된 주소를 둘 다 목록으로 만들어, 고친 뒤 200과 404를 한 번에 확인해요.
개발 서버에서는 두 문제가 모두 잘 보이지 않았어요. 개발 모드는 페이지를 요청할 때마다 새로 그리기 때문에 정적 렌더링 제약이 드러나지 않거든요. 다국어 사이트를 운영한다면 "프로덕션 빌드 + 운영과 같은 환경 변수"로 영어 주소 몇 개만 열어 보는 습관을 권해요.