Godot 4 프로젝트 구조 — CSV로 데이터 빼기, 컴포넌트로 로직 쪼개기
Tiny Reign을 만들면서 구조를 두 번 갈아엎었다. 한 번은 데이터가 코드에 박혀 있어서, 다른 한 번은 로직이 한 클래스에 다 몰려 있어서였다. 시점은 달랐지만 결국 같은 작업이었다. 한곳에 뭉쳐 있던 걸 밖으로 빼내는 일.
스탯이 코드에 박혀 있었다
유닛 수가 늘어날수록 계속 신경 쓰이는 게 있었다. 전사 공격력을 바꾸려면 .gd 파일을 열고 숫자를 찾아서 고쳐야 했다. 유닛이 네 종류일 때도 이미 귀찮았는데, 앞으로 더 늘어나면 어떻게 하나 싶었다.
CSV 하나로 시작했다

unit_stats.csv 파일 하나에 유닛 종류별로 행을 나눴다. 열은 unit_type, hp, atk, speed, detection_range, attack_range, attack_cooldown 같은 식이다. 이걸 Autoload 싱글턴인 UnitStatsDB에서 게임 시작할 때 한 번 읽어서 딕셔너리로 들고 있는다.
실제로 유닛이 스탯을 가져올 때는 UnitStatsDB.get_stats(“warrior”) 한 줄이면 된다. CSV 파싱도 Godot의 FileAccess로 충분했다. 복잡한 라이브러리 없이 됐다.
가장 좋은 건 밸런싱이 쉬워졌다는 거다. 전사 체력이 너무 강하다 싶으면 CSV 열어서 숫자 바꾸고 저장하면 끝이다. 코드를 건드릴 필요가 없으니 실수도 줄었다.
AI 협업할 때도 편했다. warrior의 attack_range를 155로 바꿔달라고 하면 AI가 CSV를 직접 수정해준다. 코드 구조를 설명할 필요가 없다. 숫자가 어디 있는지 명확하니까. Red팀 유닛을 추가할 때도 Blue팀 CSV를 복사해서 수치만 조금 바꿨다. 행 하나 추가하는 게 전부였다.
CSV의 함정
CSV는 타입 정보가 없다. 파싱할 때 int()나 float()로 직접 변환해야 한다. 이걸 빠뜨리면 숫자인데 문자열로 들어와서 비교가 안 되는 버그가 생긴다. 처음에 이걸 몰라서 공격 판정이 왜 안 되는지 한참 헤맸다.
열 순서가 바뀌면 파서가 깨질 수 있으니 헤더 이름 기반으로 읽는 게 안전하다. 첫 줄을 키로 쓰는 방식으로 구현하면 나중에 열을 추가해도 기존 코드가 안 깨진다.
발사체 데이터도 같은 방식으로 ProjectileStatsDB를 따로 만들었다. 화살 속도, 데미지, 관통 횟수 같은 것들. 데이터 파일이 분리되어 있으니 나중에 찾기도 쉬웠다.
데이터 주도 설계라는 거창한 말이 있는데, CSV 파일 하나가 그 출발점이 될 수 있다.
이번엔 로직이 뭉쳤다
데이터를 빼고 한동안은 괜찮았다. 그런데 Warrior, Lancer, Monk를 추가하던 날 Character 베이스 클래스가 한계에 왔다. 전투 로직, 버프 처리, 스킬 발동이 한 파일에 다 들어가서 수백 줄이 됐다. 몽크의 힐 스킬 하나 고치려고 전투 코드 사이를 한참 스크롤하다가, 이대로는 안 되겠다 싶어 갈아엎었다.
노드가 곧 컴포넌트다

Godot에서는 컴포넌트 패턴을 따로 구현할 필요가 없다. 기능별 스크립트를 노드로 만들어 Character에 자식으로 붙이면 그게 컴포넌트다. 지금 구조는 CombatComponent(HP·데미지·사망), BuffComponent(버프 적용·스택·지속시간), SkillComponent(발동 조건·실행), EffectComponent(피격 플래시 같은 연출) 넷이다. 전부 평범한 Node 하나에 스크립트를 붙인 것뿐이다.
CombatComponent 전체가 이 정도로 작다.
class_name CombatComponent extends Node
signal died
var current_hp: int = 0
var max_hp: int = 0
var _is_dead: bool = false
func initialize(stats: Dictionary) -> void:
max_hp = int(stats.get("max_hp", 80))
current_hp = max_hp
func take_damage(amount: int) -> void:
if _is_dead:
return
current_hp -= max(1, amount)
if current_hp <= 0:
_is_dead = true
emit_signal("died")
func attack(target: Node, damage: int, attacker: Node = null) -> void:
if not is_instance_valid(target) or target.is_dead():
return
target.take_damage(damage, attacker)
여기서 중요한 건 died 시그널이다. 컴포넌트는 죽었다고 알리기만 하고, 사망 애니메이션이나 시체 처리는 Character 쪽 일이다. 이 선을 지키니까 파일이 40줄 밑으로 유지된다. 데미지가 최소 1은 들어가게 한 max(1, amount)도 방어력이 공격력을 넘어서는 유닛 조합이 나왔을 때 전투가 영원히 안 끝나는 걸 막으려고 넣은 것이다.
통신은 부모를 거친다
컴포넌트끼리 직접 참조하면 의존성이 엉킨다. 그래서 통신은 전부 부모인 Character를 거치게 했다. 버프가 붙은 유닛의 최종 공격력은 Character가 BuffComponent에 물어봐서 만든다.
func get_atk() -> int:
var base = int(stats.get("atk", 15))
if is_instance_valid(_buff_component):
return _buff_component.get_final_atk(base)
return base
CombatComponent는 BuffComponent의 존재 자체를 모른다. 전투 쪽 코드를 고칠 때 버프 쪽이 깨질 일이 없다는 뜻이다.
어그로에서 제대로 데였다
이 구조에서 제대로 데인 건 어그로 시스템을 넣을 때였다. 궁수한테 탐지 거리 밖에서 화살을 맞은 유닛이 반격을 안 하고 그 자리에 서서 죽었다.
반격하려면 누가 때렸는지를 알아야 하는데, 처음 만든 attack(target, damage)에는 공격자 정보가 아예 없었다. 데미지라는 숫자만 흘러다니고 출처는 어디에도 없는 구조였던 거다. 결국 시그니처를 attack(target, damage, attacker)로 바꾸고, 화살 같은 발사체 경로까지 attacker를 끝까지 들고 다니도록 전 구간을 고쳤다.
컴포넌트로 쪼개면 책임은 깔끔해지는데, 이렇게 여러 컴포넌트를 관통해야 하는 데이터가 생기면 파라미터 릴레이가 줄줄이 생긴다는 걸 그날 배웠다.
유닛마다 조합이 다르다
이 구조의 진짜 장점은 유닛마다 필요한 컴포넌트만 붙이는 거다. 몽크는 힐이 있으니 SkillComponent를 붙이고(쿨다운 8초), 일반 전사는 안 붙인다. 성(Castle)은 움직이지 않으니 CombatComponent만 있으면 된다. 안 쓰는 기능의 코드가 유닛에 아예 존재하지 않는다.
그리고 처음부터 4개로 쪼갠 게 아니다. CombatComponent 하나로 시작했고, 닷새 뒤 몽크 힐을 만들면서 heal()이 추가됐고, 버프 시스템이 필요해진 날 BuffComponent와 SkillComponent가 생겼다. 처음부터 다 쪼갰으면 과설계였을 거다.
이 구조의 한계
통신은 부모를 거친다는 규칙은 컴포넌트가 4개니까 지켜지는 거다. 개수가 더 늘면 Character가 중계소 노릇을 하느라 다시 비대해진다. 어그로 때처럼 여러 컴포넌트를 관통하는 데이터가 또 나오면, 그때는 파라미터 릴레이 대신 시그널 버스 같은 통신 방식을 검토해야 할 것 같다.
정리하면
CSV로 데이터를 빼는 것과 컴포넌트로 로직을 쪼개는 건 규모가 다른 작업 같지만 성격은 같았다. 한 곳이 너무 많은 걸 알고 있을 때 생기는 문제를 푸는 일이다.
다만 순서는 중요했다. 둘 다 필요해진 시점에 했지 미리 하지 않았다. CSV는 유닛이 네 종류가 되고 나서, 컴포넌트는 클래스가 수백 줄이 되고 나서였다. 컴포넌트 분리는 코드를 줄여주는 마법이 아니라 복잡도를 어디에 둘 것인가를 정하는 일에 가깝고, 그 결정은 복잡도가 실제로 생긴 다음에야 제대로 내릴 수 있었다.



답글 남기기