게임 프로젝트에서 리팩토링이 생존을 결정한다

2019년, 우리 팀은 출시 2주를 앞두고 전투 시스템을 전면 재작성해야 하는 상황에 몰렸다. 원인은 단순했다. 프로토타입 단계에서 급하게 짠 전투 로직을 한 번도 정리하지 않은 채 8개월을 달려온 것이다. 처음에는 적 하나와 플레이어 하나의 1:1 전투만 있었지만, 기획이 확장되면서 다대다 전투, 상태이상, 스킬 조합, 보스 패턴까지 추가됐다. 새 기능을 넣을 때마다 기존 코드 어딘가가 깨졌고, 버그를 고치면 다른 버그가 생겼다. 마지막에는 누구도 그 코드를 건드리고 싶어 하지 않았다.
그때 깨달은 것이 있다. 리팩토링은 코드를 예쁘게 만드는 작업이 아니라, 프로젝트가 끝까지 살아남을 수 있게 하는 생존 전략이라는 것이다. 게임 개발은 특히 기획 변경이 잦고, 개발 기간이 길며, 시스템 간 의존성이 높다. 이 환경에서 리팩토링을 미루면 기술 부채가 복리로 쌓이고, 결국 프로젝트 자체를 위협한다.
이 글에서는 게임 프로젝트에서 리팩토링이 왜 특별히 중요한지, 언제 해야 하는지, 그리고 실전에서 어떻게 적용하는지를 구체적인 사례와 함께 정리한다.
기술 부채가 게임 프로젝트를 죽이는 방식
기술 부채라는 표현은 워드 커닝햄이 처음 사용한 비유다. 금융 부채처럼, 당장은 빠르게 코드를 작성할 수 있지만 나중에 이자를 갚아야 한다는 뜻이다. 게임 개발에서 이 부채가 특히 치명적인 이유는 세 가지다.
첫째, 게임은 시스템 간 결합이 매우 촘촘하다. 전투 시스템이 인벤토리와 연결되고, 인벤토리는 UI와 연결되며, UI는 네트워크 동기화와 연결된다. 한 곳의 구조적 문제가 연쇄적으로 다른 시스템에 영향을 미친다. 웹 서비스에서 API 하나를 수정하는 것과, 게임에서 데미지 계산 공식을 바꾸는 것은 파급 범위가 전혀 다르다.
둘째, 기획 변경이 일상이다. “보스가 3페이즈로 바뀌었습니다”, “스킬에 쿨타임 시스템을 추가해 주세요”, “멀티플레이를 넣기로 했습니다.” 이런 요청이 개발 중반 이후에도 계속 들어온다. 리팩토링 없이 쌓아올린 코드는 이 변경을 수용할 유연성이 없다.
셋째, 게임의 버그는 사용자 경험을 직접 망가뜨린다. 서버 응답이 0.5초 느린 것과, 보스전 도중 캐릭터가 벽에 끼는 것은 플레이어에게 전달되는 불쾌함의 크기가 다르다. 구조적 문제에서 비롯된 버그는 수정해도 재발하고, 재발할수록 팀의 사기가 떨어진다.
우리 팀이 겪은 구체적인 사례를 하나 더 들어보겠다. 2020년 프로젝트에서 인벤토리 시스템을 담당했을 때, 초기에는 아이템을 List<Item>으로 단순하게 관리했다. 프로토타입에서는 문제가 없었다. 그런데 장비 장착, 아이템 합성, 거래소 연동, 퀘스트 보상 지급까지 붙으면서, 하나의 리스트가 게임 전체의 병목이 됐다. 아이템을 추가하는 코드가 7군데에 흩어져 있었고, 각각의 코드가 서로 다른 방식으로 인벤토리를 조작하고 있었다. 결국 “아이템이 사라지는 버그”가 간헐적으로 발생했는데, 원인을 찾는 데만 3일이 걸렸다. 리팩토링을 하고 나서야, 인벤토리 조작을 단일 인터페이스로 통합하여 문제를 근본적으로 해결할 수 있었다.
리팩토링의 타이밍: 언제 해야 하고, 언제 하면 안 되는가
리팩토링에서 가장 어려운 판단은 “언제”다. 너무 일찍 하면 아직 확정되지 않은 기획에 맞춰 구조를 잡게 되고, 너무 늦으면 손대기 어려운 수준까지 복잡해진다.
경험상, 게임 프로젝트에서 리팩토링이 필요한 시점은 크게 세 가지다.
프로토타입에서 본 개발로 넘어갈 때. 프로토타입은 빠르게 검증하는 것이 목적이므로, 구조가 엉망이어도 괜찮다. 하지만 본 개발에 들어가면서 프로토타입 코드를 그대로 가져가는 것은 위험하다. 이 시점에서 핵심 시스템의 구조를 한 번 정리해야 한다. 우리 팀은 이 단계를 “구조 잡기 스프린트”라고 부르며, 본 개발 시작 전 1~2주를 별도로 확보한다.
같은 종류의 버그가 반복될 때. 버그 하나를 고쳤는데 비슷한 버그가 다른 곳에서 나타난다면, 그것은 코드의 문제가 아니라 구조의 문제다. 이때가 리팩토링 신호다. 예를 들어, “적이 죽었는데 공격 이펙트가 남아 있다”는 버그를 고쳤더니, “플레이어가 죽었는데 버프 아이콘이 남아 있다”는 버그가 나왔다면, 오브젝트 소멸 시 관련 리소스를 정리하는 공통 구조가 없다는 뜻이다.
새 기능 추가가 비정상적으로 오래 걸릴 때. 단순한 기능인데 구현에 일주일 이상 걸린다면, 기존 코드가 변경을 수용하지 못하는 상태다. 이 상황에서 기능을 억지로 끼워 넣으면 기술 부채가 더 쌓인다. 차라리 관련 부분을 먼저 정리하고 기능을 추가하는 것이 전체 일정에서 더 빠르다.
반대로, 리팩토링을 하면 안 되는 시점도 있다. 출시 직전에는 구조를 바꾸면 예상치 못한 문제가 생길 수 있으므로, 최소한의 수정만 해야 한다. 그리고 기획이 아직 확정되지 않은 기능에 대해 미리 구조를 잡는 것도 낭비다. “나중에 멀티플레이가 들어올 수도 있으니까 미리 네트워크 레이어를 만들어 두자”는 식의 접근은, 실제로 그 기능이 들어올 때 요구사항이 달라져서 결국 다시 작성하게 되는 경우가 많다.
실전 리팩토링 기법: 게임 코드에서 자주 쓰는 패턴
리팩토링의 구체적인 기법은 마틴 파울러의 “리팩터링” 책에 잘 정리되어 있다. 여기서는 그중에서도 게임 프로젝트에서 특히 자주 쓰이는 것들을 실제 코드와 함께 소개한다.
거대한 함수 쪼개기
게임 개발에서 가장 흔한 코드 악취는 하나의 함수가 너무 많은 일을 하는 것이다. 특히 Update() 함수에 모든 로직을 넣는 습관이 위험하다.
리팩토링 전:
void Update()
{
// 입력 처리
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
transform.position += new Vector3(h, 0, v) * speed * Time.deltaTime;
// 공격 판정
if (Input.GetKeyDown(KeyCode.Space))
{
Collider[] hits = Physics.OverlapSphere(transform.position, attackRange);
foreach (var hit in hits)
{
if (hit.CompareTag("Enemy"))
{
hit.GetComponent<Enemy>().TakeDamage(attackPower);
}
}
}
// 체력 UI 갱신
hpBar.fillAmount = currentHP / maxHP;
if (currentHP <= 0) { GameOver(); }
}
리팩토링 후:
void Update()
{
HandleMovement();
HandleAttack();
UpdateHealthUI();
}
void HandleMovement()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
transform.position += new Vector3(h, 0, v) * speed * Time.deltaTime;
}
void HandleAttack()
{
if (!Input.GetKeyDown(KeyCode.Space)) return;
Collider[] hits = Physics.OverlapSphere(transform.position, attackRange);
foreach (var hit in hits)
{
if (hit.CompareTag("Enemy"))
hit.GetComponent<Enemy>().TakeDamage(attackPower);
}
}
void UpdateHealthUI()
{
hpBar.fillAmount = currentHP / maxHP;
if (currentHP <= 0) GameOver();
}
이렇게 분리하면, 공격 로직에 버그가 있을 때 HandleAttack()만 보면 된다. 이동 로직을 변경할 때 공격이나 UI가 깨질 걱정도 없다. 단순한 변경이지만, 프로젝트가 커질수록 이 차이가 크게 벌어진다.
매직 넘버 제거
게임 코드에서 숫자가 직접 박혀 있는 경우가 많다. “왜 30인가?”를 6개월 뒤에 아무도 기억하지 못하는 상황이 반드시 온다.
// 리팩토링 전
if (player.Level >= 30 && player.Gold >= 5000)
{
UnlockArea("DragonLair");
}
// 리팩토링 후
private const int DRAGON_LAIR_REQUIRED_LEVEL = 30;
private const int DRAGON_LAIR_REQUIRED_GOLD = 5000;
if (player.Level >= DRAGON_LAIR_REQUIRED_LEVEL
&& player.Gold >= DRAGON_LAIR_REQUIRED_GOLD)
{
UnlockArea("DragonLair");
}
게임에는 밸런스 수치가 수백 개 존재한다. 이것들이 코드 곳곳에 하드코딩되어 있으면, 밸런스 조정 때마다 모든 파일을 뒤져야 한다. 상수로 분리하거나, 더 나아가 데이터 테이블로 외부화하면 기획자가 직접 수치를 조정할 수 있게 된다.
조건문 체인을 다형성으로 전환
게임에서 적의 종류가 늘어날 때마다 if-else 체인이 길어지는 것은 흔한 문제다.
// 리팩토링 전
void ProcessEnemyAttack(Enemy enemy)
{
if (enemy.Type == "Goblin")
{
player.TakeDamage(enemy.Attack);
}
else if (enemy.Type == "Dragon")
{
player.TakeDamage(enemy.Attack * 2);
player.ApplyBurn(3);
}
else if (enemy.Type == "Ghost")
{
if (!player.HasHolyShield)
player.TakeDamage(enemy.Attack);
}
}
적 종류가 50개로 늘어나면 이 함수는 수백 줄이 된다. 각 적 타입이 자신의 공격 방식을 스스로 정의하도록 바꾸면 해결된다.
// 리팩토링 후
public abstract class Enemy : MonoBehaviour
{
public int Attack;
public abstract void PerformAttack(Player player);
}
public class Dragon : Enemy
{
public override void PerformAttack(Player player)
{
player.TakeDamage(Attack * 2);
player.ApplyBurn(3);
}
}
public class Ghost : Enemy
{
public override void PerformAttack(Player player)
{
if (!player.HasHolyShield)
player.TakeDamage(Attack);
}
}
새로운 적을 추가할 때 기존 코드를 전혀 수정하지 않아도 된다. 이것이 개방-폐쇄 원칙의 실전 적용이다.
팀에서 리팩토링 문화를 만드는 법
개인이 리팩토링의 가치를 알아도, 팀 전체가 동의하지 않으면 실행하기 어렵다. “지금 일정도 빠듯한데 리팩토링할 시간이 어디 있나”라는 말은 모든 게임 개발 팀에서 들린다. 이 저항을 넘기 위해 우리 팀이 시도한 방법 세 가지를 공유한다.
보이스카우트 규칙을 적용했다. “캠프장을 떠날 때 왔을 때보다 깨끗하게 남겨라”는 보이스카우트 원칙을 코드에 적용한 것이다. 어떤 파일을 수정할 때, 그 파일에서 작은 개선 하나를 함께 한다. 변수명 하나를 고치거나, 중복 코드 한 줄을 함수로 추출하거나, 불필요한 주석을 제거하는 정도다. 한 번에 대규모 리팩토링을 하는 것이 아니라, 매일 조금씩 코드 품질을 올리는 습관이다.
기술 부채 보드를 운영했다. 스프린트 보드 옆에 “기술 부채” 보드를 별도로 만들었다. 개발 중 발견한 구조적 문제를 카드로 적어두고, 매 스프린트마다 한두 장씩 처리하는 방식이다. 이렇게 하면 리팩토링이 별도의 큰 작업이 아니라, 일상적인 개발 프로세스의 일부가 된다.
코드 리뷰에서 구조적 피드백을 장려했다. “이 코드가 동작하는가?”만 보는 것이 아니라, “이 구조가 다음 기능 추가를 수용할 수 있는가?”를 함께 보도록 했다. 처음에는 리뷰 시간이 길어졌지만, 몇 달 뒤에는 오히려 전체 개발 속도가 빨라졌다. 구조적 문제를 초기에 잡으니까, 나중에 대규모 수정을 할 필요가 줄어든 것이다.
핵심 정리
-
리팩토링은 사치가 아니라 생존 전략이다. 게임은 시스템 간 결합이 강하고 기획 변경이 잦기 때문에, 기술 부채를 방치하면 프로젝트 후반에 치명적인 대가를 치른다.
-
타이밍이 핵심이다. 프로토타입에서 본 개발로 넘어갈 때, 같은 종류의 버그가 반복될 때, 단순한 기능 추가가 비정상적으로 오래 걸릴 때가 리팩토링 시점이다.
-
거대 함수 분리, 매직 넘버 제거, 조건문의 다형성 전환은 게임 코드에서 가장 효과가 큰 리팩토링 기법이다.
-
보이스카우트 규칙, 기술 부채 보드, 구조적 코드 리뷰로 팀 차원의 리팩토링 문화를 만들 수 있다.
-
리팩토링의 목적은 코드를 예쁘게 만드는 것이 아니라, 변경에 유연하게 대응할 수 있는 구조를 확보하는 것이다.
마치며: 리팩토링을 미루는 것은 미래의 자신에게 빚을 지는 일이다
2019년 출시 직전에 전투 시스템을 재작성해야 했던 경험 이후, 나는 모든 프로젝트에서 “지금 이 코드를 3개월 뒤에도 자신 있게 수정할 수 있는가?”라는 질문을 스스로에게 던진다. 답이 아니라면, 지금이 리팩토링할 때다.
게임 개발의 본질은 끊임없는 변경이다. 기획은 바뀌고, 요구사항은 늘어나며, 플랫폼은 진화한다. 이 변화를 수용할 수 있는 코드만이 끝까지 살아남는다. 리팩토링은 그 생존력을 만드는 가장 현실적인 방법이다.
참고 자료
- Martin Fowler. (2018). Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley. [리팩토링 기법의 체계적 분류와 실전 적용법]
- Robert C. Martin. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall. [클린 코드 원칙과 리팩토링의 관계]
- Game Programming Patterns by Robert Nystrom (https://gameprogrammingpatterns.com) [게임 개발에 특화된 디자인 패턴과 구조 설계]
- Unity Documentation: Best Practices - Project Organization (https://docs.unity3d.com) [Unity 프로젝트의 코드 구조화 가이드]