반응형

[PART7.클래스와 객체 입문(6/21)] required 멤버 — 반드시 초기화해야 하는 프로퍼티 (C# 11)

객체 초기화자에 강제력을 더하는 방법 / 생성자 없이 "필수 값"을 지키는 패턴 / NRT 경고를 깔끔하게 없애는 도구


1. [문제 제기] — 생성자도 객체 초기화자도 빈틈이 있다

Unity 프로젝트에서 적(Enemy) 데이터를 만든다고 가정해 봅시다. 모든 Enemy는 이름과 체력이 반드시 있어야 합니다. 이름이 없는 Enemy, 체력이 0인 Enemy는 게임 로직 자체를 깨뜨립니다.

지금까지 C#에서 "필수 값"을 강제할 수 있는 방법은 두 가지였습니다.

방법 A — 생성자로 강제하기

C#
public class EnemyData
{
    public string Name { get; }
    public int MaxHp { get; }

    public EnemyData(string name, int maxHp)
    {
        Name = name;
        MaxHp = maxHp;
    }
}

// 호출부
var slime = new EnemyData("슬라임", 50);

생성자에 매개변수를 박으면 호출자는 반드시 모든 값을 전달해야 합니다. 안전합니다. 그런데 필드가 10개로 늘어나면 어떨까요? 매개변수 10개짜리 생성자는 가독성이 무너지고, 이후에 필드가 하나 추가될 때마다 모든 호출부가 깨집니다. 옵션 매개변수를 섞으면 어떤 값이 필수인지 시그니처만 보고 알기 어려워집니다.

방법 B — 객체 초기화자로 가독성 챙기기 (C# 3.0~)

C#
public class EnemyData
{
    public string Name { get; init; } = "";
    public int MaxHp { get; init; }
}

// 호출부
var slime = new EnemyData { Name = "슬라임", MaxHp = 50 };
var bug = new EnemyData();  // ← 컴파일 OK. Name = "", MaxHp = 0 인 망가진 객체

객체 초기화자(Object Initializer)는 어떤 필드에 무엇을 대입하는지 한눈에 보입니다. 그런데 호출자가 Name을 빼먹어도 컴파일러가 잡아주지 않습니다. 빈 문자열이 들어간 채로 게임이 돌다가 런타임에 NullReferenceException이나 잘못된 데이터 표시로 터집니다.

"생성자의 강제력"과 "객체 초기화자의 가독성"을 동시에 갖고 싶다.

이 두 가지 욕심을 한 번에 해결하는 도구가 C# 11의 required 멤버입니다. 객체 초기화자 문법을 그대로 쓰면서, 누락 시 컴파일 에러를 띄웁니다.


2. [개념 정의] — required는 "초기화자에서 반드시 채워라"는 컴파일러 지시

비유: 비행기 탑승 체크리스트

여권 없이 비행기를 탈 수는 없습니다. 게이트에서 직원이 체크리스트를 보면서 "여권, 탑승권, 보안검색"을 모두 통과했는지 확인합니다. 하나라도 빠지면 탑승이 거부됩니다.

required 멤버는 이 체크리스트와 같습니다. C# 컴파일러가 게이트 직원이 되어, new 표현식 자리마다 "이 타입의 required 멤버가 모두 채워졌는가"를 확인합니다. 하나라도 누락되면 컴파일 자체가 실패합니다 — 비행기에 못 탑니다.

구조 그림

required 멤버 검증의 흐름

가장 단순한 예시

required — 초기화 강제 한정자 (Required member modifier) 객체 초기화자에서 해당 멤버를 반드시 대입하도록 컴파일러가 강제한다. 누락 시 CS9035 에러. C# 11부터 도입.
예시: public required string Name { get; init; } new Person { Name = "..." } 형태로만 생성 가능
init — 초기화 전용 접근자 (Init-only setter, C# 9) 객체 생성 시점(생성자 또는 객체 초기화자)에만 값을 설정할 수 있고, 그 이후에는 변경 불가. 불변 객체를 만들 때 사용한다.
예시: public string Name { get; init; } 생성 후 person.Name = "..." 는 컴파일 에러
C#
public class Person
{
    public required string Name { get; init; }
    public required int Age { get; init; }
}

public class Program
{
    public static void Main()
    {
        // OK — 모든 required 멤버가 객체 초기화자에서 대입됨
        var grace = new Person { Name = "Grace", Age = 30 };

        // var x = new Person();
        // ↑ 컴파일 에러 CS9035: Required member 'Person.Name' must be set...

        // var y = new Person { Name = "Bob" };
        // ↑ 컴파일 에러 CS9035: Required member 'Person.Age' must be set...
    }
}

required는 단독으로 쓸 수 있지만, 위 예시처럼 init과 결합해서 쓰는 패턴이 표준입니다. required는 "초기화를 강제"하고, init은 "초기화 이후 불변"을 보장합니다. 두 가지를 합치면 "반드시 초기화되고, 그 이후로는 누구도 못 바꾸는" 깔끔한 모델이 만들어집니다.

IL로 본 컴파일러의 표지판

required는 런타임 동작을 추가하지 않습니다. 컴파일러가 메타데이터(특성, attribute)만 IL에 박아 두고, 다른 코드를 컴파일할 때 그 표지판을 읽어서 검증합니다.

IL
.class public auto ansi beforefieldinit Person
    extends [System.Runtime]System.Object
{
    // 클래스에 RequiredMemberAttribute 부여 — "이 타입은 required 멤버를 갖는다"
    .custom instance void System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = (
        01 00 00 00
    )

    // 매개변수 없는 생성자는 자동으로 "사실상 사용 금지"가 된다
    .method public hidebysig specialname rtspecialname
        instance void .ctor () cil managed
    {
        // [Obsolete("Constructors of types with required members are not supported
        //           in this version of your compiler.")]
        .custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool) = (...)

        // [CompilerFeatureRequired("RequiredMembers")]
        // — RequiredMembers 기능을 모르는 옛 컴파일러는 이 생성자 호출을 거부
        .custom instance void System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string) = (
            01 00 0f 52 65 71 75 69 72 65 64 4d 65 6d 62 65 72 73 00 00
        )
        // ...실제 본문은 그냥 base().ctor() 호출 — 검증 코드 없음...
    }

    // 프로퍼티에도 RequiredMemberAttribute 부여
    .property instance string Name()
    {
        .custom instance void System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = (
            01 00 00 00
        )
        // ...
    }
}

세 가지가 핵심입니다.

  • RequiredMemberAttribute타입과 멤버 양쪽에 부여됩니다. 호출 측 컴파일러는 이 두 표지판을 읽어 "어떤 멤버가 필수인지"를 파악합니다.
  • 매개변수 없는 생성자에 [Obsolete(...)] + [CompilerFeatureRequired("RequiredMembers")]가 자동으로 붙습니다. 이 기능을 인지하지 못하는 옛 C# 10 이하 컴파일러는 이 생성자 호출을 막아, 호환성 문제를 사전에 차단합니다.
  • IL 본문에는 "Name이 null인지 검사" 같은 런타임 검증 코드가 없습니다. 검증은 오직 컴파일 타임에만 일어납니다.

3. [내부 동작] — 객체 초기화자는 어떻게 IL로 풀리는가

객체 초기화자의 컴파일 결과

new Person { Name = "Grace", Age = 30 }는 마법이 아닙니다. 컴파일러가 다음 세 단계로 풀어냅니다.

  1. 매개변수 없는 생성자 호출 (.ctor)
  2. 반환된 인스턴스에 대해 setter 호출 (각 필드마다 한 번씩)
  3. 결과 인스턴스를 변수에 저장
new Person { Name =

실제 IL 시퀀스

C#
var grace = new Person { Name = "Grace", Age = 30 };
IL
IL_0001: newobj   instance void Person::.ctor()
                  // 비어있는 Person 인스턴스를 힙에 만들고 참조를 스택에 푸시

IL_0006: dup
                  // 스택의 참조를 복사 — 다음 setter가 소비할 분과 그 다음 setter가 쓸 분
IL_0007: ldstr    "Grace"
IL_000c: callvirt instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit)
                  Person::set_Name(string)
                  // dup 으로 복사한 참조를 소비해 set_Name 호출

IL_0012: dup
IL_0013: ldc.i4.s 30
IL_0015: callvirt instance void modreq(IsExternalInit) Person::set_Age(int32)

IL_001b: stloc.0  // 마지막에 남은 참조를 변수에 저장

세 가지를 짚어봅시다.

  • required 자체는 IL에 검증 코드를 만들지 않습니다. 위 시퀀스는 required 없는 평범한 객체 초기화자와 동일합니다. required 위반을 잡는 일은 컴파일러가 이 IL을 만들기 직전에 이미 끝납니다.
  • modreq([...]IsExternalInit)이 setter 시그니처에 붙어 있습니다. 이건 init의 흔적입니다. 객체 초기화자나 생성자 같은 "초기화 문맥"에서만 호출 가능하다는 표시입니다. 일반 코드에서 setter를 부르려고 하면 컴파일러가 이걸 보고 거부합니다.
  • callvirt이 setter에 쓰입니다. Person이 sealed가 아니므로 가상 호출입니다. 이 호출 비용 자체는 required 유무와 무관합니다.

호출 사이트가 다르면 검증도 다르다

같은 Person이라도 new Person { ... } 자리에서만 컴파일러가 검증합니다. 리플렉션이나 직렬화처럼 new 표현식을 쓰지 않는 경로는 검증 대상이 아닙니다.

C#
// 위험한 우회 1 — Activator는 컴파일 타임 검증 대상이 아님
Person bad = (Person)Activator.CreateInstance(typeof(Person))!;
// bad.Name 은 null! — 런타임에 NRE 위험

이 동작은 required의 한계이자 의도된 설계입니다. 컴파일러가 보지 못하는 경로는 막을 수 없습니다. 직렬화 라이브러리는 이를 인지하고 별도로 처리해야 하는데, System.Text.Json은 .NET 8 이후 required 멤버를 인식해서 누락 시 예외를 던집니다.


4. [실전 적용] — DTO·도메인 모델·설정 객체

사례 1: DTO에서 NRT 경고 제거

NRT — Nullable 참조 타입 (Nullable Reference Types, C# 8) 참조 타입 변수가 null을 가질 수 있는지 컴파일러가 추적해 경고를 띄우는 기능. string은 non-nullable, string?은 nullable.

Before — null! 로 컴파일러를 속이는 패턴

C#
// Unity에서 서버 응답을 받는 DTO
public class LoginResponseDto
{
    public string AccessToken { get; init; } = null!;  // ← 컴파일러를 속이는 코드
    public string UserName { get; init; } = null!;
    public int ExpiresIn { get; init; }
}

// 호출부
var dto = new LoginResponseDto { ExpiresIn = 3600 };
// ↑ AccessToken과 UserName 누락. 컴파일은 통과. 런타임에 토큰이 null!

null!(null-forgiving 연산자)을 기본값으로 박아 NRT 경고를 끕니다. 그 대가로, 호출자가 값을 누락해도 컴파일러가 잡아주지 못합니다. 런타임에 토큰이 null인 채로 서버 호출이 일어나 401이 발생합니다.

After — required로 호출자에게 책임을 떠넘김

C#
public class LoginResponseDto
{
    public required string AccessToken { get; init; }
    public required string UserName { get; init; }
    public required int ExpiresIn { get; init; }
}

// 호출부 1 — 누락 시 컴파일 에러
// var dto = new LoginResponseDto { ExpiresIn = 3600 };
// ↑ CS9035 AccessToken, UserName 누락

// 호출부 2 — OK
var dto = new LoginResponseDto
{
    AccessToken = "eyJ...",
    UserName = "Hero",
    ExpiresIn = 3600
};

After가 더 안전합니다. 그리고 null!이 사라져 코드가 깔끔해집니다. 컴파일러는 "required면 객체 생성 시 반드시 값이 들어온다"고 신뢰하기 때문에 NRT 경고를 띄우지 않습니다.

사례 2: SetsRequiredMembers로 생성자와 공존시키기

DTO만 쓸 때는 객체 초기화자만으로 충분하지만, 도메인 모델은 종종 "유효성 검사를 포함한 생성자"가 필요합니다. 이때 생성자가 필수 값을 알아서 다 채워준다면, 호출자가 객체 초기화자로 또 채울 필요가 없습니다.

[SetsRequiredMembers] — 필수 멤버 설정자 표시 (System.Diagnostics.CodeAnalysis) 이 특성이 붙은 생성자를 호출하면, 컴파일러는 그 호출 사이트에서 required 멤버 검증을 면제한다. "이 생성자가 알아서 전부 세팅한다"는 약속.
예시: [SetsRequiredMembers] public Player(string nick) { Nickname = nick; ... } new Player("Hero") 는 객체 초기화자 없이도 컴파일 통과
C#
using System.Diagnostics.CodeAnalysis;

public class Player
{
    public required string Nickname { get; init; }
    public required int Level { get; init; }

    public Player() { }   // 객체 초기화자 전용 — required 검증 적용

    [SetsRequiredMembers]
    public Player(string nickname, int level)
    {
        if (string.IsNullOrWhiteSpace(nickname))
            throw new ArgumentException("닉네임이 비어있습니다.", nameof(nickname));
        if (level < 1) throw new ArgumentOutOfRangeException(nameof(level));

        Nickname = nickname;
        Level = level;
    }
}

// 호출 1 — 객체 초기화자 (required 검증 적용)
var p1 = new Player { Nickname = "Hero", Level = 50 };

// 호출 2 — SetsRequiredMembers 생성자 (검증 면제)
var p2 = new Player("Hero", 50);

// var p3 = new Player();
// ↑ CS9035 Nickname, Level 누락 — 매개변수 없는 생성자에는 어트리뷰트가 없음

[SetsRequiredMembers] 생성자에서는 모든 required 멤버를 직접 대입할 책임을 개발자가 집니다. 빠뜨리면 NRT 경고는 다시 등장합니다.

IL로 보는 SetsRequiredMembers의 실체

IL
// SetsRequiredMembers 생성자 — Obsolete/CompilerFeatureRequired가 없다
.method public hidebysig specialname rtspecialname
    instance void .ctor (string nickname, int32 level) cil managed
{
    .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute::.ctor() = (
        01 00 00 00
    )
    // 본문 — 그냥 setter 호출
    IL_0000: ldarg.0
    IL_0001: call instance void [System.Runtime]System.Object::.ctor()
    IL_0008: ldarg.0
    IL_0009: ldarg.1
    IL_000a: call instance void modreq(IsExternalInit) Player::set_Nickname(string)
    IL_0010: ldarg.0
    IL_0011: ldarg.2
    IL_0012: call instance void modreq(IsExternalInit) Player::set_Level(int32)
    IL_0018: ret
}

// 매개변수 없는 .ctor — 검증 우회용 어트리뷰트가 자동으로 붙음
.method public hidebysig specialname rtspecialname
    instance void .ctor () cil managed
{
    .custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool)
    .custom instance void System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
    // ...
}

두 생성자에 붙은 어트리뷰트가 정반대 방향입니다. [SetsRequiredMembers] 쪽은 "이 호출 사이트에서 검증을 끄라"는 신호, 매개변수 없는 쪽은 "이 호출 사이트에서 검증을 켜라(그리고 옛 컴파일러는 못 쓰게 막아라)"는 신호입니다.

사례 3: ScriptableObject·MonoBehaviour와의 관계 (Unity 실전 고려사항)

Unity에서 MonoBehaviournew로 만들지 않고 AddComponent<T>()로, ScriptableObjectCreateInstance<T>() 또는 에셋 로드로 만들어집니다. 두 경로 모두 컴파일러가 보는 new 표현식이 아니므로 required 검증이 적용되지 않습니다.

C#
// ❌ Unity에서 의도한 대로 동작하지 않는 패턴
public class EnemyConfig : ScriptableObject
{
    public required string DisplayName;   // Unity 직렬화는 이걸 채울 수 있는가?
    public required int MaxHp;
}

// 에디터에서 ScriptableObject 에셋 만들기
var cfg = ScriptableObject.CreateInstance<EnemyConfig>();
// ↑ 컴파일은 통과 (CreateInstance는 generic 메서드 — newobj 표현식이 아님)
// 그러나 DisplayName은 null, MaxHp는 0
// 인스펙터에서 직렬화로 채워주기 전까지는 잘못된 상태

문제는 두 가지입니다.

  1. 컴파일 검증이 무력화됩니다. CreateInstance<T>는 내부적으로 리플렉션으로 매개변수 없는 생성자를 호출하는데, 위에서 봤듯이 이 생성자에는 [Obsolete]가 붙어 있습니다. C# 코드에서 직접 호출하면 경고지만, 리플렉션은 그걸 우회합니다.
  2. Unity 직렬화는 init setter도 호출할 수 없습니다. initIsExternalInit modreq 때문에 일반 코드 경로에서 setter를 못 부릅니다. Unity의 SerializedProperty는 백킹 필드를 직접 건드리므로 동작은 하지만, "초기화자에서만 채워진다"는 required의 전제는 깨집니다.

Unity에서의 권장 패턴

C#
// ✅ MonoBehaviour는 [SerializeField] + 검증 메서드 조합으로 가는 편이 맞다
public class EnemyController : MonoBehaviour
{
    [SerializeField] private string displayName = "";
    [SerializeField] private int maxHp = 1;

    private void OnValidate()
    {
        if (string.IsNullOrEmpty(displayName))
            Debug.LogError($"{name}: displayName 누락", this);
        if (maxHp <= 0)
            Debug.LogError($"{name}: maxHp는 1 이상이어야 함", this);
    }
}

// ✅ 순수 데이터 클래스(C# new로 만드는 것)에는 required 적극 사용
public class CombatLogEntry
{
    public required string AttackerId { get; init; }
    public required string DefenderId { get; init; }
    public required int Damage { get; init; }
    public DateTime Time { get; init; } = DateTime.UtcNow;
}

// 게임 로직에서 new로 만드는 곳 — required가 빛을 발한다
var log = new CombatLogEntry
{
    AttackerId = attacker.Id,
    DefenderId = defender.Id,
    Damage = damage
};

요약하면, MonoBehaviour/ScriptableObject에는 required를 쓰지 말고, new로 만드는 순수 C# 데이터 클래스에는 적극 사용하는 것이 베스트 프랙티스입니다. Unity 직렬화 경로는 별도의 검증 메커니즘(OnValidate, 어서션, 빌드 검증 스크립트)으로 챙깁니다.


5. [함정과 주의사항] — 신입이 빠지기 쉬운 5가지

함정 1: 직렬화 라이브러리 호환성

❌ 잘못된 가정 — 모든 직렬화 라이브러리가 required를 안다

C#
public class ServerConfig
{
    public required string Host { get; init; }
    public required int Port { get; init; }
}

// 구버전 Newtonsoft.Json (13.0.1 이하)에서:
var json = "{\"Host\":\"localhost\"}";  // Port 누락
var cfg = JsonConvert.DeserializeObject<ServerConfig>(json);
// ↑ 라이브러리가 required를 인식 못 하면, Port = 0 으로 채워진 채로 통과

✅ 올바른 접근 — 검증 가능한 라이브러리를 사용하거나 수동 검증

C#
// System.Text.Json (.NET 8+) 은 required 인식
using System.Text.Json;

var json = "{\"Host\":\"localhost\"}";
var cfg = JsonSerializer.Deserialize<ServerConfig>(json);
// ↑ JsonException: JSON deserialization for type 'ServerConfig' was missing required properties...

🟡 정리

  • System.Text.Json: .NET 7부터 인식하고, .NET 8부터 누락 시 예외(이전 버전의 동작은 라이브러리 문서로 확인).
  • Newtonsoft.Json: 13.0.2부터 부분 인식. 정확히는 JsonRequiredAttribute로 명시해야 안전.
  • Unity의 JsonUtility: required를 인식하지 않습니다. 백킹 필드만 직접 채웁니다.

함정 2: 상속 시 베이스의 required도 모두 채워야 함

C#
public class Entity
{
    public required int Id { get; init; }
}

public class Enemy : Entity
{
    public required string Type { get; init; }
}

// var e = new Enemy { Type = "Slime" };
// ↑ CS9035 — 베이스의 Id도 같이 채워야 함

var e = new Enemy { Id = 1, Type = "Slime" };  // OK

IL — 상속 관계에서도 RequiredMember가 그대로 전파

IL
.class public auto ansi beforefieldinit Enemy
    extends Entity
{
    // Enemy 자체에도 RequiredMemberAttribute 부여
    .custom instance void [...]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = (
        01 00 00 00
    )
    // Type 프로퍼티에도 RequiredMemberAttribute 부여
    .property instance string Type()
    {
        .custom instance void [...]RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
    }
    // Id는 베이스에서 상속 — Entity의 메타데이터를 컴파일러가 함께 본다
}

베이스 Entity의 Id에도 RequiredMemberAttribute가 붙어 있으므로, 호출 측 컴파일러는 new Enemy { ... }를 보고 Entity와 Enemy 양쪽의 required 멤버를 합쳐 검사합니다.

함정 3: SetsRequiredMembers 생성자에서 일부만 대입

C#
public class Player
{
    public required string Nickname { get; init; }
    public required int Level { get; init; }

    [SetsRequiredMembers]
    public Player(string nickname)  // ← Level을 빠뜨림
    {
        Nickname = nickname;
        // Level 대입 누락
    }
}

// 호출자 — 컴파일러는 통과시킨다 (어트리뷰트가 검증을 끔)
var p = new Player("Hero");
// p.Level == 0 — 잘못된 상태인데 NRT 경고도 안 뜬다

[SetsRequiredMembers]신뢰 기반의 어트리뷰트입니다. "이 생성자가 책임진다"고 컴파일러에게 약속하는 것이고, 실제로 모두 대입했는지는 검증하지 않습니다. 이 어트리뷰트를 붙이는 순간, 모든 required 멤버에 값을 대입하는 책임이 개발자에게 넘어옵니다.

함정 4: 매개변수 없는 생성자 + 객체 초기화자 + Activator 우회

C#
public class Person
{
    public required string Name { get; init; }
}

// 직접 호출 — 컴파일 에러
// var p = new Person();          // CS9035

// Activator 우회 — 컴파일 통과, 런타임 통과
var bad = (Person)Activator.CreateInstance(typeof(Person))!;
// bad.Name 은 null — 모든 NRT 약속이 깨짐
Console.WriteLine(bad.Name.Length);  // NRE

required는 컴파일 타임 안전 장치입니다. 리플렉션·Activator·Unsafe.As<T>·deserializer 등 컴파일러가 보지 못하는 경로는 우회됩니다. 외부 입력을 신뢰하지 않는 코드 경로(역직렬화, 플러그인 로드)에서는 추가 검증을 직접 작성해야 합니다.

함정 5: Unity SerializeField에 required를 붙이기

C#
// ❌ 의미 없고 위험한 패턴
public class EnemyData : MonoBehaviour
{
    [SerializeField] public required string DisplayName;  // 컴파일 자체는 됨
}

Unity에서 MonoBehaviour는 게임 오브젝트에 컴포넌트를 붙일 때 엔진이 매개변수 없는 생성자(또는 더 정확히는 시리얼라이저)를 통해 만듭니다. C# 코드에 new EnemyData()가 등장하지 않으므로 required 검증은 작동하지 않습니다. 게다가 인스펙터에서 값을 비워두면 어떤 컴파일/런타임 에러도 발생하지 않습니다 — 그저 빈 문자열로 시작합니다.

대안: [SerializeField] + [Tooltip] + OnValidate() 검증 + 에디터에서 에셋 검증 스크립트 조합을 사용합니다. required는 절대 붙이지 않습니다.


6. [C# 버전별 변화] — "필수 값"을 강제하는 도구의 진화

required 멤버 자체는 C# 11에서 신설된 기능이지만, "필수 값을 강제하는 방법"의 발전 흐름을 보면 왜 이 기능이 도입됐는지 더 잘 이해됩니다.

C# 버전 도구 한계
C# 1.0 매개변수 있는 생성자 매개변수 늘어나면 가독성 급격히 저하
C# 3.0 객체 초기화자 누락 검증 없음
C# 6.0 auto-property initializer ({ get; set; } = "...") 기본값으로 가짜 값을 채움
C# 8.0 NRT (string vs string?) "초기화 안 됨" 경고 → null! 우회 만연
C# 9.0 init-only setter 불변성은 챙기지만 강제력 없음
C# 11 required 멤버 누락 시 컴파일 에러 — 마침내 해결

Before — C# 9의 init만 쓸 때 (강제력 부재)

C#
public class ConfigC9
{
    public string Host { get; init; } = "";  // 가짜 기본값
    public int Port { get; init; }
}

var bad = new ConfigC9();              // OK — Host는 ""
var bad2 = new ConfigC9 { Port = 80 };  // OK — Host는 여전히 ""

IL (init만)

IL
.property instance string Host()
{
    // RequiredMemberAttribute 없음
    .get instance string ConfigC9::get_Host()
    .set instance void modreq(IsExternalInit) ConfigC9::set_Host(string)
}

init만 있을 때는 IsExternalInit만 표지판에 붙고, 호출 측에 강제력이 없습니다.

After — C# 11의 required + init (강제력 + 불변성)

C#
public class ConfigC11
{
    public required string Host { get; init; }
    public required int Port { get; init; }
}

// var bad = new ConfigC11();           // CS9035
// var bad2 = new ConfigC11 { Port = 80 }; // CS9035

var ok = new ConfigC11 { Host = "localhost", Port = 80 };

IL (required + init)

IL
.class public auto ansi beforefieldinit ConfigC11 ...
{
    // 클래스 자체에 RequiredMemberAttribute
    .custom instance void [...]RequiredMemberAttribute::.ctor()

    .property instance string Host()
    {
        // 프로퍼티에도 RequiredMemberAttribute
        .custom instance void [...]RequiredMemberAttribute::.ctor()
        .set instance void modreq(IsExternalInit) ConfigC11::set_Host(string)
    }
}

requiredinit은 정확히 두 개의 다른 표지판(어트리뷰트) 입니다. 두 도구가 합쳐져 "초기화 강제 + 초기화 이후 불변"이라는 한 쌍의 보장을 만들어냅니다.

.NET 7 vs .NET 8 — 직렬화 행동의 변화

C# 11과 거의 같은 시기에 System.Text.Json도 required를 인식하기 시작했습니다.

  • .NET 7: required 멤버에 값이 없어도 default 값(0, null)으로 통과시켰습니다.
  • .NET 8: required 멤버가 JSON에서 누락되면 JsonException을 던지고, NRT가 enabled면 누락 시 더 친절한 메시지를 출력합니다.

Unity 2022 LTS는 .NET Standard 2.1, Unity 6은 .NET 8까지 일부 지원합니다. 직렬화 경로의 동작은 사용 중인 라이브러리의 정확한 버전을 확인해야 합니다.


7. [정리] — 이것만 기억하세요

required 멤버 핵심 체크리스트

  • [ ] required는 객체 초기화자에서 값을 누락하면 CS9035 컴파일 에러를 낸다.
  • [ ] 검증은 컴파일 타임에만 일어난다. IL에는 RequiredMemberAttribute만 메타데이터로 박힌다.
  • [ ] required + init 조합이 표준이다. 강제력(required) + 불변성(init).
  • [ ] [SetsRequiredMembers] 생성자는 검증을 면제한다. 그 안에서 모든 required 멤버를 직접 대입할 책임은 개발자.
  • [ ] 상속 시 베이스의 required도 함께 채워야 한다.
  • [ ] 리플렉션·Activator·구버전 직렬화는 검증을 우회한다. 이 경로는 별도 검증 필요.
  • [ ] MonoBehaviour/ScriptableObject에는 사용하지 않는다. Unity 직렬화는 컴파일러 검증 경로가 아니다.
  • [ ] NRT(string vs string?) 경고를 깔끔하게 없애는 도구다 — null! 패턴을 대체한다.

의사결정 가이드

  • 데이터 전달용 DTO/응답 모델 → public required T P { get; init; } 만으로 구성
  • 검증 로직이 필요한 도메인 모델 → [SetsRequiredMembers] 생성자 + required 프로퍼티 병행
  • Unity 컴포넌트/에셋 → [SerializeField] + OnValidate() 검증 (required는 사용 금지)
  • 외부 입력(JSON, DB) 기반 객체 → System.Text.Json (.NET 8+) 또는 수동 검증과 함께 사용

required 멤버는 큰 기능이 아닙니다. 작은 표지판 두 개(RequiredMemberAttribute, SetsRequiredMembersAttribute)를 메타데이터에 박는 게 전부입니다. 그러나 이 표지판이 만들어내는 "객체 생성 시점의 강제력"은 NRT가 약속한 안전을 비로소 완성시킵니다. C# 11 이상 환경의 새로운 클래스라면, 일단 시작은 public required T P { get; init; }로 두십시오.

반응형

+ Recent posts