Unity 엔진 핵심 (1) - GameObject와 Component - soo:bak
작성일 :
Unity 엔진의 핵심 구조를 이해해야 하는 이유
Unity 에디터의 Hierarchy 창에 보이는 모든 항목은 GameObject이고, Inspector 창에 나열되는 항목 하나하나가 Component입니다. 스크립트에서 사용하는 GetComponent, Instantiate, SetActive 같은 API도 모두 이 구조 위에서 동작합니다.
이 구조를 이해하면, GetComponent에 왜 비용이 따르는지, MonoBehaviour를 왜 new로 생성하면 안 되는지, 프리팹이 어떤 원리로 복제되는지를 자연스럽게 알 수 있습니다. 구조를 모른 채 API만 사용하면, 예상과 다르게 동작할 때 원인을 찾기 어렵습니다.
이 시리즈는 Unity 엔진의 기본 구조를 처음부터 다룹니다. 첫 번째 글에서는 Unity의 아키텍처 설계 철학인 컴포지션 패턴부터 시작하여, 그 위에 세워진 GameObject, Component, MonoBehaviour, 프리팹의 관계를 순서대로 살펴봅니다.
상속 vs 컴포지션
상속 기반 아키텍처의 한계
게임 엔진을 설계할 때 핵심적인 결정 중 하나는 게임 오브젝트의 구조입니다. 게임 세계에는 캐릭터, 적, 배경, 아이템 등 다양한 오브젝트가 존재하고, 각 오브젝트는 이동, 렌더링, 충돌 같은 기능을 조합하여 동작합니다.
초기 게임 엔진들은 이 기능 조합을 상속(Inheritance) 기반으로 설계했습니다. 공통 기능을 가진 기반 클래스를 만들고, 구체적인 오브젝트가 이를 상속하여 기능을 추가하는 방식입니다.
이 구조는 처음에는 깔끔해 보이지만, 기능 조합이 늘어나면 문제가 드러납니다. 이동 가능하면서, 렌더링되면서, 충돌도 처리하는 오브젝트를 만들려면 세 기능을 모두 가진 클래스가 필요합니다. 초기 게임 엔진들이 주로 사용한 C++은 다중 상속을 지원하지만, 계층이 복잡해지면 같은 기반 클래스가 여러 경로로 중복 상속되는 다이아몬드 문제가 발생하여 결국 유지보수가 어려워집니다. Unity가 사용하는 C#은 다이아몬드 문제를 원천적으로 막기 위해 다중 상속 자체를 지원하지 않으므로, 여러 기능을 조합하려면 조합마다 별도의 클래스를 직접 작성할 수밖에 없습니다.
기능이 5개라면 조합은 최대 31가지(2^5 - 1), 10개라면 1,023가지(2^10 - 1)입니다. 각 조합마다 클래스를 만드는 것은 현실적이지 않습니다. 클래스 계층이 깊어지면 한 가지 기능을 수정할 때 계층 전체에 영향이 퍼지고, 새 기능을 추가할 위치를 찾는 것 자체가 어려워집니다.
컴포지션: 빈 객체에 기능을 붙이는 방식
컴포지션(Composition) 패턴은 상속 기반 아키텍처의 조합 폭발 문제를 근본적으로 해결합니다. 빈 컨테이너 객체를 하나 만들고, 필요한 기능을 독립된 부품(컴포넌트)으로 만들어 붙이는 방식입니다.
이동만 필요하면 Movement 컴포넌트만 붙이고, 렌더링도 필요하면 Renderer를, 충돌 검사가 필요하면 Collider를 추가합니다. 조합이 자유롭고, 새 기능을 추가할 때는 새 컴포넌트를 만들면 됩니다. 기존 컴포넌트를 수정할 필요가 없습니다.
Unity는 이 컴포지션 패턴을 엔진의 핵심 아키텍처로 채택했습니다. Unity에서 모든 게임 오브젝트는 빈 컨테이너이며, 기능은 컴포넌트를 부착하여 부여합니다.
| 상속 기반 | 컴포지션 기반 (Unity) |
|---|---|
| 기능 조합 = 새 클래스 필요 | 컴포넌트를 자유롭게 조합 = 새 클래스 불필요 |
| 계층이 깊어짐 | 계층 없음 (평면 구조) |
| 기능 수정 → 계층 전체 영향 | 해당 컴포넌트만 수정 → 다른 컴포넌트에 영향 없음 |
| 런타임에 기능 추가/제거 어려움 | 런타임에 컴포넌트 추가/제거 가능 |
GameObject
빈 컨테이너
앞서 컴포지션 패턴에서 “빈 컨테이너”라는 표현을 사용했습니다. Unity에서 그 빈 컨테이너가 바로 GameObject입니다. GameObject는 Unity 씬에 존재하는 모든 오브젝트의 기본 단위로, 캐릭터, 카메라, 조명, 바닥, UI 버튼 등 씬에 보이는 것과 보이지 않는 것 모두를 포함합니다.
GameObject 자체에는 렌더링, 물리, 소리 재생 같은 기능을 담당하는 컴포넌트가 포함되어 있지 않습니다. 유일한 예외는 Transform으로, 모든 GameObject에 항상 포함되어 있습니다. 그 외의 기능은 필요한 컴포넌트를 부착하여 부여합니다.
name, tag, layer
name은 에디터의 Hierarchy 창과 코드에서 오브젝트를 식별하는 문자열입니다.
tag는 오브젝트를 분류하는 문자열 레이블입니다. “Player”, “Enemy”, “Projectile” 등 용도별로 태그를 지정하고, 충돌 처리나 검색 시 태그를 비교하여 대상을 구분합니다. 태그 비교에는 gameObject.tag == "Player" 대신 CompareTag("Player")를 사용하는 것이 원칙이며, 그 이유는 GetComponent의 내부 동작을 다루면서 함께 설명합니다.
layer는 정수 값으로, 주로 물리 충돌 필터링과 카메라 렌더링 필터링에 사용됩니다. 특정 레이어의 오브젝트만 충돌하게 하거나, 특정 레이어만 렌더링하도록 설정할 수 있습니다. 내부적으로는 비트마스크 방식으로 처리되어 연산 비용이 낮습니다.
Component
기능의 단위
Component에는 Unity에서 제공하는 내장 컴포넌트와 개발자가 스크립트로 만드는 사용자 컴포넌트, 두 종류가 있습니다. 내장 컴포넌트에는 위치·회전·크기를 담당하는 Transform, 화면에 오브젝트를 그리는 MeshRenderer, 물리 시뮬레이션을 처리하는 Rigidbody 등이 있습니다.
Transform은 모든 GameObject에 반드시 존재하는 유일한 필수 컴포넌트로, 제거할 수 없습니다. 나머지 컴포넌트는 모두 선택적이고, 필요에 따라 추가하거나 제거합니다.
MonoBehaviour
사용자 스크립트의 기반 클래스
개발자가 직접 작성하는 사용자 컴포넌트는 MonoBehaviour를 상속하는 C# 스크립트입니다.
MonoBehaviour는 Behaviour를 상속하고, Behaviour는 Component를, Component는 UnityEngine.Object를 상속합니다. Behaviour는 Component에 enabled 속성을 추가한 계층으로, 이를 통해 Update 등의 콜백 실행을 켜고 끌 수 있습니다.
다이어그램에서 Component 아래가 여러 갈래로 나뉘는 이유는 enabled의 제어 대상이 다르기 때문입니다. Behaviour의 enabled는 Update 등 엔진 콜백의 실행 여부를 제어하고, Renderer의 enabled는 렌더링 여부를, Collider의 enabled는 충돌 여부를 각각 제어합니다. Transform은 enabled 자체가 없어 항상 동작합니다.
MonoBehaviour를 상속하면 Unity 엔진이 제공하는 콜백 메서드를 사용할 수 있게 됩니다. 콜백은 개발자가 직접 호출하는 메서드가 아니라, 특정 시점에 엔진이 자동으로 호출하는 메서드입니다. Awake, Start, Update, FixedUpdate, LateUpdate, OnDestroy 등이 대표적입니다.
| 콜백 | 호출 시점 |
|---|---|
| Awake() | 스크립트 인스턴스가 로드될 때 (1회) |
| OnEnable() | 오브젝트가 활성화될 때 |
| Start() | 첫 번째 프레임 Update 직전 (1회) |
| FixedUpdate() | 고정 시간 간격마다 (물리 루프) |
| Update() | 매 프레임 (게임 로직) |
| LateUpdate() | 모든 Update 완료 후 (카메라 등) |
| OnDisable() | 오브젝트가 비활성화될 때 |
| OnDestroy() | 오브젝트가 파괴될 때 |
각 콜백은 정해진 순서로 호출되며, 위 표의 위에서 아래 방향이 곧 실행 순서입니다. 콜백 순서의 상세한 흐름은 Unity 엔진 핵심 (3) - Unity 실행 순서에서 다룹니다.
MonoBehaviour의 제약
MonoBehaviour를 상속한 클래스는 new 키워드로 인스턴스를 생성해서는 안 됩니다. C# 컴파일러는 new를 허용하지만, Unity 런타임이 경고를 출력하고 엔진이 관리하는 정상적인 객체가 생성되지 않습니다. 에디터에서 Inspector의 Add Component 버튼을 사용하거나, 코드에서 AddComponent<T>() 등 정상적인 컴포넌트 생성 경로를 통해 GameObject에 부착해야 합니다. MonoBehaviour는 Component이고, Component는 반드시 GameObject에 속해야 하기 때문입니다.
GetComponent의 내부 동작
선형 검색
MonoBehaviour에서는 같은 GameObject에 붙어 있는 다른 컴포넌트에 접근하는 일이 자주 발생합니다. Inspector에서 미리 연결하거나 DI(Dependency Injection) 프레임워크로 주입받을 수도 있지만, 코드에서 타입으로 컴포넌트를 찾을 때는 GetComponent<T>()를 사용합니다. 이 함수는 GameObject에 부착된 컴포넌트 목록에서 타입 T와 일치하는 컴포넌트를 선형 검색(Linear Search)하여 반환합니다. 선형 검색은 목록의 첫 요소부터 순서대로 하나씩 확인하는 방식입니다.
그런데 GetComponent의 비용을 제대로 이해하려면, 먼저 Unity 오브젝트의 내부 구조를 알아야 합니다. 사실 GameObject나 Component는 두 겹으로 존재합니다. 개발자가 C# 코드에서 다루는 것은 얇은 래퍼 객체이고, 이 래퍼가 가리키는 실제 데이터는 네이티브 C++ 메모리에 따로 있습니다.
앞서 MonoBehaviour를 new로 생성하면 안 된다고 한 이유도 이 구조 때문입니다. new는 C# 래퍼만 만들 뿐, 네이티브 객체는 생성하지 않습니다. 두 겹 중 한 겹만 존재하는 셈이므로 엔진이 이 객체를 정상적으로 다룰 수 없습니다.
한 번의 GetComponent 호출에는 다음 세 단계의 비용이 수반됩니다.
첫째, C# 코드에서 네이티브 C++ 코드로 넘어가는 경계 전환(Managed-to-Native boundary crossing)이 일어납니다. 두 겹 사이를 오가려면 타입 정보 등을 상대쪽이 이해할 수 있는 형식으로 변환해야 하고, 이 과정에 비용이 듭니다.
둘째, 네이티브 측에서 컴포넌트 목록을 처음부터 순회하며 타입을 비교합니다.
셋째, 결과를 다시 C# 코드로 반환하면서 경계 전환이 한 번 더 발생합니다.
컴포넌트가 3~5개인 일반적인 GameObject에서는 한 번의 호출 비용 자체는 미미합니다. 하지만 이 함수를 매 프레임 — 60 FPS라면 초당 60회 — 호출하면, 결과는 바뀌지 않는데도 같은 비용이 매번 반복됩니다.
캐싱의 필요성
결과가 바뀌지 않는다면, 한 번만 호출하고 결과를 저장해 두는 것이 효율적입니다.
매 프레임 호출하는 경우입니다.
1
2
3
4
void Update() {
Rigidbody rb = GetComponent<Rigidbody>();
rb.AddForce(Vector3.up);
}
매 프레임 네이티브 전환과 컴포넌트 목록 순회가 반복됩니다.
Awake에서 한 번만 호출하고 결과를 캐싱하는 경우입니다.
1
2
3
4
5
6
7
8
9
Rigidbody _rb;
void Awake() {
_rb = GetComponent<Rigidbody>();
}
void Update() {
_rb.AddForce(Vector3.up);
}
Awake에서 1회만 검색하고, Update에서는 캐싱된 참조를 사용하므로 추가 비용이 없습니다.
Awake()는 스크립트가 로드될 때 한 번만 호출되므로 캐싱에 적합합니다. 이 패턴의 세부 적용 방법은 스크립트 최적화 (2) - Unity API와 실행 비용에서 다룹니다.
CompareTag와 문자열 마샬링
태그를 비교할 때도 C#과 네이티브 C++ 사이의 경계 전환 비용이 발생합니다.
태그 문자열도 네이티브 C++ 메모리에 저장되어 있습니다. C#에서 gameObject.tag에 접근하면, 엔진이 네이티브 메모리의 태그 문자열을 C# 메모리에 새 문자열 객체로 복사하여 반환합니다. gameObject.tag == "Player"처럼 비교할 때마다 이 복사가 발생하고, 복사된 문자열 객체는 GC(Garbage Collection, 더 이상 사용하지 않는 메모리를 자동으로 회수하는 시스템) 대상이 됩니다. 태그 비교가 빈번하면 그만큼 GC 부담이 누적됩니다.
반면 CompareTag("Player")는 문자열 객체를 생성하지 않고 네이티브 코드에서 직접 비교를 수행하므로 GC 할당이 없습니다.
컴포넌트 검색 메서드들
GetComponentInChildren, GetComponentInParent
GetComponent는 해당 GameObject 하나만 검색하므로, 만약, 자식 오브젝트에 붙은 컴포넌트까지 찾아야 한다면 GetComponentInChildren<T>()를 사용합니다. 자기 자신을 포함한 모든 자손을 순회하므로, 자손이 많을수록 비용도 커집니다.
기본적으로 비활성 상태인 자식은 검색에서 제외되며, 만약, 비활성 자식까지 포함하고 싶다면 GetComponentInChildren<T>(true) 와 같이 인수로 true 를 전달하여 호출합니다.
반대로 부모 오브젝트의 컴포넌트를 찾아야 한다면 GetComponentInParent<T>()를 사용합니다. 자신부터 최상위 부모까지 순회하므로, 계층이 깊을수록 비용이 커집니다.
GameObject.Find와 FindObjectOfType
검색 범위가 씬 전체로 넓어지면 비용도 크게 증가합니다.
GameObject.Find(string name)은 씬에 있는 모든 활성 GameObject를 순회하며 이름이 일치하는 것을 찾습니다. 씬에 GameObject가 1000개 있으면 최악의 경우 1000번 비교합니다.
FindObjectOfType<T>()도 씬 전체를 순회하며 모든 Component를 검사하여 타입 T와 일치하는 것을 찾습니다. FindObjectsOfType<T>()는 모든 일치 항목을 배열로 반환하므로, 순회 비용에 더해 배열 할당 비용까지 발생합니다.
기존 FindObjectOfType과 FindObjectsOfType은 내부적으로 InstanceID 기준 정렬을 항상 수행하는 등, 개발자가 제어할 수 없는 비용이 포함되어 있었습니다. Unity 2023.1부터 이 두 함수는 폐기(deprecated)되고, 이러한 비용을 줄일 수 있는 새 API로 대체되었습니다.
새 API는 세 가지입니다. FindFirstObjectByType<T>()는 타입이 일치하는 첫 번째 오브젝트를 반환합니다. 순서가 중요하지 않다면 FindAnyObjectByType<T>()가 더 빠릅니다. 순서를 보장하지 않는 대신 검색 속도를 높인 것입니다.
일치하는 오브젝트를 모두 가져와야 한다면 FindObjectsByType<T>()를 사용합니다. 이 메서드는 정렬 옵션(FindObjectsSortMode)을 제공하는데, FindObjectsSortMode.None으로 정렬을 건너뛰면 불필요한 비용을 줄일 수 있습니다.
이 함수들은 초기화 시점(Awake, Start)에 한 번 호출하고 결과를 캐싱하는 것이 일반적인 사용 패턴입니다. 매 프레임 호출하면 성능에 직접적인 영향을 줍니다. 에디터의 Inspector에서 오브젝트를 직접 연결(드래그 앤 드롭)하면 Find 없이도 참조를 얻을 수 있습니다.
| 메서드 | 검색 범위 |
|---|---|
| GetComponent<T>() | 자기 자신의 컴포넌트 목록 |
| GetComponentInChildren | 자신 + 모든 자손의 컴포넌트 |
| GetComponentInParent | 자신 + 모든 조상의 컴포넌트 |
| GameObject.Find() | 씬 전체의 활성 GameObject |
| FindObjectOfType<T>() | 씬 전체의 모든 Component |
| FindObjectsOfType<T>() | 씬 전체 + 결과 배열 할당 |
| ※ Unity 2023.1+ 대체 API: | |
| FindFirstObjectByType | 씬 전체 (첫 번째 일치 반환) |
| FindAnyObjectByType | 씬 전체 (순서 무관, 더 빠름) |
| FindObjectsByType | 씬 전체 + 배열 (정렬 옵션) |
프리팹 (Prefab)
재사용 가능한 GameObject 템플릿
지금까지 GameObject에 Component를 붙여 기능을 구성하고, 필요한 컴포넌트를 검색하는 방법을 살펴보았습니다. 같은 구성의 오브젝트를 여러 개 만들어야 하는 상황에서, 매번 컴포넌트를 하나씩 붙이는 것은 비효율적입니다.
프리팹(Prefab)은 미리 구성해 둔 GameObject의 템플릿입니다. 특정 컴포넌트 조합을 가진 GameObject를 프리팹으로 저장하면, 동일한 구성을 가진 오브젝트를 언제든 복제할 수 있습니다.
Instantiate와 런타임 복제
Instantiate(prefab) 함수로 프리팹을 런타임에 복제합니다. 총알, 적, 이펙트 등 게임 중에 생성되는 오브젝트 대부분은 프리팹의 인스턴스입니다.
1
GameObject enemy = Instantiate(enemyPrefab);
이 한 줄이 실행되면, Unity 내부에서는 다음 과정이 순서대로 일어납니다.
Instantiate는 호출할 때마다 메모리 할당과 초기화가 일어납니다. 총알처럼 매 프레임 생성하고 곧바로 파괴하는 오브젝트가 있다면, Instantiate/Destroy가 반복될수록 할당과 GC 부담이 쌓입니다.
실무에서는 이 비용을 줄이기 위해 오브젝트 풀링(Object Pooling) 패턴을 사용합니다. 오브젝트를 파괴하는 대신 비활성화해서 풀에 보관해 두고, 다시 필요해지면 꺼내서 활성화하는 방식입니다. 생성·파괴를 반복하지 않으니 할당과 GC가 발생하지 않습니다. 자세한 내용은 스크립트 최적화 (1) - C# 실행과 메모리 할당에서 다루고 있습니다.
프리팹의 런타임 수정
Instantiate로 만들어진 인스턴스는 생성 시점에 프리팹의 모든 값을 그대로 복사하지만, 그 이후로는 원본과 완전히 독립됩니다. 인스턴스의 컴포넌트 값을 런타임에 자유롭게 바꿀 수 있고, 그 변경이 원본 프리팹이나 다른 인스턴스에 전파되지 않습니다.
마무리
Unity의 아키텍처는 상속이 아닌 컴포지션에 기반합니다. 빈 컨테이너인 GameObject에 기능 단위인 Component를 붙여서 게임 오브젝트를 구성하고, 이 구조가 Unity API의 설계 형태와 최적화 기법의 방향을 결정합니다.
- GameObject는 이름/태그/레이어와 필수 컴포넌트인 Transform만 가진 빈 컨테이너이며, 컴포지션 패턴을 통해 런타임에도 컴포넌트를 자유롭게 추가하거나 제거할 수 있습니다.
- MonoBehaviour는 사용자 스크립트의 기반 클래스이며, Awake/Start/Update 등의 콜백을 통해 엔진과 상호작용합니다.
- GetComponent는 네이티브 경계 전환과 선형 검색을 수반하므로 결과를 캐싱하고, Find 계열 함수는 씬 전체를 순회하므로 초기화 시 한 번만 호출합니다.
- 프리팹은 재사용 가능한 GameObject 템플릿이며, Instantiate로 런타임에 복제합니다. 반복 생성·파괴가 잦으면 오브젝트 풀링으로 할당과 GC 비용을 줄입니다.
이 캐싱, 회피, 풀링 전략은 모두 컴포지션 아키텍처의 동작 방식에서 비롯됩니다.
GameObject와 Component의 구조를 확인했으므로, 다음으로 살펴볼 것은 모든 GameObject에 반드시 존재하는 Transform입니다.
Transform은 부모-자식 관계를 통해 씬 전체의 계층 구조를 형성하며, 오브젝트 하나의 위치를 바꾸는 비용이 자식 전체로 전파될 수 있습니다. Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프에서 이어집니다.
관련 글
시리즈
- Unity 엔진 핵심 (1) - GameObject와 Component (현재 글)
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component (현재 글)
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요