다국어 처리 삽질기.
웹 서비스 다국어(i18n) 처리 실전 가이드: 시행착오와 교훈
한글 서비스를 영어/일본어로 확장하면서 겪은 시행착오와, 처음부터 다국어를 고려했다면 어떻게 했을지에 대한 회고
들어가며
"즐거운 사주(https://www.enjoysaju.com)"라는 사주팔자 기반 운세 서비스를 개발하면서, 한글 버전을 먼저 완성한 후 영어와 일본어를 추가하는 작업을 진행했습니다. 이 과정에서 예상보다 훨씬 많은 시간과 노력이 들었고, "처음부터 다국어를 고려했다면..."이라는 후회를 여러 번 했습니다.
이 글에서는 실제 프로젝트에서 겪은 경험을 바탕으로:
- 현재 아키텍처에서의 다국어 처리 방법
- 왜 그렇게 많은 시행착오가 필요했는지
- 처음부터 다국어를 쉽게 처리하려면 어떻게 해야 하는지
를 정리해보겠습니다.
1. 프로젝트 아키텍처 소개
기술 스택
┌─────────────────────────────────────────────────────────────┐ │ Frontend │ │ Next.js 14 + TypeScript + Tailwind CSS + next-intl │ │ URL: www.enjoysaju.com/[locale]/... │ └─────────────────────────────────────────────────────────────┘ │ │ REST API + Accept-Language 헤더 ▼ ┌─────────────────────────────────────────────────────────────┐ │ Backend │ │ Express + TypeScript + MySQL │ │ URL: api.enjoysaju.com │ └─────────────────────────────────────────────────────────────┘
지원 언어
- 한국어 (ko) - 기본 언어
- 영어 (en)
- 일본어 (ja)
2. 다국어 처리의 두 영역
다국어 처리는 크게 프론트엔드와 백엔드 두 영역으로 나뉩니다.
2.1 프론트엔드 다국어 처리
프론트엔드는 next-intl 라이브러리를 사용하여 처리합니다.
디렉토리 구조:
frontend/ ├── messages/ │ ├── ko.json # 한국어 번역 │ ├── en.json # 영어 번역 │ └── ja.json # 일본어 번역 ├── src/ │ └── app/ │ └── [locale]/ # 동적 라우트 │ ├── page.tsx │ ├── saju/ │ └── fortune/
사용 예시:
// 컴포넌트에서 사용 import { useTranslations } from 'next-intl'; export function SajuPage() { const t = useTranslations('saju'); return ( <h1>{t('title')}</h1> // "사주팔자" | "Four Pillars" | "四柱推命" ); }
messages/ko.json:
{ "saju": { "title": "사주팔자", "description": "생년월일시로 알아보는 나의 사주" } }
2.2 백엔드 다국어 처리
백엔드는 Accept-Language 헤더를 파싱하여 언어를 결정합니다.
언어 미들웨어:
// src/common/middlewares/language.middleware.ts export type SupportedLanguage = 'ko' | 'en' | 'ja'; export function getLanguage(req: Request): SupportedLanguage { const acceptLanguage = req.headers['accept-language'] || 'ko'; if (acceptLanguage.includes('ja')) return 'ja'; if (acceptLanguage.includes('en')) return 'en'; return 'ko'; }
API 응답에서 언어별 데이터 반환:
// 컨트롤러에서 사용 export async function getFortuneHandler(req: Request, res: Response) { const lang = getLanguage(req); const fortune = await calculateFortune(birthData); // 언어에 따라 다른 텍스트 반환 res.json({ grade: fortune.grade, label: getGradeLabelI18n(fortune.grade, lang), // "대길" | "Great" | "大吉" description: getDescriptionI18n(fortune, lang), }); }
3. 시행착오의 원인 분석
3.1 하드코딩된 한글 텍스트
가장 큰 문제는 코드 곳곳에 하드코딩된 한글이었습니다.
// ❌ 이런 코드가 수백 곳에 있었습니다 const title = "사주팔자"; const grades = ["대길", "길", "평", "흉", "대흉"]; const elements = { "목": "나무", "화": "불", "토": "흙", "금": "쇠", "수": "물" };
이런 코드를 찾아서 수정하는 데만 며칠이 걸렸습니다.
3.2 백엔드에서 한글 반환
초기에는 백엔드 API가 한글 텍스트를 직접 반환했습니다.
// ❌ 초기 백엔드 응답 { "dayMaster": { "stem": "갑", "element": "목", "description": "갑목은 큰 나무를 상징합니다" }, "fortune": { "grade": "대길", "summary": "오늘은 좋은 일이 생길 것입니다" } }
이 구조에서는 프론트엔드에서 번역할 수가 없습니다. 결국 백엔드의 모든 텍스트 생성 로직을 수정해야 했습니다.
3.3 동적 텍스트 조합
가장 까다로웠던 부분은 동적으로 조합되는 텍스트였습니다.
// ❌ 이런 동적 텍스트는 번역이 어렵습니다 const message = `${userName}님의 ${year}년 ${month}월 운세입니다`; // 각 언어별 어순이 다릅니다 // 한국어: "홍길동님의 2024년 1월 운세입니다" // 영어: "Fortune for January 2024 for Hong Gildong" // 일본어: "洪吉童さんの2024年1月の運勢です"
3.4 도메인 특화 용어
사주팔자는 동양 철학 용어가 많아 번역이 복잡했습니다.
| 한글 | 영어 | 일본어 | 설명 |
|---|---|---|---|
| 갑 | Jia | 甲 | 천간 |
| 비견 | Companion | 比肩 | 십성 |
| 육합 | Six Harmony | 六合 | 지지 관계 |
| 목생화 | Wood generates Fire | 木生火 | 상생 |
100개가 넘는 전문 용어를 3개 언어로 번역해야 했습니다.
3.5 프론트엔드/백엔드 이중 작업
같은 용어가 프론트엔드와 백엔드 양쪽에 있었습니다.
// 프론트엔드 - 라벨 표시용 const ELEMENT_LABELS = { "목": "木", "화": "火", ... }; // 백엔드 - 분석 결과 텍스트용 const ELEMENT_DESCRIPTIONS = { "목": "나무의 기운", ... };
둘 다 수정해야 했고, 일관성을 유지하기 어려웠습니다.
4. 최종 아키텍처: 어떻게 해결했는가
4.1 프론트엔드: 정적 텍스트는 messages 파일로
// messages/ko.json { "saju": { "title": "사주팔자", "form": { "name": "이름", "birthDate": "생년월일", "gender": { "male": "남성", "female": "여성" } } }, "elements": { "wood": "목(木)", "fire": "화(火)", "earth": "토(土)", "metal": "금(金)", "water": "수(水)" } }
4.2 백엔드: i18n 함수로 분리
// src/i18n/constants/elements.ts export const ELEMENT_NAMES_I18N: Record<SupportedLang, Record<Element, string>> = { ko: { '목': '목', '화': '화', '토': '토', '금': '금', '수': '수' }, en: { '목': 'Wood', '화': 'Fire', '토': 'Earth', '금': 'Metal', '수': 'Water' }, ja: { '목': '木', '화': '火', '토': '土', '금': '金', '수': '水' }, }; export function getElementName(element: Element, lang: SupportedLang): string { return ELEMENT_NAMES_I18N[lang][element]; }
4.3 API 응답 구조 개선
// ✅ 개선된 백엔드 응답 { "dayMaster": { "stem": "갑", // 코드값 (언어 무관) "stemDisplay": "甲", // 표시용 (언어별) "element": "wood", // 코드값 "elementDisplay": "木" // 표시용 }, "meta": { "language": "ja" } }
4.4 번역 키 기반 응답 (대안)
복잡한 텍스트는 키만 반환하고 프론트엔드에서 번역하는 방법도 있습니다.
// 백엔드 응답 { "fortune": { "gradeKey": "great", // 번역 키 "descriptionKey": "fortune.great.description" } } // 프론트엔드에서 번역 const label = t(`grades.${data.gradeKey}`); // "대길" | "Great" | "大吉"
5. 처음부터 다국어를 고려한다면
5.1 핵심 원칙: "하드코딩 절대 금지"
// ❌ 절대 이렇게 하지 마세요 const title = "사주팔자"; const message = "오늘의 운세입니다"; // ✅ 처음부터 이렇게 하세요 const title = t('saju.title'); const message = t('fortune.todayMessage');
한글 버전만 만들더라도 번역 함수를 통해 접근하세요.
5.2 messages 파일 구조 먼저 설계
프로젝트 시작 시 번역 키 구조를 먼저 설계합니다.
// messages/ko.json - 구조만 먼저 잡기 { "common": { "buttons": { "submit": "", "cancel": "", "back": "" }, "labels": { "name": "", "date": "", "gender": "" }, "messages": { "loading": "", "error": "", "success": "" } }, "saju": { "title": "", "form": {}, "result": {} } }
5.3 백엔드는 "코드값 + 표시값" 구조로
// ✅ 처음부터 이렇게 설계 interface ApiResponse { // 코드값 (언어 무관, 프로그래밍용) code: string; // 표시값 (언어별, UI 표시용) display: string; // 또는 번역 키 translationKey: string; }
5.4 동적 텍스트는 ICU MessageFormat 사용
// messages/ko.json { "fortune": { "header": "{name}님의 {year}년 {month}월 운세" } } // messages/en.json { "fortune": { "header": "Fortune for {name} - {month}/{year}" } }
// 사용 t('fortune.header', { name: '홍길동', year: 2024, month: 1 })
5.5 체크리스트 만들기
새 기능 개발 시 체크리스트:
- UI 텍스트가 번역 함수를 통해 접근하는가?
- API 응답에 하드코딩된 텍스트가 없는가?
- messages 파일에 새 키가 추가되었는가?
- 동적 텍스트는 MessageFormat을 사용하는가?
6. 실무 권장 워크플로우
6.1 MVP 단계 (한글만)
1. messages/ko.json 구조 설계 2. 번역 함수(t) 사용하여 개발 3. 백엔드는 코드값 + 한글 표시값 반환 4. 한글 버전 출시
6.2 다국어 확장 단계
1. messages/en.json, ja.json 생성 (구조 복사) 2. 번역 작업 (외부 번역가 또는 AI 활용) 3. 백엔드 i18n 함수 추가 4. 테스트 및 출시
6.3 번역 비용 추정
| 항목 | 처음부터 i18n | 나중에 추가 |
|---|---|---|
| 초기 개발 | +20% | 0% |
| 다국어 확장 | 번역만 | 리팩토링 + 번역 |
| 총 비용 | 1.2x | 1.5~2x |
결론: 처음부터 구조만 잡아두면 총 비용이 적습니다.
7. 유용한 도구들
7.1 프론트엔드
- next-intl: Next.js 공식 권장 i18n 라이브러리
- react-i18next: React 범용 i18n 라이브러리
- i18n Ally: VS Code 확장, 번역 키 자동완성
7.2 백엔드
- i18next: Node.js i18n 라이브러리
- Accept-Language 파싱:
accept-language-parsernpm 패키지
7.3 번역 관리
- Crowdin: 번역 협업 플랫폼
- Lokalise: 개발자 친화적 번역 관리
- Google Sheets: 간단한 프로젝트용
8. 이번 프로젝트에서 배운 점
8.1 숫자로 보는 시행착오
| 항목 | 수량 |
|---|---|
| 수정한 프론트엔드 파일 | 39개 |
| 수정한 백엔드 파일 | 73개 |
| 추가한 번역 키 | 500개 이상 |
| 전문 용어 번역 | 100개 이상 |
| 소요 시간 | 약 1주일 |
8.2 핵심 교훈
- 하드코딩은 기술 부채다: 나중에 반드시 비용으로 돌아옵니다.
- 구조 비용 < 리팩토링 비용: 처음 20% 더 투자하면 나중에 50% 절약
- 백엔드도 i18n 대상이다: API 응답 설계 시 다국어를 고려해야 합니다.
- 도메인 용어는 미리 정리: 전문 용어 목록을 먼저 만들고 시작하세요.
마치며
다국어 처리는 "나중에 하면 된다"고 미루기 쉬운 작업입니다. 하지만 실제로 나중에 추가하려면 예상보다 훨씬 많은 시간과 노력이 필요합니다.
처음부터 완벽한 번역이 필요한 게 아닙니다. 단지 t('key') 형태로 접근하는 구조만 갖추면 됩니다. 한글 messages 파일만 있어도 괜찮습니다. 나중에 en.json, ja.json을 추가하기만 하면 되니까요.
이 글이 다국어 서비스를 준비하는 분들에게 도움이 되길 바랍니다.
참고 자료
이 글은 "즐거운 사주" 프로젝트의 다국어 지원 작업 경험을 바탕으로 작성되었습니다.
댓글
댓글을 작성하려면 이 필요합니다.