blog

vue-i18n 사용 가이드: 번역 리소스부터 복수형·날짜 형식까지

Front-end / Vue#Vue#vue-i18n#i18n#Google Sheets

Vue 3.4, vue-i18n 9.13 기준으로 작성했다.

1. 번역 리소스를 Google Sheet로 관리하기

번역 문구는 기획·디자인과 함께 수정하는 경우가 많아서, Google Sheet에 key | ko | en | ... 형태로 관리하고 빌드 전에 언어별 JSON으로 변환하는 방식을 사용했다.

key              | ko        | en
button_confirm   | 확인       | Confirm
common.gender... | ...       | ...

시트는 서비스 계정에 공유해 두고, 아래 스크립트로 언어별 JSON 파일을 만든다. 실행 전에 Node 버전이 google-spreadsheet 요구 버전 이상인지 확인한다.

// scripts/create-locales.mjs
import fs from 'node:fs';
import { JWT } from 'google-auth-library';
import { GoogleSpreadsheet } from 'google-spreadsheet';
import 'dotenv/config';

const auth = new JWT({
  email: process.env.SHEET_CLIENT_EMAIL,
  // .env에 저장한 키의 \n 이스케이프를 실제 줄바꿈으로 복원
  key: process.env.SHEET_PRIVATE_KEY?.replace(/\\n/g, '\n'),
  scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});

const doc = new GoogleSpreadsheet(process.env.SHEET_ID, auth);
await doc.loadInfo();

const sheet = doc.sheetsByIndex[0];
const rows = await sheet.getRows(); // 헤더 행은 제외하고 반환됨
const langs = sheet.headerValues.filter((header) => header !== 'key');

for (const lang of langs) {
  const messages = Object.fromEntries(
    rows.map((row) => [row.get('key'), row.get(lang) ?? '']),
  );
  fs.writeFileSync(`src/locales/${lang}.json`, JSON.stringify(messages, null, 2));
}
// package.json
{
  "scripts": {
    "translate": "node scripts/create-locales.mjs",
    "prebuild": "npm run translate"
  }
}

생성 파일을 커밋하지 않으려면 .gitignore에 src/locales/*.json을 추가한다.

주의할 점:

  • 서비스 계정 키 같은 비밀값에 VITE_ 접두사를 붙이지 않는다. VITE_ 변수는 클라이언트 코드에서 참조하는 순간 번들에 그대로 포함된다.
  • 시트 키를 common.gender.male처럼 점으로 적으면 평평한(flat) JSON이 만들어진다. vue-i18n은 기본적으로 점을 중첩 경로로 해석하므로, 점이 들어간 키를 쓰려면 스크립트에서 중첩 객체로 바꾸거나 createI18n에 flatJson: true를 준다.

2. 시작하기

2-1. 설치

npm install vue-i18n@9

2-2. 플러그인 등록

Composition API에서 useI18n을 쓰려면 legacy: false를 지정한다. 기본값(legacy: true)에서는 useI18n 호출 시 에러가 난다.

// src/main.js
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import App from './App.vue';

const i18n = createI18n({
  legacy: false,
  locale: 'ko', // 적용할 언어
  fallbackLocale: 'en', // 메시지를 찾지 못했을 때 사용할 언어
  messages: {
    en: { message: 'hello world' },
    ko: { message: '안녕, 세계' },
    ja: { message: 'こんにちは、世界' },
  },
});

createApp(App).use(i18n).mount('#app');

2-3. 언어 선택 컴포넌트

<!-- src/components/SelectLocale.vue -->
<template>
  <select v-model="locale">
    <option v-for="item in availableLocales" :key="item" :value="item">
      {{ locales[item] }}
    </option>
  </select>
</template>

<script setup>
import { watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';

const { locale, availableLocales } = useI18n();

const locales = {
  en: 'English',
  ko: '한국어',
  ja: '日本語',
};

watchEffect(() => {
  localStorage.setItem('locale', locale.value);
});
</script>

useI18n으로 현재 언어(locale)와 사용 가능한 언어 목록(availableLocales)을 가져온다. 선택한 언어는 로컬 스토리지에 저장해 두고, 앱 시작 시 createI18n의 locale 초기값으로 읽어 쓰면 새로고침 후에도 유지된다.

2-4. 번역 사용하기

<!-- src/components/HelloWorld.vue -->
<template>
  <div>
    <SelectLocale />
    <div>언어: {{ locale }}</div>
    <div>메시지: {{ t('message') }}</div>
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n';
import SelectLocale from './SelectLocale.vue';

const { locale, t } = useI18n();
</script>

t('리소스 키')로 현재 언어의 문구를 가져온다. 드롭다운에서 언어를 바꾸면 메시지도 함께 바뀐다.

3. 번역 리소스

3-1. 언어별 파일 분리

번역할 문구가 많아지면 messages에 직접 쓰기보다 언어별 JSON 파일로 분리한다.

// src/locales/ko.json
{
  "message": "안녕, 세계"
}
// src/locales/en.json
{
  "message": "hello world"
}
// src/main.js
import en from './locales/en.json';
import ja from './locales/ja.json';
import ko from './locales/ko.json';

const i18n = createI18n({
  legacy: false,
  locale: 'ko',
  fallbackLocale: 'en',
  messages: { en, ko, ja },
});

3-2. 중첩 키와 동적 키

중첩된 리소스는 점으로 경로를 이어서 가져온다.

// src/locales/ko.json
{
  "common": {
    "gender": {
      "male": "남자",
      "female": "여자"
    }
  }
}
<div>{{ t('common.gender.male') }}</div>
<div>{{ t(`common.gender.${gender}`) }}</div>

3-3. 보간(interpolation)

이름 있는 자리 표시자:

// ko.json
{ "color": "선택한 색상 값은 {color}입니다." }
// en.json
{ "color": "The selected color value is {color}." }
<div>{{ t('color', { color: '#FFFFFF' }) }}</div>

여러 개도 같은 방식이다.

// ko.json
{ "error": "\"{name}\" 업로드에 실패했습니다. {reason} 파일을 다시 선택해주세요." }
<div>{{ t('error', { name: 'favicon.png', reason: '파일이 너무 큽니다.' }) }}</div>

배열을 넘기면 인덱스로 참조한다.

// ko.json
{ "date": "선택한 요일은 {0}, {1}, {2}입니다." }
<div>{{ t('date', ['월요일', '화요일', '토요일']) }}</div>

3-4. 링크 메시지로 문구 재사용

같은 문구를 여러 키에서 쓰려면 @:키로 참조한다.

{
  "button_confirm": "확인",
  "alert_modal_button": "@:button_confirm"
}

3-5. 복수형

영어처럼 단수·복수를 구분하는 언어는 |로 형태를 나눈다. 두 번째 인자에 숫자를 넘기면 복수형 선택에 쓰이고, 그 값은 {n}(또는 {count})으로 보간된다.

// ko.json
{ "car": "자동차 {n}대" }
// en.json
{ "car": "a car | {n} cars" }
<div>{{ t('car', 1) }}</div> <!-- a car / 자동차 1대 -->
<div>{{ t('car', 5) }}</div> <!-- 5 cars / 자동차 5대 -->

0개일 때를 따로 표현하려면 세 가지 형태를 쓴다.

// en.json
{ "car": "no cars | a car | {n} cars" }
t('car', 0) → no cars
t('car', 1) → a car
t('car', 5) → 5 cars

한국어도 "자동차 없음 | 자동차 {n}대 | 자동차 {n}대"처럼 세 형태를 맞추면 0개를 구분해서 표시할 수 있다.

4. 날짜 형식

언어별로 날짜 형식을 다르게 보여주려면 datetimeFormats를 등록하고 d()를 사용한다.

const datetimeFormats = {
  ko: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
  },
  en: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
  },
};

const i18n = createI18n({
  legacy: false,
  locale: 'ko',
  messages,
  datetimeFormats,
});
<div>{{ d(new Date(), 'short') }}</div>
<!-- ko: 2024년 5월 29일 / en: May 29, 2024 -->

형식은 Intl.DateTimeFormat 옵션을 조합해 만들고 이름을 붙여 쓴다. (ECMA-402 DateTimeFormat)

PropertyValues
weekdaynarrow, short, long
eranarrow, short, long
year2-digit, numeric
month2-digit, numeric, narrow, short, long
day2-digit, numeric
dayPeriodnarrow, short, long
hour2-digit, numeric
minute2-digit, numeric
second2-digit, numeric
fractionalSecondDigits1, 2, 3
timeZoneNameshort, long, shortOffset, longOffset, shortGeneric, longGeneric

12시간제(오전/오후)는 hour12: true로 지정한다. 아래는 한국어 출력 기준 예시다.

const ko = {
  // 2024. 05. 29.
  dateDigits: { year: 'numeric', month: '2-digit', day: '2-digit' },
  // 2024년 5월 29일 (수)
  dateWithWeek: { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' },
  // 2024. 05. 29. (수)
  dateDigitsWithWeek: { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' },
  // 24. 05. 29. (수)
  dateDigitsShort: { year: '2-digit', month: '2-digit', day: '2-digit', weekday: 'short' },
  // 2024. 05. 29. 17:50
  dateTimeDigits: {
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hour12: false,
  },
  // 오후 05:50
  timeDigits: { hour: '2-digit', minute: '2-digit', hour12: true },
  // 2024년 5월
  monthShort: { year: 'numeric', month: 'short' },
  // 2024. 05. 29. (수) 오후 05:50
  fullDateTime: {
    year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
    hour: '2-digit', minute: '2-digit', hour12: true,
  },
};