Unity 엔진 핵심 (3) - Unity 실행 순서 - soo:bak
작성일 :
매 프레임 코드가 실행되는 순서
Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프에서는 오브젝트들이 Transform 계층으로 공간상에서 어떻게 연결되는지 살펴봤습니다. 부모를 옮기면 자식이 따라 움직이고, 한 오브젝트의 월드 위치는 계층을 거슬러 올라가며 결정되었습니다. Transform 계층이 오브젝트가 ‘어디에’ 놓이는지를 정한다면, 한 프레임 안에는 코드가 ‘언제’ 실행되는지를 정하는 순서가 따로 있습니다.
Unity는 한 프레임 동안 물리 시뮬레이션, 입력 처리, 게임 로직, 애니메이션, 렌더링을 정해진 순서로 진행합니다. Unity 엔진 핵심 (1) - GameObject와 Component에서 본 Awake, Start, FixedUpdate, Update, LateUpdate 같은 MonoBehaviour 콜백도 이 순서 안의 정해진 시점에 호출됩니다.
이 순서를 모른 채 로직을 배치하면 타이밍 문제가 생깁니다. 카메라 추적과 캐릭터 이동을 둘 다 Update에 두면 어느 쪽이 먼저 실행될지 보장되지 않아, 카메라가 캐릭터를 한 프레임 늦게 따라오기도 합니다. 입력을 읽는 코드를 FixedUpdate에 두면, FixedUpdate가 호출되지 않는 프레임에서는 그 입력을 놓칠 수 있습니다. 두 경우 모두 어떤 로직을 어느 콜백에 두어야 하는지의 문제이고, 정확한 위치는 각 콜백이 한 프레임 안에서 언제 호출되는지를 알아야 정할 수 있습니다.
이 글에서는 Unity가 한 프레임 동안 코드를 어떤 순서로 실행하는지 정리합니다. 초기화 단계에서 시작해 물리 루프, 입력 처리, Update, LateUpdate, 렌더링 콜백, 코루틴, OnDisable, OnDestroy까지 차례로 따라가고, 마지막에는 전체 순서를 하나의 다이어그램으로 모읍니다.
초기화 단계
오브젝트가 씬에 처음 등장하거나 비활성 상태에서 다시 켜지면, Unity는 그 오브젝트의 초기화 콜백을 호출합니다. Awake, OnEnable, Start가 여기에 속합니다. 세 콜백은 모두 초기화에 쓰이지만, 호출되는 시점과 횟수가 다르고, 그 시점에 안전하게 접근할 수 있는 대상도 다릅니다. 따라서 같은 초기화 코드라도 어느 콜백에 두느냐에 따라 문제없이 실행되기도 하고, 아직 준비되지 않은 오브젝트를 참조해 오류로 이어지기도 합니다.
Awake
초기화 단계에서 가장 먼저 호출되는 콜백은 Awake()입니다. 씬이 로드되거나 Instantiate()로 오브젝트가 생성될 때, 해당 오브젝트에 붙은 각 MonoBehaviour의 Awake()가 한 번씩 호출됩니다.
Awake에 두기 좋은 작업은 자기 자신의 초기화입니다. Unity 엔진 핵심 (1) - GameObject와 Component에서 다룬 GetComponent 캐싱처럼 자신의 컴포넌트를 미리 가져오거나, 내부 변수를 초기값으로 설정하는 작업이 여기에 맞습니다.
1
2
3
4
5
6
7
8
9
// Awake에서 수행하는 작업
void Awake() {
_rb = GetComponent<Rigidbody>(); // 자기 컴포넌트 캐싱
_health = maxHealth; // 내부 변수 초기화
_moveDirection = Vector3.zero; // 상태 초기화
}
// → 자기 자신에 대한 설정만 수행
// → 다른 오브젝트를 참조하지 않음
Awake를 자기 자신의 초기화에 쓰는 이유는 호출 순서에 있습니다. 여러 MonoBehaviour의 Awake가 어떤 차례로 호출될지는 정해져 있지 않습니다. 같은 GameObject에 붙은 컴포넌트끼리도, 서로 다른 오브젝트 사이에서도 그 순서는 보장되지 않습니다. 예를 들어 오브젝트 A의 Awake가 실행되는 시점에 오브젝트 B의 Awake는 이미 끝났을 수도 있고, 아직 시작되지 않았을 수도 있습니다.
따라서 Awake에서 다른 컴포넌트나 오브젝트의 초기화 결과를 참조하면, 그 대상이 아직 준비되지 않았을 수 있습니다. 이런 의존은 Awake가 아니라 Start에 두어야 합니다. Start는 씬에 함께 로드된 모든 오브젝트의 Awake가 끝난 뒤에 호출되므로, 다른 오브젝트의 기본 초기화가 끝났다고 보고 접근할 수 있습니다.
앞에서 본 호출 순서와는 별개로, Awake의 호출 여부는 GameObject의 활성 상태가 정합니다. Awake는 GameObject가 씬 계층 전체에서 활성 상태, 즉 activeInHierarchy가 true일 때 호출됩니다. 자기 자신의 activeSelf가 true라도 부모가 비활성 상태이면 activeInHierarchy는 false가 되어, 이때는 Awake가 호출되지 않습니다. 씬 로드 시점에 비활성 상태였던 GameObject는 나중에 SetActive(true)로 처음 켜지는 순간에 Awake가 호출됩니다.
활성 상태와 구별해야 할 것이 MonoBehaviour의 enabled 속성입니다. enabled는 Update나 LateUpdate 같은 매 프레임 콜백을 켜고 끄는 스위치입니다. 반면 Awake는 GameObject의 활성 상태만 따르므로, 컴포넌트의 enabled가 false라도 GameObject만 활성 상태라면 그대로 호출됩니다.
OnEnable
OnEnable()은 오브젝트나 컴포넌트가 활성화될 때 호출되는 콜백입니다. 오브젝트 생애에 한 번만 호출되는 Awake와 달리, OnEnable은 활성화될 때마다 거듭 호출됩니다. 최초 활성화 때는 Awake 직후 오브젝트와 컴포넌트가 활성 상태라면 OnEnable이 이어서 호출되고, 그 뒤로는 SetActive(true)나 enabled = true로 다시 활성화할 때마다 호출됩니다.
OnEnable은 활성화될 때마다 호출되므로, 오브젝트가 켜질 때 새로 해 두어야 하는 작업을 두기 좋은 위치입니다. Awake와 Start는 처음 한 번만 호출되어 비활성화했다가 다시 켜도 실행되지 않으니, 다시 켜질 때마다 되살려야 하는 작업이라면 OnEnable에 두어야 합니다. 대표적인 예가 이벤트 구독입니다. OnEnable에서 이벤트를 구독해 두면 오브젝트가 켜져 있는 동안에만 이벤트를 받고, 비활성화될 때 OnDisable에서 구독을 해제해 짝을 맞춥니다.
Start
Start()는 세 콜백 가운데 가장 늦게 호출됩니다. 오브젝트마다 한 번씩, 그 오브젝트의 첫 Update가 호출되기 직전에 실행됩니다.
씬과 함께 로드된 오브젝트라면, Unity는 씬에 있는 모든 오브젝트의 Awake와 OnEnable을 먼저 끝냅니다. 그런 다음 첫 프레임에서 각 오브젝트의 Start를 호출하고, 이어서 Update를 시작합니다. 이 시점이면 씬의 다른 오브젝트들도 이미 Awake로 자기 초기화를 마친 뒤입니다. 따라서 Awake에서는 대상이 아직 준비되지 않아 미뤄 두어야 했던 다른 오브젝트 참조를, Start에서는 안전하게 처리할 수 있습니다.
런타임에 Instantiate()로 생성한 오브젝트는 이 순서를 그대로 따르지 않습니다. 새 오브젝트의 Awake와 OnEnable은 Instantiate()가 호출되는 그 순간 실행되어, 호출한 코드가 다음 줄로 넘어갈 때는 이미 끝나 있습니다. 반면 Start는 씬 로드 때와 마찬가지로 그 오브젝트의 첫 Update 직전까지 미뤄집니다. 이 때문에 Instantiate() 바로 다음 줄에서 새 오브젝트의 값을 읽으려 해도, 그 값을 채우는 Start가 아직 실행되기 전이라 원하는 값을 얻지 못합니다.
세 콜백을 역할로 나누어 보면, Awake는 자기 컴포넌트와 내부 상태를 초기화하는 자리이고, Start는 다른 오브젝트의 초기화 결과에 접근하는 자리입니다. 이 기준대로 초기화 코드를 배치하면 호출 순서 차이로 생기는 null 참조를 줄일 수 있습니다. 다만 Awake와 Start는 오브젝트 생애에 한 번만 호출되므로, 다시 켤 때마다 거듭해야 하는 초기화는 앞서 본 OnEnable에서 처리합니다.
물리 루프
초기화가 끝나면 프레임마다 같은 작업이 정해진 순서로 반복됩니다. 그 작업 가운데 물리 연산은 다른 처리와 달리 별도의 시간 기준을 따릅니다. 화면 프레임은 일정한 속도로 그려지지 않아 프레임마다 걸리는 시간이 제각각이기 때문입니다.
물리 시뮬레이션을 이 일정하지 않은 프레임 간격에 그대로 맡기면, 한 번에 계산하는 시간 구간이 프레임마다 달라져 같은 장면이라도 물체의 움직임이 기기 성능에 따라 달라질 수 있습니다. 따라서 Unity는 물리 처리를 화면 프레임 속도와 분리해, 프레임 길이와 상관없이 늘 같은 크기의 시간 간격으로 진행합니다.
FixedUpdate
이렇게 늘 같은 크기로 유지되는 시간 간격을 고정 타임스텝(Fixed Timestep)이라고 하며, Unity의 물리 처리는 이 간격에 맞춰 일정하게 진행됩니다. FixedUpdate()는 물리 연산과 관련된 코드를 작성하는 콜백으로, 한 번의 고정 타임스텝이 흐를 때마다 Unity가 호출합니다. 고정 타임스텝의 기본값은 0.02초이며, 환산하면 초당 50번(50Hz)입니다. 이 값은 Project Settings > Time > Fixed Timestep에서 바꾸거나, 코드에서는 Time.fixedDeltaTime으로 조정할 수 있습니다.
Update가 프레임마다 정확히 한 번 호출되는 것과 달리, FixedUpdate의 호출 횟수는 프레임마다 일정하지 않습니다. 한 프레임 안에서 한 번도 호출되지 않을 수도, 한 번 호출될 수도, 여러 번 호출될 수도 있습니다. 고정 타임스텝은 0.02초(20ms)로 일정하지만, 실제 한 프레임에 걸리는 시간은 16.6ms로 끝나기도 하고 50ms까지 늘어나기도 하기 때문입니다.
호출 횟수는 Unity가 시간을 누적해 두었다가 고정 간격으로 처리하는 방식으로 정해집니다. Unity는 프레임과 프레임 사이에 실제로 흐른 시간을 내부 누적값에 더합니다. 이 누적값이 고정 타임스텝보다 작으면 아직 한 스텝을 채우지 못한 것이므로 그 프레임에서는 FixedUpdate를 호출하지 않고, 누적값이 고정 타임스텝 이상이면 FixedUpdate를 한 번 호출한 뒤 물리 시간 0.02초를 처리하고 누적값에서 그만큼을 뺍니다. 빼고 남은 시간이 여전히 고정 타임스텝 이상이라면 같은 과정을 반복합니다.
아래 그림은 고정 타임스텝이 20ms일 때, 프레임에 걸린 시간에 따라 FixedUpdate의 호출 횟수가 어떻게 달라지는지 보여 줍니다.
그림의 첫 번째 프레임에서는 16.6ms만 누적되므로 20ms에 도달하지 못합니다. 이 프레임에서는 Update는 한 번 호출되지만 FixedUpdate는 호출되지 않습니다. 두 번째 프레임에서 다시 16.6ms가 더해지면 누적값이 33.2ms가 되고, 이 중 20ms를 하나의 물리 스텝으로 처리합니다. 남은 13.2ms는 다음 프레임으로 넘어갑니다.
세 번째 프레임처럼 50ms가 걸린 경우에는 이전에 남아 있던 13.2ms와 합쳐져 63.2ms가 됩니다. 이 값은 20ms를 세 번 처리할 수 있는 크기이므로, 같은 화면 프레임 안에서 FixedUpdate가 세 번 호출됩니다. 세 번 처리하고 남은 3.2ms는 다시 다음 프레임으로 이월됩니다.
정리하면 FixedUpdate의 호출 횟수는 프레임마다 달라질 수 있지만, 한 번의 호출이 담당하는 물리 시간은 고정 타임스텝으로 일정합니다. 이 구조 덕분에 물리 시뮬레이션은 화면 프레임률과 분리되어, 갑작스러운 프레임 변동에도 비교적 안정적인 간격으로 진행됩니다. 다만 이것이 모든 환경에서 물리 결과가 완전히 같아진다는 뜻은 아닙니다. 물리 엔진의 세부 설정, 입력 적용 시점, 플랫폼 차이에 따라 결과는 달라질 수 있으므로, 여기서 중요한 기준은 “물리 계산을 가변 프레임 시간이 아니라 고정 시간 간격에 맞춘다”는 점입니다.
따라서 Rigidbody의 물리 상태를 직접 바꾸는 코드는 일반적으로 FixedUpdate에 두는 것이 좋습니다. 힘을 가하는 AddForce, 물리 속도를 지정하는 velocity, 물리 기반 이동에 쓰는 MovePosition 같은 처리가 여기에 해당합니다. 반대로 키 입력처럼 프레임 단위로 놓치면 안 되는 정보는 Update에서 읽고, 그 결과를 변수로 저장한 뒤 FixedUpdate에서 물리 처리에 사용하도록 역할을 나누는 것이 안전합니다.
고정 타임스텝과 가변 타임스텝의 원리는 게임 루프의 원리 (1) - 프레임의 구조에서 자세히 다룹니다.
물리 시뮬레이션과 충돌 콜백
FixedUpdate에서 AddForce로 힘을 걸거나 velocity로 속도를 지정해도, 그 호출이 끝난 순간 물체가 이미 움직여 있는 것은 아닙니다. FixedUpdate는 이번 스텝에 적용할 힘과 속도를 정해 두는 단계일 뿐, 그 값으로 물체를 실제로 움직이고 충돌을 판정하는 일은 그 직후 물리 엔진이 맡습니다. 이 계산을 담당하는 엔진은 3D에서는 PhysX, 2D에서는 Box2D입니다.
엔진은 충돌을 판정한 뒤 그 결과를 콜백으로 알립니다. 아래 그림은 FixedUpdate부터 이 콜백까지, 한 스텝의 처리 순서를 보여 줍니다.
물리 시뮬레이션 단계에 들어서면 엔진은 FixedUpdate에서 모인 힘에 중력을 더해, 한 타임스텝 동안 각 Rigidbody의 속도와 위치를 갱신합니다. 위치를 옮긴 뒤에는 그 새 위치를 기준으로 어떤 Collider가 서로 겹쳤는지 검사합니다.
이 검사에서 겹친 Collider가 나오면 엔진은 그 결과를 콜백으로 전달합니다. 호출되는 콜백은 겹친 Collider의 종류에 따라 두 가지입니다.
하나는 일반 Collider끼리 부딪히는 경우로, 두 물체가 서로 밀어내거나 멈추는 물리 반응이 일어나며 OnCollisionEnter, OnCollisionStay, OnCollisionExit가 실행됩니다.
다른 하나는 Trigger로, Collider의 Is Trigger 옵션을 켜면 그 Collider는 다른 물체를 밀어내지 않고 그대로 통과시키되 겹친 사실만 알립니다. 이때는 충돌용 콜백 대신 OnTriggerEnter, OnTriggerStay, OnTriggerExit가 호출됩니다.
영역에 들어서면 발동하는 함정이나 통과를 감지하는 골인 지점이 이런 Trigger에 해당합니다.
두 종류 모두 이름 끝의 Enter·Stay·Exit가 호출 시점을 나타냅니다. Enter는 두 물체가 막 닿은 스텝에, Exit는 떨어진 스텝에 한 번씩, Stay는 맞닿아 있는 동안 매 스텝 호출됩니다.
일반 충돌과 Trigger의 물리 반응 차이는 물리 최적화 (2) - 물리 최적화 전략에서 자세히 다룹니다.
콜백은 물리 시뮬레이션 이후에 호출되므로, 그 안에서 읽는 Rigidbody의 위치와 속도는 이번 스텝의 계산이 모두 반영된 값입니다. 따라서 충돌에 반응해 되튕기는 속도를 조정하거나, 체력을 깎거나, 상태를 전환하는 코드는 콜백 안에 두는 것이 좋습니다.
이렇게 FixedUpdate부터 충돌 콜백까지 물리 루프가 한 차례 정리되면, 프레임은 이어서 플레이어의 입력을 받아들이는 단계로 넘어갑니다.
입력 처리 시점
플레이어의 키 입력이나 마우스 클릭은 게임이 곧바로 반응해야 하는 정보입니다. 따라서 한 프레임 안에서 입력을 언제 읽느냐가 동작에 영향을 줍니다. Unity에서 입력을 다루는 방식은 두 가지입니다. Input.GetKeyDown() 같은 폴링(Polling) 함수는 코드에서 직접 호출해 그 순간의 입력 상태를 읽고, OnMouseDown() 같은 입력 이벤트 콜백은 Unity가 정해진 시점에 자동으로 호출합니다.
아래 그림은 FixedUpdate부터 Update까지 이어지는 한 프레임의 처리 순서와, 그 안에서 입력 이벤트 콜백과 폴링 입력이 다뤄지는 위치를 보여 줍니다.
입력 감지를 FixedUpdate에 두면 문제가 생깁니다. Input.GetKeyDown()은 키가 눌린 프레임에서만 true를 반환하는데, FixedUpdate는 그 프레임에 한 번도 호출되지 않을 수도, 여러 번 호출될 수도 있기 때문입니다.
예를 들어, FixedUpdate가 호출되지 않은 프레임에서는 입력을 읽는 코드도 실행되지 않아 그 입력이 전혀 처리되지 않고, 여러 번 호출된 프레임에서는 같은 입력을 여러번 처리하게 됩니다. 이러한 이유로 입력 감지는 프레임마다 정확히 한 번 호출되는 Update에 두고, 힘을 가하는 물리 반응은 FixedUpdate에 두어 역할을 나누는 것이 적절합니다.
New Input System
지금까지 입력은 모두 Legacy Input Manager(UnityEngine.Input)를 기준으로 살펴봤습니다. 이 방식은 Update에서 매 프레임 Input.GetKeyDown()을 호출해 그 프레임에 키가 눌렸는지 확인하는 폴링입니다.
Unity 6에는 이를 대체하는 New Input System(UnityEngine.InputSystem 패키지)이 있으며, 이 시스템은 이벤트 기반으로 동작합니다. 폴링에서는 입력이 있든 없든 코드가 매 프레임 상태를 확인하지만, 이벤트 기반에서는 입력이 실제로 들어왔을 때만 등록해 둔 코드가 실행됩니다.
New Input System에서는 하나의 입력을 InputAction으로 정의하고, 그 동작이 시작될 때(started)·완료될 때(performed)·취소될 때(canceled)에 각각 콜백을 연결합니다. 예를 들어 점프 입력의 performed에 Jump를 등록해 두면, 키 상태를 직접 읽는 코드 없이도 플레이어가 키를 누를 때 Jump가 호출됩니다.
1
2
3
4
5
6
// Legacy Input Manager — 폴링 방식
void Update() {
if (Input.GetKeyDown(KeyCode.Space)) // 매 프레임 상태를 직접 확인
Jump();
}
1
2
3
4
5
6
// New Input System — 이벤트 방식
void OnEnable() {
jumpAction.performed += ctx => Jump(); // 콜백 등록
jumpAction.Enable();
}
다만 이벤트 기반이라고 해서 콜백이 키가 눌리는 순간 곧바로 실행되는 것은 아닙니다. New Input System은 들어온 입력을 프레임 루프 안의 정해진 지점에서 한꺼번에 처리하는데, 여기서 입력 상태를 갱신하고 등록된 콜백을 호출합니다. 프레임의 어느 지점에서 입력을 처리할지는 Update Mode 설정에서 선택합니다.
기본값인 Dynamic Update 모드는 매 프레임 Update 직전에 입력을 처리합니다. 이 시점은 Legacy Input Manager가 입력을 읽던 때와 비슷합니다. Update가 시작될 때는 그 프레임에 들어온 입력이 이미 반영된 뒤이므로, 프레임 단위로 실행되는 게임 로직이 최신 입력 상태를 읽게 됩니다.
Fixed Update 모드는 입력 처리를 FixedUpdate 직전으로 옮깁니다. 입력 이벤트를 내부 버퍼에 모아 두었다가, 각 FixedUpdate 직전에 그 호출이 담당하는 시간 구간의 이벤트만 꺼내 전달합니다.
Fixed Update 모드에서는 FixedUpdate에서 입력이 누락되거나 중복되던 Legacy 폴링의 문제가 생기지 않습니다. FixedUpdate가 호출되지 않은 프레임의 입력은 버퍼에 남았다가 다음 FixedUpdate에 전달되고, 여러 번 호출된 프레임에서는 각 호출이 자기 구간의 입력만 받기 때문입니다. 따라서 입력 감지와 힘 적용을 같은 고정 타임스텝에 맞춰야 하는 물리 기반 캐릭터 컨트롤러에는 Fixed Update 모드가, 물리와 무관한 프레임 단위 로직에는 기본값인 Dynamic Update 모드가 적합합니다.
Update
기본 설정에서는 입력 이벤트를 처리한 직후, Unity는 Update()를 매 프레임 정확히 한 번 호출합니다. 물리에 의존하지 않는 캐릭터 이동, 입력 확인, 게임 상태 갱신, AI 판단처럼 프레임마다 새로 판단해야 하는 로직이 주로 여기에 들어갑니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Update에서 수행하는 일반적인 작업
void Update() {
// 입력 확인
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
// 이동 방향 계산
Vector3 dir = new Vector3(h, 0, v);
// Time.deltaTime으로 프레임 독립적 이동
transform.Translate(dir * speed * Time.deltaTime);
}
Time.deltaTime
Update는 프레임마다 호출되지만, 프레임 간격은 항상 같지 않습니다. 어떤 프레임은 16.6ms 만에 끝나고, 어떤 프레임은 33ms가 걸릴 수 있습니다. 이런 상황에서 이동 거리를 프레임당 고정값으로 더하면, 60fps 환경에서는 1초에 60번 더하고 30fps 환경에서는 30번만 더하므로 같은 코드라도 이동 속도가 달라집니다.
Time.deltaTime은 직전 프레임부터 이번 프레임까지 걸린 시간을 초 단위로 담습니다. 이동 거리를 속도 × Time.deltaTime으로 계산하면 한 번에 더하는 양이 프레임 간격에 비례합니다. 그래서 프레임률이 달라져도 1초 동안 움직이는 총 거리를 일정하게 유지할 수 있습니다.
이 가변 타임스텝 방식의 역사와 원리는 게임 루프의 원리 (1) - 프레임의 구조에서 자세히 다룹니다.
Update의 호출 순서
한 씬에는 여러 MonoBehaviour가 저마다 Update를 구현하고 있고, Unity는 매 프레임 이 Update들을 모두 호출합니다. 그런데 오브젝트 A의 Update와 B의 Update 가운데 어느 것이 먼저 호출되는지는 보장되지 않습니다. A의 Update가 B보다 먼저 실행된다고 가정하면, 그 가정은 언제든 어긋날 수 있습니다.
순서가 문제가 되는 것은 한 스크립트가 다른 스크립트의 이번 프레임 결과에 의존할 때입니다. A가 이번 프레임에 어떤 값을 갱신하고 B가 같은 프레임에 그 값을 읽어야 한다면, A의 Update가 먼저 실행돼야 B가 갱신된 값을 받습니다. 순서가 뒤집혀 B가 먼저 실행되면, B는 아직 갱신되지 않은 지난 프레임의 값을 읽습니다. 호출 순서가 보장되지 않는 한, 두 결과 가운데 어느 쪽이 나올지 미리 알 수 없습니다.
이처럼 순서를 반드시 지켜야 한다면, Script Execution Order에서 스크립트별 실행 순서를 지정할 수 있습니다. Project Settings > Script Execution Order에 스크립트를 등록하고 값을 부여하면, Unity는 그 값에 따라 Update를 호출합니다. 값은 기본이 0이고, 작을수록 먼저 실행됩니다. 앞의 예라면 A에 B보다 작은 값을 주어 A가 먼저 호출되도록 고정할 수 있습니다.
다만 순서를 이렇게 설정에 맡기면, 스크립트 사이의 의존 관계가 코드가 아니라 프로젝트 설정에 남습니다. 코드만 읽어서는 무엇이 먼저 실행되는지 드러나지 않고, 스크립트가 늘어날수록 설정에서 관리해야 하는 순서도 함께 복잡해집니다.
따라서 순서에 기대야 하는 로직이라면, 설정으로 맞추기보다 그 순서가 코드 자체에 나타나도록 구성하는 편이 낫습니다. 순서가 중요한 두 처리를 서로 다른 Update에 나누어 두는 대신, 한 스크립트가 다른 스크립트의 메서드를 필요한 차례대로 직접 호출하도록 작성합니다. 이렇게 하면 실행 순서가 프로젝트 설정이 아니라 코드에 그대로 드러나므로, 코드를 따라 읽는 것만으로 순서를 확인할 수 있습니다.
LateUpdate
어떤 로직은 다른 오브젝트들이 이번 프레임의 갱신을 모두 마친 뒤에야 올바르게 동작합니다. 그런데 Update는 오브젝트 사이의 호출 순서가 보장되지 않으므로, Update 안에서는 다른 오브젝트의 갱신이 이미 끝났다고 볼 수 없습니다. 이때 사용하는 콜백이 LateUpdate()입니다. LateUpdate는 한 프레임에서 모든 오브젝트의 Update가 끝난 뒤 호출되므로, 여기에 둔 로직은 다른 오브젝트의 이번 프레임 결과가 모두 확정된 상태에서 실행됩니다.
대표적인 예가 카메라 추적입니다. 카메라는 캐릭터의 이번 프레임 최종 위치를 기준으로 자신의 위치를 정해야 합니다. 그런데 캐릭터 이동과 카메라 추적을 모두 Update에 두면, 두 Update 사이의 실행 순서를 일반적으로 보장할 수 없습니다.
카메라의 Update가 캐릭터 이동보다 먼저 실행되면, 카메라는 아직 갱신되기 전의 캐릭터 위치를 읽습니다. 이 상태가 반복되면 카메라가 항상 이전 프레임의 위치를 기준으로 따라가게 되어, 화면에서는 추적이 한 박자 늦는 것처럼 보일 수 있습니다.
이 경우에는 두 로직을 서로 다른 단계로 나누는 편이 적절합니다. 캐릭터 이동은 Update에서 처리하고, 카메라 위치 계산은 LateUpdate로 미룹니다. 그러면 카메라가 실행될 때는 모든 Update가 끝난 뒤이므로, 이번 프레임의 캐릭터 위치를 기준으로 카메라를 배치할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 카메라 추적의 올바른 구현
// 캐릭터 스크립트
void Update() {
transform.position += moveDir * speed * Time.deltaTime;
// → 캐릭터 위치 갱신
}
// 카메라 스크립트
void LateUpdate() {
transform.position = target.position + offset;
// → 캐릭터의 최종 위치를 기준으로 카메라 위치 결정
// → 모든 Update가 끝났으므로 target.position은 최종값
}
카메라 추적뿐 아니라, 다른 오브젝트가 Update에서 만든 결과를 기준으로 처리해야 하는 로직은 LateUpdate에 두는 편이 안전합니다. 캐릭터의 본(Bone) 보정이나 IK(Inverse Kinematics) 보정이 여기에 해당합니다. IK는 손이나 발이 목표 지점에 맞도록 중간 관절의 각도를 계산하는 기법이므로, 대상 오브젝트의 최종 위치가 확정된 뒤 실행해야 결과가 안정적입니다.
렌더링 콜백
LateUpdate까지 끝나면 한 프레임의 게임 로직이 정리되고, 화면을 그리는 렌더링 단계로 넘어갑니다. 이 렌더링 과정의 특정 시점에 코드를 실행해야 할 때가 있습니다.
이때 URP에서는 RenderPipelineManager가 제공하는 이벤트를 구독합니다. 이 이벤트들은 프레임과 카메라의 렌더링이 시작되기 직전과 끝난 직후에 호출됩니다.
이 네 이벤트는 같은 렌더링 단계에 걸려 있지만 호출 기준은 서로 다릅니다. beginFrameRendering은 그 프레임의 렌더링이 시작되기 전에 한 번 호출되고, endFrameRendering은 프레임 렌더링이 끝난 뒤 한 번 호출됩니다. 두 이벤트는 프레임 단위 콜백이므로, 해당 프레임에서 렌더링할 카메라 목록을 함께 받습니다.
beginCameraRendering과 endCameraRendering은 카메라 단위 콜백입니다. Unity는 렌더링 대상 카메라를 하나씩 처리하면서, 각 카메라의 렌더링 직전과 직후에 이 이벤트를 호출합니다. 따라서 씬에 카메라가 여러 대라면 카메라 렌더링 구간도 카메라 수만큼 반복됩니다. 카메라별 임시 설정, 디버그 출력, 특정 카메라에만 적용할 처리는 이쪽 콜백에 두는 편이 자연스럽습니다.
사용 방법은 일반적인 C# 이벤트 구독과 같습니다. 컴포넌트가 활성화될 때 OnEnable에서 핸들러를 등록하고, 비활성화될 때 OnDisable에서 해제합니다. 이렇게 하면 컴포넌트가 켜져 있는 동안에만 렌더링 콜백을 받고, 비활성화되거나 파괴된 뒤에도 정적 이벤트에 핸들러가 남는 상황을 막을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// URP 렌더링 이벤트 구독 예시
void OnEnable() {
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
void OnDisable() {
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
}
void OnBeginCameraRendering(ScriptableRenderContext ctx, Camera cam) {
// 카메라 렌더링 직전에 수행할 작업
}
RenderPipelineManager 이벤트는 렌더링 흐름의 앞뒤에서 코드를 실행하기 위한 진입점입니다. 카메라별 상태를 준비하거나, 렌더링 직후 임시 데이터를 정리하는 용도라면 이 정도로 충분합니다.
하지만 렌더링 과정 한가운데에 직접 그리기 명령을 넣어야 하는 작업은 이벤트만으로 다루기 어렵습니다. 커스텀 후처리, 디버그 시각화, 별도 렌더 타깃 출력처럼 특정 렌더 패스 사이에 실행되어야 하는 작업이 여기에 해당합니다. 이런 경우에는 ScriptableRendererFeature로 기능을 등록하고, ScriptableRenderPass로 실제 패스를 작성해 URP 파이프라인의 원하는 지점에 삽입합니다.
렌더링 파이프라인의 구조는 Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조에서 자세히 다룹니다.
코루틴 실행 시점
앞서 다룬 콜백들은 한 프레임 안에서 정해진 시점에 호출됩니다. 반면 코루틴은 재개 시점이 하나로 고정되어 있지 않습니다. 어디서 다시 실행될지는 어떤 yield로 멈췄는지에 따라 달라집니다.
코루틴은 별도의 스레드가 아니라 메인 스레드에서 실행됩니다. StartCoroutine으로 시작한 코루틴은 yield return을 만나는 지점에서 실행을 멈추고, yield 문의 종류가 지정한 시점에 이어서 실행됩니다.
코루틴의 동작 원리는 Unity와 코루틴에서 자세히 다룹니다.
yield 문에 따른 코루틴 재개 시점
| yield 문 | 재개 시점 |
|---|---|
yield return null |
다음 프레임의 Update 직후 (LateUpdate 이전) |
yield return new WaitForEndOfFrame() |
현재 프레임의 렌더링 완료 후 |
yield return new WaitForFixedUpdate() |
다음 FixedUpdate 직후 (물리 시뮬레이션 이전) |
yield return new WaitForSeconds(t) |
t초 경과 후, Update 직후 (LateUpdate 이전) |
yield return StartCoroutine(other) |
other 코루틴 완료 후 |
이 가운데 실제 코드에서 자주 쓰이는 것은 yield return null입니다. 이 코루틴은 다음 프레임으로 넘어간 뒤, 그 프레임의 Update가 끝난 직후 다시 실행됩니다. 즉 Update와 LateUpdate 사이에서 재개됩니다.
WaitForEndOfFrame은 해당 프레임의 렌더링이 끝난 뒤 코루틴을 재개합니다. 화면에 그려진 결과를 ReadPixels로 읽어 캡처할 때처럼, 렌더링이 완료된 화면이 필요한 작업에 맞습니다.
WaitForFixedUpdate는 다음 FixedUpdate가 호출된 직후, 물리 시뮬레이션이 실행되기 전에 코루틴을 재개합니다. 고정 타임스텝에 맞춰 실행하되 FixedUpdate 메서드 안에 직접 두고 싶지 않은 로직을 분리할 때 사용할 수 있습니다. WaitForSeconds(t)로 멈춘 코루틴은 t초가 지난 뒤 가장 가까운 Update 이후에 다시 실행되며, yield return null과 마찬가지로 LateUpdate보다 앞에서 재개됩니다.
OnDisable과 OnDestroy
프레임 루프 바깥에는 오브젝트가 사용 상태에서 빠질 때 호출되는 콜백도 있습니다. OnDisable은 더 이상 활성 오브젝트로 동작하지 않는다는 신호이고, OnDestroy는 인스턴스의 수명이 끝난다는 신호입니다. 둘 다 정리 작업에 쓰이지만, 정리해야 하는 범위는 다릅니다.
OnDisable
OnDisable은 OnEnable과 짝을 이루는 콜백입니다. GameObject가 SetActive(false)로 꺼지거나, 컴포넌트의 enabled가 false가 되거나, 활성 상태의 오브젝트가 파괴 과정에 들어갈 때 호출됩니다.
이 콜백에는 활성 상태인 동안만 유지해야 하는 연결을 끊는 코드를 둡니다. 이벤트 구독, 입력 콜백, 렌더링 콜백처럼 OnEnable에서 등록한 것은 OnDisable에서 해제하는 식입니다. 이렇게 해 두면 오브젝트가 잠시 비활성화될 때도, 나중에 파괴될 때도 같은 경로로 정리됩니다.
해제를 빠뜨리면 비활성화된 오브젝트가 여전히 이벤트를 받거나, 파괴된 오브젝트의 참조가 델리게이트에 남을 수 있습니다. 오브젝트 풀처럼 켜고 끄기를 반복하는 구조에서는 같은 핸들러가 중복 등록되는 문제도 생길 수 있으므로, 구독과 해제는 한 쌍으로 관리하는 편이 안전합니다.
1
2
3
4
5
6
7
8
9
// 활성 상태 동안만 필요한 이벤트 연결
void OnEnable() {
EventManager.OnGamePaused += HandlePause;
}
void OnDisable() {
EventManager.OnGamePaused -= HandlePause;
}
OnDestroy
OnDestroy는 오브젝트가 실제로 제거될 때 호출되는 마지막 정리 지점입니다. Destroy(gameObject)로 제거되거나, 씬이 언로드되면서 그 안의 오브젝트가 함께 정리될 때 이 콜백이 호출됩니다. 활성 상태의 오브젝트가 파괴되는 경우에는 보통 OnDisable이 먼저 호출되고, 그다음 OnDestroy가 호출됩니다.
OnDisable이 활성 상태와 묶인 연결을 끊는 자리라면, OnDestroy는 인스턴스 생애 전체와 묶인 자원을 반환하는 자리입니다. Awake나 Start에서 만든 네이티브 컨테이너를 해제하거나, 정적 컬렉션에 등록해 둔 자기 참조를 제거하는 작업이 여기에 들어갑니다.
1
2
3
4
5
6
7
8
// 인스턴스 수명이 끝날 때 필요한 정리
void OnDestroy() {
EnemyManager.allEnemies.Remove(this);
if (_nativeBuffer.IsCreated)
_nativeBuffer.Dispose();
}
주의할 점은 OnDestroy가 한 번이라도 활성화된 적 있는 GameObject에서만 호출된다는 점입니다. 한 번도 활성화되지 않아 Awake조차 실행되지 않은 오브젝트라면, 그 오브젝트의 초기화 코드에서 만든 자원도 없다고 보는 편이 일반적입니다.
정리하면, OnDisable에는 다시 활성화될 수 있는 오브젝트가 잠시 멈출 때 필요한 정리를 둡니다. OnDestroy에는 다시 돌아오지 않는 인스턴스가 마지막으로 반환해야 하는 정리를 둡니다. 자원을 만든 시점이 OnEnable이라면 OnDisable에서 해제하고, 만든 시점이 Awake나 Start처럼 인스턴스 생애에 묶여 있다면 OnDestroy에서 해제하는 식으로 기준을 맞추면 됩니다.
전체 순서 다이어그램
지금까지 살펴본 콜백들을 초기화, 매 프레임 반복, 정리의 세 구간으로 나누어 실행 순서대로 정리하면 다음과 같습니다.
이 순서로 보면 각 콜백이 어느 시점에 호출되는지와, 어떤 로직을 어디에 두어야 하는지가 더 분명해집니다.
콜백별 적합한 로직
| 콜백 | 적합한 로직 |
|---|---|
Awake |
자기 컴포넌트 캐싱, 내부 변수 초기화 |
OnEnable |
이벤트 구독, 활성화 시 설정 |
Start |
다른 오브젝트 참조, 초기 배치 |
FixedUpdate |
물리 연산 (AddForce, MovePosition) |
Update |
입력 처리, 게임 로직, 비물리 이동 |
LateUpdate |
카메라 추적, 본 보정, 후처리 로직 |
OnDisable |
이벤트 해제, 활성 상태 정리 |
OnDestroy |
리소스 해제, 정적 참조 제거 |
마무리
Unity의 MonoBehaviour 콜백은 정해진 실행 순서에 따라 호출됩니다. 오브젝트가 등장할 때 Awake, OnEnable, Start로 초기화하고, 프레임마다 물리 루프와 입력 처리, Update, LateUpdate를 거쳐 화면을 렌더링하며, 오브젝트가 사라질 때 OnDisable과 OnDestroy로 정리합니다. 코루틴은 이 흐름 사이에서 yield 문이 정한 시점에 재개됩니다. 핵심은 다음과 같습니다.
- Awake / Start는 초기화를 나누는 기준입니다.
Awake는 자기 자신의 초기화에,Start는 다른 오브젝트 참조에 사용합니다. - FixedUpdate는 고정 간격으로 호출되며 프레임당 0~N회 실행될 수 있습니다. 물리 연산을 이 단계에 두면 시뮬레이션이 프레임률과 분리되어 일정한 간격으로 진행됩니다.
- 입력 처리는 시스템에 따라 시점이 다릅니다. Legacy Input Manager의 입력 이벤트 콜백은
Update이전에 호출되고, New Input System은 Update Mode 설정에 따라Update직전 또는FixedUpdate직전에 입력을 처리합니다. - Update는 매 프레임 한 번 호출됩니다. 프레임 간격이 달라질 수 있으므로 이동량이나 시간 누적에는
Time.deltaTime을 사용해야 합니다. - LateUpdate는 모든
Update가 끝난 뒤 호출됩니다. 카메라 추적처럼 다른 오브젝트의 최종 위치에 의존하는 로직에 적합합니다. - 렌더링 콜백은 렌더링 전후에 개입하기 위한 진입점입니다. URP에서는
RenderPipelineManager의beginCameraRendering,endCameraRendering같은 이벤트를 사용할 수 있습니다. - 코루틴은
yield문에 따라 재개 시점이 달라집니다.yield return null은Update와LateUpdate사이에,WaitForEndOfFrame은 렌더링 이후에 재개됩니다. - OnDisable / OnDestroy는 정리 단계입니다.
OnDisable에서는 이벤트 구독을 해제하고,OnDestroy에서는 네이티브 리소스나 정적 참조를 정리합니다.
결국 중요한 것은 로직을 그 성격에 맞는 콜백에 배치하는 일입니다. 물리처럼 일정한 시간 간격이 필요한 처리와 매 프레임 갱신되는 일반 로직을 나누고, 다른 오브젝트의 결과에 의존하는 처리는 그 결과가 확정된 뒤로 미루면, 실행 순서에서 비롯되는 타이밍 문제를 줄일 수 있습니다.
다음 글에서는 이 콜백들이 실제로 어느 스레드에서 실행되는지, 그리고 Unity의 잡 시스템과 어떻게 연결되는지 살펴봅니다. Unity 엔진 핵심 (4) - Unity의 스레딩 모델에서 이어집니다. 16.6ms라는 시간 제약 안에서 CPU와 GPU가 어떻게 병렬로 작업하는지는 게임 루프의 원리 (1) - 프레임의 구조에서 다룹니다.
관련 글
전체 시리즈
- 하드웨어 기초 (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 개요