애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 단일 `ko.json`이 모든 PR의 충돌 진원지일 때 - next-intl 메시지를 도메인으로 쪼개는 설계
    Tip 2026. 7. 8. 19:49
    728x90
    반응형

    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)

     

    파이프라인은 런타임 트랙빌드 타임 타입 트랙으로 갈린다.

    1. 분리messages/ko/{Domain}.json(런타임 값)과 messages/en/{Domain}.json(타입 기준)을 도메인별로 둔다. 한 프로젝트에서는 16개 도메인 네임스페이스로 나뉘었고, 1 도메인 = 1 slice = 1 PR로 작업 배분이 그대로 매핑됐다.
    2. codegenpnpm gen:i18n이 도메인 파일을 스캔해 (a) 로케일별 로더 맵과 (b) 전체 Messages 타입을 자동 생성한다.
    3. 로더 병합getRequestConfig가 활성 로케일의 도메인 파일들을 병렬 로드해 하나의 messages 객체로 합친다.
    4. 타입 등록 — 생성된 타입을 next-intl 4의 AppConfig에 등록하면 useTranslations/getMessages가 키를 검증한다.
    5. 런타임 — 화면은 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)

    1. 메시지 분할의 진짜 동인은 "파일 정리"가 아니라, 단일 ko.json에 집중되던 머지 충돌 면적을 도메인 단위로 가두는 것이다.
    2. 동적 glob 대신 codegen이 생성한 명시 import 로더를 써야 번들러 정적 분석이 깨지지 않는다.
    3. 런타임이 ko 하나여도 타입 기준은 en이며, codegen이 로더와 타입을 함께 만들어 도메인 추가 비용을 0에 수렴시킨다.

    지금 당신의 가장 큰 메시지 파일을 열어 최상위 키(도메인) 별 줄 수를 세보라. 충돌이 가장 잦은 도메인 하나만 별도 파일로 떼고, 로더에 명시 import로 연결하라 — 분할은 거기서 시작한다.

    i18n 메시지 구조는 결국 "사람(번역자·동료 개발자)과 시스템(번들러·타입 체커) 사이의 인터페이스"를 설계하는 일이다. 충돌 면적을 줄이고, 키를 타입으로 강제하고, 반복을 도구가 떠안게 하는 것 — 표면이 JSON 파일이든 React 컴포넌트든, 인터페이스를 잘 만드는 craft는 하나다.

    참고 자료 (검증 출처)

    위 라이브러리 동작은 next-intl 4.x 문서 기준이며, 실제 버전·번들러 설정에 따라 세부는 달라질 수 있다.

    반응형

    댓글

Designed by Tistory.