다국어 웹 앱 구현 방법: 실전 가이드
들어가며
"앱을 영어로도 쓸 수 있나요?" 한국어 전용으로 만든 앱에 이런 문의가 들어왔을 때, 다국어 지원의 필요성을 처음 체감했다. 글로벌 사용자를 확보하려면 i18n(국제화, internationalization)은 선택이 아니라 필수다. 하지만 단순히 텍스트를 번역하는 것 이상으로 고려해야 할 사항이 많다. 이 글에서는 웹 앱에 다국어 지원을 추가하면서 배운 실전 경험을 공유한다.
i18n이 중요한 이유
전 세계 인터넷 사용자의 약 75%는 영어가 모국어가 아니다. 한국어만 지원하면 전 세계 시장의 극히 일부만 대상으로 하는 것이다. 다국어 지원은 다음과 같은 이점을 제공한다.
- 사용자 기반 확대: 잠재 사용자가 수십 배로 늘어난다.
- SEO 개선: 각 언어별 페이지가 해당 언어 검색 결과에 노출된다.
- 사용자 경험 향상: 모국어로 서비스를 이용할 수 있다는 것은 큰 차이를 만든다.
- 경쟁 우위: 같은 기능이라도 다국어를 지원하면 글로벌 시장에서 유리하다.
접근 방법: 라이브러리 vs 커스텀
다국어 구현에는 크게 두 가지 접근 방법이 있다.
라이브러리 활용
웹에서는 next-intl, react-i18next, vue-i18n 같은 검증된 라이브러리들이 있다. 복수형 처리, 날짜/숫자 포맷팅, 컨텍스트 기반 번역 등 복잡한 기능을 쉽게 사용할 수 있다.
커스텀 구현
프로젝트 규모가 작거나, 라이브러리 의존성을 최소화하고 싶다면 커스텀 구현도 좋은 선택이다. 내가 모바일 앱에서 사용하는 방식은 Map<String, String> 기반의 단순한 키-값 구조다.
// en.ts
export const en = {
'app.title': 'My App',
'app.welcome': 'Welcome, {name}!',
'items.count': '{count} items',
} as const;
// ko.ts
export const ko: Record<keyof typeof en, string> = {
'app.title': '내 앱',
'app.welcome': '{name}님, 환영합니다!',
'items.count': '{count}개 항목',
};
as const로 영어 키를 정의하고, 한국어 파일에서 Record로 타입을 강제하면 키 누락을 컴파일 타임에 잡을 수 있다. 단순하지만 효과적인 패턴이다.
Next.js에서의 다국어 구현
Next.js App Router 환경에서는 next-intl이 가장 잘 통합되는 라이브러리다. 설정 방법을 간단히 살펴보자.
// messages/ko.json
{
"HomePage": {
"title": "빛나라 블로그에 오신 것을 환영합니다",
"description": "개발 이야기를 나눕니다"
}
}
// messages/en.json
{
"HomePage": {
"title": "Welcome to Bitnara's Blog",
"description": "Sharing dev stories"
}
}
// app/[locale]/page.tsx
import { useTranslations } from 'next-intl';
export default function HomePage() {
const t = useTranslations('HomePage');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
);
}
Next.js의 동적 라우팅과 결합하면 /ko/about, /en/about 같은 URL 기반 로케일 관리가 자연스럽게 된다.
번역 파일 구조
번역 파일이 커지면 관리가 어려워진다. 내가 추천하는 구조는 기능(feature) 단위로 파일을 분리하는 것이다.
messages/
ko/
common.json # 공통 UI (버튼, 네비게이션)
auth.json # 로그인, 회원가입
dashboard.json # 대시보드
settings.json # 설정
en/
common.json
auth.json
dashboard.json
settings.json
파일을 분리하면 특정 페이지에서 필요한 번역만 로드할 수 있어 번들 크기 최적화에도 도움이 된다. 네임스페이스 단위로 로드하면 불필요한 번역 데이터를 클라이언트에 보내지 않을 수 있다.
동적 콘텐츠와 복수형 처리
단순 텍스트 치환을 넘어서, 동적 값과 복수형을 처리해야 하는 경우가 많다.
// 동적 값 삽입
t('welcome', { name: 'Bitnara' })
// "Bitnara님, 환영합니다!"
// 복수형 처리 (ICU MessageFormat)
// en.json
{
"items": "{count, plural, =0 {No items} one {1 item} other {# items}}"
}
// ko.json (한국어는 복수형 구분이 없음)
{
"items": "{count}개 항목"
}
한국어는 복수형 구분이 없어서 단순하지만, 영어, 아랍어, 러시아어 등은 복수형 규칙이 복잡하다. ICU MessageFormat을 지원하는 라이브러리를 사용하면 이런 차이를 깔끔하게 처리할 수 있다.
날짜와 숫자 포맷팅
날짜와 숫자 표기법은 로케일마다 크게 다르다. JavaScript의 Intl API를 활용하면 로케일에 맞는 포맷팅을 쉽게 할 수 있다.
// 날짜 포맷팅
const date = new Date('2026-01-15');
new Intl.DateTimeFormat('ko-KR').format(date); // "2026. 1. 15."
new Intl.DateTimeFormat('en-US').format(date); // "1/15/2026"
// 숫자 포맷팅
new Intl.NumberFormat('ko-KR').format(1234567); // "1,234,567"
new Intl.NumberFormat('de-DE').format(1234567); // "1.234.567"
// 통화 포맷팅
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(49000); // "₩49,000"
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(9.99); // "$9.99"
하드코딩된 포맷 대신 Intl API를 일관되게 사용하면, 새로운 로케일을 추가할 때 포맷팅 코드를 수정할 필요가 없다.
RTL(Right-to-Left) 지원
아랍어, 히브리어 같은 RTL 언어를 지원하려면 레이아웃을 좌우 반전해야 한다.
<html lang="ar" dir="rtl">
CSS에서는 margin-left 대신 margin-inline-start, padding-right 대신 padding-inline-end 같은 논리적 속성(logical properties)을 사용하면 RTL 전환이 자연스럽다.
/* 물리적 속성 (RTL에서 문제) */
.sidebar { margin-left: 20px; }
/* 논리적 속성 (RTL 자동 대응) */
.sidebar { margin-inline-start: 20px; }
Tailwind CSS를 사용한다면 rtl: 변형자(variant)를 활용할 수도 있다. 하지만 논리적 속성을 기본으로 사용하는 것이 더 깔끔하다.
URL 기반 로케일과 언어 감지
URL에 로케일을 포함하는 방식은 SEO에 가장 유리하다. 세 가지 패턴이 있다.
- 서브패스:
example.com/ko/about— 가장 보편적이고 구현이 쉽다. - 서브도메인:
ko.example.com/about— DNS 설정이 필요하지만 분리가 명확하다. - 별도 도메인:
example.kr— 비용이 크고 관리가 어렵다.
서브패스 방식을 추천한다. Next.js의 미들웨어에서 Accept-Language 헤더를 확인하거나, 사용자의 이전 선택을 쿠키에 저장해서 자동 리다이렉트할 수 있다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const locales = ['ko', 'en'];
const defaultLocale = 'ko';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const hasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!hasLocale) {
const preferredLocale = request.cookies.get('locale')?.value
|| negotiateLanguage(request.headers.get('accept-language'))
|| defaultLocale;
return NextResponse.redirect(
new URL(`/${preferredLocale}${pathname}`, request.url)
);
}
}
다국어 사이트의 SEO
다국어 사이트에서 SEO를 제대로 하려면 몇 가지 추가 작업이 필요하다.
<!-- hreflang 태그로 동일 콘텐츠의 다른 언어 버전을 알려줌 -->
<link rel="alternate" hreflang="ko" href="https://example.com/ko/about" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
hreflang 태그는 검색 엔진에 "이 페이지에는 한국어, 영어 버전이 있다"고 알려주는 역할을 한다. x-default는 로케일이 명시되지 않은 기본 페이지를 지정한다.
사이트맵에도 각 언어 버전을 명시해야 한다. next-sitemap 같은 도구를 사용하면 자동으로 생성할 수 있다.
i18n 테스트
번역 누락은 사용자 경험을 크게 해친다. 자동화된 테스트로 방지하자.
import { en } from './messages/en';
import { ko } from './messages/ko';
describe('i18n completeness', () => {
test('한국어 번역에 모든 키가 존재해야 한다', () => {
const enKeys = Object.keys(en).sort();
const koKeys = Object.keys(ko).sort();
expect(koKeys).toEqual(enKeys);
});
test('빈 번역 값이 없어야 한다', () => {
Object.entries(ko).forEach(([key, value]) => {
expect(value.trim()).not.toBe('');
});
});
});
이런 테스트를 CI에 포함시키면, 새로운 키를 추가했는데 번역을 빠뜨린 경우 빌드가 실패하므로 배포 전에 잡을 수 있다.
흔한 함정
다국어 구현에서 자주 겪는 문제들을 정리한다.
- 문장 조합: "오늘은 요일입니다"처럼 문장을 조합하면 어순이 다른 언어에서 문제가 생긴다. 전체 문장을 번역 단위로 관리해야 한다.
- 텍스트 길이 변화: 독일어는 영어보다 30~40% 더 길어질 수 있다. UI가 긴 텍스트를 수용할 수 있는지 확인하자.
- 하드코딩된 문자열: 에러 메시지, 토스트 알림, 확인 대화상자 등에서 하드코딩된 문자열을 놓치기 쉽다.
- 이미지 내 텍스트: 이미지에 텍스트가 포함되어 있으면 언어별로 별도 이미지가 필요하다. 가능하면 CSS로 텍스트를 오버레이하자.
- 컨텍스트 부족: "Open"이 동사(열다)인지 형용사(열린)인지에 따라 번역이 달라진다. 번역 키에 컨텍스트를 포함시키자.
마무리
다국어 지원은 처음부터 고려하면 훨씬 수월하다. 나중에 추가하려면 하드코딩된 문자열을 하나씩 찾아서 키로 바꾸는 지루한 작업이 기다리고 있다. 처음부터 모든 텍스트를 번역 키로 관리하고, Intl API를 사용해 포맷팅하며, 번역 완전성 테스트를 CI에 포함시키자. 작은 투자로 전 세계 사용자에게 다가갈 수 있는 기반을 만들 수 있다.