컴포넌트 패턴 (Component Pattern)

omniAI·2025년 10월 10일
0

Design-Pattern

목록 보기
11/11

Unity 컴포넌트 패턴 — 조립으로 끝내는 설계

📌 개념 (TL;DR)

컴포넌트 패턴 = 기능 블록 조립.

"원하는 동작에 대한 기능을
블록 단위로 나누고
필요할 때 가져와서 사용하고 수정하고 하는 방식"

❌ 상속 방식
Player → Character → Entity (수정 지옥)
✅ 컴포넌트 방식
GameObject + Move + Attack + Health (조립/교체 자유)
Unity = 컴포넌트 패턴 엔진.
Transform, Rigidbody, Collider 전부 컴포넌트다.

상속 대신 GameObject에 Move/Attack/Health 같은 기능을 붙여서 조합으로 만든다.


💬 실제 사용 예시

🥉 신입 레벨 — 기본 조립

목표: 이동/공격/체력을 분리해서 플레이어 만들기

// 1. 이동 컴포넌트
public class MoveComponent : MonoBehaviour
{
    [SerializeField] float speed = 5f;
    
    void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        transform.Translate(new Vector3(h, 0, v) * speed * Time.deltaTime);
    }
}
// 2. 체력 컴포넌트
public class HealthComponent : MonoBehaviour
{
    [SerializeField] int maxHp = 100;
    int currentHp;
    
    void Start() => currentHp = maxHp;
    
    public void TakeDamage(int damage)
    {
        currentHp -= damage;
        if (currentHp <= 0) Die();
    }
    
    void Die() => gameObject.SetActive(false);
}
// 3. 공격 컴포넌트
public class AttackComponent : MonoBehaviour
{
    [SerializeField] int damage = 10;
    
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Attack();
    }
    
    void Attack()
    {
        // 레이캐스트로 타겟 찾아서 데미지
        Debug.Log("공격!");
    }
}

조립

  • GameObject에 3개 컴포넌트 붙이면 끝
  • 또는 에디터에서 Add Component로 추가

🥈 미들 레벨 — 인터페이스 + 이벤트

목표: 컴포넌트끼리 느슨하게 통신

// 1. 인터페이스 정의
public interface IDamageable
{
    void TakeDamage(int damage);
}
// 2. 체력 컴포넌트 (이벤트 추가)
public class HealthComponent : MonoBehaviour, IDamageable
{
    [SerializeField] int maxHp = 100;
    int currentHp;
    
    public event System.Action<int> OnDamaged;  // hp 변경 알림
    public event System.Action OnDied;          // 사망 알림
    
    void Start() => currentHp = maxHp;
    
    public void TakeDamage(int damage)
    {
        currentHp = Mathf.Max(0, currentHp - damage);
        OnDamaged?.Invoke(currentHp);
        
        if (currentHp == 0)
        {
            OnDied?.Invoke();
            Die();
        }
    }
    
    void Die() => gameObject.SetActive(false);
}
// 3. 공격 컴포넌트 (인터페이스 사용)
public class AttackComponent : MonoBehaviour
{
    [SerializeField] int damage = 10;
    [SerializeField] float attackRange = 2f;
    [SerializeField] LayerMask targetLayer;
    
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Attack();
    }
    
    void Attack()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, attackRange, targetLayer);
        
        foreach (var hit in hits)
        {
            if (hit.TryGetComponent<IDamageable>(out var target))
                target.TakeDamage(damage);
        }
    }
}
// 4. UI 연결 컴포넌트
public class HealthUI : MonoBehaviour
{
    [SerializeField] HealthComponent health;
    [SerializeField] Slider hpBar;
    
    void Start()
    {
        health.OnDamaged += UpdateUI;
        health.OnDied += ShowDeadScreen;
    }
    
    void UpdateUI(int hp) => hpBar.value = hp;
    void ShowDeadScreen() => Debug.Log("You Died");
}

핵심

  • 인터페이스로 타입 체크
  • 이벤트로 통신 (직접 참조 최소화)

🥇 시니어 레벨 — ScriptableObject + 데이터 분리

목표: 수치는 SO로 분리, 재사용/확장 극대화

// 1. 데이터 정의 (ScriptableObject)
[CreateAssetMenu(menuName = "Stats/AttackData")]
public class AttackData : ScriptableObject
{
    public int damage = 10;
    public float cooldown = 1f;
    public float range = 2f;
    public LayerMask targetLayer;
}
[CreateAssetMenu(menuName = "Stats/HealthData")]
public class HealthData : ScriptableObject
{
    public int maxHp = 100;
    public float regenRate = 0f;
}
// 2. 체력 컴포넌트 (데이터 주입)
public class HealthComponent : MonoBehaviour, IDamageable
{
    [SerializeField] HealthData data;
    int currentHp;
    
    public event System.Action<int, int> OnDamaged;  // (current, max)
    public event System.Action OnDied;
    
    void Start()
    {
        currentHp = data.maxHp;
        if (data.regenRate > 0)
            InvokeRepeating(nameof(Regen), 1f, 1f);
    }
    
    public void TakeDamage(int damage)
    {
        currentHp = Mathf.Max(0, currentHp - damage);
        OnDamaged?.Invoke(currentHp, data.maxHp);
        
        if (currentHp == 0)
        {
            OnDied?.Invoke();
            gameObject.SetActive(false);
        }
    }
    
    void Regen()
    {
        if (currentHp < data.maxHp)
        {
            currentHp = Mathf.Min(data.maxHp, currentHp + Mathf.RoundToInt(data.regenRate));
            OnDamaged?.Invoke(currentHp, data.maxHp);
        }
    }
}
// 3. 공격 컴포넌트 (데이터 주입)
public class AttackComponent : MonoBehaviour
{
    [SerializeField] AttackData data;
    float cooldownTimer;
    
    void Update()
    {
        cooldownTimer -= Time.deltaTime;
        
        if (Input.GetKeyDown(KeyCode.Space) && cooldownTimer <= 0)
        {
            Attack();
            cooldownTimer = data.cooldown;
        }
    }
    
    void Attack()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, data.range, data.targetLayer);
        
        foreach (var hit in hits)
        {
            if (hit.TryGetComponent<IDamageable>(out var target))
                target.TakeDamage(data.damage);
        }
    }
}
// 4. 버프 시스템 (데이터 교체)
public class BuffComponent : MonoBehaviour
{
    [SerializeField] AttackComponent attack;
    [SerializeField] AttackData normalAttack;
    [SerializeField] AttackData buffedAttack;
    [SerializeField] float buffDuration = 5f;
    
    public void ApplyBuff()
    {
        // 데이터만 교체 (리플렉션 또는 public setter 필요)
        StartCoroutine(BuffRoutine());
    }
    
    System.Collections.IEnumerator BuffRoutine()
    {
        // buffedAttack으로 교체
        yield return new WaitForSeconds(buffDuration);
        // normalAttack으로 복구
    }
}

핵심

  • 밸런스 수치는 SO로 분리 → 리빌드 없이 수정
  • 같은 컴포넌트 코드로 다양한 캐릭터 생성 (데이터만 교체)

Q. 전역 상수 방식으로 사용하면 되지 않을까? 왜 굳이 Scriptable Object를 사용할까?
1️⃣ 전역 상수 방식

public class BossHealth : MonoBehaviour
{
    const int MAX_HP = 10000;  // ← 이거 수정하려면?, 각 오브젝트마다 복사
}

// 100개 생성 → 100개의 damage 변수 (메모리 낭비)

작업 과정:
1. 코드 열기
2. 10000 → 8000 수정
3. 저장 후 리컴파일 대기 (10초~1분)
4. 플레이 버튼 눌러서 테스트
5. 또 조정 필요? → 1번부터 반복

문제점:

  • 수정할 때마다 리컴파일 (시간 낭비)
  • 기획자가 직접 못 바꿈 (프로그래머 의존)
  • 보스마다 다른 체력? → 코드 여러 개 필요
프로그래머: 코드 수정 중...
기획자: "슬라임 체력 올려주세요!"
프로그래머: "지금 다른 작업 중이라 30분 후에요"
기획자: 대기...

기획자: "고블린도요!"
프로그래머: 또 코드 열고 수정...
→ 반복 (병목 발생)

2️⃣ ScriptableObject 방식

[CreateAssetMenu(menuName = "Data/BossHealth")]
public class BossHealthData : ScriptableObject
{
    public int maxHp = 10000;
}

public class BossHealth : MonoBehaviour
{
    [SerializeField] BossHealthData data;  // ← 에셋 참조만 저장 (8바이트)
    int currentHp;
    
    void Start() => currentHp = data.maxHp;
}

작업 과정:
1. Project 창에서 에셋 클릭
2. Inspector에서 10000 → 8000 입력
3. 즉시 플레이 버튼 (리컴파일 없음)
4. 테스트
5. 또 조정? → 1번부터 (3초 컷)

이점:

  • 리컴파일 없음 (즉시 테스트)
  • 기획자가 직접 수정 가능
  • 보스마다 다른 에셋 할당 (코드 1개로 끝)
프로그래머: 코드 수정 중...
기획자: Slime.asset 클릭 → 체력 수정 → 즉시 테스트 ✅
기획자: Goblin.asset 클릭 → 체력 수정 → 즉시 테스트 ✅
→ 프로그래머 방해 없음


⚒️ 성능 최적화 - 필수 3가지

1. 참조 캐싱

// ❌ 매번 검색
void Update() => GetComponent<Rigidbody>().AddForce(Vector3.up);

// ✅ 캐싱
Rigidbody rb;
void Awake() => rb = GetComponent<Rigidbody>();
void Update() => rb.AddForce(Vector3.up);

2. Update 최소화

// ❌ 매 프레임
void Update() => CheckHealth();

// ✅ 이벤트 기반
public event Action OnHealthChanged;

3. 오브젝트 풀링

// 총알/이펙트는 생성/파괴 대신 활성화/비활성화
public class BulletPool : MonoBehaviour
{
    Queue<GameObject> pool = new();
    
    public GameObject Get()
    {
        if (pool.Count > 0)
        {
            var obj = pool.Dequeue();
            obj.SetActive(true);
            return obj;
        }
        return Instantiate(bulletPrefab);
    }
    
    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

⚠️ 실습

  1. 가장 복잡한 스크립트 1개 열기
  2. 기능 나열 (이동/공격/체력/AI...)
  3. 각 기능을 별도 파일로 분리
  4. GetComponent로 참조 연결
  5. 테스트

🎯 목표

  • 핵심 컴포넌트 3개 작성 (Move, Health, Attack)
  • 데이터 1개 ScriptableObject로 분리
  • 기존 코드 1개 리팩터링

시작은 작게: Move + Health만 분리해도 효과가 보인다.

profile
킵러닝

0개의 댓글