<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>한국 게임 개발자 노트</title>
    <link>https://kr-gamedev.tistory.com/</link>
    <description>한국 게임 개발자를 위한 매일 한 편. 유니티, 언리얼 실전 팁, 모던 게임 코드 패턴, 도구 자동화 워크플로우, 글로벌 게임 산업 트렌드까지 정리하는 게임 개발 노트입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 17 Jul 2026 18:06:06 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>kr-gamedev</managingEditor>
    <item>
      <title>AI에게 Unity 프리팹 일괄 수정을 맡길 때 Dry Run과 Undo 제대로 넣는 법</title>
      <link>https://kr-gamedev.tistory.com/71</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/bwN1i5/dJMcag0vici/AAAAAAAAAAAAAAAAAAAAAC7XPp1HQFNLeI577SMSTJFlYPIWa5qF7VKF08J81Wrc/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=TJtRDIz7ch2obpJDlJEHPuHema4%3D&quot; alt=&quot;AI 보조 게임 개발 개념도 (AI 생성)&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브 프로젝트에서는 수백 개의 프리팹에 새 컴포넌트를 붙이거나 오래된 설정값을 바꿔야 할 때가 있다. AI 코딩 도구에 요청하면 일괄 수정용 Editor 스크립트는 금방 만들어 준다. 문제는 코드 생성 속도가 아니라 잘못된 변경을 얼마나 빨리 발견하고 되돌릴 수 있느냐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안전한 자동화 도구에는 세 가지 장치가 필요하다. 실제 저장 전에 결과를 보여 주는 Dry Run, 같은 작업을 반복해도 결과가 달라지지 않는 멱등성, Unity Undo를 이용한 복구다. 이 조건을 먼저 계약으로 정하면 AI가 만든 코드도 팀 프로젝트에 투입할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 작업 범위를 파일 규칙으로 고정한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;모든 적 프리팹을 수정해 줘&amp;rdquo;라고 요청하지 말고 검색 위치와 대상 조건을 명시한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Assets/Game/Enemies 아래의 Prefab만 검사한다.
EnemyView가 있고 UseOutline이 false인 오브젝트만 변경한다.
Packages와 읽기 전용 에셋은 제외한다.
Dry Run에서는 파일을 저장하지 않는다.
Apply 후 다시 실행하면 변경 건수가 0이어야 한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 구현 방법보다 입력, 출력, 제외 조건을 먼저 주는 것이 중요하다. 특히 폴더 범위가 없으면 샘플 에셋이나 외부 패키지까지 수정할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 검사와 적용을 같은 함수에서 분리한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 변경 계획을 값으로 만든다. 검사 단계에서는 Unity 오브젝트를 건드리지 않는다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;private readonly struct ChangePlan
{
    public readonly string Path;
    public readonly EnemyView Target;
    public readonly Color Before;
    public readonly Color After;

    public ChangePlan(string path, EnemyView target, Color before, Color after)
    {
        Path = path;
        Target = target;
        Before = before;
        After = after;
    }
}

private static IEnumerable&amp;lt;ChangePlan&amp;gt; Scan()
{
    foreach (string guid in AssetDatabase.FindAssets(
                 &quot;t:Prefab&quot;, new[] { &quot;Assets/Game/Enemies&quot; }))
    {
        string path = AssetDatabase.GUIDToAssetPath(guid);
        var root = AssetDatabase.LoadAssetAtPath&amp;lt;GameObject&amp;gt;(path);

        foreach (var view in root.GetComponentsInChildren&amp;lt;EnemyView&amp;gt;(true))
        {
            if (view.UseOutline)
                continue;

            yield return new ChangePlan(
                path, view, view.OutlineColor, Color.red);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ChangePlan&lt;/code&gt;을 로그나 JSON으로 출력하면 사람이 변경 대상을 먼저 검토할 수 있다. AI에게도 이 결과만 전달해 누락과 과잉 선택을 다시 분석시킬 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Dry Run은 저장 API를 호출하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dry Run을 단순한 확인 창으로 구현하면 부족하다. 이 경로에서는 &lt;code&gt;SetDirty&lt;/code&gt;, &lt;code&gt;SaveAssets&lt;/code&gt;, 필드 대입을 호출하지 않아야 한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;private static void Preview(IReadOnlyCollection&amp;lt;ChangePlan&amp;gt; plans)
{
    foreach (var plan in plans)
        Debug.Log($&quot;[DRY RUN] {plan.Path}: {plan.Before} -&amp;gt; {plan.After}&quot;);

    Debug.Log($&quot;변경 예정: {plans.Count}건&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;4. Apply에는 Undo와 저장을 묶는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검토가 끝난 계획만 적용한다. 여러 오브젝트를 하나의 Undo 작업으로 묶으면 실수했을 때 한 번에 되돌릴 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private static void Apply(IReadOnlyCollection&amp;lt;ChangePlan&amp;gt; plans)
{
    int group = Undo.GetCurrentGroup();
    Undo.SetCurrentGroupName(&quot;Enemy Prefab Outline Migration&quot;);

    try
    {
        foreach (var plan in plans)
        {
            Undo.RecordObject(plan.Target, &quot;Update EnemyView&quot;);
            plan.Target.UseOutline = true;
            plan.Target.OutlineColor = plan.After;
            EditorUtility.SetDirty(plan.Target);
        }

        AssetDatabase.SaveAssets();
    }
    finally
    {
        Undo.CollapseUndoOperations(group);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대량 작업 전에는 버전 관리 상태도 깨끗하게 만들어야 한다. Undo는 편집기 안의 안전망이고, Git 변경 목록은 파일 단위의 최종 안전망이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 멱등성은 재실행으로 검증한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 도구는 현재 값이 목표 상태와 같으면 반드시 건너뛰어야 한다. &amp;ldquo;실행할 때마다 컴포넌트 추가&amp;rdquo;, &amp;ldquo;목록에 같은 항목 다시 삽입&amp;rdquo; 같은 코드는 금지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 방법은 단순하다. Dry Run에서 예상 건수를 확인하고 Apply를 실행한 뒤 다시 Dry Run을 실행한다. 두 번째 결과가 0건이 아니라면 조건식이나 중복 방지 로직이 잘못된 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. AI에게 결과물이 아니라 검증 절차까지 요청한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 프롬프트는 &amp;ldquo;Editor 스크립트를 작성해 줘&amp;rdquo;로 끝나지 않는다. 다음 항목을 완료 조건으로 붙인다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;컴파일 오류가 없어야 한다.
Dry Run에는 쓰기 작업이 없어야 한다.
Apply는 Undo 한 단계로 복구되어야 한다.
두 번째 실행의 변경 건수는 0이어야 한다.
변경된 프리팹 경로를 모두 출력해야 한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 요청하면 AI는 단발성 코드 조각보다 반복 실행 가능한 내부 도구를 만들 가능성이 높아진다. 생성된 코드는 작은 샘플 폴더에서 먼저 실행한 뒤 전체 범위로 확대한다.&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;br /&gt;☐ 검사 단계와 적용 단계를 분리했는가&lt;br /&gt;☐ Dry Run에서 에셋을 수정하거나 저장하지 않는가&lt;br /&gt;☐ 변경 전후 값과 프리팹 경로를 출력하는가&lt;br /&gt;☐ Apply 작업을 Unity Undo로 복구할 수 있는가&lt;br /&gt;☐ 재실행했을 때 변경 건수가 0인가&lt;br /&gt;☐ 전체 적용 전 작은 샘플과 Git 변경 목록을 검토했는가&lt;/p&gt;</description>
      <category>AI 보조 게임 개발</category>
      <category>AIcoding</category>
      <category>AssetMigration</category>
      <category>automation</category>
      <category>DryRun</category>
      <category>EditorTool</category>
      <category>prefab</category>
      <category>Undo</category>
      <category>unity</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/71</guid>
      <comments>https://kr-gamedev.tistory.com/71#entry71comment</comments>
      <pubDate>Fri, 17 Jul 2026 09:10:42 +0900</pubDate>
    </item>
    <item>
      <title>Unity 에디터 도구 설정이 재컴파일마다 초기화될 때, ScriptableSingleton 저장 위치부터 확인하세요</title>
      <link>https://kr-gamedev.tistory.com/70</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/cQQsF2/dJMcagzsRXy/AAAAAAAAAAAAAAAAAAAAAOyf_WVp65NX9lJUGQQK-lHtAT75l-onIAh8FnhCdHim/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=t14q1c4tIr0roVn6ec0riSiT6bc%3D&quot; alt=&quot;Unity&amp;middot;Unreal 실전 개념도 (AI 생성)&quot; /&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;EditorWindow&lt;/code&gt; 필드나 &lt;code&gt;static&lt;/code&gt; 변수에만 넣으면 스크립트 재컴파일과 에디터 재시작 뒤에 값이 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;EditorPrefs&lt;/code&gt;로 옮기면 저장은 되지만 프로젝트별 설정과 사용자 개인 설정이 섞이기 쉽다. Unity의 &lt;code&gt;ScriptableSingleton&amp;lt;T&amp;gt;&lt;/code&gt;을 사용하면 에디터 전용 상태를 직렬화하고, 저장 위치까지 명시적으로 결정할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. static 필드가 해결책이 아닌 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;static&lt;/code&gt;은 접근하기 편하지만 영속 저장소가 아니다. Domain Reload가 발생하면 초기화되며, Reload Domain을 꺼도 에디터 재시작까지 버티지는 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity 공식 문서에 따르면 &lt;code&gt;ScriptableSingleton&amp;lt;T&amp;gt;&lt;/code&gt;의 직렬화 가능한 데이터는 Assembly Reload 이후에도 유지되며, &lt;code&gt;FilePathAttribute&lt;/code&gt;를 함께 사용하면 에디터 세션 사이에도 보존된다.&lt;br /&gt;출처: &lt;a href=&quot;https://docs.unity3d.com/6000.0/Documentation/ScriptReference/ScriptableSingleton_1.html&quot;&gt;Unity ScriptableSingleton API&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 설정 저장소 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드는 팀이 함께 사용하는 빌드 도구 설정을 &lt;code&gt;ProjectSettings&lt;/code&gt; 아래에 텍스트로 저장한다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;using UnityEditor;
using UnityEngine;

[FilePath(
    &quot;ProjectSettings/BuildDashboardSettings.asset&quot;,
    FilePathAttribute.Location.ProjectFolder)]
public sealed class BuildDashboardSettings
    : ScriptableSingleton&amp;lt;BuildDashboardSettings&amp;gt;
{
    [SerializeField] private string outputRoot = &quot;Builds&quot;;
    [SerializeField] private bool developmentBuild = true;

    public string OutputRoot =&amp;gt; outputRoot;
    public bool DevelopmentBuild =&amp;gt; developmentBuild;

    public void Apply(string newRoot, bool newDevelopmentBuild)
    {
        if (outputRoot == newRoot &amp;amp;&amp;amp;
            developmentBuild == newDevelopmentBuild)
            return;

        outputRoot = newRoot;
        developmentBuild = newDevelopmentBuild;
        Save(true);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;instance&lt;/code&gt;에 처음 접근하면 Unity가 싱글턴을 생성하거나 기존 파일을 읽는다. &lt;code&gt;Save(true)&lt;/code&gt;의 &lt;code&gt;true&lt;/code&gt;는 텍스트 저장을 뜻하므로 버전 관리에서 변경 내용을 검토하기도 쉽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. EditorWindow와 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;창의 필드를 직접 저장하지 말고, 화면은 입력만 받고 실제 상태는 싱글턴이 소유하게 만든다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;using UnityEditor;
using UnityEngine;

public sealed class BuildDashboardWindow : EditorWindow
{
    private string outputRoot;
    private bool developmentBuild;

    [MenuItem(&quot;Tools/Build Dashboard&quot;)]
    private static void Open()
        =&amp;gt; GetWindow&amp;lt;BuildDashboardWindow&amp;gt;(&quot;Build Dashboard&quot;);

    private void OnEnable()
    {
        var settings = BuildDashboardSettings.instance;
        outputRoot = settings.OutputRoot;
        developmentBuild = settings.DevelopmentBuild;
    }

    private void OnGUI()
    {
        EditorGUI.BeginChangeCheck();

        outputRoot = EditorGUILayout.TextField(
            &quot;Output Root&quot;, outputRoot);
        developmentBuild = EditorGUILayout.Toggle(
            &quot;Development Build&quot;, developmentBuild);

        if (EditorGUI.EndChangeCheck())
        {
            BuildDashboardSettings.instance.Apply(
                outputRoot, developmentBuild);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값이 실제로 바뀌었을 때만 저장하므로 &lt;code&gt;OnGUI&lt;/code&gt;가 반복 호출되어도 불필요한 디스크 쓰기가 발생하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 저장 위치를 목적에 맞게 고르기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ProjectFolder&lt;/code&gt;는 현재 프로젝트 기준 경로다. 팀 공통 빌드 규칙처럼 저장소에 포함할 값에 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PreferencesFolder&lt;/code&gt;는 Unity 환경설정 폴더 기준이다. 창 크기, 개인 출력 경로처럼 다른 팀원에게 공유하면 안 되는 값에 사용한다. 프로젝트별 값과 개인별 값을 하나의 싱글턴에 섞지 않는 것이 핵심이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 재컴파일과 재시작을 따로 검증하기&lt;/h2&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;값을 변경하고 스크립트를 재컴파일한다.&lt;/li&gt;
&lt;li&gt;창을 다시 열어 값이 유지되는지 확인한다.&lt;/li&gt;
&lt;li&gt;Unity를 완전히 종료한 뒤 프로젝트를 다시 연다.&lt;/li&gt;
&lt;li&gt;설정 파일을 삭제했을 때 기본값으로 복구되는지 확인한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재컴파일만 통과하고 재시작에서 실패한다면 &lt;code&gt;FilePathAttribute&lt;/code&gt; 또는 &lt;code&gt;Save&lt;/code&gt; 호출이 빠졌을 가능성이 크다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Save를 OnValidate에서 호출하지 않기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity는 &lt;code&gt;Save&lt;/code&gt;를 &lt;code&gt;OnValidate&lt;/code&gt;에서 호출하지 말라고 안내한다. 파일을 읽는 도중 다시 저장을 시도해 오류가 발생할 수 있기 때문이다. 저장은 버튼, 입력 완료, 명시적인 &lt;code&gt;Apply&lt;/code&gt; 메서드처럼 변경 시점이 분명한 경로에서 실행한다.&lt;br /&gt;출처: &lt;a href=&quot;https://docs.unity3d.com/6000.0/Documentation/ScriptReference/ScriptableSingleton_1.Save.html&quot;&gt;Unity ScriptableSingleton Save API&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이 코드는 &lt;code&gt;UnityEditor&lt;/code&gt; API를 사용하므로 반드시 Editor 전용 어셈블리나 &lt;code&gt;Editor&lt;/code&gt; 폴더에 둬야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;저장 방식&lt;/th&gt;
&lt;th style=&quot;text-align: right;&quot;&gt;재컴파일 유지&lt;/th&gt;
&lt;th style=&quot;text-align: right;&quot;&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;static 필드&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;조건부&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;불가&lt;/td&gt;
&lt;td&gt;임시 캐시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EditorPrefs&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td&gt;사용자 개인 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScriptableSingleton + ProjectFolder&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td&gt;프로젝트 공통 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScriptableSingleton + PreferencesFolder&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;text-align: right;&quot;&gt;가능&lt;/td&gt;
&lt;td&gt;에디터 개인 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터 도구의 상태가 자꾸 초기화된다면 창 자체에 값을 붙잡아 두지 말자. 상태 소유권을 &lt;code&gt;ScriptableSingleton&lt;/code&gt;으로 분리하고, 공유 범위에 맞는 저장 위치와 명시적인 저장 시점을 선택하면 재컴파일과 재시작 모두에서 예측 가능한 도구를 만들 수 있다.&lt;/p&gt;</description>
      <category>Unity&amp;middot;Unreal 실전</category>
      <category>BuildTools</category>
      <category>csharp</category>
      <category>DomainReload</category>
      <category>EditorScripting</category>
      <category>EditorWindow</category>
      <category>FilePathAttribute</category>
      <category>ScriptableSingleton</category>
      <category>unity6</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/70</guid>
      <comments>https://kr-gamedev.tistory.com/70#entry70comment</comments>
      <pubDate>Thu, 16 Jul 2026 09:06:33 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code, Cursor, Copilot을 갈아타도 작업이 이어지는 Unity 인수인계 규칙 5가지</title>
      <link>https://kr-gamedev.tistory.com/69</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/xLxe1/dJMcah6hstF/AAAAAAAAAAAAAAAAAAAAAAR7yJ0Ii1bNHABJtC9ua52nLkpCFD0Nup9TxC97t2lV/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=%2BfzjPf%2F1BRaDnxeSMdE7l0Lw3EE%3D&quot; alt=&quot;AI 보조 게임 개발 개념도 (AI 생성)&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code로 구조를 고치고, Cursor에서 코드를 탐색한 뒤, Copilot으로 반복 구현을 채우는 식의 혼합 워크플로우가 늘고 있다. 문제는 도구마다 이전 대화와 판단 근거를 공유하지 않는다는 점이다. 같은 설명을 반복하거나, 이미 폐기한 설계를 다른 도구가 다시 제안하기도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 긴 채팅 기록을 복사하는 것이 아니다. 목표, 수정 범위, 검증 방법을 저장소 안의 작은 작업 패킷으로 만드는 것이다. 다음 다섯 규칙을 적용하면 어느 도구를 열어도 같은 지점에서 작업을 이어갈 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 목표 한 줄과 변경 금지선을 먼저 고정한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 규칙은 작업 목표를 결과 형태로 적는 것이다. &amp;ldquo;인벤토리를 개선해 줘&amp;rdquo;보다 &amp;ldquo;아이템 이동 후 슬롯 중복이 없고 기존 저장 데이터를 읽을 것&amp;rdquo;이 훨씬 강한 지시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 규칙은 수정 가능한 경계를 명시하는 것이다. 예를 들어 &lt;code&gt;Inventory&lt;/code&gt; 폴더와 테스트만 허용하고 저장 포맷, 공개 API, 프리팹은 변경하지 못하게 한다. AI가 주변 구조까지 정리하다가 변경량을 키우는 일을 막아 준다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public readonly record struct InventoryTaskContract(
    int MaxSlotCount,
    bool PreserveSaveFormat,
    bool AllowPrefabChanges);

public static class InventoryTask
{
    public static readonly InventoryTaskContract Contract = new(
        MaxSlotCount: 40,
        PreserveSaveFormat: true,
        AllowPrefabChanges: false);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 계약은 런타임 설정이라기보다 사람과 AI가 함께 보는 기준점이다. 프로젝트가 최신 C# 문법을 지원하지 않는다면 동일한 내용을 일반 &lt;code&gt;struct&lt;/code&gt;나 문서로 표현해도 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 설명 대신 실행 가능한 완료 조건을 건넨다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째 규칙은 완료 조건을 테스트로 만드는 것이다. &amp;ldquo;정상 동작해야 한다&amp;rdquo;는 도구마다 해석이 다르지만, 실패하는 테스트는 해석의 여지가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 번째 규칙은 테스트 이름에 요구사항을 그대로 남기는 것이다. 새 도구가 대화 없이 테스트 목록만 읽어도 의도를 복구할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;using NUnit.Framework;

public sealed class InventoryTests
{
    [Test]
    public void MoveItem_DestinationOccupied_DoesNotDuplicateItem()
    {
        var inventory = new Inventory(40);
        inventory.Add(0, new Item(&quot;Potion&quot;));
        inventory.Add(1, new Item(&quot;Sword&quot;));

        inventory.Move(0, 1);

        Assert.That(inventory.Count(&quot;Potion&quot;), Is.EqualTo(1));
        Assert.That(inventory.Count(&quot;Sword&quot;), Is.EqualTo(1));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게는 &amp;ldquo;이 테스트를 통과시켜라&amp;rdquo;만 지시하지 말고 기존 테스트 유지, 컴파일 성공, 새 경고 금지까지 함께 요구한다. 그러면 구현만 바꾸고 회귀 문제를 남기는 결과를 줄일 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 마지막에는 대화 요약이 아니라 변경 증거를 남긴다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯 번째 규칙은 도구를 바꾸기 전에 인수인계 스냅샷을 작성하는 것이다. 내용은 변경 파일, 결정한 설계, 실행한 검증, 남은 실패 네 가지면 충분하다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;목표: 슬롯 이동 시 아이템 중복 방지
변경: Inventory.Move, InventoryTests
결정: 점유 슬롯이면 두 아이템을 교환
검증: EditMode 테스트 18개 통과
남은 작업: 저장 데이터 버전 2 회귀 테스트 추가
금지: SaveData 구조와 프리팹 수정
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 도구에는 전체 채팅 대신 이 스냅샷과 현재 diff를 먼저 읽게 한다. 이후 &amp;ldquo;구현하지 말고 계약 위반과 누락된 테스트만 찾아라&amp;rdquo;라고 요청하면 독립 검토자로도 활용할 수 있다. 작성 도구와 검토 도구를 분리하면 같은 가정을 반복하는 위험도 낮아진다.&lt;/p&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;수정 가능 범위와 변경 금지선을 함께 고정한다.&lt;/li&gt;
&lt;li&gt;완료 조건은 실행 가능한 테스트와 검증 명령으로 남긴다.&lt;/li&gt;
&lt;li&gt;도구 전환 시 채팅보다 diff, 테스트 결과, 남은 실패를 전달한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>AI 보조 게임 개발</category>
      <category>AIworkflow</category>
      <category>claudecode</category>
      <category>codereview</category>
      <category>CURSOR</category>
      <category>GameDevelopment</category>
      <category>githubcopilot</category>
      <category>TestAutomation</category>
      <category>unity</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/69</guid>
      <comments>https://kr-gamedev.tistory.com/69#entry69comment</comments>
      <pubDate>Wed, 15 Jul 2026 09:09:51 +0900</pubDate>
    </item>
    <item>
      <title>4컷 만화: 5분이면 되는데</title>
      <link>https://kr-gamedev.tistory.com/68</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/bLj7Zj/dJMcafHmR91/AAAAAAAAAAAAAAAAAAAAAMOYnY5_sHXhAA-GhEg9aenFmqEjOlhDReJFlhDmXM93/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=yJIKWS831YeC80vtIiB2wdomTFc%3D&quot; alt=&quot;4컷 만화: 5분이면 되는데&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 오타 하나만요  &quot; &amp;mdash; 세상에서 가장 무서운 부탁입니다. 분명 한 글자만 고치면 되는 일이었는데, 그 한 글자가 함수를 부르고, 함수가 파일을 부르고, 파일이 2019년의 git 로그를 부릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 왜 매번 &quot;5분이면 돼&quot;에 속을까요? 아마 그게 사실이길 진심으로 바라기 때문일 겁니다. 코드는 늘 우리 예상보다 한 겹 더 얽혀 있고, 그 얽힘을 풀다 보면 어느새 창밖이 캄캄합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 텅 빈 사무실에서 마지막 커밋을 올릴 때, &quot;17개 파일 변경&quot;이라는 숫자를 보며 피식 웃게 되는 이유는 &amp;mdash; 결국 그 실타래를 끝까지 따라간 게 나였기 때문이겠죠. 오늘도 5분을 믿었던 모든 개발자들, 고생 많았습니다. ☕&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>4컷 만화&amp;middot;개발자 일상</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/68</guid>
      <comments>https://kr-gamedev.tistory.com/68#entry68comment</comments>
      <pubDate>Tue, 14 Jul 2026 09:35:43 +0900</pubDate>
    </item>
    <item>
      <title>C++23 std::to_underlying와 enum class로 게임 상태 플래그 제대로 쓰는 법</title>
      <link>https://kr-gamedev.tistory.com/67</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/93Xwn/dJMcaf8jCJV/AAAAAAAAAAAAAAAAAAAAABrAcCLETP1yA_9mwEy81XfXQxbSNPkkx8znJAiRW6fC/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=j8ecMhLMYY0QHrwwlw6Ka%2BzwwW8%3D&quot; alt=&quot;C#&amp;middot;C++ 모던 개념도 (AI 생성)&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기절, 침묵, 무적처럼 동시에 여러 개가 적용되는 게임 상태는 비트 플래그로 표현하기 좋다. 문제는 &lt;code&gt;enum class&lt;/code&gt;를 도입한 뒤다. 타입 안전성은 좋아지지만 비트 연산을 할 때마다 &lt;code&gt;static_cast&lt;/code&gt;가 반복되고, 어느 순간 상태 변경 규칙까지 캐릭터 클래스 곳곳에 흩어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C++23의 &lt;code&gt;std::to_underlying&lt;/code&gt;을 사용하면 열거형을 기반 정수형으로 명확하게 변환할 수 있다. 여기에 작은 값 객체를 더하면 비트 연산뿐 아니라 유효성 검사와 직렬화 경계까지 한곳에서 관리할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 숫자가 아니라 상태로 선언한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 값을 정수 상수로 선언하면 서로 다른 종류의 플래그도 실수로 섞을 수 있다. &lt;code&gt;enum class&lt;/code&gt;는 이런 암시적 변환을 차단한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#include &amp;lt;cstdint&amp;gt;
#include &amp;lt;utility&amp;gt;

enum class Status : std::uint32_t
{
    None       = 0,
    Stun       = 1u &amp;lt;&amp;lt; 0,
    Silence    = 1u &amp;lt;&amp;lt; 1,
    Invincible = 1u &amp;lt;&amp;lt; 2
};

constexpr Status operator|(Status lhs, Status rhs) noexcept
{
    return static_cast&amp;lt;Status&amp;gt;(
        std::to_underlying(lhs) | std::to_underlying(rhs));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;std::to_underlying&lt;/code&gt;은 C++23 표준 라이브러리에 추가된 함수다. 변환 의도가 코드에 드러나므로 기반 타입을 직접 적는 &lt;code&gt;static_cast&amp;lt;std::uint32_t&amp;gt;&lt;/code&gt;보다 열거형 변경에 강하다. 세부 명세는 &lt;a href=&quot;https://en.cppreference.com/w/cpp/utility/to_underlying.html&quot;&gt;cppreference의 std::to_underlying 문서&lt;/a&gt;에서 확인할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 비트 연산을 호출부에 노출하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 로직에서 매번 마스크를 조합하면 조건식이 읽기 어려워진다. 상태 보관과 연산을 전담하는 &lt;code&gt;StatusSet&lt;/code&gt;으로 책임을 모아 보자.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;class StatusSet
{
public:
    constexpr bool HasAll(Status flags) const noexcept
    {
        const auto value = std::to_underlying(bits_);
        const auto mask = std::to_underlying(flags);
        return (value &amp;amp; mask) == mask;
    }

    constexpr bool HasAny(Status flags) const noexcept
    {
        return (std::to_underlying(bits_)
              &amp;amp; std::to_underlying(flags)) != 0;
    }

    constexpr void Add(Status flags) noexcept
    {
        bits_ = bits_ | flags;
    }

    constexpr void Remove(Status flags) noexcept
    {
        bits_ = static_cast&amp;lt;Status&amp;gt;(
            std::to_underlying(bits_) &amp;amp; ~std::to_underlying(flags));
    }

private:
    Status bits_ = Status::None;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;HasAll&lt;/code&gt;과 &lt;code&gt;HasAny&lt;/code&gt;를 구분한 점이 중요하다. 여러 플래그가 전달됐을 때 하나만 있어도 되는지, 전부 있어야 하는지가 함수 이름에 나타난다. 애매한 &lt;code&gt;HasStatus&lt;/code&gt; 하나로 두 의미를 처리하면 전투 규칙 버그가 생기기 쉽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 캐릭터는 비트 연산보다 규칙을 말한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 컨테이너를 사용하면 호출부는 구현 방식 대신 게임 규칙을 표현한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;class Character
{
public:
    bool CanMove() const noexcept
    {
        return !statuses_.HasAny(Status::Stun);
    }

    bool CanCastSkill() const noexcept
    {
        return !statuses_.HasAny(Status::Stun | Status::Silence);
    }

    bool CanReceiveDamage() const noexcept
    {
        return !statuses_.HasAny(Status::Invincible);
    }

private:
    StatusSet statuses_;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 단일 책임 원칙에도 잘 맞는다. &lt;code&gt;Character&lt;/code&gt;는 행동 가능 여부를 결정하고, &lt;code&gt;StatusSet&lt;/code&gt;은 플래그 표현과 조작을 담당한다. 나중에 상태 저장 방식을 바꿔도 전투 코드의 조건식까지 함께 수정할 필요가 줄어든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 저장과 네트워크 입력은 마스크로 방어한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세이브 데이터나 서버 패킷에서 받은 값에는 현재 버전이 모르는 비트가 포함될 수 있다. 원시 값을 그대로 캐스팅하지 말고 허용된 비트만 남겨야 한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;constexpr std::uint32_t KnownStatusMask =
    std::to_underlying(
        Status::Stun | Status::Silence | Status::Invincible);

Status DecodeStatus(std::uint32_t raw) noexcept
{
    return static_cast&amp;lt;Status&amp;gt;(raw &amp;amp; KnownStatusMask);
}
&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;비트 플래그의 핵심은 압축이 아니라 책임 분리다. &lt;code&gt;std::to_underlying&lt;/code&gt;으로 변환 의도를 분명히 하고, 실제 연산은 전용 타입 안에 가두면 상태 코드는 더 안전하고 읽기 쉬운 게임 규칙이 된다.&lt;/p&gt;</description>
      <category>C#&amp;middot;C++ 모던</category>
      <category>bit-flags</category>
      <category>C++23</category>
      <category>enum-class</category>
      <category>game-programming</category>
      <category>Solid</category>
      <category>state-design</category>
      <category>std::to_underlying</category>
      <category>Unreal-C++</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/67</guid>
      <comments>https://kr-gamedev.tistory.com/67#entry67comment</comments>
      <pubDate>Tue, 14 Jul 2026 09:08:10 +0900</pubDate>
    </item>
    <item>
      <title>Unity 6 Awaitable 실전: 코루틴 없이 프레임 대기와 백그라운드 연산 처리하기</title>
      <link>https://kr-gamedev.tistory.com/66</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Unity에서 시간차 연출이나 비동기 로딩을 구현할 때 가장 먼저 떠오르는 도구는 코루틴이다. 하지만 작업이 여러 단계로 이어지면 &lt;code&gt;yield return&lt;/code&gt;이 중첩되고, 반환값과 예외를 다루기도 불편해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity의 &lt;code&gt;Awaitable&lt;/code&gt;은 이런 흐름을 C#의 &lt;code&gt;async&lt;/code&gt;와 &lt;code&gt;await&lt;/code&gt; 문법으로 표현한다. 다음 프레임, 물리 프레임, 지정 시간뿐 아니라 메인 스레드와 백그라운드 스레드 전환도 명시할 수 있다. 특히 로딩 화면, 절차적 데이터 생성, 복잡한 UI 연출처럼 순서가 중요한 코드에서 효과가 크다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코루틴을 프레임 단위 Awaitable로 바꾸기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드는 오브젝트가 파괴되거나 플레이 모드가 종료될 때 자동으로 중단되는 페이드 처리다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;using System.Threading;
using UnityEngine;

public sealed class CanvasFader : MonoBehaviour
{
    [SerializeField] private CanvasGroup canvasGroup;

    public async Awaitable FadeToAsync(
        float target,
        float duration,
        CancellationToken token)
    {
        float start = canvasGroup.alpha;
        float elapsed = 0f;

        while (elapsed &amp;lt; duration)
        {
            token.ThrowIfCancellationRequested();

            elapsed += Time.unscaledDeltaTime;
            float t = Mathf.Clamp01(elapsed / duration);
            canvasGroup.alpha = Mathf.Lerp(start, target, t);

            await Awaitable.NextFrameAsync(token);
        }

        canvasGroup.alpha = target;
    }

    private async Awaitable Start()
    {
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            destroyCancellationToken,
            Application.exitCancellationToken);

        try
        {
            await FadeToAsync(1f, 0.5f, linkedCts.Token);
        }
        catch (OperationCanceledException)
        {
            // 종료 과정의 정상적인 취소
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;destroyCancellationToken&lt;/code&gt;을 연결하면 씬 전환으로 컴포넌트가 사라진 뒤에도 비동기 루프가 남는 문제를 막을 수 있다. &lt;code&gt;Application.exitCancellationToken&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;맵 높이 데이터나 대량의 룩업 테이블처럼 Unity API를 사용하지 않는 계산은 워커 스레드에서 처리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;private static async Awaitable&amp;lt;int[]&amp;gt; BuildNoiseAsync(
    int count,
    CancellationToken token)
{
    await Awaitable.BackgroundThreadAsync();

    int[] values = new int[count];
    bool canceled = false;

    for (int i = 0; i &amp;lt; values.Length; i++)
    {
        if (token.IsCancellationRequested)
        {
            canceled = true;
            break;
        }

        values[i] = Hash(i) &amp;amp; 255;
    }

    await Awaitable.MainThreadAsync();

    if (canceled)
        token.ThrowIfCancellationRequested();

    return values;
}

private static int Hash(int value)
{
    unchecked
    {
        value ^= value &amp;gt;&amp;gt; 16;
        value *= 0x45d9f3b;
        value ^= value &amp;gt;&amp;gt; 16;
        return value;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 계산이 끝난 뒤 &lt;code&gt;MainThreadAsync&lt;/code&gt;로 복귀하는 것이다. &lt;code&gt;GameObject&lt;/code&gt;, &lt;code&gt;Transform&lt;/code&gt;, &lt;code&gt;Texture2D&lt;/code&gt; 같은 Unity 객체는 백그라운드 스레드에서 생성하거나 변경하면 안 된다. 워커 스레드에서는 순수 데이터만 만들고, 실제 게임 오브젝트 반영은 메인 스레드에서 수행한다.&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;Awaitable&lt;/code&gt;은 단순한 시간 대기 외에도 Unity 실행 루프의 특정 지점을 기다릴 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private async Awaitable CaptureAsync(CancellationToken token)
{
    await Awaitable.EndOfFrameAsync(token);

    Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture();
    await SavePixelsAsync(shot.GetRawTextureData(), token);

    Destroy(shot);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NextFrameAsync&lt;/code&gt;는 다음 프레임, &lt;code&gt;FixedUpdateAsync&lt;/code&gt;는 다음 물리 갱신, &lt;code&gt;EndOfFrameAsync&lt;/code&gt;는 현재 프레임의 렌더링 작업 이후에 재개된다. 필요한 시점이 코드에 직접 드러나므로 &lt;code&gt;WaitForEndOfFrame&lt;/code&gt; 객체를 따로 관리할 필요도 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, &lt;code&gt;EndOfFrameAsync&lt;/code&gt;는 메인 스레드 전용이며 에디터의 편집 모드나 배치 실행에서는 완료되지 않을 수 있다. 자동화 테스트에서는 실행 환경을 구분해야 한다. 세부 동작은 &lt;a href=&quot;https://docs.unity3d.com/kr/current/Manual/async-awaitable-introduction.html&quot;&gt;Unity Awaitable 공식 문서&lt;/a&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;Awaitable&lt;/code&gt;은 모든 코루틴을 무조건 대체하는 도구가 아니다. 인스펙터에서 실행 흐름을 관찰해야 하는 연출이나 기존 코루틴 기반 시스템은 그대로 두는 편이 단순할 수 있다. 반대로 반환값, 취소, 예외 처리, 스레드 전환이 필요한 작업은 &lt;code&gt;Awaitable&lt;/code&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;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity &lt;code&gt;Awaitable&lt;/code&gt;의 실전 핵심은 세 가지다. 프레임 대기는 &lt;code&gt;NextFrameAsync&lt;/code&gt;, 수명 관리는 &lt;code&gt;CancellationToken&lt;/code&gt;, 무거운 순수 계산은 &lt;code&gt;BackgroundThreadAsync&lt;/code&gt;로 분리한다. 그리고 Unity 객체를 만지기 전에는 반드시 메인 스레드로 돌아온다. 이 경계만 명확히 지키면 코루틴보다 순서가 잘 보이고, 취소와 오류에도 강한 비동기 게임 로직을 만들 수 있다.&lt;/p&gt;</description>
      <category>Unity&amp;middot;Unreal 실전</category>
      <category>asyncawait</category>
      <category>awaitable</category>
      <category>BackgroundThread</category>
      <category>CancellationToken</category>
      <category>csharp</category>
      <category>mainThread</category>
      <category>Performance</category>
      <category>unity6</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/66</guid>
      <comments>https://kr-gamedev.tistory.com/66#entry66comment</comments>
      <pubDate>Mon, 13 Jul 2026 09:05:25 +0900</pubDate>
    </item>
    <item>
      <title>4컷 만화: 범인은 바로 나</title>
      <link>https://kr-gamedev.tistory.com/65</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dna/b7LAsU/dJMcacDROC6/AAAAAAAAAAAAAAAAAAAAAJC4lAhH6kmoX790bxtsBzZgk5nUDjAor45dTyzo564x/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1785509999&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=G6D2j1iMxJRirHfoGpliLwarytA%3D&quot; alt=&quot;4컷 만화: 범인은 바로 나&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이해 안 되는 코드를 만나면 우리는 본능적으로 범인을 찾는다. &quot;대체 누가 이렇게 짰어?&quot; 그리고 &lt;code&gt;git blame&lt;/code&gt;을 치는 순간, 우리는 종종 진실과 마주한다. 범인은 언제나 나였다는 것을.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇 달 전의 나는 분명 그때의 사정이 있었을 거다. 마감이 급했거나, 임시로 막아둔 코드가 그대로 남았거나, &quot;일단 돌아가니까&quot; 하고 넘어갔거나. 문제는 그 사정을 지금의 내가 하나도 기억 못 한다는 거지만.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 오늘도 조용히 주석을 하나 더 단다. &lt;code&gt;// TODO: 이거 왜 이런지 나중의 내가 이해할 것&lt;/code&gt;. 물론 나중의 나도 이해 못 하겠지만, 그래도 미래의 나에게 보내는 작은 편지 같은 거니까. 우리는 그렇게 과거의 나와 미래의 나 사이에서, 오늘도 코드를 짠다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>4컷 만화&amp;middot;개발자 일상</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/65</guid>
      <comments>https://kr-gamedev.tistory.com/65#entry65comment</comments>
      <pubDate>Sun, 12 Jul 2026 09:35:05 +0900</pubDate>
    </item>
    <item>
      <title>C++23 std::optional 모나딕 연산으로 게임 조회 로직의 분기와 복사 줄이기</title>
      <link>https://kr-gamedev.tistory.com/64</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;게임 서버와 엔진 코드는 끊임없이 무언가를 조회한다. 플레이어를 찾고, 장착 아이템을 확인하고, 스킬 데이터를 가져온 뒤 재사용 대기시간을 계산한다. 이 과정 중 하나라도 실패하면 처리를 중단해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 &lt;code&gt;std::optional&lt;/code&gt;을 반환해도 호출부에 &lt;code&gt;if&lt;/code&gt;가 연속으로 쌓였다. C++23은 &lt;code&gt;and_then&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;or_else&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;cpp&quot;&gt;&lt;code&gt;std::optional&amp;lt;int&amp;gt; GetEquippedWeaponPower(
    PlayerId playerId,
    const World&amp;amp; world,
    const ItemDatabase&amp;amp; items)
{
    const Player* player = world.FindPlayer(playerId);
    if (player == nullptr)
        return std::nullopt;

    if (!player-&amp;gt;equippedWeaponId.has_value())
        return std::nullopt;

    const ItemData* item = items.Find(*player-&amp;gt;equippedWeaponId);
    if (item == nullptr)
        return std::nullopt;

    return item-&amp;gt;attackPower;
}
&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;and_then으로 실패 가능한 조회 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;and_then&lt;/code&gt;은 현재 &lt;code&gt;optional&lt;/code&gt;에 값이 있을 때만 함수를 호출한다. 호출 함수 역시 &lt;code&gt;optional&lt;/code&gt;을 반환해야 하므로 실패 가능한 다음 작업을 자연스럽게 연결할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;std::optional&amp;lt;std::reference_wrapper&amp;lt;const Player&amp;gt;&amp;gt;
FindPlayerRef(PlayerId id, const World&amp;amp; world)
{
    if (const Player* player = world.FindPlayer(id))
        return std::cref(*player);

    return std::nullopt;
}

std::optional&amp;lt;std::reference_wrapper&amp;lt;const ItemData&amp;gt;&amp;gt;
FindItemRef(ItemId id, const ItemDatabase&amp;amp; items)
{
    if (const ItemData* item = items.Find(id))
        return std::cref(*item);

    return std::nullopt;
}

std::optional&amp;lt;int&amp;gt; GetEquippedWeaponPower(
    PlayerId playerId,
    const World&amp;amp; world,
    const ItemDatabase&amp;amp; items)
{
    return FindPlayerRef(playerId, world)
        .and_then([](const Player&amp;amp; player) {
            return player.equippedWeaponId;
        })
        .and_then([&amp;amp;items](ItemId itemId) {
            return FindItemRef(itemId, items);
        })
        .transform([](const ItemData&amp;amp; item) {
            return item.attackPower;
        });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어, 무기 ID, 아이템 데이터 중 하나라도 없으면 이후 람다는 실행되지 않는다. 마지막 &lt;code&gt;transform&lt;/code&gt;은 정상 값을 공격력으로 변환하고 결과를 다시 &lt;code&gt;optional&amp;lt;int&amp;gt;&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;code&gt;std::optional&amp;lt;T&amp;amp;&amp;gt;&lt;/code&gt;는 사용할 수 없다. 큰 &lt;code&gt;Player&lt;/code&gt;나 &lt;code&gt;ItemData&lt;/code&gt;를 &lt;code&gt;optional&lt;/code&gt;에 직접 담으면 객체가 복사될 수 있다. 조회 결과를 소유하지 않는 코드라면 포인터 또는 &lt;code&gt;std::reference_wrapper&lt;/code&gt;를 사용하는 편이 명확하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;optional&amp;lt;reference_wrapper&amp;lt;const T&amp;gt;&amp;gt;&lt;/code&gt; 자체는 대상 객체를 복사하거나 힙에 할당하지 않는다. 다만 참조 대상의 수명은 보장하지 않으므로 컨테이너 재할당이나 엔티티 제거가 발생할 수 있는 프레임 경계를 넘어 저장해서는 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다 인자에서 &lt;code&gt;reference_wrapper&lt;/code&gt;가 &lt;code&gt;const T&amp;amp;&lt;/code&gt;로 변환되기 때문에 파이프라인도 비교적 간결하게 유지된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;transform과 or_else의 역할 구분하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;transform&lt;/code&gt;은 실패하지 않는 값 변환에 적합하다. 공격력 보정처럼 입력이 존재하면 반드시 결과를 만들 수 있는 단계에 사용한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;int finalPower = GetEquippedWeaponPower(id, world, items)
    .transform([buff](int power) {
        return static_cast&amp;lt;int&amp;gt;(power * buff);
    })
    .or_else([id] {
        LogMissingWeapon(id);
        return std::optional&amp;lt;int&amp;gt;{0};
    })
    .value();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;or_else&lt;/code&gt;는 값이 없을 때만 호출된다. 로그를 남기거나 대체 &lt;code&gt;optional&lt;/code&gt;을 반환하는 복구 지점으로 사용할 수 있다. 단순 기본값만 필요하다면 &lt;code&gt;value_or(0)&lt;/code&gt;이 더 짧다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 기준은 단계의 의미다. 실패 가능한 조회에는 &lt;code&gt;and_then&lt;/code&gt;, 실패하지 않는 변환에는 &lt;code&gt;transform&lt;/code&gt;, 실패 후 복구에는 &lt;code&gt;or_else&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;code&gt;if&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;C++23의 &lt;code&gt;std::optional&lt;/code&gt; 모나딕 연산은 연속 조회 코드에서 반복되는 분기와 조기 반환을 줄인다. &lt;code&gt;and_then&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;or_else&lt;/code&gt;의 역할을 분리하면 성공 경로가 위에서 아래로 선명하게 드러난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 게임 객체는 &lt;code&gt;reference_wrapper&lt;/code&gt;나 포인터로 참조해 복사를 피하고, 참조 수명과 컨테이너 변경 시점은 별도로 관리해야 한다. 성능을 과장하는 기능이라기보다, 실패 가능한 게임 로직을 비용 예측이 쉬운 형태로 구조화하는 C++23 도구에 가깝다.&lt;/p&gt;</description>
      <category>C#&amp;middot;C++ 모던</category>
      <category>and_then</category>
      <category>C++23</category>
      <category>ErrorHandling</category>
      <category>gameserver</category>
      <category>moderncpp</category>
      <category>or_else</category>
      <category>std::optional</category>
      <category>transform</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/64</guid>
      <comments>https://kr-gamedev.tistory.com/64#entry64comment</comments>
      <pubDate>Sun, 12 Jul 2026 09:02:46 +0900</pubDate>
    </item>
    <item>
      <title>Unreal Engine 5.7 Substrate 실전 전환: 젖은 표면 레이어와 GBuffer 비용 관리</title>
      <link>https://kr-gamedev.tistory.com/63</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Fest Stockholm에서 발표된 Unreal Engine 5.7의 중요한 변화 중 하나는 Substrate가 프로덕션 단계에 들어섰다는 점이다. Substrate는 Default Lit, Clear Coat처럼 미리 정해진 셰이딩 모델을 선택하는 대신, 물질을 나타내는 BSDF Slab을 조합해 표면을 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 비에 젖은 도로, 도장 위 먼지, 코팅된 금속처럼 여러 성질이 겹치는 재질에서 효과가 크다. 기존에는 Material Function과 Lerp를 중첩해 흉내 냈지만, Substrate에서는 위아래 물질의 관계를 그래프 자체로 표현할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Substrate가 바꾸는 재질 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Epic Games의 &lt;a href=&quot;https://www.unrealengine.com/news/highlights-from-the-unreal-fest-stockholm-opening-session&quot;&gt;Unreal Fest 발표 정리&lt;/a&gt;에 따르면 Substrate는 UE 5.7에서 기존 재질 기능과 주요 대상 플랫폼을 지원하며 신규 프로젝트에 기본 활성화된다. 기존 프로젝트는 Project Settings의 Rendering에서 직접 활성화해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 단위는 Substrate Slab BSDF다. Slab에는 Diffuse Albedo, F0, F90, Roughness, Mean Free Path처럼 물리적 의미를 가진 값이 들어간다. 여러 Slab은 다음 연산자로 결합한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Horizontal Blend: 진흙과 아스팔트처럼 두 표면을 마스크로 혼합&lt;/li&gt;
&lt;li&gt;Vertical Layer: 금속 위 투명 코팅처럼 위아래 물질을 적층&lt;/li&gt;
&lt;li&gt;Coverage Weight: 먼지나 물막이 덮는 비율을 조절&lt;/li&gt;
&lt;li&gt;Add: 물리적 에너지 보존보다 연출을 우선할 때 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;젖은 도로라면 아스팔트 Slab 위에 낮은 Roughness를 가진 물 Slab을 Vertical Layer로 올리고, 물 Slab의 Coverage Weight를 &lt;code&gt;Wetness&lt;/code&gt; 매개변수로 제어하면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;C++에서 날씨와 재질 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Substrate도 게임 코드에서는 일반 Material Instance와 동일하게 다룬다. 따라서 기존 날씨 시스템을 다시 작성할 필요 없이 동적 머티리얼의 매개변수만 갱신하면 된다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#include &quot;Materials/MaterialInstanceDynamic.h&quot;
#include &quot;Components/MeshComponent.h&quot;

void AWetRoadActor::BeginPlay()
{
    Super::BeginPlay();

    WetMaterial = RoadMesh-&amp;gt;CreateAndSetMaterialInstanceDynamic(0);
    SetWetness(0.0f);
}

void AWetRoadActor::SetWetness(const float Value)
{
    if (!WetMaterial)
        return;

    const float Wetness = FMath::Clamp(Value, 0.0f, 1.0f);
    const float CoatRoughness = FMath::Lerp(0.32f, 0.04f, Wetness);

    WetMaterial-&amp;gt;SetScalarParameterValue(TEXT(&quot;Wetness&quot;), Wetness);
    WetMaterial-&amp;gt;SetScalarParameterValue(
        TEXT(&quot;CoatRoughness&quot;), CoatRoughness);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;Wetness&lt;/code&gt;는 물 Slab의 Coverage Weight에, &lt;code&gt;CoatRoughness&lt;/code&gt;는 위쪽 Slab의 Roughness에 연결한다. 빗물 양이 증가할수록 표면을 덮는 비율과 반사 선명도가 함께 변한다. 여러 액터를 갱신해야 한다면 Material Parameter Collection을 사용해 전역 값 한 번으로 처리하는 편이 낫다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Blendable과 Adaptive GBuffer 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Substrate의 비용은 Slab 개수와 GBuffer 형식에 크게 좌우된다. Blendable GBuffer는 메모리 사용량과 실행 비용을 예측하기 쉬워 60fps 게임에 적합하다. 반면 Adaptive GBuffer는 복잡한 물질 정보를 더 많이 보존하지만 지원 하드웨어가 제한되고 셰이더 컴파일과 프레임 비용이 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재질 하나에 Slab을 계속 추가하면 조명 계산 비용도 거의 비례해 늘어난다. Horizontal Blend나 Vertical Layer의 &lt;code&gt;Use Parameter Blending&lt;/code&gt;을 활성화하면 여러 레이어를 단일 매개변수 집합으로 단순화할 수 있다. Material Editor의 Substrate Stats와 Material Count, Material Bytes Count 보기 모드도 반드시 확인해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 프로젝트의 안전한 전환 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 프로젝트를 한 번에 변환하는 방식은 피하는 것이 좋다. 먼저 복제 브랜치에서 Substrate를 활성화하고 대표 재질 5개 정도만 변환한다. 이후 PC, 콘솔, 모바일별 스크린샷과 GPU 시간을 비교한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 재질은 Substrate 활성화 후에도 호환 경로로 동작한다. 하지만 명시적으로 Substrate 노드로 변환한 재질은 Substrate가 꺼진 프로젝트에서 정상 표시되지 않을 수 있다. 공용 플러그인이나 마켓 에셋을 제작한다면 기존 재질 버전도 별도로 유지하는 것이 안전하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Substrate의 가치는 그래픽 품질만 높이는 데 있지 않다. 젖음, 먼지, 코팅처럼 반복되는 표면 규칙을 물질 레이어로 표준화해 아트와 코드의 계약을 단순하게 만든다. 다만 복잡한 Slab 그래프와 Adaptive GBuffer는 명확한 비용을 가진다. Blendable GBuffer로 시작하고, Parameter Blending과 디버그 보기로 예산을 확인하며 필요한 재질만 단계적으로 전환하는 전략이 가장 현실적이다.&lt;/p&gt;</description>
      <category>게임 산업 트렌드</category>
      <category>BSDF</category>
      <category>cplusplus</category>
      <category>GBuffer</category>
      <category>MaterialSystem</category>
      <category>rendering</category>
      <category>substrate</category>
      <category>UnrealEngine57</category>
      <category>UnrealFest</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/63</guid>
      <comments>https://kr-gamedev.tistory.com/63#entry63comment</comments>
      <pubDate>Sat, 11 Jul 2026 09:05:39 +0900</pubDate>
    </item>
    <item>
      <title>AI 코드 생성 도구로 Unity 테스트 보일러플레이트 없애기</title>
      <link>https://kr-gamedev.tistory.com/62</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경: Unity 6000.0 LTS (Unity 6), Unity Test Framework 1.4.x, C# 9&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity에서 테스트를 붙여야 한다는 건 다들 알지만, 막상 손이 안 가는 이유는 대부분 &lt;b&gt;보일러플레이트&lt;/b&gt; 때문입니다. asmdef를 만들고, 참조를 걸고, &lt;code&gt;[Test]&lt;/code&gt; 뼈대를 깎고, 실패 메시지를 다듬는 반복 작업이 정작 검증하고 싶은 로직보다 시간을 더 잡아먹습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Copilot이나 Claude 같은 AI 코드 생성 도구는 이 반복 구간을 정확히 노려주기 좋습니다. 다만 &quot;AI가 알아서 다 해준다&quot;는 건 환상이고, &lt;b&gt;실무에서는 사람이 판을 깔아주고 AI가 그 위를 빠르게 채우는&lt;/b&gt; 분업이 현실적입니다. 이 글에서는 실제로 굴러가는 코드로 그 분업 지점을 짚어봅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 테스트 대상부터 정하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 던지기 전에 검증할 로직이 있어야 합니다. 예제로 데미지 계산기를 하나 두겠습니다. 순수 로직이라 &lt;code&gt;MonoBehaviour&lt;/code&gt; 없이 EditMode에서 바로 테스트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// Assets/Scripts/Combat/DamageCalculator.cs
using UnityEngine;

namespace Game.Combat
{
    public static class DamageCalculator
    {
        /// &amp;lt;summary&amp;gt;
        /// 최종 데미지 = 기본 공격력 * (1 - 방어율), 방어율은 0~0.9로 클램프.
        /// 결과는 최소 1로 보장(0 데미지 방지).
        /// &amp;lt;/summary&amp;gt;
        public static int Calculate(int attack, float armorRate)
        {
            if (attack &amp;lt; 0)
                throw new System.ArgumentOutOfRangeException(nameof(attack));

            float clampedArmor = Mathf.Clamp(armorRate, 0f, 0.9f);
            float raw = attack * (1f - clampedArmor);
            return Mathf.Max(1, Mathf.RoundToInt(raw));
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경계 조건(방어율 클램프, 최소 데미지 1, 음수 공격력 예외)이 명확히 박혀 있습니다. 이 &quot;경계&quot;가 뒤에서 AI에게 좋은 테스트를 뽑아내는 재료가 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. AI가 채우기 좋은 틀 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은, AI에게 백지에서 &quot;테스트 짜줘&quot;라고 하지 않는 것입니다. 대신 &lt;b&gt;테스트 클래스의 골격과 asmdef만 사람이 깔고&lt;/b&gt;, 케이스 채우기를 맡깁니다. 결과 품질과 일관성이 훨씬 좋아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 어셈블리 정의부터 만듭니다. 이걸 빼먹어서 &quot;테스트가 안 잡힌다&quot;는 삽질을 하는 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// Assets/Tests/EditMode/Game.Tests.EditMode.asmdef
{
    &quot;name&quot;: &quot;Game.Tests.EditMode&quot;,
    &quot;references&quot;: [&quot;Game.Combat&quot;],
    &quot;includePlatforms&quot;: [&quot;Editor&quot;],
    &quot;precompiledReferences&quot;: [&quot;nunit.framework.dll&quot;],
    &quot;defineConstraints&quot;: [&quot;UNITY_INCLUDE_TESTS&quot;],
    &quot;optionalUnityReferences&quot;: [&quot;TestAssemblies&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 AI에게 넘길 프롬프트는 이런 식으로 구체화합니다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;&lt;code&gt;DamageCalculator.Calculate(int attack, float armorRate)&lt;/code&gt;에 대한 NUnit EditMode 테스트를 작성해줘. 방어율 클램프(0~0.9), 최소 데미지 1 보장, 음수 attack에 대한 &lt;code&gt;ArgumentOutOfRangeException&lt;/code&gt;을 각각 검증하는 케이스를 포함하고, 경계값은 &lt;code&gt;[TestCase]&lt;/code&gt;로 파라미터화해줘.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경계 조건을 프롬프트에 명시하면, AI가 뽑아주는 결과가 아래처럼 바로 쓸 만한 형태로 나옵니다(그대로 붙여서 컴파일&amp;middot;통과 확인한 코드입니다):&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// Assets/Tests/EditMode/DamageCalculatorTests.cs
using NUnit.Framework;
using Game.Combat;

namespace Game.Tests.EditMode
{
    public class DamageCalculatorTests
    {
        [TestCase(100, 0f, 100)]     // 방어 없음
        [TestCase(100, 0.5f, 50)]    // 절반 경감
        [TestCase(100, 0.9f, 10)]    // 최대 방어율
        [TestCase(100, 1.5f, 10)]    // 0.9로 클램프되는지
        public void Calculate_ReturnsExpectedDamage(int attack, float armor, int expected)
        {
            Assert.AreEqual(expected, DamageCalculator.Calculate(attack, armor));
        }

        [Test]
        public void Calculate_NeverBelowOne()
        {
            // 공격력 1에 방어율 최대여도 최소 1 보장
            Assert.AreEqual(1, DamageCalculator.Calculate(1, 0.9f));
        }

        [Test]
        public void Calculate_NegativeAttack_Throws()
        {
            Assert.Throws&amp;lt;System.ArgumentOutOfRangeException&amp;gt;(
                () =&amp;gt; DamageCalculator.Calculate(-10, 0f));
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 사람이 한 일은 &lt;b&gt;테스트할 로직의 계약을 명확히 정의한 것&lt;/b&gt;이고, AI는 그 계약을 케이스로 옮기는 반복 노동을 대신했습니다. 반대로 로직 자체가 애매하면 AI는 애매한 테스트를 뱉습니다 &amp;mdash; GIGO(garbage in, garbage out)는 여전히 유효합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 반복 작업은 에디터 툴로 굳혀두기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 케이스를 잘 채워도, &quot;새 스크립트마다 테스트 클래스 뼈대를 손으로 만드는&quot; 반복은 남습니다. 이건 AI보다 &lt;b&gt;에디터 확장&lt;/b&gt;이 더 확실합니다. 선택한 스크립트 이름을 받아 테스트 스텁을 생성하는 메뉴 아이템을 하나 두면, 그 뒤부터 AI에게 채우기만 시키면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// Assets/Editor/TestStubGenerator.cs
using System.IO;
using UnityEditor;
using UnityEngine;

namespace Game.EditorTools
{
    public static class TestStubGenerator
    {
        private const string OutputDir = &quot;Assets/Tests/EditMode&quot;;

        // 프로젝트 창에서 .cs 스크립트를 고른 뒤 우클릭 메뉴로 실행
        [MenuItem(&quot;Assets/Game Tools/Generate Test Stub&quot;, false, 20)]
        private static void Generate()
        {
            var script = Selection.activeObject as MonoScript;
            if (script == null)
            {
                Debug.LogWarning(&quot;[TestStub] MonoScript(.cs)를 하나 선택하세요.&quot;);
                return;
            }

            string typeName = script.name;
            string path = Path.Combine(OutputDir, $&quot;{typeName}Tests.cs&quot;);

            if (File.Exists(path))
            {
                Debug.LogWarning($&quot;[TestStub] 이미 존재: {path}&quot;);
                return;
            }

            Directory.CreateDirectory(OutputDir);
            File.WriteAllText(path, BuildTemplate(typeName));
            AssetDatabase.ImportAsset(path);
            Debug.Log($&quot;[TestStub] 생성 완료: {path}&quot;);
        }

        // 우클릭한 대상이 스크립트일 때만 메뉴 활성화
        [MenuItem(&quot;Assets/Game Tools/Generate Test Stub&quot;, true)]
        private static bool GenerateValidate()
        {
            return Selection.activeObject is MonoScript;
        }

        private static string BuildTemplate(string typeName)
        {
            return
$@&quot;using NUnit.Framework;

namespace Game.Tests.EditMode
{{
    public class {typeName}Tests
    {{
        [Test]
        public void {typeName}_TODO()
        {{
            Assert.Fail(&quot;&quot;테스트를 작성하세요.&quot;&quot;);
        }}
    }}
}}
&quot;;
        }
    }
}
&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; &lt;b&gt;Generate Test Stub&lt;/b&gt; (에디터 툴이 뼈대 생성)&lt;/li&gt;
&lt;li&gt;생성된 스텁을 AI에게 넘기며 &quot;이 클래스의 계약대로 케이스 채워줘&quot; (AI가 반복 노동 대행)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Window ▸ General ▸ Test Runner&lt;/b&gt;에서 EditMode 탭 실행 (사람이 최종 검증)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단계가 잘하는 도구에 맡겨져 있습니다. 뼈대&amp;middot;파일 관리는 결정론적인 에디터 코드가, 케이스 채우기는 AI가, 최종 판단은 사람이 맡습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. AI 생성 코드를 그냥 믿지 않기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 AI 도구를 쓸 때 가장 중요한 습관은 &lt;b&gt;생성된 코드를 항상 컴파일&amp;middot;실행으로 확인하는 것&lt;/b&gt;입니다. 특히 Unity API는 버전에 따라 시그니처가 바뀌거나 아예 사라진 경우가 많아, AI가 그럴듯하지만 존재하지 않는 API를 지어내는 경우(hallucination)가 흔합니다. 자주 밟는 함정:&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;code&gt;FindObjectOfType&lt;/code&gt; 대신 Unity 6의 &lt;code&gt;FindFirstObjectByType&lt;/code&gt; 등 세대 차이)&lt;/li&gt;
&lt;li&gt;에디터 전용 API를 런타임 코드에 섞음 (&lt;code&gt;UnityEditor&lt;/code&gt; 네임스페이스는 빌드에서 빠짐)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 AI가 짜준 코드는 테스트로 감싸 통과를 확인하기 전까지 &quot;초안&quot;으로 취급하는 게 안전합니다. 역설적이지만, AI가 만든 코드를 믿기 위해서라도 테스트 자동화가 먼저 깔려 있어야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 코드 생성 도구는 게임 개발의 반복 구간, 특히 테스트 보일러플레이트에서 확실한 시간 절약을 줍니다. 다만 효과를 보려면 순서가 있습니다. 검증할 로직의 계약을 사람이 명확히 정의하고, 반복적인 파일&amp;middot;뼈대 생성은 에디터 확장으로 굳히고, 그 위에서 AI에게 케이스 채우기를 맡기고, 마지막에 Test Runner로 검증하는 흐름입니다. AI를 &quot;판단하는 주체&quot;가 아니라 &quot;잘 정의된 틀을 빠르게 채우는 도구&quot;로 둘 때 생산성이 실제로 올라갑니다.&lt;/p&gt;</description>
      <category>AI 보조 게임 개발</category>
      <category>ai 보조 도구</category>
      <category>C#</category>
      <category>unity</category>
      <category>생산성</category>
      <category>에디터 확장</category>
      <category>테스트 자동화</category>
      <author>kr-gamedev</author>
      <guid isPermaLink="true">https://kr-gamedev.tistory.com/62</guid>
      <comments>https://kr-gamedev.tistory.com/62#entry62comment</comments>
      <pubDate>Fri, 10 Jul 2026 10:51:10 +0900</pubDate>
    </item>
  </channel>
</rss>