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

SvelteKit i18n: Svelte 5로 타입 안전한 다국어 앱 구축하기

Eray Gündoğmuş
공유

SvelteKit i18n: Svelte 5로 타입 안전한 다국어 앱 구축하기

SvelteKit의 아키텍처는 국제화에 놀랍도록 잘 어울립니다. 서버 사이드 로드 함수가 렌더링 전에 실행되어 로케일 감지와 메시지 로딩이 자연스럽게 맞아 떨어집니다. 레이아웃 계층 구조를 활용한 URL 기반 라우팅 덕분에 프레임워크와 싸우지 않고도 /en/이나 /de/ 같은 접두사를 라우트에 붙일 수 있습니다. 그리고 Svelte 5의 룬(runes) — $state, $derived, $effect — 은 Svelte 4에서 필요했던 보일러플레이트 스토어 없이도 반응형 로케일 전환을 가능하게 합니다.

이 가이드는 Svelte 5로 프로덕션 수준의 다국어 SvelteKit 앱을 구축하는 과정을 안내합니다. 로케일 감지, 타입 안전한 번역 함수, Intl API를 활용한 복수형 처리, SEO 고려사항을 다룹니다. 또한 번역 파일을 번들링하는 방식과 CDN에서 제공하는 방식의 트레이드오프도 솔직하게 살펴보겠습니다.

프로젝트 설정

새로운 SvelteKit 프로젝트(TypeScript 템플릿)로 시작합니다:

npx sv create my-i18n-app
cd my-i18n-app
npm install

이 가이드에서는 의존성을 최소화합니다. 모던 브라우저에 내장된 Intl API가 포맷팅을 처리하고, 얇은 번역 레이어를 직접 작성합니다. ICU 메시지 형식 지원이 필요하다면 @messageformat/core 같은 라이브러리를 추가할 수 있습니다.

목표로 하는 폴더 구조:

src/
  lib/
    i18n/
      index.ts          # Core translation utilities
      types.ts          # Generated/shared types
      locales/
        en.json
        de.json
        fr.json
  hooks.server.ts       # Locale detection
  routes/
    [locale]/
      +layout.server.ts # Load translations
      +layout.svelte    # Provide translations to tree
      +page.svelte

공유 설정 파일에서 지원 로케일을 정의합니다:

// src/lib/i18n/config.ts
export const SUPPORTED_LOCALES = ['en', 'de', 'fr'] as const;
export type Locale = typeof SUPPORTED_LOCALES[number];
export const DEFAULT_LOCALE: Locale = 'en';

export function isSupportedLocale(locale: string): locale is Locale {
  return SUPPORTED_LOCALES.includes(locale as Locale);
}

로케일 감지와 라우팅

SvelteKit의 hooks.server.ts는 라우트가 실행되기 전에 사용자의 로케일을 감지하기에 적합한 곳입니다. URL을 먼저 확인하고, Accept-Language 헤더로 폴백한 다음, 기본값으로 폴백합니다.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { DEFAULT_LOCALE, isSupportedLocale } from '$lib/i18n/config';

export const handle: Handle = async ({ event, resolve }) => {
  // Extract locale from URL path segment
  const pathLocale = event.url.pathname.split('/')[1];

  const locale = isSupportedLocale(pathLocale)
    ? pathLocale
    : detectFromHeader(event.request.headers.get('accept-language'));

  event.locals.locale = locale;

  return resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%lang%', locale),
  });
};

function detectFromHeader(header: string | null): import('$lib/i18n/config').Locale {
  if (!header) return DEFAULT_LOCALE;

  const accepted = header
    .split(',')
    .map((part) => {
      const [lang, q = 'q=1'] = part.trim().split(';');
      return { lang: lang.split('-')[0].trim(), q: parseFloat(q.split('=')[1]) };
    })
    .sort((a, b) => b.q - a.q);

  for (const { lang } of accepted) {
    if (isSupportedLocale(lang)) return lang;
  }

  return DEFAULT_LOCALE;
}

앱의 locals 타입에 locale 필드를 추가합니다:

// src/app.d.ts
import type { Locale } from '$lib/i18n/config';

declare global {
  namespace App {
    interface Locals {
      locale: Locale;
    }
  }
}

URL 기반 라우팅을 위해 최상위 레벨에 [locale] 세그먼트를 만들고, 검증을 위한 params matcher를 추가합니다:

// src/params/locale.ts
import { isSupportedLocale } from '$lib/i18n/config';

export function match(param: string): boolean {
  return isSupportedLocale(param);
}

라우트 디렉토리에서 이 matcher를 참조합니다: src/routes/[locale=locale]/. SvelteKit은 세그먼트가 유효한 로케일 문자열인 경우에만 경로를 매칭합니다.

번역 로딩

[locale] 세그먼트 하위의 +layout.server.ts에서 현재 로케일의 번역을 로드합니다. 이는 페이지 렌더링 전 서버 사이드에서 실행되므로 번역되지 않은 콘텐츠가 번쩍이는 현상이 없습니다.

// src/routes/[locale=locale]/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ params, fetch }) => {
  const locale = params.locale;

  // Option A: Bundled JSON (imported directly)
  const messages = await import(`$lib/i18n/locales/${locale}.json`);

  // Option B: CDN endpoint (no redeploy needed for translation updates)
  // const res = await fetch(`https://cdn.example.com/translations/${locale}.json`);
  // const messages = await res.json();

  return { locale, messages: messages.default };
};

레이아웃 컴포넌트에서 하위 컴포넌트들이 번역을 사용할 수 있도록 합니다:

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  import { createTranslator } from '$lib/i18n';
  import type { LayoutData } from './$types';

  const { data }: { data: LayoutData } = $props();

  const t = createTranslator(data.messages);
  setContext('t', t);
  setContext('locale', data.locale);
</script>

<slot />

Svelte 5 룬(Runes)을 활용한 컴포넌트에서의 번역 사용

Svelte 5에서는 $state$derived를 사용하여 로케일이 변경될 때 번역을 반응형으로 만들 수 있습니다. 룬과 잘 어울리는 번역 스토어 패턴입니다:

// src/lib/i18n/index.ts
import type { Messages } from './types';

export function createTranslator(messages: Messages) {
  return function t(key: keyof Messages, params?: Record<string, string | number>): string {
    const template = messages[key] ?? key;

    if (!params) return template;

    return Object.entries(params).reduce<string>(
      (result, [k, v]) => result.replaceAll(`{${k}}`, String(v)),
      template
    );
  };
}

컴포넌트에서 context로부터 번역기를 가져옵니다:

<!-- src/routes/[locale=locale]/+page.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Translator } from '$lib/i18n/types';

  const t = getContext<Translator>('t');
  const locale = getContext<string>('locale');

  // Svelte 5: derived state from context
  let greeting = $derived(t('home.greeting', { name: 'World' }));
</script>

<h1>{greeting}</h1>
<p>{t('home.description')}</p>

전체 페이지 리로드 없이 로케일을 전환하려면 $state를 사용하여 번역기를 반응형으로 업데이트합니다:

<script lang="ts">
  import { createTranslator } from '$lib/i18n';
  import type { Messages } from '$lib/i18n/types';

  let messages = $state<Messages>({} as Messages);
  let t = $derived(createTranslator(messages));

  async function switchLocale(newLocale: string) {
    const res = await fetch(`/api/translations/${newLocale}`);
    messages = await res.json();
    // Update URL without reload
    history.pushState({}, '', `/${newLocale}${window.location.pathname.replace(/^\/[^/]+/, '')}`);
  }
</script>

타입 안전성

타입 안전한 번역 함수는 컴파일 타임에 누락된 키를 잡아내고 IDE 자동완성을 가능하게 합니다. 기본 로케일 파일로부터 타입을 생성합니다:

// src/lib/i18n/types.ts
// Auto-generated from en.json — do not edit manually
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;

이 단계를 자동화할 수 있습니다. 타입을 재생성하는 간단한 스크립트입니다:

// scripts/generate-i18n-types.ts
import { writeFileSync } from 'fs';

const output = `// Auto-generated from en.json — do not edit manually
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;
`;

writeFileSync('src/lib/i18n/types.ts', output);
console.log('Types generated.');

package.json의 빌드 프로세스에 연결합니다:

{
  "scripts": {
    "generate:i18n": "tsx scripts/generate-i18n-types.ts",
    "prebuild": "npm run generate:i18n",
    "predev": "npm run generate:i18n"
  }
}

이제 t('home.greetng')'home.greetng'Messages에 존재하지 않기 때문에 TypeScript 오류를 발생시킵니다. 오타가 프로덕션에서 사용자에게 발견되기 전에 컴파일 타임에 잡힙니다.

이 부분은 Better i18n 같은 플랫폼이 실제 가치를 발휘하는 영역입니다. 번역 스키마로부터 SDK 타입을 자동으로 생성하므로, 번역가가 키를 추가하거나 이름을 변경할 때마다 수동 스크립트 실행 없이 TypeScript 타입이 업데이트됩니다.

Svelte와 함께 React 또는 Next.js 앱을 구축하고 있다면 동일한 타입 안전 패턴이 적용됩니다. 프레임워크별 구현은 React i18n: React 국제화 완전 가이드Next.js App Router i18n: 서버 컴포넌트와 RSC 패턴을 참고하세요.

복수형 처리와 포맷팅

JavaScript 내장 Intl API는 외부 의존성 없이 로케일별로 복수형을 올바르게 처리합니다:

// src/lib/i18n/format.ts

export function pluralize(
  locale: string,
  count: number,
  forms: Record<Intl.LDMLPluralRule, string>
): string {
  const rule = new Intl.PluralRules(locale).select(count);
  const template = forms[rule] ?? forms.other;
  return template.replace('{count}', String(count));
}

export function formatNumber(locale: string, value: number, options?: Intl.NumberFormatOptions): string {
  return new Intl.NumberFormat(locale, options).format(value);
}

export function formatDate(locale: string, date: Date, options?: Intl.DateTimeFormatOptions): string {
  return new Intl.DateTimeFormat(locale, options).format(date);
}

컴포넌트에서의 사용 예시:

<script lang="ts">
  import { pluralize, formatNumber, formatDate } from '$lib/i18n/format';
  import { getContext } from 'svelte';

  const locale = getContext<string>('locale');

  const itemCount = 3;
  const price = 1499.99;
  const publishedAt = new Date('2024-06-15');

  let itemLabel = $derived(
    pluralize(locale, itemCount, {
      one: '{count} item',
      other: '{count} items',
      zero: 'no items',
      two: '{count} items',
      few: '{count} items',
      many: '{count} items',
    })
  );

  let formattedPrice = $derived(formatNumber(locale, price, { style: 'currency', currency: 'USD' }));
  let formattedDate = $derived(formatDate(locale, publishedAt, { dateStyle: 'long' }));
</script>

<p>{itemLabel}</p>
<p>{formattedPrice}</p>
<p>{formattedDate}</p>

독일어 로케일에서는 1499.991.499,99 $로, 날짜는 15. Juni 2024로 렌더링됩니다. 추가 라이브러리 없이도 말입니다.

다국어 SvelteKit을 위한 SEO

검색 엔진은 페이지의 대체 언어 버전에 대한 명시적인 신호를 필요로 합니다. 레이아웃의 <svelte:head>hreflang 태그와 지역화된 메타데이터를 추가하세요. 다국어 검색 순위를 위해 이 부분이 올바르게 구현되는 것은 매우 중요합니다. i18n SEO, hreflang 태그, 로케일 URL 완전 가이드에서 모든 구현 세부사항과 일반적인 함정을 다루고 있습니다.

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
  import { SUPPORTED_LOCALES } from '$lib/i18n/config';

  const { data }: { data: LayoutData } = $props();

  // Build alternate URLs for all supported locales
  let alternates = $derived(
    SUPPORTED_LOCALES.map((loc) => ({
      locale: loc,
      url: $page.url.href.replace(`/${data.locale}/`, `/${loc}/`),
    }))
  );
</script>

<svelte:head>
  {#each alternates as { locale, url }}
    <link rel="alternate" hreflang={locale} href={url} />
  {/each}
  <link rel="alternate" hreflang="x-default" href={alternates.find(a => a.locale === 'en')?.url} />
</svelte:head>

<slot />

사전 렌더링을 위해 모든 로케일 변형을 생성하도록 SvelteKit을 설정합니다:

// svelte.config.js
const config = {
  kit: {
    prerender: {
      entries: [
        '/en',
        '/de',
        '/fr',
        '/en/about',
        '/de/about',
        '/fr/about',
        // ... or generate dynamically
      ],
    },
  },
};

동적 사전 렌더링 항목의 경우 entries export가 있는 +page.server.ts를 사용합니다:

// src/routes/[locale=locale]/+page.server.ts
import { SUPPORTED_LOCALES } from '$lib/i18n/config';

export function entries() {
  return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}

CDN 우선 vs 번들링 번역

두 방식 모두 작동합니다. 올바른 선택은 워크플로우에 따라 달라집니다.

번들링 번역 (JSON에서 직접 import):

  • 페이지 로드당 추가 HTTP 요청 없음
  • 번역이 배포 버전에 고정됨
  • 오프라인 및 외부 의존성 없이 동작
  • 번역의 오타 하나를 수정하려면 재배포가 필요

CDN 제공:

  • 앱 재배포 없이 번역 업데이트 가능
  • 번역가가 엔지니어와 독립적으로 변경사항 배포 가능
  • 네트워크 요청 추가 (공격적인 캐싱으로 완화 가능)
  • 캐시 무효화 전략 필요

i18n 여정 초기에는 번들링 JSON이 더 단순합니다. 번역팀이 성장하고 콘텐츠 업데이트 속도가 빨라질수록 배포 의존성이 부담이 됩니다. 독일어 버튼 레이블 하나를 수정하기 위해 코드 릴리스를 배포하는 것은 확장 가능한 방식이 아닙니다. 번들링 방식을 넘어서면 CI/CD 파이프라인에서 i18n 자동화가 자연스러운 다음 단계입니다.

이것이 Better i18n 같은 플랫폼이 해결하는 핵심 문제입니다. 번역 메시지는 긴 캐시 TTL을 가진 버전 URL로 제공되는 CDN에 저장됩니다. 엔지니어는 코드를 업데이트하고, 번역가는 번역을 업데이트합니다. 서로를 막지 않고 독립적으로 말입니다. 기능 페이지에서는 번역 게시 시 캐시 무효화가 어떻게 처리되는지 포함하여 이것이 어떻게 작동하는지 자세히 설명합니다.

중간 지점도 있습니다. 빌드 타임에 CDN에서 번역을 가져와 배포 아티팩트에 번들링하는 방식입니다. 로케일 파일을 저장소에 번들링하지 않고도 빠른 로컬 로드를 얻을 수 있지만, 업데이트를 위해서는 여전히 재배포가 필요합니다.

요약

SvelteKit과 Svelte 5는 다국어 앱을 위한 탄탄한 기반을 제공합니다:

  • Hooks가 렌더링 전 서버 사이드에서 로케일 감지를 처리합니다
  • params matcher가 있는 동적 라우트 세그먼트로 깔끔한 URL 기반 로케일 라우팅을 구현합니다
  • 로드 함수가 레이아웃당 번역을 한 번 가져와 컴포넌트 트리에 전달합니다
  • Svelte 5 룬 ($state, $derived)이 수동 스토어 구독 없이 로케일 전환을 반응형으로 만듭니다
  • 기본 로케일로부터 생성된 TypeScript 타입이 컴파일 타임에 누락되거나 잘못 입력된 키를 잡아냅니다
  • Intl API가 모든 로케일에서 복수형과 숫자/날짜 포맷팅을 올바르게 처리합니다
  • svelte:head hreflang 태그와 사전 렌더링된 로케일 라우트가 검색 엔진에 정보를 제공합니다

여기서 설명한 아키텍처는 대부분의 프로덕션 요구사항을 처리합니다. 주요 한계는 운영상의 문제입니다. 앱이 성장함에 따라 저장소 내 번역 파일 관리가 엔지니어와 번역가 사이에 마찰을 만듭니다. CDN 제공과 자동화된 타입 생성은 전용 도구의 도입 효과가 가장 빠르게 나타나는 두 영역입니다.

실제 번역팀과 함께 SvelteKit 앱을 구축하고 있다면 Better i18n의 Svelte 통합을 살펴보세요. 타입 안전한 SDK, 기본 제공 CDN, 번역 변경사항이 코드와 동일한 검토 프로세스를 거치는 Git 기반 검토 워크플로우를 제공합니다.

Better i18n은 모던 프론트엔드 팀을 위해 구축된 개발자 우선 현지화 플랫폼입니다. 타입 안전한 SDK, Git 기반 워크플로우, CDN 제공, 용어집 적용이 포함된 AI 번역 — 저장소에 로케일 파일 없이도 가능합니다.


better-i18n으로 앱을 글로벌하게 만드세요

better-i18n은 AI 기반 번역, git 네이티브 워크플로우, 글로벌 CDN 제공을 하나의 개발자 우선 플랫폼으로 결합합니다. 스프레드시트 관리를 그만두고 모든 언어로 출시를 시작하세요.

무료로 시작하기 → · 기능 살펴보기 · 문서 읽기

Comments

Loading comments...