Unity를 처음 접하면 GameObject, Component, Prefab, Scene, MonoBehaviour 같은 개념이 한꺼번에 등장한다.
각각 별개의 기능처럼 보이지만 기본적인 구조를 이해하면 대부분의 개념이 서로 연결되어 있다는 것을 알 수 있다.
Unity의 가장 기본적인 구조는 다음과 같이 볼 수 있다.
Scene -> GameObject -> Component -> Script / Renderer / Collider / Transform
Unity 개발은 결국 Scene 안에 GameObject를 배치하고, Component를 조합하여 게임의 상태와 동작을 만드는 과정이라고 볼 수 있다.
Scene
Scene은 게임의 한 공간이나 상태를 저장하는 단위다.
예를 들어 다음과 같은 것들이 각각 Scene이 될 수 있다.
Title
Lobby
Stage01
Stage02
GameOver
Scene 안에는 여러 GameObject가 존재한다.
Scene -> Player / Enemy / Camera / Light / UI
Scene 자체가 게임 로직을 담당하는 것은 아니다.
어떤 GameObject가 존재하는지, 각 GameObject가 어떤 Component를 가지고 있는지, 객체의 상태가 무엇인지 등을 저장하는 공간에 가깝다.
GameObject
Unity에서 게임 오브젝트를 구성하는 기본 단위가 GameObject다.
Player
Enemy
Bullet
Camera
Light
UI
이런 것들이 모두 GameObject다.
하지만 GameObject 자체에는 구체적인 기능이 많지 않다.
실제 기능은 Component를 붙여서 구성한다.
Player GameObject -> Transform + Mesh Renderer + Collider + Rigidbody + PlayerController
이 구조가 Unity를 이해하는 데 상당히 중요하다.
Component
Unity는 상속을 이용해 모든 기능을 하나의 거대한 클래스에 넣기보다 Component를 조합하는 방식을 중심으로 설계되어 있다.
예를 들어 플레이어가 물리적으로 움직여야 한다면 다음과 같이 구성할 수 있다.
Player -> Transform + Rigidbody + Collider + PlayerController
적에게 공격 판정과 체력 시스템이 필요하다면 다음과 같이 구성할 수 있다.
Enemy -> Transform + Collider + EnemyController + Health
이 구조 덕분에 하나의 거대한 Player 클래스를 만드는 대신 여러 기능을 분리할 수 있다.
Player -> Movement + Combat + Health + Inventory + Animation
각 기능의 책임을 분리하고 필요한 Component를 조합하는 방식이다.
Transform
모든 GameObject에는 기본적으로 Transform이 존재한다.
Transform은 객체의 다음 정보를 관리한다.
Position
Rotation
Scale
3D에서는 일반적으로 다음과 같이 표현한다.
Position = (x, y, z)
Rotation = (x, y, z)
Scale = (x, y, z)
Transform에는 부모-자식 관계도 존재한다.
Player -> Weapon -> Muzzle
Weapon의 Transform이 Player를 부모로 가지고 있다면 Player가 이동할 때 Weapon도 함께 이동한다.
이 계층 구조는 UI, 캐릭터 장비, 카메라, 이펙트 등을 구성할 때 자주 사용한다.
MonoBehaviour
Unity에서 직접 스크립트를 작성할 때 자주 상속하는 클래스가 MonoBehaviour다.
public class PlayerController : MonoBehaviour
{
}
이렇게 작성한 스크립트는 GameObject에 Component로 붙일 수 있다.
MonoBehaviour를 사용하면 Unity의 생명주기와 연결할 수 있다.
대표적으로 다음과 같은 메서드가 있다.
void Awake()
{
}
void Start()
{
}
void Update()
{
}
void FixedUpdate()
{
}
void LateUpdate()
{
}
Unity Lifecycle
Unity는 특정 시점에 정해진 생명주기 메서드를 호출한다.
대략적인 흐름은 다음과 같다.
Awake -> OnEnable -> Start -> Update -> LateUpdate
물리와 관련된 업데이트는 별도의 고정 시간 간격을 기준으로 FixedUpdate()에서 처리한다.
FixedUpdate -> Physics Processing
이 차이를 이해하지 못하면 모든 로직을 Update()에 넣게 된다.
일반적인 프레임 기반 게임 로직은 Update()에서 처리할 수 있지만, Rigidbody와 연결된 물리 계산은 FixedUpdate()를 고려해야 한다.
Update와 FixedUpdate
Update()는 프레임마다 호출된다.
void Update()
{
transform.position += direction * speed * Time.deltaTime;
}
여기서 Time.deltaTime을 사용하는 이유는 프레임 속도가 달라도 시간 기준으로 이동하도록 만들기 위해서다.
반면 FixedUpdate()는 고정된 시간 간격을 기준으로 호출된다.
void FixedUpdate()
{
rigidbody.AddForce(force);
}
물리 시스템과 연결되는 로직은 FixedUpdate()를 사용하는 경우가 많다.
둘을 단순히
Update -> 일반 로직
FixedUpdate -> 물리 로직
으로만 외우기보다는 프레임 기반 처리와 물리 시간 스텝의 차이를 이해하는 것이 중요하다.
Prefab
같은 형태의 GameObject를 여러 번 사용해야 한다면 Prefab을 사용할 수 있다.
예를 들어 적 100마리를 생성한다고 하자.
Enemy Prefab -> Enemy 1 / Enemy 2 / Enemy 3 / ... / Enemy 100
Prefab은 GameObject의 구성과 설정을 재사용하기 위한 Asset이다.
예를 들어 Enemy Prefab에 다음과 같은 Component가 설정되어 있을 수 있다.
Transform + Mesh + Animator + Collider + Rigidbody + EnemyController + Health
이 Prefab을 기반으로 여러 Enemy를 생성할 수 있다.
Prefab의 핵심적인 장점은 재사용성과 일관성이다.
Instantiate
Prefab이나 GameObject를 런타임에 생성할 때 Instantiate()를 사용할 수 있다.
Instantiate(enemyPrefab, position, rotation);
예를 들어 총알을 발사한다면 다음과 같은 흐름을 만들 수 있다.
Player -> Fire -> Instantiate Bullet -> Bullet 이동
하지만 Instantiate()와 Destroy()를 지나치게 반복하면 런타임에서 메모리 할당과 해제가 발생하고 성능에 영향을 줄 수 있다.
총알처럼 매우 자주 생성되고 제거되는 객체라면 Object Pooling을 고려할 수 있다.
Object Pooling
총알을 발사할 때마다 새로운 객체를 생성하고 제거하는 대신 미리 객체를 만들어두고 재사용할 수 있다.
기존 방식은 다음과 같다.
Instantiate -> 사용 -> Destroy
Object Pooling을 사용하면 다음과 같이 동작한다.
Pool -> Get -> Use -> Return -> Pool
Pool에는 미리 생성된 객체들이 존재한다.
Pool -> Bullet / Bullet / Bullet / Bullet / Bullet
발사할 때 비활성화된 Bullet을 가져오고 사용이 끝나면 다시 Pool에 넣는다.
이 방식은 빈번한 객체 생성과 제거를 줄이는 데 유용하다.
특히 탄환, 이펙트, 적 등 생성 빈도가 높은 객체에서 많이 사용한다.
Physics
Unity의 물리 시스템은 Collider와 Rigidbody 등을 이용해 구성한다.
Rigidbody -> 물리 상태
Collider -> 충돌 형태
예를 들어 플레이어에게 Rigidbody와 Collider를 붙이면 물리 엔진의 영향을 받을 수 있다.
충돌 이벤트는 상황에 따라 다음과 같은 메서드를 사용할 수 있다.
OnCollisionEnter() -> OnCollisionStay() -> OnCollisionExit()
Trigger의 경우 다음과 같은 이벤트가 존재한다.
OnTriggerEnter() -> OnTriggerStay() -> OnTriggerExit()
여기서 Collision과 Trigger를 구분해야 한다.
Trigger는 일반적인 물리적 충돌을 일으키기보다 다른 Collider가 특정 영역에 들어왔다는 사실을 감지하는 용도로 사용할 수 있다.
Layer와 Tag
Unity에는 GameObject를 분류하기 위한 기능이 있다.
Tag
Tag는 객체를 특정 의미로 식별할 때 사용한다.
Player / Enemy / Item / Boss
예를 들어 다음과 같이 사용할 수 있다.
if (other.CompareTag("Player"))
{
}
Layer
Layer는 객체를 그룹으로 분류하고 물리 충돌이나 카메라 렌더링 등의 처리에 활용할 수 있다.
Player Layer / Enemy Layer / Projectile Layer / Environment Layer
특히 Layer Mask를 이용하면 특정 Layer만 Raycast하거나 특정 카메라에서 렌더링하도록 구성할 수 있다.
Raycast
Raycast는 특정 방향으로 가상의 선을 발사하고 무엇과 충돌하는지 확인하는 방식이다.
Camera -> Ray -> Enemy
예를 들어 FPS에서 총을 쏘는 게임이라면 카메라 중앙에서 Ray를 발사해 적을 맞췄는지 확인할 수 있다.
if (Physics.Raycast(ray, out RaycastHit hit))
{
// hit.collider 확인
}
Raycast는 총알뿐만 아니라 다음과 같은 용도로도 사용할 수 있다.
바닥 찾기
시야 확인
상호작용 대상 찾기
마우스 클릭 대상 찾기
AI 시야 판정
Input
게임에서 플레이어의 입력을 처리하는 방식도 중요하다.
키보드, 마우스, 게임패드, 터치 등 여러 입력 장치를 직접 각각 처리하기보다 입력을 추상화하는 것이 좋다.
개념적으로는 다음과 같은 구조가 된다.
Input Device -> Input Action -> Game Logic
예를 들어 키보드의 W와 게임패드의 스틱 입력을 모두 이동이라는 행동으로 연결할 수 있다.
Keyboard W + Gamepad Stick -> Move -> Player Movement
이렇게 입력 장치와 실제 게임 행동을 분리할 수 있다.
Unity에서는 현재 Input System을 사용하는 방식이 일반적이다.
Animation
Unity의 애니메이션 시스템에서는 Animator와 Animation Clip 등을 사용한다.
캐릭터에는 다음과 같은 상태가 있을 수 있다.
Idle / Walk / Run / Attack / Hit / Death
Animator Controller에서는 상태와 전환 조건을 정의할 수 있다.
Idle -> Speed > 0 -> Walk -> Speed > Threshold -> Run
하지만 게임 로직과 애니메이션 로직을 지나치게 섞으면 관리하기 어려워진다.
캐릭터의 실제 상태는 게임 로직에서 관리하고 Animator에는 필요한 상태 정보를 전달하는 식으로 분리하는 편이 좋다.
UI
Unity의 UI는 Canvas를 중심으로 구성된다.
Canvas -> HP Bar / Inventory / Minimap / Button
UI 객체 역시 GameObject와 Component 구조를 사용한다.
게임 로직과 UI를 직접 강하게 연결하기보다는 상태와 표현을 분리하는 것이 좋다.
예를 들어 다음과 같은 흐름으로 구성할 수 있다.
Player Health -> Health Changed -> UI -> HP Bar Update
이렇게 구성하면 Player가 UI 객체를 직접 찾아서 수정하는 구조를 줄일 수 있다.
ScriptableObject
Unity에서 데이터를 코드와 분리해서 관리할 때 유용한 것이 ScriptableObject다.
예를 들어 아이템 데이터를 다음과 같이 관리할 수 있다.
Sword -> Damage: 30 / Price: 100 / Icon
코드에서는 다음과 같이 정의할 수 있다.
[CreateAssetMenu]
public class ItemData : ScriptableObject
{
public string itemName;
public int damage;
public int price;
}
그러면 여러 GameObject가 동일한 데이터를 참조할 수 있다.
Sword Prefab -> SwordData
특히 다음과 같은 데이터에 잘 맞는다.
아이템
무기
스킬
몬스터 데이터
게임 설정
코드의 로직과 독립적으로 관리하고 싶은 데이터를 Asset 형태로 분리할 수 있다는 것이 장점이다.
Addressables
게임 규모가 커지면 모든 Asset을 처음부터 메모리에 올려놓을 수 없다.
씬과 리소스를 상황에 따라 로드하고 해제해야 한다.
Unity에서는 Addressables를 사용해 Asset을 주소 기반으로 관리하고 비동기 로딩할 수 있다.
전체적인 흐름은 다음과 같다.
Address -> Asset Load -> Memory -> Use -> Release
특히 대규모 게임에서는 리소스를 어떻게 묶고 언제 로드하고 언제 해제할지가 중요하다.
Asset 관리 문제는 단순히 파일이 많다는 문제가 아니다.
메모리 사용량, 로딩 시간, 빌드 크기, 패치 구조와 직접 연결된다.
Scene과 Asset 관리
Unity 프로젝트가 커지면 Asset이 빠르게 늘어난다.
Assets -> Scripts / Prefabs / Scenes / Materials / Textures / Models / Audio / ScriptableObjects
폴더 구조 자체가 성능을 결정하지는 않지만 프로젝트 규모가 커질수록 일관된 규칙이 필요하다.
특히 팀 개발에서는 다음과 같은 규칙을 어느 정도 통일하는 것이 좋다.
파일 이름
폴더 구조
Prefab 규칙
Scene 규칙
Script 규칙
규칙이 없다면 프로젝트가 커질수록 원하는 Asset을 찾는 것 자체가 비용이 된다.
메모리 관리
Unity 게임에서 성능 문제를 볼 때 CPU만 보면 안 된다.
메모리도 중요하다.
대표적으로 다음과 같은 객체가 메모리를 사용한다.
Texture / Mesh / Audio / GameObject / Managed Object
특히 고해상도 Texture와 Audio Asset은 생각보다 많은 메모리를 차지할 수 있다.
또한 C#에서는 Garbage Collection도 고려해야 한다.
런타임에서 불필요한 객체를 계속 생성하면 GC가 동작하면서 프레임에 영향을 줄 수 있다.
Update -> 반복적인 Allocation -> Garbage 증가 -> GC -> Frame Spike
실시간 게임에서 이런 문제가 반복되면 순간적인 끊김이 발생할 수 있다.
성능 최적화
Unity에서 최적화를 할 때 가장 먼저 해야 할 것은 측정이다.
감으로 특정 코드가 느릴 것이라고 판단하기보다 Profiler로 실제 병목을 찾는 것이 낫다.
대표적으로 다음과 같은 영역을 확인할 수 있다.
CPU / GPU / Memory / Rendering / Physics
예를 들어 CPU 병목이라면 다음과 같이 실제 실행 시간을 추적할 수 있다.
Update -> 특정 함수 -> 반복문 -> 불필요한 연산
GPU 병목이라면 Draw Call, Shader, Texture, Overdraw 등의 문제를 살펴볼 수 있다.
Update 남용
Unity 개발에서 흔히 발생하는 문제 중 하나가 모든 것을 Update()에 넣는 것이다.
void Update()
{
CheckSomething();
FindTarget();
UpdateUI();
ProcessEnemy();
CheckInventory();
}
객체 수가 많아지면 이 구조는 빠르게 비용이 커진다.
필요한 경우 이벤트 기반으로 변경하거나 호출 주기를 낮출 수 있다.
매 프레임 확인 -> 필요할 때만 처리
60회 / 초 -> 10회 / 초
항상 검색 -> 상태 변경 시 갱신
물론 모든 Update()가 나쁜 것은 아니다.
매 프레임 필요한 로직도 많다.
중요한 것은 정말 매 프레임 계산해야 하는가를 판단하는 것이다.
GetComponent와 참조 관리
다른 Component를 가져올 때 GetComponent()를 사용할 수 있다.
var rb = GetComponent<Rigidbody>();
하지만 매 프레임 반복해서 호출할 필요는 없다.
필요한 참조를 초기화 시점에 가져와 재사용할 수 있다.
private Rigidbody rb;
void Awake()
{
rb = GetComponent<Rigidbody>();
}
특히 FindObjectOfType(), GameObject.Find() 같은 탐색을 Update()에서 반복하는 것은 피하는 편이 좋다.
객체를 찾는 작업 자체보다 매 프레임 불필요하게 탐색하는 구조가 문제다.
Coroutine
Unity에서는 Coroutine을 이용해 시간에 걸친 작업을 표현할 수 있다.
IEnumerator AttackDelay()
{
yield return new WaitForSeconds(1f);
Attack();
}
이런 방식으로 다음과 같은 흐름을 작성할 수 있다.
공격 -> 1초 대기 -> 다음 공격
Coroutine은 별도의 스레드가 아니다.
Unity의 메인 스레드 흐름에서 실행을 여러 프레임에 걸쳐 나누어 표현하는 방식에 가깝다.
따라서 무거운 연산을 Coroutine으로 감싼다고 자동으로 멀티스레딩이 되는 것은 아니다.
비동기와 멀티스레딩
게임에서는 메인 스레드가 상당히 중요하다.
렌더링과 많은 Unity API 호출 등이 메인 스레드와 연결되어 있기 때문이다.
무거운 작업을 메인 스레드에서 수행하면 프레임이 멈출 수 있다.
Main Thread -> Heavy Calculation -> 100ms -> Frame Stall
필요한 경우 C#의 Task, Unity Job System, Burst Compiler 등의 기술을 이용해 작업을 분산할 수 있다.
특히 Unity의 Job System은 대량의 데이터를 병렬 처리하는 방향에 적합하다.
다만 멀티스레딩은 무조건 사용하는 것이 아니다.
스레드 간 동기화와 데이터 접근 문제가 추가되기 때문에 실제 병목이 확인된 경우 적절한 작업부터 분리하는 것이 좋다.
Unity의 전체 구조
지금까지의 내용을 하나로 묶어 보면 Unity 프로젝트는 대략 다음과 같은 구조로 볼 수 있다.
Unity Project -> Scene -> GameObject -> Component -> Script / Physics / Renderer / Animator
Unity Project -> Asset -> Prefab / Texture / Audio / Model / ScriptableObject
Unity Project -> Runtime -> Input / Gameplay / Physics / Animation / UI / Networking
Unity의 기능을 하나씩 외우기보다 이 구조를 머릿속에 가지고 있는 것이 편하다.
Unity에서 중요한 설계
Unity 프로젝트가 커질수록 단순히 기능을 구현하는 것보다 코드와 객체 사이의 의존성을 어떻게 관리할 것인가가 중요해진다.
예를 들어 이런 구조가 있다고 하자.
public class Player : MonoBehaviour
{
void Update()
{
GameObject.Find("UI")
.GetComponent<UIManager>()
.UpdateHealth();
}
}
작동은 할 수 있다.
하지만 Player가 UI의 구체적인 구조까지 알아야 한다.
UI 이름이 변경되거나 구조가 바뀌면 Player 코드도 수정해야 한다.
반대로 다음과 같이 이벤트나 명확한 인터페이스를 통해 연결할 수 있다.
Player -> Health Changed -> UI
이렇게 하면 Player와 UI가 서로의 구체적인 구현을 직접 알 필요가 줄어든다.
Unity 개발에서도 결국 일반적인 소프트웨어 설계 원칙이 그대로 적용된다.
응집도는 높이고, 불필요한 결합은 줄이는 것이 중요하다.
Unity 개발을 하나의 흐름으로 보기
게임을 하나 만든다고 생각하면 Unity의 주요 시스템은 다음과 같이 연결할 수 있다.
Input -> Gameplay Logic -> Game State -> Physics / Animation -> Rendering -> UI
게임에 필요한 데이터는 별도의 Asset이나 외부 시스템에서 가져올 수 있다.
ScriptableObject / Prefab / Asset / Database / Server -> Game System
온라인 게임이라면 여기에 네트워크가 추가된다.
Client Input -> Game Logic -> Network -> Server -> Validation -> Game State -> Client
Unity는 이 전체 시스템 중 상당 부분을 구현할 수 있는 게임 엔진이고, 실제 게임의 구조를 어떻게 설계할지는 개발자가 결정해야 한다.
정리
Unity를 공부하면서 처음에는 기능이 굉장히 많아 보인다.
하지만 기본 구조는 생각보다 단순하다.
Scene -> GameObject -> Component -> Script -> Game Logic
여기에 필요한 기능들이 목적에 따라 붙는다.
Prefab -> 객체 재사용
ScriptableObject -> 데이터 관리
Physics -> 충돌과 물리
Animator -> 애니메이션 상태
Input System -> 입력 추상화
Addressables -> Asset 관리
Profiler -> 성능 분석
Job System / Burst -> 병렬 처리와 CPU 최적화
Unity 개발에서 중요한 것은 Unity API를 얼마나 많이 외웠느냐가 아니다.
게임의 상태와 객체의 책임을 어떻게 나누고, 필요한 기능을 어떤 Component와 시스템으로 연결할 것인가가 더 중요하다.
처음에는 MonoBehaviour 하나에 게임 로직을 전부 넣어도 게임은 돌아간다.
문제는 프로젝트가 커진 이후다.
기능이 늘고 객체가 늘어나면 서로 강하게 연결된 코드가 유지보수를 어렵게 만든다.
그래서 Unity를 제대로 사용하려면 엔진 사용법과 함께 객체지향 설계, 데이터 구조, 메모리, 렌더링, 물리, 비동기 처리, 네트워크 같은 기본적인 컴퓨터 과학 지식도 같이 가져가는 것이 좋다.