{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[Unreal Engine] AI 기반 10개국어 현지화 사례

번역을 맡길 돈이 없어요!

 Phil in the Mirror는 7000+ 단어의 텍스트 분량을 가진 게임이었는데요. AI를 기반으로 한국어를 포함한 10개국어의 번역을 목표로 했습니다. 특히, 서사 기반의 퍼즐 게임이었던 만큼 번역 검수에는 신경을 많이 썼던 것 같습니다. 은유적인 표현들로 퍼즐들이 구성되기도 했기 때문에, 약간의 뉘앙스 차이만으로도 퍼즐의 난이도가 완전히 달라질 염려가 있었기 때문입니다.


  언리얼 엔진에서는 프로젝트의 텍스트들을 수집하여 *.po 파일로 export하는 것을 지원합니다. po 파일의 구성은 대략적으로 다음과 같은 항목들의 나열입니다:

#. Key:	Content_Init_030
#. SourceLocation:	/Game/StringTable/ST_Computer.ST_Computer
#: /Game/StringTable/ST_Computer.ST_Computer
msgctxt "ST_Computer,Content_Init_030"
msgid "모듈 로딩 중: 입원, 진단, 청구..."
msgstr ""

  msgctxt의 경우 언리얼 엔진에서 StringTable의 namespace이고, msgid는 원어, msgstr은 번역본이 됩니다. 구조 자체는 굉장히 단순하므로, 사실 이것만으로도 충분히 CLI 기반 AI가 번역할 수 있다고 생각했습니다. msgstr의 내용을 채우라고 시키는 것이죠. Gemini CLI를 이용하여 번역을 시도했습니다.


결과는 쓸만하긴 했으나 여러 문제들이 있었습니다.

문장부호 처리

  po 파일은 msgctxt, msgid, msgstr와 같은 key가 있고, 그 뒤에 큰따옴표와 함께 내용이 나오는 구조를 갖고 있습니다. 만약 번역 내용 안에 큰따옴표가 있다면, \"와 같은 형태로 작성해야 합니다. 문제는 AI가 이런 형태에서 잦은 실수를 한다는 것이었습니다. 파일의 구조를 무시하고 큰따옴표 없이 번역을 수행한다든가, \"가 아닌 "를 써버려서 po 파일의 구조를 망가뜨린다거나 하는 것이었습니다. 예를 들어 다음처럼, 각 내용을 큰따옴표로 감싸지 않아 po파일의 구조가 완전히 깨져버리곤 했습니다:

#. Key:	Content_Init_030
#. SourceLocation:	/Game/StringTable/ST_Computer.ST_Computer
#: /Game/StringTable/ST_Computer.ST_Computer
msgctxt ST_Computer,Content_Init_030
msgid 모듈 로딩 중: 입원, 진단, 청구...
msgstr Carregando módulos: Internação, Diagnóstico, Faturamento...


토큰 소모

  또한 굳이 필요없는 정보들이 po 파일에 포함되어 있어서, 토큰의 소모도 꽤 있었습니다. 기존 미번역된 po 파일에 대응되는 번역된 새로운 .po 파일을 생성해내는 것이기 때문입니다. 이는 일부 내용을 누락시키기도 했습니다. 저는 하루에 일정량 무료로 제공하는 gemini-2.5-pro만으로 번역을 수행하는 것이 목표였기 때문에 이는 중요한 문제였습니다. api를 결제해서 사용한다고 해도 마찬가지로 중요한 문제이긴 했겠지요.


해결 방법

  물론 혹자는 프롬프트를 제대로 쓰지 않아서 그런 것이라고 지적할 수도 있겠지만, 구조적으로 AI가 손쉽게 번역을 할 수 있는 단순한 형태로 만드는 것 또한 중요하다고 생각했습니다. 


msgid와 msgstr 쌍에만 집중하기

  msgctxt은 언리얼 엔진의 스트링 테이블에서 네임스페이스 값을 의미합니다. 이는 각종 맥락에 따라 같은 원어여도 다른 번역이 필요할 때 유용하게 사용될 수 있습니다. 하지만 대부분의 경우에는 큰 의미가 없고, 개발하는 과정에서 편의상 에셋을 나누기 위해 다른 네임스페이스를 갖는 경우도 있을 것입니다. 따라서 이러한 정보는 과감하게 없애고, msgid와 msgstr 쌍에만 집중하기로 했습니다.


파일 형식 간소화하기

  기존의 po 파일 형태는 msgid, msgstr 뒤에 큰따옴표로 감싸진 값이 와야만 했습니다. 이런 불필요한 구조를 제거하고, 단순히 개행으로 구분되는 형태로 변경하였습니다. 다음처럼요:

msgid
기타 문의 사항은 관리부(내선 <redact>99</>)로 연락 바랍니다.
msgstr
W przypadku innych pytań prosimy o kontakt z działem administracji (wew. <redact>99</>).


파일 나누어서 처리하기

  7000+ 단어를 번역하기 위한 파일 자체는 아무리 간소화해도 여전히 긴 내용을 담고 있을 것입니다. 이런 긴 파일의 내용을 한 번에 AI가 처리하게 시키는 것보다는, 나누어서 단계적으로 처리하도록 만드는 것이 훨씬 더 좋은 결과를 낼 것이라고 믿었습니다. 저는 AI 전문가가 아니므로 사실 이러한 접근이 실제로 효용이 있는지는 잘 모르겠으나, 적어도 제 사례에서는 유효한 것 같은 느낌을 주었습니다. 일정 개수의 msgid, msgstr쌍으로 파일을 나누는 것이죠.


파이썬 프로그램 작성

프로그램의 모습

  언리얼 엔진으로부터 export한 po 파일을, 위에서 제안된 해결책을 이용해 가공해줄 자동화된 프로그램이 필요했습니다. 파이썬으로 간단하게 만들어볼 수 있을 것이라고 생각했습니다. 마침 po 파일을 다룰 수 있는 라이브러리도 존재했어서, 제가 원하는 형태로 구성할 수 있었습니다. 프로세스는 다음과 같습니다.


번역되지 않은 항목 추출

  우선 po 파일에서 번역되지 않은 항목들을 추출할 필요가 있었습니다. 기존에 번역된 항목들을 굳이 다시 재번역할 필요는 없었기 때문입니다. 이렇게 추출된 항목들은 간소화된 msgid, msgstr 쌍으로 만들어져 파일로 구성됩니다. 다음 코드를 참고하세요:

def extract_untranslated_gui(self):
    po_file = self.po_file_path.get()
    work_dir = self.work_dir.get()

    if not po_file or not os.path.exists(po_file):
        messagebox.showerror("오류", "유효한 .po 파일을 선택하세요.")
        return
    if not work_dir or not os.path.isdir(work_dir):
        messagebox.showerror("오류", "유효한 작업 폴더를 선택하세요.")
        return

    try:
        self.update_status("추출 작업 중...", "orange")
        output_file = os.path.join(work_dir, os.path.basename(po_file).replace(".po", "_untranslated.txt")) # 변경
        count = self.extract_untranslated(po_file, output_file)
        messagebox.showinfo("성공", f"총 {count}개의 번역되지 않은 항목을 추출했습니다.\n파일: {output_file}")
        self.update_status(f"추출 완료: {count}개 항목", "green")
    except Exception as e:
        messagebox.showerror("오류", f"추출 실패: {e}")
        self.update_status(f"오류: {e}", "red")

def extract_untranslated(self, po_file_path, output_file_path):
    '''
    polib를 사용하여 번역되지 않은 항목을 단순화된 형식으로 추출합니다.
    '''
    po = polib.pofile(po_file_path, encoding='utf-8')
    untranslated_entries = po.untranslated_entries()
    
    with open(output_file_path, 'w', encoding='utf-8') as f_out:
        for entry in untranslated_entries:
            msgid = entry.msgid
            normalized_msgid = msgid.replace('\r\n', '\n').replace('\r', '\n')

            f_out.write("msgid\n")
            f_out.write(normalized_msgid)
            f_out.write("\nmsgstr\n\n")
    
    return len(untranslated_entries)


추출된 파일 분할

  추출된 파일 자체는 여전히 긴 내용을 가지므로, AI가 번역하기 용이하도록 일정 항목 개수만큼 나누어 파일을 분할하도록 하였습니다. 다음 코드를 참고하세요:

def split_extracted_file_gui(self):
    po_file = self.po_file_path.get()
    work_dir = self.work_dir.get()
    split_count = self.split_count.get()

    if not po_file or not os.path.exists(po_file):
        messagebox.showerror("오류", "원본 .po 파일을 먼저 선택하세요.")
        return

    # 자동으로 _untranslated.txt 파일 경로 생성
    extracted_file = os.path.join(work_dir, os.path.basename(po_file).replace(".po", "_untranslated.txt")) # 변경

    if not os.path.exists(extracted_file):
        messagebox.showerror("오류", f"추출된 파일을 찾을 수 없습니다.\n경로: {extracted_file}\n\n먼저 '번역되지 않은 항목 추출'을 실행하세요.")
        return
    
    if not work_dir or not os.path.isdir(work_dir):
        messagebox.showerror("오류", "유효한 작업 폴더를 선택하세요.")
        return
    if split_count <= 0:
        messagebox.showerror("오류", "분할 개수는 0보다 커야 합니다.")
        return

    try:
        existing_files = [f for f in os.listdir(work_dir) if f.startswith("splitted_") and f.endswith(".txt")] # 변경
        if existing_files:
            answer = messagebox.askyesno(
                "확인",
                f"기존에 분할된 파일 {len(existing_files)}개가 있습니다.\n모두 삭제하고 새로 생성하시겠습니까?"
            )
            if answer: # '예'를 선택한 경우
                for f in existing_files:
                    os.remove(os.path.join(work_dir, f))
            else: # '아니요'를 선택한 경우
                self.update_status("분할 작업이 사용자에 의해 취소되었습니다.", "blue")
                return # 작업 취소
    except Exception as e:
        messagebox.showerror("오류", f"기존 파일 삭제 중 오류 발생: {e}")
        self.update_status(f"오류: {e}", "red")
        return

    try:
        self.update_status("분할 작업 중...", "orange")
        num_files = self.split_extracted_file(extracted_file, work_dir, split_count) # 변경
        messagebox.showinfo("성공", f"파일을 총 {num_files}개로 분할했습니다.\n폴더: {work_dir}") # 변경
        self.update_status(f"분할 완료: {num_files}개 파일 생성", "green")
    except Exception as e:
        messagebox.showerror("오류", f"파일 분할 실패: {e}")
        self.update_status(f"오류: {e}", "red")

def split_extracted_file(self, extracted_file_path, output_dir, entries_per_file):
    '''
    추출된 파일을 분할합니다.
    '''
    with open(extracted_file_path, 'r', encoding='utf-8') as f_in:
        content = f_in.read()

    # 정규식의 'lookahead' 기능을 사용하여 'msgid\n' 문자열 직전에서 분리합니다.
    blocks = re.split(r'(?=msgid\n)', content.strip())
    
    # 분리 과정에서 생길 수 있는 빈 항목을 제거합니다.
    blocks = [b for b in blocks if b.strip()]

    file_idx = 0
    for i in range(0, len(blocks), entries_per_file):
        chunk = blocks[i:i + entries_per_file]
        output_filename = os.path.join(output_dir, f"splitted_{file_idx:03d}.txt")
        with open(output_filename, 'w', encoding='utf-8') as f_out:
            # 각 블록 사이에 구분자 '\n\n'를 다시 추가해줍니다.
            f_out.write('\n\n'.join(chunk) + '\n\n')
        file_idx += 1
    return file_idx


번역

  번역은 Gemini CLI를 이용하여 수행했습니다. 프롬프트에는 일반적으로 번역을 할 때 주의해야 하는 사항들과 제가 만든 파일 구조에 대한 설명 및 방침들을 제공하였습니다. 특히 Phil in the Mirror에서는 날짜 및 고유명사가 매우 중요해서, 이 형식이 변경되지 않아야 했습니다. 이와 관련한 프롬프트들 역시 신경써서 적어주었습니다:

1. You are an expert in charge of the localization for a narrative-driven, single-package game.

2. The source language is Korean, and it needs to be translated into the following target languages: English, Simplified Chinese, Russian, Spanish (Spain), Portuguese (Brazil), German, Japanese, French, and Polish.

3. The name of the game is "Phil in the Mirror", and the following are the requirements for translation:

4. You will be working with text files. Your task is to read the original files, named splitted_*.txt, fill in any untranslated entries, and then save the result as a new file named splitted_translated_*.txt. It is mandatory that you perform this task manually; do not write or execute any programming code. You must directly translate the text and construct the new file's content yourself. Review each msgid/msgstr pair. Any entry where the msgstr is empty is considered untranslated. Your primary goal is to fill these empty strings with the correct translation in the new file. 

The core principle is to create a new file for the translations, not to modify the original source files. You must never touch the contents of the msgid.

Please follow these steps:
a) Read the original file: Get the entire content of a splitted_*.txt file.
b) Process, Translate, and Create a New File:
	1) Initialize an empty list to store the lines for the new translated file (splitted_translated_*.txt).
	2) Iterate through the lines of the original splitted_*.txt file.
	3) Maintain a state to detect msgid and msgstr blocks.
	4) When a msgid block is found, temporarily save its lines.
	5) If an empty msgstr block is found immediately after a msgid block, perform the translation:
	6) Translate each line of the saved msgid into given language, preserving formatting (space, bold).
	7) Construct the new msgstr lines with the translated content.
	8) Add all other lines (comments, headers, already translated entries, and msgid blocks followed by non-empty msgstr blocks) directly to the list.
	9) Once all lines have been processed, write the entire contents of the list to a new file named splitted_translated_*.txt.

Absolute Rule for Structural Integrity:
a) The msgstr you generate must be a perfect structural mirror of the msgid. This is the most important rule.
b) Line-for-Line Matching: msgstr must be exactly identical to the number of lines in msgid.
c) One-to-One Translation: Translate the content of each msgid line and place it into the corresponding msgstr line. Do not merge lines.

5. Do not modify msgstr entries that are already translated. If you identify a critical issue in an existing translation (e.g., a severe mistranslation) that requires correction, you must ask for confirmation and receive approval before implementing the change.

6. Dates must follow a standard format. For example, '1998년 3월 14일' must be formatted as 1998-03-14. For descriptive phrases, it is acceptable to translate '3월 14일' contextually (e.g., 'March 14'). However, it is critical that if the source string uses the YYYY-MM-DD format, you must never alter the order to formats like DD-MM-YYYY.

7. Proper nouns must be kept in English. This includes character names like 'Phil' and specific terms like 'Pendulmn Mechanics'. Even for languages that do not use the Latin alphabet, these terms must remain in English and should not be transliterated. This is because they function as puzzle clues and must not be changed.

8. Respect all markup tags (e.g., <tag></>). The tags themselves must not be changed or translated. The content enclosed within the tags must be translated while ensuring its meaning remains consistent with the source.


번역된 파일 병합

  이렇게 만들어진 번역 파일은 프롬프트에 의해 splitted_translated_1.txt 따위의 형태로 파일들이 만들어지게 됩니다. 이들을 다시 읽어들여, 기존의 po파일에 병합하는 과정을 거쳐야 했습니다. 기존의 po 파일들 중 번역되지 않은 항목들을 읽은 뒤, 번역된 파일 안에서 일치하는 msgid를 찾아 번역본 텍스트를 채우는 형태로 구성됩니다. 다음 코드를 참고하세요:

def merge_translated_files_gui(self):
    translated_dir = self.work_dir.get() # 변경
    original_po_file = self.po_file_path.get()

    if not translated_dir or not os.path.isdir(translated_dir):
        messagebox.showerror("오류", "번역된 파일이 있는 폴더를 선택하세요.")
        return
    if not original_po_file or not os.path.exists(original_po_file):
        messagebox.showerror("오류", "원본 .po 파일을 선택하세요.")
        return

    try:
        self.update_status("병합 작업 중...", "orange")
        # 병합 결과를 새 파일에 저장하여 원본을 보호합니다.
        output_po_file = original_po_file.replace(".po", "_merged.po")
        stats = self.merge_translated_files(translated_dir, original_po_file, output_po_file)
        
        summary_message = (
            f"병합 완료!\n"
            f"결과 파일: {output_po_file}\n\n"
            f"성공적으로 업데이트된 항목: {stats['updated']}\n"
            f"번역을 찾지 못한 항목: {stats['not_found']}\n"
            f"총 번역된 항목 수: {stats['total_translated']}\n"
            f"아직 번역되지 않은 항목 수: {stats['still_untranslated']}"
        )
        messagebox.showinfo("병합 요약", summary_message)
        self.update_status(f"병합 완료. 업데이트: {stats['updated']}개", "green")
    except Exception as e:
        messagebox.showerror("오류", f"파일 병합 실패: {e}")
        self.update_status(f"오류: {e}", "red")

def merge_translated_files(self, translated_dir, original_po_file_path, output_po_file_path):
    '''
    polib를 사용하여 번역된 파일들을 원본 .po 파일에 병합합니다.
    '''

    def normalize_key(text):
        if not text: return ""
        normalized = text.replace('\r\n', '\n').replace('\r', '\n')
        return normalized

    translated_data = {} # {msgid: msgstr}

    # 1. 번역된 모든 텍스트 파일들을 읽어 딕셔너리에 저장
    for filename in os.listdir(translated_dir):
        if filename.startswith("splitted_translated_") and filename.endswith(".txt"):
            file_path = os.path.join(translated_dir, filename)
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()
            
            entries = content.split('msgid\n')
            
            # 첫 번째 요소는 비어있을 수 있으므로, 두 번째 요소부터 순회합니다.
            for entry_content in entries[1:]:
                # 각 항목을 '\nmsgstr\n' 기준으로 msgid와 msgstr로 다시 분리합니다.
                # maxsplit=1 인자를 주어 단 한 번만 분리하도록 하여 안정성을 높입니다.
                parts = entry_content.split('\nmsgstr\n', 1)
                
                if len(parts) == 2:
                    msgid = parts[0]
                    msgstr = parts[1].strip('\r\n')
                    
                    if msgid and msgstr: # 번역된 내용이 있는 경우에만 추가
                        translated_data[msgid] = msgstr

    po = polib.pofile(original_po_file_path, encoding='utf-8')
    updated_count = 0
    # 사용된 번역을 추적하여 '찾지 못한 항목' 수를 정확하게 계산
    used_translations = set()

    # .po 파일의 '번역되지 않은' 모든 항목을 순회합니다. 
    for entry in po.untranslated_entries():
        # 현재 항목의 msgid를 가져와 정규화합니다.
        lookup_key = normalize_key(entry.msgid)
        
        # 정규화된 키를 사용해 번역 데이터를 조회하지 않고,
        # '원본 msgid'를 직접 translated_data에서 찾습니다.
        # 추출 시 이미 정규화했기 때문에 키가 일치해야 합니다.
        if lookup_key in translated_data:
            # 일치하는 번역을 찾으면, 현재 entry의 msgstr을 업데이트합니다.
            entry.msgstr = translated_data[lookup_key]
            if 'fuzzy' in entry.flags:
                entry.flags.remove('fuzzy')
            updated_count += 1
            used_translations.add(entry.msgid)
        else:
            print(f'Cannot find msgid: "{lookup_key}"')

    po.save(output_po_file_path)

    stats = {
        'updated': updated_count,
        'not_found': len(translated_data) - len(used_translations),
        'total_translated': len(po.translated_entries()),
        'still_untranslated': len(po.untranslated_entries())
    }
    return stats


결과

  Gemini CLI를 이용할 때 제공되는 무료 2.5-gemini-pro 토큰만으로도 하루에 3개 언어 번역 정도는 충분히 가능했습니다. 유료 api로도 실험을 해봤었는데, 출력 토큰을 줄이는 작업이 이루어지지 않은 상태에서도 한 언어에 10만원이 채 나오지 않았습니다. 게다가 2.5-flash로도 충분히 좋은 퀄리티의 번역을 뽑을 수 있었고, 오히려 문장부호 등을 다루는데 2.5-flash가 더 나은 부분도 있었습니다.

  저는 일주일이 채 안 되는 기간에 10개국어의 번역과 검수를 완료할 수 있었고, 추리 게임의 특징을 갖고 있어 번역이 중요한 게임이었음에도 번역의 품질 보증에서 큰 문제가 없었습니다.


끝.

댓글

이 블로그의 인기 게시물

[코딩의탑] 5층: 포탈(Portal), 더 나아가기

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링