단일 `ko.json`이 모든 PR의 충돌 진원지일 때 - next-intl 메시지를 도메인으로 쪼개는 설계
1. 도입부 (Why This Matters)
화면에 문구 하나 추가하는 PR이 왜 자꾸 머지 충돌이 날까. 범인은 대개 하나다 — 모든 도메인의 번역이 들어찬 단일 messages/ko.json. 검색·상세·결제·랜딩처럼 서로 다른 기능을 건드리는 PR이 전부 같은 파일의 다른 줄을 편집한다. 파일이 수천 줄로 불어나면 병렬 작업과 릴리스 분기에서 충돌은 상수가 된다.
이 글은 그 단일 파일을 도메인 파일로 쪼갠 설계 기록이다. 단순히 파일을 나누는 게 아니라 — 충돌 면적 축소, 번들러 정적 분석 안전성, 타입 기준(source of truth), 그리고 도메인 추가 비용을 0에 수렴시키는 codegen까지 — 네 가지를 한 번에 푼다. 읽는 데 약 8분, 읽고 나면 당신의 가장 큰 메시지 파일을 어디서부터 쪼갤지 안다.
2. 핵심 개념 (What & Why)
i18n 메시지 분할이란, 한 로케일의 번역을 거대한 단일 JSON 하나가 아니라 도메인 네임스페이스별 파일 여러 개로 나누는 것이다. 네임스페이스는 메시지의 최상위 키다 — Search, Detail, Billing 같은. useTranslations('Search')가 그 네임스페이스를 집어 쓴다.
비유하면, 회사 전체가 한 권의 전화번호부를 공유하다가 부서별 수첩으로 나누는 것이다. 누가 영업부 번호를 고쳐도 개발부 페이지와 충돌하지 않는다.
왜 이제야 나누나? 다국어 인프라(next-intl)는 처음부터 깔지만, 실제 출하 로케일이 ko 하나여도 — 아니 하나이기 때문에 — 단일 파일은 더 빨리 비대해진다. 한국어 문구가 전부 한 파일에 쌓이기 때문이다.
"그냥 파일 import만 나누면 되지 않나?"와는 다르다. 핵심은 세 가지다: (1) 번들러가 무엇을 포함할지 정적으로 알게 하고, (2) 키 오타를 타입으로 잡고, (3) 도메인이 늘 때 사람이 로더를 손대지 않게 codegen에 떠넘기는 것.
3. 동작 원리 (How It Works)

파이프라인은 런타임 트랙과 빌드 타임 타입 트랙으로 갈린다.
- 분리 —
messages/ko/{Domain}.json(런타임 값)과messages/en/{Domain}.json(타입 기준)을 도메인별로 둔다. 한 프로젝트에서는 16개 도메인 네임스페이스로 나뉘었고, 1 도메인 = 1 slice = 1 PR로 작업 배분이 그대로 매핑됐다. - codegen —
pnpm gen:i18n이 도메인 파일을 스캔해 (a) 로케일별 로더 맵과 (b) 전체Messages타입을 자동 생성한다. - 로더 병합 —
getRequestConfig가 활성 로케일의 도메인 파일들을 병렬 로드해 하나의messages객체로 합친다. - 타입 등록 — 생성된 타입을 next-intl 4의
AppConfig에 등록하면useTranslations/getMessages가 키를 검증한다. - 런타임 — 화면은 ko 값만 받는다. en은 빌드 타임 타입 기준으로만 존재한다.
정적 분석이 왜 핵심인가. 도메인 파일을 import.meta.glob이나 디렉터리 동적 import로 끌어오면 번들러(webpack/Turbopack)가 무엇을 번들에 넣을지 컴파일 타임에 확정하지 못한다. 그래서 codegen이 명시 import(() => import('.../Search.json'))를 나열한 로더를 만든다. 사람이 쓰면 귀찮은 그 나열을 도구가 대신한다. 참고로 feature 폴더 co-location(컴포넌트 옆에 메시지를 두는 안)도 같은 동적 import 정적분석 이슈로 기각됐다.
en이 왜 타입 기준인가. 런타임은 ko-only지만 키의 정답 집합은 en 파일이 쥔다. 파일명이 곧 네임스페이스이므로(Search.json → 'Search'), en 도메인 파일들을 네임스페이스별로 합친 형태가 곧 전체 Messages 타입이다. 따라서 en에는 모든 키가 존재해야 하고(실제 영문 값), ko는 그 키들을 런타임 값으로 채운다. 이 결정은 "en/jp 기계번역은 출하 품질에 미달 → 런타임은 ko-only, en은 타입 기준으로만 존재"라는 결정 기록(ADR)으로 못 박혔다.
네임스페이스 경계는 어떻게 나누나. 라우트·기능 경계를 그대로 따른다 — 검색, 상세, 결제처럼 한 화면군이 함께 바뀌는 단위가 한 도메인이다. 공통 버튼·검증 메시지처럼 여러 화면이 쓰는 문구는 Common 한 곳에 모은다. 경계가 곧 PR 경계가 되므로, "이 문구를 누가 언제 같이 고치나"를 기준으로 잡으면 충돌이 더 줄어든다. 경계가 애매한 문구는 일단 도메인에 두고, 두 번째 화면이 같은 문구를 찾을 때 Common으로 올리면 된다.
4. 실무 적용 (Practical Examples)
✅ 권장 패턴 (Good Practice)
codegen이 만든 registry를 단일 진실로 삼고, 로더와 타입을 한곳에서 가져온다.
// src/i18n/messageRegistry.generated.ts — pnpm gen:i18n 산출물 (직접 수정 금지)
import Landing from '../../messages/en/Landing.json';
import Search from '../../messages/en/Search.json';
import Detail from '../../messages/en/Detail.json';
// 런타임 로더: 로케일 → 네임스페이스 → 동적 import (명시 나열 = 정적 분석 안전)
export const messageLoaders = {
ko: {
Landing: () => import('../../messages/ko/Landing.json'),
Search: () => import('../../messages/ko/Search.json'),
Detail: () => import('../../messages/ko/Detail.json'),
},
} as const;
// 타입 기준은 en — 파일명이 곧 네임스페이스
export type AppMessages = {
Landing: typeof Landing;
Search: typeof Search;
Detail: typeof Detail;
};
// src/global.ts — next-intl 4 타입 등록 (전역 IntlMessages 대신 AppConfig)
import type { AppMessages } from '@/i18n/messageRegistry.generated';
declare module 'next-intl' {
interface AppConfig {
Messages: AppMessages; // en 기준 타입이 곧 키 검증 기준
}
}
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing'; // defineRouting({ locales: ['ko'], defaultLocale: 'ko' })
import { messageLoaders } from './messageRegistry.generated';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
// 도메인 파일들을 병렬 로드 → 네임스페이스 키로 하나의 messages 객체에 병합
const loaders = messageLoaders[locale] ?? messageLoaders[routing.defaultLocale];
const messages: Record<string, unknown> = {};
await Promise.all(
Object.entries(loaders).map(async ([ns, load]) => {
messages[ns] = (await load()).default;
}),
);
return { locale, messages };
});
❌ 안티패턴 (Anti-Pattern)
// ❌ 1) 동적 glob — 번들러가 무엇을 포함할지 정적으로 알 수 없음
const all = import.meta.glob('../../messages/ko/*.json'); // 트리셰이킹·코드 스플리팅 어긋남
// ❌ 2) ko를 타입 기준으로 → ko에만 있는 키는 통과, en 누락은 못 잡음(런타임 빈 문구)
// Messages: typeof koMessages;
// ❌ 3) 도메인 추가할 때마다 request.ts에서 import 줄을 손으로 추가 → 누락·충돌
import Landing from '.../Landing.json';
import Search from '.../Search.json'; // 새 도메인마다 사람이 한 줄씩… (codegen이 할 일)
🔍 실행 결과
- 새 도메인 추가는
messages/{ko, en}/Billing.json두 파일 +pnpm gen:i18n한 번. 로더와 타입에 자동 반영된다. t('invoice')(네임스페이스Billing)는 자동완성되고, 오타t('invioce')는 빌드 시 타입 에러로 막힌다.- 결제 PR과 검색 PR이 서로 다른 파일을 건드리므로 충돌이 도메인 내부로 갇힌다.
전환기 팁. 거대한 단일 ko.json을 한 번에 못 지운다면, 로더에서 레거시 ko.json(A 구조)과 도메인 파일(B 구조)을 함께 먹이면 된다 — Object.assign({}, legacyKo, domainMessages). 덕분에 대규모 리베이스 브랜치가 아직 머지되지 않은 상태에서도 로더는 양쪽을 다 읽는다. 정리가 끝나면 A를 제거한다.
그리고 클라이맥스. 설계 문서(ADR)에는 처음에 "도메인을 추가할 때 request.ts 로더를 손으로 수정하는 비용은 감수한다"라고 적혀 있었다. 그 감수한 비용을 이후 codegen이 통째로 없앴다. 트레이드오프를 명시해 두면, 다음 라운드에서 그걸 도구로 지울 수 있다 — 이게 설계 기록을 남기는 진짜 이유다.
5. 장단점 및 고려사항
| 장점 | 단점 |
|---|---|
| ✓ 충돌 면적을 도메인 파일 내부로 가둠 (병렬 PR 안전) | ✗ 빌드/CI에 codegen 스텝이 하나 늘어남 |
| ✓ 명시 import 로더 → 번들러 정적 분석·코드 스플리팅 안전 | ✗ 런타임에 안 써도 en 키를 항상 유지해야 함 |
| ✓ en 기준 타입으로 키 오타를 컴파일 타임에 차단 | ✗ 전환기에는 A/B 구조 혼재를 잠시 관리 |
✓ 도메인 추가 비용 ≈ pnpm gen:i18n 한 번 |
✗ 생성 파일 직접 수정 금지 규율이 필요 |
| ✓ 1 slice = 1 도메인 = 1 PR로 작업 배분이 그대로 매핑 | ✗ 네임스페이스 경계 설계를 처음에 잘 잡아야 함 |
6. 핵심 3줄 요약 (Key Takeaways)
- 메시지 분할의 진짜 동인은 "파일 정리"가 아니라, 단일
ko.json에 집중되던 머지 충돌 면적을 도메인 단위로 가두는 것이다. - 동적 glob 대신 codegen이 생성한 명시 import 로더를 써야 번들러 정적 분석이 깨지지 않는다.
- 런타임이 ko 하나여도 타입 기준은 en이며, codegen이 로더와 타입을 함께 만들어 도메인 추가 비용을 0에 수렴시킨다.
지금 당신의 가장 큰 메시지 파일을 열어 최상위 키(도메인) 별 줄 수를 세보라. 충돌이 가장 잦은 도메인 하나만 별도 파일로 떼고, 로더에 명시 import로 연결하라 — 분할은 거기서 시작한다.
i18n 메시지 구조는 결국 "사람(번역자·동료 개발자)과 시스템(번들러·타입 체커) 사이의 인터페이스"를 설계하는 일이다. 충돌 면적을 줄이고, 키를 타입으로 강제하고, 반복을 도구가 떠안게 하는 것 — 표면이 JSON 파일이든 React 컴포넌트든, 인터페이스를 잘 만드는 craft는 하나다.
참고 자료 (검증 출처)
- next-intl, TypeScript augmentation (
AppConfig에Messages/Formats/Locale등록) — next-intl.dev/docs/workflows/typescript - next-intl, next-intl 4.0 (revamped augmented types: 전역 스코프 →
AppConfig,getMessages타입 반환) — next-intl.dev/blog/next-intl-4-0 - next-intl, Request configuration (
getRequestConfig로 요청별 메시지 로딩·코드 스플리팅) — next-intl.dev/docs/usage/configuration
위 라이브러리 동작은 next-intl 4.x 문서 기준이며, 실제 버전·번들러 설정에 따라 세부는 달라질 수 있다.