개요
국제화(i18n)를 구현하다 보면 번역 파일을 어떻게 관리할 것인가?라는 질문을 마주하게 된다. 처음에는 하나의 거대한 JSON 파일에 모든 번역을 때려넣으면 될 것 같지만, 프로젝트가 조금만 커져도 수천 개의 키가 뒤엉키게 된다.
그래서 이번 글에서는 국제화 파일을 페이지별로 분할해서 관리하되, 빌드 시점에 자동으로 병합하는 방식을 소개하려고 한다. 단순히 이렇게 하면 된다고 결론부터 내리기보다, 왜 이런 접근이 필요했는지, 그리고 실제로 어떻게 구현했는지의 흐름을 따라가보자.
문제
파일 분할의 필요성
국제화 파일을 단일 파일로 관리하면 처음에는 간단해 보인다. en.json 하나, ko.json 하나, 끝. 더 필요하면 es.json 혹은 ja.json 정도. 하지만 프로젝트가 커지면서 문제가 드러난다. ▼
// en.json - 1000줄이 넘어가는 거대한 파일
{
"login_title": "Login",
"login_email": "Email",
"login_password": "Password",
// ... 수백 개의 login 관련 키들
"register_title": "Register",
"register_name": "Name",
// ... 수백 개의 register 관련 키들
"profile_edit": "Edit Profile",
// ... 계속해서 늘어나는 키들
}
이런 구조의 문제점은 명확하다. 특정 페이지의 번역을 수정하려면 거대한 파일에서 해당 키를 찾아 헤매야 하고, 여러 개발자가 동시에 작업하면 git 충돌이 일상이 된다. 게다가 어떤 키가 어느 페이지에서 사용되는지 파악하기도 어렵다. 인덱싱이 전혀 안되기 때문에 문구를 찾는데에 시간이 많이 할애된다.
"주석을 통해 구분을 하면 되지 않나?"
주석을 통해 구분을 하는 방법도 생각을 안해보진 않았다. 하지만 json에서는 주석을 허용하지 않기에(PostMan이나 다른 서비스에서 허용하는 주석은 해당 서비스만의 기능에 가깝다) 이 방법은 우회를 필요로 한다.
주석 대신에 아래와 같은 방식으로 데이터를 통한 구분도 가능은 하지만 IDE에서 인지할 수 없기에 가독성면에서 좋지 못하다. ▼
...
"__loginPage__": "로그인 페이지의 국제화",
"login": "로그인",
"id & password" : "아이디와 패스워드",
...
기존 라이브러리의 한계
Flutter의 easy_localization
Flutter에서 가장 널리 쓰이는 easy_localization 패키지를 사용해보니, 한 언어에서 여러 개의 파일로 나누어 적용하는 것이 불가능했다. 예를 들어 이런 구조로 국제화 파일을 만들 수 없다. ▼
translations/
en/
en_login.json
en_register.json
en_profile.json
라이브러리는 오직 en.json 하나만 인식한다. 결국 모든 번역을 한 파일에 몰아넣어야 했고, 페이지별 관리는 주석을 통한 방식 말고는 사용할 수 없었다.
React의 react-i18next
React 진영의 react-i18next도 마찬가지였다. 문서를 뒤져봐도 JSON 파일을 자동으로 분할 병합하는 기능은 찾을 수 없었다. namespace 기능이 있긴 하지만, 결국 코드에서 명시적으로 각 파일을 import해야 하는 방식이라 근본적인 해결책은 아니었다.
스크립트로 해결하기
"그럼 우리가 만들면 되지 않나?"
단순한 발상이었다. 개발 중에는 페이지별로 분할된 파일을 관리하고, 빌드 전에 스크립트로 자동 병합하는 방식이다. 작업 흐름은 이렇다.
- 개발자는 페이지별로 번역 파일 작성 (en_login.json, en_register.json)
- 스크립트 실행하면 자동으로 en.json으로 병합
- 중복 키는 자동 제거 (첫 번째 값만 유지)
새로운 값이 추가되면 파일의 가장 하단에 추가 - 다른 언어 파일도 자동 동기화 (빈 값으로 키만 추가)
병합 스크립트 구현
아래는 Node.js와 dart로 구현한 병합 스크립트들이다.
dart 스크립트
dart ▼
// tool/merge_and_sync_translations.dart
import 'dart:io';
import 'dart:convert';
/// en 폴더 아래 JSON 파일 병합 및,
/// 기존 en.json이 있으면 그 순서를 유지하며 새 키만 맨 아래에 추가
Future<void> mergeTranslations() async {
final inputDir = Directory('assets/translations/en');
if (!await inputDir.exists()) {
print('디렉터리 없음: ${inputDir.path}');
return;
}
// 0) 이전에 만들어둔 en.json(기준 파일) 읽기
final outFile = File('assets/translations/en.json');
Map<String, dynamic> oldMap = {};
final oldOrder = <String>[];
if (await outFile.exists()) {
final text = await outFile.readAsString();
if (text.trim().isNotEmpty) {
oldMap = jsonDecode(text) as Map<String, dynamic>;
oldOrder.addAll(oldMap.keys);
}
}
// 1) 폴더 스캔해서 newMap과 scanOrder 구성 (첫 등장만)
final newMap = <String, dynamic>{};
final scanOrder = <String>[];
await for (var entity in inputDir.list(recursive: true, followLinks: false)) {
if (entity is File && entity.path.toLowerCase().endsWith('.json')) {
final text = await entity.readAsString();
if (text.trim().isEmpty) continue;
final jsonMap = jsonDecode(text) as Map<String, dynamic>;
jsonMap.forEach((key, value) {
if (!newMap.containsKey(key)) {
newMap[key] = value;
scanOrder.add(key);
}
});
}
}
// 2) oldOrder 순서 유지하며, 남아있는 키만 순서대로 넣기
final ordered = <String, dynamic>{};
for (var key in oldOrder) {
if (newMap.containsKey(key)) {
ordered[key] = newMap[key]!;
newMap.remove(key);
}
}
// 3) scanOrder 순서대로, newMap에 남은(=새로 추가된) 키 뒤에 붙이기
for (var key in scanOrder) {
if (newMap.containsKey(key)) {
ordered[key] = newMap[key]!;
newMap.remove(key);
}
}
// 4) 파일 기록
await outFile.create(recursive: true);
await outFile.writeAsString(
const JsonEncoder.withIndent(' ').convert(ordered),
);
print('✅ en.json 병합 완료: ${ordered.length}개 키 (기존 순서 유지, 새 키는 맨 아래)');
}
/// en.json -> es.json 동기화 (없는 키는 빈 문자열로 추가하고, 새 키는 맨 아래에)
Future<void> syncEnToEs() async {
final enFile = File('assets/translations/en.json');
final esFile = File('assets/translations/es.json');
if (!await enFile.exists()) {
print('Error: en.json 없음');
return;
}
final enContent = await enFile.readAsString();
if (enContent.trim().isEmpty) {
print('Error: en.json 비어 있음');
return;
}
final enMap = jsonDecode(enContent) as Map<String, dynamic>;
// 기존 es.json 읽기
Map<String, dynamic> oldEsMap = {};
final esOrder = <String>[];
if (await esFile.exists()) {
final esContent = await esFile.readAsString();
if (esContent.trim().isNotEmpty) {
oldEsMap = jsonDecode(esContent) as Map<String, dynamic>;
esOrder.addAll(oldEsMap.keys); // 원래 순서 기억
}
}
// 1) 기존 esOrder 순서대로 번역값 채우기 (enMap에 없더라도 보존)
final ordered = <String, dynamic>{};
for (var key in esOrder) {
ordered[key] = oldEsMap[key];
}
// 2) enMap에만 있는(=새로 추가된) 키를 빈 문자열과 함께 맨 아래에 추가
for (var key in enMap.keys) {
if (!oldEsMap.containsKey(key)) {
ordered[key] = '';
}
}
// 3) 파일에 쓰기
await esFile.create(recursive: true);
await esFile.writeAsString(
const JsonEncoder.withIndent(' ').convert(ordered),
);
print('✅ es.json 동기화 완료: ${ordered.length}개 키 (새 키는 맨 아래)');
}
Future<void> main() async {
await mergeTranslations();
stdout.write('en.json → es.json 동기화 진행할래? (y/n): ');
final input = stdin.readLineSync();
if (input?.toLowerCase() == 'y') {
await syncEnToEs();
} else {
print('동기화 건너뜀');
}
}
Node.js 스크립트
Node.js ▼
const fs = require('fs').promises;
const path = require('path');
async function mergeTranslations() {
const inputDir = path.join('public', 'locales', 'en');
const outputFile = path.join('public', 'locales', 'en.json');
try {
// 기존 en.json 읽기 (순서 유지용)
let oldMap = {};
let oldOrder = [];
try {
const oldContent = await fs.readFile(outputFile, 'utf-8');
if (oldContent.trim()) {
oldMap = JSON.parse(oldContent);
oldOrder = Object.keys(oldMap);
}
} catch (err) {
// 파일이 없으면 무시
}
// 분할된 파일들 읽어서 병합
const newMap = {};
const scanOrder = [];
const files = await fs.readdir(inputDir);
for (const file of files) {
if (!file.endsWith('.json')) continue;
const filePath = path.join(inputDir, file);
const content = await fs.readFile(filePath, 'utf-8');
if (!content.trim()) continue;
const jsonData = JSON.parse(content);
Object.entries(jsonData).forEach(([key, value]) => {
if (!newMap.hasOwnProperty(key)) {
newMap[key] = value;
scanOrder.push(key);
}
});
}
// 기존 순서 유지하면서 병합
const ordered = {};
// 1) 기존 키들 순서대로
for (const key of oldOrder) {
if (newMap.hasOwnProperty(key)) {
ordered[key] = newMap[key];
delete newMap[key];
}
}
// 2) 새로운 키들은 맨 아래에
for (const key of scanOrder) {
if (newMap.hasOwnProperty(key)) {
ordered[key] = newMap[key];
}
}
// 파일 쓰기
await fs.writeFile(
outputFile,
JSON.stringify(ordered, null, 2)
);
console.log(`✅ en.json 병합 완료: ${Object.keys(ordered).length}개 키`);
return ordered;
} catch (error) {
console.error('❌ 병합 실패:', error);
throw error;
}
}
// 다른 언어 파일 동기화
async function syncToOtherLanguages(enData, targetLang = 'ko') {
const targetFile = path.join('public', 'locales', `${targetLang}.json`);
try {
// 기존 파일 읽기
let oldData = {};
let oldOrder = [];
try {
const content = await fs.readFile(targetFile, 'utf-8');
if (content.trim()) {
oldData = JSON.parse(content);
oldOrder = Object.keys(oldData);
}
} catch (err) {
// 파일 없으면 무시
}
const ordered = {};
// 1) 기존 순서대로 값 유지
for (const key of oldOrder) {
ordered[key] = oldData[key];
}
// 2) 새로운 키는 빈 문자열로 추가
for (const key of Object.keys(enData)) {
if (!oldData.hasOwnProperty(key)) {
ordered[key] = '';
}
}
await fs.writeFile(
targetFile,
JSON.stringify(ordered, null, 2)
);
console.log(`✅ ${targetLang}.json 동기화 완료: ${Object.keys(ordered).length}개 키`);
} catch (error) {
console.error(`❌ ${targetLang} 동기화 실패:`, error);
throw error;
}
}
// 메인 실행
async function main() {
try {
const enData = await mergeTranslations();
// 다른 언어들 동기화
const languages = ['ko', 'ja', 'es']; // 필요한 언어 추가
for (const lang of languages) {
await syncToOtherLanguages(enData, lang);
}
} catch (error) {
console.error('스크립트 실행 실패:', error);
process.exit(1);
}
}
// 스크립트 실행
if (require.main === module) {
main();
}
module.exports = { mergeTranslations, syncToOtherLanguages };
+) dart : 실행
프로젝트 최상단에 두고 dart를 통해 실행한다. ▼
dart translation.dart
// 혹은 지정한 파일명 dart [지정한 파일명].dart 로 실행
+) node : package.json에 스크립트 추가
편하게 실행할 수 있도록 package.json에 추가한다. ▼
{
"scripts": {
"i18n:merge": "node scripts/merge-translations.js",
"build": "npm run i18n:merge && next build"
}
}
핵심 기능 설명
기존 순서 유지
스크립트의 핵심은 기존 키 순서를 유지하면서 새 키만 맨 아래에 추가하는 것이다. 왜 이게 중요할까? ▼
// 처음 병합
{
"login_title": "Login",
"login_email": "Email",
"register_title": "Register"
}
// 새 키 추가 후 재병합 - 순서 유지됨!
{
"login_title": "Login",
"login_email": "Email",
"register_title": "Register",
"login_password": "Password" // 새 키는 맨 아래
}
이렇게 하면 git diff가 깔끔해지고, 번역가가 작업할 때도 새로 추가된 부분만 맨 아래에서 찾으면 된다.
중복 키 처리
'만약 여러 파일에 같은 키가 있으면 어떻게 되나?'
첫 번째로 발견된 값만 유지한다. 그래서 파일명을 의도적으로 순서를 정할 수 있게 네이밍하면 우선순위를 제어할 수 있다. ▼
en/
00_common.json // 공통 문구 (최우선)
10_login.json // 로그인 페이지
20_register.json // 회원가입 페이지
빈 값 동기화
새로운 키가 추가되면 다른 언어 파일에도 자동으로 빈 문자열로 추가된다. ▼
// ko.json - 자동 생성됨
{
"login_title": "로그인", // 기존 번역 유지
"login_email": "이메일", // 기존 번역 유지
"login_password": "" // 새 키는 빈 값으로
}
번역가는 빈 값만 찾아서 채우면 되니 작업이 한결 수월해진다.
실제 사용 예시
Flutter 프로젝트 구조는 이렇게 구성한다. ▼
assets/
translations/
en/
en_common.json
en_login.json
en_dashboard.json
en_profile.json
en.json // 자동 생성
ko.json // 자동 생성
ja.json // 자동 생성
Web 프로젝트 구조는 이렇게 구성한다. ▼
public/
locales/
en/
en_common.json
en_login.json
en_dashboard.json
en_profile.json
en.json // 자동 생성
ko.json // 자동 생성
ja.json // 자동 생성
개발자는 작업하는 페이지의 파일만 수정하면 된다. ▼
// en/en_login.json
{
"login_title": "Welcome Back",
"login_subtitle": "Sign in to continue",
"login_email_label": "Email Address",
"login_password_label": "Password",
"login_remember_me": "Remember me",
"login_forgot_password": "Forgot password?",
"login_submit": "Sign In"
}
Flutter라면 `dart translation.dart`를 빌드 전에 실행, web이라면 빌드 전에 `npm run i18n:merge`를 실행하면 모든 파일이 자동으로 병합된다.
장점과 한계
장점
- 페이지별 관리: 작업 중인 페이지의 번역만 집중해서 볼 수 있음
- Git 충돌 최소화: 여러 개발자가 다른 페이지 작업 시 충돌 거의 없음
- 자동화: 수동 병합 작업 불필요
- 번역 작업 효율화: 새 키만 맨 아래 추가되어 찾기 쉬움
한계
- 빌드 과정 추가: 스크립트 실행이 빌드 파이프라인에 포함되어야 함
- 실시간 반영 불가: 개발 중 파일 수정 시 스크립트 재실행 필요
마치며
이번 글에서는 국제화 파일을 페이지별로 분할 관리하는 방법을 소개했다. 요약하자면 이렇다. ▼
- 기존 라이브러리들은 파일 분할을 제대로 지원하지 않아, 대규모 프로젝트에서 관리가 어려움
- 자동 병합 스크립트를 통해 개발 편의성과 빌드 요구사항을 모두 충족
- 기존 순서 유지와 새 키 추가 방식으로 번역 작업의 효율성도 개선
완벽한 해결책은 아니지만, 지금 당장 쓸 수 있는 실용적인 방법이라는 점에서 의미가 있다고 생각한다. 앞으로는 파일 watch 기능을 추가해서 실시간 병합이 되도록 개선하거나, 네임스페이스 개념을 도입해서 같은 키를 다른 맥락에서도 사용할 수 있게 확장해봐야겠다.
무엇보다 이런 작은 도구 하나가 팀 전체의 생산성을 크게 개선할 수 있다는 점을 다시 한 번 실감했다. 때로는 거창한 아키텍처보다 이런 소소한 자동화가 더 큰 가치를 만들어내는 것 같다.
'Develop > Develop' 카테고리의 다른 글
| [Develop] 모델 검증 로직은 어디에 위치하는게 좋을까 (0) | 2025.07.03 |
|---|---|
| [Develop] 제어 주도에 따른 동기와 비동기 (0) | 2025.05.12 |
| [Develop] svg vs png : 뭐가 더 좋을까? (0) | 2025.02.07 |