글로벌 서비스에 다국어 기능은 선택이 아닌 필수다. 그런데 단순히 한국어만을 고려한 어플리케이션과, 여러 개의 국가를 고려한 어플리케이션은 메세지를 화면에 띄워주는 구조부터 다르다. 따라서 글로벌 시장을 목표로 한다면, 초기 설계 단계부터 다국어를 고려하는 게 좋다. 이번 글에서는 다국어 웹사이트를 구성하기 위해서는 어떤 것들을 고려해야 하고, 어떻게 만들어야 할 지를 다뤄보고자 한다.
i18n이란
Internationalization(국제화) 의 준말. 비슷한 용어로 Localization(현지화) 도 있다. 일반적으로 다음과 같은 것들을 의미한다.
- 언어에 맞는 다국어 번역 및 최적화된 UI를 제공하는 것은 기본이다. 번역본을 관리하고, 언어 설정에 맞게 번역된 텍스트를 UI에 노출시켜야 하며, 텍스트 박스 사이즈를 많이 차지하는 언어와 간결한 길이로 표현되는 언어의 길이 간극도 맞춰주어야 한다.
- 날짜 및 시간 형식, 통화 기호 등 Locale 대응 역시 중요하다. 데이터베이스에 날짜가 UTC 타임존 기반으로 저장된다면, 그것을 불러와서 사용자에게 보여줄 땐 현지 타임존에 맞춰 보여주어야 하고, 날짜 형식도 현지에서 사용되는 형식으로 바꾸어 보여줘야 한다. 예를 들면 우리나라에서는 yyyy-mm-dd 형식을 사용하지만, 미국을 비롯한 많은 나라에서는 dd-mm-yyyy 형식을 사용한다.
- 국가/지역 특성에 맞는 데이터 수집이 필요하다. 휴대폰번호를 받는 서비스일 경우, 휴대폰번호에 대한 유효성검사 규칙이 나라마다 전부 다를 것이다. 배송이 들어가는 서비스라면 주소를 입력받아야 하는데, 주소에 대한 규칙과 형식 역시 국가마다 다를 수 있으므로 유의해야 한다. 본인인증, 우편번호 등도 국가에 따라 다르기 때문에 관련 기능을 지원하는 API가 있는지 검토가 필요할 것이다.
- 그밖에 대상 시장의 언어나 문화 및 기타 요구사항을 충족하기 위해 제품을 조정하는 것이 필요하다. 이것은 현지화의 영역인데, 예를 들면 한국에서의 폭력성 검열 수위와 미국에서의 검열 수위가 다르기 때문에 게임을 출시할 땐 기능을 조금씩 변경해서 출시할 것이다. 대만, 홍콩 등의 나라를 일부 지역에서는 국가가 아닌 지역으로 표기하는 행위도 이에 해당한다.
이처럼 완벽한 국제화/현지화를 위해서는 상당히 많은 요소들이 고려되어야 하지만, 본 글에서는 우선 언어 설정에 따라 번역본을 UI에 표현하는 과정을 다뤄보고자 한다. Next.js App Router 환경을 기준으로 진행해볼 것이지만, 기본적인 동작원리는 타 환경에서도 동일할 것이다. 라이브러리는 프로젝트 규모와 성격에 맞게 선택하면 된다. (https://phrase.com/blog/posts/react-i18n-best-libraries/)
다국어 프로젝트 설정
일반적으로 여러 언어를 지원하는 서비스에서는 유저가 언어를 직접 설정할 수 있는 기능을 제공해주는 것이 좋다. 이 과정에서 URL 라우팅 역시 다국어에 따라 다르게 국제화할 수 있다. 일반적으로 권장되는 관리 방식은 다음 두 가지다.
- Sub-Path 로 Locale을 관리하는 방법
- /fr/products 처럼, 언어 설정이 라우팅 경로로서 존재하는 방식이다.
- app 디렉토리 프로젝트 기준으로, 모든 페이지들이 app/[lang]/ 디렉토리 하위에 있어야 한다. 즉, 언어라는 라우팅 경로가 최상위에 하나 생기는 셈이다.
- ex. https://nodejs.org/en
- 언어별 도메인을 따로 관리하는 방법
- my-site.fr/products 처럼, 언어 별로 도메인을 만들어서 언어 설정을 관리하는 방식이다.
- i18n 라우팅을 신경쓰지 않아도 된다는 편리함이 장점이다.
Next.js 프로젝트를 기준으로는, next-intl 라이브러리를 활용하여 위 두 가지 방식을 쉽게 다룰 수 있다. 이 중 i18n 라우팅을 구현해보자면, 다음과 같은 폴더 구조를 가지게 된다.
├── messages
│ ├── en.json (1)
│ └── ...
├── next.config.mjs (2)
└── src
├── i18n
│ ├── routing.ts (3)
│ └── request.ts (5)
├── middleware.ts (4)
└── app
└── [locale]
├── layout.tsx (6)
└── page.tsx (7)
1. messages/ 디렉토리 밑에는 실제 유저에게 보여줄 번역본 텍스트들을 json 파일로 저장하면 된다. 이때 모든 언어의 json은 형식이 동일해야 한다.
2. Next.js 프로젝트에서 서버 요청 시 i18n 설정을 공유하기 위해, 다음과 같이 플러그인을 설정해준다.
// next.config.mjs
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withNextIntl(nextConfig);
3. i18n 라우팅을 래핑한 Navigation API들을 만들어준다. 예를 들면 페이지 이동 시 router.push(/login) 을 실행했을 때, my-site/login 으로 이동하는 것이 아닌 my-site/en/login 으로 이동할 수 있게끔 기본 Next.js 라우팅과의 통합이 필요할 것이다.
// src/i18n/routing.ts
import {defineRouting} from 'next-intl/routing';
import {createSharedPathnamesNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'de'],
// Used when no locale matches
defaultLocale: 'en'
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter} =
createSharedPathnamesNavigation(routing);
따라서 Navigation API가 필요한 경우, 원본 인스턴스를 사용하는 것이 아닌, 래핑한 버전의 API를 import 해서 사용해야 한다.
import Link from 'next/link'; (X)
import { Link } from '@/i18n/routing'; (O)
4. 언어 설정에 따라 리다이렉트를 실행해줄 수 있는 미들웨어 설정이 필요하다. 이것도 손수 구현하려면 양이 상당한데, next-intl 을 사용하면 미리 정의된 미들웨어를 쉽게 끌어와서 프로젝트에 적용할 수 있다.
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(de|en)/:path*']
};
5. Server Component를 사용한다면, Next.js 서버 요청 시에도 i18n 설정과 메세지들을 불러올 수 있어야 한다. 다음과 같이 설정해줄 수 있다.
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
export default getRequestConfig(async ({locale}) => {
// Validate that the incoming `locale` parameter is valid
if (!routing.locales.includes(locale as any)) notFound();
return {
messages: (await import(`../../messages/${locale}.json`)).default
};
});
6. 이것을 클라이언트 환경에서도 사용할 수 있도록, 최상위 Layout.tsx 파일에서 Provider 를 제공해주면 설정은 끝이다. next-intl 에서 제공해주는 NextIntlClientProvider 를 사용하면 된다. 이때 html 의 lang 속성을 지정해주는 것도 잊지 말자.
import {NextIntlClientProvider} from 'next-intl';
import {getMessages} from 'next-intl/server';
export default async function LocaleLayout({
children,
params: {locale}
}: {
children: React.ReactNode;
params: {locale: string};
}) {
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
번역된 컨텐츠 보여주기
프로젝트 설정이 되었으니, 이제 번역본을 사용할 페이지에서 원하는 형태로 사용할 수 있다. 기본적인 사용법은 다음과 같다.
import {useTranslations} from 'next-intl';
import {Link} from '@/i18n/routing'; // 반드시 위에서 정의한 래핑한 Navigation API를 import 해야 한다.
export default function HomePage() {
const t = useTranslations('HomePage');
return (
<div>
<h1>{t('title')}</h1>
<Link href="/about">{t('about')}</Link>
</div>
);
}
번역본은 기본적으로 useTranslations API 를 통해 가져오게 되는데, 이때 인자로 네임스페이스를 넘길 수 있다.
const t = useTranslations('HomePage');
번역된 콘텐츠들이 많아질수록, 하나의 json에 1 depth로 모든 텍스트를 관리하는 것보다는 도메인(사용처)에 따라 한 depth를 더 두어서 네임스페이스로 관리하는 것이 편리하다. 네임스페이스를 가진 실제 번역 파일은 다음과 같은 구조로 구성되어야 한다.
// en.json
{
"About": {
"title": "About us",
"description": "This is About page."
},
"Payment": {
"title": "Order/Payment",
"orderProducts": "Order Items",
"orderCount": "Quantity : {n}",
}
}
about 페이지에서 쓸 다국어 컨텐츠들은 “About” 하위에, 결제 페이지에서 쓸 다국어들은 “Payment” 하위에 들어가 있는 것을 볼 수 있다. 보통 페이지 별로 네임스페이스를 나누는 것이 관리 편의성이 좋았다.
다국어에는 기본 plain text 외에도 각종 형식의 문자열들이 들어갈 수 있는데, 주로 다음과 같은 케이스들이 있다.
변수 넘기기
다국어 파일에서 상황에 맞게 데이터가 포함되어야 하는 경우, 메세지 내 변수로 관리할 수 있다.
먼저 다국어에서 표현하고자 하는 변수를 {} 내에 변수명을 포함하여 작성한다.
"totalItems": "Total {n} items"
해당 변수를 사용할 땐, 변수명을 참고하여 t 함수의 두 번째 인자로 변수를 넘기면 된다.
const t = useTranslations('Payment');
t('totalItems', { n: 35 }) // Total 35 items
복수 데이터 표현하기
영미권에서는 보통 ‘-s’ 를 붙여서 복수형을 표현한다. 이때 다국어가 컨텐츠의 숫자에 따라 달라져야 하는데, 이 부분 역시 plural 인자를 통해 손쉽게 표현할 수 있다.
"message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
변수에 데이터를 넣으면, 자동으로 단수/복수를 계산하여 알맞는 텍스트 표현이 가능하다.
t('message', {count: 3580}); // "You have 3,580 followers."
HTML/마크업 컨텐츠 표현하기
다국어 중간 중간에 줄바꿈 포인트를 다르게 주고 싶거나 특정 스타일을 먹이고 싶을 경우, 번역본 텍스트 자체를 html 또는 마크업 형식으로 저장해서 사용하기도 한다. 이 경우 rich, markup, raw 등의 API를 사용하여 표현도 가능하다.
// Rich Text
{
"message": "Please refer to <guidelines>the guidelines</guidelines>."
}
// Returns `<>Please refer to <a href="/guidelines">the guidelines</a>.</>`
t.rich('message', {
guidelines: (chunks) => <a href="/guidelines">{chunks}</a>
});
// Raw Messages
{
"content": "<h1>Headline</h1><p>This is raw HTML</p>"
}
<div dangerouslySetInnerHTML={{__html: t.raw('content')}} />
타입 적용하기
json으로 관리되는 번역본 데이터는 기본적으로 Type Safe 하지 못하다. 어떤 키를 넣어도, 해당 데이터가 실제로 있는지, 없어서 에러가 나는지는 런타임에서만 확인할 수 있다. 따라서 유효한 키를 통해 데이터에 접근하고 있는지 검증하기 위해서는 직접 타입 정의를 해줘야 한다.
import en from './messages/en.json';
// en 을 포함한 모든 언어의 다국어 데이터가 동일한 형식으로 들어갈 것이기 때문에, en.json 데이터로만 검증한다.
type Messages = typeof en;
declare global {
// Use type safe message keys with `next-intl`
interface IntlMessages extends Messages {}
}
이렇게 타입 정의를 해주면, 잘못된 key가 입력될 경우 타입 에러를 발생시킬 수 있다.
function About() {
// ✅ Valid namespace
const t = useTranslations('About');
// ✖️ Unknown message key
t('description');
// ✅ Valid message key
t('title');
}
// en.json
{
"About": {
"title": "Hello"
}
}
결론
이렇게 i18n이란 무엇인지, 그리고 다국어 프로젝트를 셋업하고 실무에서 사용하는 방법을 Next.js 프로젝트 기준으로 알아보았다. 코드는 https://github.com/jihyundev/nextjs-app-router-i18n-sample 레포지토리에서도 확인해볼 수 있다. next-intl의 경우 라이브러리가 워낙 잘 되어있어서 변수, 마크업 적용 등 필요한 기능들의 대부분을 문제없이 적용해볼 수 있었다. 이어서 다음 포스팅에서는 규모가 커진 웹 어플리케이션에서 다국어 번역 파일을 효과적으로 관리하는 방법에 대해 다뤄보고자 한다.
Reference
'Web' 카테고리의 다른 글
구글 스프레드 시트로 i18n 메세지 관리 자동화하기 (3) | 2024.11.10 |
---|---|
서로 다른 윈도우 간 안전하게 통신하는 방법 (0) | 2024.03.17 |
Axios 인터셉터로 JWT 토큰 로테이션 구현하기 (0) | 2024.02.18 |
헷갈리는 줄바꿈, 올바르게 제어해보자 (15) | 2023.12.10 |
JavaScript로 이미지 파일 데이터 다루기 (0) | 2023.08.31 |