유니티 사화 리팩토링
출시까지 마친 유니티 사화를 Unity 6 프로젝트로 옮겨 코드를 처음부터 다시 설계한 개인 프로젝트입니다.
기능을 더 넣는 것이 목적이 아니라, 같은 게임을 유지보수 가능한 구조로 다시 짜는 것이 목적이었습니다. 시스템 경계를 문서로 확정하고, 그 규칙을 AI가 검사하게 만들어 384개 커밋을 쌓았습니다.

프로젝트 개요
장르 및 플랫폼
- - 3D 액션 어드벤처
- - PC(Windows)
- - Mobile(Android)
엔진 및 언어
- - Unity 6 (6000.3.11f1)
- - C#
개발 기간 및 인원
- - 2026.04 ~ 2026.09 (1차 마무리)
- - 개인 프로젝트 (1인)
- - 설계 · 구현 · 배포 전담
작업 방식
- - Claude Code 기반 AI 협업
- - 컨벤션 · 평가 규칙을 커맨드로 고정
- - 커밋 384건
배포
- - Steam 업데이트
- - Google Play 업데이트
대상
- - 출시작 유니티 사화의 전체 코드
- - 기믹 · 몬스터 등 타인 작성 코드는 이식만
리팩토링 전후 비교
출시 당시 저장소와 현재 저장소를 같은 기준으로 직접 측정한 값입니다. 게임 코드만 세었고 외부 플러그인은 제외했습니다.
Assets 루트에 흩어진 파일
▼ 100%스크립트 · 프리팹 · 터레인이 루트에 그대로 쌓여 있었습니다. 지금은 코드 · 레벨 · 콘텐츠 · 플러그인 · 데이터 5개 폴더로만 진입합니다.
파일당 평균 길이
▼ 60%전체 줄 수는 19,893 → 16,317줄로 줄었지만 파일 수는 142 → 288개로 늘었습니다. 한 파일이 한 가지만 하도록 쪼갠 결과입니다.
가장 긴 클래스
▼ 41%Before는 AnimalMaskSkill, After는 Enemy입니다. Enemy는 제가 작성하지 않은 코드라 이식만 하고 리팩토링 대상에서 제외했습니다.
300줄이 넘는 클래스
▼ 81%싱글톤 static Instance
▼ 91%남은 1개는 의존성 주입기 자신입니다. 나머지 참조는 전부 [Inject] 어트리뷰트로 대체했습니다.
플레이어 시스템 코드량
▼ 60%캐릭터 3종의 스킬 클래스를 통째로 복제하던 구조를, 상태 데이터 에셋과 하나의 StateRunner로 합쳤습니다.
Android 빌드 용량
▼ 29%Google Play Console의 500MB 제한을 넘겨 업로드가 막혔던 문제를 계측 기반으로 해결했습니다.
문서화된 시스템 규칙
▲ null%시스템마다 역할 · 진입점 · 외부 연결 · 설계 규칙을 SystemUsageGuide에 확정해 두고, 코드를 쓰기 전에 그 항목을 먼저 읽습니다.
주요 활동
시스템 경계 재정의
출시작에는 '어디까지가 한 시스템인가'라는 기준이 없어, 필요할 때마다 매니저를 하나씩 늘리는 식으로 코드가 자랐습니다. 폴더 하나를 시스템 하나로 못 박고, 시스템마다 역할·진입점·외부 연결·설계 규칙을 문서로 확정한 뒤에만 코드를 쓰도록 순서를 바꿨습니다.
- ⇒ 12개 시스템의 규칙을 SystemUsageGuide로 확정, 코드 작성 전 참조 대상으로 고정
- ⇒ Assets 루트에 흩어져 있던 71개 파일을 5개 최상위 폴더로 정리
문제
시스템의 범위를 아무도 말할 수 없었습니다.
출시작은 기능이 필요할 때마다 매니저 클래스를 하나씩 늘리는 방식으로 커졌습니다. 그 결과:
- 새 기능을 넣을 때 어느 클래스에 붙여야 하는지 판단할 근거가 없음 — 매번 즉흥적으로 결정
- 매니저끼리 서로를 직접 참조해, 한 곳을 고치면 어디까지 영향이 가는지 알 수 없음
Assets루트에 스크립트·프리팹·터레인·머티리얼이 그대로 쌓여 71개 파일이 방치됨- 같은 역할의 코드가 여러 폴더에 흩어져 있어, 고칠 곳을 찾는 데만 시간이 들었음
리팩토링을 시작하면서 가장 먼저 부딪힌 문제는 코드 품질이 아니라 “무엇을 기준으로 좋고 나쁨을 판정할 것인가” 였습니다. 기준이 없으면 리팩토링은 취향 싸움이 됩니다.
접근
규칙을 먼저 문서로 확정하고, 코드는 그 다음에 썼습니다.
시스템의 정의를 물리적인 것으로 고정했습니다.
시스템 경계 =
Assets/1.Code/Scripts/바로 아래 폴더 하나. 하위 폴더(PlayerSystem/Movement등)는 그 시스템의 모듈이지 독립 시스템이 아니다.
폴더 하나가 시스템 하나이므로, “이건 어느 시스템인가”에 대한 답이 항상 하나로 나옵니다. 그리고 시스템마다 아래 항목을 Docs/SystemUsageGuide.md에 채웠습니다.
## <시스템명>
- 상태: 확정 | 미확정
- 역할: 한 줄
- 진입점: 외부에서 이 시스템을 쓸 때 건드리는 타입
- 사용법:
- 호출 예시 한 줄
- 외부 연결:
- <쓰는 외부 시스템> — <무엇을 위해, 어떤 타입으로 어떻게 받는지>
- 설계 규칙:
- 이 시스템 코드를 작성할 때 지켜야 할 규칙핵심은 상태 항목입니다. 미확정인 항목이 하나라도 있으면 클래스 단위 평가를 진행하지 않도록 못 박았습니다. 확정되지 않은 규칙으로 코드를 판정하면 그 판정 자체가 오염되기 때문입니다.
수치를 어디 둘 것인가
시스템 경계와 함께 전역 규칙 하나를 더 세웠습니다. 매직넘버가 코드 곳곳에 박히는 것이 출시작의 고질적인 문제였기 때문입니다.
튜닝 대상이 되는 값은 둘 중 하나에만 둡니다.
- ScriptableObject로 뺀다 — 게임 규칙·밸런스 값(대시 거리, 무적 시간, 쿨타임). 바꿀 때 모든 개체가 같이 바뀌어야 하는 값
[SerializeField]로 둔다 — 그 개체의 배치·개성(순찰 반경, 문 열림 각도, 추종 오프셋)
판별 기준은 한 줄입니다. “이 값을 바꿀 때 다른 개체도 같이 바뀌길 원하나?” — 예면 SO입니다.
판단이 서지 않으면 임의로 정하지 않고 결정을 남깁니다. // 개체별 주석이 붙어 있으면 이미 판단이 끝난 값이라는 표시라, 다음에 볼 때 같은 고민을 반복하지 않습니다.
한계도 함께 적었습니다
각 항목에는 한계(다음 프로젝트에서 해결) 칸을 두고, 지금 구조가 못 하는 일을 그대로 적었습니다. 예를 들어 의존성 주입은 씬 Awake 1회뿐이라 런타임에 스폰한 프리팹이 주입을 못 받는데, 이를 감추지 않고 우회 지점(Enemy.cs:156, WisuSpawnPhase.cs:12)까지 파일·라인으로 남겼습니다.
문서가 자기 자랑이 되면 다음에 읽을 때 쓸모가 없어집니다. 못 하는 일을 적어 둬야 다음 판단이 빨라집니다.
결과
- 12개 시스템(DI · Audio · Camera · Combat · Enemy · GameState · Gimmick · Input · Language · Player · Save · Settings · UI)의 규칙이 문서로 확정됐습니다.
Assets최상위를1.Code / 2.Level / 3.Content / 4.Plug-in / 5.Data로 재편해, 루트 직속 파일이 71개에서 0개가 됐습니다.- 코드를 쓰기 전에 해당 시스템 항목을 읽고 시작하는 순서가 고정됐습니다. 새 코드가 규칙을 어겼는지는 취향이 아니라 문서 대조로 판정됩니다.
의존성 주입 시스템 자체 구현
출시작은 시스템 간 참조를 싱글톤 11개와 인스펙터 드래그로 연결하고 있었습니다. 외부 DI 프레임워크를 얹는 대신, 어트리뷰트 한 줄로 인터페이스를 주입받는 컨테이너를 직접 만들었습니다. 리플렉션 비용은 어셈블리 로드 직후 1회 캐싱으로 걷어냈습니다.
- ⇒ 싱글톤 static Instance 11개 → 1개(주입기 자신)로 축소
- ⇒ [Inject] 필드 79곳이 구체 클래스가 아닌 인터페이스로 연결됨
문제
시스템끼리 서로를 직접 붙잡고 있었습니다.
출시작의 참조 방식은 세 가지였습니다.
public static PlayerController instance;같은 싱글톤 — 코드 전체에서 11개- 인스펙터에 오브젝트를 끌어다 놓는
[SerializeField]연결 FindObjectOfType·GetComponent호출 — 177곳
각각의 문제는 이렇습니다.
- 싱글톤: 어디서든 접근 가능하니 호출 지점을 추적할 수 없고, 구체 클래스에 직접 묶여 교체가 불가능합니다.
- 인스펙터 드래그: 씬이 늘어날 때마다 같은 연결을 반복해야 하고, 하나 빠뜨려도
NullReferenceException이 나기 전까지 모릅니다. FindObjectOfType: 비용도 문제지만, 비활성 오브젝트를 못 찾는다는 점이 더 문제였습니다.
이 문제는 출시 당시 실제 사고로 이어진 적이 있습니다. 빌드 발표 당일 실행 파일에서 입력이 먹지 않던 문제의 뿌리도, 참조가 코드 밖(에셋 경로·인스펙터)에 있어서 컴파일 시점에 검증되지 않는 구조였습니다.
출시작의 싱글톤 목록 — 리팩토링 전
public static CameraController instance; // Camera/Control/CameraController.cs
public static CameraData Instance // Camera/Data/CameraData.cs
public static CoroutineManager Instance // Manager/CoroutineManager.cs
public static LanguageManager Instance; // Manager/LanguageManager.cs
public static PlatformSwitcher instance; // Manager/PlatformSwitcher.cs
public static SaveManager instance; // Manager/SaveManager.cs
public static SceneSwitcher instance; // Manager/SceneSwitcher.cs
public static TextManager Instance // Manager/TextManager.cs
public static MobileInput instance; // Player/Control/MobileInput.cs
public static PlayerController instance; // Player/Control/PlayerController/PlayerController.cs
public static MaskChange instance; // Player/Control/PlayerController/PlayerMaskChange.cs왜 직접 만들었나
Zenject·VContainer 같은 검증된 컨테이너가 있는데도 직접 만든 이유는, 이 프로젝트의 목적이 게임 출고가 아니라 설계 학습이었기 때문입니다. 남이 만든 컨테이너를 쓰면 “주입이 왜 이 시점에 일어나는가”를 끝까지 따질 일이 없습니다.
대신 직접 만드는 대가로 한계를 정확히 아는 것을 목표로 잡았습니다. 실제로 아래 「남은 한계」 항목은 전부 만들면서 부딪힌 것들입니다.
구현
사용하는 쪽 코드는 어트리뷰트 한 줄입니다.
// 필수 의존 — 없으면 LogError
[Preserve, Inject] private ISaveService _saveService;
// 선택 의존 — 없으면 LogWarning만 남기고 계속 동작
[Preserve, Inject(true)] private ISoundSettings _settings;주입기는 세 단계로 동작합니다.
1단계 — 타입 수집 (게임 시작 시 1회)
// [왜] 런타임 리플렉션 비용을 감소시키기 위해, 어셈블리 로드 이후(Awake 이전) 1회만 타입을 훑어 캐싱한다
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
public static void CollectType()
{
IEnumerable<Type> allTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.Namespace == "Refactoring" && t.IsClass && !t.IsAbstract);
// ...
}리플렉션은 느리다는 이유로 흔히 기피되지만, 느린 것은 매번 훑을 때입니다. 어셈블리 로드 직후 한 번만 훑어 static 딕셔너리에 캐싱해 두면, 이후 씬 전환에서는 캐시를 읽기만 합니다.
2단계 — 씬 인스턴스 등록 (씬마다 Awake)
씬마다 존재하는 오브젝트가 다르므로 등록은 씬 단위로 다시 합니다. Script Execution Order를 -100으로 두어 다른 Awake보다 먼저 돌게 했습니다. 여기서 두 가지를 보장합니다.
- 비활성 오브젝트도 스캔해 등록합니다.
FindObjectOfType이 못 하던 일입니다. - 인터페이스·부모 타입뿐 아니라 구체 클래스 타입으로도 등록합니다. 한 시스템 안에서 한 곳에만 연결되는 참조까지 인터페이스를 강제하면 인터페이스만 늘어나기 때문에, 시스템 내부 결합은 의도적으로 허용했습니다.
3단계 — 주입
캐싱된 필드 목록을 돌며 등록된 인스턴스를 꽂습니다. 없으면 Inject는 LogError, Inject(true)는 LogWarning을 남깁니다. 조용히 null로 남지 않는 것이 이 시스템의 핵심입니다. 출시작에서 참조 누락이 런타임에야 터졌던 문제를 여기서 막았습니다.
규칙으로 고정한 것
주입기만 만들어 두면 결국 예전처럼 쓰게 되므로, 사용 규칙을 SystemUsageGuide에 함께 못 박았습니다.
- 다른 시스템을 참조할 땐
[Inject]를 쓴다.[SerializeField]는 자기 GameObject의 컴포넌트나 인스펙터 조정값에만 쓴다. - 참조가 없으면 핵심 기능이 안 도는 의존은
[Inject], 없어도 일단 도는 의존은[Inject(true)] - 부모 클래스의
[Inject]필드는 반드시protected로 둔다.private는 자식 스캔에서 안 잡혀 경고도 없이 null로 남는다. - 씬을 넘겨 값을 유지할 때
DontDestroyOnLoad를 쓰지 않는다. 씬 사본과 함께 2개가 등록된다.static값만 남긴다.
마지막 두 줄은 둘 다 실제로 하루씩 잡아먹은 뒤 규칙으로 승격시킨 것입니다.
남은 한계
문서에 그대로 적어 둔 미해결 항목입니다.
- 주입은 씬
Awake1회뿐이라 런타임에 스폰한 프리팹은 주입을 못 받습니다. 지금은 스포너가 미리 받아 넘기는 방식으로 개별 우회 중입니다(Enemy.cs:156,WisuSpawnPhase.cs:12). - 다음 단계로는 스폰 직후 호출할
InjectTo(object)를 진입점으로 두는 방향을 검토 중입니다. - 같은 타입 구현이 2개 이상이면 첫 번째만 주입되고
LogWarning만 남습니다(AttributeInjector.cs:196). 키 기반 구분이 없는 것이 현재 설계의 명확한 결함입니다.
결과
- 싱글톤
static Instance가 11개에서 1개(주입기 자신)로 줄었습니다. - 79개 필드가
[Inject]로 연결되며, 대부분 구체 클래스가 아닌 인터페이스를 받습니다. - 참조 누락이 런타임 예외가 아니라 시작 시점 로그로 드러납니다.
플레이어 상태 시스템 재설계
출시작은 캐릭터 3종의 스킬 클래스를 통째로 복제해 2,864줄을 쓰고 있었습니다. 상태를 상속 계층으로 짜다가, 자식 클래스에 로직이 없다는 사실을 확인하고 상속 자체를 걷어냈습니다. 상태는 데이터 에셋이 되고, 실행은 StateRunner 하나가 맡습니다.
- ⇒ 플레이어 시스템 8,854줄 → 3,542줄, 스킬 추가가 코드 작성이 아닌 에셋 편집이 됨
- ⇒ 상태를 아는 쪽과 반응하는 쪽을 이벤트 채널로 분리해 상태 추가 시 기존 코드 수정 불필요
문제
캐릭터를 하나 늘리면 스킬 클래스를 통째로 복제해야 했습니다.
출시작의 플레이어 코드는 마스크(캐릭터) 종류별로 스킬 클래스를 따로 두는 구조였습니다.
| 클래스 | 줄 수 |
|---|---|
AnimalMaskSkill.cs | 1,154 |
HumanMaskSkill.cs | 1,127 |
GhostMaskSkill.cs | 583 |
| 합계 | 2,864 |
세 클래스가 하는 일은 거의 같습니다. 애니메이션을 재생하고, 특정 타이밍에 히트박스를 켜고, 이펙트를 뿌리고, 사운드를 내고, 카메라를 흔듭니다. 다른 것은 어느 타이밍에 무엇을 켜는가 하는 수치뿐이었습니다.
그런데 그 수치가 코드 안에 박혀 있으니:
- 스킬 하나의 타격 타이밍을 0.1초 옮기려면 코드를 고치고 다시 컴파일해야 함
- 캐릭터가 늘면 1,000줄짜리 클래스가 하나 더 생김
- 공통 로직을 고치면 세 파일을 똑같이 세 번 고쳐야 함
1차 시도 — 상속 계층
처음에는 교과서적인 방향으로 갔습니다. CharacterBaseState<EState, ECharacter> 추상 클래스를 두고, 상태마다 자식 클래스를 만들어 StateKey·AnimationName을 override하는 구조였습니다.
public abstract class CharacterBaseState<EState, ECharacter> where EState : Enum where ECharacter : Enum
{
public abstract bool CanReenter { get; }
public abstract EState StateKey { get; }
public abstract ECharacter CharacterType { get; }
protected abstract string AnimationName { get; }
// ...
}만들고 나서 자식 클래스들을 다시 봤더니, 자식이 override하는 것이 전부 값이었습니다. 로직은 단 한 줄도 없었습니다. 상태마다 클래스 파일을 하나씩 만들면서 얻는 것이 “상수 몇 개를 선언할 자리”뿐이었던 것입니다.
이 지점에서 방향을 뒤집었습니다.
왜 상속구조 삭제?: 자식 클래스(상태)에 로직이 존재하지 않기 때문. 자식 클래스에 존재하는 데이터를 상태 데이터 SO에 포함시킴
2차 설계 — 상태는 데이터, 실행은 하나
상태를 클래스가 아니라 ScriptableObject 에셋으로 정의했습니다.
[CreateAssetMenu(fileName = "StateData", menuName = "Data/StateData")]
public class StateData : ScriptableObject
{
[SerializeField] private PlayerStateType stateType; // 상태 정체성. 자식 클래스 override 대신 인스펙터에서 지정
[SerializeField] private bool isLooping;
[SerializeField] private float cooldown;
// 시간 구간형 — "언제부터 언제까지"
[SerializeField] private IntervalDataEntry[] inputBlock;
[SerializeField] private IntervalDataEntry[] inputBuffer;
[SerializeField] private IntervalDataEntry[] superArmor;
[SerializeField] private IntervalDataEntry[] invincible;
[SerializeField] private IntervalDataEntry[] cameraLock;
// 시점형 — "이 진행도에서 무엇을"
[SerializeField] private SkillMoveDataEntry[] skillMove;
[SerializeField] private SkillEffectDataEntry[] effect;
[SerializeField] private HitboxDataEntry[] hitbox;
[SerializeField] private AudioDataEntry[] audio;
[SerializeField] private CameraShakeDataEntry[] cameraShake;
[SerializeField] private CameraZoomDataEntry[] cameraZoom;
[SerializeField] private FinishDataEntry[] finish;
[SerializeField] private ObjectToggleDataEntry[] objectToggle;
}그리고 이 데이터를 읽어 실행하는 클래스는 StateRunner 하나뿐입니다. 상속이 없습니다.
// 역할: 상태를 관찰하고 무엇을 할지 신호(의도)를 보낸다.
public class StateRunner
{
public void Enter()
{
_tracker.Begin();
Animator.CrossFade(StateKey.ToString(), 0.1f, 0, 0f);
RaiseEvent(0f);
}
public void Update()
{
_tracker.Update();
RaiseEvent(_tracker.Progress);
}
}StateRunner는 애니메이션 진행도(0~1) 만 추적하고, 데이터에 적힌 progress를 넘어설 때마다 해당 카테고리의 이벤트를 올립니다. 초 단위가 아니라 진행도 기준이라, 애니메이션 길이를 바꿔도 타이밍 비율이 유지됩니다.
핵심은 StateRunner가 무엇을 하라고 지시하지 않는다는 점입니다. “지금 히트박스 카테고리의 시점을 지났다”는 신호만 보냅니다. 실제로 히트박스를 켜는 것은 그 신호를 구독한 쪽입니다.
신호를 받는 쪽 — 이벤트 채널
상태를 아는 쪽과 상태에 반응하는 쪽을 채널로 끊었습니다.
PlayerStateEventChannel— 상태 진행 중 발생하는 이벤트(히트박스·이펙트·사운드·카메라)PlayerStateTriggerChannel— 상태 전이를 요청하는 신호PlayerCurrentStateChannel— 현재 상태를 읽기 전용으로 노출
각 채널은 올리는 쪽(IPlayerStateEventRaiser)과 받는 쪽(IPlayerStateEventSubscriber) 인터페이스가 나뉘어 있습니다. 구독자는 자기가 필요한 카테고리만 듣습니다. 그래서 새 연출을 붙일 때 상태 시스템 코드를 건드리지 않습니다. 구독자 하나를 추가하면 끝입니다.
에디터 쪽 처리
데이터 항목이 15종이라 인스펙터가 복잡해질 수 있었습니다. 이건 커스텀 에디터로 해결했습니다.
많으면 복잡하지 않는가?: 없는 변수들을 Inspector에서 가려지도록 Editor에서 처리함. 복잡해 보이는 것은 실제로 변수들이 많기 때문이고, Editor로 원하는 것만 보이도록 구현가능
또 플레이 모드에서 SO의 progress 값을 실시간으로 고치면 다음 진입부터 반영되도록, Enter()에서 UNITY_EDITOR 한정으로 이벤트를 재정렬합니다. 게임을 껐다 켜지 않고 타이밍을 잡을 수 있어야 튜닝 속도가 나오기 때문입니다.
public void Enter()
{
#if UNITY_EDITOR
// 에디터 플레이모드에서 SO progress를 런타임 수정하면 다음 진입부터 반영되도록 재정렬한다.
SortEvents(_data);
IsLooping = _data.IsLooping;
#endif
// ...
}진행 중 배운 것
작업 일지에 남긴 문장 하나가 이 설계의 기준이 됐습니다.
처음 계획한 지점에서, 코드를 크게 안 고치고도 기능을 추가하거나 바꿀 수 있어야 한다. 딱 거기까지만 설계하는 것. 하나하나 디테일하게 설계하는 것도 일이고, 수정은 자주 일어난다. 결국 “수정하기 쉽게 짜는 것”을 기본으로 두어야 한다. 어차피 또 고칠 테니까.
실제로 SkillMove를 만들 때 “플레이어·몬스터·기믹 공용 시스템으로 키우자”는 계획이 나왔지만 취소했습니다. 기본 기능을 다 만든 뒤에 해도 늦지 않고, 지금 의도와 다른 설계를 미리 하는 것은 시간을 버리는 일이기 때문입니다.
결과
- 플레이어 시스템 코드가 8,854줄(36파일) → 3,542줄(82파일)로 줄었습니다. 파일 수가 늘어난 것은 한 파일이 한 가지만 하도록 쪼갠 결과입니다.
- 스킬을 추가할 때 클래스를 만들지 않습니다.
StateData에셋을 하나 만들고 애니메이션과 타이밍을 채우면 끝입니다. - 관련해 해결한 버그: 캐릭터 전환 시 콤보가 끊기던 문제, 대시 중 공격 스킬을 쓰면 대시 이동이 스킬에 그대로 얹히던 문제.
AI를 품질 게이트로 세운 협업 방식
AI에게 코드를 대신 짜게 하는 대신, 제가 정한 기준으로 제 코드를 검사하게 만들었습니다. 컨벤션·클래스 평가·시스템 평가·근거 조사를 각각 슬래시 커맨드로 고정하고, 사용자 편을 들지 말라는 규칙을 최우선으로 박아 넣었습니다. 1인 프로젝트에 코드 리뷰어를 붙인 셈입니다.
- ⇒ 판정 기준을 문서로 고정해 8종 커맨드로 운영, 리뷰가 취향이 아닌 대조 작업이 됨
- ⇒ 혼자 5개월 · 커밋 384건 규모의 전면 재설계를 완주
문제
혼자 하는 리팩토링에는 반대하는 사람이 없습니다.
출시작을 다시 짜기로 했을 때 가장 걱정한 것은 실력이 아니라 판단의 편향이었습니다.
- 내가 짠 코드는 내가 읽기 쉽습니다. 그래서 “읽기 쉬운가”를 스스로 판정할 수 없습니다.
- 리팩토링은 끝이 없어서, 어디서 멈출지 정해 주는 사람이 없으면 계속 고치게 됩니다.
- 팀 프로젝트에서는 리뷰어가 걸러 주던 것들이 1인 프로젝트에서는 전부 통과합니다.
AI를 쓰기로 한 이유가 여기 있습니다. 코드를 빨리 뽑기 위해서가 아니라, 반대할 사람을 만들기 위해서 였습니다.
원칙 — 사용자 편을 들지 말 것
프로젝트 CLAUDE.md 최상단에 규칙을 박았습니다.
- 사용자 편을 절대로 들지 말 것. 중립적이면서 기계적으로 근거에 의한 답변을 제시할 것.
- 파일 생성/삭제/변경은 진행하기 전 무조건 질문할 것.AI는 기본적으로 사용자에게 동의하려는 경향이 있습니다. 그 상태로는 리뷰어 역할을 못 합니다. 그래서 동의 금지를 규칙의 첫 줄로 올리고, 평가 커맨드에도 엄격하고 진지하게, 중립적으로 판단한다를 반복해 넣었습니다.
두 번째 규칙(변경 전 질문)은 주도권을 지키기 위한 것입니다. AI가 파일을 알아서 고치기 시작하면 설계 판단이 제 손을 떠납니다. 설계는 제가 하고, AI는 검사와 근거 조사를 합니다.
커맨드로 고정한 8가지
매번 프롬프트로 설명하면 기준이 흔들리므로, 반복되는 판정을 전부 슬래시 커맨드로 만들었습니다.
| 커맨드 | 역할 |
|---|---|
analysis-origin | 출시작 코드를 수집·분석해 설계 계획서를 쓴다 |
system-usage | 시스템 목록과 사용 규칙(SystemUsageGuide)을 기준으로 삼는다 |
system-evaluation | 시스템 단위 설계를 평가하고 미확정 항목을 확정받는다 |
code-evaluation | 클래스 단위 품질을 5개 축으로 판정한다 |
code-convention | C#/Unity 코드 컨벤션 위반을 표로 보고한다 |
comment-convention | 주석 컨벤션 위반을 표로 보고한다 |
evidence-research | 웹에서 근거를 찾아 출처·원문 그대로 제시한다 |
git-commit / git-issue | 변경사항을 읽어 커밋안·이슈를 만들고 승인 절차를 거친다 |
평가 기준을 추상 원칙에서 떼어냈습니다
클래스 평가에서 SOLID 같은 원칙을 점수 항목으로 두지 않았습니다. 원칙 이름으로 판정하면 결국 해석 싸움이 되기 때문입니다.
좋은 코드의 기준: 읽기 쉽고 유지보수가 싼가. 그래서 추상 원칙(SOLID 등)을 점수 항목으로 두지 않고, “유지보수가 싼가”라는 결과만 본다.
대신 관찰 가능한 냄새로 축을 짰습니다.
- 변경 파급 — 이거 하나 고치면 몇 군데가 깨지나 (본문의
new·Find·GetComponent·싱글톤 직접 호출, 깊은 체이닝, 같은 규칙 복붙) - 이해 비용 — 처음 보는 사람이 30초에 흐름을 따라가나 (인자 3개 초과, bool 플래그로 모드 가르기, 이름에 없는 사이드 이펙트)
- 교체/삭제 용이 — 기능 하나 빼거나 갈아끼울 때 깔끔히 떨어지나 (새 종류마다 기존
switch에 분기 덧대기) - 과설계 없음 — 구현 1개뿐인 인터페이스, 호출 1곳뿐인 레이어, 안 올 확장의 선구현
- 유니티 함정 —
OnEnable구독과OnDisable해제의 짝이 맞나 등
그리고 억지 평가를 금지했습니다. 냄새가 안 보이면 그 행은 해당없음으로 적습니다. 지적할 것을 억지로 찾으면 리뷰가 노이즈가 됩니다.
순서를 강제한 장치
커맨드끼리 선행 조건을 걸어 두었습니다. 클래스 평가의 0번 절차입니다.
대상 파일이 속한 시스템, 그리고 그 파일이 참조하는 외부 시스템 중
Docs/SystemUsageGuide.md에서상태: 미확정인 것이 하나라도 있으면 평가를 진행하지 않는다.미확정 N개: <시스템명 나열>. system-evaluation으로 먼저 확정할 것.만 출력하고 멈춘다.
확정되지 않은 규칙으로 코드를 판정하면 그 판정이 오염됩니다. 그래서 시스템 규칙 확정 → 클래스 평가 순서를 도구 차원에서 못 박았습니다. 제가 순서를 건너뛰고 싶어도 커맨드가 멈춥니다.
근거와 추측을 분리했습니다
AI를 쓸 때 가장 위험한 것은 그럴듯한 추측을 근거처럼 받는 것입니다. 그래서 조사 커맨드에 별도 규칙을 뒀습니다.
근거와 추측을 절대 섞지 않는다. (근거가 추측처럼, 추측이 근거처럼 보이면 안 된다) 근거가 없는 내용을 들고오면 폐기 대상이다. 빈손을 감추려 추측을 근거처럼 포장하지 말 것.
이 규칙 덕에 실제로 걸러진 사례가 있습니다. 모바일 GPU 분석에서 Shader Variant 트래킹이 Terrain 셰이더를 못 잡는 문제를 확인했을 때, 원인을 추측으로 채우는 대신 “Unity의 알려진 한계로 확인, 이 부분은 미해결” 로 보고서에 남겼습니다. 빌드 용량 분석에서도 Play Asset Delivery를 검토한 뒤 “PAD가 나쁘다는 뜻이 아니라 이번 문제의 원인과 규모에 맞지 않았다”고 판단 근거를 분리해 적었습니다.
AI가 한 일과 제가 한 일
| 담당 | |
|---|---|
| 시스템 경계·설계 방향 결정 | 제가 |
| 클래스 구조 설계 | 제가 |
| 컨벤션·평가 규칙 작성 | 제가 |
| 규칙 위반 검사, 판정표 출력 | AI |
| 근거 자료 조사(출처·원문 확보) | AI |
| 반복 작업(이름 변경, 파일 분리, 주석 정리) | AI |
| 최종 채택 여부 | 제가 |
커밋 로그에도 이 경계가 남아 있습니다. AI 평가를 적용한 커밋에는 어떤 커맨드를 돌렸고 무엇이 바뀌었는지, 그리고 핵심 기능은 건드리지 않았다는 사실을 함께 적었습니다.
[DISystem] AttributeInjector.cs 평가 적용
- ai를 통한 설계 평가(code-convention, comment-convention, system-evaluation, code-evaluation)를
적용하여 일부 주석 및 코드 수정됨(핵심 기능 수정 X)규칙은 계속 다듬었습니다
처음부터 잘 돌아간 것은 아닙니다. 커맨드 자체를 여러 번 고쳤습니다.
코드 평가 규칙 대폭 간소화— 항목이 많으면 판정이 흐려집니다더 단순하게 평가하기 위해 규칙 수정규칙 단호하게 수정— 애매한 표현을 남기면 AI가 사용자 편으로 기웁니다코드 평가 경각심 담긴 문장 추가
규칙을 만드는 것 자체가 설계 작업이었습니다. 무엇을 나쁜 코드로 볼 것인지 문장으로 못 쓰면, 그 기준은 제 머릿속에도 없는 것이었습니다.
결과
- 판정 기준이 문서로 고정돼, 코드 리뷰가 취향 논쟁이 아니라 문서 대조 작업이 됐습니다.
- 혼자서 5개월 · 384개 커밋 규모의 전면 재설계를 완주했습니다. AI 없이 같은 기간에 같은 범위를 검사까지 하며 진행하기는 어려웠을 것이라 봅니다.
- 부수 효과로, 규칙을 문장으로 쓰는 과정에서 제 판단 기준이 정리됐습니다. 이 문서화 습관은
SystemUsageGuide와 활동 일지로도 이어졌습니다.
그 외 활동
빌드 용량 감축
Play Console이 500MB 제한을 넘겼다며 업로드를 막았습니다. 추측으로 에셋을 지우는 대신 build-report-inspector로 용량 분포를 먼저 계측했고, 상위 항목이 전부 비압축 텍스처임을 확인해 일괄 압축 도구를 만들었습니다.
⇒ 550.73MB → 389.69MB (-161MB), Play Console 500MB 제한 통과
렌더링 최적화 도구 제작
출시작은 셰이더 팝인을 막으려고 씬 시작 시 카메라로 맵 전체를 훑고 있었습니다. 기준이 '화면에 보였는가'라 풀링 오브젝트를 커버하지 못했습니다. ShaderVariantCollection으로 실사용 조합만 수집하도록 바꾸고, 배칭을 깨뜨리는 중복 머티리얼을 찾는 에디터 툴을 만들었습니다.
⇒ 커버리지 기준을 '화면 노출'에서 '실제 사용된 variant'로 교체, 풀링 오브젝트까지 포함
모바일 GPU 병목 분석
Galaxy S10에서 40 FPS를 못 지켰습니다. Unity Profiler는 모바일 GPU 타이밍을 안 주므로 Arm Streamline으로 하드웨어 카운터를 실측했고, 보스 이펙트 프레임의 74.9%가 '왜곡 강도 0인 셰이더의 화면 전체 복사'였음을 찾아냈습니다.
⇒ GPU 19.2ms(프레임의 74.9%)를 차지하던 무의미한 Scene Color 복사 제거 — 시각적 변화 없음
가림 오브젝트 투시 기능
카메라와 플레이어 사이의 나무가 시야를 가리는 문제가 있었습니다. 머티리얼에 직접 값을 쓰는 방식과 Raycast 판정 두 가지를 차례로 폐기하고, 전역 셰이더 변수와 픽셀 깊이 비교로 다시 짰습니다. 왜 이 방법이 되었는지를 코드 주석에 그대로 남겼습니다.
⇒ 나무 종류·개수가 늘어도 코드 수정 없이 동작하는 구조로 정착
스토어 업데이트 배포
리팩토링은 유저가 볼 수 없는 작업이라 그대로 두면 저장소 안에서 끝납니다. 재설계한 빌드를 이미 출시된 Steam·Google Play 채널에 업데이트로 올려, 실제 유저 환경에서 도는 것까지 확인했습니다.
⇒ Steam · Google Play 업데이트 배포로 리팩토링 결과를 출시 환경에서 검증
문제
Google Play Console 업로드 시 경고가 떴습니다.
App Bundle의 일부 기능 모듈이 최대 압축 다운로드 크기(500MB)를 초과합니다. base 모듈의 크기를 줄이세요.
| 항목 | 값 |
|---|---|
| Total Size | 550.73 MB |
| Download Size (arm64-v8a) | 522.18 MB |
| Download Size (armeabi-v7a) | 520.06 MB |
계측부터
용량 문제는 “커 보이는 걸 지운다”로 접근하면 대개 헛수고로 끝납니다. 실제 분포를 보기 위해 com.unity.build-report-inspector 패키지를 설치했습니다.
// Packages/manifest.json
"com.unity.build-report-inspector": "0.3.0-preview"Window > Open Last Build Report → SourceAssets 탭에서 상위 용량 에셋을 확인했더니 거의 전부 텍스처였습니다.
| 에셋 | 크기 |
|---|---|
t_minotaur1_MetallicSmoothness.png | 21.33 MB |
t_minotaur1_Normal.png | 21.33 MB |
blck_ink.png (2개) | 17.85 MB ×2 |
Solmoe_KDG_Medium SDF.asset | 16.00 MB |
white one.png | 15.71 MB |
t_minotaur1_AO.png | 10.67 MB |
5.33 / 21.33 MB 같은 딱 떨어지는 수치는 비압축 상태의 크기입니다. Player Settings의 Texture Compression 기본값은 개별 텍스처가 Automatic일 때만 적용되는데, 개별 오버라이드가 걸린 텍스처들이 그 설정을 통째로 무시하고 있었습니다.
조치 — 일괄 압축 도구
수백 개 텍스처를 하나씩 열 수는 없어 에디터 스크립트를 작성했습니다.
Assets/1.Code/Scripts/Editor/TextureCompressTool.cs 메뉴: Tools > Texture > Android 압축 일괄 적용 (ASTC 6x6, 1024)
- 프로젝트 전체
t:Texture2D순회 - 각 텍스처의 Android 플랫폼 오버라이드를 설정
Max Size=Mathf.Min(기존값, 1024)— 기존이 512면 512 유지, 작은 텍스처를 키우지 않음Format=ASTC_6x6,Compression=Compressed
- 이미 동일 설정이면 건너뜀 (불필요한 재임포트 방지)
- 원본 파일은 수정하지 않고 임포트 설정만 변경
포맷 선정 근거
| 포맷 | 비트레이트 | 비고 |
|---|---|---|
| ETC2 RGBA | 8 bpp | 고정, 구형 호환 |
| ASTC 4x4 | 8 bpp | 고품질 |
| ASTC 6x6 | 3.56 bpp | 품질/용량 절충 (채택) |
| ASTC 8x8 | 2 bpp | 뭉개짐 심함 |
ASTC 요구사항은 GLES 3.1 / Vulkan입니다. 목표 기기인 Galaxy S10(GLES 3.2)은 완전 지원하고, 실질 하한선은 Android 5.0~6.0대 저가 기기 수준이라 판단해 채택했습니다.
결과
| 항목 | 이전 | 이후 | 감소 |
|---|---|---|---|
| Total Size | 550.73 MB | 389.69 MB | -161 MB |
| Download (arm64-v8a) | 522.18 MB | 361.15 MB | -161 MB |
| Download (armeabi-v7a) | 520.06 MB | 359.03 MB | -161 MB |
500MB 기준을 통과했고, 텍스처 상위 항목이 2~5MB 수준으로 평탄해졌습니다.
부수 문제 — 터레인 번들거림
압축 적용 후 터레인이 번들거리는 현상이 나타났습니다. 가설을 하나씩 기각해 원인을 좁혔습니다.
| 가설 | 검증 결과 |
|---|---|
| ASTC 압축 손상 | Format을 4x4로 올려도 재현 → 기각 |
| Max Size 축소 | 해상도를 올려도 재현 → 기각 |
| Normal Map Encoding | 인코딩은 노멀 정밀도 문제이지 반사 강도와 무관 → 기각 |
| Smoothness Source | Diffuse Alpha → Constant 변경 시 해소 → 확정 |
터레인 머티리얼이 Diffuse 텍스처의 알파 채널을 smoothness 소스로 쓰고 있었는데, 그 알파에 의미 있는 값이 없는 상태에서 압축까지 거치며 값이 튄 것이었습니다. Smoothness Source를 Constant로 바꿔 해소했습니다.
압축이 원인처럼 보였지만 실제로는 압축이 기존 설정 오류를 드러낸 것이었습니다.
검토 후 채택하지 않은 것
Play Asset Delivery(PAD) 도입을 검토했습니다. 다만 이번 초과분은 161MB 규모였고 텍스처 압축만으로 해소되는 수준이었습니다. PAD를 얹으면 팩 자체가 비대해지고 다운로드 흐름이 복잡해지므로 채택하지 않았습니다.
이는 “PAD가 나쁘다”는 뜻이 아니라 이번 문제의 원인과 규모에 맞지 않았다는 뜻입니다.
SDF 폰트 아틀라스 2개(32MB, 전체의 약 6%)는 Dynamic 전환 시 줄일 수 있으나 런타임 부하가 생겨 미적용으로 뒀습니다. 오디오는 상위 용량 목록에 아예 없어 조치가 불필요했습니다.
1. 첫 등장 오브젝트의 셰이더 팝인
기존 방식
씬 시작 시 카메라로 맵 전체를 한 번 비추고, 이펙트 오브젝트도 미리 껐다 켜서 렌더링을 강제로 트리거했습니다.
문제
- 화면에 노출되지 않은 각도·거리의 variant는 여전히 누락됨
- 풀링으로 나중에 생성되는 오브젝트, 씬에 미배치된 프리팹은 커버 불가
- 맵 전체 스캔에 로딩 시간·연산 비용 소모
- 씬 구성이 바뀔 때마다 스캔 범위를 다시 맞춰야 함
원인
Unity는 셰이더 keyword 조합(variant)을 최초 렌더링 시점에 동적으로 컴파일합니다. 이 컴파일이 메인스레드를 블로킹해 스터터로 나타납니다.
카메라 스캔 방식은 “화면에 보였는가”를 기준으로 삼는데, 정작 필요한 건 “어떤 shader + keyword 조합이 쓰였는가” 입니다. 기준 자체가 어긋나 있었습니다.
개선
ShaderVariantCollection으로 실제 사용된 조합만 기록하고, 로딩 구간에서 WarmUp()으로 일괄 미리 컴파일하도록 바꿨습니다.
- Recording 중 프리팹·풀 오브젝트를 한 번씩 노출시키면 함께 수집됨
- 수집 대상이 “화면 노출”이 아닌 “실제 variant”라 스캔 범위·정확도 문제가 사라짐
- 결과물은 씬과 무관한 독립 에셋(
.shadervariants) — 앱 실행 중 1회 WarmUp이면 이후 재사용
| 항목 | 카메라 스캔 | Variant Collection |
|---|---|---|
| 커버리지 | 노출된 것만 | 실사용 variant 전체 |
| 풀링/프리팹 | 미커버 | recording 중 노출 시 커버 |
| 유지보수 | 씬 바뀔 때마다 수동 조정 | 재녹화로 자동 갱신 |
정량 개선폭은 프로파일러 실측이 남아 있습니다. 여기서는 커버리지 기준을 바로잡은 것까지가 확인된 성과입니다.
2. 중복 머티리얼로 인한 배칭 손실
기존 방식
별도 검사 도구 없이, 작업자가 씬을 직접 돌아다니며 눈으로 머티리얼 사용 현황을 확인했습니다.
문제
- 육안 확인은 값이 미세하게만 다른 복제본을 놓치기 쉬움
- 씬 규모가 커질수록 전수 확인 자체가 비현실적
- 불필요한 복제 머티리얼이 방치되어 메모리·드로우콜 낭비가 누적됨
원인
SRP Batcher와 GPU Instancing은 동일 머티리얼 에셋일 때만 배칭합니다. 값이 완전히 같아도 에셋이 복제되어 있으면 별개 머티리얼로 취급되어 배칭이 깨집니다.
즉 판단 기준은 “이름이 비슷한가”가 아니라 “shader + 모든 프로퍼티값이 동일한가” 입니다.
개선
에디터 스크립트(MaterialDuplicateFinder.cs)로 씬 내 모든 Renderer의 머티리얼을 수집하고, shader + 프로퍼티값 시그니처가 같은 것끼리 그룹핑해 중복을 콘솔에 출력하도록 만들었습니다.
메뉴: Tools > 씬 머티리얼 중복 검사
출시작 저장소를 보면 Copy Rock4.mat, Copy_Bark10_Albedo.mat, Copy_Bark10_Albedo mk1.mat 처럼 복제 흔적이 이름에 그대로 남아 있습니다. 이런 것들이 배칭을 조용히 깨뜨리고 있었습니다.
함께 만든 에디터 도구
같은 맥락에서 반복 수작업을 없애기 위해 몇 개를 더 만들었습니다.
| 도구 | 역할 |
|---|---|
TextureCompressTool | Android 텍스처 압축 설정 일괄 적용 |
MaterialDuplicateFinder | 배칭을 깨는 중복 머티리얼 검출 |
MeshCombinerTool | 정적 메쉬 병합 |
SceneComponentBatchEditor | 씬 내 컴포넌트 값 일괄 편집 |
SceneUsageFinder | 에셋이 어느 씬에서 쓰이는지 역추적 |
RemoveMissingScriptsTool | Missing 스크립트 참조 정리 |
도구를 만드는 데 드는 시간이 아깝게 느껴질 수 있지만, 눈으로 확인하는 작업은 규모가 커지면 반드시 틀립니다. 검사 기준을 코드로 적어 두면 그 기준이 문서 역할도 같이 합니다.
문제
목표는 Galaxy S10(Exynos 9820 / Mali-G76 MP12)에서 40 FPS 유지였습니다. 세 가지 증상이 있었습니다.
- 씬5: 보스 스킬의 불기둥 이펙트가 9개 동시 생성될 때 10 FPS대까지 하락
- 씬1/4/5: 조명 intensity가 0으로 전환되는 순간 프레임 급락
- 그 외 씬: 40 FPS 근처에서 애매하게 못 미치는 저하
원인이 GPU인지 CPU인지, 어느 렌더링 단계인지 특정되지 않은 상태에서 시작했습니다.
분석 환경부터 구축
Unity 자체 Profiler는 대부분의 모바일 기기에서 GPU 타이밍을 지원하지 않습니다. 그래서 툴을 조합했습니다.
| 툴 | 용도 |
|---|---|
| Unity Frame Debugger | draw call 구조, 배칭 여부, 사용 셰이더/키워드 |
| Unity Profiler (CPU) | 메인 스레드 시간 분포, hitch 지점 특정 |
| Arm Performance Studio (Streamline) | Mali GPU 하드웨어 카운터 실측 |
| URP Rendering Debugger | Overdraw Mode로 오버드로우 분포 시각화 |
Streamline은 비루팅 기기 기준 Development Build로만 캡처 가능합니다. Frame Debugger는 시간(ms)을 주지 않으므로 시간은 Profiler·Streamline 몫이라는 점도 미리 정리해 두고 시작했습니다.
어디가 병목인가 — 씬별 카운터 실측
| 지표 | 씬1 | 씬2 | 씬3 | 씬4 | 씬5 |
|---|---|---|---|---|---|
| Fragment queue 사용률 | 87% | 87% | 84% | 83% | 80% |
| Overdraw (fragments/pixel) | 1.84 | 1.86 | 1.91 | 1.79 | 2.50 |
| Arithmetic 유닛 사용률 | 43% | 44% | 47% | 46% | 53% |
| GPU 실행 코어 사용률 | 85% | 85% | 85% | 86% | 82% |
| CPU 빅코어 사용률 | 99% | 1% | 23% | 50% | 99% |
- 모든 씬에서 Fragment(픽셀) 단계가 80~87%로 지배적 → 병목은 정점·타일링이 아니라 픽셀 처리
- 씬5가 오버드로우·연산량 모두 최고 → 불기둥 구간이 GPU 부하 최댓값
- 씬1/4/5에서 CPU 특정 코어가 99%로 고정되는 별개 현상 발견
Draw call은 프레임당 190개였고, 그 대부분은 터레인 6타일 분할 + 레이어 5개(4개 초과분이 Add Pass 발생) + 풀 디테일 36개였습니다. SRP Batcher가 묶은 27개는 정상이었습니다.
가장 큰 소득 — 74.9%짜리 낭비
씬5 프레임을 GPU 쪽에서 분해했습니다.
| 단계 | GPU 비중 | GPU 시간 |
|---|---|---|
| 전체 프레임 | 99.1% | 25.401 ms |
| Setup Camera | 74.9% | 19.215 ms |
| DrawTransparentObjects (실제 이펙트) | 20.4% | 5.239 ms |
이펙트 자체보다 Setup Camera가 3배 이상 컸습니다. 정상이 아니라고 보고 추적했습니다.
추적 경로 불기둥 계열 프리팹 6종 → 공통으로 Noise18bd1/2/3.mat 참조 → 셰이더 HS_BlendDistort.shadergraph → 그래프 안에 Scene Color 노드(카메라 컬러 텍스처 샘플링, 왜곡용) 존재.
결정적 확인: 세 머티리얼 모두 _Distortionpower: 0 이었습니다. 즉 왜곡이 시각적으로 전혀 적용되지 않는 상태인데도, 셰이더는 매 프레임 화면 전체를 복사하는 비용을 그대로 지불하고 있었습니다. 100% 낭비였습니다.
그래프를 더 보니 Scene Color 출력은 Branch 노드를 거치고 있었고, 그 Predicate는 #ifdef HAVE_DECALS로 HDRP 여부만 판별하는 Custom Function이었습니다. 이 프로젝트는 URP이므로 Predicate는 항상 false → Branch는 항상 Scene Color 경로로 귀결되고, True측 HDRP 폴백은 죽은 코드였습니다. 조건부 스위치처럼 보였지만 URP에서는 스위치가 아니었던 것입니다.
조치: HS_BlendDistort.shadergraph에서 Scene Color·Branch·HDRP Custom Function 노드를 삭제하고 그 자리에 Vector2(0,0) 상수를 넣었습니다. 시각적 변화는 없습니다. 원래 왜곡 강도가 0이었기 때문입니다.
조명 전환 hitch의 정체
씬4에서 조명 intensity가 0이 되는 프레임(748ms)을 잡았습니다.
| 항목 | 비중 | 시간 |
|---|---|---|
| 전체 프레임 CPU | — | 748 ms |
| DrawOpaqueObjects | 82.5% | 617 ms |
└ Shader.CreateGPUProgram (SRPBatcher) | 42.4% | 318 ms |
└ Shader.CreateGPUProgram (StdRender) | 39.9% | 299 ms |
Frame Debugger로 조명 켜짐/꺼짐 상태의 Terrain 셰이더 키워드를 비교했습니다.
- 켜짐:
_MAIN_LIGHT_SHADOWS_CASCADE,_SHADOWS_SOFT포함 - 꺼짐: 두 키워드가 빠지고
_MAIN_LIGHT_SHADOWS로 전환
원인: intensity 0 전환 시 URP가 그림자 키워드 조합을 바꾸고, 그 조합의 variant가 런타임에 처음 쓰이며 즉석 컴파일이 일어나 대형 hitch가 됩니다.
시도와 결과: Shader Variant Collection을 저장해 Preloaded Shaders에 등록했습니다. 그런데 저장된 파일 안에 Terrain 문자열이 포함된 셰이더가 하나도 없었습니다. Unity의 Shader Variant 트래킹이 Terrain 셰이더(런타임 레이어 조합 방식)를 캡처하지 못하는 알려진 한계로 확인했고, 이 부분은 미해결로 남겼습니다.
미확인으로 남긴 것
CPU 빅코어가 99%로 고정되는 현상은 후보를 둘로 좁혔지만 결론을 내지 못했습니다.
- 실제 연산(Physics/Script/Animator)이 그 코어를 점유
- GPU 완료를 기다리는 busy-wait이 “활동”으로 잡힘 (GPU 병목의 결과일 뿐일 가능성)
Profiler에서 Gfx.WaitForPresent 비중을 봐야 구분 가능한데 아직 확인하지 못했습니다. 둘 중 하나로 단정하면 엉뚱한 곳을 고치게 되므로 미확인 그대로 적어 뒀습니다.
남은 작업
- 터레인 셰이더 variant 워밍업 — 로딩 구간에서 조명 intensity 0 상태를 포함한 더미 렌더링으로 강제 컴파일
- CPU 코어 99% 고정 원인 특정 —
Gfx.WaitForPresent비중 확인 - 풀(Detail) 오버드로우 개선 — 셰이더 Blend/Cutout 여부 확인 후 전환 검토
- 터레인 6타일 → 1타일 병합 (에디터 스크립트로 Heightmap/Alphamap 병합)
- 보스 이펙트 동시 생성 개수 제한(풀링 캡)
- 조치 이후 씬1~5 재측정으로 개선폭 정량 확인 (Streamline CSV 재비교)
출시작과 리팩토링 프로젝트가 모두 남아 있으므로, 같은 기기·같은 구간에서 두 빌드를 직접 비교 측정하는 것이 다음 단계입니다.
문제
3인칭 카메라가 나무 뒤로 들어가면 플레이어가 가려집니다. 가리는 오브젝트에 구멍을 뚫어 플레이어가 보이게 만드는 기능이 필요했습니다.
1차 시도 — 머티리얼에 직접 SetFloat
가장 단순한 방법으로 시작했습니다. 가리는 오브젝트의 머티리얼을 찾아 SetFloat으로 구멍 위치를 넘겼습니다.
폐기 이유: 나무 종류가 늘어나면 못 씁니다. 슬롯이 하나뿐이라 나머지 머티리얼은 갱신되지 않고, 각자 저장된 옛 값으로 제각각 동작합니다.
2차 시도 — Shader.SetGlobal
그럼 전역으로 넘기면 되겠다고 생각했지만, 이것만으로는 해결되지 않았습니다.
폐기 이유: Shader Graph 프로퍼티는 Show In Inspector를 꺼도 머티리얼이 값을 계속 소유합니다. 그래서 머티리얼 값이 전역 값을 이깁니다.
해결: SeeThroughGlobals.hlsl에 전역 변수를 직접 선언하고 Custom Function 노드로 읽도록 했습니다. 프로퍼티가 아니므로 머티리얼이 개입할 방법 자체가 없어집니다.
3차 시도 — Raycast 판정
“가려졌는가”를 Physics.Raycast로 판정했습니다.
폐기 이유: 전역 값은 1비트뿐이라 어느 오브젝트가 가렸는지 구분하지 못합니다. 하나라도 가리면 화면 안의 모든 대상이 같이 뚫렸습니다.
해결: 판정을 CPU에서 GPU로 옮겼습니다. 셰이더가 자기 픽셀의 깊이를 플레이어 깊이와 비교해 스스로 판정하므로, 레이캐스트가 아예 필요 없어집니다. 픽셀 단위로 판정하니 “누가 가렸는가”를 따질 필요도 사라집니다.
최종 구조
C# 쪽이 하는 일은 단 하나입니다. 현재 캐릭터의 몸 중앙을 화면 좌표로 바꿔 구멍 설정과 함께 전역 셰이더 값으로 보냅니다.
// 역할: 카메라와 플레이어 사이를 가리는 오브젝트에 구멍을 뚫도록, 셰이더에 기준값을 넘긴다.
public class SeeThroughWall : MonoBehaviour
{
private static readonly int PositionId = Shader.PropertyToID("_SeeThroughPosition");
private static readonly int SizeId = Shader.PropertyToID("_SeeThroughSize");
private static readonly int OpacityId = Shader.PropertyToID("_SeeThroughOpacity");
private static readonly int EdgeSoftnessId = Shader.PropertyToID("_SeeThroughEdgeSoftness");
[Preserve, Inject] private ICurrentCharacterProvider _currentCharacter;
[Tooltip("구멍 반경")]
[SerializeField, Range(0f, 3f)] private float _holeSize = 0.2f;
[Tooltip("구멍 안쪽에 남는 진하기. 0이면 완전히 뚫린다")]
[SerializeField, Range(0f, 1f)] private float _opacity;
[Tooltip("구멍 경계가 퍼지는 정도")]
[SerializeField, Range(0f, 1f)] private float _edgeSoftness;
}현재 캐릭터는 [Inject]로 ICurrentCharacterProvider를 받습니다. 캐릭터 전환이 있는 게임인데도 이 클래스는 어느 캐릭터인지 알 필요가 없습니다.
구멍 크기·진하기·경계 부드러움은 이 오브젝트의 배치 값이라 [SerializeField]에 두었습니다. 모든 개체가 같이 바뀌어야 하는 값이 아니기 때문입니다. (시스템 규칙의 「수치를 어디 둘 것인가」 판별 기준)
실패 경로를 주석으로 남긴 이유
이 클래스 맨 위에는 위의 세 시도가 그대로 적혀 있습니다.
여기까지 온 이유 (같은 실수를 반복하지 않기 위해 남김)
주석 컨벤션의 원칙은 “오직 코드를 작성한 ‘왜’를 주석으로 적는다” 입니다. 이 코드의 최종 형태만 보면 “왜 굳이 hlsl에 전역 변수를 직접 선언했지?” 하는 의문이 남고, 다음에 보는 사람(대개 몇 달 뒤의 저)이 프로퍼티 방식으로 되돌리려 할 가능성이 큽니다.
폐기한 방법과 그 이유를 남겨야 그 되돌림을 막을 수 있습니다. 코드가 하는 일은 코드를 읽으면 알 수 있지만, 하지 않기로 한 일은 어디에도 남지 않습니다.
왜 배포까지 했나
리팩토링은 유저가 알아챌 수 없는 작업입니다. 구조를 아무리 잘 바꿔도 화면은 똑같습니다. 그래서 저장소에 커밋만 쌓고 끝내면, 정말로 동작하는지 확인되지 않은 채로 끝납니다.
출시작은 이미 Steam과 Google Play에서 서비스 중이었고 누적 다운로드 18,802에 해외 유저가 94%였습니다. 이 채널이 그대로 살아 있다는 것은 리팩토링 결과를 실제 환경에서 검증할 자리가 있다는 뜻이었습니다.
- 에디터에서만 도는 코드는 리팩토링이 끝난 게 아닙니다. 출시작에서 이미 겪은 일입니다 — 에디터에서는 멀쩡했지만 빌드에서 입력이 죽었던 사고
- 두 플랫폼(PC·Android)에 동시에 올려야 플랫폼 분기 코드가 실제로 검증됩니다
- 스토어 심사·업로드 절차까지 다시 밟아야 빌드 파이프라인이 살아 있는지 확인됩니다
배포 과정에서 드러난 것
배포 절차 자체가 검증 도구 역할을 했습니다.
Google Play Console이 업로드를 막았습니다. 압축 다운로드 크기가 500MB 제한을 넘겼기 때문입니다. Unity 6로 옮기면서 텍스처 임포트 설정이 재정렬된 것이 원인이었고, 이 문제를 계측 기반으로 해결한 것이 [빌드 용량 감축] 작업입니다. 배포를 시도하지 않았다면 550MB짜리 빌드를 문제로 인식조차 못 했을 것입니다.
실기기 검증도 배포 준비 과정에서 이뤄졌습니다. Galaxy S10 기준으로 프레임을 다시 재면서 보스 이펙트 구간의 GPU 병목을 찾았습니다. 이것도 에디터에서는 보이지 않는 문제였습니다.
배포 채널
| 채널 | 내용 |
|---|---|
| Steam | 기존 출시 페이지에 업데이트 배포 |
| Google Play | App Bundle 업데이트 배포 (500MB 제한 통과 후) |
정리
이 프로젝트에서 배포는 마케팅이 아니라 검증 단계였습니다. 출시작을 만들면서 배운 것 중 가장 크게 남은 것이 “빌드해서 실기기에 올려 보기 전까지는 끝난 게 아니다”였고, 리팩토링에서도 같은 기준을 적용했습니다.
구조 개선의 결과가 유저 화면에서는 아무것도 아니지만, 아무것도 아닌 상태로 정상 동작하는 것이 리팩토링의 성공 조건입니다.