사용자 정의 == 연산자를 유지해야 하나요?

Unity에서 이 작업을 수행할 때
if (myGameObject == null) {}
Unity는 == 연산자로 특별한 기능을 수행합니다. 대부분의 사람들이 예상하는 것과는 달리 == 연산자를 특별히 구현했습니다.
이는 두 가지 용도로 사용됩니다:
1) 모노비헤이비어에 필드가 있는 경우, 에디터에서만[1] 해당 필드를 '실제 널'로 설정하지 않고 '가짜 널' 객체로 설정합니다. 사용자 정의 == 연산자는 이러한 가짜 널 객체 중 하나인지 확인하고 그에 따라 동작할 수 있습니다. 이것은 이색적인 설정이지만, 가짜 널 객체에 정보를 저장하여 메서드를 호출하거나 객체에 프로퍼티를 요청할 때 더 많은 컨텍스트 정보를 제공할 수 있습니다. 이 트릭이 없으면 스택 추적인 NullReferenceException만 얻을 수 있지만, 어떤 게임 오브젝트에 널 필드를 가진 모노비헤이비어가 있는지 알 수 없습니다. 이 트릭을 사용하면 인스펙터에서 게임 오브젝트를 강조 표시하고 "여기 이 모노비헤이비어에서 초기화되지 않은 필드에 액세스하고 있는 것 같으니 인스펙터를 사용하여 필드가 무언가를 가리키도록 하세요"와 같은 추가 지침을 제공할 수도 있습니다.
목적 2는 조금 더 복잡합니다.
2) "게임 오브젝트"[2] 유형의 c# 오브젝트를 받으면 거의 아무것도 포함하지 않는데, 이는 Unity가 C/C++ 엔진이기 때문입니다. 이 게임 오브젝트에 대한 모든 실제 정보(이름, 오브젝트에 포함된 컴포넌트 목록, HideFlags 등)는 C++ 쪽에 있습니다. c# 객체에 있는 유일한 것은 네이티브 객체에 대한 포인터뿐입니다. 우리는 이러한 c# 객체를 "래퍼 객체"라고 부릅니다. 게임 오브젝트와 같은 C++ 오브젝트와 UnityEngine.Object에서 파생되는 다른 모든 오브젝트의 수명은 명시적으로 관리됩니다. 이러한 오브젝트는 새 씬을 로드할 때 파괴됩니다. 또는 Object.Destroy(myObject); 를 호출할 때도 마찬가지입니다. 가비지 컬렉터를 사용하여 c# 개체의 수명을 c# 방식으로 관리합니다. 즉, 이미 소멸된 c++ 객체를 래핑하는 c# 래퍼 객체가 여전히 존재할 수 있습니다. 이 객체를 null과 비교하면 사용자 정의 == 연산자는 이 경우 실제 c# 변수가 실제로는 null이 아니더라도 "true"를 반환합니다.
이 두 가지 사용 사례는 매우 합리적이지만 사용자 정의 널 검사에는 여러 가지 단점도 있습니다.
- 직관적이지 않습니다.
- 두 개의 UnityEngine.Object를 서로 또는 null과 비교하는 작업은 예상보다 느립니다.
- 사용자 정의 == 연산자는 스레드 안전하지 않으므로 메인 스레드에서 객체를 비교할 수 없습니다. (이 문제는 수정할 수 있습니다).
- 이 연산자는 널 검사를 수행하는 ?? 연산자와 일관성 없이 동작하지만, 이 연산자는 순수한 c# 널 검사를 수행하며 사용자 지정 널 검사를 호출하기 위해 우회할 수 없습니다.
이러한 모든 장단점을 살펴볼 때, API를 처음부터 다시 빌드했다면 사용자 정의 널 검사를 수행하지 않고 대신 객체가 죽었는지 여부를 확인하는 데 사용할 수 있는 myObject.destroyed 속성을 선택하고, 널인 필드에서 함수를 호출할 경우 더 나은 오류 메시지를 제공할 수 없다는 사실을 감수하고 살았을 것입니다.
저희가 고려하고 있는 것은 이 기능을 변경할지 여부입니다. 이는 '오래된 것을 수정하고 정리하는 것'과 '오래된 프로젝트를 망치지 않는 것' 사이에서 적절한 균형을 찾기 위한 끝없는 탐구의 한 단계입니다. 이 경우 어떻게 생각하시는지 궁금합니다. 유니티는 Unity5에서 스크립트를 자동으로 업데이트하는 기능을 개발 중입니다(자세한 내용은 다음 블로그 포스팅에서 확인할 수 있습니다). 안타깝게도 이 경우에는 스크립트를 자동으로 업그레이드할 수 없습니다. (왜냐하면 "이것은 실제로 이전 동작을 원하는 이전 스크립트"와 "이것은 실제로 새로운 동작을 원하는 새 스크립트"를 구분할 수 없기 때문입니다.)
"사용자 정의 == 연산자를 제거"하는 쪽으로 기울고 있지만, 현재 프로젝트에서 수행하는 모든 null 검사의 의미가 달라지기 때문에 망설여집니다. 그리고 객체가 '진짜 널'이 아니라 소멸된 객체인 경우, 널 체크는 참을 반환하는 데 사용되며, 이를 변경하면 거짓을 반환합니다. 변수가 소멸된 객체를 가리키고 있는지 확인하려면 코드를 변경하여 "if (myObject.destroyed) {}"를 대신 확인해야 합니다. 이 블로그 게시물을 읽지 않으셨다면, 그리고 읽으셨다면 이 변경된 동작을 인식하지 못하실 가능성이 높습니다. 특히 대부분의 사람들이 이 사용자 정의 널 체크가 존재한다는 사실을 전혀 인식하지 못하기 때문입니다[3].
만약 변경한다면, 사용자가 감내할 수 있는 업그레이드의 고통에 대한 문턱이 메이저 릴리스가 아닌 경우에는 훨씬 더 낮기 때문에 Unity5에 적용해야 합니다.
프로젝트에서 널 체크를 변경해야 하는 대신 더 깔끔한 경험을 제공하길 원하시나요, 아니면 그대로 유지하길 원하시나요?
안녕, 루카스(@lucasmeijer)
[1] 편집기에서만 이 작업을 수행합니다. 따라서 존재하지 않는 컴포넌트를 쿼리하기 위해 GetComponent()를 호출하면 C# 메모리 할당이 발생하는 것을 볼 수 있는데, 이는 새로 할당된 가짜 null 객체 내에서 이 사용자 지정 경고 문자열이 생성되기 때문입니다. 이 메모리 할당은 빌드된 게임에서는 발생하지 않습니다. 이는 게임을 프로파일링하는 경우 항상 에디터가 아닌 실제 스탠드얼론 플레이어 또는 모바일 플레이어를 프로파일링해야 하는 좋은 예시입니다. 에디터에서는 약간의 성능 저하를 감수하더라도 사용자의 편의를 위해 많은 추가 보안/안전/사용량 검사를 수행하기 때문입니다. 성능 및 메모리 할당을 위해 프로파일링할 때는 절대 에디터를 프로파일링하지 말고 항상 빌드된 게임을 프로파일링하세요.
[2] 이는 게임 오브젝트뿐만 아니라 UnityEngine.Object에서 파생되는 모든 것에 해당됩니다.
[3] 재미있는 이야기: GetComponent<T>() 성능을 최적화하는 과정에서 이 문제가 발생했는데, 트랜스폼 컴포넌트에 대한 캐싱을 구현하는 동안 성능 이점이 전혀 나타나지 않았습니다. 그러자 @jonasechterhoff가 문제를 살펴본 후 같은 결론에 도달했습니다. 캐싱 코드는 다음과 같습니다:
private Transform m_CachedTransform
public Transform transform
{
get
{
if (m_CachedTransform == null)
m_CachedTransform = InternalGetTransform();
return m_CachedTransform;
}
}
알고 보니 저희 엔지니어 중 두 명이 널 체크가 예상보다 비용이 많이 든다는 사실을 놓쳤고, 이것이 캐싱으로 인한 속도 향상 효과를 보지 못하는 원인이었습니다. "우리도 놓치면 얼마나 많은 사용자들이 놓칠까?"라는 생각으로 이 블로그 포스팅을 작성하게 되었습니다.)