스킬 효과 실행과 책임 분리 구조
1. 시스템 요구 사항
스킬의 입력 처리, 애니메이션 흐름, 자원 잠금 구조까지 정리되었지만, 여전히 남아 있던 문제는 스킬의 실제 효과를 어디에서, 어떻게 실행할 것인가였다.
스킬이 발동되면 투사체를 생성하거나, 범위 데미지를 주거나, 파티클 이펙트를 재생하는 등 다양한 결과가 발생한다.
이때 이러한 로직이 스킬 컨트롤러나 애니메이션 처리 코드 안에 섞이기 시작하면, 스킬 시스템은 빠르게 비대해지고 수정이 어려워진다.
특히 투사체형 스킬의 경우, 어디서 생성되는가보다 더 중요한 문제는 투사체가 생성된 이후 어떤 책임을 가지는가였다.
투사체의 이동, 충돌 판정, 사거리 제한, 타격 처리, 이펙트 생성, 자기 자신 정리까지를 스킬 코드가 모두 책임지는 구조는 스킬이 늘어날수록 중복과 결합도를 급격히 키운다.
따라서 스킬 시스템에서는 스킬은 효과를 생성까지만 책임지고, 그 이후의 행동은 효과 오브젝트 스스로가 자신의 생명주기를 관리하도록 설계할 필요가 있었다.
2. 설계 목표
- 투사체/이펙트는 생성 이후의 이동·충돌·종료를 스스로 관리할 것
- 스킬과 효과 오브젝트 사이의 의존성을 최소화할 것
- 스킬이 늘어나도 기존 스킬 코드가 복잡해지지 않도록 할 것
3. 흐름도

이 흐름에서 중요한 점은, UseSkill 이후에는 스킬 시스템이 더 이상 개입하지 않는다는 점이다.
스킬은 효과를 발사하는 역할까지만 담당하고, 이후의 결과는 효과 오브젝트의 책임으로 위임된다.
4. 구현
4.1. Explosion 스킬의 UseSkill 책임 범위
public override void UseSkill()
{
if (!attackPosition || !skillData.SkillEffectPrefab) return;
var go = Instantiate(
skillData.SkillEffectPrefab,
attackPosition.position,
Quaternion.identity
);
go.transform.forward = transform.forward;
var proj = go.GetComponent<ExplosionFireBall>();
if (proj)
{
float finalDamage =
skillData.Damage + playerManager.PlayerStatus.MagicAttack;
proj.SetParams(finalDamage, skillData.MaxRange, 10f);
}
}
Explosion 스킬의 UseSkill은 의도적으로 아주 제한적인 책임만 가진다.
스킬 데이터에 정의된 프리팹을 지정된 위치에서 생성하고, 방향을 맞춘 뒤, 투사체가 동작하는 데 필요한 최소한의 초기 값만 전달한다.
여기서 Instantiate는 Unity에서 런타임에 프리팹을 복제해 오브젝트를 생성하는 기능이다.
프리팹 기반 생성의 장점은, 코드 수정 없이도 이펙트나 투사체 형태를 교체할 수 있어 기획·아트 작업과의 분업이 쉬워진다는 점이다.
반면 잦은 생성과 파괴는 GC와 성능 스파이크를 유발할 수 있다는 단점이 있다.
이 프로젝트에서는 스킬 발동이 이벤트성이고, 투사체 수가 제한적이기 때문에 구조적 명확성을 우선해 프리팹 생성 방식을 선택했다.
transform.forward를 투사체에 그대로 전달하는 방식은, 투사체가 생성되는 순간 플레이어가 바라보고 있는 방향으로 즉시 발사되도록 만들기 위한 선택이다.
이 프로젝트에서는 플레이어 회전이 카메라 방향과 동기화되어 있기 때문에, 별도의 조준 벡터 계산 없이도 조작감과 시각적 방향성이 자연스럽게 일치한다.
투사체에 필요한 정보는 SetParams 메서드를 통해 한 번에 전달한다.
이 방식은 외부에서 필드를 직접 수정하는 대신, 투사체가 요구하는 초기화 계약을 명확히 정의하는 장점이 있다.
다만 호출을 누락하면 투사체가 잘못된 상태로 동작할 수 있기 때문에, 실제 프로젝트에서는 초기화 여부를 검증하거나 방어 로직을 추가할 여지가 있다.
이 함수에서 중요한 점은, 스킬 코드가 투사체의 이동이나 충돌, 종료 시점을 전혀 알지 못한다는 것이다.
UseSkill은 오직 이 스킬은 이런 효과를 발생시킨다는 선언 역할만 수행한다.
4.2. 폭발 투사체의 이동 및 충돌 처리
public class ExplosionFireBall : MonoBehaviour
{
private Rigidbody rb;
private GameObject explosionEffect;
private float damage;
private float maxRange;
private float speed;
private Vector3 startPos;
private void Awake()
{
rb = GetComponent<Rigidbody>();
explosionEffect = Resources.Load<GameObject>(
"Prefabs/Skill/Player/Explosion/Explosion"
);
}
private void Start()
{
startPos = transform.position;
}
private void Update()
{
rb.velocity = transform.forward * speed;
if (Vector3.Distance(transform.position, startPos) > maxRange)
{
Destroy(gameObject);
}
}
public void SetParams(float dmg, float range, float moveSpeed)
{
damage = dmg;
maxRange = range;
speed = moveSpeed;
}
private void OnTriggerEnter(Collider other)
{
if (other.gameObject.layer == LayerMask.NameToLayer("Monster"))
{
var monster = other.GetComponent<Monster>();
if (monster)
monster.DamagedPlayerSkill(damage);
if (explosionEffect)
Instantiate(explosionEffect, transform.position, transform.rotation);
Destroy(gameObject);
}
}
}
ExplosionFireBall 컴포넌트는 투사체의 완전한 자기 책임 구조를 가진다.
스킬로부터 전달받은 파라미터를 기반으로 전방 이동을 수행하고, 시작 위치와 현재 위치를 비교해 최대 사거리를 초과하면 스스로 제거된다.
이동은 Rigidbody.velocity를 직접 설정하는 방식으로 구현했다.
물리적으로 반동이나 충돌 반응을 시뮬레이션하는 것이 목적이 아니라, 일정 속도로 직선 이동하는 투사체를 안정적으로 표현하는 것이 목표였기 때문에 이 방식을 선택했다.
정석적으로는 FixedUpdate나 MovePosition을 사용할 수도 있지만, 이 프로젝트의 규모와 요구사항에서는 단순성과 가독성을 우선했다.
사거리 체크에는 Vector3.Distance를 사용했다.
이 방식은 내부적으로 제곱근 연산이 포함되므로, 대량의 투사체가 존재할 경우 성능 이슈가 생길 수 있다.
하지만 투사체 수가 제한적인 구조이기 때문에, 가독성과 의도 전달이 명확한 Distance 기반 체크를 사용했다.
충돌 판정은 OnTriggerEnter를 사용한다.
Trigger 방식은 물리 반동 없이 이벤트성 충돌만 처리할 수 있어, 투사체가 튕기거나 멈추지 않고 즉시 효과를 발생시키는 구조에 적합하다.
Layer 기반 필터링을 통해 몬스터와의 충돌만 처리함으로써, 불필요한 태그 비교나 조건 분기를 줄이고 책임 범위를 명확히 했다.
충돌 시 데미지를 전달하고, 폭발 이펙트를 생성한 뒤 Destroy(gameObject)로 자신을 제거한다.
투사체는 단발성 오브젝트이기 때문에, 생명주기를 명확히 종료하지 않으면 씬 오브젝트 수가 누적되어 퍼포먼스와 메모리 관리에 악영향을 준다.
Destroy는 즉시 제거가 아니라 프레임 종료 시점에 처리되므로, 이펙트 생성과 충돌 처리 이후 안전하게 정리된다.
4.3. 폭발 이펙트의 생명주기 관리
public class Explosion : MonoBehaviour
{
ParticleSystem particle;
float particleDuration;
private void Awake()
{
particle = GetComponentInChildren<ParticleSystem>();
}
void Start()
{
particleDuration = particle.main.duration;
Destroy(gameObject, particleDuration);
}
}
폭발 이펙트 역시 스스로의 생명주기를 관리한다.
ParticleSystem.main.duration은 파티클이 재생되는 기본 지속 시간을 의미하며, 이를 기준으로 오브젝트를 자동 제거함으로써 이펙트가 씬에 남아 누적되는 문제를 방지한다.
이 방식의 장점은 파티클 설정이 변경되더라도 코드 수정 없이 생명주기가 자동으로 맞춰진다는 점이다.
단, 루프 파티클이나 서브 이미터가 포함된 복잡한 구조에서는 duration만으로 종료 시점을 판단하기 어려울 수 있다.
이 프로젝트의 폭발 이펙트는 단발 파티클이라는 전제가 명확했기 때문에, 가장 단순하고 유지보수 비용이 낮은 방식을 선택했다.
5. 개발 의도
이 게시글에서 보여주고자 한 것은, 스킬 시스템을 중심으로 모든 책임을 끌어당기지 않는 설계였다.
스킬은 효과를 발생시키는 트리거일 뿐이며, 그 이후의 동작은 각 오브젝트가 자기 책임 하에 처리하도록 분리했다.
이 구조를 통해 스킬 시스템은 입력, 조건, 애니메이션, 자원 관리라는 본래의 역할에 집중할 수 있고, 투사체나 이펙트는 각자의 생명주기를 독립적으로 관리하게 된다.
결과적으로 스킬이 늘어나더라도 기존 코드의 복잡도는 급격히 증가하지 않으며, 새로운 효과 타입을 추가할 때도 기존 스킬 구조를 건드리지 않고 확장할 수 있는 기반이 마련되었다.
