<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>AI NPC 연구소</title>
        <link>https://velog.io/</link>
        <description>작가, PM 출신 비개발자의 개발새발공부</description>
        <lastBuildDate>Mon, 25 May 2026 16:03:18 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>AI NPC 연구소</title>
            <url>https://velog.velcdn.com/images/ljhljh0703-cmd/profile/8fb7d438-fad6-4679-962b-3fa99f572e9a/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. AI NPC 연구소. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ljhljh0703-cmd" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[ML-Agents로 전투 설계의 약점을 찾기]]></title>
            <link>https://velog.io/@ljhljh0703-cmd/ML-Agents%EB%A1%9C-%EC%A0%84%ED%88%AC-%EC%84%A4%EA%B3%84%EC%9D%98-%EC%95%BD%EC%A0%90%EC%9D%84-%EC%B0%BE%EA%B8%B0</link>
            <guid>https://velog.io/@ljhljh0703-cmd/ML-Agents%EB%A1%9C-%EC%A0%84%ED%88%AC-%EC%84%A4%EA%B3%84%EC%9D%98-%EC%95%BD%EC%A0%90%EC%9D%84-%EC%B0%BE%EA%B8%B0</guid>
            <pubDate>Mon, 25 May 2026 16:03:18 GMT</pubDate>
            <description><![CDATA[<p>CSV 요약</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/33bb7ed6-c608-4876-87fd-129be69f8356/image.png" alt=""></p>
<p>왜 이 글을 쓰는가</p>
<p>로그라이크 전투에서 가장 위험한 순간은 버튼이 많아 보이지만 사실상 한 버튼만 누르면 되는 상태다. Attack만 누르거나 Skill만 눌러도 전투가 풀리면, 전투 선택지는 UI에만 존재하고 플레이어의 판단은 사라진다.</p>
<p>회귀자는 탑을 오른다도 이 위험이 있었다. 전투에는 Attack, Defend, Skill이 있었고, 마타이오스도 actor로 들어오기 시작했다. 하지만 &quot;마타이오스가 언제 보호하고, 언제 공격을 돕고, 언제 스킬 타이밍을 보조해야 하는가&quot;를 감으로 정하기에는 근거가 부족했다.</p>
<p>그래서 ML-Agents를 붙였다.</p>
<p>다만 중요한 전제가 있었다. ML-Agents를 본편 runtime AI로 넣으려는 게 아니었다. 본편 combat은 deterministic해야 하고, 같은 상태에서는 같은 결과가 나와야 한다. 그래서 ML-Agents는 shipped NPC brain이 아니라, 전투 설계의 취약점을 찾는 실험용 Combat Design Lab으로 분리했다.</p>
<p>결론부터 말하면, PPO를 더 오래 돌리는 것보다 전투 규칙과 context 설계를 고치는 게 먼저였다. 그리고 최종 handoff는 ONNX runtime 연결이 아니라 deterministic ContextPolicy였다.</p>
<p>실험실을 본편에서 분리했다
처음 세운 경계는 단순했다.</p>
<p>본편 runtime에는 RL을 넣지 않는다.
별도 TrainingCombat 환경에서만 학습한다.
PPO 결과는 설계 근거로만 사용한다.
본편에는 deterministic rule로 환원한다.
이 경계를 잡지 않으면 실험과 게임이 서로 망가진다. 본편은 안정적인 플레이와 QA가 필요하고, 실험 환경은 빠르게 규칙을 바꾸고 metric을 추가할 수 있어야 한다.</p>
<p>그래서 TrainingCombat은 본편의 HUD, save/load, node map, shop/event flow에서 분리된 전투 실험 장면으로 잡았다. 여기서 PPO agent와 baseline policy를 비교했다.</p>
<p>비교 대상은 다음과 같았다.</p>
<p>RandomPolicy
AttackSpamPolicy
SkillSpamPolicy
DefendHeavyPolicy
ContextPolicy
PPO Agent
baseline을 먼저 둔 이유는 간단하다. PPO 하나만 보면 학습이 된 것처럼 보일 수 있다. 하지만 AttackSpam이나 SkillSpam보다 못하면, 그건 좋은 전투 정책이 아니라 reward surface의 빈틈을 따라간 결과일 수 있다.</p>
<p>Experiment 01: 연결부터 확인했다
첫 실험의 목표는 학습 성능이 아니었다.</p>
<p>목표는 세 가지였다.</p>
<p>Unity executable과 Python trainer 연결
ML-Agents training loop 동작
ONNX export 확인
이 단계는 pipeline milestone이다. &quot;좋은 agent가 나왔다&quot;가 아니라 &quot;이제 실험할 수 있는 연결이 생겼다&quot;에 가깝다.</p>
<p>이 구분이 중요하다. 포트폴리오에서 ONNX export를 보여줄 수는 있지만, 그것만으로 게임 AI가 완성됐다고 말하면 안 된다.</p>
<p>Experiment 02: PPO가 SkillSpam과 구분되지 않았다
Exp02에서 처음으로 PPO와 spam baseline을 비교했다.</p>
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="620" viewBox="0 0 1000 620">
  <rect width="100%" height="100%" fill="#ffffff"/>
  <style>
    text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
    .title { font-size: 22px; font-weight: 700; }
    .label { font-size: 12px; }
    .small { font-size: 10px; fill: #4b5563; }
    .axis { stroke: #d1d5db; stroke-width: 1; }
    .grid { stroke: #e5e7eb; stroke-width: 1; }
  </style>
  <text x="80" y="42" class="title">PPO Action Share Shift Across Exp02-06</text>
  <line x1="80" y1="510.0" x2="920" y2="510.0" class="grid"/>
  <text x="68" y="514.0" text-anchor="end" class="small">0.0</text>
  <line x1="80" y1="426.0" x2="920" y2="426.0" class="grid"/>
  <text x="68" y="430.0" text-anchor="end" class="small">0.2</text>
  <line x1="80" y1="342.0" x2="920" y2="342.0" class="grid"/>
  <text x="68" y="346.0" text-anchor="end" class="small">0.4</text>
  <line x1="80" y1="258.0" x2="920" y2="258.0" class="grid"/>
  <text x="68" y="262.0" text-anchor="end" class="small">0.6</text>
  <line x1="80" y1="174.0" x2="920" y2="174.0" class="grid"/>
  <text x="68" y="178.0" text-anchor="end" class="small">0.8</text>
  <line x1="80" y1="90.0" x2="920" y2="90.0" class="grid"/>
  <text x="68" y="94.0" text-anchor="end" class="small">1.0</text>
  <rect x="80" y="342.0" width="840" height="63.0" fill="#f97316" opacity="0.10"/>
  <rect x="113.6" y="398.1" width="72.8" height="111.9" fill="#64748b"/>
  <rect x="113.6" y="392.4" width="72.8" height="5.8" fill="#14b8a6"/>
  <rect x="113.6" y="90.0" width="72.8" height="302.4" fill="#f97316"/>
  <text x="150.0" y="538" text-anchor="middle" class="label">Exp02</text>
  <rect x="253.6" y="243.5" width="72.8" height="266.5" fill="#64748b"/>
  <rect x="253.6" y="219.9" width="72.8" height="23.6" fill="#14b8a6"/>
  <rect x="253.6" y="90.0" width="72.8" height="129.9" fill="#f97316"/>
  <text x="290.0" y="538" text-anchor="middle" class="label">Exp03</text>
  <rect x="393.6" y="326.8" width="72.8" height="183.2" fill="#64748b"/>
  <rect x="393.6" y="213.5" width="72.8" height="113.3" fill="#14b8a6"/>
  <rect x="393.6" y="90.0" width="72.8" height="123.5" fill="#f97316"/>
  <text x="430.0" y="538" text-anchor="middle" class="label">Exp04</text>
  <rect x="533.6" y="309.6" width="72.8" height="200.4" fill="#64748b"/>
  <rect x="533.6" y="176.2" width="72.8" height="133.4" fill="#14b8a6"/>
  <rect x="533.6" y="90.0" width="72.8" height="86.2" fill="#f97316"/>
  <text x="570.0" y="538" text-anchor="middle" class="label">Exp05</text>
  <rect x="673.6" y="314.3" width="72.8" height="195.7" fill="#64748b"/>
  <rect x="673.6" y="186.8" width="72.8" height="127.5" fill="#14b8a6"/>
  <rect x="673.6" y="90.0" width="72.8" height="96.8" fill="#f97316"/>
  <text x="710.0" y="538" text-anchor="middle" class="label">Exp05b</text>
  <rect x="813.6" y="306.6" width="72.8" height="203.4" fill="#64748b"/>
  <rect x="813.6" y="179.7" width="72.8" height="126.9" fill="#14b8a6"/>
  <rect x="813.6" y="90.0" width="72.8" height="89.7" fill="#f97316"/>
  <text x="850.0" y="538" text-anchor="middle" class="label">Exp06</text>
  <rect x="80" y="550" width="14" height="14" fill="#64748b" rx="2"/>
  <text x="100" y="562" class="label">Attack</text>
  <rect x="190" y="550" width="14" height="14" fill="#14b8a6" rx="2"/>
  <text x="210" y="562" class="label">Defend</text>
  <rect x="300" y="550" width="14" height="14" fill="#f97316" rx="2"/>
  <text x="320" y="562" class="label">Skill</text>
  <rect x="410" y="550" width="14" height="14" fill="#f97316" opacity="0.25" rx="2"/>
  <text x="430" y="562" class="label">Skill target 25-40%</text>
</svg>


<p>결과는 애매했다. PPO는 Random이나 AttackSpam보다 좋아 보였지만, SkillSpam과는 명확히 구분되지 않았다.</p>
<p>핵심 수치는 이렇다.<img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/93f5a67e-877b-4bcd-83eb-b0a89b2c4e9b/image.svg" alt=""></p>
<p>Policy    Reward    Win proxy    Attack    Defend    Skill    해석
PPO10k    1.605229    1.000000    0.266310    0.013761    0.719929    Skill-heavy learned policy
AttackSpamPolicy    1.581029    1.000000    1.000000    0.000000    0.000000    공격 반복도 강함
SkillSpamPolicy    1.609308    1.000000    0.000000    0.000000    1.000000    Skill shortcut 노출
DefendHeavyPolicy    -1.139359    0.003000    0.237230    0.762389    0.000381    Defend payoff 부족
PPO의 Skill share는 0.719929였다. 거의 Skill-heavy policy였다.</p>
<p>이건 실패처럼 보이지만, 실제로는 가장 좋은 발견 중 하나였다. 전투 구조가 Skill-heavy shortcut을 허용하고 있다는 걸 데이터로 확인했기 때문이다.</p>
<p>문제는 agent가 아니었다. 규칙이 그렇게 행동하도록 열려 있었다.</p>
<p>Experiment 03: 보상보다 metric이 먼저였다
다음 단계에서는 Skill spam을 줄이고 Defend를 살리려고 했다.</p>
<p>변경한 것은 크게 두 가지다.</p>
<p>Skill cooldown / cost 추가
Defend prevented-damage metric 추가
결과는 바로 보였다. PPO의 Skill share가 줄었다.</p>
<p>0.719929 → 0.309283
또한 방어가 실제로 피해를 막았는지 metric으로 드러나기 시작했다.</p>
<p>DamagePreventedPerStep: 0 → 0.245509
하지만 이것만으로 충분하지 않았다. SkillSpam과 AttackSpam은 여전히 강했고, PPO는 spam baseline을 명확히 이기지 못했다.</p>
<p>여기서 얻은 교훈은 단순하다.</p>
<p>학습을 더 오래 돌리기 전에,
전투 규칙이 좋은 선택을 구분할 수 있게 만들어야 한다.
Experiment 04: 규칙을 바꾸자 결과가 바뀌었다
<img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/3d1596b1-d088-4f8b-8241-268adcd160b6/image.svg" alt="">
Exp04에서는 reward만 만지지 않고 전투 규칙을 더 적극적으로 바꿨다.</p>
<p>변경 내용은 다음과 같다.</p>
<p>2턴 Skill cooldown
low-HP Skill waste penalty
high-threat enemy turn
Defend tempo attack payoff
ContextPolicy 강화
목표는 spam policy보다 나은 결과를 만드는 것이었다.</p>
<p>이번에는 변화가 있었다.</p>
<p>Policy    Reward    Win    Attack    Defend    Skill    DamagePrevented/Step    SkillWasted/Ep
PPOExp04_10k    1.855968    0.988142    0.436213    0.269650    0.294137    1.720721    0.280632
AttackSpamPolicy    1.005000    0.785000    1.000000    0.000000    0.000000    0.000000    0.000000
SkillSpamPolicy    1.536994    1.000000    0.666667    0.000000    0.333333    0.000000    0.000000
ContextPolicy    2.101917    1.000000    0.438940    0.296159    0.264901    2.397086    0.000000
PPO reward는 가장 강한 spam baseline보다 +0.318974 높았다.</p>
<p>이제 PPO는 Attack, Defend, Skill을 모두 사용했다. Defend도 살아났고, Skill도 무작정 반복하지 않았다.</p>
<p>그런데 더 중요한 사실이 있었다. PPO보다 ContextPolicy가 더 좋았다.</p>
<p>ContextPolicy는 사람이 설계한 deterministic policy다. high-threat에서는 방어하고, 방어 성공 후에는 tempo attack으로 보상을 가져가고, Skill은 낭비되지 않을 때만 쓴다. PPO는 그 방향을 어느 정도 배웠지만, 10k 단계에서는 여전히 Skill waste가 남았다.</p>
<p>이 지점에서 결론이 정리됐다.</p>
<p>PPO는 좋은 설계 probe였다.
하지만 본편에 넣을 후보는 PPO가 아니라 ContextPolicy다.
Experiment 05~06: 더 돌리는 게 답은 아니었다
Exp04 이후에도 tuning을 더 해봤다.</p>
<p>Exp05에서는 Skill opportunity gate를 추가했다. Skill waste는 0으로 줄었지만, Skill share가 0.205214까지 내려갔다. 너무 보수적으로 변한 것이다.</p>
<p>Exp05b에서는 HP threshold를 12에서 10으로 완화했다. PPO reward와 SpamPolicyGap은 좋아졌지만, Skill share는 0.230376으로 여전히 목표에 조금 못 미쳤다.</p>
<p>Exp06에서는 Skill opportunity observation bit를 추가했다. 하지만 결과는 strict fail이었다.</p>
<p>PPO reward: 1.798036
Skill share: 0.213534
ContextPolicyGap: -0.304295
이 결과는 중요했다. 관측을 하나 더 넣고, tuning을 더 한다고 자동으로 좋은 정책이 나오는 게 아니었다.</p>
<p>그래서 여기서 멈췄다. 50k training이나 ONNX runtime 연결로 밀고 가지 않았다. Exp05~06은 성공으로 포장하지 않고 failure/near-miss evidence로 남겼다.</p>
<p>본편에는 어떻게 반영하나
실험 결과를 본편에 직접 RL로 넣지 않는다.</p>
<p>대신 ContextPolicy의 판단 구조를 deterministic Mataios combat brain으로 옮긴다.</p>
<p>예를 들면 이런 식이다.</p>
<p>상황    마타이오스 행동
enemy threat high    protects
player low HP    stabilizes
enemy vulnerable    assists / finishes
repeated attack pattern    pressure assist
defend success    counter / tempo payoff
player Skill timing valid    skill setup assist
본편 구조는 이렇게 잡힌다.</p>
<p>MataiosCombatContext
  → MataiosCombatBrain
  → MataiosActionPlan
여기에는 ONNX inference가 없다. runtime learning도 없다. 외부 API call도 없다. Affinity나 붕괴도 같은 narrative state를 전투 modifier로 쓰지도 않는다.</p>
<p>같은 state가 들어오면 같은 action이 나온다.</p>
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="620" viewBox="0 0 1000 620">
  <rect width="100%" height="100%" fill="#ffffff"/>
  <style>
    text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
    .title { font-size: 22px; font-weight: 700; }
    .label { font-size: 12px; }
    .small { font-size: 10px; fill: #4b5563; }
    .axis { stroke: #d1d5db; stroke-width: 1; }
    .grid { stroke: #e5e7eb; stroke-width: 1; }
  </style>
  <text x="80" y="42" class="title">Exp05-06 Tuning Tradeoff: Waste Fixed, Skill Share Over-Corrected</text>
  <line x1="80" y1="490.0" x2="920" y2="490.0" class="grid"/>
  <text x="68" y="494.0" text-anchor="end" class="small">0.0</text>
  <line x1="80" y1="390.0" x2="920" y2="390.0" class="grid"/>
  <text x="68" y="394.0" text-anchor="end" class="small">0.2</text>
  <line x1="80" y1="290.0" x2="920" y2="290.0" class="grid"/>
  <text x="68" y="294.0" text-anchor="end" class="small">0.4</text>
  <line x1="80" y1="190.0" x2="920" y2="190.0" class="grid"/>
  <text x="68" y="194.0" text-anchor="end" class="small">0.6</text>
  <line x1="80" y1="90.0" x2="920" y2="90.0" class="grid"/>
  <text x="68" y="94.0" text-anchor="end" class="small">0.8</text>
  <rect x="80" y="290.0" width="840" height="75.0" fill="#f97316" opacity="0.10"/>
  <text x="80.0" y="518" text-anchor="middle" class="label">Exp02</text>
  <text x="248.0" y="518" text-anchor="middle" class="label">Exp03</text>
  <text x="416.0" y="518" text-anchor="middle" class="label">Exp04</text>
  <text x="584.0" y="518" text-anchor="middle" class="label">Exp05</text>
  <text x="752.0" y="518" text-anchor="middle" class="label">Exp05b</text>
  <text x="920.0" y="518" text-anchor="middle" class="label">Exp06</text>
  <polyline points="80.0,130.0 248.0,335.4 416.0,342.9 584.0,387.4 752.0,374.8 920.0,383.2" fill="none" stroke="#f97316" stroke-width="3"/>
  <circle cx="80.0" cy="130.0" r="5" fill="#f97316"/>
  <circle cx="248.0" cy="335.4" r="5" fill="#f97316"/>
  <circle cx="416.0" cy="342.9" r="5" fill="#f97316"/>
  <circle cx="584.0" cy="387.4" r="5" fill="#f97316"/>
  <circle cx="752.0" cy="374.8" r="5" fill="#f97316"/>
  <circle cx="920.0" cy="383.2" r="5" fill="#f97316"/>
  <polyline points="80.0,490.0 248.0,490.0 416.0,169.3 584.0,490.0 752.0,490.0 920.0,490.0" fill="none" stroke="#7c3aed" stroke-width="3"/>
  <circle cx="80.0" cy="490.0" r="5" fill="#7c3aed"/>
  <circle cx="248.0" cy="490.0" r="5" fill="#7c3aed"/>
  <circle cx="416.0" cy="169.3" r="5" fill="#7c3aed"/>
  <circle cx="584.0" cy="490.0" r="5" fill="#7c3aed"/>
  <circle cx="752.0" cy="490.0" r="5" fill="#7c3aed"/>
  <circle cx="920.0" cy="490.0" r="5" fill="#7c3aed"/>
  <rect x="80" y="540" width="14" height="14" fill="#f97316" rx="2"/><text x="100" y="552" class="label">Skill share</text>
  <rect x="210" y="540" width="14" height="14" fill="#7c3aed" rx="2"/><text x="230" y="552" class="label">SkillWasted/Episode</text>
  <text x="430" y="552" class="small">Orange band = Skill share target 25-40%</text>
</svg>
이게 이 프로젝트의 원칙과 맞다. ML-Agents는 AI NPC 자동화가 아니라, 전투 설계의 취약점을 찾고 더 나은 deterministic policy를 설계하기 위한 도구였다.

<p>그래서 포트폴리오는 아래와 같이 소개하고자 한다.</p>
<p>Unity ML-Agents를 활용해 로그라이크 전투를 별도 TrainingCombat 환경으로 분리하고,
PPO agent와 Random/AttackSpam/SkillSpam/DefendHeavy/ContextPolicy baseline을 비교했다.
실험을 통해 Skill-heavy shortcut과 Defend payoff 부재를 발견했고,
reward/metric repair 및 combat rule redesign을 거쳐 spam baseline을 넘는 PPO probe를 만들었다.
최종적으로는 PPO를 본편 runtime에 직접 연결하지 않고,
ContextPolicy의 설계 근거를 deterministic Mataios combat brain으로 환원하는 방향을 선택했다.</p>
<p>여기서 중요한 건 &quot;강화학습을 했다&quot;가 아니다.</p>
<p>강화학습을 실제 게임 제작 문제에 연결했다는 점이다. 수동 QA로 찾기 어려운 전투 설계 shortcut을 baseline과 PPO로 확인했고, 그 결과를 본편 설계로 되돌렸다.</p>
<p>배운 것
첫째, baseline 없이 PPO만 보면 착각하기 쉽다. PPO가 좋아 보여도 SkillSpam과 비슷하면 좋은 정책이라고 말할 수 없다.</p>
<p>둘째, reward를 고치기 전에 metric을 고쳐야 할 때가 있다. Defend가 의미 있는지 보려면 방어가 실제로 막은 피해가 metric으로 드러나야 한다.</p>
<p>셋째, 더 긴 학습이 항상 답은 아니다. Exp05~06은 오히려 context와 rule design이 먼저라는 사실을 보여줬다.</p>
<p>넷째, 실험 결과는 본편 결정이 아니라 설계 근거다. 본편은 deterministic policy로 유지하고, ML-Agents는 실험 도구로 분리하는 것이 맞았다.</p>
<p>마지막으로, 좋은 AI 포트폴리오는 모델 성능만 보여주는 게 아니라 문제 발견과 설계 개선 루프를 보여줘야 한다. 이 Combat Design Lab은 그 방향의 첫 증거다.</p>
<hr>
<p>이 글은 개발 기록 기반 초안이다. PPO/ML-Agents는 본편 runtime AI로 연결하지 않았고, 실험 결과는 게임 규칙을 자동 확정하지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[강화학습 첫 실험기 - 똑바로 서라 ]]></title>
            <link>https://velog.io/@ljhljh0703-cmd/%EA%B0%95%ED%99%94%ED%95%99%EC%8A%B5-%EC%B2%AB-%EC%8B%A4%ED%97%98%EA%B8%B0-%EB%98%91%EB%B0%94%EB%A1%9C-%EC%84%9C%EB%9D%BC</link>
            <guid>https://velog.io/@ljhljh0703-cmd/%EA%B0%95%ED%99%94%ED%95%99%EC%8A%B5-%EC%B2%AB-%EC%8B%A4%ED%97%98%EA%B8%B0-%EB%98%91%EB%B0%94%EB%A1%9C-%EC%84%9C%EB%9D%BC</guid>
            <pubDate>Sat, 23 May 2026 15:14:42 GMT</pubDate>
            <description><![CDATA[<h1 id="하체-부실-3d-휴머노이드의-하체-코어-길러주기">하체 부실 3D 휴머노이드의 하체 코어 길러주기</h1>
<blockquote>
<p>Gymnasium + Stable Baselines3 + MuJoCo로 CartPole 완벽 클리어, Humanoid 160% 성능 개선까지.</p>
<p><code>#강화학습</code> <code>#PPO</code> <code>#MuJoCo</code> <code>#Gymnasium</code> <code>#StableBaselines3</code> <code>#GameAI</code></p>
</blockquote>
<hr>
<h2 id="왜-이-실험을-했는가">왜 이 실험을 했는가</h2>
<p>게임 AI NPC를 만들고 싶었음. 플레이어 전략에 적응하는 적, 밸런스 테스트를 자동으로 돌리는 시스템, 시뮬레이션 기반 실험 프레임워크 — 이런 걸 구현하려면 강화학습(RL)이 기본기임.</p>
<p>그래서 가장 기초적인 환경부터 직접 돌려보기로 함. &quot;랜덤 에이전트 vs 학습된 에이전트&quot;의 차이를 눈으로 확인하는 게 이번 목표. 이걸 위해 예제도 찾아봄. 좋은 예제가 마련된 환경에 늘 감사하는 나날.</p>
<p>#참고 예제
<a href="https://wikidocs.net/325543">https://wikidocs.net/325543</a></p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/e94e863e-0f1a-4ea8-9a29-4a7830a83afd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/d5ebbd82-a836-4058-b77d-ba1503f0db5e/image.png" alt=""></p>
<p>결론부터 말하면: CartPole은 PPO 5만 스텝으로 500/500 완벽 클리어. Humanoid는 30만 스텝으로 랜덤 대비 160% 성능 개선. 20스텝 만에 쓰러지던 녀석이 50~59스텝까지 버티게 됨.</p>
<hr>
<h2 id="실험-환경">실험 환경</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>스펙</th>
</tr>
</thead>
<tbody><tr>
<td>하드웨어</td>
<td>MacBook Air (Apple Silicon)</td>
</tr>
<tr>
<td>Python</td>
<td>3.14 (venv 가상환경)</td>
</tr>
<tr>
<td>핵심 라이브러리</td>
<td>Gymnasium, Stable Baselines3, MuJoCo</td>
</tr>
<tr>
<td>RL 알고리즘</td>
<td>PPO (Proximal Policy Optimization)</td>
</tr>
<tr>
<td>학습 디바이스</td>
<td>CPU (Mac GPU 미사용)</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/4310a1aa-61ad-46c9-8eba-556753a93981/image.png" alt=""></p>
<p>Mac 환경에서의 세팅은 Windows 가이드를 참고하되 꽤 수정이 필요했음. Python 3.14에서 <code>stable-baselines3</code>가 구버전 <code>gym</code>을 끌어오려다 빌드가 실패하는 문제가 있었고, <code>gymnasium</code>을 먼저 설치한 뒤 <code>&quot;stable-baselines3&gt;=2.0.0&quot;</code>으로 버전을 명시해서 해결함.</p>
<p>사실 중간에 막혀서 VS Code 내 에이전트(Copilot 활용) 통해 가상환경 설치를 진행함.
이번 시간 덕분에 가상환경 개념을 얼추 익혀, 앞으로도 유용하게 사용해 볼 예정. 비개발자 출신의 서러움을 언젠가는 잊고 말것.</p>
<hr>
<h2 id="실험-1-cartpole--막대기-세우기">실험 1: CartPole — 막대기 세우기</h2>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/c315e7f7-0e1f-4a78-a355-da05f79814c4/image.png" alt=""></p>
<h3 id="문제-정의">문제 정의</h3>
<p>카트 위에 세워진 막대기가 쓰러지지 않도록 카트를 좌우로 미는 문제.</p>
<ul>
<li>관찰 공간: 4차원 (카트 위치, 카트 속도, 막대 각도, 막대 각속도)</li>
<li>행동 공간: 2개 (왼쪽 밀기, 오른쪽 밀기)</li>
<li>보상: 매 스텝 +1 (오래 버틸수록 높은 보상)</li>
<li>최대: 500스텝</li>
</ul>
<h3 id="랜덤-에이전트-학습-없음">랜덤 에이전트 (학습 없음)</h3>
<pre><code class="language-python"># 동전 던지기로 좌/우 결정
action = env.action_space.sample()</code></pre>
<p>아무런 전략 없이 랜덤으로 행동을 선택함. 결과는 예상대로.</p>
<pre><code>에피소드 1: 22스텝 생존, 총 보상: 22.0
에피소드 2: 18스텝 생존, 총 보상: 18.0
에피소드 3: 41스텝 생존, 총 보상: 41.0</code></pre><p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/82065427-9c32-4352-a6d1-a94eaf5c3a13/image.png" alt=""></p>
<p>평균 20~50스텝. 막대기가 한쪽으로 기울면 버틸 방법이 없음.</p>
<h3 id="ppo-학습-에이전트">PPO 학습 에이전트</h3>
<pre><code class="language-python">model = PPO(&quot;MlpPolicy&quot;, train_env, learning_rate=3e-4, n_steps=2048)
model.learn(total_timesteps=50_000)</code></pre>
<p>5만 스텝 학습 후:</p>
<pre><code>500스텝 생존! 총 보상: 500.0
🎉 완벽! 막대기를 끝까지 세웠습니다!</code></pre><p><strong>500/500 만점.</strong> 5만 번의 시행착오만으로 &quot;오른쪽으로 기울면 오른쪽으로 밀어라&quot;를 스스로 깨달음.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/1c0cab86-3453-401a-ac37-f989acfdb7c3/image.png" alt=""></p>
<h3 id="핵심-포인트">핵심 포인트</h3>
<p>여기서 중요한 건 <strong>보상 함수를 직접 설계하지 않았다</strong>는 점임. &quot;매 스텝 +1&quot;이라는 단순한 보상만 줬을 뿐인데, PPO가 알아서 최적 전략을 찾음. 이게 강화학습의 핵심 — 보상만 잘 정의하면 행동은 에이전트가 스스로 발견함.</p>
<p>이전에 GPT를 쓸때, 지나가는 말로 &quot;고블린&quot;을 절대 채팅에 입력하지 말라 들은 적 있는데 비슷한 유형인가봄.
<a href="https://www.aitimes.com/news/articleView.html?idxno=209975">https://www.aitimes.com/news/articleView.html?idxno=209975</a></p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/517a93c7-2f87-4031-9dd0-fa21c6f728d7/image.png" alt=""></p>
<p>이걸 나중에 게임 쪽에도 응용할 방법을 찾아봐야 겠음. </p>
<hr>
<h2 id="실험-2-humanoid--3d-사람-모델-제어">실험 2: Humanoid — 3D 사람 모델 제어</h2>
<h3 id="문제-정의-1">문제 정의</h3>
<p>17개 관절을 가진 3D 인간형 모델을 넘어지지 않게 제어하는 문제. CartPole과는 차원이 다르다는데, 비개발자가 볼땐 뭐가 뭔지 아직 어렵다는 점에서 비슷했음.</p>
<table>
<thead>
<tr>
<th>비교</th>
<th>CartPole</th>
<th>Humanoid</th>
</tr>
</thead>
<tbody><tr>
<td>관찰 공간</td>
<td>4차원</td>
<td>376차원</td>
</tr>
<tr>
<td>행동 공간</td>
<td>2개 (좌/우)</td>
<td>17개 (관절 토크)</td>
</tr>
<tr>
<td>난이도</td>
<td>입문</td>
<td>상급</td>
</tr>
<tr>
<td>필요 학습량</td>
<td>5만 스텝</td>
<td>수백만 스텝</td>
</tr>
</tbody></table>
<h3 id="랜덤-에이전트">랜덤 에이전트</h3>
<pre><code>넘어짐! 22스텝, 보상: 112.7
넘어짐! 20스텝, 보상: 100.9
넘어짐! 28스텝, 보상: 144.6</code></pre><p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/5f98e2b2-a6d0-452e-a190-54c775bb993b/image.png" alt=""></p>
<p>17개 관절을 랜덤으로 움직이니 당연히 즉시 넘어짐. 평균 보상 약 115.</p>
<h3 id="ppo-학습-30만-스텝">PPO 학습 (30만 스텝)</h3>
<p>CartPole과 달리 여러 세팅을 추가함.</p>
<pre><code class="language-python">model = PPO(
    &quot;MlpPolicy&quot;, train_env,
    learning_rate=3e-4,
    n_steps=2048,
    batch_size=64,
    n_epochs=10,
    gamma=0.99,           # 미래 보상 할인율
    gae_lambda=0.95,      # GAE 파라미터
    clip_range=0.2,        # PPO 클리핑 — 정책 변화 ±20% 제한
    policy_kwargs=dict(
        net_arch=dict(pi=[256, 256], vf=[256, 256])
    ),
)</code></pre>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/5f2c8717-a36c-4839-8d6a-a99bb17a039c/image.png" alt=""></p>
<p>주요 차이점:</p>
<ul>
<li><strong>병렬 환경 4개</strong> (<code>DummyVecEnv</code>): 데이터 수집 속도 4배. CartPole은 1개로 충분했지만 Humanoid는 필수.</li>
<li><strong>신경망 확장</strong> (<code>[256, 256]</code>): 376차원 입력을 처리하기 위해 네트워크를 키움.</li>
<li><strong>체크포인트 저장</strong>: 10~20분 걸리는 학습이 중간에 끊겨도 이어갈 수 있도록.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/168aa827-ec4d-499e-8ec8-59d9176f1e9e/image.png" alt=""></p>
<h3 id="결과">결과</h3>
<pre><code>📊 학습 후 성적:
   평균 보상: 298.8 ± 14.7

📊 랜덤 에이전트 성적 (비교용):
   평균 보상: 114.9

🔺 개선: 183.9 (160%↑)</code></pre><p>시각화에서 확인한 에피소드별 성적:</p>
<pre><code>에피소드: 55스텝, 보상: 302.1
에피소드: 57스텝, 보상: 312.9
에피소드: 59스텝, 보상: 323.3
에피소드: 56스텝, 보상: 304.2</code></pre><p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/1c15c3d2-1433-4ea6-b297-83f3a161e677/image.png" alt=""></p>
<p>20스텝에서 풀썩 쓰러지던 모델이 50~59스텝까지 버팀. 걷지는 못하지만, &quot;균형을 잡고 서 있는 법&quot;은 학습한 것.</p>
<hr>
<h2 id="ppo는-어떻게-학습하는가">PPO는 어떻게 학습하는가</h2>
<p>코드만 돌리면 블랙박스가 되니까, 내부 원리를 정리함.</p>
<p>PPO의 학습 루프는 세 단계의 반복임:</p>
<p><strong>1단계 — 데이터 수집.</strong> 현재 정책(신경망)으로 환경에서 행동하고, (상태, 행동, 보상)을 기록함. <code>n_steps=2048</code> × 환경 4개 = 8192개 데이터.</p>
<p><strong>2단계 — 어드밴티지 계산.</strong> &quot;이 상황에서 이 행동은 기대보다 좋았는가?&quot;를 계산함. <code>gamma=0.99</code>는 미래 보상을 거의 다 반영한다는 뜻이고, <code>gae_lambda=0.95</code>는 분산과 편향의 균형을 잡는 파라미터.</p>
<p><strong>3단계 — 정책 업데이트.</strong> 좋았던 행동의 확률은 올리고, 나빴던 행동의 확률은 내림. 이때 <code>clip_range=0.2</code>가 핵심 — 한 번에 정책을 너무 크게 바꾸면 학습이 발산하니까, 변화량을 ±20%로 제한함. 이름이 &quot;Proximal(근접한) Policy Optimization&quot;인 이유.</p>
<p>이 세 단계를 30만 스텝 동안 반복하면, 신경망이 376차원 관찰에서 17개 관절의 최적 토크 조합을 찾아감.</p>
<hr>
<h2 id="삽질-기록">삽질 기록</h2>
<p>순탄하지만은 않았음. Mac + Python 3.14 조합에서 발생한 문제들:</p>
<p><strong>1. <code>stable-baselines3</code> 설치 실패</strong>
pip이 구버전 SB3(1.x)까지 탐색하면서 <code>gym==0.21</code> 빌드를 시도 → Python 3.14에서 <code>setuptools</code> 호환성 에러. <code>&quot;stable-baselines3&gt;=2.0.0&quot;</code>으로 버전을 명시해서 해결.</p>
<p><strong>2. <code>pygame</code> 미설치</strong>
<code>gymnasium</code>을 설치해도 렌더링 의존성인 <code>pygame</code>은 자동으로 안 들어옴. <code>pip install pygame</code> 별도 실행.</p>
<p><strong>3. <code>mujoco</code> 미설치</strong>
<code>gymnasium[mujoco]</code>로 설치해야 MuJoCo 바인딩이 포함됨. 기본 <code>gymnasium</code>만 설치하면 Humanoid 환경 로드 시 <code>ModuleNotFoundError</code>.</p>
<p><strong>4. <code>tensorboard</code>, <code>tqdm</code>, <code>rich</code> 누락</strong>
SB3의 <code>progress_bar=True</code>와 <code>tensorboard_log</code>는 별도 패키지가 필요함. 에러 메시지가 친절해서 바로 해결 가능.</p>
<p>이런 의존성 문제는 <code>venv</code> 가상환경 덕분에 시스템 파이썬을 더럽히지 않고 처리할 수 있었음. 앞으로 따로 메모를 해서, 같은 상황에서의 시행착오를 줄일 예정.</p>
<hr>
<h2 id="배운-것">배운 것</h2>
<ol>
<li><p><strong>보상 설계가 전부다.</strong> CartPole의 &quot;매 스텝 +1&quot;처럼 단순한 보상만으로도 에이전트가 최적 행동을 찾음. 반대로 보상이 잘못 설계되면 원하지 않는 행동을 학습할 수 있음. 게임 AI NPC의 행동 품질은 보상 함수 설계에 달림.</p>
</li>
<li><p><strong>복잡도에 따라 학습량이 기하급수적으로 늘어남.</strong> CartPole(4차원) → 5만 스텝. Humanoid(376차원) → 30만으로도 부족. 게임 NPC는 관찰/행동 공간 설계가 성능을 좌우함.</p>
</li>
<li><p><strong>PPO의 클리핑이 안정성의 핵심.</strong> 정책 변화를 ±20%로 제한하는 단순한 트릭이 학습 안정성을 크게 높임. 게임처럼 환경이 복잡할수록 이런 안정화 기법이 중요해짐.</p>
</li>
<li><p><strong>병렬 환경은 선택이 아니라 필수.</strong> Humanoid급 문제에서 환경 1개로는 데이터 수집이 너무 느림. 4개 병렬로 돌리는 것만으로 학습 시간이 체감상 크게 줄어듦.</p>
</li>
<li><p><strong>Mac에서도 RL 학습은 가능하지만 한계가 있음.</strong> CPU만으로 30만 스텝은 10~20분. 하지만 완전한 보행(500만+ 스텝)을 학습시키려면 GPU 서버나 Colab Pro가 현실적.</p>
</li>
</ol>
<hr>
<h2 id="다음-단계">다음 단계</h2>
<p>이번에 확인한 건 &quot;RL이 동작한다&quot;는 것. 다음은 이걸 게임에 적용하는 단계임.</p>
<ul>
<li><strong>Unity ML-Agents</strong>로 넘어가서 게임 엔진 안에서 NPC를 직접 학습시킬 예정</li>
<li>Curriculum Learning(단계별 난이도), Self-Play(에이전트 대전) 등 고급 기법 실험</li>
<li>궁극적으로는 게임 밸런스 테스트 자동화와 시뮬레이션 기반 실험 프레임워크 구축이 목표</li>
</ul>
<hr>
<h2 id="기술-스택-요약">기술 스택 요약</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>사용 기술</th>
</tr>
</thead>
<tbody><tr>
<td>언어</td>
<td>Python 3.14</td>
</tr>
<tr>
<td>환경 관리</td>
<td>venv 가상환경</td>
</tr>
<tr>
<td>RL 인터페이스</td>
<td>Gymnasium</td>
</tr>
<tr>
<td>RL 알고리즘</td>
<td>Stable Baselines3 (PPO)</td>
</tr>
<tr>
<td>물리 엔진</td>
<td>MuJoCo</td>
</tr>
<tr>
<td>시각화</td>
<td>Pygame (2D), MuJoCo Viewer (3D)</td>
</tr>
<tr>
<td>모니터링</td>
<td>TensorBoard</td>
</tr>
</tbody></table>
<hr>
<p><em>본 실험은 <a href="https://wikidocs.net/325543">위키독스 - 로보틱스를 위한 강화학습 이미테이션러닝 입문</a>을 참고하여 Mac 환경에 맞게 재구성함.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[게임을 만들면서 AI QA 도구도 같이 만드는 이유]]></title>
            <link>https://velog.io/@ljhljh0703-cmd/%EA%B2%8C%EC%9E%84%EC%9D%84-%EB%A7%8C%EB%93%A4%EB%A9%B4%EC%84%9C-AI-QA-%EB%8F%84%EA%B5%AC%EB%8F%84-%EA%B0%99%EC%9D%B4-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@ljhljh0703-cmd/%EA%B2%8C%EC%9E%84%EC%9D%84-%EB%A7%8C%EB%93%A4%EB%A9%B4%EC%84%9C-AI-QA-%EB%8F%84%EA%B5%AC%EB%8F%84-%EA%B0%99%EC%9D%B4-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 22 May 2026 14:28:56 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Unity 로그라이크 본편 개발과 별도로, 결정적 시뮬레이션·밸런스 QA 자동화·RL 실험 환경을 분리한 기록.<br>핵심은 &quot;AI NPC 게임&quot;이 아니라 &quot;게임 완성도를 높이는 AI QA 트랙&quot;이다.</p>
</blockquote>
<hr>
<h2 id="왜-이-글을-쓰는가">왜 이 글을 쓰는가</h2>
<p>처음 이 프로젝트를 소개할 때는 보통 &quot;AI NPC가 있는 모바일 로그라이크&quot;라고 말하게 된다. 틀린 말은 아니다. 마타이오스라는 NPC가 있고, 플레이어의 선택과 기억에 반응하는 구조를 목표로 하고 있으니까.</p>
<p>그런데 오늘 방향을 다시 잡았다. 이 프로젝트를 포트폴리오로 설명할 때 더 강한 축은 AI NPC 자체가 아니라, <strong>게임을 개발하면서 그 게임을 더 잘 검증하기 위한 AI QA 도구를 병행해서 만든다</strong>는 점이다.</p>
<p>문제의 출발점은 단순했다.</p>
<p>로그라이크는 한두 번 손으로 플레이해서는 밸런스를 판단하기 어렵다. 특정 능력이 과하게 강한지, 상점 선택지가 사실상 죽은 선택지인지, 보스가 너무 빨리 죽는지, 어떤 빌드가 특정 seed에서만 강한지 사람이 직접 반복해서 확인하기에는 시간이 너무 많이 든다.</p>
<p>그래서 프로젝트를 두 트랙으로 나누기로 했다.</p>
<ul>
<li><strong>Game Track</strong>: <code>회귀자는 탑을 오른다</code> 본편을 만든다.</li>
<li><strong>AI QA Track</strong>: 본편의 완성도를 높이기 위한 결정적 시뮬레이션, 자동 밸런스 리포트, RL 실험 환경을 만든다.</li>
</ul>
<p>이 글은 그 결정을 어떻게 내렸고, 왜 이렇게 분리하는 게 맞는지 정리한 기록이다.</p>
<h2 id="처음-질문-이-프로젝트에-rl-시뮬레이터를-반영할-수-있나">처음 질문: 이 프로젝트에 RL 시뮬레이터를 반영할 수 있나</h2>
<p>처음 검토한 요구사항은 꽤 넓었다.</p>
<ul>
<li>강화학습 기반 시뮬레이터 환경 구성 및 실험 설계</li>
<li>게임 밸런스 테스트 자동화를 위한 AI 시스템 설계 및 구현</li>
<li>다양한 장르의 게임을 대상으로 한 시뮬레이션 기반 실험 프레임워크 개발</li>
</ul>
<p>이걸 그대로 본편에 넣으면 위험하다. 지금 프로젝트는 모바일 로그라이크 데모를 완성해야 하고, 본편 런타임은 최대한 가볍고 결정적으로 유지해야 한다. Unity ML-Agents 같은 외부 의존성을 먼저 들이거나, 본편 전투 로직 안에 RL 코드를 넣는 건 범위가 너무 커진다.</p>
<p>하지만 &quot;로그라이크 한정&quot;으로 범위를 자르자 판단이 바뀌었다.</p>
<p>이 게임에는 이미 AI QA 트랙과 잘 맞는 구조가 있었다.</p>
<ul>
<li>시드 기반 결정적 run 구조</li>
<li>전투/상점/노드/보상 흐름</li>
<li>ScriptableObject 기반 능력·아이템·시너지 데이터</li>
<li>자동 전투/해결 경로</li>
<li>EditMode/PlayMode 테스트 기반</li>
</ul>
<p>즉, 여러 장르를 커버하는 범용 프레임워크가 아니라, <strong>이 로그라이크의 run을 대량 재생하는 실험 도구</strong>라면 충분히 반영할 수 있었다.</p>
<p>그래서 이름도 &quot;범용 프레임워크&quot;가 아니라 <code>RoguelikeSim</code> 또는 <code>Balance Lab</code> 쪽이 맞다고 봤다. 이건 게임 엔진이 아니라, 이 게임의 밸런스를 검증하기 위한 실험실이다.</p>
<h2 id="프로젝트와-디벨롭-방향성을-매핑해보니">프로젝트와 디벨롭 방향성을 매핑해보니</h2>
<p>개인적으로 게임에 직접 적용해 보고 싶었던 AI_MODEL 핵심 키워드가 있었다.</p>
<ul>
<li>강화학습 기반 시뮬레이터</li>
<li>밸런스 테스트 자동화</li>
<li>시뮬레이션 기반 실험 프레임워크</li>
<li>Unity/Unreal과 Python 연동</li>
<li>DQN, PPO, MuZero 같은 RL 응용</li>
<li>Monte Carlo Simulation</li>
</ul>
<p>그래서 이번 프로젝트를 Codex로 개발하고 있는 만큼, 좀 더 AI를 적극 사용해 보기로 했다. 그 중 하나가 시뮬레이션 기반 실험 프레임 워크를 로그라이크로 적용하고, 밸런스 테스트 자동화를 AI QA Track을 통해 검증하는 것이다.</p>
<p>Game Track이 기능을 만들면, AI QA Track은 그 기능을 대량으로 재생하고 지표로 읽는다. AI QA Track이 문제를 찾으면, Game Track은 그 결과를 근거로 수치나 pool을 수정한다. 수정 후에는 같은 seed set으로 다시 검증한다.</p>
<p>이 루프가 포트폴리오의 핵심 증거가 된다.</p>
<h2 id="track-a-game-track">Track A: Game Track</h2>
<p>Game Track은 본편으로, 목표는 플레이 가능한 로그라이크 데모 버전의 완성도다.</p>
<p>담당 범위는 아래와 같다.</p>
<ul>
<li>전투</li>
<li>상점</li>
<li>능력</li>
<li>시너지</li>
<li>노드 맵</li>
<li>UI polish</li>
<li>Android/demo readiness</li>
<li>GDD 결정 반영</li>
<li>플레이어 경험</li>
</ul>
<p>현재 Game Track의 주요 작업은 First Build Surface다.</p>
<p>핵심은 밸런스를 바로 맞추는 게 아니라, <strong>의미 있는 빌드 선택지가 실제 run 안에 노출되고 적용되는 상태</strong>를 만드는 것이다. 기존에는 런타임 reachable surface가 좁았다. 특정 능력이나 아이템은 데이터에는 있어도 실제 플레이에서 의미 있게 선택되지 않거나, resolver가 없는 dead pick이 섞일 위험이 있었다.</p>
<p>그래서 First Build Surface의 목표는 다음처럼 정리됐다.</p>
<ul>
<li>dead pick을 reachable pool에서 제거한다.</li>
<li>검, 술, 방어, 시너지 축을 최소한으로 노출한다.</li>
<li><code>SWORD_03</code>, <code>ARTS_03</code>, <code>GUARD_01</code>, <code>FRENZY</code> 같은 테스트 가능한 축을 만든다.</li>
<li><code>ITEM_07</code>처럼 아직 계약이 닫히지 않은 효과는 OQ가 해결되기 전까지 보류한다.</li>
<li>정확한 수치 튜닝은 나중으로 미룬다.</li>
</ul>
<p>이게 중요하다. 밸런스 자동화는 아무 표면이나 돌린다고 의미가 생기지 않는다. 먼저 비교 가능한 선택지가 실제로 적용돼야 한다.</p>
<h2 id="track-b-ai-qa-track">Track B: AI QA Track</h2>
<p>AI QA Track은 본편이 아니다. 본편을 더 잘 만들기 위한 도구다.</p>
<p>초기 범위는 이렇게 잡았다.</p>
<ul>
<li>결정적 seed replay</li>
<li>scripted policy 기반 자동 플레이</li>
<li>Monte Carlo style batch run</li>
<li>balance metric CSV export</li>
<li>Python 분석/요약</li>
<li>나중에 붙일 RL baseline을 위한 State / Action / Reward / Done 경계</li>
</ul>
<p>처음부터 DQN이나 PPO를 붙이지 않기로 했다. RL은 baseline이 있어야 의미가 있다. Random, Greedy, Survival 같은 간단한 policy가 먼저 있어야, 나중에 RL policy가 정말 나아졌는지 비교할 수 있다.</p>
<p>그래서 첫 구현은 작게 시작했다.</p>
<pre><code class="language-text">Tools/RoguelikeSim/
Tools/RoguelikeSimPython/
Docs/Portfolio/</code></pre>
<p><code>RoguelikeSim</code>은 Unity runtime 바깥의 C# console skeleton이다. Unity package도 추가하지 않고, 본편 <code>Assets/_Project</code> 런타임에 직접 의존하지 않는다. 현재는 First Build Surface를 손으로 mirror한 fixture를 사용한다. 나중에는 Unity Editor exporter가 ScriptableObject 데이터를 neutral JSON으로 내보내는 구조로 바꿀 수 있다.</p>
<p>첫 CSV schema는 이렇다.</p>
<pre><code class="language-text">seed,policy,win_loss,floor_reached,turns_to_kill,remaining_hp,gold,item_picks,ability_picks,synergy_completion,boss_result</code></pre>
<p>이 schema가 중요한 이유는 간단하다. &quot;느낌상 어렵다&quot;를 &quot;어떤 policy가 몇 층까지 갔고, 몇 턴을 썼고, 어떤 선택지를 골랐는가&quot;로 바꾸기 때문이다.</p>
<h2 id="첫-skeleton-seed-replay부터">첫 skeleton: seed replay부터</h2>
<p>오늘 최신 PROGRESS 기준으로 AI QA Seed Replay skeleton은 이미 추가됐다.</p>
<p>생성된 축은 다음과 같다.</p>
<ul>
<li><code>RandomPolicy</code></li>
<li><code>GreedyPolicy</code></li>
<li><code>SurvivalPolicy</code></li>
<li>deterministic seed replay CSV</li>
<li>Python summary</li>
<li>portfolio report 초안</li>
</ul>
<p>초기 seed range는 <code>1000..1029</code>, 총 30개 seed다. 세 policy를 돌리면 90 rows의 replay CSV가 나온다.</p>
<p>분석 결과는 아직 밸런스 결론이 아니다. 오히려 현재 skeleton의 한계를 드러내는 초기 증거에 가깝다.</p>
<pre><code class="language-text">| policy | runs | win rate | avg floor | avg turns | avg HP | avg Gold | boss win | synergy |
| GreedyPolicy | 30 | 0.00% | 3.00 | 7.43 | 0.00 | 8.00 | 0.00% | 0.00% |
| RandomPolicy | 30 | 0.00% | 2.77 | 9.00 | 0.00 | 8.00 | 0.00% | 0.00% |
| SurvivalPolicy | 30 | 0.00% | 2.67 | 11.17 | 0.00 | 8.00 | 0.00% | 0.00% |</code></pre>
<p>이 표만 보면 &quot;다 졌다&quot;가 먼저 보인다. 하지만 이 단계의 목적은 &quot;밸런스가 좋다/나쁘다&quot;를 결론내리는 게 아니다.</p>
<p>첫 질문은 이것이다.</p>
<pre><code class="language-text">같은 seed와 같은 policy를 넣었을 때,
항상 같은 결과가 나오고,
policy별 차이가 지표로 드러나는가?</code></pre>
<p>그 답은 어느 정도 보이기 시작했다. GreedyPolicy는 평균 도달 층이 조금 높고, SurvivalPolicy는 평균 전투 턴이 길다. 아직 모두 boss win은 0%지만, policy의 성향 차이는 metric으로 남는다.</p>
<p>이 정도면 다음 단계로 갈 수 있다. 이제 hand-authored fixture를 Unity ScriptableObject export로 바꾸고, seed 수를 늘리고, First Build Surface 변경 전후를 같은 seed set으로 비교하면 된다.</p>
<h2 id="중요한-가드레일">중요한 가드레일</h2>
<p>이 트랙에서 가장 중요한 건 선을 넘지 않는 것이다.</p>
<p>AI QA Track의 결과는 디자인 결정을 대신하지 않는다. 시뮬레이션 결과는 제안 근거다. 게임 규칙이나 밸런스 변경을 확정하려면 GDD/PM 결정이 필요하다.</p>
<p>그래서 다음을 명시적으로 금지했다.</p>
<ul>
<li>Unity 본편 런타임에 RL 코드 넣기</li>
<li>Unity package 선도입</li>
<li>DQN/PPO부터 시작하기</li>
<li>simulator output을 곧바로 GDD 결정으로 잠그기</li>
<li>붕괴도, Affinity, NPC state를 전투 modifier로 다시 넣기</li>
<li>OQ가 닫히지 않은 <code>ITEM_07</code> 패턴 효과를 임의 구현하기</li>
</ul>
<p>이 가드레일은 포트폴리오에서도 중요하다. AI를 붙였다는 사실보다 중요한 건, AI 도구가 본편 제작 파이프라인을 망치지 않도록 분리했다는 점이다.</p>
<h2 id="완성도-개선-루프">완성도 개선 루프</h2>
<p>이제 프로젝트의 개선 루프는 이렇게 잡을 수 있다.</p>
<ol>
<li><p>Game Track에서 기능을 만든다.<br>예: 검 x3 시너지, 상점 pool, 보스 수치, 아이템 효과.</p>
</li>
<li><p>AI QA Track이 같은 기능을 실험 가능한 fixture로 읽는다.<br>예: 1000 seeds, 3~5 policies, 승률/TTK/잔여 HP/Gold/빌드 완성률.</p>
</li>
<li><p>AI QA Track이 이상 징후를 찾는다.<br>예: 특정 능력 선택 시 승률 과상승, 특정 상점 선택지 dead pick, boss 평균 턴 수 목표 이탈.</p>
</li>
<li><p>Game Track으로 수정 제안을 넘긴다.<br>예: HP cost 조정, reward pool 정리, enemy HP/ATK 재조정.</p>
</li>
<li><p>같은 seed set으로 다시 돌린다.<br>before/after가 포트폴리오 증거가 된다.</p>
</li>
</ol>
<p>이 구조가 좋다. &quot;게임을 만들었다&quot;와 &quot;AI 실험을 해봤다&quot;가 따로 놀지 않는다. 게임을 만들다가 실제 QA 문제가 생겼고, 그 문제를 줄이기 위해 AI QA 도구를 설계했다는 흐름이 된다.</p>
<h2 id="ai_model-직무에는-어떻게-어필할-수-있나">AI_MODEL 직무에는 어떻게 어필할 수 있나</h2>
<p>이 프로젝트를 이렇게 설명할 수 있다.</p>
<pre><code class="language-text">Unity 로그라이크 프로젝트에서 게임 로직을 결정적으로 재현 가능한 시뮬레이션 환경으로 분리하고,
seed 기반 batch run을 통해 밸런스 QA 지표를 자동 수집했습니다.</code></pre>
<p>또는 이렇게 말할 수 있다.</p>
<pre><code class="language-text">ScriptableObject 기반 능력/아이템/시너지 데이터를 실험 파라미터로 취급해
Monte Carlo simulation과 policy baseline을 비교하는 밸런스 테스트 파이프라인을 설계했습니다.</code></pre>
<p>더 중요한 문장은 이쪽이다.</p>
<pre><code class="language-text">강화학습은 단독 데모가 아니라,
실제 게임 제작 과정의 반복 QA 문제를 해결하기 위한 도구로 적용했습니다.</code></pre>
<p>이 방향이면 AI_MODEL 직무의 요구사항과 정면으로 맞는다.</p>
<ul>
<li>RL simulator environment: run을 episode로 정의할 수 있다.</li>
<li>Balance automation: seed batch replay와 metric export가 있다.</li>
<li>Simulation framework: 로그라이크 한정 실험 프레임워크로 범위를 잘랐다.</li>
<li>Unity + Python: C# replay CSV와 Python summary가 연결된다.</li>
<li>RL readiness: DQN/PPO는 후순위지만 policy baseline과 metric schema가 준비된다.</li>
</ul>
<h2 id="아직-부족한-것">아직 부족한 것</h2>
<p>현재 상태는 첫 skeleton이다. 아직 포트폴리오 완성형이라고 말하면 안 된다.</p>
<p>부족한 점은 명확하다.</p>
<ul>
<li>Unity ScriptableObject exporter가 아직 없다.</li>
<li>fixture는 hand-authored mirror다.</li>
<li>seed 수는 30개로 작다.</li>
<li>모든 policy가 boss win 0%라 balance insight는 제한적이다.</li>
<li>PyTorch DQN/PPO baseline은 아직 없다.</li>
<li>simulator output을 본편 수정으로 다시 적용한 before/after 루프도 아직 없다.</li>
</ul>
<p>하지만 방향은 맞다. 오늘 만든 건 RL 프로젝트가 아니라, RL로 가기 전 필요한 실험 기반이다.</p>
<h2 id="다음-단계">다음 단계</h2>
<p>다음 작업은 세 단계가 좋다.</p>
<p>첫째, Unity Editor exporter를 만든다. First Build Surface의 ScriptableObject 데이터를 neutral JSON으로 내보내고, <code>RoguelikeSim</code>은 그 JSON을 읽는다. 그래야 hand-authored fixture와 본편 데이터가 어긋나는 문제를 줄일 수 있다.</p>
<p>둘째, seed range를 늘린다. 30 seeds는 smoke에 가깝다. 최소 100 seeds, 가능하면 1000 seeds까지 늘려야 policy별 차이를 더 안정적으로 볼 수 있다.</p>
<p>셋째, 첫 before/after 실험을 만든다. 예를 들어 SWORD_03 HP cost, boss HP, shop pool 중 하나를 바꾸고, 같은 seed set에서 변경 전후를 비교한다. 이게 포트폴리오에서 가장 강한 증거가 된다.</p>
<p>RL은 그 다음이다. DQN이나 PPO는 마지막에 붙여도 된다. 먼저 Random, Greedy, Survival baseline이 설득력 있어야 RL 결과도 의미가 있다.</p>
<h2 id="배운-것">배운 것</h2>
<p>첫째, 이번 프로젝트는 게임 개발의 목적도 있지만, 당장에는 AI 포트폴리오로 활용하기 위함이다. 그렇기에 &quot;AI를 붙였다&quot;보다 &quot;어떤 반복 업무를 줄였는가&quot;가 더 중요하다.</p>
<p>둘째, 게임 본편과 실험 도구는 분리해야 한다. 본편 런타임에 실험 코드를 넣으면 데모 안정성이 깨지고, 실험도 자유롭게 못 한다.</p>
<p>셋째, RL은 첫 단계가 아니다. 결정적 재현, scripted baseline, metric schema, CSV export가 먼저다.</p>
<p>넷째, simulator output은 결정이 아니라 증거다. 게임 디자인 결정은 여전히 GDD와 PM 판단을 거쳐야 한다.</p>
<p>다섯째, 이 투트랙 구조는 프로젝트 설명을 바꾼다. 이제 이 프로젝트는 단순히 &quot;AI NPC가 있는 로그라이크&quot;가 아니라, <strong>로그라이크 본편을 만들면서 그 완성도를 높이는 AI QA 실험 환경까지 설계한 프로젝트</strong>로 말할 수 있다.</p>
<hr>
<p><em>이 글은 개발 기록 기반 초안이다. 현재 Seed Replay skeleton은 QA/포트폴리오 도구이며, 본편 게임 규칙이나 밸런스 결정을 자동으로 확정하지 않는다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Codex로 1주일 동안 게임을 만든 기록 — 검은 화면에서 APK까지]]></title>
            <link>https://velog.io/@ljhljh0703-cmd/Codex%EB%A1%9C-1%EC%A3%BC%EC%9D%BC-%EB%8F%99%EC%95%88-%EA%B2%8C%EC%9E%84%EC%9D%84-%EB%A7%8C%EB%93%A0-%EA%B8%B0%EB%A1%9D-%EA%B2%80%EC%9D%80-%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-APK%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@ljhljh0703-cmd/Codex%EB%A1%9C-1%EC%A3%BC%EC%9D%BC-%EB%8F%99%EC%95%88-%EA%B2%8C%EC%9E%84%EC%9D%84-%EB%A7%8C%EB%93%A0-%EA%B8%B0%EB%A1%9D-%EA%B2%80%EC%9D%80-%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-APK%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Wed, 20 May 2026 15:10:39 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Unity 모바일 로그라이크 <code>회귀자는 탑을 오른다</code>를 Codex와 함께 데모 상태까지 끌어올린 1주일의 기록.<br>Lobby black screen → 세로형 UI 재구성 → screenshot QA → Android APK 빌드까지.</p>
</blockquote>
<hr>
<h2 id="왜-이-글을-쓰는가">왜 이 글을 쓰는가</h2>
<p>최근 1주일 동안 Codex를 거의 개발팀처럼 썼다. 정확히는 PM, 클라이언트 개발자, QA, 빌드 담당을 세션별로 나눠 맡긴 것에 가깝다.</p>
<p>처음부터 거창한 완성품을 만든 것은 아니다. 시작점은 더 현실적이었다. Unity Editor에서 Lobby를 열었는데 Game View가 검은 화면이었다. 버튼도 안 보이고, 새 게임도 못 누르고, 첫 보스까지 가는 수동 QA도 막혔다.</p>
<p>결론부터 말하면, 그 상태에서 1주일 안에 여기까지 왔다.</p>
<ul>
<li>모바일 세로형 UI shell 재구성</li>
<li>Lobby / Floor Map / Event / Rest / Shop / Combat / Boss / Ending 화면 정리</li>
<li>1080x1920 screenshot harness 구축</li>
<li>GUI Test Runner 기준 EditMode / PlayMode 검증 흐름 복구</li>
<li>BossGate 선택지와 지도/상태/장비 유틸리티 보강</li>
<li>Android APK 생성 및 재빌드</li>
</ul>
<p>이 글은 &quot;AI가 코드를 다 짜줬다&quot;는 식의 이야기가 아니다. 오히려 반대다. Codex를 제대로 쓰려면 작업 범위를 쪼개고, 사실과 추측을 분리하고, 테스트 실패와 실행 환경 실패를 계속 구분해야 했다.</p>
<h2 id="시작점-게임은-있었지만-플레이할-수-없었다">시작점: 게임은 있었지만, 플레이할 수 없었다</h2>
<p>이미 프로젝트에는 많은 구조가 있었다. ScriptableObject 기반 데이터, encounter pipeline, 전투 루프, 마타이오스 NPC, save/continue, 상점과 휴식 구조도 있었다.</p>
<p>문제는 &quot;있는 것&quot;과 &quot;플레이 가능한 것&quot;이 다르다는 점이었다.</p>
<p>2026년 5월 15일 QA 기록의 가장 큰 blocker는 Lobby black screen이었다. Unity는 열리고, Play Mode도 들어가는데, Game View에는 아무것도 보이지 않았다. New Game을 누를 수 없으니 자연스럽게 Floor Map, 전투, 상점, 보스까지 이어지는 수동 검증도 막혔다.</p>
<p>이때 중요한 판단은 &quot;기능을 더 붙이지 말고, 먼저 보이는 게임으로 만들자&quot;였다.</p>
<p>Codex에게 시킨 일도 그래서 구현보다 복구에 가까웠다.</p>
<ul>
<li>Lobby scene bootstrap 확인</li>
<li>Canvas / EventSystem / GraphicRaycaster 계층 점검</li>
<li>portrait safe area와 runtime UI 생성 경로 정리</li>
<li>PlayMode smoke로 Lobby UI가 실제로 활성화되는지 고정</li>
</ul>
<p>이후 Lobby는 다시 보이기 시작했다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/01af93f9-c75d-451b-b9bd-4f456bc6d09a/image.png" alt=""></p>
<p>화면 하나가 보이는 데서 끝나지 않았다. 모바일 게임에서 Lobby는 단순 메뉴가 아니라 첫 인상이다. 그래서 이후 작업은 &quot;기능을 이어 붙이는 것&quot;에서 &quot;세로 화면에서 읽히는 게임으로 만드는 것&quot;으로 넘어갔다.</p>
<h2 id="세로형-ui를-다시-짠-이유">세로형 UI를 다시 짠 이유</h2>
<p>PC에서 돌아가는 Unity UI와 모바일 세로형 UI는 완전히 다르다. 특히 1080x1920 기준으로 보면, 작은 텍스트나 상단 HUD 과밀이 바로 드러난다.</p>
<p>초기 UI의 문제는 크게 세 가지였다.</p>
<ol>
<li>화면마다 이전 상태의 잔상이 남았다.</li>
<li>debug-like label이나 raw stableId가 종종 플레이어 화면에 보였다.</li>
<li>버튼은 있었지만, 무엇을 눌러야 하는지 한눈에 들어오지 않았다.</li>
</ol>
<p>그래서 <code>PrototypeHud</code>를 다시 나눴다. 큰 방향은 화면을 계층으로 분리하는 것이었다.</p>
<ul>
<li>top status</li>
<li>objective</li>
<li>visual stage</li>
<li>companion area</li>
<li>result summary</li>
<li>map</li>
<li>action buttons</li>
<li>ending layer</li>
</ul>
<p>이 분리는 생각보다 중요했다. 전투 화면에서는 지도와 상점 잔상이 없어야 하고, 이벤트 화면에서는 전투 결과 패널이 끼어들면 안 된다. Rest 화면에서는 입력창과 액션 카드가 겹치면 안 되고, Shop 화면에서는 merchant visual과 상품 카드가 서로 방해하면 안 된다.</p>
<p>지도도 다시 잡았다. 위에서 아래로 내려가는 지도보다, 모바일 세로 화면에서는 아래에서 위로 오르는 구조가 더 직관적이었다. 탑을 오른다는 게임 컨셉과도 맞았다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/23a61f6a-c282-4fc9-ae96-25799616d1dc/image.png" alt=""></p>
<p>이 시점부터 Codex 작업은 단순히 &quot;버튼 추가&quot;가 아니라 &quot;화면 상태를 서로 침범하지 않게 정리하는 일&quot;이 됐다. 이건 자동 생성 코드만으로 해결하기 어렵다. 계속 screenshot을 보고, 겹침을 찾고, 어떤 layer가 어느 상태에서 꺼져야 하는지 테스트로 고정해야 했다.</p>
<h2 id="게임-루프를-화면으로-증명하기">게임 루프를 화면으로 증명하기</h2>
<p>vertical slice에서 중요한 건 기능 목록이 아니다. 플레이어가 한 번의 run으로 실제 흐름을 이해할 수 있어야 한다.</p>
<p>이번 주에 정리한 핵심 루프는 이렇다.</p>
<pre><code class="language-text">Lobby
  → Floor Map
  → Event / Combat / Rest / Shop
  → BossGate
  → Boss Combat
  → Ending Choice</code></pre>
<p>각 화면은 기능적으로 이미 존재했지만, 데모로 보이려면 presentation이 필요했다.</p>
<p>Event 화면은 top status, central cutscene image, event text, bottom choice 구조로 다시 잡았다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/3da55b0a-2385-4e60-b702-0e418e4fd034/image.png" alt=""></p>
<p>Rest 화면은 마타이오스가 있는 화면이다. 여기서 NPC가 단순한 UI mode처럼 보이면 게임의 차별점이 죽는다. 그래서 portrait, rest action cards, input field, response bubble을 분리했다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/a3bc3b23-6cc5-4a05-818c-ef7030d73940/image.png" alt=""></p>
<p>Shop은 보스 전 준비 장소로 정리했다. 상품명, 가격, 효과, Gold 부족 상태가 보여야 했다. 단순히 &quot;구매 불가&quot;라고 쓰는 것보다, 플레이어가 무엇이 부족하고 무엇을 목표로 해야 하는지 알 수 있어야 했다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/a7c441d5-0040-4822-abd2-bae3a9f3af94/image.png" alt=""></p>
<p>Combat은 가장 많이 흔들린 화면이다. 처음에는 Attack / Defend / Skill 버튼이 있다는 것만으로 충분해 보였지만, 실제로 보면 선택 이유가 약했다. 그래서 combat dock을 다시 만들었다. 적은 위, 전투 로그는 가운데, 플레이어와 마타이오스는 아래에 고정했다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/30294fb5-1c49-4e45-afe4-c5f02aa3a4ad/image.png" alt=""></p>
<p>아직 전투는 완성됐다고 말하기 어렵다. enemy intent, defend value, skill condition, hit/guard feedback은 더 필요하다. 하지만 최소한 &quot;전투 화면으로 읽힌다&quot;는 단계까지는 끌어올렸다.</p>
<h2 id="screenshot-harness가-qa의-기준이-됐다">Screenshot harness가 QA의 기준이 됐다</h2>
<p>이번 주 가장 큰 전환점은 screenshot harness였다.</p>
<p>UI를 고칠 때 가장 위험한 말은 &quot;대충 괜찮아 보인다&quot;다. 어떤 화면에서 괜찮았는지, 몇 해상도에서 봤는지, 다음 수정 후에도 그대로인지가 남지 않는다.</p>
<p>그래서 PlayMode 기반 screenshot QA를 만들었다. 기준은 1080x1920 portrait였다. 처음에는 8개 화면을 찍었다.</p>
<ul>
<li>Lobby</li>
<li>Floor Map</li>
<li>Event / Jar Room</li>
<li>Rest / Mataios</li>
<li>Shop</li>
<li>Normal Combat</li>
<li>Boss Combat</li>
<li>Ending Choice</li>
</ul>
<p>이후 Floor 3~5 shop과 BossGate choice까지 확장되면서 12장 기준으로 늘어났다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/284367f6-00d8-485e-af43-8a9d2f728f77/image.png" alt=""></p>
<p>이 스크린샷들은 단순 결과물이 아니라 테스트 기준이었다. 예를 들어 Shop을 고치면 Shop만 보는 게 아니라, Rest나 Combat에 이전 UI가 남지 않는지도 같이 봐야 했다. BossGate 선택지를 고치면 boss combat으로 바로 들어가는 경로뿐 아니라 <code>돌아간다</code>가 route state를 망가뜨리지 않는지도 봐야 했다.</p>
<p>이때부터 Codex에게 줄 수 있는 지시도 훨씬 구체적이 됐다.</p>
<p>나쁜 지시는 이런 식이다.</p>
<pre><code class="language-text">UI 좀 더 예쁘게 해줘.</code></pre>
<p>실제로 도움이 된 지시는 이런 식이었다.</p>
<pre><code class="language-text">Rest 화면에서 action card, input field, response bubble이 겹치지 않게 phase를 분리해.
Shop과 Map 상태에서는 result/objective/debug layer가 남지 않게 꺼.
수정 후 screenshot harness 기준으로 01~12 화면을 다시 검증해.</code></pre>
<p>Codex는 추상적인 미감보다 명확한 실패 조건을 줬을 때 훨씬 잘 움직였다.</p>
<h2 id="test-runner와-licenseclient-삽질">Test Runner와 LicenseClient 삽질</h2>
<p>이번 주에 제일 많이 시간을 먹은 건 게임 로직이 아니라 검증 경로였다.</p>
<p>Unity batchmode는 몇 번이나 애매하게 실패했다. 어떤 날은 LicenseClient timeout이 났고, 어떤 날은 PlayMode test가 exit code 0으로 끝났는데 XML이나 PNG가 생성되지 않았다. 5월 19일 manual QA에서는 Lobby가 보였지만, Codex/OS coordinate click으로 <code>새 게임</code> 버튼을 실제로 진행시키지 못했다.</p>
<p>여기서 중요한 건 실패를 한 덩어리로 뭉개지 않는 것이다.</p>
<ul>
<li>게임 로직이 실패한 것인가</li>
<li>Unity 실행 환경이 실패한 것인가</li>
<li>batchmode test runner가 실패한 것인가</li>
<li>GUI Test Runner에서는 통과하는가</li>
<li>실제 물리 입력에서는 되는가</li>
<li>screenshot은 이전 것인가, 이번 run에서 새로 생성된 것인가</li>
</ul>
<p>이걸 분리하지 않으면 QA 기록이 금방 오염된다. &quot;테스트 통과&quot;라고 했는데 사실은 오래된 XML을 본 것일 수도 있고, &quot;게임이 안 된다&quot;고 했는데 실제로는 자동화 클릭이 Unity Game View에 전달되지 않은 것일 수도 있다.</p>
<p>그래서 진행 로그에는 일부러 지저분한 사실도 남겼다.</p>
<ul>
<li>batchmode가 XML/PNG를 만들지 않음</li>
<li>GUI Test Runner로 우회 검증</li>
<li>임시 TestRunner wrapper 사용 후 제거</li>
<li>ADB device 없음으로 실기 smoke 미실행</li>
<li>manual route는 Lobby click automation에서 막힘</li>
</ul>
<p>블로그에 이런 내용을 쓰는 이유는 간단하다. 실제 개발은 성공한 커밋만으로 설명되지 않는다. 테스트 실행 경로를 복구하는 시간도 개발 시간이다.</p>
<h2 id="apk까지-갔다">APK까지 갔다</h2>
<p>5월 18일에는 Android APK를 만들었다. Unity 6, IL2CPP, ARM64, portrait 설정으로 release/non-development APK를 생성했고, APK signature verify도 통과했다.</p>
<p>5월 20일에는 <code>2550281</code> 기준으로 다시 빌드했다. 이 기준점은 기록상 stable gameplay commit으로 남겼다.</p>
<pre><code class="language-text">2550281 Update tests for polished map and utility flow</code></pre>
<p>업로드용 APK 파일의 SHA-256도 확인했다.</p>
<pre><code class="language-text">ee80aa86d7d053a6cd0278c6f79e9ec605e62a798d001cabd552db6fb411a20b</code></pre>
<p>다만 여기서도 선을 그어야 한다. APK 생성은 완료됐지만, Android 실기 install/launch smoke는 ADB 기기가 없어 아직 별도 과제로 남았다. 즉 &quot;APK가 있다&quot;와 &quot;실기 검증까지 끝났다&quot;는 다른 문장이다.</p>
<p>Ending 화면도 데모 경로에 들어왔다.</p>
<p><img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/bde9ff8b-5f8e-47a6-aa16-836da48a55d8/image.png" alt=""></p>
<p>아직 텍스트와 연출은 더 다듬어야 한다. 하지만 Lobby black screen에서 시작했던 1주일을 생각하면, 여기까지 온 것만으로도 큰 전환이다. 이제 최소한 화면 단위로 이야기할 수 있는 게임이 됐다.</p>
<h2 id="codex를-어떻게-썼나">Codex를 어떻게 썼나</h2>
<p>이번 작업에서 Codex를 잘 쓰는 방식은 점점 분명해졌다.</p>
<p>첫째, 세션 역할을 나눴다. 구현 세션, QA 세션, 빌드 세션, 블로그/기록 세션을 섞지 않았다. 한 세션이 모든 것을 하게 두면 맥락이 흐려지고, 특히 QA와 구현이 섞이면 &quot;검증했다&quot;는 말의 의미가 약해진다.</p>
<p>둘째, PROGRESS를 계속 남겼다. Codex가 이전 작업의 이유를 기억하려면 실행 로그가 필요하다. 단순히 &quot;수정함&quot;이 아니라 무엇을 고쳤고, 어떤 테스트를 돌렸고, 무엇이 막혔는지를 남겨야 다음 세션이 이어진다.</p>
<p>셋째, Codex에게 추상적 완성도를 맡기지 않았다. &quot;좋게 만들어줘&quot;보다 &quot;이 화면에서 이 layer를 숨기고, 이 버튼만 남기고, screenshot harness를 통과시켜&quot;가 훨씬 낫다.</p>
<p>넷째, dirty worktree를 함부로 정리하지 않았다. 작업 중에는 에셋, 문서, 테스트 산출물, 임시 파일이 계속 생긴다. Codex에게도 &quot;내가 만들지 않은 변경은 되돌리지 말라&quot;는 원칙이 중요했다.</p>
<p>다섯째, 테스트 수치를 과장하지 않았다. GUI Test Runner로 확인한 것, batchmode가 실패한 것, screenshot이 새로 생성된 것, 사용자 제공 기준인 것을 분리했다.</p>
<h2 id="배운-것">배운 것</h2>
<ol>
<li><p><strong>AI로 게임을 만든다는 말은 너무 넓다</strong><br>실제로는 화면 하나, 테스트 하나, 커밋 하나씩 쪼개서 지시해야 한다. Codex는 방향을 읽을 수 있지만, 검증 기준이 없으면 쉽게 넓어진다.</p>
</li>
<li><p><strong>vertical slice는 기능 목록이 아니라 경로다</strong><br>Lobby, Map, Combat, Shop이 따로 있는 것과 한 번의 run으로 이어지는 것은 다르다. 이번 주의 핵심은 화면을 연결된 경로로 만드는 것이었다.</p>
</li>
<li><p><strong>스크린샷은 문서가 아니라 테스트다</strong><br>1080x1920 screenshot harness가 생기고 나서야 UI 수정이 누적될 수 있었다. 눈으로 보는 QA도 반복 가능해야 한다.</p>
</li>
<li><p><strong>실패 기록이 다음 작업 속도를 만든다</strong><br>LicenseClient timeout, batchmode XML 미생성, Lobby click automation 실패는 보기 좋은 기록은 아니다. 그래도 남겨야 다음에 같은 문제를 게임 로직 버그로 오해하지 않는다.</p>
</li>
<li><p><strong>AI NPC보다 먼저 필요한 것은 결정적인 게임 루프다</strong><br>이 게임의 장기 목표는 AI NPC지만, 이번 주에 가장 중요했던 건 AI 연결이 아니었다. 같은 입력과 같은 선택이 같은 결과를 내고, 그 결과를 테스트할 수 있는 구조였다.</p>
</li>
</ol>
<h2 id="다음-단계">다음 단계</h2>
<p>다음은 두 갈래다.</p>
<p>하나는 게임 자체의 QA다. Android 실기에서 install/launch smoke를 하고, Lobby에서 첫 보스까지 물리 입력 기준으로 한 번 더 확인해야 한다. Combat 선택 이유, Shop disabled state, Ending copy도 더 다듬어야 한다.</p>
<p>다른 하나는 AI NPC 연결이다. 이미 LLM provider abstraction, prompt hash, deterministic cache, memory/reaction slot은 준비되어 있다. 이제 실제 local 또는 on-device AI를 붙일 때, 출력 품질보다 먼저 캐시와 fallback이 게임 루프를 흔들지 않는지 봐야 한다.</p>
<p>이번 1주일은 완성품을 만든 시간이 아니었다. 대신 검은 화면에서 시작해, 화면을 찍고, 테스트하고, APK로 묶을 수 있는 데모의 뼈대를 만든 시간이었다.</p>
<hr>
<p><em>이 글은 개발 기록 기반 초안이다. 게임 스토리 원문, 내부 경로, 계정/인증 정보, 외주 원본 데이터는 포함하지 않았다.</em>
<img src="https://velog.velcdn.com/images/ljhljh0703-cmd/post/be354746-e893-48c3-be3b-880386d7d7cc/image.md" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[소형 LLM으로 게임 NPC 만들기 
— 3전 4기 기록]]></title>
            <link>https://velog.io/@ljhljh0703-cmd/%EC%86%8C%ED%98%95-LLM%EC%9C%BC%EB%A1%9C-%EA%B2%8C%EC%9E%84-NPC-%EB%A7%8C%EB%93%A4%EA%B8%B0-3%EC%A0%84-4%EA%B8%B0-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@ljhljh0703-cmd/%EC%86%8C%ED%98%95-LLM%EC%9C%BC%EB%A1%9C-%EA%B2%8C%EC%9E%84-NPC-%EB%A7%8C%EB%93%A4%EA%B8%B0-3%EC%A0%84-4%EA%B8%B0-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Tue, 19 May 2026 14:01:16 GMT</pubDate>
            <description><![CDATA[<h1 id="소형-llm으로-게임-npc-만들기--실패-3번-성공-1번의-기록">소형 LLM으로 게임 NPC 만들기 — 실패 3번, 성공 1번의 기록</h1>
<blockquote>
<p>2.4B 파라미터 한국어 모델로 캐릭터 일관성을 가진 AI NPC를 만든 과정.
HCX-SEED 실패 → Qwen2.5 실패 → EXAONE 성공 → Unity 연동까지.</p>
</blockquote>
<hr>
<h2 id="왜-이-글을-쓰는가">왜 이 글을 쓰는가</h2>
<p>Unity로 모바일 로그라이크 게임을 만들고 있다. NPC가 하나 있는데, 플레이어의 자유 입력에 반응해야 한다. 선택지 기반 대화로는 캐릭터의 개성을 표현하기 어려웠고, 대형 API(Claude, GPT-4o)는 비용과 레이턴시가 문제였다. 그래서 소형 LLM을 파인튜닝해서 로컬에서 돌리기로 했다.</p>
<p>결론부터 말하면, <strong>3번 실패하고 4번째에 성공했다</strong>. 이 글은 그 과정에서 배운 것을 정리한 기록이다.</p>
<hr>
<h2 id="1-프로젝트-개요">1. 프로젝트 개요</h2>
<p><strong>게임</strong>: &quot;회귀자는 탑을 오른다&quot; — 모바일 로그라이크
<strong>NPC</strong>: 마타이오스 — 기억을 잃은 소녀 전사. 호감도에 따라 말투가 달라지고, 스토리 진행에 따라 기억을 되찾는다.</p>
<p>NPC에게 요구되는 &quot;행동 계약&quot;:</p>
<ul>
<li>호감도 5단계(hostile/cold/neutral/warm/open)에 따른 어조 분리</li>
<li>게임 세계관 밖 질문(날씨, AI 모델, 수학 질문 등) 차단 (BIW 규칙)</li>
<li>스토리 진행(S0~S5)에 따른 기억 키워드 반응</li>
<li>S3 이후 정체성 혼란(fracture) 패턴</li>
<li>항상 한국어 반말</li>
</ul>
<p>이걸 소형 모델 하나로 해결해야 했다.</p>
<hr>
<h2 id="2-첫-번째-시도-hcx-seed-05b--gguf-변환-자체가-안-됨">2. 첫 번째 시도: HCX-SEED 0.5B — GGUF 변환 자체가 안 됨</h2>
<p>네이버의 HCX-SEED 0.5B. 한국어 특화 모델이라 기대가 컸다.</p>
<p><strong>결과</strong>: 완전 실패.</p>
<ul>
<li>GGUF 변환 시 가중치 매핑 오류 (hyperclovax 아키텍처가 llama.cpp 미지원)</li>
<li>Ollama에 올려봤더니 이름을 &quot;마타리오스&quot;로 출력하고, 성별을 남성으로 답하고, 마크다운 표를 그렸다</li>
<li>Colab에서 직접 추론해도 BIW 차단 실패, AI 자인 발생</li>
</ul>
<p>나중에 알게 된 건, <strong>학습 자체가 안 됐을 가능성이 높다</strong>는 것이다. BPE 토큰화 문제로 <code>DataCollatorForCompletionOnlyLM</code>이 응답 경계를 찾지 못했고, 모든 라벨이 -100이 되어 사실상 학습 토큰이 0개였을 수 있다.</p>
<p><strong>교훈</strong>: 모델을 고르기 전에 GGUF 변환 호환 여부부터 확인할 것. 그리고 훈련 후 반드시 라벨 검증을 할 것.</p>
<hr>
<h2 id="3-두-번째-시도-qwen25-15b--다국어-누출이-해결-안-됨">3. 두 번째 시도: Qwen2.5-1.5B — 다국어 누출이 해결 안 됨</h2>
<p>LLaMA 호환 아키텍처, ChatML 네이티브, GGUF 지원. 조건이 완벽해 보였다.</p>
<p><strong>v6.0</strong>: BPE 토큰 경계 버그 발견 — <code>&quot;마타이오스: &quot;</code> trailing space가 문맥 내에서 다음 문자와 합쳐져 매칭 실패. 이건 수정했다.</p>
<p><strong>v6.1</strong>: 훈련 성공, 추론 9건 테스트. <strong>4건(44%)에서 영어/중국어 출력</strong>. &quot;서울 날씨 어때?&quot;에 영어로 답하고, &quot;너 AI지?&quot;에 &quot;人工智能。&quot;이라고 답했다.</p>
<p><strong>v6.2</strong>: 시스템 프롬프트에 &quot;반드시 한국어로만 응답한다&quot; 추가 + BIW 거부 데이터 85건 증강 후 재훈련. <strong>개선 없음 — 여전히 44%</strong>.</p>
<p>622건 SFT로는 Qwen2.5의 다국어 prior를 덮을 수 없었다. 다국어 모델의 base 지식이 너무 강해서, 학습 데이터에 없는 패턴(세계관 외부 질문)이 들어오면 영어/중국어 모드로 전환되는 현상이었다.</p>
<p><strong>교훈</strong>: 다국어 모델에서 단일언어 SFT를 하려면 데이터가 훨씬 많거나, 아예 한국어 primary 모델을 써야 한다.</p>
<hr>
<h2 id="4-bpe-토큰화-버그--이건-따로-기록할-가치가-있다">4. BPE 토큰화 버그 — 이건 따로 기록할 가치가 있다</h2>
<p><code>DataCollatorForCompletionOnlyLM</code>에 response_template을 문자열로 전달하면 <strong>사일런트 실패</strong>가 발생할 수 있다.</p>
<p>핵심은 BPE의 <strong>컨텍스트 민감성</strong>이다. 같은 문자열이라도 독립적으로 토큰화할 때와 긴 텍스트의 일부로 토큰화할 때 토큰 ID가 달라진다.</p>
<pre><code>독립: &quot;마타이오스: &quot; → [..., 25, 220]     (공백이 독립 토큰)
문맥: &quot;마타이오스: …좋진 않아.&quot; → [..., 25, 4593]  (공백이 다음 문자와 합쳐짐)</code></pre><p>콜레이터는 독립 토큰화 결과로 문맥 내 시퀀스를 찾으니까 매칭이 실패하고, 모든 라벨이 -100이 되고, 모델은 아무것도 학습하지 않는다. <strong>loss가 0인데 에러가 안 나서</strong> &quot;왜 파인튜닝이 안 먹히지?&quot;가 된다.</p>
<p>해결법: trailing space를 제거하고, 토큰 ID 리스트로 전달하고, 훈련 전에 active 라벨이 0이 아닌지 검증하는 셀을 추가했다.</p>
<p><strong>이 문제는 Qwen2.5뿐 아니라 모든 BPE 모델에서 발생할 수 있다.</strong></p>
<hr>
<h2 id="5-세-번째-시도-성공-exaone-35-24b--한국어가-1순위인-모델">5. 세 번째 시도 (성공): EXAONE 3.5 2.4B — 한국어가 1순위인 모델</h2>
<p>리서치를 다시 했다. 핵심 발견들:</p>
<ul>
<li>단일언어 SFT가 해당 언어에서 더 높은 성능을 보인다는 논문 (EACL 2024)</li>
<li>소형 모델 + 페르소나 고정 + 모듈형 메모리가 NPC 대화에 적합하다는 연구</li>
<li>EXAONE 3.5 2.4B는 <strong>한국어가 1순위 학습 언어</strong> (Korean-primary)</li>
</ul>
<p>EXAONE으로 교체하고, QLoRA rank를 16→32로 올리고, 데이터를 622→697건으로 증강했다.</p>
<p><strong>v7 결과 (5세트 26턴)</strong>:</p>
<ul>
<li>한국어 유지율: <strong>100%</strong> (v6.1의 56%에서 완전 해결)</li>
<li>BIW 차단: <strong>100%</strong></li>
<li>호감도 어조 분리: <strong>정상</strong></li>
<li>Fracture 패턴: <strong>정상</strong></li>
</ul>
<p>다국어 누출이 0%가 된 건 모델 교체 덕분이 크다. 데이터 증강이나 rank 강화도 도움이 됐겠지만, 근본적으로는 <strong>한국어 prior가 강한 모델을 쓴 것</strong>이 결정적이었다.</p>
<hr>
<h2 id="6-ollama가-안-돼서-fastapi로-갈아탄-이야기">6. Ollama가 안 돼서 FastAPI로 갈아탄 이야기</h2>
<p>EXAONE 모델은 GGUF로 변환은 된다. llama.cpp의 <code>convert_hf_to_gguf.py</code>가 EXAONE 아키텍처를 지원하기 때문이다.</p>
<p>그런데 <strong>추론이 안 된다</strong>. GGUF &quot;변환기&quot;와 &quot;추론 엔진&quot;은 별개인데, llama.cpp 추론 경로에 EXAONE의 attention 패턴이 구현되어 있지 않았다.</p>
<p>처음에는 이걸 몰라서 한참 삽질했다. q8_0 양자화까지 성공하고, <code>ollama create</code>까지 됐는데, <code>ollama run</code>에서 추론 오류가 나서야 알게 됐다.</p>
<p>대안으로 <strong>FastAPI + transformers 직접 추론 서버</strong>를 만들었다. Python에서 transformers로 모델을 로드하고, HTTP API를 열어서 Unity에서 호출하는 구조다.</p>
<pre><code>Unity (C#) ──HTTP POST──▶ FastAPI (Python) ──▶ transformers + EXAONE 2.4B</code></pre><p>Apple Silicon Mac에서는 MPS, NVIDIA GPU가 있으면 CUDA, 없으면 CPU로 자동 감지한다.</p>
<hr>
<h2 id="7-unity-연동">7. Unity 연동</h2>
<p>두 가지 방식을 만들었다:</p>
<ol>
<li><strong>RemoteLLMProvider</strong>: 기존 Unity 프로젝트의 <code>ILLMProvider</code> 인터페이스에 맞춘 동기식 클라이언트. 서버 불통 시 결정론적 폴백 제공.</li>
<li><strong>MataiosDialogueClient</strong>: 비동기 UnityWebRequest 코루틴 기반. 실제 게임에서 사용할 것. UI가 안 멈춘다.</li>
</ol>
<p>Unity Inspector에서 <code>LLMProviderMode</code>를 <code>Remote</code>로 바꾸고 서버 URL을 입력하면 끝이다.</p>
<hr>
<h2 id="8-기술-스택-요약">8. 기술 스택 요약</h2>
<table>
<thead>
<tr>
<th>레이어</th>
<th>선택</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>베이스 모델</td>
<td>EXAONE 3.5 2.4B-Instruct</td>
<td>Korean-primary, trust_remote_code</td>
</tr>
<tr>
<td>파인튜닝</td>
<td>QLoRA r=32, alpha=64</td>
<td>Colab A100, 697건 SFT</td>
</tr>
<tr>
<td>추론 서버</td>
<td>FastAPI + transformers</td>
<td>MPS/CUDA/CPU 자동 감지</td>
</tr>
<tr>
<td>게임 엔진</td>
<td>Unity (C#)</td>
<td>RemoteLLMProvider + MataiosDialogueClient</td>
</tr>
<tr>
<td>데이터</td>
<td>697건 JSONL</td>
<td>13개 task, S0<del>S5, 호감도 -100</del>100</td>
</tr>
</tbody></table>
<hr>
<h2 id="9-실패에서-배운-것">9. 실패에서 배운 것</h2>
<ol>
<li><strong>모델 선택이 데이터 품질보다 중요할 수 있다</strong>: 622건으로 Qwen2.5의 다국어 prior를 못 덮었지만, EXAONE에서는 697건으로 충분했다.</li>
<li><strong>BPE 사일런트 실패는 진짜 무섭다</strong>: loss가 0인데 에러가 안 나면 아무도 눈치 못 챈다. 라벨 검증 셀은 필수다.</li>
<li><strong>GGUF 변환 ≠ 추론 가능</strong>: 변환기와 추론 엔진이 별개라는 걸 알기 전에 많은 시간을 날렸다.</li>
<li><strong>trust_remote_code는 반드시 revision 핀</strong>: HF 모델 팀이 최신 커밋을 업데이트하면 어떤 transformers 버전을 깔아도 호환이 안 될 수 있다.</li>
<li><strong>Ollama가 안 되면 FastAPI로 우회할 수 있다</strong>: transformers 직접 추론은 느리지만, 로컬 서버 용도로는 충분하다.</li>
</ol>
<hr>
<h2 id="10-다음-단계">10. 다음 단계</h2>
<ul>
<li><input disabled="" type="checkbox"> 모바일 빌드 테스트 (FastAPI 서버가 같은 네트워크에 있으면 됨)</li>
<li><input disabled="" type="checkbox"> MLC-LLM 온디바이스 추론 재검토 (EXAONE 지원 여부)</li>
<li><input disabled="" type="checkbox"> 대화 히스토리 관리 + 요약 파이프라인</li>
<li><input disabled="" type="checkbox"> 유저 테스트 — 실제 플레이어 반응 수집</li>
</ul>
<hr>
<h2 id="부록-에러-로그-전체">부록: 에러 로그 (전체)</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>버전</th>
<th>에러</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>E-01</td>
<td>v5</td>
<td>GGUF 변환 실패</td>
<td>HCX-SEED 아키텍처 미지원</td>
<td>모델 교체</td>
</tr>
<tr>
<td>E-02</td>
<td>v6.0</td>
<td>response_template 매칭 실패</td>
<td>BPE trailing space</td>
<td>trailing space 제거 + 토큰 ID 리스트</td>
</tr>
<tr>
<td>E-03</td>
<td>v6.0</td>
<td>학습 안 됨 (loss=0)</td>
<td>콜레이터 매칭 실패 → 라벨 전부 -100</td>
<td>토큰 ID 리스트 + 라벨 검증 셀</td>
</tr>
<tr>
<td>E-04</td>
<td>v6.2</td>
<td>SyntaxError</td>
<td>f-string 이스케이프 충돌</td>
<td>변수에 먼저 할당</td>
</tr>
<tr>
<td>E-05</td>
<td>v6.2</td>
<td>다국어 누출 44%</td>
<td>Qwen2.5 다국어 prior 억제 불가</td>
<td>모델 교체 (→ EXAONE)</td>
</tr>
<tr>
<td>E-06</td>
<td>v7.0</td>
<td>ImportError 연쇄</td>
<td>trust_remote_code 최신 커밋 비호환</td>
<td>revision 핀 + transformers 버전 고정</td>
</tr>
<tr>
<td>E-07</td>
<td>v7.0</td>
<td>pip install 실패</td>
<td>쉼표 누락 → 문자열 연결</td>
<td>쉼표 위치 수정</td>
</tr>
</tbody></table>
<hr>
<p><em>이 글은 개발 일지이며, 게임 스토리/대사 원본은 포함하지 않습니다.</em>
<em>모델 파일은 배포하지 않으며, 재현 방법만 기술합니다.</em></p>
]]></description>
        </item>
    </channel>
</rss>