유니티 사화 리팩토링
촉박한 일정과 잦은 기획 변경으로 유니티 사화의 코드가 스파게티화되어, 유저 피드백 반영이 어려운 상황이었습니다.
유지보수 가능한 구조로 다시 짜는 것이 목적이었고, TODO를 거쳐 Steam·Google Play에 업데이트로 배포했습니다.

프로젝트 개요
프로젝트 정보
- - 장르: 3D 액션 어드벤처
- - 플랫폼: Windows, Android
- - 엔진: Unity 6 (6000.3.11f1)
- - 언어: C#
- - 기간: 2026.04 ~ 2026.09 (1인 프로젝트)
개발 정보
- - 일부 코드를 제외한 코드 설계(결합도 낮추기, 책임 분리) 진행
- - 설계 방향과 판단은 본인이, 평가와 반복 작업은 일부 Claude Code가 진행
- - 주요활동 요약
- - 그 외 활동 요약
리팩토링 전후 유지보수 비교
(몬스터, 기믹, 테스트코드 제외)
클래스 간 직접 결합
▼ 100%싱글톤 · public 직접 참조 155곳을 인터페이스 주입으로 교체(인터페이스 2 → 53개) → 연쇄 수정 차단
ⓘ 싱글톤 · public 직접 참조 155곳을 인터페이스 주입으로 교체(인터페이스 2 → 53개) → 연쇄 수정 차단책임 분리
▼ 82%판정 · 연출 · 사운드 · 카메라를 한 클래스(AnimalMaskSkill)가 쥐고 있던 것을 책임 단위로 해체 → 가장 긴 파일 기준 줄 수
ⓘ 판정 · 연출 · 사운드 · 카메라를 한 클래스(AnimalMaskSkill)가 쥐고 있던 것을 책임 단위로 해체 → 가장 긴 파일 기준 줄 수상시 실행 코루틴
▼ 95%매 프레임 폴링을 애니메이션 진행률 이벤트로 교체
ⓘ 매 프레임 폴링을 애니메이션 진행률 이벤트로 교체주요 활동
플레이어 상태 시스템 재설계
클래스 하나에 여러 플레이어 상태가 코루틴으로 구현되고, 각 코루틴 내부에서 판단/물리/연출(애니메이션·히트박스·이펙트 등) 기능을 while 문으로 호출
- → 상태를 데이터 에셋으로 정의하여 상태머신에서 실행되도록 구현. 상태가 실행될 때, 애니메이션 진행률에 따라 연출 기능들이 독립적으로 호출되도록 이벤트 발행.
- ⇒ 데이터 에셋을 추가하는 것으로 상태 추가 가능. 상태 간의 결합, 상태와 기능 간의 결합을 분리함.
문제
예시: AnimalMaskSkill 클래스의 코루틴 메서드 CoFirstAttack(…) 호출
CoFirstAttack()에서 해당 상태에 필요한 기능들을 모두 호출하는데, 그 기능이 또 다른 코루틴을 호출시키거나 기능의 동작을 While 문에서 직접 수행하기도 함문제 분석
상속 구조로 옮겨봐도 자식 클래스에 로직이 남지 않음
CharacterBaseState<EState, ECharacter> 추상 클래스를 두고, 상태마다 자식 클래스를 만들어 StateKey·AnimationName을 override하는 구조로 1차 시도
CharacterBaseState.cs — 폐기한 상속 구조
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하는 것이 전부 값이고, 로직은 한 줄도 없음을 확인
- 상태마다 클래스 파일을 하나씩 만들며 얻는 것이 “상수 몇 개 선언할 자리”뿐 → 상속 대신 데이터로 정의하는 방향으로 전환
개선
상태는 데이터 에셋으로 정의하고, 실행은 StateRunner 하나가 담당
상태를 클래스가 아니라 ScriptableObject 에셋으로 정의
StateData.cs — 상태를 정의하는 SO
[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 하나뿐, 상속 없음
StateRunner.cs — 데이터를 읽어 신호만 보내는 실행기
// 역할: 상태를 관찰하고 무엇을 할지 신호(의도)를 보낸다.
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) 인터페이스 분리, 구독자는 필요한 카테고리만 수신 → 새 연출 추가 시 상태 시스템 코드 수정 불필요
에디터 쪽 처리
- 데이터 항목이 여러 종류라 인스펙터가 복잡해질 수 있어, 커스텀 에디터로 필요한 항목만 노출되도록 처리
- 플레이 모드에서 SO progress를 실시간 수정해도 다음 진입부터 반영되도록
Enter()에서UNITY_EDITOR한정 이벤트 재정렬 적용 → 게임을 재시작하지 않고 타이밍 튜닝 가능
StateRunner.Enter() — 에디터 한정 재정렬
public void Enter()
{
#if UNITY_EDITOR
// 에디터 플레이모드에서 SO progress를 런타임 수정하면 다음 진입부터 반영되도록 재정렬한다.
SortEvents(_data);
IsLooping = _data.IsLooping;
#endif
// ...
}결합 구조 실측
- before: 양방향
SerializeField순환 참조 6쌍 확인(HumanMaskSkill↔PlayerSkillMove등) — 양쪽 파일이 서로의 타입을 필드로 들고 있어 한쪽을 고치면 반대쪽도 매번 확인 필요 - after: 의존이 인터페이스·채널을 통해 한 방향으로만 흘러 순환 참조 0건
- before: 대부분이 씬에 붙어야만 존재하는
MonoBehaviour라 씬 없이는 읽기·검증이 어려움 - after: 상당수가 순수 C# 클래스·인터페이스라 코드만으로 계약 파악 가능
결과
- 양방향 순환 참조 해소로, 한 클래스를 고칠 때 반대쪽까지 확인해야 하던 부담 제거
- 스킬 추가 시 클래스 작성 불필요,
StateData에셋을 하나 만들어 애니메이션과 타이밍만 채우면 완료 - 관련해 해결한 버그: 캐릭터 전환 시 콤보가 끊기던 문제, 대시 중 공격 스킬을 쓰면 대시 이동이 스킬에 그대로 얹히던 문제
한계 및 회고
회고
- 코드를 크게 안 고치고도 기능을 추가·변경할 수 있어야 한다는 선을 기준으로 설계. 디테일까지 전부 미리 설계하는 대신, “수정하기 쉽게 짜는 것”을 기본으로 두는 쪽을 선택
- SkillMove를 만들 때 “플레이어·몬스터·기믹 공용 시스템으로 키우자”는 계획이 나왔지만 취소. 지금 의도와 다른 설계를 미리 하는 것은 시간을 버리는 일이라고 판단
의존성 주입 시스템 자체 구현
시스템 간 참조를 싱글톤과 `.instance` 호출로 연결해, 어떤 클래스가 무엇에 의존하는지 선언부만으로 파악 불가
- ⇒ 의존 관계가 선언부(`[Inject]` 필드)에 그대로 드러나 코드만 보고 파악 가능, 필수/선택 의존도 구분되어 표시
문제
싱글톤이 의존 해결 수단이었음
출시작은 CameraController·SaveManager·PlayerController·MaskChange 등 다수의 싱글톤 클래스로 시스템 간 참조를 연결. .instance 호출이 코드 곳곳에 흩어진 상태
- 어떤 클래스가 무엇에 의존하는지 선언부만 봐서는 알 수 없고, 메서드 본문의
.instance호출을 전부 읽어야 파악 가능 PlayerHumanMaskData처럼 수치 데이터 클래스까지 싱글톤이라 교체·테스트 불가능
문제 분석
초기화 순서를 실행 순서에 기대는 구조
싱글톤 초기화 순서가 Awake 실행 순서에 좌우됨. 레거시 MouseSettingUI에는 // TODO: TimelineHelper에서 savemanager 불러오는 문제 -> instance부터 하는건지라는 주석이 남아 있어, 문제는 인지했으나 해결하지 못한 상태로 확인
의존을 선언이 아니라 실행 순서로 관리하는 한, 클래스를 추가할 때마다 초기화 순서를 함께 신경 써야 하는 구조. “누가 무엇을 필요로 하는가”를 코드가 직접 말하게 하는 구조가 필요하다고 판단
개선
[Inject] 어트리뷰트 기반 주입 컨테이너를 직접 구현
외부 DI 프레임워크 대신 자체 컨테이너를 구현
구성 파일 — InjectAttribute · AttributeInjector · DataContainer · IDataProvider
| 파일 | 줄 | 책임 |
|---|---|---|
InjectAttribute | 17 | 주입 표시. Optional 여부만 가짐 |
AttributeInjector | 217 | 타입 수집 → 씬 객체·SO 등록 → 어트리뷰트 필드에 주입 |
DataContainer | 33 | SO 수치 에셋 공급 |
IDataProvider | 11 | SO 공급 계약 |
동작 순서:
[RuntimeInitializeOnLoadMethod(AfterAssembliesLoaded)]로 어셈블리 로드 직후 1회만 타입과[Inject]필드 캐싱 — 런타임 리플렉션 재조회 없음- 씬
Awake에서FindObjectsByType<MonoBehaviour>로 씬 객체 등록. 인터페이스·추상 타입도 키로 등록해 계약 타입으로 주입 가능 - ScriptableObject는 씬 객체가 아니므로
IDataProvider를 통해서만 수집 - 필드가
List<T>면 해당 타입 전부를, 단일 필드면 첫 번째를 주입
의존이 선언부에 드러남
after의 주입 필드는 49파일 161개. 타입별로 분류하면:
| 구분 | 수 | 비율 |
|---|---|---|
인터페이스 (I~) | 101 | 69% |
List<인터페이스> | 6 | 4% |
| 구체 타입 | 39 | 27% |
구체 타입 39개는 교체 대상이 아닌 것들 — 채널(HitChannel·AudioChannel), SO 데이터(EffectCatalog·AudioCatalog 등), 씬 컴포넌트 목록(List<CameraRole> 등), 외부 타입(InputActionAsset 등)
선택적 주입(Inject(true))은 90개로, “없어도 되는 의존”과 “필수 의존”을 구분해 표시. 없으면 필수는 LogError, 선택은 LogWarning을 남김
결과 및 회고
- 싱글톤과
.instance호출이 사라지고,[Inject]필드가 그대로 의존 목록으로 남아 코드를 실행하지 않고도 선언부만 보고 의존 관계 파악 가능 - 필수 의존과 선택적 의존을 구분해 표시해, 없어도 되는 의존을 빼먹었을 때와 필수 의존을 빼먹었을 때를 다른 로그로 구별
회고
- 같은 타입 구현체가 여럿이면 첫 번째만 주입되고 경고만 남는 한계
- 런타임에 생성되는 객체는 주입 대상 아님 —
Awake시점의 씬 객체만 스캔 Script Execution Order를 -100으로 설정해야 동작하고, 부모 클래스 필드를 주입받으려면private가 아닌protected/public이어야 함. 둘 다 코드 주석에 명시
시스템 경계 재정의
폴더 10개로 나뉘어 있었지만 namespace·인터페이스가 거의 없어 폴더를 넘나드는 참조가 흔했음
- ⇒ 시스템 간 연결이 인터페이스·채널로 한정되어, 한 시스템을 고쳐도 다른 시스템이 그 여파를 받지 않음
문제
폴더는 있었지만 경계는 없었음
출시작은 폴더 10개(Player·Enemy·Gimmick·UI·Manager·Camera·Object·Struct·Interface·Timeline)로 구성. 폴더는 분류일 뿐 경계가 아닌 상태
namespace선언이 거의 없어, 모든 타입이 전역 이름 공간에 위치해 이름 충돌을 파일명 규칙으로만 회피MenuUI(UI)가MaskChange(Player)·CameraController(Camera)를 직접 참조하고,TimelineHelper(Player)가MenuUI·MouseSettingUI(UI)를 참조하는 식으로 폴더를 넘나드는 참조가 흔함Interface폴더에 인터페이스가IDamageable·IEvent둘뿐. 폴더는 만들었지만 계약 기반 설계는 자리잡지 못한 상태Manager폴더는SaveManager·SceneSwitcher·LanguageManager·TextManager·CoroutineManager등 성격이 다른 전역 객체의 집합소 역할
문제 분석
경계 없는 참조가 반복될 수밖에 없었던 이유
폴더 구조는 “무엇을 어디에 둘지”만 정했을 뿐, “어디까지가 이 시스템의 책임이고 외부와 어떻게 연결되는지”는 문서화되지 않은 상태. 규칙이 없으니 새 코드를 쓸 때마다 각자 판단으로 참조를 만들었고, 그 결과가 폴더를 넘나드는 직접 참조와 전역 싱글톤. 구조를 코드로 먼저 짜면 이미 얽힌 뒤에야 경계가 보이므로, 순서를 뒤집어 규칙을 코드보다 먼저 확정하기로 판단
개선
폴더를 시스템으로 못 박고, 코드보다 규칙을 먼저 확정
코드를 쓰기 전에 역할·진입점·외부 연결·설계 규칙을 SystemUsageGuide 문서로 먼저 확정하도록 순서 변경
시스템 16개 구성 — 파일·줄·노출 인터페이스
| 시스템 | 파일 | 줄 | 노출 인터페이스 |
|---|---|---|---|
| PlayerSystem | 81 | 3,691 | 26 |
| GimmickSystem | 34 | 1,685 | 1 |
| UISystem | 28 | 1,380 | 2 |
| EnemySystem | 25 | 2,682 | 0 |
| CameraSystem | 24 | 1,249 | 8 |
| SaveSystem | 15 | 767 | 3 |
| InputSystem | 12 | 396 | 6 |
| Editor | 11 | 777 | 0 |
| LanguageSystem | 11 | 644 | 1 |
| AudioSystem | 9 | 606 | 1 |
| CombatSystem | 6 | 360 | 1 |
| GameStateSystem | 6 | 168 | 2 |
| DISystem | 4 | 278 | 1 |
| SettingsSystem | 3 | 77 | 1 |
| PlatformSystem | 2 | 115 | 0 |
파일 대부분이 namespace Refactoring 아래로 정리. 시스템 간 연결은 인터페이스 또는 채널(AudioChannel·HitChannel·PlayerStateEventChannel·PlayerStateTriggerChannel·PlayerCurrentStateChannel) 5종을 통하고, 배선은 AttributeInjector가 씬 Awake에서 자동 처리
경계가 실제로 작동한 사례
PlayerSystem이 카메라를 직접 조작하지 않음 —CameraSystem이IPlayerStateEventSubscriber로 상태 이벤트를 구독해 흔들림·줌을 스스로 처리 (before는PlayerCameraEffect가 플레이어 코드 안에 위치)CombatSystem은DamageInfo/IDamageable만 정의, 공격자·피격자는 서로의 구체 타입을 모름UISystem은 게임 상태를IGameStateController로만 조작 (before는MenuUI가Time.timeScale과 플레이어를 직접 조작)
결과 및 회고
- 시스템 간 연결이 인터페이스·채널로 한정되어, 한 시스템 내부를 고쳐도 다른 시스템이 그 여파를 받지 않음
- 전역 접근(싱글톤·
.instance호출)이 사라져, 초기화 순서를 신경 쓰지 않고도 의존을 주입받을 수 있는 구조 확보
회고
EnemySystem과PlatformSystem은 노출 인터페이스가 0개로, 아직 계약 없이 구체 타입으로 연결GimmickSystem도 시스템 크기 대비 계약이 적음. 둘 다 작업 범위 밖(Enemy·Gimmick 코드는 타인 작성)이라 다음 과제로 남김
AI를 품질 게이트로 세운 협업 방식
1인 리팩토링이라 코드 판정을 스스로 내려야 했고, 자기가 짠 코드는 스스로 읽기 쉬운지 판단하기 어려운 구조
- ⇒ 판정 기준이 문서·커맨드로 고정되어 코드 리뷰가 취향 논쟁이 아닌 문서 대조 작업이 됨
문제
혼자 하는 리팩토링에는 반대하는 사람이 없음
출시작을 다시 짜기로 했을 때 가장 걱정한 것은 실력이 아니라 판단의 편향
- 자기가 짠 코드는 자기가 읽기 쉬움 — “읽기 쉬운가”를 스스로 판정 불가
- 리팩토링은 끝이 없어서, 어디서 멈출지 정해 주는 사람이 없으면 계속 고치게 되는 구조
- 팀 프로젝트에서는 리뷰어가 걸러 주던 것들이 1인 프로젝트에서는 전부 통과
AI를 쓰기로 한 이유가 여기 있음 — 코드를 빨리 뽑기 위해서가 아니라, 반대할 사람을 만들기 위해서
문제 분석
AI는 기본적으로 사용자 편을 듦
반대할 사람을 AI로 세우려 해도, AI는 기본적으로 사용자에게 동의하려는 경향. 그 상태로 붙이면 리뷰어가 아니라 들러리가 되는 구조. AI를 쓰기 전에 “동의하지 않아도 되는 조건”부터 규칙으로 못 박아야 한다고 판단. AI가 파일을 알아서 고치기 시작하면 설계 판단이 손을 떠나므로, 설계는 직접 하고 AI는 검사만 하도록 역할 분리 필요
개선
원칙 — 사용자 편을 들지 말 것
프로젝트 CLAUDE.md 최상단에 규칙 명시
- 사용자 편을 절대로 들지 말 것. 중립적이면서 기계적으로 근거에 의한 답변을 제시할 것.
- 파일 생성/삭제/변경은 진행하기 전 무조건 질문할 것.동의 금지를 규칙의 첫 줄로 올리고, 평가 커맨드에도 엄격하고 진지하게, 중립적으로 판단한다를 반복 삽입. 두 번째 규칙(변경 전 질문)은 주도권을 지키기 위한 장치. 설계는 직접, AI는 검사와 근거 조사 담당
커맨드로 고정한 11가지
매번 프롬프트로 설명하면 기준이 흔들리므로, 반복되는 판정을 전부 슬래시 커맨드로 고정
| 커맨드 | 줄 | 역할 |
|---|---|---|
code-convention | 381 | C#/Unity 코드 컨벤션 위반을 표로 보고한다 |
system-evaluation | 154 | 시스템 단위 설계를 평가하고 미확정 항목을 확정받는다 |
comment-convention | 114 | 주석 컨벤션 위반을 표로 보고한다 |
drawio-convention | 112 | 시스템 관계도(drawio) 표기 규칙을 검사한다 |
diagram-html-convention | 92 | HTML 다이어그램 표기 규칙을 검사한다 |
code-evaluation | 91 | 클래스 단위 품질을 5개 축으로 판정한다 |
git-issue | 86 | 변경사항을 읽어 이슈안을 만들고 승인 절차를 거친다 |
git-commit | 86 | 변경사항을 읽어 커밋안을 만들고 승인 절차를 거친다 |
analysis-origin | 61 | 출시작 코드를 수집·분석해 설계 계획서를 쓴다 |
evidence-research | 33 | 웹에서 근거를 찾아 출처·원문 그대로 제시한다 |
system-usage | 6 | 시스템 목록과 사용 규칙(SystemUsageGuide)을 기준으로 삼는다 |
평가 기준을 추상 원칙에서 떼어냈음
클래스 평가에서 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가 사용자 편으로 기우는 문제코드 평가 경각심 담긴 문장 추가
규칙을 만드는 것 자체가 설계 작업. 무엇을 나쁜 코드로 볼 것인지 문장으로 못 쓰면, 그 기준은 머릿속에도 없는 것이라는 판단
버린 기록
통제는 말로 되지만 폐기는 증거가 남음. 납득하지 못한 결과물은 남기지 않음
- 상속 + 제네릭 상태 구조 — 만들다 보니 제네릭에 플레이어 전용 타입이 들어가 플레이어 상태머신으로 고정돼 폐기
- Unity Localization + Addressable 패키지 — 도입 후 필요 없다고 판단해 제거, 자체 구현으로 전환
- 코드 설계 에이전트 — 만들었다가 재구현하고 결국 커맨드 방식으로 정리
- git-flow·PR 규칙 문서 — 실효가 없어 삭제
- 전면 자동화(코드 작성 → 평가 → 요약) — Pro 요금제 토큰이 곧바로 바닥나고 결과 만족도가 낮아 포기, 부분 자동화도 하지 않기로 함
결과 및 회고
- 판정 기준이 문서로 고정되어, 코드 리뷰가 취향 논쟁이 아니라 문서 대조 작업으로 전환
- 혼자서 5개월·384개 커밋 규모의 전면 재설계를 완주. AI 없이 같은 기간에 같은 범위를 검사까지 하며 진행하기는 어려웠을 것으로 판단
회고
- 규칙을 문장으로 쓰는 과정에서 판단 기준이 정리됨. 이 문서화 습관은
SystemUsageGuide와 활동 일지로도 이어짐 - 처음부터 완성된 규칙은 없었음. 여러 번 고치고 단순화하며 다듬었고, 그 자체가 설계 작업
그 외 활동
창 관리 구조 재설계
창 9개를 `MenuUI` 클래스 하나가 전부 직접 켜고 끄고, 뒤로가기도 if-else 다단 중첩으로 판단 → 창을 추가할 때마다 같은 클래스를 고쳐야 하고, UI가 카메라·플레이어·세이브까지 직접 참조하는 구조가 원인 → 스택이 열린 순서를 대신 기억하게 하고, 창은 `IWindow` 계약만 지키도록 변경
⇒ 창 추가 시 `MenuUI`를 고칠 필요 없이 `IWindow`를 구현한 창 하나만 만들면 자동으로 등록·관리됨
입력 · 게임 상태 분리
입력을 받아도 되는지를 매 프레임 가드 조건 여러 개를 나열해 판별하고, 캐릭터 분기가 입력 처리 안에 섞여 있음 → 게임 상태(게임플레이·메뉴·컷씬)를 한 곳에서 판단하지 않고 각자 bool을 조회한 것이 원인 → 지금 게임 상태가 도메인 핸들러를 고르고, 핸들러가 처리하는 구조로 변경
⇒ 입력 소비자가 입력 원본(PC/모바일)이나 캐릭터 종류를 몰라도 되고, 일시정지 등 상태 전환 지점이 한 곳으로 모임
툴 및 편의 기능
빌드 용량 초과, 렌더링 부하, 시야 가림, 데이터 편집 불편 등 플레이어·UI·입력 시스템 밖에서도 문제 산재 → 원인이 서로 다른 문제라 각각 계측·역추적으로 원인을 특정하는 과정이 필요 → 문제별로 도구를 직접 만들어 추측 대신 계측 기반으로 해결
⇒ Steam·Google Play 업데이트 배포까지 마쳐, 구조 변경이 실제 환경에서도 문제없이 동작함을 확인
문제
MenuUI 하나가 창 9개를 전부 직접 담당
출시작은 창 9개(새게임·일시정지·메인메뉴복귀·설정·입력키·슬롯목록·불러오기·종료·스토리)를 MenuUI 한 클래스가 SetActive(true/false)로 직접 켜고 끔
SetUIElements()가 창 전체를 초기화 — 창을 추가하면 이 메서드부터 수정 필요SetButtonFunction()이 반복문 + 인덱스로 버튼 동작을 배선- 뒤로가기 처리(
MenuSwitch())는 각 창의activeSelf를 직접 물어보며 if-else 5단 중첩으로 닫을 대상을 결정. “창이 열려 있다” = GameObject가 켜져 있다는 것과 같아, 별도 상태 개념 부재
문제 분석
UI가 게임 전체를 알고 있었음
문제는 창 개수가 아니라 UI가 게임 전체를 알고 있었다는 것. MenuUI는 CameraController·MaskChange·PlayerSound·PlayerState·GameTimeScale 같은 외부 컴포넌트를 SerializeField로, TimelineHelper·LoadingUI·SaveManager 등 싱글톤을 직접 참조했고, 자기 자신도 public static instance 싱글톤. 창을 열고 닫는 로직과 게임 상태를 조작하는 로직이 한 클래스에 뒤섞여 있어, 창 하나를 추가하는 작업이 게임 전체에 영향을 줄 위험을 내포
개선
스택이 열린 순서를 대신 기억
구성 — IWindow · UIWindow · UINavigator · UIRoot
| 구성 | 줄 | 책임 |
|---|---|---|
IWindow | 8 | Open()/Close() 두 개만 약속 |
UIWindow | 19 | 이름표(WindowType)를 들고 켜기/끄기만 담당 |
UINavigator | 33 | Stack<IWindow>에 쌓고 맨 위부터 닫음 |
UIRoot | 138 | 자식 창을 이름표로 자동 등록, 열기/닫기와 게임 모드 전환 조율 |
- 뒤로가기 순서를 판단하는 코드 없음 — 스택이 순서를 대신 처리 (
CloseTop()) UIRoot.BuildRegistry()가GetComponentsInChildren<UIWindow>(true)로 자식 창을 자동 수집. 창을 추가해도UIRoot는 그대로- 외부 의존은 선택적 주입 4개(
IInputPressedProvider·IMenuInputProvider·ICutsceneInputProvider·IGameStateController)뿐이고, 전부 인터페이스 - 메인메뉴 씬과 인게임 씬의 차이는
_entryWindow인스펙터 값 하나로 구분
세부 대비
확인 창(예/아니오) — before는 MenuUI 내부 분기(activeSelf로 판별). after는 ConfirmWindow 추상 클래스 + 파생 3종(NewGameConfirmWindow·LoadConfirmWindow·QuitConfirmWindow)으로, 공통 처리(예 버튼 배선·창 닫기 순서)는 부모가 맡고 파생은 RunYes()만 구현
UI 이펙트 — before UIEffect는 public static instance 싱글톤이 CanvasGroup·Image·Coroutine을 직접 소유, 효과마다 FadeOutScreen·ShowDeathScreen 같은 전용 메서드를 따로 구현. after는 UIFader·UIShake가 SO에서 효과 종류를 드롭다운으로 선택하고 채널로 재생/정지를 요청
설정 UI — before는 SoundSettingUI·InputKeySettingUI·MouseSettingUI 모두 SaveManager.instance를 직접 호출, MenuUI가 셋을 SerializeField로 물고 셋도 MenuUI를 다시 참조. after는 SettingsWindow + VolumeSlider + MouseSpeedSlider + KeyRebindButton이 부모-자식 관계 없이 각자 독립적으로 주입
세이브 슬롯 UI — before는 MenuUI 안에서 activeSelf 분기로 SaveManager.instance.SelectedIndex를 직접 조작. after는 SlotListWindow + SaveSlotButtonView + ISaveSlots 계약을 통해 주입
결과 및 회고
UI에서 바뀐 것은 화면 개수가 아니라 책임의 위치
- 창 상태 판단:
GameObject.activeSelf조회 → 스택이 순서를 대신 기억 - 창 등록:
MenuUI에 필드를 수동 배선 →UIRoot가 자식을 자동 수집, 창 추가 시 등록 코드 불필요 - 시스템 접근: 싱글톤 직접 호출 → 인터페이스 선택적 주입, 창이 게임 내부 구조를 몰라도 됨
문제
입력을 받아도 되는지 가드 조건을 매번 나열해 판별
출시작은 PlayerController.Update()가 매 프레임 폴링하며 Input.GetKeyDown(...)을 조건마다 반복 호출
- 입력을 받아도 되는지를 가드 조건 나열로 판별 —
menuUI.isPlayerControlDisabled,playerState.playerCurrentState == DEAD,playerState.doNotAct,player.IsPerformingHitAction,playerState.isPerfomingSklill,!PlatformSwitcher.instance.IsPCPlatform6종 - 캐릭터 분기(
maskChange.CurrentMask == maskChange.HumanMask)가 입력 처리 안에 섞여, 같은 키 처리가 캐릭터마다 복제 - 키를 눌렀을 때 할 일을
humanMaskSkill.NormalAttack()식으로 구체 클래스를 직접 호출
문제 분석
게임 상태를 한 곳이 아니라 각자 판단
게임 상태를 관리하는 전담 클래스가 없어, Time.timeScale을 여러 곳에서 직접 조작하고 isPaused·IsPlayingMainMenu·IsTimelinePlaying 같은 개별 bool을 코드 곳곳에서 각자 조회. “지금 게임 중인가 / 메뉴인가 / 컷씬인가”를 한 곳이 아니라 각자 판단하다 보니, 입력 가드 조건도 상태 조회도 계속 늘어나는 구조로 확인
개선
상태가 도메인을 고르고, 핸들러가 처리
구성 — InputHub · 도메인 핸들러 3종
| 구성 | 줄 | 책임 |
|---|---|---|
InputHub | 166 | 입력을 받아 현재 게임 상태에 맞는 도메인 핸들러로 분배 |
GameplayInputHandler | 18 | 게임플레이 입력 처리 |
MenuInputHandler | 17 | 메뉴 입력 처리 |
CutsceneInputHandler | 27 | 컷씬 입력 처리 |
IDomainInputHandler | 14 | 도메인 처리 계약 |
MobileInputButton | 65 | 모바일 버튼을 같은 경로로 흡수 |
InputKeySettings | 30 | 키 리바인딩 저장 연동 |
InputHub가Dictionary<GameStateType, IDomainInputHandler>를 만들고, 입력이 오면_gameStateProvider.Current로 핸들러를 찾아 전달 — 가드 조건 나열 제거- 소비자는
IInputPressedProvider·IInputMoveProvider·IMenuInputProvider·ICutsceneInputProvider중 필요한 것만 주입받고, 입력 원본(PC/모바일)을 모름 GameStateManager가 상태를Push/Pop으로 쌓아 관리하고,CursorController·GamePauseController가 상태 변화를 받아 각자 처리
핵심 대비
| before | after | |
|---|---|---|
| 입력 방식 | 매 프레임 폴링 | 이벤트 |
| 입력 가능 여부 | 가드 조건 6종 나열 | 현재 게임 상태로 핸들러 선택 |
| 캐릭터 분기 | 입력 처리 안에서 분기, 키 처리 복제 | 없음 (상태머신이 담당) |
| 실행 대상 | 구체 클래스 직접 호출 | 이벤트 발행, 구독자가 처리 |
| 모바일 대응 | PlatformSwitcher.instance.IsPCPlatform 조건 분기 | MobileInputButton이 같은 경로로 흡수 |
| 일시정지 | Time.timeScale 직접 조작 9곳 | GamePauseController 1곳 |
결과 및 회고
- 직접 폴링하던 입력을 Unity Input System 기반 이벤트로 전환, 소비자가 입력 원본(PC/모바일)을 몰라도 되는 구조 확보
- 게임 상태가 핸들러를 고르는 구조가 되면서, 새 상태를 추가해도 기존 입력 처리 코드는 그대로
회고
- 처음부터 완성된 설계는 아니었음. 시스템 평가 과정에서 구독 해제가 안 되는 구조와 우회 전달 경로를 발견해 뒤늦게 정리(이슈 #99). 평가 커맨드가 없었다면 놓쳤을 문제
문제
플레이어·UI·입력 밖에도 흩어진 문제들
리팩토링 범위 밖에서도 실제로 배포를 가로막거나 성능에 영향을 주는 문제가 존재. Google Play Console의 용량 제한 초과, 모바일 GPU 프레임 드롭, 나무에 가려 보이지 않는 플레이어, 수치 데이터를 다루기 어려운 인스펙터까지 성격이 전부 다른 문제들
문제 분석
추측이 아니라 계측으로 원인부터 좁혀야 하는 문제들
성격이 다른 문제라 공통된 해법이 없고, 각각 프로파일러·리포트 도구로 원인을 먼저 좁힌 뒤에야 조치가 나오는 구조. 아래는 문제별로 원인을 특정하고 조치한 기록
개선
빌드 용량 감축
추측으로 지우는 대신 용량 분포부터 계측
Google Play Console이 압축 다운로드 크기 500MB 제한을 넘겼다며 업로드 차단(Total 550.73MB). 추측으로 에셋을 지우는 대신 com.unity.build-report-inspector 패키지로 용량 분포를 먼저 계측, 상위 항목이 전부 비압축 텍스처임을 확인
- 원인: Player Settings의 Texture Compression 기본값은 개별 텍스처가
Automatic일 때만 적용되는데, 개별 오버라이드가 걸린 텍스처들이 이 설정을 통째로 무시 - 조치:
TextureCompressTool.cs(에디터 확장)로 프로젝트 전체 텍스처의 Android 플랫폼 오버라이드를 일괄 적용. Max Size는 기존값보다 키우지 않는 조건(Min(기존값, 1024))으로 제한 - 포맷은 ETC2·ASTC 4x4·6x6·8x8을 비트레이트·품질로 비교해 ASTC 6x6(3.56bpp) 채택. 목표 기기 Galaxy S10(GLES 3.2)에서 지원
- 결과: Total 550.73MB → 389.69MB, Download 522.18MB → 361.15MB로 500MB 제한 통과
- 부수 문제: 압축 적용 후 터레인이 번들거리는 현상 발생. 가설 4개(ASTC 압축 손상·Max Size 축소·Normal Map Encoding·Smoothness Source)를 하나씩 검증해 Smoothness Source(Diffuse Alpha → Constant)가 원인임을 확정
렌더링 최적화 도구
커버리지 기준을 “화면 노출”에서 “실사용 variant”로 교체
셰이더 팝인 — 출시작은 셰이더 컴파일 지연을 막으려고 씬 시작 시 카메라로 맵 전체를 훑는 방식. 기준이 “화면에 보였는가”라 풀링 오브젝트를 커버하지 못하는 한계. ShaderVariantCollection으로 실제 사용된 variant만 기록하고 로딩 구간에서 WarmUp()으로 일괄 컴파일하도록 변경, 커버리지 기준을 “화면 노출”에서 “실사용 variant”로 교체 (정량 개선폭은 실측 남음)
배칭 손실 — SRP Batcher는 동일 머티리얼 에셋일 때만 배칭. 값이 같아도 에셋이 복제돼 있으면 별개로 취급되어 배칭이 깨지는 구조. MaterialDuplicateFinder.cs로 씬 내 모든 머티리얼을 shader + 프로퍼티값 시그니처 기준으로 그룹핑해 중복 검출하는 도구 제작. 출시작 저장소에는 Copy Rock4.mat 같은 복제 흔적이 실제로 확인됨
이 과정에서 함께 만든 도구: MeshCombinerTool(메시 병합) · TextureCompressTool · SceneComponentBatchEditor(컴포넌트 값 일괄 편집) · SceneUsageFinder(사용처 추적) · RemoveMissingScriptsTool(Missing 스크립트 정리)
모바일 GPU 병목 분석
Unity Profiler가 못 주는 GPU 타이밍을 직접 실측
Galaxy S10(Exynos 9820 / Mali-G76 MP12)에서 40 FPS 미달. Unity Profiler는 대부분의 모바일 기기에서 GPU 타이밍을 지원하지 않아, Arm Performance Studio(Streamline)로 Mali GPU 하드웨어 카운터 직접 실측
- 씬별 카운터를 비교해 모든 씬에서 Fragment(픽셀) 단계가 80~87%로 지배적임을 확인 — 병목은 정점·타일링이 아니라 픽셀 처리
- 씬5(불기둥 이펙트 9개 동시 생성 구간)를 프레임 단위로 분해한 결과, GPU 시간의 74.9%(19.2ms)가 이펙트 자체가 아니라
Setup Camera단계에서 발생 - 프리팹 → 머티리얼 → 셰이더그래프까지 역추적해 Scene Color 노드(화면 전체 복사)가 원인임을 특정. 해당 머티리얼은 왜곡 강도(
_Distortionpower)가 0이라 왜곡 효과가 시각적으로 전혀 쓰이지 않는데도 매 프레임 화면 복사 비용만 지불하는 구조. 노드를 삭제하고 상수로 대체해, 시각적 변화 없이 프레임의 74.9%를 차지하던 낭비 제거 - 조명 intensity가 0이 되는 순간 748ms짜리 프레임(셰이더 variant 런타임 컴파일)도 확인, Unity의 Shader Variant 트래킹이 Terrain 셰이더를 캡처하지 못하는 한계에 막혀 이 부분은 미해결로 기록
가림 오브젝트 투시 기능
세 번 폐기하고 나서야 나온 최종 구조
카메라와 플레이어 사이의 나무가 시야를 가리는 문제. 나무 종류·개수가 계속 늘어나는 맵이라 개별 대응 불가능
머티리얼에 직접 값을 쓰는 방식(슬롯 1개뿐이라 나무가 늘면 갱신 안 됨), Shader.SetGlobal(머티리얼이 프로퍼티 값을 계속 소유해 전역값을 무시), Physics.Raycast 판정(어느 오브젝트가 가렸는지 구분 못 함) 세 가지를 차례로 폐기. 최종적으로 hlsl에 전역 셰이더 변수를 직접 선언하고, 셰이더가 자기 픽셀의 깊이를 플레이어 깊이와 비교해 스스로 판정하도록 변경 — 나무 종류·개수가 늘어도 코드 수정 없이 동작
폐기한 세 방법과 그 이유는 클래스 코드 주석에 그대로 기록. 최종 형태만 보면 “왜 프로퍼티 대신 전역 변수를 썼는지” 알 수 없어, 다음에 보는 사람이 폐기한 방식으로 되돌릴 위험 방지 목적
데이터 편집 도구 · 다국어
데이터화만으로는 부족하고, 다룰 수단까지 만들어야 완성
수치를 코드 밖 SO(ScriptableObject)로 빼면서 인스펙터에 숫자·ID로만 보이는 문제 발생. 데이터화만으로는 부족하고, 그 데이터를 다룰 수단까지 만들어야 완성이라는 판단
TextTableCsv(CSV 임포트) ·UIOrderViewer(Canvas 렌더 우선순위 확인) ·TextKeyDrawer(텍스트 키 드롭다운) ·StateDataEditor(상태 데이터 편집) · PropertyDrawer 4종(텍스트 테이블·오브젝트 토글·카메라 흔들림·오디오 카탈로그) 제작- 다국어는 처음 Unity Localization + Addressable 패키지를 도입했다가, 이 프로젝트 규모에 비해 무겁다고 판단해 걷어내고
TextTableData(SO) 기반 자체 구현으로 전환. 문자열 키 오타로 인한 조용한 실패를 막기 위해TextKeyAttribute+ Drawer로 드롭다운 제공, 번역 갱신은 CSV 임포트로 처리해 스프레드시트 작업 결과를 그대로 반영
스토어 업데이트 배포
배포하지 않으면 동작 여부가 확인되지 않은 채로 끝남
구조를 아무리 잘 바꿔도 화면은 그대로라 유저가 알아챌 수 없는 작업. 저장소에 커밋만 쌓고 끝내면 정말로 동작하는지 확인되지 않은 채로 끝난다고 판단, 재설계한 빌드를 이미 출시된 Steam·Google Play 채널에 업데이트로 배포
- 배포를 시도하면서 Google Play Console이 500MB 제한으로 업로드 차단. Unity 6로 옮기며 텍스처 임포트 설정이 재정렬된 것이 원인이었고, 이 문제를 계측 기반으로 해결한 것이 위 「빌드 용량 감축」
- 배포 준비 과정에서 Galaxy S10 기준으로 프레임을 다시 측정하다 보스 이펙트 구간의 GPU 병목 발견. 둘 다 에디터에서는 보이지 않던 문제
- 출시작에서 “에디터에서는 멀쩡했지만 빌드에서 입력이 죽었던” 사고를 겪은 뒤로, 빌드해서 실기기에 올려 보기 전까지는 끝난 게 아니라는 기준을 리팩토링에도 동일 적용
결과 및 회고
- 계측 기반으로 원인을 좁혀 해결, Play Console 용량 제한과 GPU 병목처럼 배포를 막던 문제를 실제로 통과
- Steam·Google Play에 업데이트로 배포해, 구조 변경이 화면에 드러나지 않아도 실제 환경에서 문제없이 동작함을 확인
회고
- 조명 전환 hitch(셰이더 variant 런타임 컴파일)는 Unity의 Shader Variant 트래킹이 Terrain 셰이더를 캡처하지 못하는 한계에 막혀 미해결로 남김. 추측으로 채우지 않고 미해결 그대로 기록