로컬 모델이 번역가를 대신하지는 못합니다. 그래도 짧은 UI 문자열이라면 무료로 쓸 만한 초안을 얻을 수 있고, 텍스트는 컴퓨터 밖으로 나가지 않습니다. 작업의 대부분은 모델 바깥에서 이루어집니다. 문맥을 주고, 플레이스홀더를 지키고, 돌아온 결과를 검토하는 일입니다.
모델 설치하기
Ollama에서는 명령 하나로 모델을 내려받고, 로컬 서버가 http://localhost:11434에서 대기합니다.
ollama pull gemma3:12b
120억 매개변수 모델은 기본 양자화 빌드로 16 GB 메모리에 들어갑니다. 작은 컴퓨터에서는 gemma3:4b로 간단한 문자열을 처리할 수 있지만, 긴 문장에서는 실수가 늘어납니다.
LM Studio에서는 앱에서 모델을 고르고 로컬 서버를 시작합니다. OpenAI 호환 API가 기본적으로 http://localhost:1234/v1에 열립니다.
모델에게 알려 줘야 할 것
"Share list"만 보내면 모델은 그것이 버튼인지, 제목인지, 동사인지 추측할 수밖에 없습니다. 확실히 차이를 만드는 것은 세 가지입니다.
- 키의 설명: ARB 파일에
@key.description을 채워 두었다면 쓸 수 있습니다. 설명을 쓰는 가장 좋은 이유입니다. - 앱의 문맥: "가족용 장보기 목록 앱, 쉬운 어조, 프랑스어에서는 친근한 tu" 같은 정보.
- 형식 규칙: 모든
{placeholder}와 ICU 구문을 그대로 두고, 번역만 답할 것.
temperature도 0으로 설정하세요. 번역에 필요한 것은 가장 가능성 높은 답입니다.
출발점이 될 스크립트
이 Dart 스크립트는 app_fr.arb에서 빠진(또는 비어 있는) 키를 Ollama로 하나씩 번역합니다. ARB 파일은 건드리지 않습니다. 초안은 별도 파일에 저장되고, 플레이스홀더가 바뀐 답은 따로 제외됩니다.
import 'dart:convert';
import 'dart:io';
const model = 'gemma3:12b';
final placeholder = RegExp(r'\{[A-Za-z_]\w*\}');
Future<void> main() async {
final source = _read('lib/l10n/app_en.arb');
final target = _read('lib/l10n/app_fr.arb');
final drafts = <String, String>{};
for (final key in source.keys.where((k) => !k.startsWith('@'))) {
if ((target[key] as String?)?.isNotEmpty ?? false) continue;
final text = source[key] as String;
final description = (source['@$key'] as Map?)?['description'] ?? '';
final draft = await _translate(text, description as String);
if (_placeholders(draft) != _placeholders(text)) {
stderr.writeln('$key: placeholders changed, redo by hand');
continue;
}
drafts[key] = draft;
}
File('app_fr.drafts.json').writeAsStringSync(const JsonEncoder.withIndent(' ').convert(drafts));
print('${drafts.length} drafts in app_fr.drafts.json');
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
String _placeholders(String text) => (placeholder.allMatches(text).map((m) => m[0]!).toList()..sort()).join(',');
Future<String> _translate(String text, String description) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse('http://localhost:11434/api/generate'));
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'model': model,
'stream': false,
'options': {'temperature': 0},
'prompt': 'Translate this mobile app string from English to French. '
'Keep every {placeholder} and the ICU plural/select syntax unchanged. '
'Context: $description\n'
'Reply with the translation only.\n\n$text',
}));
final response = await request.close();
final body = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
client.close();
return (body['response'] as String).trim();
}
프롬프트에 앱의 문맥을 추가하고, 대상 언어가 프랑스어가 아니라면 바꾸세요(한국어라면 app_ko.arb와 "to Korean").
잘못되는 부분
ICU 복수형. 가장 약한 부분입니다. 모델은 other 키워드를 번역하거나, 분기를 빠뜨리거나, 한 분기에서만 {count}의 이름을 바꿀 수 있습니다. 스크립트의 플레이스홀더 비교로 일부는 잡을 수 있지만, 나머지는 모든 복수형을 검토해서 확인하세요. 프랑스어에서 0에도 적용되는 =1 분기도 주의해야 합니다(자세한 내용은 ICU 복수형 가이드 참고).
고유 명사. 모델은 앱 이름, 유료 플랜 이름, 기능 이름까지 번역해 버리기 쉽습니다. 프롬프트에 유지할 용어로 나열하고, 나중에 확인하세요.
일관성. 문자열은 하나씩 따로 번역되므로 "item"이 어떤 곳에서는 "article", 다른 곳에서는 "élément"이 될 수 있습니다. 프롬프트에 짧은 용어집을 넣으면 대부분 해결됩니다.
길이. 프랑스어와 독일어는 영어보다 길어지기 쉽습니다. 영어에서 들어맞던 버튼이 넘칠 수 있으며, 이는 화면에서만 확인할 수 있습니다.
LM Studio의 경우
바뀌는 것은 요청뿐입니다. API는 OpenAI 형식을 따릅니다. 메시지 목록을 /v1/chat/completions로 보내고 응답에서 choices[0].message.content를 읽습니다. 스크립트의 나머지는 그대로입니다.
검토한 뒤 병합하기
초안 파일을 app_en.arb 옆에 열고 키를 하나씩 살펴봅니다. 좋은 것은 app_fr.arb에 복사하고, flutter gen-l10n을 다시 실행한 뒤 해당 화면을 확인합니다. LocalePilot은 이 과정을 자동화합니다. Ollama나 LM Studio의 제안은 "검토할 AI 초안"으로 작업 공간에 들어오고, QA가 플레이스홀더를 비교하며, 내보내기 전까지 파일에는 아무것도 기록되지 않습니다.