다국어 지원 붙이면서 제일 무서웠던 게 "언어 하나 추가했는데 다른 데는 안 바뀌어있으면 어쩌지" 하는 거였어요. img-compressor 저장소의 middleware.ts랑 i18n 설정으로 어떻게 정리했는지 보여드릴게요. 이 프로젝트는 app/[locale]/[page] 구조로 한국어·영어 두 언어를 지원해요.
지원 언어 목록은 딱 한 파일에서만 관리해요
지원 언어랑 기본 언어는 i18n/locales.ts 딱 한 곳에서만 선언하고, 나머지는 다 여기서 가져다 써요.
export const locales = ["ko", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "ko";
미들웨어, 라우트 세그먼트, 사이트맵이 다 같은 배열을 참조하게 해두면, 한쪽에서만 언어를 추가하고 다른 쪽을 빼먹는 실수를 확실히 줄일 수 있더라고요.
미들웨어가 경로 앞에 언어 코드를 강제로 붙여요
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always'
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
localePrefix: 'always'는 /ko, /en처럼 모든 경로에 언어 코드가 항상 붙는다는 뜻이에요. matcher는 API 라우트, Next.js 내부 경로(_next, _vercel), 확장자 있는 정적 파일은 정규식으로 다 걸러서 미들웨어를 건너뛰게 했어요. 정적 파일까지 미들웨어를 거치면 괜히 불필요한 리다이렉트가 생기더라고요.
지원 안 하는 언어는 라우트 단계에서 404로 처리해요
미들웨어를 통과하고 나서도, 실제로 메시지 파일을 불러오는 시점에 한 번 더 언어 코드를 검증해요.
export default getRequestConfig(async ({ requestLocale }) => {
const locale = await requestLocale;
if (!locales.includes(locale as Locale)) notFound();
return {
locale,
messages: (await import(\`../messages/\${locale}.json\`)).default,
};
});
이렇게 이중으로 검증하는 이유는, 미들웨어의 경로 매칭이랑 실제 페이지 렌더링 시점의 세그먼트 값이 항상 같은 소스를 보장하는 게 아니라서예요. locales 배열에 없는 값이 들어오면 메시지 파일을 동적 import하기 전에 notFound()를 불러서, 존재하지 않는 언어 코드로 빈 페이지가 렌더링되는 상황을 막아요.
다만 이 구조는 언어별로 완전히 분리된 정적 페이지를 전제로 하는 거라, 프리픽스 없이 자동 감지해서 리다이렉트하는 방식(localePrefix: 'as-needed')이랑은 라우팅 동작이 달라요. 어떤 전략이 맞는지는 캐싱 방식이랑 언어별 URL 공유 요구에 따라 다르니까, 이 글은 "always 프리픽스"를 골랐을 때의 동작만 다룰게요.