크롬 확장 다국어(i18n) 구현하기: 런타임 언어 전환까지

크롬 확장에서 다국어를 지원하려면 Chrome i18n API만으로는 부족합니다. 런타임 언어 전환, 선언적 번역 적용, 여러 페이지 동기화까지 지원하는 커스텀 i18n 모듈을 구현했습니다.

크롬 확장 다국어(i18n) 구현하기: 런타임 언어 전환까지

Chrome i18n API의 한계를 넘어, 페이지 새로고침 없이 언어를 전환하는 커스텀 i18n 모듈 구현

작성일: 2026-01-23
프로젝트: 사이드 프로젝트 (크롬 확장 프로그램)
기술 스택: Chrome Extension Manifest V3, JavaScript, Chrome Storage API
키워드: 크롬 확장 다국어, chrome.i18n, i18n 구현, 런타임 언어 전환, 다국어 지원


1. 문제 상황

요구사항: 다국어 지원

크롬 확장 프로그램을 개발하면서 다국어 지원이 필요해졌습니다. 최소한 영어와 한국어를 지원해야 하고, 사용자가 언어를 선택할 수 있어야 합니다.

Chrome i18n API: 공식 솔루션

크롬 확장에는 공식 i18n API가 있습니다:

extension/
  _locales/
    en/
      messages.json
    ko/
      messages.json
  manifest.json
// _locales/en/messages.json
{
  "appName": {
    "message": "My Extension",
    "description": "Extension name"
  },
  "buttonSave": {
    "message": "Save",
    "description": "Save button text"
  }
}
// 사용법
chrome.i18n.getMessage('appName');  // "My Extension"

Chrome i18n API의 한계

공식 API를 테스트해보니 몇 가지 한계가 있었습니다:

1. 런타임 언어 전환 불가

// Chrome i18n은 브라우저 언어 설정을 따름
chrome.i18n.getUILanguage();  // "ko" (변경 불가)

// 사용자가 앱 내에서 영어로 바꾸고 싶어도
// 브라우저 전체 언어를 바꿔야 함

2. 페이지 새로고침 필요

// 언어 변경 후 반영하려면
location.reload();  // 전체 페이지 새로고침 필요

3. HTML에서 직접 사용 불가

<!-- 이런 문법 지원 안 함 -->
<button>__MSG_buttonSave__</button>

<!-- manifest.json과 CSS에서만 __MSG_key__ 사용 가능 -->
// HTML 텍스트는 JS에서 일일이 설정해야 함
document.getElementById('saveBtn').textContent =
  chrome.i18n.getMessage('buttonSave');

목표: 더 나은 i18n

  1. 런타임 언어 전환: 페이지 새로고침 없이 즉시 반영
  2. HTML 선언적 사용: data-i18n 속성으로 간편하게
  3. 자동 감지 + 수동 선택: 브라우저 언어 자동 감지, 사용자 선택 우선
  4. 여러 페이지 동기화: popup, options 등에서 언어 설정 공유

2. 아키텍처 설계

Chrome i18n API vs 커스텀 모듈

기능 Chrome i18n API 커스텀 모듈
런타임 언어 전환 ❌ 불가 ✅ 가능
HTML 선언적 사용 ❌ 제한적 ✅ data-i18n
브라우저 언어 감지 ✅ 자동 ✅ getUILanguage() 활용
manifest.json 번역 ✅ 지원 ❌ 불가
번역 파일 관리 별도 폴더 JS 내장 or 별도 파일
빌드 도구 필요

결론: 두 가지를 조합하여 사용

  • Chrome i18n API: manifest.json의 확장 이름/설명 번역
  • 커스텀 모듈: 앱 내 UI 텍스트 번역 (런타임 전환 지원)

전체 구조

extension/
  _locales/               # Chrome i18n (manifest용)
    en/messages.json
    ko/messages.json
  shared/
    i18n.js               # 커스텀 i18n 모듈
  popup/
    popup.html            # data-i18n 속성 사용
    popup.js              # i18n.init() 호출
  options/
    options.html
    options.js

3. 커스텀 i18n 모듈 구현

3.1 기본 구조

// shared/i18n.js
const i18n = (function () {
  'use strict';

  // 번역 메시지 저장소
  const messages = {
    en: {
      appName: 'My Extension',
      buttonSave: 'Save',
      buttonCancel: 'Cancel',
      settingsTitle: 'Settings',
      // ... 더 많은 메시지
    },
    ko: {
      appName: '내 확장 프로그램',
      buttonSave: '저장',
      buttonCancel: '취소',
      settingsTitle: '설정',
      // ... 더 많은 메시지
    },
  };

  let currentLang = 'en';  // 현재 언어

  // 번역 가져오기
  function t(key) {
    return messages[currentLang]?.[key]
        || messages.en[key]  // fallback to English
        || key;              // fallback to key itself
  }

  return { t };
})();

3.2 Fallback 체인

번역이 없을 때를 대비한 fallback 전략:

function t(key) {
  // 1. 현재 언어에서 찾기
  const current = messages[currentLang]?.[key];
  if (current) return current;

  // 2. 기본 언어(영어)에서 찾기
  const fallback = messages.en?.[key];
  if (fallback) return fallback;

  // 3. 키 자체를 반환 (개발 중 누락 발견용)
  console.warn(`[i18n] Missing translation: ${key}`);
  return key;
}

Fallback이 중요한 이유:

// 한국어 번역 누락 시
messages.ko.newFeature = undefined;
messages.en.newFeature = 'New Feature';

i18n.t('newFeature');  // "New Feature" (영어로 fallback)

// 둘 다 누락 시
i18n.t('typoKey');  // "typoKey" (키 자체 반환, 디버깅에 유용)

3.3 HTML 선언적 번역 (data-i18n)

JS에서 일일이 텍스트를 설정하는 대신, HTML에서 선언적으로 사용:

<!-- HTML -->
<h1 data-i18n="settingsTitle">Settings</h1>
<button data-i18n="buttonSave">Save</button>
<input data-i18n-placeholder="searchPlaceholder" placeholder="Search...">
<button data-i18n-title="tooltipHelp" title="Help">?</button>
// i18n.js - 번역 적용 함수
function applyTranslations() {
  // 텍스트 콘텐츠 번역
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    const key = el.getAttribute('data-i18n');
    const text = t(key);
    if (text) {
      el.textContent = text;
    }
  });

  // placeholder 번역
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    const key = el.getAttribute('data-i18n-placeholder');
    const text = t(key);
    if (text) {
      el.placeholder = text;
    }
  });

  // title(툴팁) 번역
  document.querySelectorAll('[data-i18n-title]').forEach((el) => {
    const key = el.getAttribute('data-i18n-title');
    const text = t(key);
    if (text) {
      el.title = text;
    }
  });

  // 페이지 타이틀 번역
  const titleEl = document.querySelector('title[data-i18n]');
  if (titleEl) {
    const key = titleEl.getAttribute('data-i18n');
    document.title = t(key);
  }
}

장점:

  1. HTML만 보고 어떤 텍스트가 번역되는지 파악 가능
  2. 번역 키 변경 시 HTML만 수정
  3. 기본값(영어)이 HTML에 있어서 번역 로드 전에도 표시됨

3.4 브라우저 언어 자동 감지

Chrome i18n API의 getUILanguage()를 활용:

function detectBrowserLanguage() {
  // Chrome 확장 환경
  if (typeof chrome !== 'undefined' && chrome.i18n) {
    const browserLang = chrome.i18n.getUILanguage();
    // "ko", "ko-KR", "en", "en-US" 등의 형태

    // 한국어 계열이면 'ko', 아니면 'en'
    return browserLang.startsWith('ko') ? 'ko' : 'en';
  }

  // 일반 웹 환경 (테스트용)
  return navigator.language.startsWith('ko') ? 'ko' : 'en';
}

3.5 사용자 설정 저장 및 로드

사용자가 선택한 언어를 chrome.storage에 저장:

const SETTINGS_KEY = 'settings';

// 언어 설정 가져오기
async function getLanguage() {
  try {
    const data = await chrome.storage.local.get(SETTINGS_KEY);
    const settings = data[SETTINGS_KEY] || {};
    const saved = settings.language;

    // 저장된 설정이 있고, 'auto'가 아니면 그대로 사용
    if (saved && saved !== 'auto') {
      return saved;
    }

    // 'auto'이거나 설정 없으면 브라우저 언어 감지
    return detectBrowserLanguage();
  } catch (e) {
    console.error('[i18n] Failed to get language:', e);
    return 'en';  // 에러 시 영어로 fallback
  }
}

// 언어 설정 저장
async function saveLanguage(lang) {
  const data = await chrome.storage.local.get(SETTINGS_KEY);
  const settings = data[SETTINGS_KEY] || {};
  settings.language = lang;
  await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
}

4. 런타임 언어 전환

4.1 즉시 반영

언어 변경 시 페이지 새로고침 없이 즉시 반영:

async function setLanguage(lang) {
  if (lang === 'auto') {
    currentLang = detectBrowserLanguage();
  } else {
    currentLang = lang;
  }

  // DOM에 번역 즉시 적용
  applyTranslations();
}

4.2 여러 페이지 동기화

popup에서 언어를 바꾸면 options 페이지에도 반영되어야 합니다:

// 초기화 시 storage 변경 리스너 등록
async function init() {
  // 저장된 언어 로드
  currentLang = await getLanguage();
  applyTranslations();

  // 다른 페이지에서 언어 변경 시 감지  ← 핵심
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes[SETTINGS_KEY]) {
      const newSettings = changes[SETTINGS_KEY].newValue || {};
      if (newSettings.language) {
        setLanguage(newSettings.language);
      }
    }
  });

  return currentLang;
}

동작 흐름:

[Options 페이지]                    [Popup 페이지]
     |                                   |
  언어 변경 (ko → en)                    |
     |                                   |
  storage.set({language: 'en'})          |
     |                                   |
     +------- storage.onChanged -------->|
                                         |
                                    setLanguage('en')
                                         |
                                    applyTranslations()
                                         |
                                    UI 즉시 업데이트

4.3 언어 선택 UI

<!-- options.html -->
<select id="languageSelect">
  <option value="auto">Auto (Browser Default)</option>
  <option value="en">English</option>
  <option value="ko">한국어</option>
</select>
// options.js
const languageSelect = document.getElementById('languageSelect');

// 초기값 설정
async function initLanguageSelect() {
  const settings = await chrome.storage.local.get('settings');
  const lang = settings.settings?.language || 'auto';
  languageSelect.value = lang;
}

// 변경 이벤트
languageSelect.addEventListener('change', async () => {
  const lang = languageSelect.value;

  // 설정 저장
  const data = await chrome.storage.local.get('settings');
  const settings = data.settings || {};
  settings.language = lang;
  await chrome.storage.local.set({ settings });

  // 현재 페이지에 즉시 적용
  i18n.setLanguage(lang);
});

// 초기화
initLanguageSelect();

5. 동적 텍스트 처리

5.1 플레이스홀더 치환

번역 메시지에 동적 값을 삽입해야 할 때:

// 번역 메시지
messages.en.itemCount = '$1 items selected';
messages.ko.itemCount = '$1개 선택됨';

// 치환 함수
function t(key, ...args) {
  let msg = messages[currentLang]?.[key] || messages.en[key] || key;

  // $1, $2, ... 치환
  args.forEach((arg, i) => {
    msg = msg.replace(`$${i + 1}`, arg);
  });

  return msg;
}

// 사용
i18n.t('itemCount', 5);  // "5 items selected" 또는 "5개 선택됨"

5.2 복수형 처리 (간단한 방식)

// 영어는 단수/복수 구분 필요
messages.en.itemCount = '$1 item(s) selected';

// 또는 별도 키로 관리
messages.en.itemCountSingle = '1 item selected';
messages.en.itemCountMultiple = '$1 items selected';

function tPlural(keySingle, keyMultiple, count) {
  const key = count === 1 ? keySingle : keyMultiple;
  return t(key, count);
}

// 사용
tPlural('itemCountSingle', 'itemCountMultiple', 1);  // "1 item selected"
tPlural('itemCountSingle', 'itemCountMultiple', 5);  // "5 items selected"

5.3 JavaScript에서 동적 번역

data-i18n으로 처리할 수 없는 동적 콘텐츠:

// 토스트 메시지
function showToast(messageKey) {
  const text = i18n.t(messageKey);
  // 토스트 표시 로직...
}

showToast('toastSaved');  // "Saved" 또는 "저장됨"

// 에러 메시지
function showError(errorKey, details) {
  const text = i18n.t(errorKey, details);
  alert(text);
}

showError('errorRateLimit', 30);  // "Too many attempts. Try again in 30s."

6. Chrome i18n API 병행 사용

6.1 manifest.json 번역

manifest.json의 확장 이름과 설명은 Chrome i18n API로만 번역 가능:

// manifest.json
{
  "name": "__MSG_extName__",
  "description": "__MSG_extDescription__",
  "default_locale": "en",
  // ...
}
// _locales/en/messages.json
{
  "extName": {
    "message": "My Extension"
  },
  "extDescription": {
    "message": "A helpful browser extension"
  }
}

// _locales/ko/messages.json
{
  "extName": {
    "message": "내 확장 프로그램"
  },
  "extDescription": {
    "message": "유용한 브라우저 확장 프로그램"
  }
}

6.2 번역 파일 동기화 전략

Chrome i18n과 커스텀 모듈의 번역을 동기화하는 방법:

방법 1: 커스텀 모듈을 Single Source of Truth로

// shared/i18n.js 내 messages 객체가 원본
// _locales/*.json은 manifest용으로만 최소한 유지

방법 2: _locales를 Single Source of Truth로

// _locales/en/messages.json을 빌드 시 i18n.js로 변환
// 단점: 빌드 스텝 필요

방법 3: 독립적으로 관리 (권장)

_locales/
  en/messages.json    # manifest용 (extName, extDescription만)
  ko/messages.json

shared/
  i18n.js             # 앱 UI용 (나머지 모든 메시지)

manifest용 번역은 거의 변경되지 않으므로 독립 관리가 실용적입니다.


7. 완성된 i18n 모듈

전체 코드

// shared/i18n.js
const i18n = (function () {
  'use strict';

  // ============================================
  // 번역 메시지
  // ============================================
  const messages = {
    en: {
      // 공통
      appName: 'My Extension',
      buttonSave: 'Save',
      buttonCancel: 'Cancel',
      buttonDelete: 'Delete',
      buttonConfirm: 'Confirm',

      // 설정
      settingsTitle: 'Settings',
      settingsLanguage: 'Language',
      settingsLanguageAuto: 'Auto (Browser Default)',

      // 메시지
      toastSaved: 'Saved successfully',
      toastDeleted: 'Deleted',
      toastError: 'An error occurred',
      errorRateLimit: 'Too many attempts. Try again in $1s.',
      itemCount: '$1 items selected',

      // ... 더 많은 메시지
    },
    ko: {
      // 공통
      appName: '내 확장 프로그램',
      buttonSave: '저장',
      buttonCancel: '취소',
      buttonDelete: '삭제',
      buttonConfirm: '확인',

      // 설정
      settingsTitle: '설정',
      settingsLanguage: '언어',
      settingsLanguageAuto: '자동 (브라우저 기본값)',

      // 메시지
      toastSaved: '저장되었습니다',
      toastDeleted: '삭제되었습니다',
      toastError: '오류가 발생했습니다',
      errorRateLimit: '시도 횟수 초과. $1초 후 다시 시도하세요.',
      itemCount: '$1개 선택됨',

      // ... 더 많은 메시지
    },
  };

  const SETTINGS_KEY = 'settings';
  let currentLang = 'en';

  // ============================================
  // 언어 감지 및 설정
  // ============================================

  function detectBrowserLanguage() {
    if (typeof chrome !== 'undefined' && chrome.i18n) {
      const browserLang = chrome.i18n.getUILanguage();
      return browserLang.startsWith('ko') ? 'ko' : 'en';
    }
    return navigator.language.startsWith('ko') ? 'ko' : 'en';
  }

  async function getLanguage() {
    try {
      const data = await chrome.storage.local.get(SETTINGS_KEY);
      const settings = data[SETTINGS_KEY] || {};
      const saved = settings.language;

      if (saved && saved !== 'auto') {
        return saved;
      }
      return detectBrowserLanguage();
    } catch (e) {
      console.error('[i18n] Failed to get language:', e);
      return 'en';
    }
  }

  // ============================================
  // 번역 함수
  // ============================================

  function t(key, ...args) {
    let msg = messages[currentLang]?.[key]
           || messages.en[key]
           || key;

    // 플레이스홀더 치환 ($1, $2, ...)
    args.forEach((arg, i) => {
      msg = msg.replace(`$${i + 1}`, arg);
    });

    return msg;
  }

  // ============================================
  // DOM 번역 적용
  // ============================================

  function applyTranslations() {
    // 텍스트 콘텐츠
    document.querySelectorAll('[data-i18n]').forEach((el) => {
      const key = el.getAttribute('data-i18n');
      el.textContent = t(key);
    });

    // placeholder
    document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
      const key = el.getAttribute('data-i18n-placeholder');
      el.placeholder = t(key);
    });

    // title (툴팁)
    document.querySelectorAll('[data-i18n-title]').forEach((el) => {
      const key = el.getAttribute('data-i18n-title');
      el.title = t(key);
    });

    // 페이지 타이틀
    const titleEl = document.querySelector('title[data-i18n]');
    if (titleEl) {
      document.title = t(titleEl.getAttribute('data-i18n'));
    }
  }

  // ============================================
  // 언어 전환
  // ============================================

  async function setLanguage(lang) {
    if (lang === 'auto') {
      currentLang = detectBrowserLanguage();
    } else {
      currentLang = lang;
    }
    applyTranslations();
  }

  // ============================================
  // 초기화
  // ============================================

  async function init() {
    currentLang = await getLanguage();
    applyTranslations();

    // 다른 페이지에서 언어 변경 시 동기화
    chrome.storage.onChanged.addListener((changes, area) => {
      if (area === 'local' && changes[SETTINGS_KEY]) {
        const newSettings = changes[SETTINGS_KEY].newValue || {};
        if (newSettings.language) {
          setLanguage(newSettings.language);
        }
      }
    });

    return currentLang;
  }

  // ============================================
  // 유틸리티
  // ============================================

  function getCurrentLang() {
    return currentLang;
  }

  function getAvailableLanguages() {
    return [
      { code: 'auto', name: 'Auto (Browser Default)' },
      { code: 'en', name: 'English' },
      { code: 'ko', name: '한국어' },
    ];
  }

  // ============================================
  // Public API
  // ============================================

  return {
    init,
    t,
    setLanguage,
    getLanguage,
    getCurrentLang,
    getAvailableLanguages,
    applyTranslations,
  };
})();

// 전역 노출
if (typeof window !== 'undefined') {
  window.i18n = i18n;
}

사용 예시

<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
  <title data-i18n="appName">My Extension</title>
  <script src="../shared/i18n.js"></script>
</head>
<body>
  <h1 data-i18n="settingsTitle">Settings</h1>

  <input
    type="text"
    data-i18n-placeholder="searchPlaceholder"
    placeholder="Search..."
  >

  <button data-i18n="buttonSave">Save</button>
  <button data-i18n="buttonCancel">Cancel</button>

  <script src="popup.js"></script>
</body>
</html>
// popup.js
document.addEventListener('DOMContentLoaded', async () => {
  // i18n 초기화 (번역 적용 + 리스너 등록)
  await i18n.init();

  // 이제 동적 번역도 사용 가능
  showToast(i18n.t('toastSaved'));
});

8. 베스트 프랙티스

DO (해야 할 것)

  • [x] 모든 UI 텍스트를 번역 키로 관리
  • [x] HTML에 data-i18n 속성으로 선언적 번역
  • [x] 영어를 기본(fallback) 언어로 설정
  • [x] chrome.storage.onChanged로 페이지 간 동기화
  • [x] 플레이스홀더($1, $2)로 동적 값 처리
  • [x] 번역 누락 시 콘솔 경고 출력 (개발 중 발견용)

DON'T (하지 말아야 할 것)

  • [ ] 하드코딩된 텍스트 방치
  • [ ] 언어 전환 시 location.reload() 사용
  • [ ] 번역 파일을 외부 서버에서 로드 (확장 번들에 포함해야 함)
  • [ ] 번역 키에 특수문자 사용 (button-savebuttonSave)
  • [ ] 문장 중간 자르기 (문법 구조가 언어마다 다름)

번역 키 네이밍 규칙

// 좋은 예: 영역 + 요소 + 상태/용도
const goodKeys = {
  buttonSave: 'Save',
  buttonCancel: 'Cancel',
  settingsTitle: 'Settings',
  settingsLanguage: 'Language',
  toastSaved: 'Saved successfully',
  errorRateLimit: 'Too many attempts',
  modalConfirmTitle: 'Confirm',
  modalConfirmText: 'Are you sure?',
};

// 나쁜 예
const badKeys = {
  btn1: 'Save',           // 의미 없는 이름
  'button-save': 'Save',  // 특수문자 사용
  SAVE: 'Save',           // 일관성 없는 케이스
  settingsPageMainTitle: 'Settings',  // 너무 긴 이름
};

번역 품질 체크리스트

  • [ ] 모든 키가 양쪽 언어에 존재하는가?
  • [ ] 플레이스홀더 개수가 일치하는가? ($1, $2)
  • [ ] 맥락에 맞는 번역인가? (같은 단어도 맥락에 따라 다름)
  • [ ] 길이가 비슷한가? (UI 레이아웃 깨짐 방지)
  • [ ] 특수문자, 이모지가 올바르게 표시되는가?

9. 트러블슈팅

문제 1: 번역이 적용되지 않음

증상: data-i18n 속성을 추가했는데 텍스트가 바뀌지 않음

원인 및 해결:

// 원인 1: init()을 호출하지 않음
document.addEventListener('DOMContentLoaded', async () => {
  await i18n.init();  // ← 반드시 호출
});

// 원인 2: 스크립트 로드 순서
// i18n.js가 먼저 로드되어야 함
<!-- 올바른 순서 -->
<script src="../shared/i18n.js"></script>
<script src="popup.js"></script>

문제 2: 언어 변경이 다른 페이지에 반영 안 됨

증상: Options에서 언어 바꿨는데 Popup에는 안 바뀜

원인 및 해결:

// storage.onChanged 리스너가 등록되어 있는지 확인
async function init() {
  // ...

  // 이 리스너가 있어야 함
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes[SETTINGS_KEY]) {
      const newSettings = changes[SETTINGS_KEY].newValue || {};
      if (newSettings.language) {
        setLanguage(newSettings.language);
      }
    }
  });
}

문제 3: 동적으로 추가된 요소가 번역 안 됨

증상: JavaScript로 추가한 요소에 data-i18n이 있는데 번역 안 됨

해결:

// 요소 추가 후 수동으로 번역 적용
function addNewItem() {
  const item = document.createElement('div');
  const span = document.createElement('span');
  span.setAttribute('data-i18n', 'itemLabel');
  span.textContent = 'Label';  // 기본값
  item.appendChild(span);
  container.appendChild(item);

  // 새 요소에 번역 적용  ← 핵심
  i18n.applyTranslations();
}

// 또는 개별 요소만 번역
function addNewItem() {
  const item = document.createElement('div');
  const span = document.createElement('span');
  span.textContent = i18n.t('itemLabel');  // 직접 번역
  item.appendChild(span);
  container.appendChild(item);
}

문제 4: 플레이스홀더가 치환되지 않음

증상: $1 items selected가 그대로 표시됨

원인 및 해결:

// 잘못된 사용
element.textContent = i18n.t('itemCount');  // "$1 items selected"

// 올바른 사용
element.textContent = i18n.t('itemCount', 5);  // "5 items selected"

10. FAQ

Q: Chrome i18n API 대신 커스텀 모듈을 쓰는 이유는?

A: Chrome i18n API는 런타임 언어 전환을 지원하지 않습니다. 사용자가 앱 내에서 언어를 바꾸려면 페이지 새로고침이 필요하고, 그마저도 브라우저 전체 언어 설정을 따르기 때문에 완전한 제어가 불가능합니다. 커스텀 모듈은 즉시 전환과 사용자 선호 저장을 지원합니다.

Q: 번역 메시지를 별도 JSON 파일로 분리해도 되나요?

A: 가능하지만, 크롬 확장에서는 fetch()로 JSON을 로드하는 것보다 JavaScript 객체로 번들링하는 것이 더 안정적입니다. 외부 파일 로드 시 타이밍 이슈, 캐싱 문제, CSP(Content Security Policy) 제약 등이 발생할 수 있습니다.

Q: 새 언어를 추가하려면?

A: messages 객체에 새 언어 키를 추가하고, getAvailableLanguages()에 옵션을 추가하면 됩니다:

const messages = {
  en: { /* ... */ },
  ko: { /* ... */ },
  ja: {  // 일본어 추가
    appName: '私の拡張機能',
    buttonSave: '保存',
    // ...
  },
};

function getAvailableLanguages() {
  return [
    { code: 'auto', name: 'Auto' },
    { code: 'en', name: 'English' },
    { code: 'ko', name: '한국어' },
    { code: 'ja', name: '日本語' },  // 추가
  ];
}

Q: 번역 품질 관리는 어떻게 하나요?

A: 간단한 스크립트로 검증할 수 있습니다:

// 번역 키 검증 스크립트
function validateTranslations() {
  const langs = Object.keys(messages);
  const baseKeys = Object.keys(messages.en);

  langs.forEach(lang => {
    baseKeys.forEach(key => {
      if (!messages[lang][key]) {
        console.warn(`Missing: ${lang}.${key}`);
      }
    });
  });
}

Q: SSR/Prerendering 환경에서는?

A: 크롬 확장은 클라이언트 사이드만 존재하므로 SSR 고려가 필요 없습니다. 일반 웹앱에서 이 패턴을 사용한다면, 초기 HTML에 기본 언어 텍스트를 넣어두고 클라이언트에서 번역을 적용하면 됩니다.


11. 참고 자료


12. 다음 단계

이 글에서는 크롬 확장의 다국어 지원을 런타임 언어 전환까지 구현했습니다. 이 시리즈의 다른 글들도 확인해보세요.

시리즈 목차:

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기
  5. 크롬 확장 다국어(i18n) 구현하기 ← 현재 글