| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 2D Camera
- RSA
- 프레임워크
- AES
- ui
- sha
- 최적화
- Unity Editor
- adfit
- 직장인자기계발
- DotsTween
- job
- Tween
- unity
- TextMeshPro
- Job 시스템
- 환급챌린지
- Custom Package
- Framework
- C#
- 가이드
- 샘플
- base64
- 직장인공부
- 암호화
- 패스트캠퍼스후기
- Dots
- 패스트캠퍼스
- 게임개발
- 오공완
- Today
- Total
EveryDay.DevUp
프로퍼티 — 필드와 무엇이 다른가 본문
프로퍼티 — 필드와 무엇이 다른가
get/set의 내부 구현 · 자동 구현 프로퍼티 · init 접근자 · required(C# 11)
목차
Unity에서 public int Health;라고 필드를 선언하면 인스펙터에 바로 노출되고 편리하다. 그런데 왜 숙련된 개발자들은 "필드 대신 프로퍼티를 써라"라고 말할까? 그 이유는 단순한 코딩 관례가 아니라, 컴파일러가 생성하는 코드 자체가 다르기 때문이다. 이 글에서는 필드와 프로퍼티의 근본적인 차이부터 시작해, 프로퍼티가 C# 3.0 → 6.0 → 9.0 → 11까지 어떻게 진화해 왔는지를 IL(Intermediate Language, .NET 컴파일러가 생성하는 중간 언어) 수준에서 증명한다.
필드 vs 프로퍼티 — get/set의 내부 구현
문제 제기
Unity로 RPG를 만들고 있다고 하자. 적 캐릭터의 체력을 관리하는 가장 단순한 방법은 public float Health; 필드를 선언하는 것이다.
public class BadEnemy : MonoBehaviour
{
public float Health = 100f;
}
문제는 어떤 스크립트에서든 enemy.Health = -999f;를 호출할 수 있다는 것이다. 체력이 음수가 되어도 아무도 막지 못한다. 유효성 검사, 이벤트 발생, 값 클램핑 — 이런 로직을 끼워 넣을 틈이 없다. 필드는 그저 메모리 공간 그 자체이기 때문이다.
"그러면 나중에 프로퍼티로 바꾸면 되지 않나?"라고 생각할 수 있다. 하지만 필드를 프로퍼티로 바꾸면 컴파일된 IL 코드가 완전히 달라진다. 같은 어셈블리 안에서는 소스 코드를 재컴파일하면 되지만, 다른 어셈블리(예: 팀원이 만든 DLL)에서 이 필드를 참조하고 있었다면 바이너리 호환성이 깨져 그 DLL도 재컴파일해야 한다. 처음부터 프로퍼티로 설계하면 이 문제를 원천 차단할 수 있다.
개념 정의
아파트 현관문에 비유하면 이해가 쉽다.
- 필드는 잠금장치 없는 문이다. 누구든 들어와서 집 안의 물건을 마음대로 바꿀 수 있다.
- 프로퍼티는 도어록이 달린 문이다. 들어오려면(
get) 인증을 거쳐야 하고, 물건을 놓으려면(set) 허가를 받아야 한다. 필요하면 CCTV(로깅)를 달 수도, 배달만 허용(읽기 전용)할 수도 있다.
get/set— 접근자 (Accessor) 프로퍼티의 값을 읽을 때 실행되는 코드 블록이get, 값을 쓸 때 실행되는 코드 블록이set이다.set내부에서value는 할당되는 값을 나타내는 암시적 매개변수다.
예시:set { _health = Math.Clamp(value, 0f, _maxHealth); }value가 음수여도 0으로 클램핑된다
C# 코드에서 프로퍼티는 필드처럼 보이지만, 실제로는 메서드다:
public class PropertyExample
{
private int _health;
public int Health
{
get { return _health; }
set { _health = value; }
}
}
내부 동작
컴파일러는 프로퍼티를 어떻게 변환할까? 필드 접근과 프로퍼티 접근을 나란히 컴파일해 보면 차이가 명확하다.
// 필드 직접 접근
public class FieldExample
{
public int Health;
}
// 프로퍼티 접근
public class PropertyExample
{
private int _health;
public int Health
{
get { return _health; }
set { _health = value; }
}
}
public class Program
{
public static void Main()
{
var f = new FieldExample();
f.Health = 100;
int a = f.Health;
var p = new PropertyExample();
p.Health = 100;
int b = p.Health;
}
}
// ── FieldExample: 필드 하나만 선언됨 ──
.class public auto ansi beforefieldinit FieldExample
{
.field public int32 Health // 필드 그 자체 — 메서드 없음
}
// ── PropertyExample: get_/set_ 메서드 쌍이 생성됨 ──
.class public auto ansi beforefieldinit PropertyExample
{
.field private int32 _health // 백킹 필드
// get 접근자 → get_Health() 메서드로 변환
.method public hidebysig specialname
instance int32 get_Health () cil managed
{
IL_0001: ldarg.0 // this 참조 로드
IL_0002: ldfld int32 PropertyExample::_health // 필드 값 읽기
IL_000b: ret // 반환
}
// set 접근자 → set_Health(int32 value) 메서드로 변환
.method public hidebysig specialname
instance void set_Health (int32 'value') cil managed
{
IL_0001: ldarg.0 // this 참조 로드
IL_0002: ldarg.1 // value 매개변수 로드
IL_0003: stfld int32 PropertyExample::_health // 필드에 저장
IL_0008: ret
}
// 프로퍼티 메타데이터 — get/set 메서드를 하나로 묶음
.property instance int32 Health()
{
.get instance int32 PropertyExample::get_Health()
.set instance void PropertyExample::set_Health(int32)
}
}
// ── 호출하는 쪽의 IL 비교 ──
// 필드 접근: stfld / ldfld
IL_0008: ldc.i4.s 100
IL_000a: stfld int32 FieldExample::Health // 메모리에 직접 쓰기
IL_0010: ldfld int32 FieldExample::Health // 메모리에서 직접 읽기
// 프로퍼티 접근: callvirt
IL_001d: ldc.i4.s 100
IL_001f: callvirt instance void PropertyExample::set_Health(int32) // 메서드 호출
IL_0026: callvirt instance int32 PropertyExample::get_Health() // 메서드 호출
핵심 차이를 정리하면:
- 필드 접근은
stfld/ldfld— 메모리 주소에 직접 읽고 쓴다. 중간에 어떤 코드도 끼어들 수 없다. - 프로퍼티 접근은
callvirt—get_Health()/set_Health()메서드를 호출한다.value는set_메서드의 첫 번째 매개변수(ldarg.1)일 뿐이다. .propertyIL 디렉티브는 메타데이터로만 존재한다. 실제 실행 코드는get_/set_메서드에 있다.
성능에 대해: "프로퍼티는 메서드 호출이니까 느리지 않나?"라는 의문이 들 수 있다. 단순한 get/set 접근자는 JIT(Just-In-Time, 실행 시점에 IL을 네이티브 코드로 변환하는 컴파일러) 컴파일러가 인라이닝한다. 인라이닝이란 메서드 호출 코드를 메서드의 실제 내용으로 대체하는 최적화다. 결과적으로 callvirt가 사라지고 stfld/ldfld와 동일한 네이티브 코드가 생성된다. 성능 차이는 없다.
단, 가상(virtual) 프로퍼티나 인터페이스를 통한 프로퍼티 접근은 vtable(가상 메서드 테이블, 런타임이 실제 호출할 메서드를 결정하는 테이블) 간접 호출이 발생해 인라인되지 않는다. Unity의 Update 루프처럼 매 프레임 수천 번 호출되는 핫패스에서는 인터페이스 프로퍼티 접근 대신 직접 타입 참조를 유지하는 것이 좋다.
실전 적용
Unity에서 가장 권장되는 패턴은 [SerializeField] private 필드 + public 프로퍼티 조합이다.
❌ Before — public 필드 노출
// 나쁜 예: public 필드
public class BadEnemy
{
public float Health = 100f;
}
.class public auto ansi beforefieldinit BadEnemy
{
.field public float32 Health // 누구든 직접 접근 가능
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
IL_0001: ldc.r4 100 // 100f 로드
IL_0006: stfld float32 BadEnemy::Health // 필드에 직접 저장
}
}
// 호출 측
IL_0008: ldc.r4 -999
IL_000d: stfld float32 BadEnemy::Health // -999를 그대로 저장 — 방어 로직 없음
stfld로 메모리에 직접 쓰기 때문에 어떤 값이든 무조건 저장된다.
✅ After — 프로퍼티로 보호
using System;
public class Enemy
{
private float _health;
private float _maxHealth = 100f;
public float Health
{
get { return _health; }
set
{
_health = Math.Clamp(value, 0f, _maxHealth);
}
}
}
.method public hidebysig specialname
instance void set_Health (float32 'value') cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1 // value(-999f) 로드
IL_0003: ldc.r4 0.0 // 최솟값 0 로드
IL_0008: ldarg.0
IL_0009: ldfld float32 Enemy::_maxHealth // 최댓값 100 로드
IL_000e: call float32 System.Math::Clamp(...) // Clamp 호출 → 0으로 클램핑
IL_0013: stfld float32 Enemy::_health // 클램핑된 값만 저장됨
IL_0018: ret
}
// 호출 측
IL_0019: ldc.r4 -999
IL_001e: callvirt instance void Enemy::set_Health(float32) // set 메서드 경유
set_Health() 메서드를 경유하기 때문에 Math.Clamp가 반드시 실행된다. -999f를 넣어도 _health에는 0f만 저장된다.
Unity에서의 표준 패턴:
using UnityEngine;
public class Enemy : MonoBehaviour
{
[SerializeField] private float _health = 100f; // 인스펙터에 노출
[SerializeField] private float _maxHealth = 100f;
public float Health
{
get => _health;
private set => _health = Mathf.Clamp(value, 0f, _maxHealth);
}
public void TakeDamage(float amount)
{
Health -= amount; // set 접근자 경유 → 자동 클램핑
}
}
이 패턴의 장점:
- 인스펙터 편집: 기획자가
_health초기값을 쉽게 수정할 수 있다 - 캡슐화: 다른 스크립트에서
enemy.Health = -999f;를 시도하면 컴파일 오류(private set) - 통제된 변경: 체력 변경은 반드시
TakeDamage같은 메서드를 거치며,set접근자가 값을 클램핑한다
함정과 주의사항
❌ 함정 1: 필드를 프로퍼티로 변경하면 바이너리 호환성이 깨진다
// 원래 코드 (다른 팀원의 DLL이 이 필드를 참조 중)
public class Config
{
public int MaxRetry = 3; // 필드
}
이 필드를 프로퍼티로 바꾸면:
public class Config
{
public int MaxRetry { get; set; } = 3; // 프로퍼티로 변경
}
소스 코드에서는 config.MaxRetry = 5;로 동일하게 사용할 수 있지만, IL이 stfld → callvirt set_MaxRetry()로 변경된다. 이 클래스를 참조하던 다른 DLL은 여전히 stfld로 접근하려 하므로 런타임 에러가 발생한다. 처음부터 프로퍼티로 선언했으면 이 문제는 일어나지 않는다.
❌ 함정 2: Unity 인스펙터는 프로퍼티를 직렬화하지 않는다
// ❌ 인스펙터에 표시되지 않음
public float Health { get; set; } = 100f;
// ✅ 인스펙터에 표시됨
[SerializeField] private float _health = 100f;
public float Health => _health;
Unity의 직렬화 시스템은 필드만 대상으로 한다. [SerializeField]를 프로퍼티에 직접 붙여도 무시된다. 반드시 private 필드에 [SerializeField]를 붙이고, 별도 프로퍼티로 접근을 제어하는 패턴을 사용해야 한다.
❌ 함정 3: [field: SerializeField]의 데이터 유실 위험
// C# 7.3 이후 문법 — auto property 백킹 필드에 직접 어트리뷰트 적용
public float Health { get; private set; } = 100f;
// [field: SerializeField]를 쓰면 가능하지만...
[field: SerializeField]를 사용하면 auto property의 백킹 필드에 직접 어트리뷰트를 적용할 수 있다. 하지만 Unity가 직렬화 데이터를 저장할 때 필드 이름으로 <Health>k__BackingField를 사용한다. 프로퍼티 이름을 리팩토링하면 이 이름이 바뀌어 기존 씬과 프리팹의 데이터가 유실된다. 명시적 [SerializeField] private 필드 패턴이 더 안전하다.
C# 버전별 변화
프로퍼티의 기본 개념(get/set 접근자가 메서드로 컴파일되는 구조)은 C# 1.0부터 존재하며 변하지 않았다. 이후 버전의 변화(자동 구현 프로퍼티, init, required 등)는 각 소주제에서 다룬다.
정리
- ✅ 필드는
stfld/ldfld로 메모리를 직접 읽고 쓴다 — 중간에 코드를 끼울 수 없다 - ✅ 프로퍼티는
callvirt get_/set_메서드를 호출한다 — 유효성 검사, 이벤트, 접근 제어 가능 - ✅ 단순 프로퍼티는 JIT 인라이닝으로 필드와 동일한 성능
- ✅ Unity에서는
[SerializeField]private 필드 + public 프로퍼티 조합이 표준 - ✅ 필드 → 프로퍼티 변경은 바이너리 호환성을 깨뜨리므로 처음부터 프로퍼티로 설계
자동 구현 프로퍼티 (Auto Property)
문제 제기
앞 섹션에서 프로퍼티의 장점을 확인했다. 하지만 단순히 값을 저장하고 읽는 프로퍼티를 선언하려면 매번 private 백킹 필드를 직접 만들어야 할까?
// 이렇게 매번 해야 하나?
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
private int _level;
public int Level
{
get { return _level; }
set { _level = value; }
}
프로퍼티 하나당 필드 선언 + get + set = 최소 7줄이다. 클래스에 프로퍼티가 10개면 70줄의 반복 코드가 생긴다. 컴파일러가 할 수 있는 일을 개발자가 반복하고 있다.
개념 정의
자동 구현 프로퍼티 (Auto-Implemented Property) 백킹 필드와 get/set 본문을 컴파일러가 자동으로 생성해주는 프로퍼티 선언 방식이다. C# 3.0에서 도입되었다.
예시:public string Name { get; set; }한 줄로 프로퍼티 선언 완료
내부 동작
자동 구현 프로퍼티를 컴파일하면 컴파일러가 정확히 무엇을 생성하는지 IL로 확인하자.
public class Player
{
// 자동 구현 프로퍼티
public string Name { get; set; }
public int Level { get; set; }
// getter-only auto property (C# 6)
public string Id { get; }
public Player(string id)
{
Id = id;
}
}
.class public auto ansi beforefieldinit Player
{
// ── 컴파일러가 자동 생성한 백킹 필드들 ──
.field private string '<Name>k__BackingField' // Name의 백킹 필드
[CompilerGenerated] // 컴파일러가 만들었다는 표시
.field private int32 '<Level>k__BackingField' // Level의 백킹 필드
.field private initonly string '<Id>k__BackingField' // Id — initonly(readonly)!
// ── get_Name(): 백킹 필드에서 읽기 ──
.method public hidebysig specialname
instance string get_Name () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld string Player::'<Name>k__BackingField' // 백킹 필드 읽기
IL_0006: ret
}
// ── set_Name(): 백킹 필드에 쓰기 ──
.method public hidebysig specialname
instance void set_Name (string 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1 // value 로드
IL_0002: stfld string Player::'<Name>k__BackingField' // 백킹 필드에 저장
IL_0007: ret
}
// ── get_Id(): getter만 존재 (setter 없음) ──
.method public hidebysig specialname
instance string get_Id () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld string Player::'<Id>k__BackingField'
IL_0006: ret
}
// ── 생성자: Id 백킹 필드에 직접 stfld ──
.method public hidebysig specialname rtspecialname
instance void .ctor (string id) cil managed
{
IL_0008: ldarg.0
IL_0009: ldarg.1
IL_000a: stfld string Player::'<Id>k__BackingField' // 생성자에서만 쓰기 가능
}
// ── 프로퍼티 메타데이터 ──
.property instance string Name() { .get ... .set ... }
.property instance int32 Level() { .get ... .set ... }
.property instance string Id() { .get ... } // setter 없음!
}
핵심 발견:
- 백킹 필드 이름:
<Name>k__BackingField—<>가 포함된 이름이라 C# 코드에서 직접 접근할 수 없다. 컴파일러만 접근 가능한 필드다. [CompilerGenerated]어트리뷰트: 이 코드가 개발자가 아닌 컴파일러에 의해 생성되었음을 표시한다.- getter-only auto property의 비밀:
public string Id { get; }의 백킹 필드에는initonly플래그가 붙는다. 이는 CLR(Common Language Runtime, .NET의 실행 환경) 수준에서 생성자에서만 값을 할당할 수 있음을 강제한다.
실전 적용
자동 구현 프로퍼티의 C# 6.0 기능 중 특히 유용한 것이 프로퍼티 초기화자다.
// C# 6.0: 프로퍼티 초기화자
public class V6
{
public string Name { get; } = "Default"; // getter-only + 초기값
public int Level { get; set; } = 1; // 읽기/쓰기 + 초기값
}
// 생성자에서 초기값이 백킹 필드에 직접 저장됨
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
IL_0001: ldstr "Default"
IL_0006: stfld string V6::'<Name>k__BackingField' // "Default" 저장
IL_000c: ldc.i4.1
IL_000d: stfld int32 V6::'<Level>k__BackingField' // 1 저장
IL_0013: call instance void System.Object::.ctor()
}
프로퍼티 초기화자 = "Default"는 생성자의 맨 앞에서 실행된다. Object::.ctor() 호출보다도 먼저 백킹 필드에 값이 저장된다. 이는 필드 초기화자와 동일한 동작이다.
함정과 주의사항
❌ 함정: auto property에 유효성 검사를 추가할 수 없다
// ❌ 컴파일 오류 — auto property에는 본문을 넣을 수 없다
public int Level { get; set { if (value < 0) throw new ArgumentException(); } }
자동 구현 프로퍼티는 get과 set 모두 본문이 없어야 한다. 유효성 검사가 필요하면 수동으로 백킹 필드를 선언해야 한다.
// ✅ 유효성 검사가 필요하면 수동 백킹 필드
private int _level;
public int Level
{
get => _level;
set => _level = value >= 0 ? value : throw new ArgumentOutOfRangeException();
}
C# 버전별 변화
| C# 버전 | 변화 | 핵심 차이 |
|---|---|---|
| C# 3.0 | { get; set; } 도입 |
백킹 필드 자동 생성 |
| C# 6.0 | { get; } + 초기화자 |
백킹 필드에 initonly 적용, 읽기 전용 불변 프로퍼티 가능 |
정리
- ✅ 컴파일러가
<Name>k__BackingField백킹 필드를 자동 생성한다 - ✅ getter-only auto property(
{ get; })의 백킹 필드는initonly— 생성자에서만 할당 가능 - ✅ 프로퍼티 초기화자는 생성자 맨 앞에서
stfld로 실행된다 - ✅ 유효성 검사가 필요하면 수동 백킹 필드로 전환
init 접근자 — 불변 초기화의 새로운 방법
문제 제기
게임 설정 데이터를 담는 클래스를 만든다고 하자.
public class StageConfig
{
public string StageName { get; }
public int MaxPlayers { get; }
// 필수 값이 늘어날수록 생성자 매개변수도 늘어난다
public StageConfig(string stageName, int maxPlayers)
{
StageName = stageName;
MaxPlayers = maxPlayers;
}
}
getter-only auto property로 불변성은 지켰지만, 프로퍼티가 늘어날수록 생성자 매개변수가 비대해진다. "이름 있는 초기화"를 쓰고 싶어서 { get; set; }으로 바꾸면 불변성이 깨진다. 생성 시점의 유연한 초기화와 생성 이후의 불변성 — 두 가지를 동시에 원하는 상황이다.
개념 정의
init— 초기화 전용 접근자 (Init-only setter)set과 동일하게 값을 할당하지만, 객체 초기화 시점(생성자 또는 객체 이니셜라이저)에만 호출할 수 있다. 초기화가 끝난 후에는 읽기 전용이 된다. C# 9.0에서 도입되었다.
예시:public string Name { get; init; }new Config { Name = "A" }가능, 이후config.Name = "B"컴파일 오류
내부 동작
init은 IL에서 어떻게 구현될까?
public class GameConfig
{
public required string StageName { get; init; }
public required int MaxPlayers { get; init; }
public string? Description { get; set; }
}
public class Program
{
public static void Main()
{
var config = new GameConfig
{
StageName = "Desert",
MaxPlayers = 4
};
string name = config.StageName;
}
}
// ── init 접근자: set과 거의 동일하지만 modreq가 붙는다 ──
.method public hidebysig specialname
instance void modreq(System.Runtime.CompilerServices.IsExternalInit)
set_StageName (string 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string GameConfig::'<StageName>k__BackingField'
IL_0007: ret
}
// 비교: 일반 set 접근자 (Description)
.method public hidebysig specialname
instance void set_Description (string 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string GameConfig::'<Description>k__BackingField'
IL_0007: ret
}
// 백킹 필드에 initonly 플래그
.field private initonly string '<StageName>k__BackingField'
.field private string '<Description>k__BackingField' // initonly 없음
핵심은 modreq(IsExternalInit)이다.
modreq(modified requirement)는 IL의 타입 시스템 제약이다. 이 한정자를 이해하지 못하는 컴파일러/런타임은 해당 메서드를 호출할 수 없다.- C# 컴파일러는
modreq(IsExternalInit)이 붙은set_메서드를 발견하면, 생성자와 객체 이니셜라이저 내부에서만 호출을 허용한다. 그 외의 위치에서 호출하면 컴파일 오류다. - 메서드 본문 자체는 일반
set과 완전히 동일하다 — 런타임 오버헤드 없음.
호출하는 쪽의 IL도 살펴보자:
// 객체 이니셜라이저에서의 init 호출
IL_0001: newobj instance void GameConfig::.ctor() // 객체 생성
IL_0006: dup // 스택에 참조 복제
IL_0007: ldstr "Desert"
IL_000c: callvirt instance void modreq(...) GameConfig::set_StageName(string) // init 호출
IL_0012: dup
IL_0013: ldc.i4.4
IL_0014: callvirt instance void modreq(...) GameConfig::set_MaxPlayers(int32) // init 호출
IL_001a: stloc.0 // 변수에 저장 — 이 시점부터 수정 불가
dup으로 객체 참조를 스택에 복제해놓고 set_ 메서드를 연속 호출한다. 변수에 stloc으로 저장되기 전까지가 "초기화 구간"이며, 저장 후에는 컴파일러가 set_ 호출을 차단한다.
실전 적용
init은 데이터 전달 객체(DTO)나 불변 설정에 이상적이다.
// 스테이지 설정 — 한번 설정하면 변경되지 않아야 하는 데이터
public class StageConfig
{
public string StageName { get; init; }
public int MaxPlayers { get; init; }
public float TimeLimit { get; init; }
}
// 사용
var config = new StageConfig
{
StageName = "Desert",
MaxPlayers = 4,
TimeLimit = 300f
};
// config.MaxPlayers = 8; // ❌ 컴파일 오류 — 게임 중간에 변경 불가
생성자에 매개변수를 나열하지 않아도 되고, 객체 이니셜라이저의 가독성을 그대로 유지하면서 불변성을 보장한다.
함정과 주의사항
❌ 함정: init은 런타임이 아닌 컴파일 타임 제약이다
리플렉션(Reflection, 런타임에 타입 정보를 분석하고 조작하는 기능)을 사용하면 init 프로퍼티도 변경할 수 있다. modreq는 컴파일러 레벨의 제약이지 런타임 보안 메커니즘이 아니다.
// ❌ 리플렉션으로 init 프로퍼티 우회 가능
var prop = typeof(GameConfig).GetProperty("StageName");
prop!.SetValue(config, "Hacked"); // 실행됨!
보안이 중요한 데이터라면 init만으로는 부족하다. 추가적인 런타임 검증이 필요하다.
C# 버전별 변화
init 접근자는 C# 9.0에서 도입된 기능 자체가 버전별 변화이므로, 도입 전후를 비교한다.
C# 8 이전 — 불변 초기화의 불편함:
// 방법 1: 생성자 — 매개변수가 많아지면 가독성 저하
public class Config
{
public string Name { get; }
public int Max { get; }
public Config(string name, int max) { Name = name; Max = max; }
}
// 방법 2: private set — 클래스 내부에서는 변경 가능 (진정한 불변 아님)
public class Config2
{
public string Name { get; private set; }
}
C# 9 이후 — init으로 해결:
public class Config
{
public string Name { get; init; }
public int Max { get; init; }
}
var c = new Config { Name = "A", Max = 10 }; // 유연한 초기화 + 불변 보장
정리
- ✅
init은 IL에서modreq(IsExternalInit)이 붙은set_메서드다 - ✅ 메서드 본문은 일반
set과 동일 — 런타임 오버헤드 없음 - ✅ 생성자 + 객체 이니셜라이저에서만 호출 가능, 이후 읽기 전용
- ✅ 컴파일 타임 제약이므로 리플렉션으로 우회 가능 — 보안 목적에는 부적합
required (C# 11) — 필수 프로퍼티 강제
문제 제기
init으로 불변성을 확보했지만, 한 가지 문제가 남아 있다. 개발자가 프로퍼티 설정을 깜빡하는 것을 막지 못한다.
public class StageConfig
{
public string StageName { get; init; }
public int MaxPlayers { get; init; }
}
// ❌ StageName을 빠뜨려도 컴파일 성공 — 런타임에 null 참조 에러
var config = new StageConfig { MaxPlayers = 4 };
Console.WriteLine(config.StageName.Length); // NullReferenceException!
생성자에 매개변수를 넣으면 강제할 수 있지만, 그러면 init을 쓰는 의미가 줄어든다. 객체 이니셜라이저의 유연함을 유지하면서 필수 프로퍼티 설정을 컴파일 타임에 강제할 방법이 필요하다.
개념 정의
required— 필수 멤버 한정자 (C# 11) 프로퍼티나 필드에required를 붙이면, 객체를 생성할 때 반드시 객체 이니셜라이저에서 값을 설정해야 한다. 설정하지 않으면 컴파일 오류가 발생한다.
예시:public required string Name { get; init; }new Config { }→ 컴파일 오류,new Config { Name = "A" }→ 성공
내부 동작
required는 컴파일 타임에만 동작하는 순수한 어트리뷰트 기반 제약이다.
public class GameConfig
{
public required string StageName { get; init; }
public required int MaxPlayers { get; init; }
public string? Description { get; set; }
}
// ── 클래스에 RequiredMemberAttribute가 붙는다 ──
.class public auto ansi beforefieldinit GameConfig
{
.custom instance void System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor()
// ── required 프로퍼티에도 RequiredMemberAttribute ──
.property instance string StageName()
{
.custom instance void RequiredMemberAttribute::.ctor() // required 표시
.get instance string GameConfig::get_StageName()
.set instance void modreq(IsExternalInit) GameConfig::set_StageName(string)
}
.property instance int32 MaxPlayers()
{
.custom instance void RequiredMemberAttribute::.ctor() // required 표시
.get instance int32 GameConfig::get_MaxPlayers()
.set instance void modreq(IsExternalInit) GameConfig::set_MaxPlayers(int32)
}
// Description — RequiredMemberAttribute 없음
.property instance string Description()
{
.get instance string GameConfig::get_Description()
.set instance void GameConfig::set_Description(string)
}
}
핵심:
RequiredMemberAttribute: 클래스와 프로퍼티 양쪽에 붙는다. 컴파일러는 이 어트리뷰트를 보고 "이 클래스를 생성할 때 required 프로퍼티가 모두 초기화되었는지 확인하라"고 판단한다.- 런타임 코드 변화 없음:
set_StageName()메서드의 본문은required없는init과 완전히 동일하다.required는 IL 실행 코드에 영향을 주지 않는다 — 메타데이터(어트리뷰트)만 추가된다. set과도 결합 가능:required는init에만 쓸 수 있는 것이 아니다.required string Name { get; set; }처럼set과도 사용 가능하다. 이 경우 초기화는 강제하되 이후 변경은 허용한다.
실전 적용
[SetsRequiredMembers]— 생성자가 모든 필수 멤버를 설정한다는 선언 이 어트리뷰트를 생성자에 붙이면 해당 생성자로 객체를 생성할 때required검사를 건너뛴다. 생성자가 모든 필수 멤버를 초기화한다고 컴파일러에게 약속하는 것이다.
예시:[SetsRequiredMembers] public Config(string name) { Name = name; }
required와 생성자를 함께 사용하는 패턴:
using System.Diagnostics.CodeAnalysis;
public class V11WithCtor
{
public required string Name { get; init; }
[SetsRequiredMembers]
public V11WithCtor(string name)
{
Name = name;
}
public V11WithCtor() { }
}
// 호출 측 IL — 생성자와 이니셜라이저 두 경로 비교
// 경로 1: 객체 이니셜라이저 (required 검사 적용)
IL_002b: newobj instance void V11::.ctor()
IL_0031: ldstr "D"
IL_0036: callvirt instance void modreq(...) V11::set_Name(string) // required 충족
// 경로 2: [SetsRequiredMembers] 생성자 (required 검사 건너뜀)
IL_003d: ldstr "E"
IL_0042: newobj instance void V11WithCtor::.ctor(string) // 생성자가 책임짐
[SetsRequiredMembers]가 붙은 생성자를 호출하면 컴파일러가 required 검사를 하지 않는다. 생성자 내부에서 모든 required 멤버를 설정한다고 개발자가 보장해야 한다.
함정과 주의사항
❌ 함정 1: [SetsRequiredMembers]는 실제 설정을 검증하지 않는다
[SetsRequiredMembers]
public V11WithCtor(string name)
{
// Name = name; ← 실수로 빠뜨려도 컴파일 성공!
}
컴파일러는 [SetsRequiredMembers]를 신뢰할 뿐, 실제로 모든 required 멤버가 설정되었는지 검사하지 않는다. 이 어트리뷰트는 "내가 책임진다"는 약속이지 자동 검증이 아니다.
❌ 함정 2: required는 JSON 직렬화에서 자동 검증되지 않는다
// required는 컴파일 타임 전용 — JSON 역직렬화 시에는 무시됨
var json = "{ }";
var config = JsonSerializer.Deserialize<GameConfig>(json);
// config.StageName은 null — 런타임 오류 가능
required는 컴파일러가 new를 사용하는 코드를 분석할 때만 동작한다. JSON 역직렬화처럼 리플렉션 기반으로 객체를 생성하는 경우에는 required 검사가 이루어지지 않는다. 외부 입력 데이터에 대해서는 별도의 유효성 검사가 필요하다.
❌ 함정 3: 상속 시 required 멤버 누적
public class Base
{
public required string Id { get; init; }
}
public class Derived : Base
{
public required string Name { get; init; }
}
// Id와 Name 모두 설정해야 함
var d = new Derived { Id = "001", Name = "Player" };
// var d2 = new Derived { Name = "Player" }; // ❌ Id 누락 — 컴파일 오류
파생 클래스를 생성할 때 기반 클래스의 required 멤버도 모두 초기화해야 한다. 상속 계층이 깊어지면 초기화해야 할 프로퍼티가 늘어나므로 주의가 필요하다.
C# 버전별 변화
required는 C# 11에서 도입된 기능 자체가 버전별 변화다. 도입 이전에는 필수 초기화를 강제하려면 생성자 매개변수가 유일한 방법이었다.
C# 10 이전:
public class Config
{
public string Name { get; init; }
// 필수 초기화 강제를 위해 생성자를 만들어야 했음
public Config(string name) { Name = name; }
}
C# 11 이후:
public class Config
{
public required string Name { get; init; }
// 생성자 없이도 필수 초기화 강제
}
var c = new Config { Name = "A" }; // 객체 이니셜라이저로 깔끔하게
정리
- ✅
required는RequiredMemberAttribute를 클래스와 프로퍼티에 추가하는 컴파일 타임 전용 제약 - ✅ IL 실행 코드에 변화 없음 — 메타데이터만 추가
- ✅
[SetsRequiredMembers]생성자는 required 검사를 건너뛰지만, 실제 초기화는 개발자 책임 - ✅ 리플렉션/JSON 역직렬화에서는 동작하지 않음 — 외부 입력은 별도 검증 필요
- ✅ 상속 시 기반 클래스의 required 멤버도 누적됨
정리
| 기능 | C# 버전 | IL 핵심 | 용도 |
|---|---|---|---|
| 프로퍼티 (get/set) | 1.0 | callvirt get_/set_ 메서드 생성 |
캡슐화, 유효성 검사 |
| 자동 구현 프로퍼티 | 3.0 | <Name>k__BackingField 자동 생성 |
보일러플레이트 제거 |
| getter-only + 초기화 | 6.0 | 백킹 필드에 initonly |
불변 프로퍼티 |
| init 접근자 | 9.0 | modreq(IsExternalInit) |
객체 이니셜라이저 + 불변성 |
| required | 11 | RequiredMemberAttribute |
필수 초기화 강제 |
체크리스트:
- ✅ 외부에 노출하는 데이터는 필드가 아닌 프로퍼티로 선언하라
- ✅ 단순 저장 용도면 auto property, 유효성 검사가 필요하면 수동 백킹 필드
- ✅ 생성 후 변경되면 안 되는 데이터에는
init - ✅ 반드시 설정해야 하는 데이터에는
required - ✅ Unity에서는
[SerializeField]private 필드 + public 프로퍼티 조합이 표준 - ✅ 프로퍼티 성능 걱정은 불필요 — JIT 인라이닝으로 필드와 동일
'C# 심화' 카테고리의 다른 글
| struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.01 |
|---|---|
| string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.01 |
| string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 (0) | 2026.03.31 |
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
