다국어 지원 붙이면서 제일 무서웠던 게 "언어 하나 추가했는데 다른 데는 안 바뀌어있으면 어쩌지" 하는 거였어요. img-compressor 저장소middleware.tsi18n 설정으로 어떻게 정리했는지 보여드릴게요. 이 프로젝트는 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 프리픽스"를 골랐을 때의 동작만 다룰게요.