Unity 엔진 핵심 (1) - GameObject와 Component - soo:bak
작성일 :
Unity 엔진의 핵심 구조를 이해해야 하는 이유
Unity에서 게임을 만들 때 가장 자주 보는 창은 Hierarchy와 Inspector입니다. Hierarchy에 보이는 항목 하나하나가 GameObject이고, 이를 선택하면 Inspector에 나열되는 항목이 Component입니다. 스크립트에서 사용하는 GetComponent, Instantiate, SetActive 같은 API도 이 GameObject와 Component 구조 위에서 동작합니다.
사용법만 알아도 간단한 게임은 만들 수 있지만, 내부 구조를 이해하면 여러 API의 제약과 비용을 더 정확히 판단할 수 있습니다. GetComponent를 호출할 때마다 검색 비용이 드는 것도, MonoBehaviour를 new로 만들면 정상적으로 동작하지 않는 것도, 프리팹에서 복제된 오브젝트가 서로 독립적으로 수정되는 것도 모두 이 구조에서 비롯됩니다.
이 시리즈는 Unity 엔진의 기본 구조를 GameObject와 Component에서 시작해 Transform 계층, 실행 순서, 스레딩 모델로 이어서 설명합니다. 첫 글에서는 기능을 조립해 붙이는 컴포지션 구조를 중심으로, GameObject, Component, MonoBehaviour, 프리팹이 어떻게 연결되는지 살펴봅니다.
상속 vs 컴포지션
Unity가 GameObject에 Component를 붙이는 구조를 이해하려면, 먼저 왜 깊은 상속 계층만으로는 게임 오브젝트를 표현하기 어려운지 볼 필요가 있습니다. 기능을 물려받는 방식과 기능을 조립하는 방식의 차이가 Unity 구조의 출발점입니다.
상속 기반 아키텍처의 한계
게임 엔진에서 중요한 설계 문제 중 하나는 장면 안의 오브젝트를 어떤 구조로 표현할 것인가입니다. 한 장면에는 플레이어, 적, 지형, 아이템, 카메라, 조명처럼 서로 다른 오브젝트가 함께 존재합니다. 이 오브젝트들은 이동, 렌더링, 충돌, 사운드 같은 기능을 서로 다른 조합으로 갖춥니다.
상속 기반 구조에서는 공통 기능을 담은 기반 클래스를 만들고, 구체적인 오브젝트가 그 클래스를 상속해 필요한 기능을 추가합니다.
이 구조는 기능 수가 적을 때는 단순하지만, 한 오브젝트가 여러 기능을 동시에 갖춰야 하면 금세 복잡해집니다. 이동과 렌더링, 충돌을 모두 처리하는 오브젝트가 필요하면 세 기능을 함께 담은 클래스를 따로 정의해야 합니다. 이렇게 기능을 한 클래스에 모으려면 여러 기반 클래스를 한꺼번에 물려받아야 하는데, 다중 상속을 허용하는 C++에서는 같은 기반 클래스가 여러 경로로 상속되면 다이아몬드 상속 문제가 생길 수 있습니다. 반면 C#은 다중 상속 자체를 허용하지 않으므로, 기능을 자유롭게 섞지 못하고 조합이 바뀔 때마다 새 클래스를 만들어야 합니다.
기능이 다섯 개만 되어도 가능한 조합은 최대 31가지(2^5 - 1)이고, 열 개면 1,023가지(2^10 - 1)까지 늘어납니다. 모든 조합을 클래스로 표현하는 것은 현실적이지 않습니다. 또한 계층이 깊어질수록 기능 하나를 수정했을 때 영향을 받는 범위가 커지고, 새 기능을 어느 계층에 넣어야 하는지도 점점 판단하기 어려워집니다.
컴포지션: 빈 객체에 기능을 붙이는 방식
컴포지션(Composition) 패턴은 이 문제를 상속이 아니라 조합으로 해결합니다. 기능을 상속 계층에 미리 넣어 두는 대신, 기본 오브젝트를 만들고 필요한 기능을 독립된 모듈로 붙입니다. Unity에서는 이 모듈이 Component입니다.
이동만 필요한 오브젝트에는 Movement 컴포넌트를 붙이고, 화면에 보여야 한다면 Renderer를 붙입니다. 충돌이 필요하면 Collider를 추가합니다. 새 기능이 필요할 때도 기존 계층을 바꾸는 대신 컴포넌트를 새로 만들어 붙이면 됩니다.
Unity는 이 컴포지션 구조를 엔진의 기본 모델로 사용합니다. GameObject 자체는 기능을 직접 구현하는 클래스가 아니라, Component를 담는 컨테이너에 가깝습니다. GameObject가 어떤 기능을 갖는지는 어떤 Component가 붙어 있는지에 따라 결정됩니다.
| 상속 기반 | 컴포지션 기반 (Unity) |
|---|---|
| 기능 조합 = 새 클래스 필요 | 컴포넌트를 자유롭게 조합 = 새 클래스 불필요 |
| 계층이 깊어짐 | 계층 없음 (평면 구조) |
| 기능 수정 → 계층 전체 영향 | 해당 컴포넌트만 수정 → 다른 컴포넌트에 영향 없음 |
| 런타임에 기능 추가/제거 어려움 | 런타임에 컴포넌트 추가/제거 가능 |
GameObject
GameObject는 Unity 씬에서 오브젝트 하나를 나타내는 기본 단위입니다. 다만 기능을 직접 구현하는 본체라기보다는, 여러 Component를 붙여 하나의 엔티티처럼 다루기 위한 컨테이너에 가깝습니다.
빈 컨테이너
Hierarchy에 배치되는 캐릭터, 바닥, 아이템처럼 화면에 보이는 대상뿐 아니라 카메라, 조명, UI 버튼, 빈 부모 노드도 모두 GameObject로 표현됩니다.
이처럼 다양한 대상이 모두 GameObject로 표현될 수 있는 것은, 그 자체가 비어 있는 컨테이너이기 때문입니다. 새로 만든 GameObject에는 기본적으로 화면에 그리기, 물리 반응, 사운드 재생 같은 기능이 없습니다. 예외는 Transform입니다. Transform은 위치, 회전, 크기를 저장하는 필수 컴포넌트이며 모든 GameObject에 항상 존재합니다. 그 밖의 기능은 필요한 Component를 추가해야 생깁니다.
name, tag, layer
GameObject는 기능을 직접 구현하지 않지만, 오브젝트를 식별하고 분류하기 위한 기본 필드는 자체적으로 갖추고 있습니다. 대표적인 것이 name, tag, layer입니다.
name은 Hierarchy에서 오브젝트를 구분하고, 코드에서 이름 기반 검색을 할 때 사용하는 문자열입니다.
tag는 오브젝트를 용도별로 구분하는 문자열 레이블입니다. “Player”, “Enemy”, “Projectile”처럼 태그를 지정해 두면 충돌 처리나 검색 코드에서 대상을 손쉽게 분류할 수 있습니다. 태그 비교에는 gameObject.tag == "Player"보다 CompareTag("Player")를 사용하는 편이 좋습니다. 그 이유는 뒤의 문자열 마샬링 절에서 설명합니다.
layer는 오브젝트마다 지정하는 정수 값이며, 주로 물리 충돌과 카메라 렌더링 필터링에 사용됩니다. 레이어를 이용하면 특정 레이어끼리만 충돌하게 하거나, 특정 레이어의 오브젝트만 카메라에 렌더링할 수 있습니다. 내부적으로는 비트마스크 기반으로 처리되므로 필터링 비용이 낮습니다.
Component
Component는 GameObject에 실제 기능을 부여하는 단위입니다. 렌더링, 물리, 스크립트 동작처럼 서로 다른 기능이 Component로 나뉘어 붙고 떨어지기 때문에, 같은 GameObject도 조합에 따라 전혀 다른 역할을 할 수 있습니다.
기능의 단위
GameObject에 붙는 Component는 Unity가 미리 제공하는 내장 컴포넌트와, 개발자가 직접 작성하는 사용자 컴포넌트로 나뉩니다. 내장 컴포넌트로는 위치·회전·크기를 저장하는 Transform, 메시를 화면에 그리는 MeshRenderer, 물리 계산에 참여하는 Rigidbody 등이 있습니다. 사용자 컴포넌트는 게임에 필요한 동작을 담은 스크립트입니다.
MonoBehaviour
게임에 필요한 동작은 개발자가 C# 스크립트로 작성하지만, 평범한 C# 클래스만으로는 Unity가 그 코드를 언제 실행할지 알 수 없습니다. 스크립트가 MonoBehaviour를 상속하면, 그때부터 Unity는 이를 GameObject에 붙는 Component로 다루며 Start나 Update 같은 정해진 시점에 직접 호출합니다. 객체를 만들고 호출하고 없애는 일까지 엔진이 맡으므로, 개발자는 이 흐름에 들어갈 동작만 메서드로 채워 넣으면 됩니다.
사용자 스크립트의 기반 클래스
MonoBehaviour가 이런 역할을 맡을 수 있는 것은 여러 단계의 상속을 거쳐 능력을 물려받았기 때문입니다. UnityEngine.Object에서 출발해 Component, Behaviour를 거쳐 MonoBehaviour에 이르며, 각 단계가 서로 다른 능력을 더합니다. Component는 GameObject에 붙을 수 있게 하고, Behaviour는 컴포넌트를 켜고 끄는 enabled 속성을 더합니다.
컴포넌트를 켜고 끄는 enabled는 컴포넌트마다 제어하는 대상이 다릅니다. Behaviour를 물려받은 MonoBehaviour에서 enabled를 끄면 Update 같은 메서드 호출이 멈추고, Renderer에서는 렌더링이, Collider에서는 충돌 판정이 멈춥니다. 반면 Transform은 끌 수 없는 필수 컴포넌트여서 enabled 속성 자체가 없습니다.
MonoBehaviour를 상속하면 Unity 엔진이 호출하는 콜백 메서드를 사용할 수 있습니다. 콜백은 개발자가 직접 호출하는 메서드가 아니라, 엔진이 정해진 시점에 호출하는 메서드입니다. Awake, Start, Update, FixedUpdate, LateUpdate, OnDestroy 등이 여기에 해당합니다.
| 콜백 | 호출 시점 |
|---|---|
| Awake() | 스크립트 인스턴스가 로드될 때 (1회) |
| OnEnable() | 오브젝트가 활성화될 때 |
| Start() | 첫 번째 프레임 Update 직전 (1회) |
| FixedUpdate() | 고정 시간 간격마다 (물리 루프) |
| Update() | 매 프레임 (게임 로직) |
| LateUpdate() | 모든 Update 완료 후 (카메라 등) |
| OnDisable() | 오브젝트가 비활성화될 때 |
| OnDestroy() | 오브젝트가 파괴될 때 |
콜백 호출 순서는 Unity가 정합니다. 이 순서와 프레임 루프의 세부 흐름은 Unity 엔진 핵심 (3) - Unity 실행 순서에서 자세히 다룹니다.
MonoBehaviour의 제약
MonoBehaviour를 상속한 클래스는 new 키워드로 직접 만들면 안 됩니다. C# 컴파일러는 이를 막지 않지만, new로 만든 객체는 Unity 런타임에서 정상적인 컴포넌트로 동작하지 않습니다.
이유는 Unity의 GameObject나 Component가 C#과 엔진 양쪽에 걸쳐 존재하는 데 있습니다. 실제 객체는 C++로 작성된 엔진 내부에 만들어지고, 개발자가 C# 코드에서 다루는 것은 그 엔진 객체를 가리키는 래퍼(wrapper)입니다. 그런데 new는 C# 래퍼만 만들 뿐, 짝이 되는 엔진 객체를 만들지 않습니다. 엔진이 관리하는 실제 객체가 없으니 그 컴포넌트는 어떤 GameObject에도 붙지 못하고, 엔진이 Start나 Update를 호출해 주지도 않습니다.
그래서 MonoBehaviour 컴포넌트는 에디터에서 Add Component를 사용하거나 코드에서 AddComponent<T>()를 호출해 만들어야 합니다. 두 방식 모두 C# 래퍼와 엔진 객체를 함께 만들어 GameObject에 붙입니다.
GetComponent의 내부 동작
GetComponent는 필요한 컴포넌트를 찾는 간단한 함수처럼 보이지만, 그 내부에서는 컴포넌트 목록을 차례로 확인하는 검색이 일어납니다. 한 번 호출하는 비용은 대개 작지만, 같은 호출을 매 프레임 반복하면 결과가 달라지지 않는데도 같은 검색을 다시 하게 됩니다. GetComponent가 내부에서 어떻게 동작하는지 알아 두면, 어느 호출을 그대로 두고 어느 호출을 줄여야 할지 판단하는 데에 도움이 됩니다.
선형 검색
같은 GameObject에 붙은 다른 컴포넌트를 가져올 때, 참조를 Inspector에 미리 연결해 둘 수도 있지만 코드에서 타입만으로 찾아야 할 때는 GetComponent<T>()를 사용합니다. 이 함수는 GameObject의 컴포넌트 목록을 앞에서부터 하나씩 확인하다가 타입 T와 처음으로 맞는 컴포넌트를 반환합니다. 목록을 처음부터 순서대로 검사하는 이런 방식을 선형 검색(Linear Search)이라고 합니다.
검색은 목록을 앞에서부터 하나씩 확인하는 단순한 과정이고, 일반적인 GameObject에는 컴포넌트가 몇 개뿐이라 한 번의 검색은 금방 끝납니다. GetComponent의 비용이 문제가 되는 지점은 검색의 길이보다, 이 호출이 매번 C# 코드와 엔진 내부 사이를 넘나든다는 데 있습니다.
네이티브 경계를 넘는 비용
앞서 보았듯 GameObject나 Component의 실제 데이터는 C++로 작성된 엔진 내부에 있습니다. 이 영역을 네이티브 영역이라 하고, C# 객체는 그 데이터를 가리키는 래퍼일 뿐입니다. GetComponent가 검사하는 컴포넌트 목록도 네이티브 영역에서 관리되므로, C# 코드에서 컴포넌트를 찾으려면 그 경계를 넘어 네이티브 쪽에 요청을 보내야 합니다.
그래서 GetComponent<T>()는 한 번 호출될 때마다 세 단계를 거칩니다.
먼저 C# 코드에서 네이티브 코드로 넘어가는 경계 전환(Managed-to-Native boundary crossing)이 일어납니다. 찾으려는 타입 정보를 네이티브 쪽이 읽을 수 있는 형식으로 전달해야 하므로, 호출 자체에 비용이 따릅니다.
다음으로 네이티브 영역에서 컴포넌트 목록을 순회하며 타입을 하나씩 비교합니다. 앞서 살펴본 선형 검색이 바로 이 단계입니다.
마지막으로 찾은 컴포넌트를 다시 C# 코드로 돌려보내며 경계를 한 번 더 넘습니다.
이 세 단계가 한 번의 호출에서 차지하는 비용은 크지 않습니다. 문제는 같은 호출을 매 프레임 거듭할 때입니다. 컴포넌트 구성이 바뀌지 않아 결과가 늘 같은데도, 경계 전환과 검색이 프레임마다 처음부터 반복됩니다.
캐싱의 필요성
결과가 늘 같은 호출이라면 매번 다시 찾을 필요가 없습니다. 한 번 찾은 참조를 필드에 저장해 두고 그다음부터 재사용하면 되는데, 이렇게 찾은 결과를 보관해 두고 다시 쓰는 것을 캐싱(caching)이라고 합니다.
먼저 캐싱하지 않은 코드입니다. GetComponent를 Update 안에서 호출하면, 프레임마다 경계 전환과 컴포넌트 목록 검색이 그대로 반복됩니다.
1
2
3
4
void Update() {
Rigidbody rb = GetComponent<Rigidbody>();
rb.AddForce(Vector3.up);
}
이번에는 같은 호출을 캐싱한 코드입니다. Awake에서 참조를 한 번만 찾아 두면, Update는 그 결과를 다시 찾지 않고 그대로 사용합니다.
1
2
3
4
5
6
7
8
9
Rigidbody _rb;
void Awake() {
_rb = GetComponent<Rigidbody>();
}
void Update() {
_rb.AddForce(Vector3.up);
}
이제 검색과 경계 전환은 Awake에서 단 한 번만 일어나고, 그 뒤로 Update는 저장해 둔 참조를 읽기만 하므로 GetComponent 호출에 프레임마다 들던 비용이 사라집니다.
Awake()는 스크립트 인스턴스가 로드될 때 한 번 호출되어 초기화에 쓰이는 콜백이라, 이런 참조 캐싱을 해 두기에 알맞습니다. 이 패턴을 실제 코드에 적용하는 방법은 스크립트 최적화 (2) - Unity API와 실행 비용에서 자세히 다룹니다.
CompareTag와 문자열 마샬링
경계를 넘나드는 비용은 GetComponent에만 있는 것이 아닙니다. 오브젝트의 태그를 비교할 때도 숨은 비용이 따르는데, 여기서는 그 비용이 새 문자열을 만드는 데서 생깁니다.
태그 값도 네이티브 영역에서 관리되는 데이터입니다. 그래서 gameObject.tag로 태그를 읽으면, Unity는 네이티브 쪽 태그를 C#이 다룰 수 있는 문자열로 변환해 건네줍니다. 이렇게 한쪽의 데이터를 다른 쪽이 읽을 수 있는 형식으로 바꾸는 과정을 마샬링(marshalling)이라고 합니다. 태그를 문자열로 마샬링할 때마다 새 문자열 객체가 만들어지므로, gameObject.tag == "Player"처럼 매 프레임 태그를 읽어 비교하면 GC가 처리해야 할 할당이 그만큼 쌓입니다.
CompareTag("Player")는 비교할 문자열을 네이티브 쪽으로 넘겨 그곳에서 태그와 비교하고 결과만 돌려받으므로, C# 문자열을 새로 만들지 않습니다. 그래서 같은 비교를 반복해도 할당이 쌓이지 않습니다. 충돌이나 트리거를 처리할 때처럼 태그를 자주 확인하는 코드에서는 이 차이가 모여 무시하기 어려워지므로, 태그 비교에는 ==보다 CompareTag를 쓰는 편이 좋습니다.
컴포넌트 검색 메서드들
Unity에는 GetComponent 외에도, 자식과 부모, 나아가 씬 전체로 검색 범위를 넓힌 API가 있습니다. 검색 범위가 넓어질수록 순회할 대상이 늘어 비용도 커지므로, 각 메서드가 어디까지 검사하는지 알아 두고 필요한 만큼만 찾도록 선택해 쓰는 것이 좋습니다.
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>()와 FindObjectsOfType<T>()입니다. 전자는 씬의 모든 Component를 검사해 타입 T와 맞는 첫 컴포넌트를 반환하고, 후자는 일치하는 모든 컴포넌트를 배열로 돌려줍니다. 씬 전체를 순회하는 비용은 GameObject.Find와 다르지 않고, 복수형은 여기에 결과 배열을 할당하는 비용까지 더해집니다.
기존 FindObjectOfType과 FindObjectsOfType에는 추가 비용도 있었습니다. 결과를 반환하기 전에 InstanceID 기준 정렬을 수행했는데, 개발자가 이 정렬을 끌 수 없었습니다. Unity 2023.1부터는 이 함수들이 폐기(deprecated)되고, 정렬 비용을 제어할 수 있는 새 API가 제공됩니다.
새 API는 용도에 따라 나뉩니다. FindFirstObjectByType<T>()는 타입이 맞는 첫 오브젝트를 반환합니다. 어느 것을 받아도 상관없다면 FindAnyObjectByType<T>()를 사용할 수 있습니다. 반환 순서를 보장하지 않는 대신 더 빠를 수 있습니다.
여러 결과가 필요하면 FindObjectsByType<T>()를 사용합니다. 이 메서드는 FindObjectsSortMode 옵션을 받으므로, 정렬이 필요 없다면 FindObjectsSortMode.None을 지정해 정렬 비용을 줄일 수 있습니다.
Find 계열 함수는 매 프레임 호출하지 않는 것이 기본입니다. 초기화 시점인 Awake나 Start에서 한 번 호출하고 결과를 캐싱해 두는 식으로 사용합니다. 참조할 대상이 미리 정해져 있다면 Inspector에서 직접 연결하는 방식이 더 명확하고 비용도 없습니다.
| 메서드 | 검색 범위 |
|---|---|
| GetComponent<T>() | 자기 자신의 컴포넌트 목록 |
| GetComponentInChildren | 자신 + 모든 자손의 컴포넌트 |
| GetComponentInParent | 자신 + 모든 조상의 컴포넌트 |
| GameObject.Find() | 씬 전체의 활성 GameObject |
| FindObjectOfType<T>() | 씬 전체의 모든 Component |
| FindObjectsOfType<T>() | 씬 전체 + 결과 배열 할당 |
| ※ Unity 2023.1+ 대체 API: | |
| FindFirstObjectByType | 씬 전체 (첫 번째 일치 반환) |
| FindAnyObjectByType | 씬 전체 (순서 무관, 더 빠름) |
| FindObjectsByType | 씬 전체 + 배열 (정렬 옵션) |
프리팹 (Prefab)
프리팹은 같은 구성의 GameObject를 여러 곳에서 재사용하기 위한 템플릿입니다. 원본 템플릿과 복제된 인스턴스의 관계를 살펴보면, 런타임 복제에 드는 비용과 인스턴스가 서로 독립적으로 동작하는 이유를 함께 이해할 수 있습니다.
재사용 가능한 GameObject 템플릿
여기까지 GameObject에 Component를 붙여 기능을 구성하고, 필요한 컴포넌트를 찾아 쓰는 방법을 살펴보았습니다. 하지만 같은 구성의 적이나 총알, 이펙트를 여러 개 만들어야 할 때마다 빈 GameObject에 컴포넌트를 직접 붙이는 방식은 비효율적이고, 오류가 생기기도 쉽습니다.
프리팹(Prefab)은 미리 구성해 둔 GameObject를 에셋으로 저장한 템플릿입니다. 필요한 컴포넌트와 초기값을 갖춘 GameObject를 프리팹으로 만들어 두면, 같은 구성의 오브젝트를 반복해서 생성할 수 있습니다.
Instantiate와 런타임 복제
게임 실행 중에 프리팹을 복제해 실제 씬 오브젝트로 만드는 함수가 Instantiate(prefab)입니다. 이렇게 만들어진 복제본을 프리팹의 인스턴스라고 합니다. 발사되는 총알이나 새로 등장하는 적, 폭발 이펙트처럼 런타임에 등장하는 오브젝트는 대부분 이 방식으로 생성됩니다.
1
GameObject enemy = Instantiate(enemyPrefab);
이 한 줄을 실행하면 Unity 내부에서는 다음 다섯 단계가 차례로 진행됩니다.
이 다섯 단계를 거치는 동안 프리팹의 데이터가 복사되고 새 GameObject와 Component가 메모리에 새로 만들어지므로, Instantiate를 호출할 때마다 메모리 할당과 초기화 비용이 따릅니다. 총알처럼 자주 생성되고 곧 파괴되는 오브젝트에 Instantiate와 Destroy를 반복하면, 할당과 GC 부담이 계속 쌓입니다.
실무에서는 이 비용을 줄이기 위해 오브젝트 풀링(Object Pooling)을 사용합니다. 다 쓴 오브젝트를 파괴하지 않고 비활성화해 풀에 보관해 두었다가, 다시 필요할 때 활성화해서 재사용합니다. 생성과 파괴를 반복하지 않으므로 새 할당과 GC 부담을 줄일 수 있습니다.
오브젝트 풀링과 그 바탕이 되는 메모리 할당·GC는 스크립트 최적화 (1) - C# 실행과 메모리 할당에서 자세히 다룹니다.
인스턴스의 독립성
앞 절에서 보았듯이 Instantiate는 프리팹의 데이터를 통째로 복사해, 인스턴스마다 별도의 GameObject와 Component를 메모리에 만듭니다. 각 인스턴스가 다루는 데이터는 생성 시점의 값을 복사한 자기만의 사본이라, 프리팹이나 다른 인스턴스와 메모리를 공유하지 않습니다. 따라서 한 인스턴스의 컴포넌트 값을 런타임에 바꿔도 그 변경은 자기 사본에만 반영될 뿐, 원본 프리팹이나 함께 생성된 다른 인스턴스에는 영향을 주지 않습니다.
예를 들어 같은 Enemy 프리팹에서 적 셋을 생성하면, 셋 모두 처음에는 같은 HP와 이동 속도로 출발합니다. 이후 한 마리가 공격을 맞아 HP가 줄고 다른 한 마리는 엘리트 개체로 이동 속도가 올라가더라도, 이런 변화는 값을 바꾼 인스턴스에만 적용됩니다. 나머지 한 마리의 HP와 속도는 처음 값 그대로 남고, 원본 프리팹의 값도 변하지 않습니다.
각 인스턴스가 이렇게 자기 상태를 따로 유지하기 때문에, 같은 프리팹에서 나온 오브젝트라도 게임 안에서는 저마다 다르게 동작할 수 있습니다.
마무리
Unity는 깊은 상속 계층보다 컴포지션을 중심으로 GameObject를 구성합니다. GameObject는 Component를 담는 컨테이너이고, 실제 기능은 붙어 있는 Component가 담당합니다. 이 구조가 Unity API의 형태와 최적화 방향을 함께 결정합니다.
- GameObject는 이름·태그·레이어와 필수 컴포넌트인 Transform만 가진 빈 컨테이너이며, 컴포지션 패턴 위에서 동작하므로 런타임에도 컴포넌트를 자유롭게 붙이거나 떼어낼 수 있습니다.
- MonoBehaviour는 사용자 스크립트의 기반 클래스이며, Awake·Start·Update 같은 콜백으로 엔진과 맞물려 동작합니다.
- GetComponent는 네이티브 경계를 넘나들며 선형 검색을 거치므로 그 결과를 캐싱해 두고, Find 계열 함수는 씬 전체를 검색하므로 초기화 시점에 한 번만 호출하는 편이 좋습니다.
- 프리팹은 재사용을 위한 GameObject 템플릿이며, Instantiate로 런타임에 복제합니다. 같은 오브젝트를 자주 만들고 없앤다면 오브젝트 풀링으로 할당과 GC 비용을 줄일 수 있습니다.
캐싱, 불필요한 검색 회피, 오브젝트 풀링은 모두 이 구조를 이해했을 때 자연스럽게 따라오는 최적화 전략입니다.
지금까지 GameObject와 Component가 어떻게 맞물리는지 살펴보았습니다. 다음 글에서는 모든 GameObject에 반드시 존재하는 Transform으로 넘어갑니다.
Transform은 부모-자식 관계로 씬의 계층 구조를 이루며, 이 계층은 위치 계산과 업데이트 비용에도 영향을 줍니다. 자세한 내용은 Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프에서 이어집니다.
관련 글
전체 시리즈
- 하드웨어 기초 (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 개요