[PART2.변수와 기본 데이터 타입(13/13)] nullable 참조 타입(NRT) — string?과 null 안정성 (C# 8)
C#에서 가장 많은 런타임 크래시의 원인 / 컴파일러가 null을 어떻게 추적하는가 / string과 string?이 IL에서는 같은 이유
목차
- 문제 제기 — "10억 달러짜리 실수"와 Unity 현장
- 개념 정의
- 활성화 — `<Nullable>enable</Nullable>`과 `#nullable`
- 동작 원리 — 컴파일러의 흐름 분석(Flow Analysis)
- 주요 컴파일러 경고
- null 관련 연산자 — 실전 도구
- 내부 동작 — IL 실측으로 확인하기
- 한계 — NRT로 막히지 않는 것들
- 제네릭 제약 — `class` · `class?` · `notnull`
- Unity 실전 — `SerializeField`·`GetComponent`·`null` 비교
- 값 타입 `int?`와 참조 타입 `string?`의 근본적 차이
- 체크리스트 — 실전에서 NRT를 쓸 때 점검할 것
- 정리
1. 문제 제기 — "10억 달러짜리 실수"와 Unity 현장
Unity로 처음 RPG를 만들면 거의 예외 없이 이 크래시를 만납니다.
public class Player : MonoBehaviour
{
private Rigidbody rb; // 인스펙터에서 할당 안 했거나
private Inventory inv; // GetComponent 실패했거나
public string PlayerName; // 서버 응답에 누락됐거나
void Update()
{
rb.AddForce(Vector3.up); // NullReferenceException
inv.AddItem("sword"); // NullReferenceException
Debug.Log(PlayerName.ToUpper()); // NullReferenceException
}
}
NullReferenceException은 런타임에야 터집니다. 컴파일은 "아무 문제 없음"이라며 통과시킵니다. 문제는 이 "아무 문제 없음"이 거짓말이라는 것입니다. 컴파일러는 rb가 null일 수 있다는 사실을 알 방법이 없었고, 개발자에게 물어본 적도 없습니다.
1965년 토니 호어(Tony Hoare)가 ALGOL에 null 참조를 도입하며 남긴 유명한 말이 있습니다.
"I call it my billion-dollar mistake." — 나는 이것을 10억 달러짜리 실수라고 부른다.
C# 8(2019)은 이 실수를 뒤집기 위해 nullable 참조 타입(Nullable Reference Types, NRT) 을 도입했습니다. 핵심 아이디어는 단순합니다.
"참조 타입은 기본적으로null을 허용하지 않는다.null을 허용하려면?를 붙여야 한다."
C# 7까지는 string name이 "문자열 또는 null"이었지만, NRT가 켜진 C# 8 이후에는 string name은 절대 null이 아닌 문자열이고, "null일 수도 있음"은 string? name 으로 명시합니다. 컴파일러는 이 선언을 근거로 위험한 역참조를 발견하면 경고를 띄웁니다.
이 글이 끝나면 다음을 구분해서 쓸 수 있게 됩니다.
string과string?이 런타임에는 같은 타입인데 왜 컴파일러가 구분할 수 있는가- NRT를 어떻게 켜고(
<Nullable>enable</Nullable>), 어떻게 점진적으로 적용하는가 - 컴파일러의 흐름 분석(flow analysis) 이
if (x != null)이후를 어떻게 안전 구간으로 인식하는가 ?.·??·??=·!연산자를 언제 어떻게 써야 하는가 (그리고!를 왜 남용하면 안 되는가)- Unity의
SerializeField·GetComponent에서 NRT를 어떻게 조율하는가 - 값 타입
int?와 참조 타입string?이 본질적으로 다른 기능이라는 점
2. 개념 정의
2.1 nullable 참조 타입의 의미
NRT는 참조 타입에 두 가지 상태를 부여합니다.
- non-nullable 참조 타입 —
string,Player,List<int>— "이 변수는null이 아니어야 한다" - nullable 참조 타입 —
string?,Player?,List<int>?— "이 변수는null일 수 있다"
중요한 사실 하나: 런타임은 이 구분을 모릅니다. JIT·CLR·GC는 string과 string?을 완전히 동일한 System.String으로 취급합니다. NRT는 전적으로 C# 컴파일러 수준의 정적 분석 도구입니다. IL 메타데이터에 "이 매개변수는 nullable이다"라는 주석(annotation) 이 붙을 뿐이고, 런타임에 null 대입을 막지는 않습니다 — 그냥 컴파일 시 경고를 띄울 뿐입니다.
이 "경고일 뿐"이라는 점이 NRT 이해의 핵심입니다. NRT는 방패가 아니라 안내판입니다. NullReferenceException을 절대 못 나게 막아주는 기능이 아니라, 컴파일 시점에 개발자에게 위험한 코드를 알려주는 검사입니다.
2.2 ADEPT — 비유로 먼저 잡기
NRT를 "공항 출국장"에 비유하면 이해가 빠릅니다.
- Non-nullable 참조 타입
string— "여권(값)이 반드시 있는 사람만 지나가는 게이트". 창구 직원(컴파일러)은 여권이 없는 사람을 보면 "경고! 여권이 없는데요?"라고 알려줍니다. - Nullable 참조 타입
string?— "여권이 없어도 지나갈 수 있는 게이트". 창구 직원은 통과는 시키지만, 다음 검사대(멤버 접근)에서 "여권 확인 먼저 해주세요"라고 요구합니다. ?.연산자 — "여권이 있으면 도장 찍고, 없으면 건너뛰기"??연산자 — "여권이 없으면 임시 증명서 발급"!연산자 — "제가 여권 챙겼다고 서명했으니 그냥 통과시켜 주세요" (서명이 거짓이면 본인 책임)

2.3 왜 C# 8 전에는 불가능했을까
C# 8 이전에도 참조 타입은 원래 null이 될 수 있었습니다. 언어 수준에서는 이미 "nullable"이었던 셈입니다. 바뀐 것은 문법이 아니라 기본값입니다.
| 버전 | string name 의 의미 |
null 허용 표기 |
|---|---|---|
| C# 1–7 | "문자열 또는 null" (암묵적 nullable) | 표기 없음 — 모두 nullable |
| C# 8+ (NRT on) | "문자열" (non-null) | string? |
이 기본값을 뒤집기 위해 C# 팀은 기존 코드와의 하위 호환성을 포기할 수 없었습니다. 수천만 줄의 기존 C# 코드가 전부 "경고 지옥"이 되면 안 되니까요. 그래서 NRT는 옵트인(opt-in) — 프로젝트나 파일 단위로 명시적으로 켜야만 활성화되는 기능입니다.
3. 활성화 — <Nullable>enable</Nullable>과 #nullable
3.1 프로젝트 전체 — .csproj
가장 표준적인 방법입니다. .csproj의 <PropertyGroup>에 한 줄 추가합니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
.NET 6 이상의 새 프로젝트 템플릿은 기본적으로 enable이 들어 있습니다. 기존 프로젝트에 켜면 경고가 수백 개 튀어나올 수 있는데, 그래서 점진적 적용 옵션이 있습니다.
| 설정 값 | annotations (? 허용) |
warnings (경고 출력) |
|---|---|---|
disable (기본 — C# 7 호환) |
❌ | ❌ |
enable |
✅ | ✅ |
annotations |
✅ | ❌ |
warnings |
❌ | ✅ |
annotations만 켜고 warnings를 끄면 "API 시그니처에 ?는 쓸 수 있지만 내부 구현은 아직 경고 없이"라는 절충안이 가능합니다. 라이브러리 작성자가 점진적으로 마이그레이션할 때 유용합니다.
3.2 파일·영역 단위 — #nullable 지시문
기존 대형 프로젝트에 한꺼번에 켜기 어려울 때, 파일 상단에 붙여 그 파일만 NRT를 적용합니다.
#nullable enable
// 이 파일 전체에 NRT 분석 적용
public class UserService
{
public User? Find(string id) => /* ... */;
}
특정 블록만 끄거나 되돌릴 수도 있습니다.
#nullable enable
public class Legacy
{
public void SafePart(string s) => s.Length;
#nullable disable
// 레거시 코드 — 경고 억제
public string OldMethod(string input) { return null; }
#nullable restore
// 파일 기본 설정(= enable)로 복원
}
3.3 Unity 프로젝트에서 켜기
Unity는 .csproj를 자동 생성하므로 수동 수정은 덮어쓰기됩니다. 두 가지 방법이 있습니다.
csc.rsp파일을Assets/루트에 생성 — Roslyn 컴파일러 옵션을 주입합니다.- 파일 단위
#nullable enable— 개별 스크립트 상단에 지시문을 박습니다. 팀 전체에 영향을 주지 않고 내 스크립트부터 쓰기 좋습니다.
Unity 2021.2 이상(C# 9.0 지원)에서 NRT가 안정적으로 동작합니다. 더 오래된 Unity에서는 ? 문법 자체가 파싱 에러를 낼 수 있으니 주의합니다.
4. 동작 원리 — 컴파일러의 흐름 분석(Flow Analysis)
NRT의 핵심 기술은 정적 흐름 분석입니다. 컴파일러는 모든 참조 변수의 null 상태(null-state) 를 추적합니다.
- not-null — 현재 시점에서 절대
null이 아니라고 컴파일러가 확신함 - maybe-null — 현재 시점에서
null일 수 있음
4.1 상태 추적 예제
#nullable enable
void Process(string? name)
{
// name 의 초기 상태: maybe-null (타입이 string?)
Console.WriteLine(name.Length); // ⚠️ CS8602: 'name'이 null일 수 있음
if (name != null)
{
// 이 블록 안에서 name 상태: not-null (if가 null을 걸러냄)
Console.WriteLine(name.Length); // ✅ 경고 없음
}
// if 블록 밖: 다시 maybe-null 로 돌아옴
Console.WriteLine(name.Length); // ⚠️ CS8602
name ??= "익명";
// name = null일 때 "익명" 할당했으므로 → not-null
Console.WriteLine(name.Length); // ✅ 경고 없음
}
컴파일러는 if (name != null), name ??= "...", name is not null, ArgumentNullException.ThrowIfNull(name) 같은 구문을 보고 해당 변수의 상태를 재평가합니다. 이 분석은 메서드 본문 안에서만 유효하며, 다른 메서드로 변수를 넘기거나 람다에 캡처되면 분석이 리셋됩니다.
4.2 상태 전이 다이어그램

4.3 nullable 어노테이션 — 컴파일러가 모르는 부분 알려주기
흐름 분석만으로는 API의 의미를 전부 파악할 수 없습니다. 예를 들어 string.IsNullOrEmpty(s)가 false를 반환하면 s는 null이 아니라는 사실을 컴파일러가 어떻게 알까요? 답은 System.Diagnostics.CodeAnalysis 네임스페이스의 어노테이션입니다.
// .NET 런타임 구현 (요약)
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) { /* ... */ }
[NotNullWhen(false)]는 "이 메서드가 false를 반환하면 value는 null이 아니다"라는 계약을 컴파일러에 알려줍니다.
| 어노테이션 | 의미 |
|---|---|
[NotNull] |
반환값 또는 out 매개변수가 항상 non-null |
[MaybeNull] |
non-nullable 타입이지만 실제로는 null이 나올 수 있음 |
[AllowNull] |
non-nullable 파라미터에 null 입력을 허용 |
[DisallowNull] |
nullable 타입이지만 null 입력은 금지 |
[NotNullWhen(bool)] |
반환값이 bool일 때 매개변수가 not-null임을 보장 |
[MaybeNullWhen(bool)] |
반환값이 bool일 때 매개변수가 null일 수 있음 |
[MemberNotNull(nameof(...))] |
이 메서드 호출 후 지정 필드가 not-null |
Unity 프로젝트에서 직접 쓸 일은 드물지만, TryGetComponent·IsNullOrEmpty·Dictionary.TryGetValue 같은 표준 API가 왜 경고 없이 동작하는지 설명해 주는 장치입니다.
5. 주요 컴파일러 경고
NRT를 켜면 다음 경고들을 자주 만납니다. 각각이 언제 터지는지 알면 수정 방향이 명확해집니다.
5.1 CS8600 — null 할당
string name = null; // CS8600: null을 non-nullable에 할당
string? maybe = GetName();
string name2 = maybe; // CS8600: maybe-null을 non-nullable에 할당
5.2 CS8602 — null 역참조
string? name = FetchFromApi();
Console.WriteLine(name.Length); // CS8602: name이 null일 수 있음
5.3 CS8603 — null 반환
string GetName()
{
string? found = Find();
return found; // CS8603: null일 수 있는 값을 non-nullable로 반환
}
5.4 CS8604 — null 인자 전달
void Print(string s) => Console.WriteLine(s);
string? maybe = Get();
Print(maybe); // CS8604: non-nullable 파라미터에 null 전달 가능
5.5 CS8618 — 생성자 종료 후 non-nullable 필드 미초기화
public class Player
{
public string Name; // CS8618: 생성자 종료 시 null일 수 있음
// 해결책 1 — 생성자에서 초기화
// 해결책 2 — 초기화 구문: public string Name = "";
// 해결책 3 — required 키워드 (C# 11): public required string Name;
// 해결책 4 — null-forgiving: public string Name = null!; (Unity 필드에서 자주)
}
이 경고 하나가 Unity 개발자들이 NRT에 가장 많이 걸리는 지점입니다. 6절에서 자세히 다룹니다.
6. null 관련 연산자 — 실전 도구
6.1 ?. — null-conditional 접근
좌항이 null이면 우항 평가를 건너뛰고 null을 반환합니다.
string? name = GetUser()?.Profile?.DisplayName;
// GetUser() == null → name = null
// Profile == null → name = null
// DisplayName == null → name = null
// 아무 단계에서든 null이면 전체가 null
int? length = name?.Length; // name이 null이면 length도 null
// 이벤트 호출 패턴
OnScoreChanged?.Invoke(newScore); // 구독자 없으면 건너뜀
6.2 ?? — null-coalescing (기본값)
좌항이 null이면 우항을 사용합니다.
string? apiName = FetchName();
string display = apiName ?? "이름 없음";
// Unity에서
Rigidbody rb = GetComponent<Rigidbody>() ?? gameObject.AddComponent<Rigidbody>();
6.3 ??= — null-coalescing 할당 (C# 8)
변수가 null일 때만 새 값을 할당합니다.
private List<string>? _cache;
void Add(string item)
{
_cache ??= new List<string>(); // 처음 호출 시에만 할당
_cache.Add(item);
}
if (_cache == null) _cache = new List<string>();의 간결 버전입니다. 지연 초기화(lazy init) 패턴에 자주 쓰입니다.
6.4 ! — null-forgiving ("내가 책임질게")
컴파일러에게 "여기는 확실히 null 아님"이라고 단언합니다. 컴파일러 검사만 건너뛰며 런타임 동작은 바뀌지 않습니다 — 실제로 null이면 그대로 NullReferenceException이 납니다.
string? maybe = FetchFromLegacyApi();
// 이 API가 null을 주지 않음을 내가 안다
int len = maybe!.Length;
// Unity 필드 패턴
[SerializeField] private Rigidbody rb = null!; // 인스펙터가 반드시 할당한다고 보증
남용하면 NRT가 무력화됩니다. 경고가 귀찮다고 !만 붙이고 다니면, NRT는 그냥 "느낌표를 더 타이핑하게 만드는 기능"이 되고 런타임 크래시는 그대로입니다. !는 다음 경우에만 정당화됩니다.
- 외부 API 계약상
null이 불가능한데 어노테이션이 부족한 경우 - 흐름 분석이 놓치는 복잡한 패턴(다른 메서드에서 먼저 검사한 경우 등)
- Unity
SerializeField처럼 엔진이 주입하는 것을 컴파일러가 알 수 없는 경우

7. 내부 동작 — IL 실측으로 확인하기
"런타임은 string과 string?을 구분하지 못한다"는 주장을 IL로 검증해 봅시다. 다음 C# 코드를 빌드해 ilspycmd로 디컴파일합니다.
7.1 샘플 C# 코드
#nullable enable
namespace NrtInspect;
public class Container
{
public string NonNull { get; set; } = "";
public string? Nullable { get; set; }
public string? Process(string input, string? maybe)
{
return maybe ?? input;
}
public T? GetOrNull<T>() where T : class => null;
public T GetRequired<T>() where T : notnull => default!;
}
7.2 IL 실측 결과 — 필드·프로퍼티
.class public auto ansi beforefieldinit NrtInspect.Container
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
01 00 01 00 00
)
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
01 00 00 00 00
)
.field private string '<NonNull>k__BackingField'
// (NullableContext 상속: 01 → not-null)
.field private string '<Nullable>k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
01 00 02 00 00
)
// (02 → nullable)
}
핵심 관찰:
- 두 필드의 타입이 완전히 동일함 — 둘 다
.field private string.System.String이고 그뿐입니다. NRT 때문에 타입이 바뀌지 않습니다. - 차이는
NullableAttribute의 바이트 값뿐 —01 00 02 00 00의 3번째 바이트02가 "nullable"이라는 뜻입니다.0=oblivious,1=not-null,2=nullable. - 클래스 수준의
NullableContextAttribute(1)— "이 클래스의 기본은 not-null"을 선언. 필드마다01 00 01 ...을 반복하지 않도록 컨텍스트로 압축합니다.
7.3 IL 실측 결과 — Process 메서드 본문 (?? 연산자)
.method public hidebysig
instance string Process (
string input,
string maybe
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
01 00 02 00 00
)
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
01 00 01 00 00
)
// 메서드 기본: nullable(2), input 파라미터만 오버라이드: not-null(1)
IL_0000: ldarg.2 // maybe 로드
IL_0001: dup // 스택 복제
IL_0002: brtrue.s IL_0006 // null이 아니면 유지
IL_0004: pop // null이면 버리고
IL_0005: ldarg.1 // input 로드
IL_0006: ret
}
핵심 관찰:
- 메서드 파라미터 타입도 그냥
string—string?이라는 별도 타입은 IL에 존재하지 않습니다. ??연산자는dup+brtrue.s+pop3바이트짜리 분기 — 전용 명령이 아니라null체크 분기로 풀립니다. 런타임 오버헤드는 한 번의 조건 분기로,if (maybe != null) return maybe; else return input;와 동등합니다..param [1]에 별도NullableAttribute(1)—input파라미터만 not-null로 표시(string이므로).maybe는 클래스 컨텍스트에 따라 nullable로 처리됨.
7.4 IL 실측 결과 — 제네릭 제약
.method public hidebysig
instance !!T GetOrNull<class T> () cil managed
{
.param [0]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
01 00 02 00 00
)
// 반환 T? — T에 nullable 어노테이션(02) 부착
// 제약 조건: <class T> — CLR의 기존 "reference type" 제약
}
.method public hidebysig
instance !!T GetRequired<T> () cil managed
{
// where T : notnull — CLR 수준의 별도 제약 없음
// 컴파일러 메타데이터 어노테이션으로만 강제
}
where T : class는 IL의<class T>(CLR 레벨) 제약으로 직접 매핑됩니다 — 런타임이 보장합니다.where T : notnull은 CLR 수준의 제약이 없습니다. 컴파일러가 메타데이터로만 강제합니다. 어셈블리 외부에서 리플렉션으로 뚫으면notnull계약이 깨집니다.
7.5 결론 — "메타데이터 주석"이라는 말의 실제
- 런타임·GC·JIT는 NRT를 모릅니다.
- NRT 정보는
NullableAttribute(멤버 수준)와NullableContextAttribute(타입·모듈 수준)에 바이트 플래그로 저장됩니다. - 다른 어셈블리가 이 메타데이터를 읽어 경고를 낼 수 있습니다 (라이브러리 경계를 넘는 안전성 공유).
string?은 타입이 아니라string에 붙은 라벨입니다.
8. 한계 — NRT로 막히지 않는 것들
NRT는 컴파일 시점 경고입니다. 런타임 안전성을 100% 보장하지 않습니다. 아래 경우들을 특히 조심해야 합니다.
8.1 JSON 역직렬화
System.Text.Json·Newtonsoft.Json은 리플렉션으로 필드에 값을 주입합니다. NRT 어노테이션을 기본적으로 존중하지 않습니다.
public class PlayerDto
{
public string Name { get; set; } = ""; // non-nullable
public int Level { get; set; }
}
// 서버 응답: { "Level": 5 } (Name 누락!)
var dto = JsonSerializer.Deserialize<PlayerDto>(json);
Console.WriteLine(dto!.Name.Length);
// 컴파일 경고 없음. 런타임: NullReferenceException.
대응책:
required키워드(C# 11) —public required string Name { get; set; }— 역직렬화 시 필드 누락 감지- 명시적 검증 —
if (dto.Name is null) throw new InvalidOperationException(); ArgumentNullException.ThrowIfNull(dto.Name);— .NET 6+
8.2 리플렉션·Activator.CreateInstance
리플렉션은 타입 시스템을 우회하여 필드에 null을 주입할 수 있습니다. NRT는 이를 감지하지 못합니다.
8.3 배열 초기화
string[] names = new string[10]; // 모든 요소가 null인데도 경고 없음
Console.WriteLine(names[0].Length); // 런타임 NullReferenceException
배열의 요소 타입은 non-nullable string이지만, new string[10]의 초기 상태는 모두 null입니다. 컴파일러는 배열 인덱스 접근의 모든 경우를 흐름 분석하지 못합니다. 명시적 초기화가 필요합니다.
8.4 다른 메서드로 변수 넘기기
#nullable enable
void Foo(string? name)
{
if (name != null)
{
Bar(); // 이 안에서 name에 뭐가 일어날지 모름
name.Length; // 여전히 not-null 유지 (지역변수 캡처 아님)
}
}
사실 이 예는 아직 안전합니다. 하지만 name이 참조로 공유되는 필드라면 흐름 분석이 리셋되어야 안전합니다.
class Worker
{
public string? Name;
void Process()
{
if (Name != null)
{
OtherMethod(); // 내부에서 Name = null 할 수도 있음
Name.Length; // ⚠️ 컴파일러는 이걸 not-null로 간주 (한계!)
}
}
}
멀티스레드 환경이나 인스턴스 필드는 지역 변수로 복사한 뒤 검사하는 것이 안전합니다.
var name = Name;
if (name != null) { OtherMethod(); name.Length; /* OK */ }
8.5 경고 억제(#pragma warning disable)
#pragma warning disable CS8618로 경고를 꺼버리면 NRT는 무력화됩니다. ! 연산자와 비슷하지만 범위가 더 넓어 더 위험합니다. 정말 불가피한 경우에만, 파일 상단이 아닌 최소 범위로 지정합니다.
9. 제네릭 제약 — class · class? · notnull
NRT는 제네릭 제약 의미를 정교화합니다.
| 제약 | 의미 | T에 허용되는 타입 |
|---|---|---|
where T : class |
non-nullable 참조 타입 | string, Player (O) · string? (X) · int (X) |
where T : class? |
참조 타입 (nullable 여부 무관) | string, string?, Player (O) · int (X) |
where T : struct |
non-nullable 값 타입 | int, Vector3 (O) · int? (X) |
where T : notnull |
non-null 보장 (참조·값 모두) | int, string, Vector3 (O) · string?, int? (X) |
where T : notnull은 특히 유용합니다. 값 타입과 참조 타입을 모두 허용하면서 null만은 배제합니다.
public class Cache<TKey, TValue> where TKey : notnull
{
private Dictionary<TKey, TValue> _map = new();
// Dictionary의 Key는 notnull 이어야 함 — 제약을 전파
}
Cache<string, int> ok = new(); // 컴파일 OK
Cache<string?, int> bad = new(); // 경고: CS8714
9.1 제네릭과 T? 반환의 묘함
public T? GetOrNull<T>(int id) where T : class
{
return _data.TryGetValue(id, out var v) ? v : null;
}
여기서 T?는 "참조 타입이면 nullable 참조, 값 타입이면 Nullable<T>"가 되어야 하는데, where T : class 제약 아래에서는 참조 타입으로 고정됩니다. 제약이 없다면 T?의 의미가 모호해져서 [MaybeNull] 어노테이션이 대안이 됩니다.
10. Unity 실전 — SerializeField·GetComponent·null 비교
10.1 SerializeField 필드 — null! 패턴
Unity의 인스펙터가 필드를 주입하는 것을 컴파일러는 모릅니다. 그래서 CS8618이 터집니다.
#nullable enable
public class PlayerController : MonoBehaviour
{
[SerializeField] private Rigidbody rb = null!; // 인스펙터가 반드시 할당
[SerializeField] private Animator anim = null!;
[SerializeField] private Transform? lookTarget; // 선택적 — 없어도 됨
void Start()
{
rb.velocity = Vector3.zero; // OK — null! 로 컴파일러 설득
lookTarget?.LookAt(transform); // null-conditional — 없을 수 있음
}
}
규칙:
- 필수 할당 필드 →
null!초기화 + non-nullable 타입 - 선택적 필드 →
?타입 + 사용처에서?.나?? null!를 쓴 필드에 대한 책임은 개발자에게 — 인스펙터에서 실제로 할당되었는지 검증해야 합니다.void OnValidate()에서Assert체크를 두는 습관이 좋습니다.
10.2 GetComponent와 TryGetComponent
Unity의 GetComponent<T>()는 컴포넌트가 없으면 null을 반환합니다. Unity 2022.2부터 공식 어노테이션이 개선되어 T?로 취급됩니다(정확한 지원은 Unity 버전·NuGet 패키지에 따라 다릅니다).
// 방어적 사용
Rigidbody? rb = GetComponent<Rigidbody>();
if (rb != null) rb.AddForce(Vector3.up);
// TryGetComponent 패턴 — 더 명확
if (TryGetComponent<Rigidbody>(out var rigid))
{
rigid.AddForce(Vector3.up);
}
10.3 Unity의 "가짜 null" 주의
Unity의 UnityEngine.Object(GameObject·Component 등)는 == 연산자를 오버로드해서 "파괴된 객체"도 null처럼 보이게 만듭니다. 이 특성은 NRT 흐름 분석과 충돌할 수 있습니다.
GameObject? target = FindTarget();
if (target != null)
{
// 컴파일러: target은 not-null 상태
// 런타임: target이 Destroy된 상태라면 target == null 은 true지만
// 실제 참조는 null이 아님 → NRT 흐름 분석은 이를 간파하지 못함
Destroy(target);
target.name; // NRT 경고 없음. Unity "가짜 null"은 실제 실행 시 예외.
}
대응:
Destroy후 명시적으로target = null;재할당- 필드로 관리하는 경우
Destroyed플래그 분리 - Unity
== null체크는 흐름 분석 후에도 반복
10.4 Unity 버전별 NRT 지원
| Unity 버전 | C# 언어 | NRT 활용 |
|---|---|---|
| 2019 이전 | C# 7.3 | ? 문법 미지원 |
| 2020 LTS | C# 8.0 | 가능하지만 권장 안 함 (미성숙) |
| 2021.2+ | C# 9.0 | 안정적 |
| 2022.2+ | C# 9.0 | 엔진 API 어노테이션 개선 |
| 2023+ | C# 9.0 | 권장 |
11. 값 타입 int?와 참조 타입 string?의 근본적 차이
주제 12(default 리터럴과 Nullable<T>)에서 다뤘던 값 타입 int?와 이번 주제의 참조 타입 string?은 이름이 비슷할 뿐 완전히 다른 기능입니다. 이 둘을 섞으면 혼란이 깊어집니다.

| 항목 | int? (값 타입 nullable) |
string? (참조 타입 NRT) |
|---|---|---|
| 언제 도입 | C# 2 (2005) | C# 8 (2019) |
| 실제 타입 | System.Nullable<int> (struct) |
System.String (그대로) |
| 런타임 구조 | HasValue + Value 필드 |
일반 참조 |
| CLR 인식 | 독립 타입으로 인식 | 구분 못 함 (주석일 뿐) |
| null 안전성 | 런타임 보장 (타입 시스템) | 컴파일 경고 (메타데이터) |
null 대입 |
구조체에 특수 상태로 저장 | 그냥 null 참조 |
| 활성화 | 기본 제공 | <Nullable>enable</Nullable> |
혼동하기 쉬운 점: int? x = null은 실제로 값이 없는 구조체이지만, string? s = null은 그냥 null 참조입니다. 둘 다 null과 비교가 가능하고 ?.·?? 연산자가 동작하지만, 내부 메커니즘은 전혀 다릅니다.
12. 체크리스트 — 실전에서 NRT를 쓸 때 점검할 것
- [ ] 프로젝트
.csproj에<Nullable>enable</Nullable>설정 (Unity는csc.rsp) - [ ] 초기에는
warnings:error를 켜지 말고 경고로만 유지 → 점진적 수정 - [ ] 공개 API의 매개변수·반환값 시그니처부터
?표기 확정 - [ ] 필드는
CS8618을 피하기 위해 초기값을 주거나 생성자에서 반드시 할당 - [ ] Unity
SerializeField필수 필드는= null!패턴 +OnValidate에서 검증 - [ ]
GetComponent는 nullable로 받고?·??로 처리, 또는TryGetComponent사용 - [ ]
!연산자는 "경고 숨기기"로 쓰지 말고 "외부 계약상 null 불가"일 때만 - [ ] JSON 역직렬화 결과는 NRT를 믿지 말고
ArgumentNullException.ThrowIfNull로 재검증 - [ ]
==오버로드된 Unity 객체는 NRT 흐름 분석 이후에도 null 체크 반복 - [ ] 멀티스레드 공유 필드는 지역 변수로 복사해 검사
13. 정리
nullable 참조 타입(NRT)은 "참조 타입은 기본적으로 null이 아니다"라는 전제를 C#에 도입한 컴파일 타임 정적 분석 기능입니다.
- 기본 뒤집기 — C# 8부터
string은 non-nullable,null허용은string? - 흐름 분석 — 컴파일러가
not-null·maybe-null상태를 추적해 위험한 역참조에 경고 - 연산자 도구 —
?.·??·??=·!로 상태를 안전하게 조작 - IL 실체 —
string?은 타입이 아니라NullableAttribute바이트 플래그. 런타임은 동일System.String - 한계 — 경고일 뿐, JSON 역직렬화·리플렉션·배열 초기화·멀티스레드에서는 여전히 수동 검증 필요
- Unity 실무 —
SerializeField는null!,GetComponent는 nullable, Unity "가짜 null"은 별도 경계
NRT는 NullReferenceException을 완전히 막는 방패가 아니라 컴파일 시점에 쏘는 예광탄입니다. 개발자가 "여기에 null이 들어오는구나"라고 알아차리고, ?.·??로 안전 구간을 만들고, !는 근거 있는 단언에만 쓰는 것 — NRT의 진짜 가치는 이 습관을 자연스럽게 유도하는 데 있습니다. 그리고 이것이 참조 타입과 값 타입, 그리고 이 PART 2에서 쌓아 올린 C# 기본 데이터 타입 전체를 관통하는 마지막 주제이기도 합니다.