튜토리얼//10 최소 읽기 시간

Vue i18n 대규모 운영: 다국어 앱을 위한 Composition API 패턴

Eray Gündoğmuş
공유

Vue i18n은 Composition API와 함께 크게 발전하였습니다. 2020년에 작성된 튜토리얼의 Options API 패턴을 아직도 따르고 있다면, 특히 타입 안전성, 지연 로딩, CDN 제공 메시지 등 많은 이점을 놓치고 있는 것입니다.

이 글은 대규모로 확장 가능한 다국어 Vue 3 애플리케이션 구축을 위한 실용적인 가이드입니다. 컴포넌트별 번역부터 라우트 기반 로케일 전환, Nuxt 3 통합까지 다룹니다. 데모에서만 작동하는 패턴이 아니라 엔터프라이즈 규모에서도 통하는 패턴을 살펴보겠습니다.

Composition API로 vue-i18n 설정하기

Composition API 모드로 시작하십시오. Options API 모드는 하위 호환성을 위해 여전히 존재하지만, 대형 코드베이스를 관리하기 쉽게 만드는 composable 패턴을 제공하지 않습니다.

npm install vue-i18n@9
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'

export type MessageSchema = typeof import('./locales/en.json')

export const i18n = createI18n<[MessageSchema], 'en' | 'fr' | 'de'>({
  legacy: false, // Composition API 모드
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {} // 지연 로딩
  }
})

legacy: false로 설정하는 것이 핵심 단계입니다. 이를 통해 useI18n()과 전체 Composition API 인터페이스가 활성화됩니다.

useI18n(): 핵심 Composable

번역이 필요한 모든 컴포넌트는 useI18n()을 사용합니다. 기본 패턴은 다음과 같습니다.

// src/components/WelcomeHeader.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'

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

<template>
  <h1>{{ t('welcome.heading') }}</h1>
  <p>{{ t('welcome.subheading', { plan: 'Pro' }) }}</p>
</template>

t() 함수는 반응형입니다. locale이 변경되면 모든 t() 호출이 자동으로 재평가됩니다. 즉, 수동 재렌더링 없이 로케일 전환이 자동으로 작동합니다.

전역 스코프 vs. 로컬 스코프

대규모 운영에서 대부분의 팀이 실수하는 부분이 바로 여기입니다. vue-i18n은 두 가지 스코프를 지원하며, 이를 무분별하게 혼용하면 유지 관리 문제가 발생합니다.

전역 스코프 — i18n 인스턴스 레벨에서 정의된 메시지로 어디서나 접근 가능합니다.

// src/i18n/locales/en.json
{
  "nav.home": "Home",
  "nav.pricing": "Pricing",
  "common.save": "Save",
  "common.cancel": "Cancel"
}

로컬 스코프 — 특정 컴포넌트에 한정된 메시지로 컴포넌트 자체에 정의됩니다.

// src/components/BillingForm.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n({
  messages: {
    en: {
      title: 'Billing Information',
      cardNumber: 'Card number',
      expiryDate: 'Expiry date',
      errors: {
        invalidCard: 'Please check your card number'
      }
    },
    fr: {
      title: 'Informations de facturation',
      cardNumber: 'Numéro de carte',
      expiryDate: "Date d'expiration",
      errors: {
        invalidCard: 'Veuillez vérifier votre numéro de carte'
      }
    }
  }
})
</script>

기본 원칙: 앱 전반에 걸쳐 나타나는 네비게이션, 공통 액션, 오류 메시지는 전역 스코프에 둡니다. 기능별 UI 문구는 컴포넌트와 함께 위치한 로컬 스코프에 둡니다.

로컬 스코프 메시지는 전역 네임스페이스를 오염시키지 않으며, 어떤 번역이 어떤 기능에 속하는지 명확하게 합니다. 컴포넌트를 삭제하면 해당 번역도 함께 삭제됩니다.

TypeScript 타입 안전성

타입 안전성 없이는 i18n이 런타임 오류 문제가 됩니다. 번역 키가 누락되어 있어도 사용자가 프로덕션에서 빈 문자열을 볼 때까지 알 수 없습니다.

vue-i18n은 완전한 TypeScript 통합을 지원합니다. 설정에는 타입 선언이 필요합니다.

// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enMessages from './locales/en.json'

type MessageSchema = typeof enMessages

declare module 'vue-i18n' {
  export interface DefineLocaleMessage extends MessageSchema {}
}

export const i18n = createI18n<false, MessageSchema>({
  legacy: false,
  locale: 'en',
  messages: {
    en: enMessages
  }
})

이제 t()가 완전히 타입화됩니다. 존재하지 않는 키를 전달하면 TypeScript가 컴파일 시점에 이를 잡아냅니다.

const { t } = useI18n()

t('nav.home')        // OK
t('nav.homee')       // TypeScript 오류: '"nav.homee"' 타입의 인수는 할당 불가
t('welcome.heading') // OK — 스키마에 존재하는 경우

대규모 운영에서 이것은 i18n이 유지 관리 부담이 되느냐 아니면 눈에 띄지 않게 작동하느냐의 차이입니다. IDE에서 키 자동 완성이 가능하고, 오타는 프로덕션이 아닌 빌드 시점에 실패합니다.

로케일 지연 로딩

모든 로케일을 처음부터 로드하는 것은 소규모 앱에서는 괜찮습니다. 하지만 20개 이상의 언어와 수천 개의 키를 가진 엔터프라이즈 앱에서는 적합하지 않습니다. 해결책은 지연 로딩입니다. 활성 로케일만 필요할 때 로드합니다.

// src/i18n/loader.ts
import { i18n } from './index'

const loadedLocales = new Set<string>(['en']) // en은 초기화 시 로드됨

export async function loadLocale(locale: string): Promise<void> {
  if (loadedLocales.has(locale)) return

  const messages = await import(`./locales/${locale}.json`)
  i18n.global.setLocaleMessage(locale, messages.default)
  loadedLocales.add(locale)
}

export async function setLocale(locale: string): Promise<void> {
  await loadLocale(locale)
  i18n.global.locale.value = locale as any
  document.documentElement.setAttribute('lang', locale)
}

로케일 전환 컴포넌트에서 이를 사용합니다.

// src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { setLocale } from '@/i18n/loader'

const locales = [
  { code: 'en', label: 'English' },
  { code: 'fr', label: 'Français' },
  { code: 'de', label: 'Deutsch' },
]

async function handleChange(code: string) {
  await setLocale(code)
}
</script>

<template>
  <select @change="e => handleChange((e.target as HTMLSelectElement).value)">
    <option v-for="l in locales" :key="l.code" :value="l.code">
      {{ l.label }}
    </option>
  </select>
</template>

각 로케일 번들은 별도의 청크입니다. 사용자는 자신이 사용하는 언어만 다운로드합니다.

라우트 기반 로케일 전환

SEO가 중요한 앱의 경우 로케일은 URL에 포함되어야 합니다 — /en/pricing, /fr/tarifs. 이 방식은 각 로케일에 고유한 인덱싱 가능한 URL을 제공하고 검색 엔진이 사이트의 언어 구조를 이해할 수 있게 합니다.

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { setLocale } from '@/i18n/loader'

const SUPPORTED_LOCALES = ['en', 'fr', 'de']

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/:locale(en|fr|de)',
      component: () => import('@/layouts/LocaleLayout.vue'),
      children: [
        { path: '', component: () => import('@/pages/Home.vue') },
        { path: 'pricing', component: () => import('@/pages/Pricing.vue') },
        { path: 'features', component: () => import('@/pages/Features.vue') },
      ]
    },
    {
      path: '/:pathMatch(.*)*',
      redirect: to => `/en/${to.params.pathMatch}`
    }
  ]
})

router.beforeEach(async (to) => {
  const locale = to.params.locale as string
  if (SUPPORTED_LOCALES.includes(locale)) {
    await setLocale(locale)
  }
})

export default router

로케일 레이아웃 컴포넌트는 SEO를 위한 <link rel="alternate" hreflang> 태그를 처리합니다.

// src/layouts/LocaleLayout.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useHead } from '@vueuse/head'

const route = useRoute()
const locale = route.params.locale as string
const LOCALES = ['en', 'fr', 'de']

useHead({
  htmlAttrs: { lang: locale },
  link: LOCALES.map(l => ({
    rel: 'alternate',
    hreflang: l,
    href: `https://example.com/${l}${route.path.replace(`/${locale}`, '')}`
  }))
})
</script>

<template>
  <RouterView />
</template>

복수형 처리와 서식 지정

복수형 처리는 단순한 i18n 구현이 무너지는 곳입니다. 영어는 두 가지 복수형을 가집니다. 아랍어는 여섯 가지입니다. 러시아어는 숫자의 마지막 자릿수에 따라 다른 형태를 사용합니다. vue-i18n은 올바르게 사용하면 이를 정확하게 처리합니다.

복수형 처리:

// en.json
{
  "items": "no items | one item | {count} items"
}

// ru.json — 러시아어 복수형 규칙
{
  "items": "нет элементов | {count} элемент | {count} элемента | {count} элементов"
}
const { t, n, d } = useI18n()

// 복수형 처리
t('items', 0)   // "no items"
t('items', 1)   // "one item"
t('items', 42)  // "42 items"

// 명명된 형식으로 숫자 서식 지정
n(1234567.89, 'currency') // en에서 "$1,234,567.89", fr에서 "1 234 567,89 €"

i18n 인스턴스 레벨에서 명명된 형식을 정의합니다.

export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  numberFormats: {
    en: {
      currency: { style: 'currency', currency: 'USD', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 }
    },
    fr: {
      currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 }
    }
  },
  datetimeFormats: {
    en: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
    },
    fr: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
    }
  }
})

이제 n()d()는 컴포넌트별 로직 없이 각 로케일에 맞게 올바르게 서식을 지정합니다.

CDN 제공 메시지 vs. 번들된 JSON

JSON 로케일 파일을 앱에 번들하는 것은 실질적인 트레이드오프가 있습니다. 모든 번역 업데이트에 재배포가 필요합니다. 번역을 자주 업데이트하는 팀 — 문구 A/B 테스트 실행, 오류 수정, 새 시장 로컬라이제이션 — 에게는 이것이 의미 있는 제약입니다.

대안은 CDN 제공 메시지입니다. 번역 파일이 CDN에 위치하고 앱이 런타임에 이를 가져옵니다. 이는 모든 Vue 설정 — Nuxt 3, Vite SPA, 또는 커스텀 서버 렌더링 — 에서 작동합니다.

// src/i18n/cdn-loader.ts
const CDN_BASE = 'https://cdn.example.com/i18n'

export async function loadLocaleFromCDN(locale: string): Promise<Record<string, string>> {
  const res = await fetch(`${CDN_BASE}/${locale}.json`, {
    headers: { 'Cache-Control': 'max-age=3600' }
  })
  if (!res.ok) throw new Error(`Failed to load locale: ${locale}`)
  return res.json()
}

트레이드오프는 로케일 로드 시 네트워크 요청이 발생한다는 것입니다. 적절한 캐싱이 설정된 CDN의 경우, 첫 로드 이후에는 일반적으로 캐시 히트가 됩니다. 장점은 코드 배포 파이프라인을 건드리지 않고 번역 팀이 즉시 문구 업데이트를 배포할 수 있다는 것입니다.

Better i18n에서는 CDN 전송이 플랫폼에 내장되어 있습니다. 번역은 검토 워크플로를 거쳐 게시되고, 재배포 없이 CDN을 통해 전 세계에 즉시 제공됩니다 — 오타 수정을 위한 PR도 필요 없습니다. 기능 페이지에서 전송 파이프라인이 어떻게 작동하는지 다루며, Vue 프로젝트에 대해 평가 중이라면 개발자 페이지에 SDK 통합 예제가 있습니다.

Nuxt 3 통합

Nuxt 3는 vue-i18n을 래핑하고 서버 사이드 렌더링, 라우트 생성, SEO 유틸리티를 추가하는 @nuxtjs/i18n을 통해 i18n을 일급으로 지원합니다.

npm install @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', iso: 'en-US', file: 'en.json' },
      { code: 'fr', iso: 'fr-FR', file: 'fr.json' },
      { code: 'de', iso: 'de-DE', file: 'de.json' }
    ],
    defaultLocale: 'en',
    langDir: 'i18n/locales/',
    lazy: true,
    strategy: 'prefix_except_default',
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root'
    }
  }
})

strategy: 'prefix_except_default'를 사용하면 영어 라우트에는 접두사가 없고(/pricing) 다른 로케일에는 있습니다(/fr/tarifs). Nuxt가 자동으로 라우트 생성을 처리합니다.

컴포넌트에서 useI18n()은 Nuxt가 아닌 앱과 동일하게 작동합니다.

// pages/pricing.vue
<script setup lang="ts">
const { t } = useI18n()

useSeoMeta({
  title: t('pricing.meta.title'),
  description: t('pricing.meta.description')
})
</script>

SSR 통합은 번역된 콘텐츠가 초기 HTML에 포함되도록 합니다 — 번역되지 않은 콘텐츠가 깜빡이지 않으며 검색 엔진이 로컬라이즈된 버전을 볼 수 있습니다.

개발자 워크플로 확장하기

번역 범위가 커지면서 병목 현상은 기술적 설정에서 워크플로로 이동합니다. 대규모 운영에서 유효한 몇 가지 패턴을 소개합니다.

번역 키는 위치가 아닌 의미론적으로 유지하십시오. nav.pricinglink3보다 오래 유효합니다. 네비게이션이 재설계되면 link3은 의미 없어집니다. nav.pricing은 여전히 올바릅니다.

누락된 번역을 빌드 오류로 처리하십시오. 키가 기본 로케일에는 존재하지만 대상 로케일에 누락된 경우 빌드가 경고 또는 실패하도록 구성하십시오. 사용자보다 먼저 발견하십시오.

번역 추출을 자동화하십시오. CI의 일부로 vue-i18n-extract 또는 유사한 도구를 실행하면 모든 새 문자열이 배포 전에 번역 대기로 플래그됩니다.

번역 검토를 코드 리뷰에서 분리하십시오. 번역 품질은 코드 품질과는 다른 기술입니다. 번역가가 오타를 수정하기 위해 PR을 열어야 한다면 불필요한 마찰을 만든 것입니다. 언어 검토가 Git이 아닌 언어로 이루어지는 번역 플랫폼을 사용하십시오.

이 글에서 설명한 기술적 기반은 엔지니어링 측면을 처리합니다. 워크플로 측면 — 번역 검토 받기, 번역가 액세스 관리, 언어 전반의 용어집 일관성 유지 — 은 전용 도구가 불균형적으로 큰 가치를 발휘하는 곳입니다.

Comments

Loading comments...