← 모든 글

Unity에서 컴포넌트 패턴을 바라보는 방식

컴포넌트 패턴이 왜 등장했는지, 일반적인 예시와 Unity의 GameObject/Component 구조를 통해 어떻게 활용할 수 있는지 정리합니다.

Unity UnityComponent PatternGameObjectArchitecture

들어가기에 앞서

이 글에서 말하는 컴포넌트 패턴은 흔히 알려진 ECS(Entity Component System)와 비슷한 부분이 있지만, 같은 개념은 아니다. 둘 다 객체를 기능 단위로 나누어 구성한다는 점에서는 닮아 있지만, ECS는 데이터와 시스템을 더 엄격하게 분리하는 아키텍처에 가깝다. 여기서는 Unity의 GameObjectComponent 구조를 이해하기 위한 컴포넌트 패턴에 초점을 맞춘다.

컴포넌트 패턴이 등장한 배경

객체지향을 처음 배울 때는 보통 상속을 먼저 떠올린다. Character가 있고, 그 아래에 Player, Enemy, NPC를 만들고, 다시 FlyingEnemy, BossEnemy처럼 클래스를 나누는 방식이다. 처음에는 깔끔해 보이지만, 기능이 늘어나기 시작하면 금방 난감해진다.

예를 들어 어떤 캐릭터는 움직일 수 있고, 어떤 캐릭터는 공격할 수 있고, 어떤 캐릭터는 대화할 수 있고, 어떤 캐릭터는 아이템을 주울 수 있다고 해보자. 이 기능들을 상속 구조로만 표현하려고 하면 클래스 조합이 폭발한다. 이를 상속 지옥이라고도 부른다.

Character
├─ MovableCharacter
├─ AttackableCharacter
├─ TalkableCharacter
├─ MovableAttackableCharacter
├─ MovableTalkableCharacter
└─ MovableAttackableTalkableCharacter

기능이 세 개만 있어도 이미 읽기 싫어진다. 실제 게임에서는 이동, 공격, 체력, 애니메이션, 입력, 사운드, 피격, 상태 이상, 상호작용, 저장 데이터처럼 훨씬 많은 기능이 붙는다. 상속만으로는 "무엇인가가 무엇이다"라는 관계는 표현하기 쉽지만, "무엇인가가 어떤 기능을 가진다"라는 조합을 표현하기 어렵다.

컴포넌트 패턴은 이 문제를 해결하기 위해 등장한 방식이다. 하나의 큰 객체가 모든 기능을 직접 가지는 대신, 작고 독립적인 기능 조각을 붙여서 객체의 동작을 구성한다.

상속이 Player is a Character에 가깝다면, 컴포넌트는 Player has Movement, Player has Health, Player has Inventory에 가깝다. 즉 정체성보다 조합을 중심에 둔다.

컴포넌트 패턴의 예시

게임이 아니더라도 컴포넌트 패턴은 여러 곳에서 볼 수 있다. 예를 들어 UI 버튼을 생각해보자.

버튼 하나에는 다음과 같은 기능이 붙을 수 있다.

이 모든 것을 Button 클래스 하나에 전부 넣을 수도 있다. 하지만 그렇게 하면 버튼은 점점 커지고, 다른 UI 요소와 공유할 수 있는 기능도 버튼 안에 갇힌다.

컴포넌트 방식으로 보면 버튼은 여러 기능의 조합이 된다.

ButtonObject
├─ RectTransform
├─ ImageRenderer
├─ ClickDetector
├─ AudioFeedback
└─ EventDispatcher

이렇게 나누면 같은 ClickDetector를 아이템 슬롯에도 붙일 수 있고, 같은 AudioFeedback을 토글 버튼이나 팝업 닫기 버튼에도 붙일 수 있다. 기능이 객체의 상속 계층에 묶이지 않고 독립적으로 재사용된다.

이 패턴의 핵심은 "객체를 분류해서 깊게 상속하기"보다 "필요한 행동을 작은 단위로 나누고 붙이기"다.

이것으로 할 수 있는 것

먼저 컴포넌트 패턴의 장점부터 이야기해보려 한다. 소프트웨어가 이런 방식으로 구성되면, 프로그램 안에서 동작하는 오브젝트를 훨씬 유연하게 만들 수 있다.

게임으로 예를 들어보자. 같은 NPC라고 해도 어떤 NPC는 이동만 하고, 어떤 NPC는 이동하면서 공격도 할 수 있다. 컴포넌트 패턴에서는 이 차이를 상속 계층으로 나누기보다, 필요한 기능을 붙이는 방식으로 표현한다.

1번 NPC
├ 충돌
├ 체력
└ 이동

2번 NPC
├ 충돌
├ 체력
├ 공격 기능
└ 이동

이렇게 보면 NPC라는 오브젝트는 컴포넌트들을 담는 컨테이너에 가깝다. 이 구조는 다음처럼 JSON 데이터로도 표현할 수 있다.

[
  {
    "displayName": "NPC1",
    "components": [
      {
        "size": 1.0
      },
      {
        "maxHealth": 100,
        "currentHealth": 100
      },
      {
        "speed": 5.0
      }
    ]
  },
  {
    "displayName": "NPC2",
    "components": [
      {
        "size": 1.0
      },
      {
        "maxHealth": 100,
        "currentHealth": 100
      },
      {
        "speed": 5.0
      },
      {
        "damage": 10
      }
    ]
  }
]

런타임에 이런 데이터를 읽어 오브젝트를 구성할 수 있다면, 게임의 일부 동작은 코드 수정 없이 텍스트 데이터만 바꿔도 달라질 수 있다. 실제 게임에서는 훨씬 복잡한 검증과 에디터 도구가 필요하겠지만, 방향성은 분명하다. 데이터를 편집해서 오브젝트의 구성을 바꾸는 방식이고, 이를 에디터로 구현하면 지금의 Unity 사용 방식과도 꽤 비슷해진다.

컴포넌트 간의 통신

컴포넌트 패턴에서는 기본적으로 컴포넌트가 서로 독립적으로 만들어져야 한다. 즉, 각 컴포넌트는 가능한 한 다른 컴포넌트의 내부 사정을 몰라야 한다. 그래야 같은 컴포넌트를 다른 오브젝트에도 붙일 수 있고, 수정 범위도 작게 유지할 수 있다.

하지만 실제 게임 로직은 그렇게 단순하지 않다. 체력이 0이 되면 애니메이션을 재생해야 하고, 공격이 성공하면 사운드를 재생하거나 UI를 갱신해야 한다. 컴포넌트 패턴의 장점을 유지하면서 컴포넌트끼리 느슨하게 통신하려면 이벤트를 사용할 수 있다. 예시 코드는 다음과 같다.

public static class ObjectManager
{
    public static List<Obj> objects = new List<Obj>();

    public static void NotifyEvent(IEvent e)
    {
        foreach (var obj in objects)
        {
            foreach (var component in obj.components)
            {
                component.OnEvent(e);
            }
        }
    }
}

이 방식에서는 이벤트를 보낸 쪽이 어떤 컴포넌트가 반응할지 몰라도 된다. 각 컴포넌트는 자신이 관심 있는 이벤트만 처리하면 된다. 대신 이벤트가 많아지면 흐름을 추적하기 어려워지고, 모든 컴포넌트를 순회하는 구조는 성능 비용도 생길 수 있다. 그래서 실제 구현에서는 이벤트 종류별로 구독자를 나누거나, 필요한 대상에게만 전달하는 필터링이 필요하다.

컴포넌트 패턴의 장단점

컴포넌트 패턴의 가장 큰 장점은 상속 지옥을 피할 수 있다는 점이다. 기능을 상속 계층에 묶어두지 않고 독립적인 조각으로 분리하기 때문에, 같은 기능을 여러 오브젝트에 재사용하기 쉽다.

또한 데이터 기반 구성과도 잘 맞는다. 충분한 컴포넌트가 준비되어 있다면, 새로운 오브젝트를 만들 때 코드를 매번 작성하지 않아도 된다. 어떤 컴포넌트를 붙이고 어떤 값을 넣을지만 정하면 되기 때문이다. 런타임에 데이터를 읽어 게임을 구성하는 구조라면, 경우에 따라 데이터 패치만으로도 게임 내용을 수정할 수 있다.

반대로 단점도 있다. 컴포넌트가 독립적이어야 한다는 말은, 그만큼 책임을 잘 나누어야 한다는 뜻이기도 하다. 어떤 기능을 어느 컴포넌트에 둘지, 컴포넌트끼리 어디까지 알아도 되는지 계속 판단해야 한다.

또한 이벤트 기반 통신은 구조를 느슨하게 만들어주지만, 이벤트 흐름이 많아지면 디버깅이 어려워질 수 있다. 모든 컴포넌트의 이벤트 함수를 순회하는 방식이라면 성능 문제도 생긴다. 이 부분은 이벤트 필터링, 구독 방식, 관심 대상 분리 같은 설계로 보완해야 한다.

Unity에서의 컴포넌트 패턴

Unity는 컴포넌트 패턴을 엔진의 기본 구조로 사용한다. Unity에서 씬에 존재하는 대부분의 대상은 GameObject이고, GameObject 자체는 이름, 활성 상태, 계층 구조 정도만 가진 빈 컨테이너에 가깝다. 실제 동작은 Transform, Rigidbody, Collider, Animator, AudioSource, 그리고 직접 만든 MonoBehaviour 컴포넌트가 담당한다.

예를 들어 플레이어 오브젝트는 이런 식으로 구성될 수 있다.

Player
├─ Transform
├─ Rigidbody
├─ CapsuleCollider
├─ Animator
├─ PlayerInput
├─ PlayerMovement
├─ PlayerHealth
└─ WeaponHolder

여기서 Player라는 GameObject는 플레이어라는 이름의 묶음이고, 실제 기능은 컴포넌트들이 나누어 가진다. 이동 로직은 PlayerMovement, 체력 관리는 PlayerHealth, 무기 장착은 WeaponHolder가 담당한다.

Unity는 SendMessage, BroadcastMessage 같은 함수를 통해 문자열 기반 메시지 호출을 지원한다. 이를 이용하면 특정 메서드 이름을 가진 컴포넌트에게 메시지를 보낼 수 있다. 다만 문자열 기반 호출이라 추적이 어렵고 성능 비용도 있기 때문에, 실제 프로젝트에서는 널리 권장되는 방식은 아니다.

대신 Unity에서는 상황에 따라 C# 이벤트, UnityEvent, 직접 만든 이벤트 버스, 명시적인 참조 연결 등을 함께 사용한다. 중요한 것은 어떤 방식을 쓰느냐보다, 컴포넌트가 서로를 과하게 붙잡지 않도록 통신 경계를 관리하는 것이다.

정리

컴포넌트 패턴은 상속 구조가 복잡해지는 문제에서 출발했다. 기능을 깊은 클래스 계층에 넣는 대신, 작은 동작 단위로 나누고 필요한 객체에 조합하는 방식이다.

Unity는 이 패턴을 GameObjectComponent 구조로 자연스럽게 사용하게 만든다. GameObject는 컨테이너가 되고, 실제 기능은 여러 컴포넌트가 나누어 맡는다. 이 구조를 잘 활용하면 같은 기능을 여러 오브젝트에 재사용할 수 있고, 수정 범위도 작게 유지할 수 있다.

내가 중요하게 보는 점은 컴포넌트의 개수가 아니라 책임의 경계다. 너무 큰 컴포넌트는 수정하기 어렵고, 너무 작은 컴포넌트는 흐름을 읽기 어렵다. 결국 좋은 컴포넌트 설계는 "이 기능이 왜 바뀌는가"를 관찰하고, 그 변화의 이유에 맞게 코드를 나누는 데서 시작한다고 생각한다.