<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>손이 많이 가는 개발일지</title>
    <link>https://deepdive-dev.tistory.com/</link>
    <description>개인적인 심심풀이로 개발하는 개발자의 이야기. 
아이디어 시작부터 진행, 완성, 폐기 등 과정을 기록. 제안, 댓글 환영!
좋은 제안은 크레딧에 표기 예정!</description>
    <language>ko</language>
    <pubDate>Sat, 9 May 2026 06:16:13 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>삽질연구소 소장</managingEditor>
    <image>
      <title>손이 많이 가는 개발일지</title>
      <url>https://tistory1.daumcdn.net/tistory/8329914/attach/ea2d9677c664488bbf89bce12d17d78d</url>
      <link>https://deepdive-dev.tistory.com</link>
    </image>
    <item>
      <title>Unity 게임 번역기 개발기 #6: 최적화와 크로스플랫폼</title>
      <link>https://deepdive-dev.tistory.com/6</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;382&quot; data-origin-height=&quot;152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zuPSg/dJMb9XK6dFQ/4KtrHmRMKvpKrAc12tE1K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zuPSg/dJMb9XK6dFQ/4KtrHmRMKvpKrAc12tE1K1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zuPSg/dJMb9XK6dFQ/4KtrHmRMKvpKrAc12tE1K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzuPSg%2FdJMb9XK6dFQ%2F4KtrHmRMKvpKrAc12tE1K1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;382&quot; height=&quot;152&quot; data-origin-width=&quot;382&quot; data-origin-height=&quot;152&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 비용이 생각보다 나간다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPG Maker 게임 번역하다 보니 API 비용이 보이기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 게임 하나: $0.50&lt;br /&gt;중간 크기 게임: $2.00&lt;br /&gt;큰 게임: $5.00+&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt Caching으로 90% 절감했는데도 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;b&gt;중복 번역&lt;/b&gt;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Translation Memory 개선&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Translation Memory는 단순했다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 기존 (단순 해시)
key = hashlib.md5(f&quot;{original_text}:{language_pair}&quot;.encode()).hexdigest()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공백 차이 (&lt;code&gt;&quot;こんにちは&quot;&lt;/code&gt; vs &lt;code&gt;&quot;こんにちは &quot;&lt;/code&gt;) &amp;rarr; 다른 키&lt;/li&gt;
&lt;li&gt;줄바꿈 차이 &amp;rarr; 다른 키&lt;/li&gt;
&lt;li&gt;같은 내용인데 두 번 번역&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정규화 추가&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# core/translation_memory.py

def _normalize_text(self, text):
    &quot;&quot;&quot;텍스트 정규화&quot;&quot;&quot;
    # 앞뒤 공백 제거
    text = text.strip()
    # 연속 공백을 하나로
    text = re.sub(r'\s+', ' ', text)
    # 줄바꿈 통일
    text = text.replace('\r\n', '\n')
    return text

def get(self, original_text, language_pair):
    normalized = self._normalize_text(original_text)
    key = hashlib.md5(f&quot;{normalized}:{language_pair}&quot;.encode()).hexdigest()

    self.cursor.execute(&quot;SELECT translated FROM cache WHERE key=?&quot;, (key,))
    result = self.cursor.fetchone()
    return result[0] if result else None&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;Before: 캐시 히트율 ~70%
After:  캐시 히트율 ~85%

공백/줄바꿈 차이로 인한 중복 번역 감소.
체감상 10-20% 정도 비용 절감.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공백 하나 때문에 돈 나가는 거 막았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배치 번역 (API 호출 줄이기)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존: 대사 하나씩 API 호출&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;for line in lines:
    translated = api.translate(line)  # 500번 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 호출 500번&lt;/li&gt;
&lt;li&gt;네트워크 오버헤드&lt;/li&gt;
&lt;li&gt;느림&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배치 처리&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# core/translator.py

def translate_batch(self, texts, batch_size=10):
    &quot;&quot;&quot;배치 번역 (최대 10개씩)&quot;&quot;&quot;
    results = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i:i+batch_size]

        # 배치를 하나의 프롬프트로
        prompt = &quot;다음 문장들을 번역하세요:\n\n&quot;
        for idx, text in enumerate(batch, 1):
            prompt += f&quot;{idx}. {text}\n&quot;

        response = self.client.messages.create(
            model=&quot;claude-3-5-sonnet-20241022&quot;,
            system=self._get_system_prompt(),
            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}]
        )

        # 결과 파싱
        translated_batch = self._parse_batch_response(response.content[0].text)
        results.extend(translated_batch)

    return results&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 비교&lt;/h3&gt;
&lt;pre class=&quot;haml&quot;&gt;&lt;code&gt;Before (개별 호출):
- 500개 대사
- 500번 API 호출
- 소요 시간: ~10분
- 비용: $1.00

After (배치 10개):
- 500개 대사
- 50번 API 호출
- 소요 시간: ~8분
- 비용: $0.90&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 오버헤드는 줄었지만 체감할 만큼은 아니다.&lt;br /&gt;배치 크기를 너무 키우면 파싱 오류 발생. 10개 정도가 안정적.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mac에서도 쓰고 싶다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;윈도우 데스크탑에서 개발했다. 외출할 때는 맥북 들고 다닌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥에서도 쓰고 싶었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 1: AssetsTools.NET&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Windows만 됨
import clr
from System import Activator&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pythonnet은 Windows에서 .NET Framework 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mac은 .NET Core/Mono. 안 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: 플랫폼 감지&lt;/h3&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# core/unity_extractor.py

import platform

class UnityExtractor:
    def __init__(self):
        self.system = platform.system()

        if self.system == 'Windows':
            self._init_assetstools_net()
        else:
            # Mac/Linux는 UnityPy만 사용
            self.use_assetstools = False

    def extract(self, bundle_path):
        if self.use_assetstools:
            return self._extract_with_assetstools(bundle_path)
        else:
            return self._extract_with_unitypy(bundle_path)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UnityPy는 순수 Python. 크로스플랫폼 지원.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AssetsTools.NET은 Windows 전용 기능으로 분리.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 2: 파일 경로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows: &lt;code&gt;E:\game\data.bundle&lt;/code&gt;&lt;br /&gt;Mac: &lt;code&gt;/Users/name/game/data.bundle&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# Before (하드코딩)
backup_path = bundle_path.replace('.bundle', '.bundle.backup')

# After (pathlib)
from pathlib import Path

backup_path = Path(bundle_path).with_suffix('.bundle.backup')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pathlib는 OS 상관없이 작동. 더 이상 경로 문제 없음.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 3: GUI 폰트&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# Windows
font = QFont(&quot;맑은 고딕&quot;, 10)

# Mac에는 맑은 고딕 없음&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: 폰트 폴백&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# gui/main_window.py

def _get_default_font(self):
    system = platform.system()

    if system == 'Windows':
        return QFont(&quot;맑은 고딕&quot;, 10)
    elif system == 'Darwin':  # Mac
        return QFont(&quot;Apple SD Gothic Neo&quot;, 10)
    else:  # Linux
        return QFont(&quot;Noto Sans KR&quot;, 10)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OS별 기본 폰트 자동 선택.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실행 스크립트&lt;/h3&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;# run_gui_with_project.sh (Mac/Linux)

#!/bin/bash

# 가상환경 활성화
source venv/bin/activate

# GUI 실행
python -m gui.main_window

# 인자 전달 지원
if [ $# -gt 0 ]; then
    python -m gui.main_window &quot;$@&quot;
fi&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;# run_gui.bat (Windows)

@echo off
venv_win\Scripts\python.exe -m gui.main_window %*&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mac에서 &lt;code&gt;./run_gui_with_project.sh&lt;/code&gt; 실행. 끝.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;용어 사전 시스템&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임마다 표현 수위가 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 게임은 폭력적 표현이 많고, 일부는 거의 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 정책에 걸리는 단어들이 있다.&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;Error: content_policy_violation
Your request was blocked due to content policy&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번역 중단. 게임 번역 못 함.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;용어 치환 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 정책에 걸리는 표현을 완곡하게 바꾸고 번역 후 복원.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# core/translator.py

class UniversalTranslator:
    def __init__(self):
        self.term_replacements = self._load_replacements()
        self.temp_map = {}  # 임시 매핑

    def _preprocess(self, text):
        &quot;&quot;&quot;번역 전 용어 치환&quot;&quot;&quot;
        processed = text

        for original, safe in self.term_replacements.items():
            if original in processed:
                self.temp_map[safe] = original
                processed = processed.replace(original, safe)

        return processed

    def _postprocess(self, translated):
        &quot;&quot;&quot;번역 후 복원&quot;&quot;&quot;
        result = translated

        for safe, original in self.temp_map.items():
            if safe in result:
                original_translated = self.tm.get(original, self.language_pair)
                if original_translated:
                    result = result.replace(safe, original_translated)

        self.temp_map.clear()
        return result

    def translate(self, text):
        safe_text = self._preprocess(text)
        translated = self._api_translate(safe_text)
        final = self._postprocess(translated)
        return final&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;작동 원리&lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;원문:      &quot;敵を倒す&quot;
전처리:    &quot;敵を制圧する&quot;
API 번역:  &quot;적을 제압한다&quot;
후처리:    &quot;적을 쓰러뜨린다&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API는 완곡한 표현을 보고 번역. 정책 통과.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 원본 의미 유지.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 해결&lt;/h3&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;Before:
- content_policy_violation
- 번역 중단

After:
- 정책 통과
- 원본 의미 유지
- 모든 게임 번역 가능&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설정 파일 구조화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용어 사전을 규칙별로 분리.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;config/
├── rules/
│   ├── general.yaml    # 일반 게임
│   ├── violence.yaml   # 폭력적 표현
│   └── custom.yaml     # 사용자 정의
└── dicts/
    ├── defaults/       # 기본 사전
    │   ├── proper_nouns.json
    │   ├── speaker_names.json
    │   └── game_terms.json
    └── ...             # 사용자 사전&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 게임 특성에 맞는 규칙 선택.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;# GUI
rule_type = self.rule_combo.currentText()

if rule_type == &quot;폭력적 표현&quot;:
    translator.load_rules(&quot;config/rules/violence.yaml&quot;)
else:
    translator.load_rules(&quot;config/rules/general.yaml&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단. 명확.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mac 설정 가이드 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥 사용자를 위한 가이드.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# MAC_SETUP_GUIDE.md

## 필수 요구사항
- Python 3.10+
- Homebrew

## 설치
# Python 설치 (Homebrew)
brew install python@3.10

# 저장소 클론
git clone https://github.com/hasjin/game-translator.git
cd game-translator/gametranslator

# 가상환경
python3 -m venv venv
source venv/bin/activate

# 의존성
pip install -r requirements.txt

# 실행
./run_gui.sh

## 주의사항
- AssetsTools.NET 기능은 Mac에서 비활성화
- UnityPy로만 작동 (대부분 게임 지원)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥 사용자도 이제 쓸 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 최적화 성과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 비용&lt;/h3&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;최적화 기법들:
1. Prompt Caching (Claude 제공)
2. Translation Memory (중복 제거)
3. 텍스트 정규화 (캐시 히트율 개선)
4. 배치 처리 (네트워크 오버헤드 감소)

체감 효과:
- 같은 게임 재번역: 거의 무료 (캐시 히트)
- 신규 게임: Prompt Caching으로 상당히 저렴
- 구체적 수치는 게임마다 천차만별&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;속도&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Translation Memory 히트: 즉시
새로운 문장: ~1-2초/문장 (Claude API 응답 속도)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;크로스플랫폼&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;✅ Windows (완전 지원)
✅ Mac (UnityPy만, 대부분 게임 OK)
✅ Linux (이론상 가능, 미테스트)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 변경&lt;/h2&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;~ core/translation_memory.py (정규화 로직)
~ core/translator.py (배치 처리, 용어 치환)
~ core/unity_extractor.py (플랫폼 감지)
~ gui/main_window.py (폰트 폴백)
+ run_gui_with_project.sh (Mac/Linux)
+ MAC_SETUP_GUIDE.md
+ config/rules/violence.yaml
+ config/rules/general.yaml
+ config/rules/custom.yaml&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;정규화는 중요하다&lt;/b&gt;&lt;br /&gt;공백 하나 차이로 캐시 미스. 정규화로 불필요한 중복 번역 감소.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배치 처리는 양날의 검&lt;/b&gt;&lt;br /&gt;네트워크 호출은 줄지만 파싱 복잡도 증가. 적절한 크기가 중요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;크로스플랫폼은 처음부터&lt;/b&gt;&lt;br /&gt;pathlib, 플랫폼 감지. 나중에 하면 지옥.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 정책 대응&lt;/b&gt;&lt;br /&gt;전처리/후처리로 용어 치환. 정책 통과하면서 의미 유지.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정 파일 분리&lt;/b&gt;&lt;br /&gt;게임 특성별 규칙 파일. 사용자가 선택. 깔끔.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/6</guid>
      <comments>https://deepdive-dev.tistory.com/6#entry6comment</comments>
      <pubDate>Sun, 19 Oct 2025 13:18:36 +0900</pubDate>
    </item>
    <item>
      <title>Unity 게임 번역기 개발기 #5: RPG 메이커? 그것도 된다</title>
      <link>https://deepdive-dev.tistory.com/5</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;293&quot; data-origin-height=&quot;266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQHm1t/dJMb9NIyuFx/07pDTRtHVvkWVnhWOXJCS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQHm1t/dJMb9NIyuFx/07pDTRtHVvkWVnhWOXJCS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQHm1t/dJMb9NIyuFx/07pDTRtHVvkWVnhWOXJCS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQHm1t%2FdJMb9NIyuFx%2F07pDTRtHVvkWVnhWOXJCS1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;293&quot; height=&quot;266&quot; data-origin-width=&quot;293&quot; data-origin-height=&quot;266&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새 기능 시작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPG 메이커 게임도 번역이 하고 싶어졌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPG Maker MV/MZ 구조 파악&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPG 메이커는 Unity랑 완전히 다르다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;RPGMaker_Game/
├── www/
│   └── data/
│       ├── Map001.json
│       ├── Map002.json
│       ├── CommonEvents.json
│       ├── Actors.json
│       └── ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON이다. 그냥 평문 JSON.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity처럼 Bundle 파싱 필요 없다. 그냥 읽으면 된다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;import json

with open('www/data/Map001.json', 'r', encoding='utf-8') as f:
    data = json.load(f)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하다. 너무 간단해서 의심스럽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPG Maker JSON 구조&lt;/h2&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;id&quot;: 1,
  &quot;name&quot;: &quot;Map001&quot;,
  &quot;events&quot;: [
    {
      &quot;id&quot;: 1,
      &quot;pages&quot;: [
        {
          &quot;list&quot;: [
            {&quot;code&quot;: 401, &quot;parameters&quot;: [&quot;こんにちは&quot;]},
            {&quot;code&quot;: 401, &quot;parameters&quot;: [&quot;元気ですか？&quot;]},
            {&quot;code&quot;: 102, &quot;parameters&quot;: [[&quot;はい&quot;, &quot;いいえ&quot;]]}
          ]
        }
      ]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;code: 401&lt;/code&gt; = 대사&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code: 102&lt;/code&gt; = 선택지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code: 355&lt;/code&gt; = 스크립트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 번호로 구분. 심플하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추출 로직 (Unity보다 쉽다)&lt;/h2&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# core/rpgmaker_extractor.py

class RPGMakerExtractor:
    def extract_from_json(self, json_path):
        with open(json_path, 'r', encoding='utf-8') as f:
            data = json.load(f)

        entries = []

        # Map 파일
        if 'events' in data:
            for event in data['events']:
                if not event:
                    continue
                for page in event['pages']:
                    for cmd in page['list']:
                        # 401 = 대사, 405 = 스크롤 텍스트
                        if cmd['code'] in [401, 405]:
                            text = cmd['parameters'][0]
                            if self._is_japanese(text):
                                entries.append({
                                    'file': json_path.name,
                                    'original': text,
                                    'code': cmd['code']
                                })

                        # 102 = 선택지
                        elif cmd['code'] == 102:
                            for choice in cmd['parameters'][0]:
                                if self._is_japanese(choice):
                                    entries.append({
                                        'file': json_path.name,
                                        'original': choice,
                                        'code': 102
                                    })

        # CommonEvents, Actors, Items 등
        # ... 비슷한 로직 ...

        return entries&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity보다 훨씬 간단하다. Bundle 파싱도 없고, TypeTree도 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 발생: Excel 가져오기 폭사&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번역 완료. Excel 내보내기 성공.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Excel 수정 후 가져오기 클릭.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Traceback (most recent call last):
  File &quot;gui/handlers/excel_handler.py&quot;, line 316
    for entry in data.get('entries', []):
AttributeError: 'list' object has no attribute 'get'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;터졌다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 파악 (포맷 차이)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity 포맷 (dict):&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
    &quot;entries&quot;: [
        {&quot;text&quot;: &quot;Hello&quot;, &quot;translated&quot;: &quot;안녕&quot;},
        {&quot;text&quot;: &quot;World&quot;, &quot;translated&quot;: &quot;세계&quot;}
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPG Maker 포맷 (list):&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[
    {&quot;id&quot;: 1, &quot;original&quot;: &quot;こんにちは&quot;, &quot;translated&quot;: &quot;안녕하세요&quot;},
    {&quot;id&quot;: 2, &quot;original&quot;: &quot;元気ですか&quot;, &quot;translated&quot;: &quot;잘 지내세요&quot;}
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JSON 최상위가 dict vs list.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 Unity dict만 가정했다. RPG Maker list는 처리 못 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;수정: 포맷 감지 로직&lt;/h2&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# gui/handlers/excel_handler.py

def _apply_excel_to_json(self, json_path, updates_map):
    with open(json_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    modified = False

    # 포맷 감지
    if isinstance(data, list):
        # RPG Maker 포맷 (list)
        for entry in data:
            original_text = entry.get('original', '')
            if original_text in updates_map:
                entry['translated'] = updates_map[original_text]
                modified = True

    elif isinstance(data, dict) and 'entries' in data:
        # Unity 포맷 (dict)
        for entry in data.get('entries', []):
            original_text = entry.get('text', '')
            if original_text in updates_map:
                entry['translated'] = updates_map[original_text]
                modified = True

    if modified:
        with open(json_path, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    return modified&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;isinstance로 타입 체크.&lt;/b&gt; list면 RPG Maker, dict면 Unity.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Excel 가져오기 재시도. &lt;b&gt;성공.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;번역 검수가 불편하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Excel 워크플로우:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;번역 완료&lt;/li&gt;
&lt;li&gt;Excel 내보내기&lt;/li&gt;
&lt;li&gt;Excel 열기&lt;/li&gt;
&lt;li&gt;수정&lt;/li&gt;
&lt;li&gt;Excel 저장&lt;/li&gt;
&lt;li&gt;Excel 가져오기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6단계나 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자: &quot;Excel 열지 않고 프로그램에서 바로 검수하면 안 돼요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 아이디어다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;번역 검수 뷰어 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요구사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이징 (50개씩)&lt;/li&gt;
&lt;li&gt;검색 (원문/번역/수정본)&lt;/li&gt;
&lt;li&gt;테이블에서 직접 수정&lt;/li&gt;
&lt;li&gt;수정 사항 하이라이트&lt;/li&gt;
&lt;li&gt;저장 버튼 한 번에 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# gui/widgets/translation_viewer.py

class TranslationViewerWidget(QWidget):
    entry_modified = pyqtSignal(int, str)  # (index, modified_text)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.entries = []
        self.filtered_entries = []
        self.current_page = 0
        self.items_per_page = 50
        self.modified_entries = {}  # {index: modified_text}

        self.init_ui()

    def init_ui(self):
        # 검색 영역
        self.search_input = QLineEdit()
        self.search_input.setPlaceholderText(&quot;원문 또는 번역문 검색...&quot;)

        self.search_type_combo = QComboBox()
        self.search_type_combo.addItems([&quot;전체&quot;, &quot;원문만&quot;, &quot;번역만&quot;, &quot;수정본만&quot;])

        # 테이블
        self.table = QTableWidget()
        self.table.setColumnCount(5)
        self.table.setHorizontalHeaderLabels([
            &quot;번호&quot;, &quot;파일&quot;, &quot;원문&quot;, &quot;AI 번역&quot;, &quot;수정본&quot;
        ])

        # 수정본 컬럼만 편집 가능
        self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)

        # ... UI 구성 ...&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;페이징 로직&lt;/h3&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def update_table(self):
    # 현재 페이지 항목
    start_idx = self.current_page * self.items_per_page
    end_idx = min(start_idx + self.items_per_page, len(self.filtered_entries))
    page_entries = self.filtered_entries[start_idx:end_idx]

    self.table.setRowCount(len(page_entries))

    for row, entry in enumerate(page_entries):
        global_idx = self.entries.index(entry)

        # 원문
        item_original = QTableWidgetItem(entry['original'])
        item_original.setFlags(item_original.flags() &amp;amp; ~Qt.ItemFlag.ItemIsEditable)
        self.table.setItem(row, 2, item_original)

        # AI 번역
        item_translated = QTableWidgetItem(entry['translated'])
        item_translated.setFlags(item_translated.flags() &amp;amp; ~Qt.ItemFlag.ItemIsEditable)
        self.table.setItem(row, 3, item_translated)

        # 수정본 (편집 가능)
        modified = self.modified_entries.get(global_idx, '')
        item_modified = QTableWidgetItem(modified)
        item_modified.setFlags(item_modified.flags() | Qt.ItemFlag.ItemIsEditable)

        # 수정된 항목 하이라이트
        if modified:
            item_modified.setBackground(QColor(200, 255, 200))  # 연두색
            item_modified.setFont(QFont(&quot;맑은 고딕&quot;, 9, QFont.Weight.Bold))
        else:
            item_modified.setBackground(QColor(255, 255, 200))  # 노란색 (편집 대기)

        self.table.setItem(row, 4, item_modified)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검색 기능&lt;/h3&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;def on_search(self):
    search_text = self.search_input.text().strip().lower()
    search_type = self.search_type_combo.currentText()

    if not search_text:
        self.filtered_entries = self.entries.copy()
    else:
        self.filtered_entries = []

        for entry in self.entries:
            original = entry.get('original', '').lower()
            translated = entry.get('translated', '').lower()

            idx = self.entries.index(entry)
            modified = self.modified_entries.get(idx, '').lower()

            # 검색 타입별 필터링
            match = False
            if search_type == &quot;전체&quot;:
                match = (search_text in original or
                        search_text in translated or
                        search_text in modified)
            elif search_type == &quot;원문만&quot;:
                match = search_text in original
            elif search_type == &quot;번역만&quot;:
                match = search_text in translated
            elif search_type == &quot;수정본만&quot;:
                match = search_text in modified

            if match:
                self.filtered_entries.append(entry)

    self.current_page = 0
    self.update_view()&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UI 통합&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# gui/ui/tab_builder.py

def create_excel_tab(self):
    layout = QVBoxLayout()

    # 버튼 영역
    btn_layout = QHBoxLayout()

    btn_load = QPushButton(&quot;  번역 결과 불러오기&quot;)
    btn_load.clicked.connect(self.load_translation_for_review)
    btn_layout.addWidget(btn_load)

    btn_export = QPushButton(&quot;  Excel 내보내기&quot;)
    btn_export.clicked.connect(self.export_excel)
    btn_layout.addWidget(btn_export)

    btn_import = QPushButton(&quot;  수정본 Excel 가져오기&quot;)
    btn_import.clicked.connect(self.import_excel_to_viewer)
    btn_layout.addWidget(btn_import)

    btn_save = QPushButton(&quot;  수정 사항 저장&quot;)
    btn_save.clicked.connect(self.save_viewer_modifications)
    btn_layout.addWidget(btn_save)

    layout.addLayout(btn_layout)

    # 뷰어 위젯
    self.translation_viewer = TranslationViewerWidget()
    layout.addWidget(self.translation_viewer)

    return layout&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;워크플로우 개선&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before (Excel 필수)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;번역 &amp;rarr; Excel 내보내기 &amp;rarr; Excel 열기 &amp;rarr; 수정 &amp;rarr; 저장 &amp;rarr; 가져오기
6단계&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;After (뷰어 사용)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;번역 &amp;rarr; 뷰어에서 바로 수정 &amp;rarr; 저장
3단계&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;50% 단계 감소.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 여전히 Excel로도 작업 가능. 선택권 제공.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사용 사례&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPG Maker 게임 번역&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;게임 폴더 선택 (&lt;code&gt;www/data/&lt;/code&gt; 자동 감지)&lt;/li&gt;
&lt;li&gt;번역 시작 (Claude API)&lt;/li&gt;
&lt;li&gt;뷰어에서 검수
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;魔法&quot; 검색 &amp;rarr; 관련 대사 전부 확인&lt;/li&gt;
&lt;li&gt;고유명사 일관성 체크&lt;/li&gt;
&lt;li&gt;어색한 번역 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;저장 버튼 클릭&lt;/li&gt;
&lt;li&gt;게임 실행 &amp;rarr; 한글 나옴&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;총 소요 시간: 10분&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Unity + Naninovel 게임&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전과 동일하게 작동. 포맷 감지가 자동이라 신경 안 써도 됨.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;지원 게임 엔진&lt;/h3&gt;
&lt;pre class=&quot;cos&quot;&gt;&lt;code&gt;✅ Unity (일반)
✅ Unity + Naninovel
✅ RPG Maker MV
✅ RPG Maker MZ&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;워크플로우&lt;/h3&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;✅ 뷰어 내 검수 (NEW)
✅ Excel 검수 (기존)
✅ 검색/필터 (NEW)
✅ 페이징 50개씩 (NEW)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 변경&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;+ gui/widgets/translation_viewer.py (379줄)
+ gui/widgets/__init__.py (5줄)
~ gui/handlers/excel_handler.py (+포맷 감지 로직)
~ gui/ui/tab_builder.py (뷰어 통합)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;포맷 가정하지 말기&lt;/b&gt;&lt;br /&gt;Unity는 dict, RPG Maker는 list. isinstance로 체크.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;게으름이 기능을 만든다&lt;/b&gt;&lt;br /&gt;&quot;Excel 말고 프로그램에서 확인하자&quot; &amp;rarr; 뷰어 탄생.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;선택권을 주자&lt;/b&gt;&lt;br /&gt;Excel 워크플로우 제거 안 함. 뷰어 추가만.&lt;br /&gt;사용자가 선택. 강요 안 함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;페이징은 필수&lt;/b&gt;&lt;br /&gt;5,000개 항목 한 번에? 프로그램 죽음. 50개씩 나누기.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;검색은 생산성&lt;/b&gt;&lt;br /&gt;&quot;魔法&quot; 검색 &amp;rarr; 고유명사 일관성 한 번에 체크 가능.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/5</guid>
      <comments>https://deepdive-dev.tistory.com/5#entry5comment</comments>
      <pubDate>Sat, 18 Oct 2025 09:57:07 +0900</pubDate>
    </item>
    <item>
      <title>Unity 게임 번역기 개발기 #4: 개발은 디테일이다</title>
      <link>https://deepdive-dev.tistory.com/4</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;1112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJhYtx/btsQ5klOQgg/wGxT2bMB1hOZgwkGw0rB0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJhYtx/btsQ5klOQgg/wGxT2bMB1hOZgwkGw0rB0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJhYtx/btsQ5klOQgg/wGxT2bMB1hOZgwkGw0rB0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJhYtx%2FbtsQ5klOQgg%2FwGxT2bMB1hOZgwkGw0rB0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1922&quot; height=&quot;1112&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;1112&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 키 보안 (평문? 미친 짓)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude API 키를 평문으로 저장? 그건 미친 짓이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;config.json에 API 키 저장하고 GitHub에 올려서 API 키 털린 뉴스 많이 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5단계 보안 계층을 구축했다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;# security/secure_storage.py

# 1. 하드웨어 기반 키 생성
def _get_hardware_key():
    mac = uuid.getnode()              # MAC 주소
    cpu = platform.processor()        # CPU
    system = platform.system()        # OS
    return hashlib.sha256(f&quot;{mac}{cpu}{system}&quot;.encode()).digest()

# 2. AES-256 암호화
from cryptography.fernet import Fernet
key = Fernet.generate_key()
cipher = Fernet(key)
encrypted = cipher.encrypt(api_key.encode())

# 3. XOR 난독화
hw_key = _get_hardware_key()
obfuscated = bytes(a ^ b for a, b in zip(encrypted, hw_key))

# 4. 바이너리 패딩
padding = os.urandom(random.randint(16, 64))
padded = obfuscated + padding

# 5. HMAC 무결성 검증
import hmac
signature = hmac.new(hw_key, padded, hashlib.sha256).digest()
final_data = signature + padded

# .credentials.bin 저장
with open('.credentials.bin', 'wb') as f:
    f.write(final_data)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 복사해도 다른 PC에서는 복호화 불가능.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MAC 주소, CPU 정보가 다르면 복호화 실패. 내 PC가 아니면 못 연다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Excel 검수 워크플로우&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번역을 외주 준다면 그사람에게 Python 코드 보여주면 안 된다. Excel 주면 된다.&lt;/p&gt;
&lt;pre class=&quot;python&quot; data-ke-language=&quot;python&quot;&gt;&lt;code&gt;# core/excel_manager.py

class TranslationEntry:
    def __init__(self, file_path, line, japanese, korean):
        filename = Path(file_path).name
        # MD5 ID 생성 (파일명:라인:원문)
        self.id = hashlib.md5(f&quot;{filename}:{line}:{japanese}&quot;.encode()).hexdigest()[:12]
        self.file_path = file_path
        self.line_number = line
        self.japanese = japanese
        self.korean = korean

class ExcelManager:
    def export_to_excel(self, entries, output_path):
        df = pd.DataFrame([{
            'ID': e.id,
            '파일': e.file_path,
            '라인': e.line_number,
            '일본어': e.japanese,
            '한국어': e.korean
        } for e in entries])

        # 스타일링 (한국어 열 넓게)
        with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
            df.to_excel(writer, index=False)
            worksheet = writer.sheets['Sheet1']
            worksheet.column_dimensions['E'].width = 50

    def import_from_excel(self, excel_path):
        df = pd.read_excel(excel_path)
        entries_map = {e.id: e for e in self.entries}

        # ID 기반 매칭
        for _, row in df.iterrows():
            if row['ID'] in entries_map:
                entries_map[row['ID']].korean = row['한국어']

        return list(entries_map.values())

    def apply_to_files(self, entries):
        # 라인 번호 기반 교체
        for file_path, file_entries in groupby(entries, key=lambda e: e.file_path):
            with open(file_path, 'r', encoding='utf-8') as f:
                lines = f.readlines()

            for entry in file_entries:
                lines[entry.line_number] = entry.korean + '\n'

            with open(file_path, 'w', encoding='utf-8') as f:
                f.writelines(lines)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워크플로우:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;번역 완료 &amp;rarr; Excel 내보내기&lt;/li&gt;
&lt;li&gt;번역가: Excel에서 수정 (편함)&lt;/li&gt;
&lt;li&gt;Excel 가져오기 &amp;rarr; ID로 정확히 매칭&lt;/li&gt;
&lt;li&gt;파일에 자동 반영&lt;/li&gt;
&lt;li&gt;게임 적용&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Bundle 패킹 (한 줄이 중요하다)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번역된 텍스트를 게임에 적용한다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# core/bundle_packer.py

class BundlePacker:
    def pack_and_apply(self, game_path, translated_files):
        bundles = self._find_target_bundles(game_path)

        for bundle_path in bundles:
            # 백업 (중요!)
            backup_path = bundle_path.with_suffix('.bundle.backup')
            if not backup_path.exists():
                shutil.copy(bundle_path, backup_path)
                # 실수해도 복구 가능

            # 로드
            env = UnityPy.load(str(bundle_path))

            # 번역 매핑
            translated_dict = {Path(f).stem: f for f in translated_files}

            # 적용
            for obj in env.objects:
                if obj.type.name == 'TextAsset':
                    data = obj.read()
                    if data.name in translated_dict:
                        with open(translated_dict[data.name], 'r', encoding='utf-8') as f:
                            data.m_Script = f.read()  # 문자열!
                        data.save()

            # 저장
            with open(bundle_path, 'wb') as f:
                f.write(env.file.save())&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중요:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;data.m_Script = content&lt;/code&gt; ✅ (문자열)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data.m_Script = content.encode()&lt;/code&gt; ❌ (bytes 할당 오류)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄 차이로 3시간 디버깅했다. 문자열 직접 할당해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임 언어 감지 (자동화가 답이다)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 구조를 자동으로 감지한다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# core/game_language_detector.py

class GameLanguageDetector:
    def detect_game_format(self, game_path):
        streaming = game_path / &quot;StreamingAssets&quot;

        # Naninovel 감지
        if streaming.exists():
            bundles = list(streaming.glob(&quot;**/*.bundle&quot;))
            if bundles:
                language_folders = self._detect_language_folders(bundles)
                chapter_structure = self._detect_chapter_structure(bundles)
                return {
                    'format': 'Naninovel',
                    'has_language_folders': language_folders,
                    'has_chapter_structure': chapter_structure
                }

        # 일반 Unity 감지
        if (game_path / &quot;data.unity3d&quot;).exists():
            return {'format': 'Unity Single Bundle'}

        assets = list(game_path.glob(&quot;*.assets&quot;))
        if assets:
            return {'format': 'Unity Asset Split'}

        return {'format': 'Unknown'}  # 포기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자: 게임 폴더만 선택&lt;br /&gt;프로그램: 알아서 구조 파악&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;魔法少女ノ魔女裁判 최종 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;챕터1 번역 완료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;61개 스크립트 파일&lt;/li&gt;
&lt;li&gt;약 5,000개 대사&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비용 비교&lt;/h3&gt;
&lt;pre class=&quot;ldif&quot;&gt;&lt;code&gt;Qwen 로컬 모델: 무료
- 품질: 어색함
- 시간: 챕터당 5시간
- 전기세: ???

Claude API: $0.50
- 품질: 자연스러움
- 시간: 챕터당 30분
- Prompt Caching + Translation Memory&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;품질 비교 (결정적 차이)&lt;/h3&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;원문: 辛すぎる

Qwen:   너무 매운     (X) 문맥 이해 실패
Claude: 너무 힘들어   (O) 완벽&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude 압도적 승리. 돈값 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 통계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 품질&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;모듈화: 2,600줄 &amp;rarr; 7개 모듈 (평균 377줄)
컴파일: 100% 성공
순환 참조: 0건&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;번역 속도: ~2초/대사 (Claude)
캐싱: 80-90% 중복 방지
API 비용: 90% 절감 (Prompt Caching 덕분)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;지원 범위&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;✅ Naninovel 게임 (魔法少女ノ魔女裁判 완료)
✅ 일반 Unity 게임 (AssetsTools.NET)
✅ Excel 검수 워크플로우
✅ 5단계 API 키 보안
✅ 자동 백업
✅ 다중 API 엔진 (Claude, GPT-4, DeepL)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;로컬 모델 vs API&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬: 무료, 품질 낮음, GPU 불탄다&lt;/li&gt;
&lt;li&gt;API: 유료, 품질 높음, 최적화로 90% 절감 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안은 다층 방어&lt;/b&gt;&lt;br /&gt;하드웨어 키 + AES + XOR + 패딩 + HMAC.&lt;br /&gt;평문 저장? 그건 자살 행위.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Excel 워크플로우&lt;/b&gt;&lt;br /&gt;아직은 사람 손이 타야 품질이 좋아진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동화가 핵심&lt;/b&gt;&lt;br /&gt;게임 구조 감지, 백업, 번역 모두 자동화.&lt;br /&gt;사용자는 클릭만.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디테일이 생명&lt;/b&gt;&lt;br /&gt;&lt;code&gt;data.m_Script = content&lt;/code&gt; vs &lt;code&gt;content.encode()&lt;/code&gt;&lt;br /&gt;한 줄 차이로 3시간 날린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1부: Naninovel 번역&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UnityPy로 Bundle 파싱&lt;/li&gt;
&lt;li&gt;Ruby 태그 제거&lt;/li&gt;
&lt;li&gt;Qwen AI 문맥 번역 (어색했다)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2부: API 모델 전환&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 모델 한계 깨달음&lt;/li&gt;
&lt;li&gt;Claude API 선택 (Prompt Caching 사기)&lt;/li&gt;
&lt;li&gt;Translation Memory (돈 아끼기)&lt;/li&gt;
&lt;li&gt;범용 Unity 게임 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3부: 리팩토링&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2,600줄 괴물 &amp;rarr; 7개 모듈&lt;/li&gt;
&lt;li&gt;폴더 구조 최적화 (이름이 중요)&lt;/li&gt;
&lt;li&gt;순환 참조 제거 (Lazy import)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4부: 일단 완성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;5단계 API 키 보안 (평문은 NO)&lt;/li&gt;
&lt;li&gt;Excel 검수 워크플로우&lt;/li&gt;
&lt;li&gt;자동 백업 시스템&lt;/li&gt;
&lt;li&gt;魔法少女ノ魔女裁判 완료&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기술 스택&lt;/h2&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;언어: Python 3.10+
GUI: PyQt6
Unity: UnityPy, AssetsTools.NET (pythonnet으로 호출)
AI: Claude API (Qwen 7B는 품질 문제로 폐기)
보안: cryptography (AES-256)
데이터: SQLite, pandas, openpyxl&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막 한마디&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 하나 번역하려다 번역기 만들었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 모델로 시작&lt;/li&gt;
&lt;li&gt;API로 전환 (돈 쓰기)&lt;/li&gt;
&lt;li&gt;코드 2,600줄 괴물 탄생&lt;/li&gt;
&lt;li&gt;리팩토링으로 구원&lt;/li&gt;
&lt;li&gt;일단 완성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;魔法少女ノ魔女裁判, 이제 한글로 플레이한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 여기서 개발 멈춤.&lt;br /&gt;원하는 게임 번역했으니 만족&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 게임을 많이 하는건 아니라 내가 원하는 것만 하면 됐지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 공개했으니 원하면 가져다가 사용해도 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/hasjin/game-translator.git&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/hasjin/game-translator.git&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/4</guid>
      <comments>https://deepdive-dev.tistory.com/4#entry4comment</comments>
      <pubDate>Sun, 12 Oct 2025 12:30:37 +0900</pubDate>
    </item>
    <item>
      <title>Unity 게임 번역기 개발기 #3: 코드가 괴물이 되다</title>
      <link>https://deepdive-dev.tistory.com/3</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a2.png&quot; data-origin-width=&quot;356&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XM8ka/btsQ3w8Hejw/Z5icH0yztvlTBkKmfVn4n0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XM8ka/btsQ3w8Hejw/Z5icH0yztvlTBkKmfVn4n0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XM8ka/btsQ3w8Hejw/Z5icH0yztvlTBkKmfVn4n0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXM8ka%2FbtsQ3w8Hejw%2FZ5icH0yztvlTBkKmfVn4n0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;356&quot; height=&quot;410&quot; data-filename=&quot;a2.png&quot; data-origin-width=&quot;356&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 인식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 추가할 때마다 &lt;code&gt;main_window.py&lt;/code&gt;에 코드를 계속 때려박았다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# main_window.py (2,600줄)
class MainWindow(QMainWindow):
    def __init__(self):
        # 프로젝트 관리
        # 번역 엔진
        # Excel 관리
        # UI 생성
        # 세션 저장
        # ... 100줄

    def create_translation_tab(self):
        # 200줄

    def start_translation(self):
        # 150줄

    def export_excel(self):
        # 100줄

    # ... 계속 이어진다... 2,000줄 더...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤을 내리는데 끝이 안 보인다. 어디에 뭐가 있는지 나도 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그 수정하려고 검색하면 같은 함수가 3번 나온다. 복붙의 역사.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mixin 패턴 (분할 통치)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능별로 파일을 쪼갰다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# handlers/excel_handler.py
class ExcelHandlerMixin:
    def export_excel(self):
        # Excel 내보내기만
        pass

    def import_excel(self):
        # Excel 가져오기만
        pass

# managers/session_manager.py
class SessionManagerMixin:
    def _save_session(self):
        # 세션 저장만
        pass

    def _restore_session(self):
        # 세션 복원만
        pass

# managers/project_manager.py
class ProjectManagerMixin:
    def auto_create_or_select_project(self):
        # 프로젝트 관리만
        pass

# ui/tab_builder.py
class TabBuilderMixin:
    def create_translation_tab(self):
        # UI 생성만
        pass&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 파일은 Mixin 조합으로.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;# main_window.py (914줄)
class MainWindow(
    ExcelHandlerMixin,
    SessionManagerMixin,
    ProjectManagerMixin,
    TabBuilderMixin,
    QMainWindow
):
    def __init__(self):
        # 초기화만
        super().__init__()&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Before: main_window.py = 2,600줄 (악몽)

After:
├── main_window.py          = 914줄 (65% 감소!)
├── ui/tab_builder.py       = 459줄
├── managers/
│   ├── project_manager.py = 254줄
│   └── session_manager.py = 204줄
├── handlers/
│   └── excel_handler.py   = 216줄
├── workers/
│   └── translation_worker.py = 460줄
└── dialogs/
    └── chapter_selection.py = 130줄

Total: 2,637줄 (7개 모듈)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 어디 봐야 할지 안다. Excel 문제? &amp;rarr; &lt;code&gt;excel_handler.py&lt;/code&gt; 보면 됨.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;폴더 구조 최적화 (이름이 중요하다)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더명이 애매했다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;src/     # 뭐 하는 폴더? 다 소스 코드지
utils/   # 보안이랑 도구가 왜 같이?
formats/ # naninovel.py 하나만 덩그러니...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명확하게 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;Before                      After
────────────────────────────────────────
src/                   &amp;rarr;    cli/
                            (아, CLI 도구구나)

utils/                 &amp;rarr;    security/
                            (보안 전용)

                       &amp;rarr;    tools/
                            (개발 도구)

formats/naninovel.py   &amp;rarr;    core/
                            (핵심 엔진에 통합)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 구조&lt;/h3&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;gametranslator/
├── gui/          # PyQt6 GUI (한눈에 알아본다)
├── core/         # 번역 엔진 (핵심)
├── cli/          # CLI 도구 (내부용)
├── security/     # API 키 암호화 (중요)
├── tools/        # 개발 도구 (잡다한 것들)
└── examples/     # 예시 파일 (사용자용)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 열면 바로 안다. &quot;아, 여기 보면 되겠네.&quot;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Import 구문 수정 (대공사)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 이름이 바뀌면서 import도 전부 고쳐야 한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# Before (옛날)
from src.extractor import UnityTextExtractor
from utils.secure_storage import SecureStorage

# After (지금)
from cli.extractor import UnityTextExtractor
from security.secure_storage import SecureStorage&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 프로젝트를 검색해서 일괄 수정.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 검증 (떨리는 순간)
python -m py_compile gui/**/*.py core/**/*.py cli/**/*.py
# 100% 성공!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 에러 없이 넘어갔을 때 그 희열.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순환 참조 제거 (무한 루프의 공포)&lt;/h2&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;ImportError: cannot import name 'UniversalTranslator' from partially initialized module 'core.translator'
(most likely due to a circular import)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순환 참조. 모듈 A가 B를 import, B가 A를 import.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lazy import로 해결.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# Before (순환 참조)
from core.translator import UniversalTranslator

class MainWindow(QMainWindow):
    def __init__(self):
        self.translator = UniversalTranslator()  # 여기서 터짐

# After (Lazy import)
class MainWindow(QMainWindow):
    def __init__(self):
        self.translator = None

    def start_translation(self):
        if not self.translator:
            from core.translator import UniversalTranslator
            self.translator = UniversalTranslator()  # 필요할 때만&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요할 때 import. 순환 참조 해결.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설계 원칙&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;폴더명이 기능을 명확히 표현&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;cli/&lt;/code&gt;: CLI 도구 (명확)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;security/&lt;/code&gt;: 보안 전용 (명확)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tools/&lt;/code&gt;: 개발 도구 (명확)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/&lt;/code&gt;: 뭐 하는 거? (불명확)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;각 폴더는 독립적 기능 단위&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GUI와 Core는 분리&lt;/li&gt;
&lt;li&gt;CLI는 Core 사용&lt;/li&gt;
&lt;li&gt;순환 참조 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Mixin으로 기능 분리&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 책임 원칙 (한 클래스 한 기능)&lt;/li&gt;
&lt;li&gt;테스트 용이 (각 Mixin 독립 테스트)&lt;/li&gt;
&lt;li&gt;재사용 가능 (다른 프로젝트에도 쓸 수 있음)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성과&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;코드 품질:
- 모듈화: 2,600줄 &amp;rarr; 7개 모듈 (평균 377줄)
- 컴파일: 100% 성공
- 순환 참조: 0건

유지보수성:
- 최대 파일: 914줄 (이전 2,600줄)
- 함수당 평균: 20-30줄 (읽을 만하다)
- 클래스 책임: 단일 책임 원칙 (깔끔)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2,600줄 괴물이 7개 모듈로 분리됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 새 기능 추가할 때 어디에 넣어야 할지 안다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;리팩토링은 필수다&lt;/b&gt;&lt;br /&gt;2,600줄은 유지보수 재앙. 3개월 뒤 내가 못 읽는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;폴더명이 중요하다&lt;/b&gt;&lt;br /&gt;&lt;code&gt;src/&lt;/code&gt; 같은 애매한 이름 쓰지 말기. &lt;code&gt;cli/&lt;/code&gt;, &lt;code&gt;security/&lt;/code&gt; 같이 명확하게.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Mixin 패턴의 힘&lt;/b&gt;&lt;br /&gt;다중 상속으로 기능 조합. 레고 블록 조립하듯이.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Lazy import&lt;/b&gt;&lt;br /&gt;순환 참조는 Lazy import로 해결. 필요할 때만 import.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한번에 하지 말자&lt;/b&gt;&lt;br /&gt;모듈화 &amp;rarr; 폴더 정리 &amp;rarr; Import 수정 &amp;rarr; 순환 참조 해결. 단계별로.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다음 편&lt;/b&gt;: 보안, 워크플로우, 일단 완성&lt;/p&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/3</guid>
      <comments>https://deepdive-dev.tistory.com/3#entry3comment</comments>
      <pubDate>Sat, 11 Oct 2025 12:30:49 +0900</pubDate>
    </item>
    <item>
      <title>Unity 게임 번역기 개발기 #2: 돈을 쓰기로 했다</title>
      <link>https://deepdive-dev.tistory.com/2</link>
      <description>&lt;h2&gt;로컬 모델의 현실&lt;/h2&gt;
&lt;p&gt;Qwen2.5-7B로 魔法少女ノ魔女裁判 챕터 1을 번역했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어색하다&lt;/li&gt;
&lt;li&gt;하지만 플레이는 가능하다&lt;/li&gt;
&lt;li&gt;GPU는 불타고 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;챕터 2 시작. 문제 발생.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;싫어받다 (X)
미움받다 (O)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;로컬 7B 모델은 문맥을 이해 못 한다.&lt;/p&gt;
&lt;p&gt;무슨 상황에서 나온말인지 구분을 못 한다.&lt;/p&gt;
&lt;h2&gt;다른 Unity 게임도 하고 싶었다&lt;/h2&gt;
&lt;p&gt;魔法少女ノ魔女裁判은 Naninovel 엔진이다. 다른 Unity 게임들도 번역하고 싶었다.&lt;/p&gt;
&lt;p&gt;일반 Unity 게임은 텍스트가 MonoBehaviour에 직렬화되어 있다. UnityPy로는 안 읽힌다.&lt;/p&gt;
&lt;h3&gt;AssetsTools.NET이라는 구원자&lt;/h3&gt;
&lt;p&gt;UABE라는 도구의 핵심 라이브러리. TypeTree로 모든 Unity 버전 지원.&lt;/p&gt;
&lt;p&gt;문제: C# DLL이다.&lt;/p&gt;
&lt;p&gt;나는 Python을 쓴다.&lt;/p&gt;
&lt;p&gt;해결: pythonnet&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install pythonnet
# .NET을 Python에서 쓴다.&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import clr
from System import Activator
from System.Reflection import Assembly

# AssetsTools.NET 로드
assembly = Assembly.LoadFrom(&amp;quot;AssetsTools.NET.dll&amp;quot;)
manager_type = assembly.GetType(&amp;quot;AssetsTools.NET.AssetsManager&amp;quot;)
manager = Activator.CreateInstance(manager_type)

# TypeTree DB 로드
manager.LoadClassPackage(&amp;quot;classdata.tpk&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;성공. 일반 Unity 게임도 텍스트 추출 가능.&lt;/p&gt;
&lt;p&gt;Python에서 .NET DLL 호출하는 날이 올 줄이야.&lt;/p&gt;
&lt;h2&gt;API 모델 조사 (지갑을 열 시간)&lt;/h2&gt;
&lt;p&gt;로컬 7B로는 한계다. 돈을 쓰기로 했다.&lt;/p&gt;
&lt;h3&gt;1. Claude API (Anthropic)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;장점: 번역 품질 최상&lt;/li&gt;
&lt;li&gt;단점: 비싸다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;특이사항&lt;/strong&gt;: Prompt Caching으로 90% 할인&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. GPT-4 (OpenAI)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;장점: 번역 품질 좋음&lt;/li&gt;
&lt;li&gt;단점: Claude보다 비쌈&lt;/li&gt;
&lt;li&gt;특이사항: Caching 없음 (돈 많이 나감)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. DeepL API&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;장점: 번역 전문&lt;/li&gt;
&lt;li&gt;단점: 커스텀 규칙 적용 어려움&lt;/li&gt;
&lt;li&gt;특이사항: &amp;quot;魔法少女&amp;quot; 같은 고유명사 처리 애매&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. Google Translate&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;장점: 무료&lt;/li&gt;
&lt;li&gt;단점: 품질 낮음 (이미 경험함)&lt;/li&gt;
&lt;li&gt;특이사항: Ruby 태그 때문에 폭사&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Claude API 선택 (Prompt Caching의 힘)&lt;/h2&gt;
&lt;p&gt;Prompt Caching이 결정적이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;system = [{
    &amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;,
    &amp;quot;text&amp;quot;: open(&amp;quot;translation_rules_ja_ko.yaml&amp;quot;).read(),  # 5000토큰
    &amp;quot;cache_control&amp;quot;: {&amp;quot;type&amp;quot;: &amp;quot;ephemeral&amp;quot;}  # 5분 TTL
}]

response = client.messages.create(
    model=&amp;quot;claude-3-5-sonnet-20241022&amp;quot;,
    system=system,
    messages=[{&amp;quot;role&amp;quot;: &amp;quot;user&amp;quot;, &amp;quot;content&amp;quot;: japanese_text}]
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;비용:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;첫 호출: $1.00 (전체 토큰)&lt;/li&gt;
&lt;li&gt;캐시 히트: $0.10 (90% 절감!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;번역 규칙 5,000토큰을 매번 보낸다. 하지만 캐싱 덕분에 많이 비용 절감이 된다&lt;/p&gt;
&lt;h2&gt;번역 엔진 추상화&lt;/h2&gt;
&lt;p&gt;여러 API를 쉽게 바꿀 수 있게 했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# core/translation_engines.py

class BaseTranslator(ABC):
    @abstractmethod
    def translate(self, text, source_lang, target_lang):
        pass

class ClaudeTranslator(BaseTranslator):
    def translate(self, text, source_lang, target_lang):
        # Claude API
        pass

class GPT4Translator(BaseTranslator):
    def translate(self, text, source_lang, target_lang):
        # GPT-4 API
        pass

class DeepLTranslator(BaseTranslator):
    def translate(self, text, source_lang, target_lang):
        # DeepL API
        pass&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GUI에서 엔진 선택 가능. 드롭다운 하나면 끝.&lt;/p&gt;
&lt;h2&gt;Translation Memory (중복은 NO)&lt;/h2&gt;
&lt;p&gt;같은 문장 두 번 번역하면 바보다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# core/translation_memory.py

import sqlite3
import hashlib

class TranslationMemory:
    def get(self, original_text, language_pair):
        key = hashlib.md5(f&amp;quot;{original_text}:{language_pair}&amp;quot;.encode()).hexdigest()
        self.cursor.execute(&amp;quot;SELECT translated FROM cache WHERE key=?&amp;quot;, (key,))
        result = self.cursor.fetchone()
        return result[0] if result else None

    def save(self, original_text, translated_text, language_pair):
        key = hashlib.md5(f&amp;quot;{original_text}:{language_pair}&amp;quot;.encode()).hexdigest()
        self.cursor.execute(
            &amp;quot;INSERT OR REPLACE INTO cache VALUES (?, ?, ?, ?)&amp;quot;,
            (key, original_text, translated_text, language_pair)
        )&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 비용:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prompt Caching: 90% 절감&lt;/li&gt;
&lt;li&gt;Translation Memory: 80-90% 중복 방지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;최종: 원래 비용의 1-2%&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;지갑이 살았다.&lt;/p&gt;
&lt;h2&gt;품질 비교 (돈값을 하는가?)&lt;/h2&gt;
&lt;p&gt;魔法少女ノ魔女裁判 챕터 1을 다시 번역했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;품질&lt;/th&gt;
&lt;th&gt;속도&lt;/th&gt;
&lt;th&gt;비용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Qwen 7B&lt;/td&gt;
&lt;td&gt;어색함&lt;/td&gt;
&lt;td&gt;1.5시간&lt;/td&gt;
&lt;td&gt;무료 (전기세 제외)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;자연스러움&lt;/td&gt;
&lt;td&gt;5분&lt;/td&gt;
&lt;td&gt;$0.50 (캐싱 후)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;td&gt;7분&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepL&lt;/td&gt;
&lt;td&gt;괜찮음&lt;/td&gt;
&lt;td&gt;3분&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Claude 압도적 승리.&lt;/p&gt;
&lt;p&gt;챕터 1 번역에 1.5시간 걸리던 게 5분으로 줄었다. GPU 팬도 조용해졌다.&lt;/p&gt;
&lt;h2&gt;배운 것&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;로컬 모델은 한계가 있다&lt;/strong&gt;&lt;br&gt;7B로는 자연스러운 번역 어렵다. 문맥 이해가 약하다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prompt Caching은 사기다&lt;/strong&gt;&lt;br&gt;5,000토큰 규칙을 매번 보내도 90% 절감. 이게 기술이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Translation Memory는 필수&lt;/strong&gt;&lt;br&gt;같은 문장 두 번 번역하는건 바보짓. 80-90% 추가 절감.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;엔진 추상화의 중요성&lt;/strong&gt;&lt;br&gt;오늘은 Claude, 내일은 GPT-4. 드롭다운 하나로 끝.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;돈을 쓸 때와 아낄 때를 구분하자&lt;/strong&gt;&lt;br&gt;품질이 중요하면 돈 쓰기. 최적화로 90% 아끼기.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;다음 편&lt;/strong&gt;: 코드 2,600줄이라는 재앙&lt;/p&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/2</guid>
      <comments>https://deepdive-dev.tistory.com/2#entry2comment</comments>
      <pubDate>Fri, 10 Oct 2025 12:00:09 +0900</pubDate>
    </item>
    <item>
      <title>Unity 게임 번역기 개발기 #1: 이게 시작이었다</title>
      <link>https://deepdive-dev.tistory.com/1</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a1.png&quot; data-origin-width=&quot;1102&quot; data-origin-height=&quot;872&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cripkA/btsQ4QFjwmW/cjuAyysdaxEh2Uje6rrap1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cripkA/btsQ4QFjwmW/cjuAyysdaxEh2Uje6rrap1/img.png&quot; data-alt=&quot;마지막 개발중에 돌린 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cripkA/btsQ4QFjwmW/cjuAyysdaxEh2Uje6rrap1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcripkA%2FbtsQ4QFjwmW%2FcjuAyysdaxEh2Uje6rrap1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1102&quot; height=&quot;872&quot; data-filename=&quot;a1.png&quot; data-origin-width=&quot;1102&quot; data-origin-height=&quot;872&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;마지막 개발중에 돌린 내용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사건의 발단&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;魔法少女ノ魔女裁判 라는 일본 비주얼 노벨 게임을 발견했다. 단간론파 비슷한 느낌이다. 재밌어 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제: 한글이 없다. 중국어는 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일본어는 어느정도 할 줄 안다. 하지만 편하게 우리말로 하고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중국어를 한글로 바꾸기만 하면 되는 거 아니야? 이게 모든 삽질의 시작이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Unity Bundle 파헤치기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity 게임의 텍스트는 &lt;code&gt;.bundle&lt;/code&gt; 파일에 들어있다. 암호문 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글링 1시간 &amp;rarr; UnityPy 라이브러리 발견.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;import UnityPy

env = UnityPy.load(&quot;game.bundle&quot;)
for obj in env.objects:
    if obj.type.name == 'TextAsset':
        data = obj.read()
        text = data.m_Script  # 됐다!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공. 근데...&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Naninovel 포맷이라는 복병&lt;/h2&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;# CharacterID
; 日本語原文
中文翻译&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 뭐야?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 결과: Naninovel이라는 엔진이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;;&lt;/code&gt; (세미콜론) = 일본어 주석&lt;/li&gt;
&lt;li&gt;그 아래 줄 = 실제 표시되는 텍스트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일본어 주석을 번역하면 중국어 대신 한국어가 나온다. 구조는 파악했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Google Translate 돌려보자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;24개 스크립트 파일을 Google Translate API로 번역. 10분 완료.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 실행. 한글 나온다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;...근데 화면에 이상한 게 보인다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;ruby=&quot;くびつ&quot;&amp;gt;首吊&amp;lt;/ruby&amp;gt;り縄&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;루비 태그가 그대로 화면에 나온다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목을 매는 밧줄이라고 써야 하는데 &lt;code&gt;&amp;lt;ruby=&quot;くびつ&quot;&amp;gt;首吊&amp;lt;/ruby&amp;gt;り縄&lt;/code&gt; 이게 그대로 나온다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Ruby 태그와의 전쟁&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정규표현식으로 태그를 제거했다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def clean_ruby_tags(text):
    import re
    return re.sub(r'&amp;lt;ruby=&quot;[^&quot;]*&quot;&amp;gt;([^&amp;lt;]+)&amp;lt;/ruby&amp;gt;', r'\1', text)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;ruby=&quot;くびつ&quot;&amp;gt;首吊&amp;lt;/ruby&amp;gt;り縄&lt;/code&gt; &amp;rarr; &lt;code&gt;首吊り縄&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깔끔. 이제 번역하면 되겠네?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 번역 품질이 별로다. 기계적이다. 어색하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Qwen AI 투입 (로컬의 맛)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Qwen2.5-7B 모델 발견. RTX 4080에서 돌아간다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained(
    &quot;Qwen/Qwen2.5-7B-Instruct&quot;,
    device_map=&quot;auto&quot;,
    load_in_8bit=True  # VRAM 7GB
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문맥을 줘봤다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 3개 대사를 context로 제공.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;context = previous_lines[-3:]  # 이전 3개 대사
prompt = f&quot;&quot;&quot;
이전 대사:
{context}

번역할 문장:
{japanese_text}
&quot;&quot;&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 3개?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반말/존댓말 왔다갔다 안 하게&lt;/li&gt;
&lt;li&gt;말줄임(...) 같은 거 제대로 처리하게&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RTX 4080 16GB 기준:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;8비트 양자화: 문장당 1-2초&lt;/li&gt;
&lt;li&gt;챕터 1 전체: &lt;b&gt;1.5시간&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 어색하지만 게임은 플레이 가능.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPU 팬 소리가 비행기 이륙하는 소리 같았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;도구부터 찾아라&lt;/b&gt;&lt;br /&gt;UnityPy 없었으면 바이너리 파싱부터 했을 것이다. 인생 낭비.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문제는 부딪히면서 해결&lt;/b&gt;&lt;br /&gt;Ruby 태그? 게임 켜보고 발견. 미리 알 수 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문맥이 중요하다&lt;/b&gt;&lt;br /&gt;3개 대사 참고하니까 번역이 좀 낫다. 아주 조금.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로컬 AI의 장점&lt;/b&gt;&lt;br /&gt;무료다. 무제한이다. 전기세? 그건 나중 문제다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다음 편&lt;/b&gt;: 로컬 모델은 한계가 있다는 걸 깨닫는 순간&lt;/p&gt;</description>
      <category>개발관련</category>
      <author>삽질연구소 소장</author>
      <guid isPermaLink="true">https://deepdive-dev.tistory.com/1</guid>
      <comments>https://deepdive-dev.tistory.com/1#entry1comment</comments>
      <pubDate>Thu, 9 Oct 2025 15:20:32 +0900</pubDate>
    </item>
  </channel>
</rss>