반응형

[PART3.연산자와 표현식(7/11)] null 관련 연산자 — ?. · ?? · ??= · Null 조건부 대입(C# 14)

null 체크를 한 줄로 줄이는 4종 연산자 / Short-circuit 평가가 IL에서 어떻게 생기는가 / Unity의 "fake null" 함정까지


1. 문제 제기 — NullReferenceException이 배포 후에 터진다

Unity 모바일 프로젝트에서 가장 많이 나오는 런타임 예외 중 하나가 NullReferenceException(NRE)입니다. 다음 장면을 떠올려 보시기 바랍니다. 플레이어 UI를 갱신하는 코드를 이렇게 작성했습니다.

C#
// 플레이어 인벤토리의 첫 번째 아이템 이름을 UI에 표시
string itemName = player.Inventory.Items[0].Name.ToUpper();
nameText.text = itemName;

테스트 빌드에서는 잘 돌아갔습니다. 그런데 QA 막바지에 이런 버그 리포트가 들어옵니다.

  • "튜토리얼 직후 화면이 멈춘다."
  • 원인: player.Inventory는 있지만 Inventory.Items가 아직 서버에서 내려오기 전이라 null.
  • Items[0]에서 NRE 발생 → UI 갱신 코루틴 중단 → 화면 멈춤.

신입 개발자라면 이 시점에서 방어 코드를 추가합니다.

C#
string itemName;
if (player != null &&
    player.Inventory != null &&
    player.Inventory.Items != null &&
    player.Inventory.Items.Count > 0 &&
    player.Inventory.Items[0] != null &&
    player.Inventory.Items[0].Name != null)
{
    itemName = player.Inventory.Items[0].Name.ToUpper();
}
else
{
    itemName = "없음";
}

한 줄이 아홉 줄로 늘었고, 가독성은 무너졌습니다. 이 글에서 다룰 C#의 null 관련 연산자 4종(?., ?[], ??, ??=)과 C# 14에서 새로 들어온 null 조건부 대입은 정확히 이 문제를 해결하기 위해 존재합니다. 위 아홉 줄은 다음 한 줄로 줄어듭니다.

C#
string itemName = player?.Inventory?.Items?[0]?.Name?.ToUpper() ?? "없음";

하지만 편리함 뒤에는 반드시 알아야 할 내부 동작Unity 특유의 함정이 숨어 있습니다. ?. 연산자가 Unity의 Destroy()된 오브젝트를 "null이 아님"으로 오판해 MissingReferenceException을 일으키는 유명한 사례가 대표적입니다. 이 글은 연산자 하나하나의 동작을 IL 레벨까지 내려가서 증명한 뒤, Unity 실무에서 어떻게 써야 하는지를 정리합니다.


2. 개념 정의 — 네 가지 연산자와 C# 14 신규 기능

2.1 ?. null 조건부 멤버 접근 연산자

?. — Null 조건부 멤버 접근 연산자 (Null-conditional member access) 왼쪽 피연산자가 null이 아니면 오른쪽 멤버에 접근하고, null이면 오른쪽 전체를 건너뛰고 null을 반환한다. .을 안전하게 만든 버전이라 생각하면 된다.
예시: int? len = name?.Length; name이 null이면 len은 null, 그렇지 않으면 문자열 길이

비유하자면 .는 "반드시 집에 누군가 있다고 가정하고 초인종을 누르는 행위"이고, ?.는 "문 앞에 가서 인기척이 없으면 조용히 돌아가는 행위"입니다. 집(객체)이 비어 있으면(null) 들어가지 않고 그냥 빈손으로 돌아옵니다.

`obj?.Member` 평가 흐름

쉬운 설명으로 다시 말하면, "왼쪽이 비어 있으면 뒤에 뭐가 붙어 있든 건드리지 않는다"입니다. 이 "건드리지 않는다"가 바로 short-circuit 평가이며, 내부 동작과 성능 면에서 핵심이 됩니다.

2.2 ?[] null 조건부 인덱스 연산자

?[] — Null 조건부 인덱스 연산자 (Null-conditional element access) 왼쪽 피연산자가 null이 아니면 인덱서를 호출하고, null이면 그 자리에서 null을 반환한다. ?.와 원리는 같고 대상이 배열·리스트 인덱스일 뿐이다.
예시: int? first = arr?[0]; arr가 null이면 first는 null, 그렇지 않으면 arr[0]

arr[0]arr가 null이면 NRE를 던집니다. arr?[0]는 null이면 null을 반환하고, null이 아닐 때만 인덱서를 호출합니다. 인덱스 범위 초과(IndexOutOfRangeException)는 별개의 얘기라는 점에 주의합니다 — ?[]null만 막고 범위 초과는 막지 않습니다.

2.3 ?? null 병합 연산자

?? — Null 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null이 아니면 왼쪽 값을 그대로 쓰고, null이면 오른쪽 값을 계산해서 쓴다. "기본값 지정" 패턴의 표준 도구다.
예시: string display = name ?? "익명"; name이 null이면 "익명", 아니면 name 그대로
C#
string input = null;
string safe = input ?? "기본값";
// safe == "기본값"

중요한 점은 ??오른쪽 단락 평가를 한다는 것입니다. 왼쪽이 null이 아니면 오른쪽 표현식은 아예 실행되지 않습니다. 따라서 오른쪽에 비싼 함수 호출이 있어도 불필요하게 돌지 않습니다.

C#
// 왼쪽이 null이 아니면 LoadFromDisk()는 호출조차 되지 않는다
byte[] cached = _buffer ?? LoadFromDisk();

2.4 ??= null 병합 대입 연산자 (C# 8)

??= — Null 병합 대입 연산자 (Null-coalescing assignment) 왼쪽 변수가 null일 때만 오른쪽 값을 계산해서 대입한다. 이미 값이 있으면 오른쪽은 실행조차 안 된다. Lazy 초기화 패턴의 표준형이다.
예시: _list ??= new List<int>(); _list가 null이면 새 리스트를 만들어 넣고, 이미 있으면 그대로 둔다

??=는 C# 8에서 도입되었습니다. 다음 두 코드는 완전히 동등합니다.

C#
// 전통 방식
if (_list == null) _list = new List<int>();

// ??= 방식
_list ??= new List<int>();

Unity에서 GetComponent 결과를 캐싱할 때 자주 쓰입니다. 뒤의 [실전 적용]에서 다시 다룹니다.

2.5 C# 14 신규 — Null 조건부 대입 (?.왼쪽에 쓴다)

C# 14부터는 ?.?[]대입문의 왼쪽에 둘 수 있습니다.

C#
// C# 14: 대입문 왼쪽에 ?. 사용 가능
customer?.Order = GetCurrentOrder();

// C# 13 이하에서는 같은 코드가 컴파일 에러였다
// → if (customer is not null) customer.Order = GetCurrentOrder(); 으로 풀어 써야 했다

동작 규칙은 예상대로입니다. customer가 null이면 오른쪽의 GetCurrentOrder() 호출 자체가 일어나지 않습니다. 다음 두 코드가 똑같다고 생각하면 됩니다.

C#
// C# 14의 null 조건부 대입
customer?.Order = GetCurrentOrder();

// 풀어 쓴 동등 코드
if (customer is not null)
    customer.Order = GetCurrentOrder();

복합 대입(+=, -=, *= 등)도 허용됩니다. 단, ++--는 금지됩니다. 이유는 뒤의 [함정과 주의사항] 섹션에서 l-value(좌측값) 규칙과 함께 다룹니다.

이 섹션 마지막에 기술 정의로 묶자면 다음과 같습니다.

  • ?.·?[]: null 조건부 연산자(Null-conditional operators). 왼쪽 피연산자가 null이면 오른쪽 표현식을 단락 평가하여 전체가 null로 평가된다.
  • ??: null 병합 연산자(Null-coalescing operator). 왼쪽이 null이 아니면 왼쪽, null이면 오른쪽.
  • ??=: null 병합 대입 연산자(Null-coalescing assignment, C# 8).
  • Null 조건부 대입(C# 14): ?.·?[]을 대입문의 왼변에 허용. ++·--는 제외.

3. 내부 동작 — IL에서 short-circuit은 brtrue.s 분기로 구현된다

여기서는 ?.·??·??=·?[]가 IL 레벨에서 실제로 어떻게 구현되는지 봅니다. 짧게 결론부터 말하면 모두 "값을 로드한 뒤 조건부 분기(brtrue.s / brfalse.s)로 나머지 코드를 건너뛰는 구조"입니다.

3.1 ?. 체이닝은 brtrue.s로 short-circuit한다

다음 C# 코드를 IL로 변환해서 살펴봅니다.

C#
public class Person { public string Name { get; set; } }
public class Demo1
{
    public static int GetLen(Person p)
    {
        return p?.Name?.Length ?? 0;
    }
}

위 코드는 p가 null이거나 p.Name이 null이면 0, 둘 다 null이 아니면 p.Name.Length를 반환합니다. 컴파일러가 생성한 IL은 다음과 같습니다.

IL
.method public hidebysig static int32 GetLen (class Person p) cil managed 
{
    .locals init (
        [0] valuetype [System.Runtime]System.Nullable`1<int32>,   // int? 임시
        [1] int32                                                  // 최종 반환값
    )

    IL_0000: nop
    IL_0001: ldarg.0                 // p를 스택에 로드
    IL_0002: brtrue.s IL_000f        // p != null 이면 IL_000f로 점프 (실제 접근)

    // --- p가 null인 경로: int? 임시변수를 기본값(HasValue=false)으로 세팅 ---
    IL_0004: ldloca.s 0
    IL_0006: initobj valuetype [System.Runtime]System.Nullable`1<int32>  // int? = null
    IL_000c: ldloc.0
    IL_000d: br.s IL_002e            // 병합 연산자(??) 평가 지점으로 점프

    // --- p가 null이 아닌 경로 ---
    IL_000f: ldarg.0
    IL_0010: call instance string Person::get_Name()   // p.Name 호출
    IL_0015: dup                                         // Name 참조를 복제 (null 체크용)
    IL_0016: brtrue.s IL_0024        // Name != null 이면 IL_0024로 점프

    // --- Name이 null인 경로 ---
    IL_0018: pop                     // dup한 Name(null)을 스택에서 버림
    IL_0019: ldloca.s 0
    IL_001b: initobj valuetype [System.Runtime]System.Nullable`1<int32>  // int? = null
    IL_0021: ldloc.0
    IL_0022: br.s IL_002e

    // --- Name도 null이 아닌 경로: 실제 Length 호출 ---
    IL_0024: call instance int32 [System.Runtime]System.String::get_Length()
    IL_0029: newobj instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
                                     // int -> int? 로 감싸기 (new Nullable<int>(len))

    // --- ?? 병합 지점: int? 값을 꺼내서 HasValue이면 Value, 아니면 0 ---
    IL_002e: stloc.0
    IL_002f: ldloca.s 0
    IL_0031: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault()
    IL_0036: stloc.1
    IL_0037: br.s IL_0039
    IL_0039: ldloc.1
    IL_003a: ret
}

IL 분석 포인트

1. brtrue.s는 "null이 아니면 점프"를 의미한다

brtrue.s IL_000f는 스택 최상단 값이 "true 해석 가능"(참조 타입의 경우 null이 아님)일 때 분기합니다. 반대로 brfalse.s는 "false 해석 가능"(null)일 때 분기합니다. 즉 ?. 연산자는 brtrue.s를 이용해서 "null이 아닐 때만 멤버 접근 코드를 실행하고, null이면 그 블록을 뛰어넘는" 구조를 만듭니다. 이것이 short-circuit의 IL 구현입니다.

2. 체이닝(p?.Name?.Length)은 분기가 중첩된다

체인 단계마다 brtrue.s가 하나씩 생겼습니다 — p에 대해 한 번, p.Name에 대해 한 번. 단계가 늘어나면 분기도 하나씩 늘어납니다. null인 경로를 타면 나머지 분기는 모두 건너뜁니다. "왼쪽에서 오른쪽으로 평가하다가 중간에 null을 만나면 그 자리에서 멈춘다"는 우리가 이해하는 short-circuit이 그대로 IL에 박혀 있습니다.

3. 값 타입 Length(int)는 자동으로 Nullable<int>(int?)로 감싸진다

IL_0029: newobj ... Nullable<int>::.ctor를 보시기 바랍니다. String.Lengthint 값 타입이지만, ?.의 결과는 "null일 수도 있음"을 의미해야 합니다. C# 컴파일러는 값 타입을 자동으로 Nullable<T>로 감싸서 타입 일관성을 맞춥니다. 체인 경로별로 두 쪽 모두 Nullable<int>에 값을 담기 때문에, 이후 ?? 연산자가 통일된 타입으로 처리할 수 있게 됩니다.

4. ??GetValueOrDefault로 바뀐다

IL_0031: call ... Nullable<int>::GetValueOrDefault() 줄이 바로 ?? 0의 실체입니다. 오른쪽 피연산자가 default(int) == 0과 일치하면 JIT는 GetValueOrDefault()로 최적화할 수 있습니다. 일반적인 a ?? b는 "a가 null이 아니면 a의 값, 아니면 b"로 분기하는 별도 IL이 생성됩니다.

3.2 비교: 전통적 if-null 체크는 어떤 IL을 만드는가

같은 동작을 if문으로 풀어 쓴 다음 코드와 비교합니다.

C#
public class Person2 { public string Name { get; set; } }
public class Demo2
{
    public static int GetLen(Person2 p)
    {
        if (p == null) return 0;
        if (p.Name == null) return 0;
        return p.Name.Length;
    }
}
IL
.method public hidebysig static int32 GetLen (class Person2 p) cil managed 
{
    .locals init (
        [0] bool,     // p == null 결과
        [1] int32,    // 반환값
        [2] bool      // p.Name == null 결과
    )

    IL_0001: ldarg.0
    IL_0002: ldnull
    IL_0003: ceq                  // p == null 비교 (명시적 비교 명령어)
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: brfalse.s IL_000d    // 비교 결과가 false(= p != null)면 점프
    IL_0009: ldc.i4.0
    IL_000a: stloc.1
    IL_000b: br.s IL_002c         // return 0

    IL_000d: ldarg.0
    IL_000e: callvirt instance string Person2::get_Name()
    IL_0013: ldnull
    IL_0014: ceq                  // p.Name == null 비교
    IL_0016: stloc.2
    IL_0017: ldloc.2
    IL_0018: brfalse.s IL_001e
    IL_001a: ldc.i4.0
    IL_001b: stloc.1
    IL_001c: br.s IL_002c

    IL_001e: ldarg.0
    IL_001f: callvirt instance string Person2::get_Name()   // Name 두 번째 호출 (!)
    IL_0024: callvirt instance int32 [System.Runtime]System.String::get_Length()
    IL_0029: stloc.1

    IL_002c: ldloc.1
    IL_002d: ret
}

IL 분석 포인트

1. ceq+stloc+brfalse.s 3단 구조 vs brtrue.s 단일 분기

전통 방식은 "비교(ceq) → 결과 저장(stloc) → 분기(brfalse.s)" 3단계를 거칩니다. ?. 방식은 brtrue.s 한 번으로 끝납니다. IL 레벨에서 더 간결합니다.

2. 전통 방식은 get_Name()을 두 번 호출한다

IL_000eIL_001f, 같은 Person2::get_Name()이 두 번 호출됐습니다. 소스 코드에 p.Name이 두 번 등장하기 때문입니다. Name이 단순 자동 속성이면 JIT 인라이닝으로 비용이 사라지지만, 만약 Name이 계산 비용이 있는 속성이라면 실제로 두 번 실행됩니다. ?. 방식은 dup(스택 최상단 복제)으로 한 번 로드한 값을 재사용하므로 이런 중복이 없습니다.

3. callvirt는 null 체크를 내장한다 — 그래도 필요한 이유

callvirt는 가상 디스패치 명령어이면서 동시에 "대상이 null이면 NRE를 던진다"는 동작을 포함합니다. 즉 p.Name에서 p가 null이면 callvirt가 알아서 NRE를 터뜨립니다. 하지만 우리는 NRE를 던지지 않으려고 null 체크를 하는 것이므로, 명시적 brfalse.s/brtrue.s로 미리 분기해야 합니다.

3.3 ??= 대입 연산자의 IL — 한 번만 할당된다

??=가 lazy 초기화에 자주 쓰이는 이유를 IL이 분명하게 보여줍니다.

C#
public class Demo3
{
    private List<int> _cache;
    public List<int> GetCache()
    {
        _cache ??= new List<int>();
        return _cache;
    }
}
IL
.method public hidebysig instance class [System.Collections]System.Collections.Generic.List`1<int32> GetCache () cil managed 
{
    .locals init (
        [0] class [System.Collections]System.Collections.Generic.List`1<int32>
    )

    IL_0001: ldarg.0
    IL_0002: ldfld class ...List`1<int32> Demo3::_cache    // _cache 필드 로드
    IL_0007: brtrue.s IL_0014                               // _cache != null 이면 할당 블록 건너뜀

    // --- _cache가 null인 경로: 새로 할당 ---
    IL_0009: ldarg.0
    IL_000a: newobj instance void class ...List`1<int32>::.ctor()   // new List<int>()
    IL_000f: stfld class ...List`1<int32> Demo3::_cache              // _cache 에 대입

    // --- 공통 경로: _cache 반환 ---
    IL_0014: ldarg.0
    IL_0015: ldfld class ...List`1<int32> Demo3::_cache
    IL_001a: stloc.0
    IL_001b: br.s IL_001d
    IL_001d: ldloc.0
    IL_001e: ret
}

IL 분석 포인트

1. newobjbrtrue.s 뒤에 있다 — 한 번 할당된 뒤에는 스킵된다

IL_000a: newobj ... List1::.ctor()는 **IL_0007: brtrue.s가 분기하지 않을 때만** 실행됩니다. 즉 _cache가 null인 **첫 호출에서만** new List<int>()가 실행됩니다. 두 번째 호출부터는 brtrue.s가 점프해서 newobj` 블록을 건너뜁니다.

2. Unity 핫패스에서 이 차이가 곧 GC 스파이크 방지다

Update 루프처럼 매 프레임 호출되는 메서드에서 _cache = _cache ?? new List<int>();를 쓴다고 가정해 봅시다. 만약 ??를 잘못 이해해서 매 프레임 new List<int>()를 호출한다면, 매 프레임 힙 할당이 발생하여 GC 압박이 쌓입니다. ??=은 할당이 null일 때만 일어나므로 안전합니다. IL에서 newobj가 분기 뒤에 숨어 있는 점을 눈으로 확인할 수 있을 때 이 차이를 직관적으로 이해할 수 있습니다.

3.4 ?[]의 IL — 인덱스 접근을 분기로 감싼다

C#
public class Demo4
{
    public static int FirstOrZero(int[] arr)
    {
        return arr?[0] ?? 0;
    }
}
IL
.method public hidebysig static int32 FirstOrZero (int32[] arr) cil managed 
{
    .locals init ([0] int32)

    IL_0001: ldarg.0
    IL_0002: brtrue.s IL_0007        // arr != null 이면 점프

    IL_0004: ldc.i4.0                 // null인 경로: 0을 로드
    IL_0005: br.s IL_000a

    IL_0007: ldarg.0                  // null이 아닌 경로
    IL_0008: ldc.i4.0                 // 인덱스 0
    IL_0009: ldelem.i4                // arr[0] 읽기 (int 배열 원소 로드)

    IL_000a: stloc.0
    IL_000b: br.s IL_000d
    IL_000d: ldloc.0
    IL_000e: ret
}

IL 분석 포인트

1. ldelem.i4brtrue.s 뒤에만 실행된다

배열 원소 로드 명령어인 ldelem.i4arr가 null이면 실행되지 않습니다. JIT가 arr == null 상태에서 ldelem.i4를 실행하려 하면 NRE가 납니다. brtrue.s가 그 상황을 사전에 차단합니다.

2. int 반환이라 Nullable<int> 박싱이 생략됐다

3.1의 ?.Name?.Length에서는 Nullable<int>로 감싸는 newobj가 있었는데, 여기서는 없습니다. 이유는 ?? 0의 오른쪽 피연산자가 int 0이고 JIT가 간단한 케이스라고 판단하여 "null이면 0으로 바로 대입"하는 형태로 IL을 축약한 것입니다. int[]는 참조 타입이라 null이 될 수 있지만, arr[0]이 반환하는 int는 값 타입이어서 Nullable<int> 임시를 통하지 않는 최단 경로로 끝낼 수 있었습니다.


4. 실전 적용 — Before/After 패턴

4.1 이벤트 핸들러: 구독자 없을 때 안전하게 호출

이벤트를 발생시키기 전에 구독자가 있는지 확인해야 합니다. 구독자가 한 명도 없으면 이벤트 자체가 null이기 때문입니다.

Before — null 체크를 ==

C#
public event Action<int> OnScoreChanged;

void AddScore(int amount)
{
    _score += amount;
    if (OnScoreChanged != null)
    {
        OnScoreChanged(_score);
    }
}

위 코드는 멀티스레드 환경에서 TOCTOU 버그(체크와 사용 사이에 상태가 바뀌는 문제)가 있습니다. != null 체크를 통과한 직후 다른 스레드가 마지막 구독자를 해제하면 OnScoreChanged(_score) 호출 시점에 null이 되어 NRE가 납니다. 이 시나리오는 드물지만 실제로 발생합니다.

After — ?.Invoke로 원자적 처리

C#
public event Action<int> OnScoreChanged;

void AddScore(int amount)
{
    _score += amount;
    OnScoreChanged?.Invoke(_score);
}

?.Invoke는 컴파일러가 현재 이벤트 참조를 한 번 복사해서 임시 변수에 담고, 그 임시 변수가 null이 아닐 때만 호출하는 IL을 생성합니다. 3.1에서 본 dup(복제)이 그 역할입니다. 다른 스레드가 이후 구독을 해제해도, 이미 로컬로 복사한 참조는 살아 있으므로 NRE가 발생하지 않습니다. 이벤트는 무조건 ?.Invoke — 실무에서 예외 없는 규칙입니다.

4.2 Unity — GetComponent 캐싱 (??=)

Unity에서 GetComponent<T>()는 내부적으로 네이티브 호출을 하므로 매번 부르면 비쌉니다. 처음 한 번만 캐싱하고 재사용해야 합니다.

Before — 매번 GetComponent

C#
public class Enemy : MonoBehaviour
{
    void Update()
    {
        // 매 프레임 GetComponent 호출 — 핫패스에서는 성능 이슈
        var rb = GetComponent<Rigidbody>();
        rb.AddForce(Vector3.forward);
    }
}

Update()는 프레임마다 호출되는 핫패스입니다. 60fps 기준 초당 60번, 30초 플레이 시 1800번 GetComponent를 호출합니다. 적이 100마리면 18만 번이 됩니다.

After — ??=으로 lazy 캐싱

C#
public class Enemy : MonoBehaviour
{
    private Rigidbody _rb;

    void Update()
    {
        _rb ??= GetComponent<Rigidbody>();   // 첫 호출에서만 GetComponent 실행
        _rb.AddForce(Vector3.forward);
    }
}

3.3의 IL에서 확인했듯이, ??=_rb가 null일 때만 GetComponent를 호출합니다. 한 번 캐싱된 뒤로는 brtrue.s 분기가 할당 블록을 건너뛰므로 실제 작업은 필드 읽기 한 번이 전부입니다.

주의: Unity의 Destroy(_rb) 이후에는 "fake null" 문제가 발생합니다. 이 내용은 [함정과 주의사항]에서 따로 다룹니다.

4.3 설정 값 기본값 (??)

JSON 설정 파서, 서버 응답, 사용자 입력 등에서 "없으면 기본값"을 주는 패턴입니다.

Before — 장황한 삼항/if

C#
string userName = profile != null && profile.Name != null ? profile.Name : "게스트";
int maxHp = (config?.MaxHp).HasValue ? config.MaxHp.Value : 100;

After — ?.??의 조합

C#
string userName = profile?.Name ?? "게스트";
int maxHp = config?.MaxHp ?? 100;

두 번째 줄에서 config?.MaxHp의 결과는 int?이고, ?? 100으로 int로 바뀝니다. 타입 변환 한번에 안전성과 가독성을 모두 얻습니다.

4.4 C# 14 — 새 객체 생성과 대입을 한 줄에

C# 14의 null 조건부 대입은 "객체가 있을 때만 이 필드를 설정" 패턴을 간결하게 만듭니다.

Before (C# 13 이하)

C#
if (customer is not null)
{
    customer.Order = CreateExpensiveOrder();
}

After (C# 14)

C#
customer?.Order = CreateExpensiveOrder();

두 코드는 의미가 완전히 같습니다. customer가 null이면 CreateExpensiveOrder()호출되지 않습니다 — 이 점이 중요합니다. 비싼 계산이 null일 때 공회전하지 않습니다.

복합 대입도 됩니다.

C#
// 모두 C# 14에서 허용
player?.Score += 10;
config?.Tags ??= new List<string>();

단, ++--는 허용되지 않습니다. 자세한 이유는 [함정과 주의사항]에서 설명합니다.


5. 함정과 주의사항

5.1 Unity 함정 — ?.로 파괴된 오브젝트를 검사하면 터진다

C#을 쓰는 개발자에게 "Unity에서 ?.를 쓰지 말라"는 조언은 한 번쯤은 들어보셨을 것입니다. 이유는 Unity가 UnityEngine.Object에 대해 == 연산자를 오버로드했기 때문입니다.

배경 — Unity의 "fake null"

GameObject, MonoBehaviour, Component 같은 타입은 모두 UnityEngine.Object를 상속합니다. Unity 오브젝트는 C# 쪽의 관리 객체(wrapper)와 C++ 쪽의 네이티브 객체 두 쌍으로 구성됩니다. Destroy(gameObject)를 호출하면 네이티브 객체는 즉시 파괴되지만, C# 래퍼는 GC가 수거하기 전까지 살아있습니다. 즉:

  • C# 참조 관점: gameObject는 null이 아니다 (래퍼 객체가 아직 메모리에 있음)
  • 실제 엔진 관점: 네이티브 객체는 이미 없다

Unity는 이 혼란을 줄이려고 UnityEngine.Object.operator ==를 오버로드해서 "네이티브 객체가 파괴되었으면 null처럼 보이게" 만들었습니다. 이것이 fake null입니다.

C#
GameObject go = Instantiate(prefab);
Destroy(go);
// 다음 프레임...
Debug.Log(go == null);  // True — Unity 오버로드 == 이 "파괴됐으니 null"로 판정

❌ 잘못된 패턴 — ?. 사용

C#
void Update()
{
    _target?.SetActive(false);   // _target이 Destroy된 상태면?
}

?. 연산자는 CLR 런타임 null만 체크합니다. 즉 Unity가 오버로드한 ==를 전혀 호출하지 않고, 순수 C# 참조가 0(진짜 null)인지만 봅니다. 파괴된 GameObject의 래퍼는 여전히 메모리에 있으므로, ?."null 아님"으로 판정하고 SetActive(false) 호출을 시도합니다. 그 결과 네이티브 호출이 존재하지 않는 C++ 객체를 건드려 MissingReferenceException이 납니다.

✅ 올바른 패턴 — == null 명시 검사

C#
void Update()
{
    if (_target != null)
    {
        _target.SetActive(false);
    }
}

!=는 Unity가 오버로드한 연산자를 그대로 사용합니다. 파괴된 오브젝트는 "null로 판정"되어 안전하게 분기합니다.

Unity 파괴된 오브젝트: `?.`는 통과시키고, `!= null`은 막는다

실무 규칙 (Unity)

  • UnityEngine.Object 파생 타입(GameObject, MonoBehaviour, Component, ScriptableObject 등)에는 ?.·??·??=를 쓰지 않는다.
  • 반드시 if (obj != null) 또는 if (obj) (Unity의 암시적 bool 변환) 사용.
  • 일반 C# 타입(string, List<T>, POCO 등)에서는 ?.·??·??= 정상 사용.
⚠️ Unity 6에서도 이 문제는 동일합니다. ?.가 가상 호출 규약을 오버로드된 ==로 대체하지 않는 것은 C# 언어 스펙이라 엔진 버전과 무관합니다.

5.2 C# 14 — 왜 customer?.Count++은 금지인가

C# 14에서 null 조건부 대입이 허용됐지만, ++--는 여전히 컴파일 에러입니다.

C#
customer?.Order = GetOrder();   // ✅ C# 14 OK
customer?.Count += 1;           // ✅ C# 14 OK (복합 대입)
customer?.Count++;              // ❌ 컴파일 에러
customer?.Count--;              // ❌ 컴파일 에러

이유 — l-value와 부수 효과의 모호성

++/--는 단순 대입이 아니라 "값을 읽고, 1을 더해서, 같은 자리에 쓴 뒤, 변경 전(또는 후) 값을 반환"이라는 복합 동작입니다. customer?.Count++를 허용한다고 가정하면 customer가 null일 때 다음 질문들에 답할 수 없게 됩니다.

  1. 반환값의 타입이 뭔가? Countint라면 null일 때 반환값은 무엇이어야 하는가? int?로 바꿀 수 있다는 논리도 있지만, customer.Count++ 원래 반환 타입은 int다. 타입이 상황에 따라 달라지면 표현식의 의미가 혼란스러워진다.
  2. 부수 효과는 어디서 일어나는가? ++는 "메모리 위치에 쓰기"라는 부수 효과다. customer가 null이면 쓰기를 건너뛴다는 것까지는 괜찮지만, "건너뛴 뒤 반환값"의 의미를 어떻게 정의할 것인가?
  3. 확장 가능성: customer?.List[index]++ 같은 중첩 시나리오는 조합 폭발을 만든다.

C# 언어 디자인 팀은 이런 모호성을 방지하기 위해 ++/--는 스펙에서 명시적으로 제외했습니다. 대신 필요하면 다음처럼 풀어 씁니다.

C#
// ❌ 금지
customer?.Count++;

// ✅ 대안 1: 복합 대입
customer?.Count += 1;

// ✅ 대안 2: 명시적 if
if (customer is not null)
    customer.Count++;

5.3 값 타입 반환에서 intint? 혼동

?.의 결과가 값 타입일 때 자동으로 Nullable<T>가 된다는 규칙을 놓치면 컴파일 오류나 의도와 다른 결과를 만나게 됩니다.

❌ 컴파일 에러

C#
int age = person?.Age;   // 에러: int? 를 int 에 대입 불가

✅ 올바른 패턴

C#
// 세 가지 대안 중 상황에 맞는 하나를 선택 (같은 블록에 동시에 쓰면 중복 선언 에러)
int? age1 = person?.Age;                        // 변수 타입을 int? 로
int  age2 = person?.Age ?? 0;                   // ?? 로 기본값 제공하여 int 로 환원
int  age3 = (person?.Age).GetValueOrDefault();  // 명시적 꺼내기 (기본값 0)

실무에서는 ?? 기본값 조합이 가장 흔합니다.

5.4 ????=의 왼쪽은 "null이 될 수 있는 값"이어야 한다

C#
int x = 0;
int y = x ?? 10;   // ❌ 에러: x가 int(non-nullable) 라 null 일 수 없음

int는 절대 null일 수 없으므로 컴파일러가 ??를 거부합니다. int?, 참조 타입, nullable reference type에서만 의미 있습니다.


6. C# 버전별 변화

C#에서 null 관련 연산자는 여러 버전에 걸쳐 점진적으로 도입·확장되었습니다.

버전 기능
C# 1.0 null 체크는 if (x != null) 만 가능
C# 2.0 Nullable<T> 도입 (int? 같은 값 타입 nullable)
C# 6.0 ?.·?[] null 조건부 연산자 도입
C# 6.0 ?? null 병합 연산자 (이전부터 있었으나 정비)
C# 8.0 ??= null 병합 대입 연산자 도입
C# 8.0 Nullable Reference Types(NRT, 참조 타입도 nullable 여부를 선언) 도입
C# 14 Null 조건부 대입(customer?.Order = value) 도입

6.1 C# 5 이전 vs C# 6 이후 (?. 도입)

Before (C# 5)

C#
int? length;
if (name != null)
    length = name.Length;
else
    length = null;

After (C# 6+)

C#
int? length = name?.Length;

6.2 C# 7 vs C# 8 (??= 도입)

Before (C# 7)

C#
if (_cache == null)
    _cache = new List<int>();

After (C# 8+)

C#
_cache ??= new List<int>();

IL 레벨에서도 한 번의 brtrue.s로 할당 블록 전체를 건너뛰는 최적 형태가 됩니다(3.3 참조).

6.3 C# 13 vs C# 14 (Null 조건부 대입)

Before (C# 13)

C#
// 대입문 왼쪽에 ?. 를 쓰면 컴파일 에러가 발생한다:
//   customer?.Order = GetOrder();   // ❌ CS0131 등

// 따라서 풀어 써야 한다
if (customer is not null)
    customer.Order = GetOrder();

After (C# 14)

C#
customer?.Order = GetOrder();   // 정상 컴파일

복합 대입(+=, -=, *=, ??= 등)도 허용. 단 ++·--는 제외(5.2 참조).


7. 정리 — 이것만 기억하시기 바랍니다

연산자 요약

연산자 동작 대표 용도
x?.M x가 null이면 null, 아니면 x.M 안전한 멤버 접근
x?[i] x가 null이면 null, 아니면 x[i] 안전한 인덱스 접근
a ?? b a가 null이면 b, 아니면 a 기본값 지정
a ??= b a가 null일 때만 a = b (b는 그때만 평가) Lazy 초기화, 캐싱
x?.P = v (C# 14) x가 null이면 아무것도 안 함, 아니면 x.P = v 조건부 대입

체크리스트

  • [ ] ?.·?[]·??·??=는 모두 short-circuit이다. 왼쪽이 null이면 오른쪽은 평가조차 되지 않는다 (IL의 brtrue.s 분기).
  • [ ] ?.이 값 타입을 반환하면 자동으로 Nullable<T>로 감싸진다. 받는 변수도 int?여야 하거나, ?? 기본값을 붙여야 한다.
  • [ ] 이벤트 호출은 무조건 ?.Invoke — TOCTOU 버그 방지의 표준.
  • [ ] ??=는 Unity의 GetComponent 캐싱에 최적. newobjbrtrue.s 뒤에 있어 이미 할당된 경우 새 인스턴스가 생기지 않는다.
  • [ ] Unity의 UnityEngine.Object 파생 타입에는 ?.·??·??=를 사용하지 않는다. "fake null" 문제로 MissingReferenceException이 난다. 대신 if (obj != null).
  • [ ] C# 14의 ?.·?[] 대입 왼변 사용은 ++·--를 지원하지 않는다 — l-value/부수 효과 모호성 때문. 필요하면 += 1 또는 명시적 if.
  • [ ] 일반 C# 코드와 Unity 코드에서 이 연산자들의 적용 범위가 다르다 — 이 구분을 코드 리뷰에서 놓치지 말 것.
반응형

+ Recent posts