"전부 공용 로직이야!" — 타운씬/배틀씬 이중관리의 폐해와 대통합 리팩토링기
들어가며: 똑같은 코드가 왜 두 군데에 있는 거죠?
게임 개발을 하다 보면 자연스럽게 씬(Scene)이 나뉩니다. 타운에서 돌아다니는 씬, 전투하는 씬. 처음엔 "어차피 다른 로직이니까 분리하자"라는 합리적인 판단으로 시작합니다. 그런데 시간이 지나면 어떻게 될까요?
이동 로직이 두 벌. 높이 처리가 두 벌. 길찾기가 두 벌. 계단 처리가 두 벌. 충돌 처리가 두 벌.
전부 같은 일을 하는 코드가, 미묘하게 다른 방식으로, 두 군데에 존재합니다.
myDGC 프로젝트에서 정확히 이 상황이 벌어졌습니다. BattleScene.ts는 1941줄, TownScene.ts는 409줄. 이동 시스템도 MovementSystem과 CharacterMovement 두 개가 따로 있었습니다. 어느 날 계단 렌더링 버그를 고치다가 깨달았습니다 — 이건 구조적으로 잘못된 거라고.
문제 발견: 계단 하나 고치려다 판도라의 상자를 열다
시작은 단순했습니다. 타일에디터에서 회전→센터링 순서 버그를 발견해서 고쳤는데, 같은 버그가 다른 곳에도 있을 것 같았습니다. Claude에게 분석을 맡겼더니 돌아온 대답이 충격적이었습니다.
BattleScene, TownScene 둘 다 피스 렌더링 시
rotation→applyDungeonModelTransform순서 버그 (센터링 틀어짐) 피스 렌더링 + 계단 존 등록 + 충돌 등록 코드가 TownScene에만 있고 BattleScene에 없음 같은 로직이 두 곳에 중복되어야 할 구조
같은 버그가 두 곳에 있다? 아니, 더 심각한 건 한쪽에는 기능 자체가 없다? 이게 이중관리의 진짜 무서운 점입니다. 한쪽을 고치면 다른 쪽은 방치되고, 한쪽에 기능을 추가하면 다른 쪽에는 까먹습니다.
현재 상태를 정리해보니 이랬습니다:
// 이중관리의 현장
BattleScene.ts (~1500줄) — 자체 MovementSystem, 전투 시스템, 유닛 관리
TownScene.ts (~400줄) — 자체 CharacterMovement, 포탈, 클릭이동
MovementSystem과 CharacterMovement. 이름만 다르지 둘 다 경로 찾기 + 이동 + 높이 처리를 합니다. 똑같은 일을 하는 시스템이 두 개.
"이중관리 안 할 거야!" — 이 한마디로 대규모 리팩토링이 시작됐습니다.
통합 계획: 50개 태스크, 3개 Phase
분노에 찬 결심은 좋지만 1941줄짜리 파일과 409줄짜리 파일을 무작정 합칠 수는 없습니다. Claude와 함께 체계적인 계획을 세웠습니다.
Phase 1 (이동 시스템 통합) → 독립, 먼저 실행 Phase 2 (전투 시스템 분리) → Phase 1 완료 후 (UnifiedMovementSystem 필요) Phase 3 (씬 통합) → Phase 1+2 완료 후
핵심 아이디어는 이렇습니다:
- 이동 시스템부터 하나로 —
MovementSystem+CharacterMovement→UnifiedMovementSystem - 전투 로직을 씬에서 빼내기 — BattleScene에 내장된 전투 시스템을
BattleManager로 분리 - 씬을 하나로 — 맵 데이터만 다르고 로직은 동일한
GameScene하나로 통합
약 50개의 세부 태스크로 쪼개서 문서화한 뒤, Phase별로 순차 실행했습니다.
// Before: 씬마다 다른 이동 시스템
class BattleScene {
private movementSystem: MovementSystem; // 전투용 이동
}
class TownScene {
private characterMovement: CharacterMovement; // 타운용 이동
}
// After: 하나의 통합 이동 시스템
class GameScene {
private movement: UnifiedMovementSystem; // 모든 씬에서 동일
private isBattle: boolean; // 모드만 다름
}
실행: 그리고 예상치 못한 버그들
리팩토링 자체는 Claude의 도움으로 비교적 순조롭게 진행됐습니다. Phase 1 완료, 기존 파일 삭제, 컴파일 통과. Phase 2+3 완료, 오케스트레이터 검수까지. 결과는 깔끔했습니다:
| 항목 | Before | After |
|---|---|---|
| 씬 파일 | BattleScene.ts (1941줄) + TownScene.ts (409줄) | GameScene.ts (508줄) |
| 이동 시스템 | MovementSystem + CharacterMovement (2개) | UnifiedMovementSystem (1개) |
| 전투 시스템 | BattleScene에 내장 | BattleManager.ts + BattleUIManager.ts |
2350줄이 508줄로. 전투 로직은 깔끔하게 분리. 이동 시스템은 하나로 통합. 완벽해 보였습니다.
하지만 진짜 삽질은 여기서부터였습니다.
삽질 1: 경로 시각화가 사라졌다
통합 후 타운에서 클릭 이동을 했는데 경로 표시가 안 됩니다. 원인을 추적해보니:
PathRenderer는
enablePathRenderer()를 호출해야 활성화되는데,requestMove에서 경로를 그리는 코드가 없습니다. 이전 CharacterMovement에서는setMoveTarget시pathRenderer.showWorldPath()를 호출했었습니다.
두 시스템을 합치면서 CharacterMovement에만 있던 경로 시각화 호출이 누락된 겁니다. 통합의 아이러니 — 이중관리를 없애려다 기능을 빠뜨리는 것.
삽질 2: 메이지가 공격을 안 한다
전투 테스트를 돌렸더니 나이트와 로그는 잘 싸우는데 메이지만 가만히 서 있습니다. 뭔가 이상했습니다.
이전
MovementSystem에서는completeMovement에서unit.stopMoving()을 호출했었습니다.UnifiedMovementSystem으로 통합하면서Movable인터페이스 기반으로 바꿨는데,Movable에는stopMoving()이 없어서 누락된 겁니다.
stopMoving() 한 줄이 빠져서 유닛이 MOVING 상태에 영원히 갇혀버린 겁니다. CombatSystem은 MOVING 상태인 유닛을 공격 대상으로 잡지 않으니 메이지는 영원히 전투에 참여할 수 없었습니다.
그런데 왜 나이트와 로그는 괜찮았을까요?
나이트/로그가 괜찮았던 건 CombatSystem이 먼저 COMBAT 상태로 바꿔서 우연히 동작한 것이고, 메이지는 원거리라 COMBAT 진입 타이밍이 달라서 드러난 겁니다.
우연히 동작하는 코드. 이것만큼 무서운 건 없습니다. 근접 유닛은 우연히 되고 원거리 유닛만 안 되는 버그라니. 이런 건 테스트 케이스를 아무리 꼼꼼하게 짜도 놓치기 쉽습니다.
삽질 3: 애니메이션 속도가 안 바뀐다
걷기 애니메이션 속도를 조정했는데 반영이 안 됩니다. 혹시 BattleManager에서 별도로 덮어쓰나? 확인해봤더니:
별도 덮어쓰기 없습니다.
DEFAULT_CONFIGS.walk.speed = 1.8은 타운/배틀 모두 동일하게 적용됩니다. 이미 통합된 상태입니다.
원인은 허무하게도 브라우저 캐시. Vite HMR이 상수 변경을 제대로 핫리로드하지 못하는 경우가 있어서 Ctrl+Shift+R 강제 새로고침이 필요했습니다. 리팩토링의 부수적 삽질 — 코드 문제인 줄 알고 한참 추적했는데 브라우저 캐시였다니.
최종 구조: 깔끔한 단일 책임
모든 삽질을 거치고 나서 완성된 구조는 이렇습니다:
// GameScene — 공통 인프라만 담당 (508줄)
class GameScene {
create() {
// Scene → Camera → Lighting → Map → Terrain → Pieces → Collision → Movement → Mode분기
}
update() {
// Camera → Movement → (Battle | Town)
}
}
// 계단 로직 — TileMap 하나에 집중
tileMap.addStairZone() // 계단 존 데이터 저장
tileMap.addStairLink() // 패스파인딩용 타일 쌍 등록
tileMap.isOnStair() // 월드 좌표 계단 판정
tileMap.getStairProgress() // Y 보간용 progress
// 씬은 맵 데이터만 다르고 로직은 동일
GameScene('town') // 타운 모드
GameScene('battle') // 배틀 모드
이제 계단을 고치면 한 곳만 고치면 됩니다. 이동 로직을 바꾸면 한 곳만 바꾸면 됩니다. 충돌 처리를 추가하면 한 곳만 추가하면 됩니다.
교훈: 이중관리는 기술 부채가 아니라 시한폭탄이다
1. "나중에 합치자"는 거짓말
분리된 코드는 시간이 지날수록 diverge합니다. 한쪽에 계단 처리를 추가하면 다른 쪽에는 안 합니다. 한쪽의 버그를 고치면 다른 쪽은 모릅니다. "나중에"는 오지 않고, 차이만 벌어집니다.
2. 통합할 때 가장 위험한 건 "누락"
두 시스템을 합치면 양쪽의 기능을 모두 가져와야 하는데, stopMoving() 같은 한 줄짜리 호출을 놓치기가 너무 쉽습니다. 그리고 그게 "나이트는 되고 메이지는 안 되는" 같은 기괴한 버그로 나타납니다.
3. Phase를 나눠서 순차 실행하라
1941줄 + 409줄을 한 번에 합치려 했으면 망했을 겁니다. 이동 통합 → 전투 분리 → 씬 통합, 각 단계마다 컴파일 확인. 이 구조 덕분에 중간에 문제가 생겨도 롤백 가능한 단위가 있었습니다.
4. 우연히 동작하는 코드를 경계하라
가장 무서운 버그는 "되는 것처럼 보이는 버그"입니다. 근접 유닛은 상태 전환 타이밍이 우연히 맞아서 동작하고, 원거리 유닛만 안 되는 상황. 이런 버그는 리팩토링이 아니었으면 영영 발견 못 했을 수도 있습니다.
결국 돌고 돌아 하나의 진리: 같은 일을 하는 코드는 한 곳에만 있어야 합니다. 단순하지만, 프로젝트가 커지면 자꾸 잊게 되는 원칙입니다. 이번 리팩토링으로 2350줄이 508줄이 됐고, 앞으로 추가할 모든 기능은 한 번만 구현하면 됩니다. 그 안도감이 이 삽질의 가장 큰 보상입니다.