Godot 4 컴포넌트 패턴 실전 — 수백 줄 유닛 클래스 쪼개기 (바이브코딩 #10)

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의 존재 자체를 모른다. 전투 쪽 코드를 고칠 때 버프 쪽이 깨질 일이 없다는 뜻이다. 충돌 레이어도 컴포넌트 바깥에서 한 번에 정리했다 — 아군 hurtbox는 레이어 2, 적군은 4. 아군의 hitbox는 마스크 4(적만 감지), 적군은 마스크 2. 숫자 두 개로 아군 오사가 원천 차단된다.

한 번 제대로 데인 날 — 어그로

이 구조에서 제대로 데인 건 어그로 시스템을 넣을 때였다. 궁수한테 탐지 거리 밖에서 화살을 맞은 유닛이 반격을 안 하고 그 자리에 서서 죽었다. 반격하려면 “누가 때렸는지”를 알아야 하는데, 처음 만든 attack(target, damage)에는 공격자 정보가 아예 없었다. 데미지라는 숫자만 흘러다니고 출처는 어디에도 없는 구조였던 거다. 결국 시그니처를 attack(target, damage, attacker)로 바꾸고, 화살 같은 발사체 경로까지 attacker를 끝까지 들고 다니도록 전 구간을 고쳤다. 컴포넌트로 쪼개면 책임은 깔끔해지는데, 이렇게 여러 컴포넌트를 관통해야 하는 데이터가 생기면 파라미터 릴레이가 줄줄이 생긴다는 걸 그날 배웠다.

유닛마다 조합이 다르다

이 구조의 진짜 장점은 유닛마다 필요한 컴포넌트만 붙이는 거다. 몽크는 힐이 있으니 SkillComponent를 붙이고(쿨다운 8초), 일반 전사는 안 붙인다. 성(Castle)은 움직이지 않으니 CombatComponent만 있으면 된다. 안 쓰는 기능의 코드가 유닛에 아예 존재하지 않는다.

그리고 처음부터 4개로 쪼갠 게 아니다. CombatComponent 하나로 시작했고, 닷새 뒤 몽크 힐을 만들면서 heal()이 추가됐고, 버프 시스템이 필요해진 날 BuffComponent와 SkillComponent가 생겼다. 처음부터 다 쪼갰으면 과설계였을 거다.

이 구조의 한계

“통신은 부모를 거친다” 규칙은 컴포넌트가 4개니까 지켜지는 거다. 개수가 더 늘면 Character가 중계소 노릇을 하느라 다시 비대해진다. 어그로 때처럼 여러 컴포넌트를 관통하는 데이터가 또 나오면, 그때는 파라미터 릴레이 대신 시그널 버스 같은 통신 방식을 검토해야 할 것 같다. 컴포넌트 분리는 코드를 줄여주는 마법이 아니라, 복잡도를 “어디에 둘 것인가”를 정하는 일에 가깝다.