vue-i18n 사용 가이드: 번역 리소스부터 복수형·날짜 형식까지
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)
| Property | Values |
|---|---|
| weekday | narrow, short, long |
| era | narrow, short, long |
| year | 2-digit, numeric |
| month | 2-digit, numeric, narrow, short, long |
| day | 2-digit, numeric |
| dayPeriod | narrow, short, long |
| hour | 2-digit, numeric |
| minute | 2-digit, numeric |
| second | 2-digit, numeric |
| fractionalSecondDigits | 1, 2, 3 |
| timeZoneName | short, 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,
},
};