Unity.Object의 null 체크에 대해서 흔히들 겪는 딜레마인데,
일단 기본적으로 네이티브 C# 클래스가 아닌 Unity.Object를 상속하는 클래스를 작성할 경우
== operator가 유니티 엔진에서 오버라이드한 오퍼레이터를 사용하는걸 알고 있을겄이다.
그리고 대부분의 IDE에서 Unity.Object의 null 체크에 대해서 비용이 큰 작업이라고 경고를 띄운다.
그 유명한 Unity 의 fake null과 관련된 내용인데, 검색하면 자세하게 나오므로 여기에선 적지 않는다.
하여튼 우리가 UnityEngine.Object에 대해서 접근할때 NullReference 오류를 띄우지 않기 위해선
접근하기 전에 == 를 이용한 null 체크를 해야한다.
다만, 앞서 말했다시피 UnityEngine.Object의 == operator를 이용한 null 체크는 고비용의 작업이다.
특정 트리거에 의해서 간헐적으로 실행하는 경우에는 큰 문제가 없지만
Update와 같이 지속적으로 체크해야하고, 해당 작업을 해야하는 인스턴스가 많을수록 신경쓰이는 부분이기도 하다.
그렇다면 그런 상황에서 Null 체크를 피하는 방법은 뭐가 있을까?
대표적인 한가지는 필요한 데이터"만" 공유하는 식이다.
예를 들어 한 Transform의 위치를 추적하는 Transform이 있다고 치자
이 경우 사실 추적하는 Transform은 추적할 Transform의 컴포넌트는 필요가 없다.
다만 추적할 위치 데이터가 필요할 뿐이니
추적당하는 대상이 지속적으로 Position을 어딘가에 공유하면
추적자는 그 공유된 Position만 따라서 이동시키면 된다.
다만 이 경우 공유해야할 데이터가 늘어날수록 구현이 번거롭고
공유할 데이터를 관리할 매니징 클래스가 필요하거나, static한 필드를 둬야한다던가 하는 문제등으로
코드가 지저분해질 가능성이 크다.
무엇보다 데이터의 공유가 아니라, 컴포넌트간에 직접적인 참조를 해야하는 경우엔 쓸 수가 없다.
다른 하나는, 명시적으로 객체의 파괴 상태를 전달하는 방법이다.
다행히 유니티의 경우 OnDestory에서 Destory시 호출되는 이벤트가 있다.
A라는 컴포넌트의 OnDestory에서 A를 참조하고 있는 대상에게 알림을 주면 그만이다.
참조 대상을 별도의 콜렉션에 등록/해제해서 관리하도록 만들어도 좋고, 이벤트를 구독하도록 만들어도 좋다.
위의 데이터만 공유하는 방법과 마찬가지로 별도의 구현은 필요하지만, 객체를 직접 참조할 수 있기 때문에
참조시 구독/해제만 제대로 구현한다면 참조할 필드나 메서드가 늘어나도 추가적인 작업없이 유연하게 쓸 수 있다는게 장점이다.
다만 이 경우에도 함정이 있는데, OnDestory의 호출을 100% 보장하지 않는다는 점이다.
UnityEngine.Object의 경우 Awake를 호출하지 않은 인스턴스의 경우 OnDestory도 마찬가지로 호출하지 않는다.
그래서 오늘 참고해 볼것은 UniTask 의 GetCancellationTokenOnDestroy 이다.
UniTask의 GetCancellationTokenOnDestroy 은 GameObject의 확장 메서드인데,
어떻게 작동하는지를 자세히 살펴보면 방금 막 했던 방식과 매우 흡사하다.
호출한 GameObject에 인스펙터에 표시되지 않는 AsyncDestroyTrigger라는 컴포넌트를 붙이고
해당 컴포넌트의 OnDestory 에서 CancellationTokenSource의 Cancel을 호출하는 식으로 작동한다.
그리고 이 컴포넌트를 자세히 뜯어보면 어떤식으로 OnDestory의 호출을 100% 보장하는지 알 수 있다..
bool called = false;
CancellationTokenSource cancellationTokenSource;
public CancellationToken CancellationToken
{
get
{
if (cancellationTokenSource == null)
{
cancellationTokenSource = new CancellationTokenSource();
if (!awakeCalled)
{
PlayerLoopHelper.AddAction(PlayerLoopTiming.Update, new AwakeMonitor(this));
}
}
return cancellationTokenSource.Token;
}
}
void Awake()
{
awakeCalled = true;
}
void OnDestroy()
{
called = true;
cancellationTokenSource?.Cancel();
cancellationTokenSource?.Dispose();
}
사실 원리 자체는 간단한데, Awake시 true로 변하는 Flag와 OnDestroy시 true로 변하는 Flag를 하나씩 세워두는 것이다.
그리고 해당 클래스의 Flag를 감시하는 Monitor 클래스를 만들고, 업데이트에서 지속적으로 해당 플래그를 체크하는 PlayerLoop에 등록한다.
PlayerLoopHelper는 유니티의 Update 타이밍에 등록된 Monitor를 순회하면서
해당 클래스의 Awake나 OnDestory가 호출되었는지 확인하고, 만약 이미 호출되었다면 Monitor를 등록 해제한다.
만약 아직 호출되지 않았다면, Monitor가 감시중인 컴포넌트를 == 체크를 통해 파괴되었는지 확인한다.
== 체크를 통해 파괴되었다면, 해당 컴포넌트는 단 한번도 활성화하지 않고 파괴되었다는 뜻이니
PlayerLoopHelper가 대신해서 해당 컴포넌트의 OnDestory를 호출하고 Monitor를 등록 해제한다.
물론 이 방법도 완전히 고비용 작업인 == 비교를 아예 없애진 않는다.
다만 좀 더 단순한 작업인 플래그 체크로 필터링 함으로써
고비용 작업을 최소화 했다고 볼 수 있다.
사실 이 모든건 UnityEngine이 OnDestory 호출을 보장하지 않기 때문인데,
null 체크도 고비용으로 만들어놓고 대신 OnDestory를 사용할걸 권장하면서
100% 호출을 보장하지 않는 이유는 나도 잘 모르겠다.
하여간 엔진이 문제임
내일 고비용널체크들 다 캔슬레이션토큰온디스뜨로이로 바꿔야지
음.. GC가 생긴다고 하네? 그냥 쓰던 곳만 쓰는 걸로
@211214 당연하지만 컴포넌트 하나를 추가로 붙이고 Monitor class를 생성하고 CancellationTokenSource도 class니까 GC가 쌓임, 그래서 저 원리만 적용해서 모니터 클래스에도 오브젝트 풀링을 도입하고, 별도의 OnDestory 호출 이벤트용 컴포넌트를 만들어서 쓰는게 좋음 async 안쓰면 CancellationTokenSource도 불필요하니까 굳이 필요없고.
나는 이벤트 구독식으로 했는데, 극단적으로 가면 이벤트에 액션을 구독/해제하는데에도 진짜 미미하지만 gc가 쌓이니까, 이것마저 없애려면 구독할 클래스용 인터페이스를 정하고, OnDestory를 호출하는 쪽에선 인터페이스를 컬렉션에 담아서 관리하는 식으로 만들고, 그 컬렉션마저도 풀링으로 관리하면 컴포넌트 하나를 추가/제거하는 비용 말고는 아예 GC가 안쌓이게 할수도 있음. 사실 이쪽이 좀 더 정석이긴 한게 파괴시 알람이 가는게 목적이라서, 그 알람을 받아서 어떤식으로 작동할지는 알람 받는 쪽이 알아서 구현해야 하는게 맞음. 그래서 이벤트 구독식보다 인터페이스 + 컬렉션 조합으로 가는게 더 타이트하게 목적에 맞게 설계하는 느낌이지...
@211214 그리고 이렇게 최적화해도 결국 저걸 위한 컴포넌트가 하나씩 생기는 셈이라, null 체크 빈도를 생각했을때랑도 비교해서 쓰는게 좋음.
아 그리고 혹시나 async를 쓴다고 해도 GetCancellationTokenOnDestroy 이건 나도 이제 안씀. 왜냐하면 유니티에서 자체적으로 Awaitable 지원을 하면서 그냥 Monobehaviour에서 .destroyCancellationToken 호출하면 유니티에서 지원하는 CancellationToken 반환하거든.....예시로 저거 잡은게 null체크와 OnDestory 호출 보장하는데 있어서 그나마 가장 인증?된 라이브러리에서 쓰이던 방식이라 예시를 든거지, 실제로 저걸 쓰라는 얘기는 아니었음. 원리만 빼서 쓰셈
@글쓴 Indie(221.139) 2022.3.62쓰고 있어서 this.GetCancellationTokenOnDestroy 이놈 호출하면 알아서 고걸로 연결되긴함
@211214 굿..근데 저것도 어쨌든 내부적으로 호출전에는 비어있는데 호출하면 CancellationTokenSource 새로 할당하는 셈이라...굳이 async에 쓰일 용도 아니면 패스하는게 좋음..저걸로 Destory 이벤트 받으려면 하는쪽에도 CTS 하나 만들어서 Linked로 새 CTS 만드는걸 매번 새로운 대상을 트래킹 할때마다 해야하니까..물론 대상이 자주 바뀌는게 아니면 새 컴포넌트 붙이는것보다 저게 더 나을수도 있음. Awaitable이나 UniTask같은 경우엔 자체적으로 풀링 이용해서 최적화 되어있으니까, async 메서드 호출할때마다 할당이 일어나진 않을거임. 그러면 실질적으로 할당 발생하는 구간이 새 CTS 만드는 부분밖에 없음
하여튼 이것도 상황 따라 다른거임...빈도가 적은 null 체크면 사실 그냥 null 체크하는게 가독성이나 코드 깔끔해지는데는 더 좋고, 아니면 로직상 아예 null체크가 필요없는 상황을 만드는게 제일 베스트긴 함. 흐름상 여기서 null이 들어올 상황 자체를 안 만들게 설계하는거지, 그 경우엔 오히려 예외 던져지는게 내 로직에 뭔가 허점이 있는 상황이라는거니까 오히려 null 체크를 하면 안됨. 근데 작업하다보면 생성/파괴/주입 타이밍을 내가 명시적으로 관리할수가 없거나 그걸 명시적으로 관리하려면 지나치게 복잡해지는 경우들이 종종 있으니까
그러게요. 이게 다 C#이 결국 C++로 만들어져서 발생하는 문제죠. 일반적으로 C,C++는 Ram의 메모리를 그대로 따라가지만(그래서 C,C++는 동적 메모리 직접 관리해야함) C#은 GC가 처리하는 메모리를 참조하는 구조다 보니깐. 가상머신이죠 머.. 그니깐 가상주소 느낌이죠 ㅋㅋㅋ 이래서 C++ 배웠는데 결국 모바일 게임개발하고싶어서 C#으로 틈
뭐 메모리 관리를 GC가 대신해주는 만큼 편의성이 있는거라서 딱히 그부분이 불만은 아닌데, 유니티에서 아예 fake null에 접근하면 오류를 뱉어버리는게 불만인거죠 ㅋㅋㅋ...사실 저렇게 플래그 세우고 필터링해서 처리하는게 유저가 구현할 부분이 아니라 엔진차원에서 해도 문제 없을거라고 생각하는데 안쓰이는 레거시 코드는 6와서도 남겨두면서 이런건 왜...
C++에서는 명시적 형변환을 통해 해결할 수 있는 문제인데... C++에서는 if(Object)가능
유니티도 그거 되긴 하는데 무늬만 따라하고 고비용인 건 마찬가지인
@211214 여기서 C++이면 객체를 잡고 있는 핸들에 explicit bool, 명시적 형변환을 통해 객체가 destroy됬는지 바로 아는데...
언어레벨이랑 엔진레벨이랑 같다고 보면 큰일나;; 언리얼에서도 안되는건데
@Indie2(119.194) pure c++로 개발이라도 하고있음?
@ㅇㅇ(59.12) MassFramework 공부 중인데 이미 FMassEntityHandle에서 하고 있는데? 언리얼에서 왜 안됨??
@Indie2(119.194) 유니티 fake null이랑 같은 맥락임 애초에 object를 게임 내 object랑 똑같이 쓰는 엔진이 어딨음?
@Indie2(119.194) 유니티 ecs에서는 이렇게 써도 되는데? 하는정도의 의미임. 데이터구조가 아예 다른데 언리얼에서는 되는데??? 하고잇네
@ㅇㅇ(59.12) ? 오브젝트를 핸들로 관리하는게 왜 언어레벨 문제라고 하는거임? C++에서 많이 쓰는건데?
@ㅇㅇ(59.12) 그래서 C++은 된다고 하는건데 안되는 문제라도 있음?정말 궁금해서 물어봄.
@Indie2(119.194) 아니...님 언리얼 시작할때 바로 mass로 시작함? 언어레벨에선 되지만 엔진레벨에서 안된다고 하는건데 왜 반대로 알아들어
@ㅇㅇ(59.12) MassFramework가 엔진은 아닌가요? 그리고 C++에서는 오브젝트 핸들로 관리하고 명시적 형변환으로 파괴 체크하는 게 드문게 아니에요.
@Indie2(119.194) 아니 빡통련아 내가 c++에서 안된다했냐? 핸들로 관리하는게 언어레벨 문제라고 했음? 언어레벨에서만 되고 엔진레벨에서 지원 안되는걸 얘기하고있잖아 그래서 내가 처음에 pure c++냐고 물어봤고 니가 언리얼이라매. 계속 말 빙빙 돌리면서 c++은 되는데요? c++은 되는데요? ㅇㅈㄹ하노
@Indie2(119.194) 언리얼 mass면 유니티 ecs보다도 비주류에 낙후된 프레임워큰데 언리얼에서 왜 안됨?? 이러고있네 싸대기마렵게
니 논리면 유니티도 ecs쓰면 fake null문제 해결이니까 'c++에선 되는데 ㅎㅎ;;' 이딴 말 자체가 성립이 안된다고. 이해가?
내 글에서 싸우지말고 cex해
@ㅇㅇ(59.12) 말 돌린게 아니라 너의 논점을 다 반박해주는건데 병신이 말 돌린다고 하네 ㅋㅋ 야 니가 언어레벨하고 엔진레벨이 같냐고 했지? 근데 실제
@Indie2(119.194) MassFramework에서 그렇게 하고 있네? 그래서 말해준건데 빡통머가리 부셔진 놈이 혼자 화나서 발광하네
언어레벨에서 구현 -> c#도 바로 null체크 가능 엔진(언리얼, 유니티 사용) -> 둘다 직접 null체크 불가능 엔진에서 데이터지향 프레임워크 사용 -> 둘다 null 체크 가능 니가 아무리 말 쳐바꿔도 니 말이 틀린게 안변한다고 개빡통련아
@Indie2(119.194) 아 씨발 이새끼 그냥 이제 막 mass 시작해서 데이터지향 뽕차서 딸치는놈이었네 꺼져 걍
@Indie2(119.194) 좆저능아련아 니가말한대로면 유니티도 c#으로 가능하다고요 ㅋㅋㅋ 좀 책 한권도 아니고 첫장읽고 아는척좀 하지마라
@ㅇㅇ(59.12) 그래그래 ㅋㅋㅋㅋ ecs에서 핸들 명시적 형변환을 통해 엔티티의 죽음을 관리해봐라 ㅋㅋㅋㅋㅋㅋㅋㅋ 개병신년 근데 유니티 ecs에서는 엔티티 죽음 알려면 근데 매니저를 통해야되네? ㅋㅋㅋㅋㅋ 하루종일 해봐 이게 봐로 언어 레벨과 엔진 레벨을 구분 못하는건가 ㅋㅋ
@ㅇㅇ(59.12) 그 말하는게 좀 불편해? 왜 너의 논리가 스스로 모순을 일으켜?
@ㅇㅇ(59.12) 그래서 MassFramework와 유니티 ECS를 모두 꿰고 있는데 명시적 형변환이 뭔지도 모르는구나? 혹시 일주일 공부했니?
와 얘는 진짜네
이런 건 공식문서 보고 찾아내는 건가요? 아니면 작업하다가 'null 체크가 비용이 크네?' 하고 찾다가 나오는 건가요?
저도 시작은 공부하다가 unity null 체크 관련해서 본 거 같은데, 진짜 순정 vscode 쓰는게 아니면 유니티 오브젝트 널체크는 앵간하면 아마 경고나 알림이 뜰걸요? 이제 그걸 어떻게 해소할건지 고민해보는거고, 직접 구현하고 보니까 OnDestory 호출이 100%가 아니네? 하고 또 찾아보는거고, 그러면 이런 문제가 나만 생각한게 아닐텐데? 하면서 레퍼런스 찾아서 어떻게 해결하는지 찾아보고 그러는거죠 뭐...
is 나 ?. 같은 문법으로 null 체크시 비용이 적게드는 이유는 유니티엔진에서 오버라이드한 == 를 쓰지 않고 c# 클래스 간의 비교라서 그럼. 근데 그러면 fake null을 체크할수가 없음. 만약 필드에 님이 작성한 컴포넌트를 캐싱해두고 == 비교가 아니라 is 나 ?. 로 null 체크를 하면 실제로 해당 컴포넌트가 파괴되었음에도, gc가 아직 수거해가지 않은 상태라면 true를 반환할거임. 근데 그렇게해서 접근하면 실제로 작동하는 c++ 객체는 이미 메모리에서 제거된 상태기 때문에 null reference 오류를 뱉음. == 비교가 고비용지만 써야하는 이유임
답글 잘못 달았네 근데 비번 까먹어서 삭제를 못하겠다 ㅈㅅ
이런 workaround들 엄청 많은데, 결국엔 자기 편한쪽으로 구현하는게 맞는듯. 어느방법이든 트레이드오프가 있어서
재밌는글 잘읽었습니다
어디서 보니까 is로 체크하면 비용이 훨씬 적게 든다는데 이 방법하고 비교해보면 어떨까요?
유니티 오브젝트에는 쓰지 않는 게 좋다네영
is 나 ?. 같은 문법으로 null 체크시 비용이 적게드는 이유는 유니티엔진에서 오버라이드한 == 를 쓰지 않고 c# 클래스 간의 비교라서 그럼. 근데 그러면 fake null을 체크할수가 없음. 만약 필드에 님이 작성한 컴포넌트를 캐싱해두고 == 비교가 아니라 is 나 ?. 로 null 체크를 하면 실제로 해당 컴포넌트가 파괴되었음에도, gc가 아직 수거해가지 않은 상태라면 true를 반환할거임. 근데 그렇게해서 접근하면 실제로 작동하는 c++ 객체는 이미 메모리에서 제거된 상태기 때문에 null reference 오류를 뱉음. == 비교가 고비용지만 써야하는 이유임
심지어 이게 더 악질인게 fake null 이 결국 c++은 메모리에서 내려가고 그 래퍼 클래스인 c# 객체는 gc가 수거해가길 대기하는 상태인건데, gc가 이 래퍼 클래스를 제거하는 타이밍과 내가 ? 나 is 로 체크하는 타이밍 아다리가 잘 맞을경우 정상적으로 작동하는 경우가 있음. 이 경우엔 실행할때마다 오류와 통과가 매번 달라서 원인을 파악하기 힘들수도 있음. 적어도 이 fake null 관련해서 지식이 없다면 파악하기 확실히 힘듬
그리고 fake null 상태에서도 멀쩡히 작동하는 경우가 있음, 말했다시피 c++ 객체만 사라진거지 본인이 작성한 c# 객체는 살아있는 상태기 때문에, native class를 호출하지 않는 필드나 메서드에 접근하는건 문제없이 실행됨. 예시를 들자면 필드에 Transform temp라는 변수를 선언하고 여기에 this.transform을 할당했을때. fake null 상태에서 temp를 호출하는건 에러가 없지만 this.transform을 호출하는건 에러가 터짐. 왜냐하면 this.transform은 사실 getter고 내부적으로 네이티브 클래스를 호출하고 있기 때문임. 물론 temp를 호출하고 거기에 접근하는건 여전히 에러가 터질거임. 다만 호출하는것 만으로 에러가 터지느냐 안터지느냐의 차이가 생김
다만 원칙적으로 파괴된 객체에 지속적으로 접근하는건 계속해서 참조가 있다는 얘기고, 그러면 gc가 수거해가지 못한다는 뜻임. 그래서 파괴한 객체는 계속해서 null 체크로 비교하는게 아니라 더 이상 그 객체를 아예 참조하지 않도록 해줘야 메모리 낭비가 없음. 방금 캐싱한 Transform temp 얘기를 했는데, 사실 이렇게 파괴한 객체 레퍼런스를 계속 들고 있는거 자체가 문제임. 일부 IDisposable 구현한 클래스들이 어차피 제거될 객체인데 명시적으로 참조 끊는 이유도 이 gc 수거 관련해서인 경우가 많음. 행여나 Dispose 후에도 해당 객체를 어디선가 참조하고 있으면 그 객체가 참조중인 객체들도 필요없어져도 계속 gc에 수거 안되고 남아있거든
갑작스럽지만 자세한 설명 감사합니다.
널체크를 == 써야 된다는건 알았는데 비용이 크다는걸 처음알았네 굳