
Python으로 만드는 게임 텍스트 번역 데이터 자동 검증 툴
CSV·JSON 형태의 게임 텍스트를 배포 전에 검사하는 Python 검증 툴을 만든다. 누락 번역, 중복 키, 형식 태그 불일치, 자리표시자 오류를 규칙으로 잡아내는 방법을 예제로 설명한다.
왜 번역 데이터는 자동 검증해야 할까
게임의 텍스트 데이터에는 UI 문구, 퀘스트 대사, 아이템 설명처럼 수천 개의 항목이 들어갈 수 있다. 이 데이터를 사람이 직접 비교하면 누락된 번역이나 {playerName} 같은 자리표시자 오타를 놓치기 쉽다.
번역 품질 자체는 언어 검수가 담당해야 하지만 데이터 구조의 오류는 규칙으로 상당 부분 찾을 수 있다. 특히 빌드 전에 자동 검증을 실행하면 런타임에서 빈 문장이나 서식이 깨진 메시지가 표시되는 문제를 줄일 수 있다.
검증 대상 형식 정하기
예제에서는 관리하기 쉬운 CSV 파일을 사용한다. 첫 줄은 헤더이며 key, ko, en 열을 가진다고 가정한다.
key,ko,en
ui.start,게임 시작,Start Game
ui.welcome,"환영합니다, {playerName}!","Welcome, {playerName}!"
item.potion,체력을 50 회복합니다.,Restores 50 HP.
실무에서는 Google Sheets에서 CSV를 내보내거나 이 CSV를 Unity의 ScriptableObject 및 JSON 데이터로 변환하는 흐름도 흔하다. 중요한 점은 검증 도구가 실제 런타임 데이터가 따르는 규칙을 알아야 한다는 것이다.
다음 흐름처럼 검증 스크립트를 빌드 전 단계나 CI에 연결할 수 있다.
flowchart LR
A[번역 CSV 내보내기] --> B[Python 검증 실행]
B --> C{오류가 있는가?}
C -- 아니오 --> D[데이터 변환 및 게임 빌드]
C -- 예 --> E[오류 목록 확인]
E --> A
먼저 잡을 규칙
처음부터 모든 문제를 해결하려 하기보다 게임에서 실제 장애로 이어지는 규칙부터 추가하는 편이 좋다.
- 키는 비어 있으면 안 되며 중복될 수 없다.
- 필수 언어 열의 번역문은 비어 있으면 안 된다.
- 원문과 번역문에 포함된 자리표시자는 같아야 한다.
- Unity Rich Text처럼 닫는 태그가 필요한 태그는 짝이 맞아야 한다.
- 줄바꿈 수처럼 프로젝트에서 중요한 형식 규칙은 별도 검사한다.
여기서 ‘원문’은 기준 언어를 뜻한다. 이 글에서는 한국어를 기준으로 영어 번역을 비교한다. 실제 프로젝트에서는 기준 언어를 인자로 받도록 만들면 재사용하기 좋다.
CSV를 읽고 오류를 모으는 기본 코드
validate_localization.py 파일을 만들고 아래 코드를 작성한다. 오류를 발견하는 즉시 프로그램을 끝내지 않고 모두 수집한 뒤 출력한다. 한 번의 실행으로 수정할 항목을 최대한 많이 보여 주기 위해서다.
from __future__ import annotations
import csv
import re
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
PLACEHOLDER_PATTERN = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
RICH_TEXT_PATTERN = re.compile(r"</?([a-zA-Z]+)(?:=[^>]*)?>")
@dataclass
class ValidationError:
row: int
key: str
message: str
def __str__(self) -> str:
return f"행 {self.row} | key={self.key!r} | {self.message}"
def extract_placeholders(text: str) -> Counter[str]:
return Counter(PLACEHOLDER_PATTERN.findall(text))
def find_unclosed_tags(text: str) -> list[str]:
stack: list[str] = []
for match in RICH_TEXT_PATTERN.finditer(text):
tag = match.group(0)
name = match.group(1).lower()
if tag.startswith("</"):
if not stack or stack[-1] != name:
return [f"잘못 닫힌 태그 </{name}>"]
stack.pop()
else:
stack.append(name)
return [f"닫히지 않은 태그 <{name}>" for name in stack]
def validate_csv(csv_path: Path) -> list[ValidationError]:
errors: list[ValidationError] = []
seen_keys: dict[str, int] = {}
with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
reader = csv.DictReader(file)
required_columns = {"key", "ko", "en"}
if not reader.fieldnames or not required_columns.issubset(reader.fieldnames):
found = ", ".join(reader.fieldnames or [])
raise ValueError(f"필수 열이 없습니다. 발견한 열: {found}")
for row_number, row in enumerate(reader, start=2):
key = (row["key"] or "").strip()
ko = (row["ko"] or "").strip()
en = (row["en"] or "").strip()
if not key:
errors.append(ValidationError(row_number, key, "키가 비어 있습니다."))
continue
if key in seen_keys:
first_row = seen_keys[key]
errors.append(
ValidationError(row_number, key, f"중복 키입니다. 처음 사용된 행: {first_row}")
)
else:
seen_keys[key] = row_number
if not ko:
errors.append(ValidationError(row_number, key, "한국어 번역이 비어 있습니다."))
if not en:
errors.append(ValidationError(row_number, key, "영어 번역이 비어 있습니다."))
if ko and en:
ko_placeholders = extract_placeholders(ko)
en_placeholders = extract_placeholders(en)
if ko_placeholders != en_placeholders:
errors.append(
ValidationError(
row_number,
key,
"자리표시자가 다릅니다. "
f"ko={dict(ko_placeholders)}, en={dict(en_placeholders)}",
)
)
for language, text in (("ko", ko), ("en", en)):
for tag_error in find_unclosed_tags(text):
errors.append(
ValidationError(row_number, key, f"{language} 열: {tag_error}")
)
return errors
def main() -> int:
if len(sys.argv) != 2:
print("사용법: python validate_localization.py <csv_파일>")
return 2
errors = validate_csv(Path(sys.argv[1]))
if not errors:
print("검증을 통과했습니다.")
return 0
print(f"검증 실패: {len(errors)}개 오류")
for error in errors:
print(f"- {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
자리표시자는 개수까지 비교해야 한다
정규식으로 {이름} 형태를 찾기만 하면 같은 자리표시자가 두 번 필요한 문장에서 오류를 놓칠 수 있다. 예를 들어 한국어가 {count}개 중 {count}개 완료인데 영어에 {count}가 한 번만 있으면 값은 들어가도 문장이 깨진다.
그래서 예제는 set 대신 Counter를 사용한다. Counter는 이름뿐 아니라 등장 횟수도 비교한다. 다만 {0}처럼 숫자 인덱스를 쓰는 프로젝트라면 정규식을 r"\{([A-Za-z0-9_]+)\}"처럼 조정해야 한다.
Rich Text 검사는 프로젝트 문법에 맞춘다
Unity UI에서 <color=red>경고</color> 같은 Rich Text를 사용한다면 번역 과정에서 닫는 태그가 빠질 수 있다. 예제의 find_unclosed_tags는 단순한 스택 방식으로 태그 순서를 검사한다.
이 구현은 모든 HTML 문법을 처리하려는 용도가 아니다. 게임에서 허용하는 태그가 color, b, i, size처럼 정해져 있다면 허용 목록을 두고 그 목록만 검사하는 편이 안전하다. <sprite=3>처럼 닫는 태그가 없는 Unity 태그도 있다면 예외 처리해야 한다.
ALLOWED_TAGS = {"b", "i", "color", "size"}
SELF_CLOSING_TAGS = {"sprite"}
프로젝트의 실제 표기법을 샘플로 수집한 뒤 검사 규칙을 추가해야 한다. 지나치게 엄격한 검사는 번역가가 올바르게 작성한 텍스트까지 오류로 표시해 도구를 신뢰하기 어렵게 만든다.
실행과 종료 코드
터미널에서 다음처럼 실행한다.
python validate_localization.py localization.csv
오류가 없으면 종료 코드 0을, 오류가 있으면 1을 반환한다. 이 차이 덕분에 GitHub Actions, Jenkins, Unity의 빌드 스크립트 같은 자동화 환경에서 검증 실패를 빌드 실패로 처리할 수 있다.
PowerShell에서는 직전 명령의 종료 코드를 다음처럼 확인할 수 있다.
python validate_localization.py localization.csv
$LASTEXITCODE
테스트 데이터로 규칙을 고정하기
검증기가 커질수록 규칙을 바꿨을 때 기존 검사가 깨지지 않는지 확인해야 한다. Python 표준 라이브러리의 unittest만으로도 핵심 규칙을 테스트할 수 있다.
import unittest
from collections import Counter
from validate_localization import extract_placeholders
class PlaceholderTest(unittest.TestCase):
def test_same_placeholder_count(self):
result = extract_placeholders("{name}: {score} / {score}")
self.assertEqual(result, Counter({"score": 2, "name": 1}))
def test_no_placeholder(self):
self.assertEqual(extract_placeholders("게임을 시작합니다."), Counter())
if __name__ == "__main__":
unittest.main()
테스트는 정상 입력뿐 아니라 빈 셀, 중복 키, 깨진 태그, 잘못된 자리표시자를 각각 한 사례씩 포함하는 것이 좋다. 버그가 수정되면 해당 버그를 재현하는 데이터를 테스트에 남겨 둔다.
운영 규칙을 도구에 반영하는 방법
자동 검증 도구는 한 번 만들고 끝나는 프로그램이 아니라 텍스트 제작 규칙을 실행 가능한 형태로 옮긴 것이다. 다음 순서로 확장하면 유지보수가 수월하다.
- 실제로 출시 장애를 일으킨 데이터 오류를 기록한다.
- 사람이 명확히 판단할 수 있는 오류만 규칙으로 만든다.
- 오류 메시지에 행 번호, 키, 수정 방향을 함께 표시한다.
- 번역 데이터가 변경되는 CI 단계에서 항상 실행한다.
번역문이 자연스러운지, 문화적으로 적절한지는 정규식만으로 판정하기 어렵다. 반면 누락 키, 변수 불일치, 태그 오류처럼 구조가 명확한 문제는 자동화의 효과가 크다. 사람의 언어 검수와 기계의 형식 검증을 분리하면 두 과정 모두 더 안정적으로 운영할 수 있다.


