| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 샘플
- job
- Custom Package
- 게임개발
- Dots
- 2D Camera
- 오공완
- TextMeshPro
- 프레임워크
- Tween
- 암호화
- C#
- RSA
- Framework
- unity
- AES
- 환급챌린지
- Unity Editor
- 직장인공부
- 직장인자기계발
- sha
- DotsTween
- base64
- Job 시스템
- 가이드
- adfit
- 패스트캠퍼스
- 최적화
- ui
- 패스트캠퍼스후기
- Today
- Total
EveryDay.DevUp
null 처리 연산자 — ??, ?., ??= 본문
null 처리 연산자 — ??, ?., ??=
세 연산자가 해결하는 문제 / 체이닝 / 실수하기 쉬운 경우
C#에서 null은 NullReferenceException의 근원이다. C# 2.0부터 8.0까지, 컴파일러는 null을 더 간결하고 안전하게 다루는 연산자 세 개를 차례로 도입했다. 이 글에서는 각 연산자가 어떤 문제를 해결하는지, IL(Intermediate Language, 중간 언어) 수준에서 어떻게 동작하는지, 그리고 Unity에서 왜 주의해야 하는지를 파고든다.
?? — 널 병합 연산자
문제 제기
서버에서 플레이어 닉네임을 받아와서 UI에 표시하는 코드를 생각해 보자. 닉네임이 null이면 "Guest"를 보여줘야 한다.
string nickname = GetNickname(); // null일 수 있음
string display;
if (nickname != null)
display = nickname;
else
display = "Guest";
한두 번은 괜찮지만, 프로젝트 전체에서 이런 패턴이 수십 번 반복되면 코드가 지저분해진다. 더 나쁜 건, if 분기 속에서 실수로 다른 변수를 대입하는 버그가 생길 수 있다는 것이다. "null이면 이걸로 대체해줘"라는 단순한 의도를 4줄이나 써야 하는 건 과하다.
개념 정의
??— 널 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null이 아니면 그 값을 반환하고, null이면 오른쪽 피연산자 값을 반환한다. 기본값을 설정할 때 사용한다.
예시:string result = name ?? "Unknown";name이 null이면 "Unknown" 대입
음식점에서 1순위 메뉴를 주문했는데 품절이면 2순위 메뉴를 주문하는 것과 같다. ??는 "왼쪽이 null이면 오른쪽으로 대체해줘"라는 한 줄 명령이다.
[문제 제기]의 if-else 4줄이 한 줄로 줄어든다.
string nickname = null;
string display = nickname ?? "Guest";
// display == "Guest"
// ?? 연산자의 IL
IL_0000: ldnull // name = null → 스택에 null 푸시
IL_0001: dup // 스택 최상단 값을 복사 (null 체크용)
IL_0002: brtrue.s IL_000a // null이 아니면 IL_000a로 점프 → 복사본을 그대로 반환
IL_0004: pop // null이면 복사본을 스택에서 제거
IL_0005: ldstr "기본값" // 오른쪽 피연산자("Guest")를 스택에 푸시
IL_000a: ret // 스택 최상단 값 반환
핵심은 dup + brtrue 조합이다. 왼쪽 값을 복사해서 null인지 확인하고, null이 아니면 복사본을 그대로 쓴다. 변수에 저장했다가 다시 꺼내는 과정 없이 스택에서 직접 처리한다.
내부 동작
??가 if-else보다 효율적인 이유를 IL로 비교해 보자.
// Before: if-else로 null 처리
string name = null;
string result;
if (name != null)
result = name;
else
result = "기본값";
// if-else의 IL — 17바이트, 로컬 변수 2개 사용
IL_0000: ldnull // name = null
IL_0001: stloc.0 // 로컬 변수 0(name)에 저장
IL_0002: ldloc.0 // name을 다시 로드
IL_0003: brfalse.s IL_0009 // null이면 else(IL_0009)로 점프
IL_0005: ldloc.0 // name을 또 로드 (3번째 접근)
IL_0006: stloc.1 // result에 저장
IL_0007: br.s IL_000f // 합류 지점으로 점프
IL_0009: ldstr "기본값" // else: "기본값" 로드
IL_000e: stloc.1 // result에 저장
IL_000f: ldloc.1 // result 로드
IL_0010: ret // 반환
// After: ?? 연산자
string name = null;
string result = name ?? "기본값";
// ?? 연산자의 IL — 11바이트, 로컬 변수 0개
IL_0000: ldnull // name = null
IL_0001: dup // 스택 복사 (변수 저장 없이 체크)
IL_0002: brtrue.s IL_000a // non-null이면 바로 반환
IL_0004: pop // null이면 버리고
IL_0005: ldstr "기본값" // 대체값 로드
IL_000a: ret // 반환
if-else는 로컬 변수 2개를 선언하고 값을 저장·로드하는 과정을 반복한다(17바이트). ??는 스택 연산만으로 처리한다(11바이트). JIT(Just-In-Time, 실행 시점에 기계어로 변환하는 컴파일러)가 최적화한 뒤의 실행 속도는 사실상 동일하지만, IL이 단순할수록 JIT 최적화 경로가 명확해진다.
단축 평가(short-circuit evaluation): ??의 오른쪽 피연산자는 왼쪽이 null일 때만 평가된다. 오른쪽에 비용이 큰 연산을 두면 불필요한 실행을 피할 수 있다.
// LoadFromDB()는 nickname이 null일 때만 실행된다
string display = nickname ?? LoadFromDB();
Nullable<T>와의 관계: ??를 Nullable<T>(값 타입에 null을 허용하는 래퍼 구조체)에 사용하면, 컴파일러가 boxing(값 타입을 힙에 올리는 연산) 없이 최적화된 코드를 생성한다.
int? nullableInt = null;
int value = nullableInt ?? 0;
// Nullable<T> ?? 의 IL — boxing 없음!
IL_0000: ldloca.s 0 // nullableInt의 주소 로드
IL_0002: initobj System.Nullable`1<int32> // null 상태로 초기화
IL_0008: ldloca.s 0 // 다시 주소 로드
IL_000a: call System.Nullable`1<int32>::GetValueOrDefault() // HasValue ? Value : default(int)
IL_000f: ret
box나 unbox 명령어가 전혀 없다. 컴파일러가 ?? 0을 GetValueOrDefault()로 변환했다. GetValueOrDefault()는 HasValue가 false이면 default(int) — 즉 0을 반환하므로, ?? 0과 정확히 같은 결과를 낸다. 힙 할당이 전혀 없는 최적화다.
실전 적용
체이닝 — 여러 후보 중 첫 번째 non-null 값을 선택할 수 있다.
string? saved = null; // 저장된 이름
string? cached = null; // 캐시된 이름
string? entered = "세 번째"; // 입력된 이름
string result = saved ?? cached ?? entered ?? "기본값";
// result == "세 번째"
// ?? 체이닝의 IL — 각 단계마다 dup + brtrue 반복
IL_0000: ldnull // saved = null
IL_0009: dup // saved 복사
IL_000a: brtrue.s IL_001c // non-null이면 바로 반환으로 점프
IL_000c: pop // null → 버림
IL_000d: ldloc.0 // cached 로드
IL_000e: dup // cached 복사
IL_000f: brtrue.s IL_001c // non-null이면 반환
IL_0011: pop // null → 버림
IL_0012: ldloc.1 // entered 로드
IL_0013: dup // entered 복사
IL_0014: brtrue.s IL_001c // non-null이면 반환 ← 여기서 "세 번째" 발견!
IL_0016: pop // (실행 안 됨)
IL_0017: ldstr "기본값" // (실행 안 됨)
IL_001c: ret // "세 번째" 반환
첫 번째 non-null("세 번째")을 만나면 나머지 "기본값"은 평가하지 않는다. 체이닝 길이에 관계없이 단축 평가가 적용된다.
함정과 주의사항
연산자 우선순위 함정 — 산술 연산자(+, -, *)는 ??보다 우선순위가 높다.
// ❌ 잘못된 코드 — 의도: (bonus ?? 0) + 100
int? bonus = null;
int total = bonus ?? 0 + 100;
// 0 + 100 = 100이 먼저 계산됨 → bonus ?? 100 → total == 100
// ✅ 올바른 코드 — 괄호로 의도를 명시
int? bonus = null;
int total = (bonus ?? 0) + 100;
// (null ?? 0) + 100 → 0 + 100 → total == 100
// bonus가 50이었다면: (50 ?? 0) + 100 → 50 + 100 → 150
이 예시에서는 우연히 같은 결과(100)가 나오지만, bonus에 값이 있으면 결과가 달라진다. 괄호를 습관적으로 쓰는 것이 안전하다.
C# 버전별 변화
??는 C# 2.0(2005)에서 Nullable<T>와 함께 도입되었다. 연산자 자체의 문법 변화는 이후 없지만, C# 8.0에서 파생 연산자 ??=가 추가되었다.
?. — 널 조건부 연산자
문제 제기
MMORPG에서 플레이어가 속한 길드의 길드장 이름을 화면에 표시하는 코드를 생각해 보자. 플레이어, 길드, 길드장 중 어느 하나라도 null이면 NullReferenceException이 터진다.
// null 체크 지옥
string leaderName = "없음";
if (player != null)
{
if (player.Guild != null)
{
if (player.Guild.Leader != null)
{
leaderName = player.Guild.Leader.Name;
}
}
}
참조 체인이 깊어질수록 중첩 if문이 비례해서 늘어난다. 실수로 한 단계를 빼먹으면 런타임에 터진다.
개념 정의
?.— 널 조건부 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이 아니면 오른쪽 멤버에 접근하고, null이면 전체 표현식을 null로 평가한다. 멤버 접근 전에 자동으로 null 체크를 수행한다.
예시:string name = player?.Name;player가 null이면 name은 null, player가 있으면 player.Name
아파트 초인종을 누르는 것과 같다. 집에 사람이 있으면(non-null) 문을 열어주고, 비어 있으면(null) 더 이상 안으로 들어가지 않고 돌아간다. ?.를 체이닝하면 각 층마다 초인종을 누르고, 중간에 빈 집이 나오면 즉시 돌아간다.
[문제 제기]의 중첩 if문이 한 줄로 줄어든다.
Player? player = null;
string? name = player?.Name;
// player가 null → name은 null (NullReferenceException 없음)
// ?. (참조 타입) 의 IL
IL_0000: ldnull // player = null → 스택에 null
IL_0001: dup // 스택 최상단 복사 (null 체크용)
IL_0002: brtrue.s IL_0007 // non-null이면 멤버 접근으로 점프
IL_0004: pop // null → 복사본 제거
IL_0005: ldnull // null을 결과로 푸시
IL_0006: ret // null 반환 (단축 평가)
IL_0007: call Player::get_Name() // non-null → Name 프로퍼티 호출
IL_000c: ret // Name 값 반환
??와 같은 dup + brtrue 패턴이다. null이면 멤버 접근 자체를 건너뛰고 null을 반환한다 — 이것이 단축 평가(short-circuit evaluation)다.
내부 동작
값 타입 → Nullable 자동 승격
?. 결과가 값 타입(int, float, bool 등)이면, 반환 타입이 자동으로 Nullable<T>로 바뀐다. ?.는 null을 반환할 수 있어야 하는데, 값 타입은 null을 담을 수 없기 때문이다.
Player? player = null;
int? hp = player?.HP; // int가 아니라 int?로 반환됨
// player가 null → hp는 null (int에는 담을 수 없으므로 int?로 승격)
// ?. (값 타입) 의 IL — Nullable<int> 래퍼 생성
IL_0000: ldnull
IL_0001: dup
IL_0002: brtrue.s IL_000f
// null 경로: Nullable<int> 기본값 (HasValue = false)
IL_0004: pop
IL_0005: ldloca.s 0
IL_0007: initobj System.Nullable`1<int32> // HasValue=false인 빈 Nullable 생성
IL_000d: ldloc.0
IL_000e: ret
// non-null 경로: HP 값을 Nullable<int>로 감싸서 반환
IL_000f: call Player::get_HP()
IL_0014: newobj System.Nullable`1<int32>::.ctor(int32) // int → Nullable<int> 래핑
IL_0019: ret
null 경로에서는 initobj로 빈 Nullable을 만들고, non-null 경로에서는 newobj로 값을 감싼다. Nullable<T>는 구조체이므로 힙 할당은 없다.
?. + ?? 조합의 최적화: ?.와 ??를 함께 쓰면 컴파일러가 Nullable 래퍼를 제거한다.
Player? player = null;
int hp = player?.HP ?? 0; // int? → int로 자동 변환
// ?. + ?? 조합의 IL — Nullable 래퍼가 사라졌다!
IL_0000: ldnull
IL_0001: dup
IL_0002: brtrue.s IL_0007
IL_0004: pop
IL_0005: ldc.i4.0 // null이면 바로 0을 푸시 (Nullable 없음!)
IL_0006: ret
IL_0007: call Player::get_HP() // non-null이면 HP 값을 바로 반환
IL_000c: ret
initobj도 newobj도 없다. 컴파일러가 ?.와 ??를 하나로 합쳐서 "null이면 0, 아니면 HP"라는 단순한 분기로 만들었다. 값 타입에 ?.를 쓸 때는 ??와 함께 쓰는 것이 IL 수준에서도 더 효율적이다.
스레드 안전성
?.는 평가 대상을 내부적으로 임시 변수에 복사한 뒤 null을 검사한다. IL에서 dup이 그 역할이다. 따라서 null 체크를 통과한 직후 다른 스레드가 원본을 null로 바꿔도, 복사된 참조를 사용하므로 NullReferenceException이 발생하지 않는다.
?[]— 널 조건부 인덱서?.의 인덱서 버전이다. 배열이나 리스트가 null이면 인덱스 접근을 건너뛴다.
예시:string? first = names?[0];names가 null이면 first는 null
string[]? names = null;
string? first = names?[0];
// names가 null → first는 null (IndexOutOfRangeException도 NullReferenceException도 없음)
// ?[] 인덱서의 IL
IL_0000: ldnull
IL_0001: dup
IL_0002: brtrue.s IL_0007 // non-null이면 인덱스 접근
IL_0004: pop
IL_0005: ldnull // null이면 null 반환
IL_0006: ret
IL_0007: ldc.i4.0 // 인덱스 0
IL_0008: ldelem.ref // 배열 요소 로드
IL_0009: ret
?.와 동일한 패턴이다. null이면 ldelem.ref(배열 요소 접근)를 건너뛴다.
실전 적용
이벤트 안전 발화 패턴 — 이벤트에 구독자가 없으면(null) 호출을 건너뛴다.
// Before: 전통적인 이벤트 발화
Action? onPlayerDied = null;
if (onPlayerDied != null)
onPlayerDied(); // null 체크와 호출 사이에 다른 스레드가 구독 해제하면?
// After: ?. 이벤트 발화 (스레드 안전)
onPlayerDied?.Invoke(); // 한 줄로 null 체크 + 호출
// ?.Invoke() 의 IL
IL_0000: ldnull
IL_0001: dup
IL_0002: brtrue.s IL_0006 // non-null이면 Invoke 호출
IL_0004: pop
IL_0005: ret // null이면 아무것도 안 함
IL_0006: callvirt System.Action::Invoke()
IL_000b: ret
dup으로 복사한 참조를 사용하므로, null 체크와 Invoke() 호출 사이에 다른 스레드가 원본을 null로 바꿔도 안전하다. C# 6.0 이후 이벤트 발화의 표준 패턴이 되었다.
깊은 참조 체인 — ?.와 ??를 조합하면 [문제 제기]의 중첩 if문을 한 줄로 대체할 수 있다.
Player? player = null;
string name = player?.Guild?.Name ?? "무소속";
// player나 Guild가 null이면 → "무소속"
// ?. + ?? 조합 체이닝의 IL
IL_0000: ldnull // player = null
IL_0001: dup
IL_0002: brtrue.s IL_0008 // player null 체크
IL_0004: pop
IL_0005: ldnull // player가 null → null
IL_0006: br.s IL_0019 // ?? 체크로 점프
IL_0008: call Player::get_Guild()
IL_000d: dup
IL_000e: brtrue.s IL_0014 // Guild null 체크
IL_0010: pop
IL_0011: ldnull // Guild가 null → null
IL_0012: br.s IL_0019 // ?? 체크로 점프
IL_0014: call Guild::get_Name() // Guild.Name 접근
IL_0019: dup // ?? 체크: Name이 null인지
IL_001a: brtrue.s IL_0022 // non-null이면 반환
IL_001c: pop
IL_001d: ldstr "무소속" // null이면 "무소속"
IL_0022: ret
3단계 ?. 체인 + ?? 기본값이 순차적인 null 체크와 분기로 변환된다. 중간에 null을 만나면 즉시 ?? 체크로 점프해서 기본값을 반환한다.
함정과 주의사항
❌ 중간 ?. 누락
// ❌ Guild에 ?. 를 빼먹음
string? name = player?.Guild.Name;
// player가 non-null이고 Guild가 null이면 → NullReferenceException!
// ✅ 모든 참조 단계에 ?. 적용
string? name = player?.Guild?.Name;
player?.Guild.Name에서 player가 non-null이면 Guild에 접근하는데, 이때 Guild가 null이면 .Name 접근에서 예외가 터진다. ?. 체인의 각 단계에서 null이 가능한 곳에는 모두 ?.를 붙여야 한다.
❌ 괄호로 단축 평가 무효화
// ❌ 괄호가 단축 평가를 끊는다
int length = (player?.Name).Length;
// player가 null → (null).Length → NullReferenceException!
// ✅ 괄호 없이 체이닝
int? length = player?.Name.Length; // player가 null → null
int safeLen = player?.Name.Length ?? 0; // null이면 0
(player?.Name)은 "player가 null이면 null을 반환"까지는 동작하지만, 괄호 바깥의 .Length는 이 단축 평가와 무관하다. null에 .Length를 호출하므로 예외가 발생한다.
❌ 델리게이트 직접 호출
// ❌ 컴파일 오류 — delegate?() 문법은 없다
Action? callback = null;
callback?();
// ✅ 반드시 Invoke() 사용
callback?.Invoke();
?. 뒤에는 멤버 이름이 와야 하므로, 델리게이트 호출 연산자 ()를 바로 쓸 수 없다. 명시적으로 .Invoke()를 사용해야 한다.
C# 버전별 변화
?.는 C# 6.0(2015)에서 도입되었다. 동시에 ?[](널 조건부 인덱서)도 함께 도입되었다. 이후 연산자 자체의 문법 변화는 없다.
??= — 널 병합 할당 연산자
문제 제기
게임에서 인벤토리를 지연 초기화(lazy initialization)하는 코드를 자주 볼 수 있다. 객체가 스폰될 때 바로 리스트를 만들지 않고, 아이템이 처음 추가될 때 만드는 패턴이다.
// 매번 이 패턴을 반복해야 한다
if (_inventory == null)
_inventory = new List<Item>();
_inventory.Add(item);
프로젝트 전체에서 이 패턴이 반복되면, 실수로 = 대신 ==를 쓰거나 if문을 빼먹는 버그가 생길 수 있다.
개념 정의
??=— 널 병합 할당 연산자 (Null-coalescing assignment operator) 왼쪽 변수가 null일 때만 오른쪽 값을 평가하여 대입한다. null이 아니면 아무것도 하지 않는다.
예시:list ??= new List<string>();list가 null이면 새 리스트를 만들어 대입, null이 아니면 기존 리스트 유지
"냉장고에 우유가 없으면 사 와, 있으면 안 사도 돼"와 같다.
List<string>? list = null;
list ??= new List<string>();
// list가 null이었으므로 새 리스트가 만들어짐
// ??= 의 IL
IL_0000: ldnull // list = null
IL_0001: stloc.0 // 로컬 변수에 저장
IL_0002: ldloc.0 // list 로드
IL_0003: brtrue.s IL_000b // null이 아니면 건너뜀 (아무것도 안 함)
IL_0005: newobj System.Collections.Generic.List`1<string>::.ctor() // null이면 새 리스트 생성
IL_000a: stloc.0 // list에 대입
IL_000b: ldloc.0 // list 로드
IL_000c: ret
null이 아니면 newobj가 실행되지 않는다. 불필요한 객체 생성을 막는 단축 평가다.
내부 동작
??=와 if-null-then-assign은 동일한 IL을 생성한다.
// Before: if문으로 null 체크 + 할당
List<string>? list = null;
if (list == null)
list = new List<string>();
// if-null-then-assign 의 IL
IL_0000: ldnull
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: brtrue.s IL_000b // ← ??= 와 완전히 동일한 IL!
IL_0005: newobj List`1<string>::.ctor()
IL_000a: stloc.0
IL_000b: ldloc.0
IL_000c: ret
바이트 단위까지 동일하다. ??=는 순수한 문법 설탕(syntactic sugar)이다. 성능 차이는 전혀 없고, 가독성만 개선한다.
a ??= b vs a = a ?? b의 차이: a = a ?? b는 a가 non-null이어도 자기 자신에게 재대입한다. a ??= b는 null이 아니면 아예 대입하지 않는다. 프로퍼티의 setter에 부작용(로깅, 이벤트 발생)이 있을 때 차이가 생길 수 있다.
실전 적용
지연 초기화 패턴
public class Player
{
private List<Item>? _inventory;
public void AddItem(Item item)
{
_inventory ??= new List<Item>(); // 첫 호출 시에만 리스트 생성
_inventory.Add(item);
}
}
_inventory가 이미 초기화되어 있으면 new List<Item>()은 실행되지 않는다. Update()에서 매 프레임 호출해도 힙 할당은 딱 한 번만 일어난다.
딕셔너리 값 초기화
Dictionary<string, List<int>> scoreBoard = new();
string team = "red";
scoreBoard[team] ??= new List<int>(); // red 팀의 점수 리스트가 없으면 생성
scoreBoard[team].Add(100);
함정과 주의사항
❌ 스레드 안전하지 않음 — ??=는 "읽기 → null 체크 → 쓰기" 과정이 원자적(atomic)이지 않다. 두 스레드가 동시에 실행하면 객체가 두 번 생성될 수 있다.
// ❌ 멀티스레드 환경에서 위험
_cache ??= new ExpensiveObject(); // 두 스레드가 동시에 null을 보고 각각 생성
// ✅ 스레드 안전이 필요하면 Lazy<T> 사용
private readonly Lazy<ExpensiveObject> _cache = new();
public ExpensiveObject Cache => _cache.Value;
Unity의 메인 루프는 단일 스레드이므로 MonoBehaviour 안에서는 ??=로 충분하다. 별도 스레드를 쓸 때만 Lazy<T>를 고려하면 된다.
C# 버전별 변화
??=는 C# 8.0(2019)에서 도입되었다. C# 8.0 미만 환경(Unity 2020 이전의 일부 설정)에서는 사용할 수 없다.
🎮 Unity 특수 함정 — UnityEngine.Object와 null 연산자
이 세 연산자는 일반 C# 환경에서는 안전하지만, Unity에서는 치명적인 함정이 있다. GameObject, Component, ScriptableObject 등 UnityEngine.Object를 상속하는 모든 타입에 ??, ?., ??=를 직접 쓰면 예상과 다르게 동작한다.
문제 상황
public class EnemyController : MonoBehaviour
{
[SerializeField] private Rigidbody _rb;
void Update()
{
// ❌ _rb가 Destroy됐는데 ?? 가 감지하지 못함
Rigidbody rb = _rb ?? GetComponent<Rigidbody>();
// _rb는 "파괴된 오브젝트"지만 C# 참조는 살아있어서 ??가 non-null로 판단
// ❌ _rb가 Destroy됐는데 ?. 가 감지하지 못함
_rb?.AddForce(Vector3.up);
// ?. 는 non-null로 판단하고 AddForce를 호출 → MissingReferenceException!
}
}
왜 오동작하는가
Unity의 GameObject나 Component는 두 부분으로 존재한다:
- C# 관리 힙(Managed Heap) — C# 참조 객체. GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 관리한다.
- Native C++ 메모리 — Unity 엔진 내부의 실제 오브젝트 데이터.
Destroy()가 파괴한다.
Destroy(go)를 호출하면 Native C++ 오브젝트는 파괴되지만, C# 참조 객체는 GC가 수거하기 전까지 살아있다. C# 런타임 관점에서 이 참조는 여전히 null이 아니다.
UnityEngine.Object는 == 연산자를 오버로딩하여, C# 참조뿐 아니라 Native 포인터의 유효성까지 함께 확인한다. 그래서 go == null은 Destroy된 오브젝트도 true를 반환한다.
반면 ??와 ?.는 C# 언어 사양에 따라 오버로딩된 ==를 사용하지 않는다. 순수하게 C# 참조가 null인지만 확인한다. 따라서 Destroy된 오브젝트를 null로 인식하지 못한다.
올바른 null 체크 패턴
public class SafeComponent : MonoBehaviour
{
[SerializeField] private Rigidbody _rb;
private MyPureClass _data; // UnityEngine.Object를 상속하지 않는 순수 C# 클래스
void Update()
{
// === UnityEngine.Object 파생 타입 ===
// ✅ Unity 오버로딩 == 사용 (Destroy 감지 가능)
if (_rb != null)
_rb.AddForce(Vector3.up);
// ✅ bool 암시 변환 (같은 동작, 더 간결)
if (_rb)
_rb.AddForce(Vector3.up);
// ✅ 기본값이 필요하면 삼항 연산자
Rigidbody rb = _rb != null ? _rb : GetComponent<Rigidbody>();
// ❌ ?. — Destroy 감지 불가 → MissingReferenceException
_rb?.AddForce(Vector3.up);
// ❌ ?? — Destroy 감지 불가 → 파괴된 오브젝트를 계속 사용
Rigidbody fallback = _rb ?? GetComponent<Rigidbody>();
// ❌ ??= — Destroy 감지 불가
_rb ??= GetComponent<Rigidbody>();
// === 순수 C# 클래스 (UnityEngine.Object 아님) ===
// ✅ ?., ??, ??= 모두 안전하게 사용 가능
_data?.ProcessData();
string name = _data?.Name ?? "default";
_data ??= new MyPureClass();
}
}
핵심 원칙: MonoBehaviour, GameObject, ScriptableObject 등 UnityEngine.Object를 상속하는 타입에는 ??, ?., ??= 사용을 피한다. 대신 if (obj != null) 또는 if (obj)를 쓴다. UnityEngine.Object를 상속하지 않는 순수 C# 클래스에는 세 연산자를 자유롭게 사용해도 된다.
정리
연산자 요약
| 연산자 | 이름 | 도입 버전 | 핵심 동작 | IL 패턴 |
|---|---|---|---|---|
?? |
널 병합 | C# 2.0 | null이면 오른쪽 값 반환 | dup + brtrue |
?. |
널 조건부 | C# 6.0 | null이면 멤버 접근 건너뜀 | dup + brtrue |
?[] |
널 조건부 인덱서 | C# 6.0 | null이면 인덱스 접근 건너뜀 | dup + brtrue |
??= |
널 병합 할당 | C# 8.0 | null일 때만 대입 | brtrue (if문과 동일) |
체크리스트
- [ ]
??로 기본값을 설정할 때, 산술 연산자와 함께 쓰면 괄호를 명시했는가? - [ ]
?.체인에서 null이 가능한 모든 단계에?.를 붙였는가? - [ ]
?.의 결과가 값 타입이면??와 함께 써서 Nullable 오버헤드를 제거했는가? - [ ] 이벤트 발화에
?.Invoke()패턴을 사용했는가? - [ ]
??=를 멀티스레드 환경에서 쓴다면Lazy<T>가 필요하지 않은지 확인했는가? - [ ]
UnityEngine.Object파생 타입에??,?.,??=를 사용하지 않았는가? - [ ] Unity 오브젝트의 null 체크에
if (obj != null)또는if (obj)를 사용했는가?
'C# 심화' 카테고리의 다른 글
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
|---|---|
| var — 타입 추론의 원리와 한계 (0) | 2026.03.30 |
| null이란 무엇인가 — null의 두 얼굴 (0) | 2026.03.30 |
| boxing과 unboxing — 값 타입이 힙에 올라가는 순간 (0) | 2026.03.30 |
| 값 타입과 참조 타입 — C#은 왜 둘로 나눴는가 (0) | 2026.03.29 |
