들어가며
이 글을 읽는 여러분이 유니티를 막 시작한 비기너 유저라고 하더라도 MonoBehaviour 클래스 안에 자동으로 생성되는 Start와 Update는 많이 보았을 것이다. 이는 미리 정의된 특수 이벤트 함수로써, 이 특수 함수들 - C#에서는 함수를 메소드라고도 한다 - 은 특정 이벤트, 즉 특정 조건 또는 시점에 유니티 엔진에 의해 자동으로 호출 된다.
유니티에는 위 Start와 Update 외에도 많은 이벤트 함수(메소드)가 있긴 하지만 오늘 포스트에서는 여러 특수 함수 중 초기화와 관련된 (아마도 이미 익숙한) Start와 (덜 익숙한) Awake에 대해서 살펴 보도록 하겠다.
이 포스트에서 여러분은 :
- Unity 이벤트 함수 호출 순서 시각화
- Awake 사용하는 법
- Start 사용하는 법
- Unity에서 Start와 Awake를 함께 사용하는 법
- OnEnable과 Start의 차이
에 대해 살펴 볼 예정이다.
Unity 이벤트 함수 호출 순서 시각화
가장 먼저 응용 프로그램이 시작, 실행 및 중지 될 때 Unity 엔진에 의해 호출 되는 이벤트 함수들과 호출 순서에 대해 살펴 보도록 하자. 유니티는 각 조건과 시점에 따라 적절한 이벤트 함수들을 호출한다.
아래 이미지는 Unity가 호출하는 이벤트 함수들의 순서를 축약해서 나열해 놓았다. 실제로는 더 많은 이벤트 함수들이 있으며 그에 대한 자세한 정보는 [여기]에서 찾을 수 있다.
'Start'와 'Awake'의 차이는 무엇인가?
Start와 Awake는 둘 다 MonoBehaviour 클래스가 초기화 될 때 호출 되는 이벤트 함수다. 이 두 함수는 거의 비슷한 방식으로 동작하지만 Awake가 먼저 호출 되고 Start가 호출 된다는 호출 순서와, Awake는 Start와 달리 스크립트가 비활성화 상태일 때도 호출 된다는 차이를 가지고 있다. 이에 대한 자세한 사항은 뒤에서 자세히 설명 하도록 하겠다.
가장 먼저 우리가 알아야할 것 중에 하나는, Start와 Awake를 같이 사용하면 초기화 작업을 두 단계로 분리할 수 있다는 것이다. 예를 들어 스크립트 자체의 초기화(예: 컴포넌트 생성 시 참조와 변수를 초기화하는 것들)를 Awake에서 완료하고 다른 스크립트의 Start에서 해당 데이터에 접근하여 사용하도록 하여 초기화가 되지 않은 데이터에 접근하는 오류를 방지 할 수 있다. 보다 자세한 설명을 위해 아래 섹션들을 살펴 보도록 하자.
Awake 사용 방법
void Awake()
{
// Awake is called even if the script is disabled.
}
Awake는 스크립트와 연결된 개체(GameObject)가 인스턴스화 되거나, 스크립트가 처음 로드 될 때 호출 된다.
Awake는 각 스크립트에서 딱 한 번만 호출되고 다른 개체가 초기화 된 후에에만 호출 된다. 즉, 다른 컴포넌트에 대한 참조를 만들어야 한다면 Awake 안에서 하는 것이 안전하다는 뜻이다. 이는 스크립트와 컴포넌트 사이에 초기 참조를 생성하기 위해 실제로 사용하는 좋은 방법이다.
하지만 모든 개체의 Awake 함수는 무작위로 호출 되기 때문에 다른 스크립트의 Awake에서 초기화하는 변수나 참조를 Awake 함수에서 사용하려고 하면 오류가 발생할 수 있다. 즉, 한 객체의 Awake가 다른 객체보다 먼저 호출되거나 Awake에서 생성한 참조가 Awake의 다른 스크립트에서 사용할 수 있다고 보장할 수 없다.
한번에 이해하기 어려울테니 예를 한가지 들어 보도록 하자.
서로의 스크립트 및 컴포넌트에 대한 참조를 가져오는 두 개의 서로 다른 개체가 있다고 생각해보자. 아래 코드를 보면 ObjectA의 Awake에서는 자체 컴포넌트 중 하나에 대한 참조를 가져오는 반면 ObjectB의 Awake에서는 ObjectA에 대한 참조를 가져오고 있다.
ObjectA
public class ObjectA : MonoBehaviour
{
public Reference myReference;
void Awake()
{
myReference = gameObject.GetComponent<Reference>();
}
}
ObjectB
public class ObjectB : MonoBehaviour
{
public ObjectA objA;
void Awake()
{
objA = GameObject.FindWithTag("ObjectA").GetComponent<ObjectA>();
}
}
Awake는 객체가 생성된 후에 호출 된다. 따라서 첫번째 이벤트 함수가 호출되더라도 GameObject와 해당 컴포넌트가 모두 이미 존재하므로 오류가 없다.
여기까지는 그러저럭 잘 동작한다. 하지만 이제 ObjectB에서 ObjectA의 컴포넌트의 멤버에 접근하는 것을 추가해보자. 이제 오류가 발생한다.
public class ObjectB : MonoBehaviour
{
public ObjectA objA;
void Awake()
{
objA = GameObject.FindWithTag("ObjectA").GetComponent<objA>();
Debug.Log(objA.myReference.message); // <-- NullReferenceException!!
}
}
앞에서 각 개체들의 Awake 순서는 보장되지 않는다고 이야기 했었다. 위 예에서 ObjectA의 Awake가 아직 호출 되지 않아 자체 컴포넌트에 대한 참조를 만들지 못한 상태에서 ObjectB가 ObjectA의 멤버에 접근하려고 했기 때문에 NullReferenceException을 발생 시킨다.
그렇다면 이에 대한 해결 방법은 무엇이 있을까? 앞에서 Awake와 Start를 이용해 초기화 단계를 분리하는 것이 유용하다고 이야기 했었다. 이제부터 그 방법을 알아 보도록하자.
Start 사용 방법
Start 이벤트 함수는 컴포넌트가 활성화 될때 Awake와 Update 함수 호출 사이에 한번 호출 된다. 이는 우리가 앞서 본 Awake와 똑같다. 하지만 괜시리 선진국의 고액 연봉자들이 이벤트를 분리 해놓은 것이아니다. Awake와 Start는 다음과 같은 주요한 차이가 있다.
- 스크립트가 비활성화 된 상태에서 개체가 생성되면 Awake는 호출 되지만 Start는 호출되지 않는다.
설명을 위해 매우 간단한 예제 스크립트를 만들어 보았다. 각각의 역할은 Awake, Start가 실행 될 때 단순히 로그를 찍는 것 뿐이다.
Script1.cs
public class Script1 : MonoBehaviour
{
void Awake()
{
Debug.Log("Script1 Awake");
}
void Start()
{
Debug.Log("Script1 Start");
}
}
Script2.cs
public class Script2 : MonoBehaviour
{
void Awake()
{
Debug.Log("Script2 Awake");
}
void Start()
{
Debug.Log("Script2 Start");
}
}
그리고 위 스크립트와 연결된 게임 오브젝트의 인스펙터를 살펴 보자. 노란색 박스를 보면 Script2의 경우는 비활성화 되어 있는것을 확인할 수 있다.
실행결과는 아래와 같다. 비록 Script2가 비활성화 되어 있더라도 Awake 함수는 호출된것을 확인할 수 있다. 물론 활성화 되어 있는 Script1은 Awake와 Start 함수가 모두 호출 되었다.
- Start는 코루틴으로 작성될 수 있다.
기본적으로 Start함수는 void 타입을 리턴하도록 자동 생성된다. 하지만 리턴 타입을 IEnumerator로 변경하는 것만으로 Start 함수를 코루틴으로 만들 수 있고, 이는 Start 내부의 초기화 코드를 지연 호출할 수 있다는 것을 의미한다.
Start를 지연 실행하는 법
위에서 언급했듯이 Start 이벤트 함수는 단순히 리턴 타입을 void에서 IEnumerator 타입으로 변경함으로써 코루틴으로 작성가능 하다. 이는 Start 내부의 초기화 코드가 미리 정의된 시간 이후에 동작하도록 지연할 수 있음을 의미한다. 예를 들어 개체가 인스턴스화 되고 5초 동안 다른 개체들이 초기화를 완료하길 기다렸다가 다른 개체들을 참조하는 초기화 작업을 시작하도록할 수 있다
IEnumerator Start()
{
yield return new WaitForSeconds(5);
// Do something after 5 seconds
}
하지만 미리 정의된 시간을 기반으로 대기했다가 초기화를 진행한다는 것은 그리 좋은 방법은 아니다. 만일 다른 개체들의 초기화가 늦어져 지정된 시간 보다 더 오래 걸렸다면 어떻게 할 것인가? 반대로 너무 빨리 끝났다면 기다리는 시간을 낭비하는 것이다.
다른 방법으로는 앞에서 Start 이벤트 함수는 스크립트가 활성화 된 경우에만 호출 된다고 했던것을 기억하자. 즉, 필요할 때 까지 스크립트를 비활성화 상태로 유지하여 Start의 실행을 수동으로 지연할 수 있다. 이는 보다 안전하고 낭비가 없는 방법이다.
Start와 Awake를 함께 사용하기
대부분의 경우 Start 또는 Awake를 구분하지 않고 둘 중 하나에서 모든 초기화를 수행할 수 있다. 하지만 선진국의 고액 연봉자들이 아무런 생각없이 이를 분리한것이 아니라고 이야기 했다. Start와 Awake를 함께 사용하면 두 단계의 초기화를 차례로 제공할수 있다.
이것은 객체와 컴포넌트 사이에 참조 생성하는 코드를 실제 참조들을 사용하여 동작하는 코드들로 분리할 수 있다는 뜻이다. 이것은 스크립트를 초기화할 때 null 참조 오류를 피하기가 더 쉽게 만들어 준다.
이런 작업을 수행할 때 딱히 정해진 규칙은 없지만 어떤 종류의 코드가 Start와 Awake에서 각각 실행 되는지 안다면 도움이 될 수 있다.
예를 들어 한 가지 접근 방식은 Awake를 사용하여 객체 자체 참조 및 변수를 초기화한 다음(예: 개체와 자체 컴포넌트 간의 연결 만들기) Start는 다른 객체나 그것의 컴포넌트에 접근하는 참조를 생성하는데 사용하는 것이다. 이런 접근 방식은 스크립트가 앞에서 살펴본 예제와 같이 아직 초기화 되지 않은 참조를 사용하려고하거나 Awake에서 호출 되면 제대로 작동하지 않는 함수를 사용하는 것을 방지한다(예: Audio Mixer의 값을 조정하기 위해 SetFloat를 호출하는 경우).
이것이 얼마나 유용할지는 전적으로 프로젝트와 구조에 따라 다르지만 일반적으로 오류를 피하기 위해 스크립트를 초기화할 때 Awake와 Start를 모두 사용하는 것이 좋다.
OnEnable vs Start
OnEnable과 Start 둘 다 수동으로 스크립트에서든, 게임의 시작으로 인해서든 어쨌든, 컴포넌트가 활성화 될 때 호출 된다. 물론 맨 앞에 살펴 봤던 이벤트 함수 호출순서에 따르면 OnEnable이 Start 보다 먼저 호출 된다.
이랬든 저랬든 어쨌든 저쨋든, 컴포넌트가 활성화 되면 OnEnable과 Start가 호출 되는데 둘의 차이는 무엇이고 우리는 무엇을 사용해야 하는가?
Start는 컴포넌트가 생성되고 최초 활성화 될 때 한번만 호출 되지만 OnEnable은 컴포넌트 또는 연결된 객체가 활성화 될 때마다 호출 된다. 이 때문에 OnEnable은 일반적으로 한번만 발생해야 하는 초기화 작업에 사용하기에는 적합하지 않다.
예를 들어 객체 풀링이 있다. 객체 풀링은 발사체 및 적과 같이 자주 재사용 되는 객체를 단순히 비활성화 했다가 다시 활성화 하여 객체의 생성과 파괴가 반복적으로 발생하는 것을 방지하는 기술이다. 만일 여러분이 객체 풀을 사용한다면 일반적으로 초기화를 위해 Start에 넣는 코드들을 OnEnable에 넣어, 객체가 비활성화 되었다가 활성회 될때 마다 초기화를 진행할 수 있다.
마치며
이상으로 유니티 개체의 초기화에 대해 알아 보았다.
- 한 개체 내에서 Awake와 Start의 호출 순서는 보장 되지만 어떤 개체의 Awake가 먼저 호출 될지는 보장하지 않는다.
- 스크립트가 비활성화 되어 있으면 스크립트와 연결된 개체가 인스턴스화 되더라도 Start 이벤트 함수가 호출 되지 않는다.