“i18n 작업? 텍스트만 번역하면 되는 거 아닌가?”
처음에는 이렇게 생각했지만, 막상 시작해보니 예상보다 훨씬 복잡한 작업이었습니다. 여러 회사에서 소규모부터 대규모까지 다양한 i18n 프로젝트를 경험하면서 느낀 점들을 정리해봤습니다.
기본 개념부터 정리하기
먼저 용어 정의부터 명확히 하고 시작하겠습니다:
- i18n (Internationalization): 소프트웨어가 여러 언어와 지역을 지원할 수 있도록 미리 설계하고 개발하는 과정
- L10n (Localization): 설계된 소프트웨어를 특정 지역에 맞게 적용하는 과정
💡 i18n은 “i”와 “n” 사이 18글자, L10n은 “L”과 “n” 사이 10글자를 줄인 표기법입니다.
단순 번역 vs 국제화의 차이점
왜 ‘번역’이 아니라 ‘국제화’일까요? 언어를 바꾸는 것 이상으로 고려해야 할 요소들이 많기 때문입니다:
-
문화적 맥락: 기호, 제스처의 의미가 문화마다 다릅니다. 엄지 손가락을 위로 올리는 제스처는 서구에서는 ‘좋다’는 의미이지만, 중동이나 방글라데시에서는 모욕적인 표현입니다.
-
사용자 행동 패턴: 지역별 사용자의 웹 탐색 습관과 기대치가 다릅니다. Nielsen Norman Group 연구에 따르면, 한국과 중국 등 고맥락 문화권(High-context culture) 사용자는 정보 밀도가 높은 페이지를 선호하며 비선형적으로 페이지를 스캔합니다. 반면 미국과 북유럽 등 저맥락 문화권(Low-context culture) 사용자는 여백이 많은 미니멀한 디자인을 선호하고 순차적 읽기 패턴을 보입니다.
-
기술적 표현: 날짜, 시간, 화폐, 숫자 형식의 지역별 차이는 물론, 주소 체계, 전화번호 형식, 우편번호 규칙까지 모두 다릅니다. 이는 표현을 위한 UI의 디자인과 구현이 섬세하게 이뤄져야 합니다.
i18n은 큰 작업입니다
i18n은 생각보다 시간과 에너지가 많이 드는 큰 작업입니다. 개발자, 디자이너, PO 그리고 번역가(현지화 매니저)가 한 팀이 되어서 긴밀하게 일해야 합니다. 프로젝트 초반에 설계하면 좋지만, 프로젝트 중간에 들어간 경우에는 개발 비용이 많이 증가했었습니다. 이미 진행 중인 프로젝트에 i18n을 적용하겠다고 마음먹었다면 계획을 세우고 우선순위에 따라서 일을 진행하시는 것을 권하고 싶습니다.
언어와 범위 정하기
가장 먼저 정해야 할 것은 어떤 언어를 지원할지와 어디까지 현지화할지입니다. 우리가 현재 서비스하는 국가와 앞으로 서비스할 국가의 언어를 명확히 정해야 합니다. 제 경험상 PO나 비즈니스에서 결정이 되었던 거 같습니다.
다음으로 중요한 것은 현지화 범위를 정하는 것입니다. 단순히 텍스트만 번역해서 보여줄 것인지, 숫자 포맷과 날짜 포맷을 현지에 맞게 표현할 것인지, 그리고 더 나아가 색상, 이미지, 문화적 요소까지 고려할 것인지 정해야 합니다.
저는 보통 텍스트와 숫자, 날짜까지 했던 것 같습니다.
준비하기
Source 언어 선택
Source 언어는 모든 번역의 기준이 되는 언어입니다. 일반적으로 개발팀이 사용하는 언어를 선택하지만, 글로벌 서비스의 경우 주요 타겟 시장의 언어를 source로 사용하는 것도 고려해볼 만합니다.
Glossary 작성
Source 언어가 정해지면 glossary를 정리해야 합니다. 단순히 단어만 나열하는 게 아니라, 각 용어가 우리 제품에서 어떤 의미와 맥락으로 사용되는지 정의와 context를 정하고 시작하는 것이 도움이 되었습니다. 이렇게 하면 번역자가 정확한 의미를 파악하고 더 적절한 번역을 할 수 있습니다.
RTL(Right-to-Left) 지원 결정
지원할 언어 목록을 살펴보고 RTL 언어가 포함되어 있는지 확인해야 합니다. 우리가 익숙한 한국어, 영어, 일본어 등은 LTR(Left-to-Right)이지만, 아랍어나 히브리어는 RTL(Right-to-Left) 언어입니다.
RTL 언어를 지원해야 한다면 처음부터 UI를 다르게 설계해야 하므로, 프로젝트 초기에 결정하는 게 좋습니다.
RTL CSS 구현
아랍어는 전 세계 4억 명이 사용하는 주요 언어입니다. 특히 아랍어 사용자의 97%가 아랍어 콘텐츠를 선호하므로, 중동 시장 진출 시 RTL 지원은 필수입니다.
RTL 설계시 주요 고려사항:
- 내비게이션: 버튼과 로고는 우측 상단으로 이동 (읽기 시작점이 오른쪽이므로)
- 드롭다운 메뉴: 오른쪽에서 왼쪽으로 열림 (자연스러운 시선 흐름을 따라)
- 진행 표시줄: 타임라인도 방향 전환 필요 (시간의 흐름도 오른쪽에서 왼쪽으로)
- 숫자는 절대 뒤집지 않음: 전화번호 1234-5678은 그대로 유지 (숫자는 국제 표준)
RTL 지원의 핵심은 HTML의 dir 속성과 CSS Logical Properties를 활용하는 것입니다. dir 속성은 텍스트의 방향뿐만 아니라 레이아웃의 시작점도 변경합니다. MDN 문서에 따르면 dir은 요소의 텍스트 방향성을 나타내는 열거형 속성으로, ltr, rtl, auto 값을 가질 수 있습니다:
<!-- HTML에서 전역 방향 설정 -->
<html dir="rtl" lang="ar"> <!-- 아랍어 -->
<html dir="rtl" lang="he"> <!-- 히브리어 -->
<html dir="ltr" lang="en"> <!-- 영어 (기본값) -->
<html dir="auto"> <!-- 자동 감지 -->
<!-- 특정 요소만 방향 변경 -->
<div dir="rtl">이 부분만 RTL로 표시됩니다</div>
dir="auto"는 브라우저가 자동으로 텍스트 방향을 결정하도록 합니다. 요소 내부의 문자를 파싱하여 강한 방향성을 가진 문자(아랍어, 히브리어 등)를 찾으면 해당 방향성을 전체 요소에 적용합니다. 혼합된 언어 콘텐츠를 다룰 때 유용합니다.
/* Logical Properties 활용 - 핵심 예시 */
.card {
margin-inline-start: 1rem; /* LTR: margin-left, RTL: margin-right */
padding-inline-end: 1rem; /* LTR: padding-right, RTL: padding-left */
text-align: start; /* LTR: left, RTL: right */
border-inline-start: 2px solid blue;
}
/* RTL 자동 대응 레이아웃 */
.nav-menu {
display: flex;
direction: inherit; /* HTML dir 속성 상속 */
}
.nav-menu[dir="rtl"] {
flex-direction: row-reverse;
}
수직 쓰기 고려하기
만약에 훈민정음과 같은 옛날 문서나 일본어나 중국어와 같은 수직 쓰기를 고려해야 할 경우 고려해야 할 점이 있습니다. 이럴때는 오른쪽에서 왼쪽으로 읽는 수직 읽기(Vertical Writing)를 구현해야합니다.
/* 수직 쓰기 기본 예시 */
.vertical-text {
writing-mode: vertical-rl;
text-orientation: mixed;
}
/* 언어별 줄바꿈 */
:lang(ja) { word-break: keep-all; }
:lang(ko) { word-break: keep-all; }
:lang(en) { hyphens: auto; }
Figma에서는 Auto Layout을 사용하면 RTL 대응이 쉬워집니다. RTLCSS 같은 도구도 고려해볼 수 있겠습니다만, 저는 처음부터 Logical Properties로 작성하는 것을 선호했습니다.
i18n Key 이름 짓기
이제 본격적으로 번역 문자열의 key-value 쌍들을 만들어야 합니다. 여기서 중요한 것은 key 이름입니다. 개발자는 코드에서 실제 텍스트 대신 key를 사용하기 때문에, key만 보아도 어떤 텍스트인지, 어디에 사용되는지 알 수 있어야 합니다. key는 모든 팀원과 함께 사용하므로 명명 규칙에 대해서 팀원간 잘 싱크가 되는 것이 좋습니다.
피해야 하는 key 이름: menu0, menu1, menu2 등과 같이 key값으로 한정된 정보를 얻을 수 없는 이름들
제가 사용했던 key 법칙은 {category}.{action}.{ui}를 주로 사용했습니다.
이를테면 user.signup.button, product.add_cart.button과 같이 이 텍스트가 어디서 사용되는지 알 수 있으면 좋습니다. 이것에 대한 건 딱히 정해져 있지 않으므로 다른 사례를 많이 찾아보고 우리 상황에 맞는 걸 적용하면 좋습니다.
{
// 기본 패턴: {category}.{feature}.{element}
"auth.login.button": "Log In",
"auth.signup.title": "Create Your Account",
"product.card.addToCart": "Add to Cart",
"product.detail.outOfStock": "Out of stock",
"common.button.cancel": "Cancel",
"common.error.network": "Network error occurred"
}
가이드를 작성하고 같이 일하는 팀원들과 공유하고 같은 페이지에서 시작할 수 있도록 해야합니다. 번역 key는 누구든지 생성할 수 있어야 합니다.
텍스트 중심 콘텐츠 관리
다른 방법도 있습니다. UI의 label을 관리할 때는 위와 같은 key-value의 쌍으로 관리하는 것이 좋지만, 텍스트가 중심인 콘텐츠인 경우 MD 혹은 HTML로 관리하는 것도 방법입니다. 약관과 같이 페이지 전체가 정적이고 텍스트 위주의 콘텐츠를 번역한다고 할 때 유용합니다.
/locales
/en
common.json
auth.json
/ko
common.json
auth.json
/content
/ko/terms.md
/en/terms.md
이 방식의 장점은 번역자가 전체 문맥을 파악하면서 작업할 수 있고, Markdown 에디터를 사용해서 쉽게 편집할 수 있다는 점입니다. 특히 긴 문서의 경우 key-value 방식보다 관리가 훨씬 편합니다.
i18n에 맞는 UI 설계하기
i18n에서 가장 중요한 것은 텍스트 길이 변화에 대응하는 유연한 UI입니다. 언어마다 텍스트 길이가 극단적으로 달라질 수 있기 때문입니다.
예를 들어 한국어 “확인”(2자)이 영어로는 “Confirm”(7자), 독일어로는 “Bestätigen”(10자)이 될 수 있습니다. 디자이너는 이러한 차이를 미리 고려해 유연한 레이아웃을 설계해야 합니다.
실제 사례
- “User” (영어) → “Benutzer” (독일어, 2배 길이)
- “User” (영어) → “utilisateur” (프랑스어, 3배 길이)
- “확인” (한국어) → “Confirmation” (영어, 3배 길이)
- 스페인어는 평균 25% 확장
- 중국어/일본어는 오히려 더 짧아질 수 있지만, 일본어는 띄어쓰기가 없어서 긴 문장이 한 줄에 이어질 때 줄바꿈 위치를 예측하기 어려워 레이아웃 깨짐이 발생할 수 있습니다.
일반적으로 영어에서 다른 언어로 번역할 때:
- 독일어: 30% 더 길어질 수 있음
- 프랑스어: 15-20% 더 길어질 수 있음
- 중국어/일본어: 50% 더 짧아질 수 있음
때문에 디자이너는 UI 디자인 시 요소에 들어가는 최대 글자 수를 정하고, 번역가는 이 가이드에 맞게 번역을 진행해야 합니다. 예를 들어 버튼에는 최대 8자, 제목에는 최대 20자 등의 제약을 두고 번역팀과 공유하는 것이 중요합니다.
언어별 특성에 맞는 폰트를 적용하며, 모든 언어가 잘 보일 수 있도록 글로벌 폰트와 개별 언어 폰트 커스터마이즈를 고려합니다. 언어는 고유 문자와 문자폭, 자간·행간 등이 다르기 때문입니다.
해결 전략
/* 기본 버튼 스타일 */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
padding: 0.75rem 1.5rem;
font-weight: 500;
border-radius: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 언어별 조정 */
.btn-primary:lang(de) {
font-size: 0.9rem; /* 독일어: 더 작게 */
padding: 0.75rem 1rem; /* 패딩 감소 */
}
.btn-primary:lang(ko) {
font-size: 1rem;
letter-spacing: -0.01em; /* 한글 자간 조정 */
}
/* 카드 레이아웃 */
.card-content {
padding: 1.5rem;
line-height: 1.6;
}
.card-content:lang(ja) {
line-height: 1.8; /* 일본어: 행간 넘리 */
word-break: keep-all;
}
.card-content:lang(en) {
hyphens: auto; /* 영어: 자동 하이픈 */
}
숫자와 날짜 처리
숫자 포맷팅
숫자의 경우는 문화권마다 숫자의 콤마와 쉼표를 찍는 위치가 다릅니다:
// 실제 사용 예시: 가격 표시 컴포넌트
function formatPrice(price, locale, currency) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(price);
}
// 사용 예시
formatPrice(1234.56, 'ko-KR', 'KRW'); // ₩1,235
formatPrice(1234.56, 'en-US', 'USD'); // $1,234.56
formatPrice(1234.56, 'de-DE', 'EUR'); // 1.234,56 €
formatPrice(1234.56, 'ja-JP', 'JPY'); // ¥1,235
// 숫자 포맷팅 유틸리티 함수
const NumberFormatter = {
format: (number, locale = 'en-US') => {
return new Intl.NumberFormat(locale).format(number);
},
percentage: (number, locale = 'en-US') => {
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 1
}).format(number);
},
compact: (number, locale = 'en-US') => {
return new Intl.NumberFormat(locale, {
notation: 'compact'
}).format(number);
}
};
// 1.2M, 1,2 Mio, 120만 등
NumberFormatter.compact(1234567, 'en-US'); // 1.2M
NumberFormatter.compact(1234567, 'de-DE'); // 1,2 Mio
NumberFormatter.compact(1234567, 'ko-KR'); // 123만
템플릿 리터럴과 동적 텍스트 처리
가장 중요한 것은 동적 텍스트(변수가 포함된 문자열)에 대한 인터폴레이션 지원입니다.
잘못된 예시 vs 올바른 예시:
// ❌ 잘못된 예시: 문자열 직접 조합
const badExample = `Hello ${userName}! You have ${count} messages`;
// ✅ 올바른 예시: 완전한 문장 번역
import { useTranslation } from 'react-i18next';
function WelcomeMessage({ userName, messageCount }) {
const { t } = useTranslation();
return (
<div>
{t('welcome.greeting', {
userName,
count: messageCount,
// ICU 포맷으로 복수형 처리
interpolation: { escapeValue: false }
})}
</div>
);
}
// 날짜 포맷팅 예시
function formatRelativeDate(date, locale) {
const rtf = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto'
});
const diffInDays = Math.floor((date - Date.now()) / (1000 * 60 * 60 * 24));
return rtf.format(diffInDays, 'day');
}
// 사용 예시
formatRelativeDate(yesterday, 'ko'); // "어제"
formatRelativeDate(yesterday, 'en'); // "yesterday"
formatRelativeDate(tomorrow, 'ja'); // "내일"
// 언어별 번역 예시
{
"welcome.greeting": "Hello {{userName}}! You have {{count}} message",
"welcome.greeting_ko": "{{userName}}님, 안녕하세요! {{count}}개의 메시지가 있습니다",
"welcome.greeting_ja": "{{userName}}さん、こんにちは!{{count}}件のメッセージがあります"
}
ICU 메시지 포맷이란?
ICU(International Components for Unicode) 메시지 포맷은 복잡한 다국어 메시지를 처리하기 위한 국제 표준입니다. 단순한 문자열 치환을 넘어서 복수형, 성별, 날짜/시간 포맷팅을 언어별 규칙에 맞게 처리할 수 있습니다.
// ICU 메시지 포맷 예시
const message = `{count, plural,
=0 {no items}
=1 {one item}
other {# items}
}`;
// 복잡한 조건 처리
const complexMessage = `{gender, select,
male {He added {count, plural, =1 {an item} other {# items}}}
female {She added {count, plural, =1 {an item} other {# items}}}
other {They added {count, plural, =1 {an item} other {# items}}}
}`;
단수/복수 처리
또한 숫자에서 중요한 것은 단수와 복수 처리를 하는 것입니다. 수량을 나타낼 때 우리말에서는 “1개”, “2개”, “3개” 등 단수/복수 표현에서 조금 자유로운 반면, 영어에서는 “1 item”, “2 items”처럼 달라져야 합니다. 그래서 저는 ICU format을 지원하는 라이브러리를 사용했습니다.
영어의 복잡한 단수/복수 규칙
가방의 개수를 표현할 때 우리는 {{ 숫자 }} 개만 넣으면 되지만 영어에서는 단수 복수 표현이 다릅니다. 또한 우리말에서는 첫째, 둘째이지만 영어에서는 1st, 2nd, 3rd, 4th입니다.
// ICU 메시지 포맷 사용
{
"itemCount": {
"zero": "No items in your bag",
"one": "{{count}} item in your bag",
"other": "{{count}} items in your bag"
},
"ranking": {
"one": "{{count}}st place",
"two": "{{count}}nd place",
"few": "{{count}}rd place",
"other": "{{count}}th place"
}
}
다른 언어들의 복잡성
- 러시아어: 1, 21, 31… (복수형1) / 2-4, 22-24… (복수형2) / 5-20, 25-30… (복수형3)
- 아랍어: 0개, 1개, 2개, 3-10개, 11-99개, 100개 이상 각각 다른 형태
- 중국어: 단수/복수 구분 없음
// ICU Message Format 예시
const messages = {
itemCount: `{count, plural,
=0 {No items}
one {# item}
other {# items}
}`
};
// 서수 처리
const ordinalMessages = {
position: `{pos, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
}`
};
번역 Key 관리 도구 선택
번역 작업을 효율적으로 관리하려면 전문 도구를 사용하는 것이 좋습니다. 저는 spreadsheet부터 시작해서 여러 도구를 사용해봤는데, 결국 Crowdin, Lokalise, Phrase 같은 Translation Management System(이하 TMS)을 사용하는 것을 추천합니다.
이런 서비스들을 선택할 때는 사용성과 pricing, 그리고 역할별 seat가 어떻게 되는지 비교해보고 적절한 것을 선택하면 좋습니다. key 관리도 할 수 있고 번역하는 역할을 나눈다거나, 더 나아가서 Figma에서 플러그인을 제공하기 때문에 UI 디자인상에서 어떻게 보이는지 알 수 있었습니다.
Spreadsheet에서는 번역하는 사람 입장에서는 key만 보고 번역해야 하기 때문에 context의 공유가 제한적이라, Translation Management System(TMS)을 사용하는 것을 권장합니다. 이하에서는 TMS라고 하겠습니다. TMS를 사용하면 해당 text가 어디서 보여지는지 screenshot을 보면서 할 수 있어서 나중에 QA에서 시간을 좀 더 아낄 수 있습니다.
프론트엔드 라이브러리 선택
번역 관리 도구를 정했다면, 이제 실제 프론트엔드에서 사용할 i18n 라이브러리를 선택해야 합니다. 프레임워크별로 많은 라이브러리가 있지만, 저는 ICU 마크업 포맷을 지원하는 라이브러리를 강력하게 추천합니다.
ICU Format을 추천하는 이유
제가 여러 프로젝트에서 ICU(International Components for Unicode) 메시지 포맷을 채택했던 이유는 다음과 같습니다:
-
복잡한 문법 규칙 처리가 가능합니다: 단순한 텍스트 치환을 넘어서 각 언어의 복잡한 문법 규칙을 처리할 수 있었습니다. 특히 러시아어나 폴란드어처럼 복잡한 복수형 규칙을 가진 언어를 지원할 때 큰 도움이 되었습니다.
-
CLDR 데이터베이스를 활용합니다: Unicode의 Common Locale Data Repository를 활용하여 각 언어별 정확한 규칙이 이미 정의되어 있어, 새로운 언어를 추가할 때마다 규칙을 직접 구현할 필요가 없었습니다.
-
포괄적인 포맷팅을 제공합니다: 날짜, 시간, 숫자, 통화 등을 로케일에 맞게 자동으로 포맷팅해주어 개발 시간을 크게 단축시켰습니다.
-
조건부 메시지 처리가 우아합니다: 성별, 선택 로직 등 조건에 따라 다른 메시지를 보여줘야 할 때 코드가 깔끔하게 유지됐습니다.
사용자 언어 감지와 설정
이제 사용자가 어떤 언어를 선호하는지 알아내야 합니다. 언어 감지는 여러 방법이 있고, 언어 감지의 우선순위를 정하는것이 중요합니다.
- URL 파라미터
- 로컬 스토리지 저장값
- 브라우저 언어 설정
- IP 기반 지역 감지
- 기본 언어
서버 사이드 vs 클라이언트 사이드
서버 사이드 감지:
// Accept-Language 헤더 파싱
app.use((req, res, next) => {
const acceptLanguage = req.headers['accept-language'];
const userLang = parseAcceptLanguage(acceptLanguage);
req.locale = userLang || 'en';
next();
});
클라이언트 사이드 감지:
// 브라우저 언어 감지
const browserLang = navigator.language || navigator.userLanguage;
// 언어 감지 우선순위 설정
function detectUserLanguage() {
// 1. URL 파라미터 확인
const urlParams = new URLSearchParams(window.location.search);
const urlLang = urlParams.get('lang');
if (urlLang) return urlLang;
// 2. 로컬 스토리지 저장값
const savedLang = localStorage.getItem('userLanguage');
if (savedLang) return savedLang;
// 3. 브라우저 언어
const browserLang = navigator.language.split('-')[0];
if (supportedLanguages.includes(browserLang)) return browserLang;
// 4. 기본 언어
return 'en';
}
저는 클라이언트에서 하는 것을 선호합니다. 그 이유는 사용자가 언어를 변경했을 때 페이지 새로고침 없이 즉시 반영할 수 있고, 서버 부하를 줄일 수 있으며, CDN 캐싱을 더 효과적으로 활용할 수 있기 때문입니다. url에 언어 코드를 포함시키고 source of truth로 사용하는것을 가장 선호합니다.
자동 언어 감지 구현
// 1. 브라우저 언어 감지 (가장 정확한 방법)
const detectLanguage = () => {
const supportedLanguages = ['ko', 'en', 'ja', 'zh'];
const browserLanguages = navigator.languages || [navigator.language];
for (const lang of browserLanguages) {
const langCode = lang.split('-')[0];
if (supportedLanguages.includes(langCode)) {
return langCode;
}
}
return 'en'; // fallback
};
// 2. IP 기반 국가 정보 활용
const getCountryFromIP = async () => {
try {
const response = await fetch('/api/geo-location');
const { country } = await response.json();
return country;
} catch (error) {
return 'US'; // fallback
}
};
// 3. 종합적인 언어 결정 로직
const getPreferredLanguage = () => {
// 1순위: 사용자 명시적 설정
const userSetting = localStorage.getItem('preferredLanguage');
if (userSetting) return userSetting;
// 2순위: URL 경로에서 추출 (/ko/products)
const pathLang = window.location.pathname.split('/')[1];
if (supportedLanguages.includes(pathLang)) return pathLang;
// 3순위: 브라우저 언어
return detectLanguage();
};
URL 구조와 SEO 전략
다국어 사이트를 구축할 때 URL 구조는 SEO와 사용자 경험에 직접적인 영향을 미치므로 신중하게 결정해야 합니다.
URL 구조 선택 기준
저는 서브디렉토리 방식(https://example.com/ko/products)을 권장합니다. 이 방식은 SEO에 가장 유리하며 Google이 공식적으로 권장하는 방식입니다. 언어별 콘텐츠 구조가 명확해서 hreflang 태그 적용이 쉽고, 사용자가 URL만 봐도 현재 어떤 언어로 된 페이지인지 직관적으로 알 수 있습니다. 또한 언어별 트래픽 분석이 용이하며, URL path의 parameter가 source of truth로 사용할 수 있어 사용자 언어를 설정하는 데 우선순위를 결정하기 쉽습니다.
https://example.com/ko/products
https://example.com/en/products
https://example.com/ja/products
쿼리 파라미터 방식(https://example.com/products?lang=ko)이나 서브도메인 방식(https://ko.example.com/products)도 고려할 수 있는데 추천하지 않습니다. 쿼리 파라미터 방식은 검색엔진이 동일한 콘텐츠로 인식할 가능성이 있고, 언어별 페이지 인덱싱이 어려우며, 캐싱 복잡성이 증가합니다. 서브도메인 방식은 SSL 인증서 관리가 복잡해지고, CDN 설정이 까다로우며, 브랜딩 측면에서도 일관성이 부족해집니다.
SEO 최적화 구현
hreflang 태그 설정:
<!-- 한국어 페이지에서 -->
<link rel="alternate" hreflang="ko" href="https://example.com/ko/products" />
<link rel="alternate" hreflang="en" href="https://example.com/en/products" />
<link rel="alternate" hreflang="ja" href="https://example.com/ja/products" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/products" />
언어별 Sitemap 생성:
<!-- sitemap-ko.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/ko/products</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/products"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://example.com/ja/products"/>
<xhtml:link rel="alternate" hreflang="ko" href="https://example.com/ko/products"/>
</url>
</urlset>
메타 태그 최적화:
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>제품 목록 - 우리 회사</title>
<meta name="description" content="한국어로 작성된 제품 설명">
<link rel="canonical" href="https://example.com/ko/products">
</head>
실제 구현 시 고려사항
저는 대부분의 프로젝트에서 서브디렉토리 방식을 선택했는데, 그 이유는 Google이 공식적으로 권장하는 SEO 친화적인 방식이기 때문입니다. 사용자가 URL만 봐도 언어를 직관적으로 알 수 있어 사용자 경험이 좋고, 언어별 트래픽 분석이 간단하며, CDN에서 언어별 캐시 관리가 명확해서 캐싱 효율성도 높습니다.
구현할 때 주의해야 할 점들이 몇 가지 있습니다. 먼저 기본 언어의 경우 /ko/ 없이 루트 경로를 사용할지 결정해야 합니다. 예를 들어 한국어가 기본 언어라면 /products와 /ko/products 중 어떤 것을 사용할지 정해야 하는데, 저는 일관성을 위해 모든 언어에 언어 코드를 포함시키는 방식을 선호했습니다.
또한 사용자 언어 감지 후 리다이렉트할 때는 SEO에 영향을 주지 않도록 302 리다이렉트를 사용해야 하고, robots.txt에서 언어별 크롤링 정책을 명확하게 설정해주는 것도 중요합니다.
현지화(Localization) 분리 여부 고려
단순 번역뿐 아니라 국가별로 다른 UI나 비즈니스 로직, 규제 등이 필요할 경우 국제화와 현지화 전략을 분리해서 설계할 필요가 있습니다. 그렇기 때문에 단순히 translation이 아니라 i18n이라고 하는 이유입니다.
실제 현지화 사례들
- 결제 방식: 한국(카카오페이, 네이버페이) vs 중국(알리페이, 위챗페이)
- 인증 방식: 한국(휴대폰 본인인증) vs 유럽(GDPR 준수 이메일 인증)
- 법적 요구사항: 쿠키 동의(EU), 개인정보 수집(한국), 데이터 로컬라이제이션(중국)
// 국가별 분기 처리 예시
const getLocalizedFeatures = (country) => {
const features = {
'KR': {
paymentMethods: ['kakaopay', 'naverpay', 'card'],
authMethod: 'phone',
gdprRequired: false
},
'CN': {
paymentMethods: ['alipay', 'wechatpay', 'unionpay'],
authMethod: 'wechat',
gdprRequired: false
},
'DE': {
paymentMethods: ['paypal', 'card', 'klarna'],
authMethod: 'email',
gdprRequired: true
}
};
return features[country] || features['US'];
};
마무리
하나의 서비스를 여러 언어로 제공하는 것은 사실 쉽지 않은 일입니다. 이 외에도 화폐는 어떻게 보여줄 것인지 등 현지화에 대한 더 많은 디테일들이 있습니다.
i18n은 단순히 텍스트를 번역하는 것이 아닙니다. 사용자가 익숙한 방식으로 정보를 받아들일 수 있도록 UI/UX 전체를 재설계하는 것입니다:
- 시각적 위계: RTL 사용자를 위한 레이아웃 재배치
- 인터랙션 패턴: 문화적 제스처와 행동 양식 반영
- 정보 아키텍처: 지역별 정보 우선순위와 탐색 패턴 고려
- 감정적 연결: 이미지, 톤앤매너를 통한 문화적 공감대 형성
요즘 AI를 통한 번역이 많이 이루어지고 있어 좋은 도구의 힘을 받으면 더 쉽게 할 수 있을 것 같습니다. 하지만 무엇보다 중요한 것은 완벽한 번역보다는 완벽한 사용자 경험을 위해, i18n을 UX/UI의 관점에서 접근하는것이 아닐까 싶습니다.
읽어보면 좋을 글
- LINE의 UX 라이팅 현지화 프로세스
- 글로벌 기업으로 거듭나기 위한 UX 현지화 전략
- Localizing the User Experience
- 국제화(i18n) 자동화 가이드
- 기존 서비스 국제화(i18n) 작업 쉽게 덜어내기: t 함수 자동 래핑 스크립트 만들기
- 국제화를 위한 프론트엔드 발판 다지기 (i18n)
- Dable의 다국어 지원(i18n) 시스템
- 다국어 지원 - 디자인베이스
- Localization vs. Internationalization - W3C
- Lessons Learned: Naming and Managing Rails I18n Keys
- Translation keys: naming conventions and organizing