[PART7.클래스와 객체 입문(4/21)] field 키워드 — 백킹 필드 선언 없이 자동 프로퍼티에 검증 로직 넣기 (C# 14)
자동 프로퍼티에 검증 로직을 넣고 싶을 때 옛날엔 private int _x 명시 선언 필수 / C# 14의 field 키워드는 접근자 본문 안에서 컴파일러가 자동 생성하는 백킹 필드를 직접 참조 / IL은 <Property>k__BackingField(자동 프로퍼티와 동일 이름)로 컴파일 — 가독성 신택스 슈가, 런타임 비용 0 / ?? throw 패턴이 IL dup+brtrue+throw로 풀림 / Lazy 초기화 패턴이 한 줄로 / field라는 이름의 실제 필드와 충돌 시 @field/this.field
목차
1. 자동 프로퍼티에 검증 한 줄 넣기 — 옛 보일러플레이트
C# 13까지는 자동 프로퍼티 setter에 검증 로직 한 줄을 넣고 싶으면 백킹 필드를 직접 선언해야 했습니다.
// 옛 패턴 — C# 13 이하
public class Player
{
private string _name; // 백킹 필드 명시 선언
public string Name
{
get => _name; // getter도 본문 작성
set => _name = value ?? throw new ArgumentNullException(nameof(value));
}
}
자동 프로퍼티(public string Name { get; set; }) 한 줄이면 끝날 코드가 세 줄짜리 보일러플레이트가 됩니다. setter에 검증 로직 하나 넣고 싶을 뿐인데 _name 이름을 만들고, getter 본문도 같이 써야 하죠.
C# 14가 이 자리를 정리합니다.
// C# 14 — field 키워드
public class Player
{
public string Name
{
get; // 자동
set => field = value ?? throw new ArgumentNullException(nameof(value));
}
}
_name 선언 없음. getter는 자동. setter만 본문을 쓰면서 컴파일러가 자동 생성하는 백킹 필드를 field 키워드로 직접 참조합니다.
이 글은 field 키워드가 IL 레벨에서 어떻게 동작하는지(자동 프로퍼티의 백킹 필드와 같은지), 어떤 자리에서 빛나는지(Lazy 패턴), 어떤 함정이 있는지(이름 충돌)까지 다룹니다.
2. field 키워드란 무엇인가
비유 — 자동으로 생기는 비서
자동 프로퍼티는 마치 비서를 자동 고용하는 것과 같습니다. public int X { get; set; }이라고 쓰면 컴파일러가 <X>k__BackingField라는 비서를 자동으로 고용해 데이터를 보관합니다. 옛날에는 비서의 이름을 알 수 없어 직접 새 비서(_x)를 고용해야 했지만, C# 14부터는 field라는 약속된 이름으로 그 자동 비서를 직접 부를 수 있게 됐죠.
상황별 키워드(Contextual Keyword)
field는 새로 추가된 예약어가 아니라 상황별 키워드(Contextual Keyword)입니다. 즉:
- 프로퍼티 접근자 본문 안에서만 백킹 필드를 가리키는 특별한 의미를 가짐
- 그 외 자리에서는 일반 식별자로 사용 가능 (변수명·메서드명 등)
이 설계 덕분에 기존 코드의 field라는 변수가 깨지지 않습니다 — 단, 클래스 내부에 field라는 이름의 실제 필드가 있으면 충돌하므로 @field 또는 this.field로 구분해야 합니다 (함정 섹션에서 다룸).
사용 자리
public string Name
{
get; // ✅ 자동
set => field = ProcessInput(value); // ✅ field 사용 가능
}
public int Computed
{
get => field == 0 ? (field = Compute()) : field; // ✅ get에서도 field 사용
}
public int Both
{
get => field * 2; // ✅
set => field = value < 0 ? 0 : value; // ✅
}
get/set/init 접근자의 본문 안에서만 field가 백킹 필드를 가리킵니다.
3. IL로 본 field 키워드 — 자동 프로퍼티와 같은 백킹 필드
C# 14의 field 키워드와 옛 명시 선언 패턴의 IL을 비교해 봅니다.
// 1) C# 14 신문법
public string Message
{
get;
set => field = value ?? throw new ArgumentNullException(nameof(value));
}
// 2) 옛 패턴 (비교)
private string _legacyMsg;
public string LegacyMessage
{
get => _legacyMsg;
set => _legacyMsg = value ?? throw new ArgumentNullException(nameof(value));
}
// ================ Message — C# 14 field 키워드 ================
.field private string '<Message>k__BackingField' // ← 자동 생성, 자동 프로퍼티와 같은 이름
.custom CompilerGeneratedAttribute
.custom DebuggerBrowsableAttribute(Never)
// get_Message — 자동 (사용자가 본문 안 씀)
.method public hidebysig specialname instance string get_Message()
{
IL_0000: ldarg.0
IL_0001: ldfld string FieldKeywordDemo::'<Message>k__BackingField'
IL_0006: ret
}
// set_Message — 사용자가 작성한 본문, field → <Message>k__BackingField로 컴파일
.method public hidebysig specialname instance void set_Message(string 'value')
{
IL_0000: ldarg.0
IL_0001: ldarg.1 // value
IL_0002: dup // 스택 복제 — ?? 패턴 시작
IL_0003: brtrue.s IL_0011 // null 아니면 분기
IL_0005: pop // null이면 스택 정리
IL_0006: ldstr "value"
IL_000b: newobj instance void ArgumentNullException::.ctor(string)
IL_0010: throw
IL_0011: stfld string FieldKeywordDemo::'<Message>k__BackingField' // ← field가 여기 매핑
IL_0016: ret
}
// ================ LegacyMessage — 옛 명시 선언 ================
.field private string _legacyMsg // 사용자 명시
// set_LegacyMessage — IL이 set_Message와 본문 동일!
.method public hidebysig specialname instance void set_LegacyMessage(string 'value')
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: dup
IL_0003: brtrue.s IL_0011
IL_0005: pop
IL_0006: ldstr "value"
IL_000b: newobj instance void ArgumentNullException::.ctor(string)
IL_0010: throw
IL_0011: stfld string FieldKeywordDemo::_legacyMsg // ← 사용자 필드 이름
IL_0016: ret
}
핵심 두 가지:
- 백킹 필드 이름이 다를 뿐 IL 본문은 100% 동일 —
<Message>k__BackingFieldvs_legacyMsg. 컴파일러가 자동으로 만든 필드 이름이 우리가 직접 쓴_legacyMsg와 명명 규칙만 다르고, 그 외 모든 것이 같습니다. field키워드는 순수 가독성 신택스 슈가 — 런타임 비용 0, IL 명령 수 동일. 그저 코드 한 줄을 줄여 주는 컴파일러 변환입니다.
?? throw IL 패턴 — dup + brtrue + throw
IL_0002: dup // value를 스택에 복제
IL_0003: brtrue.s IL_0011 // 복제본이 null 아니면 점프
IL_0005: pop // null이면 복제본 버림
IL_0006: ldstr "value"
IL_000b: newobj ArgumentNullException::.ctor(string)
IL_0010: throw
IL_0011: stfld ... // null 아닌 경우만 저장
x ?? throw new Exception(...) 패턴이 IL에서는 dup + brtrue + throw로 풀립니다. 스택에 값을 복제해 둔 뒤 null 체크하고, null이면 예외를 던지고 아니면 저장. 매우 효율적인 패턴이라 null 검증 비용은 분기 한 번 + 정상 시 추가 명령 0입니다.
4. field가 가장 빛나는 자리 — Lazy 초기화 패턴
public class ExpensiveResource
{
public int Computed
{
get => field == 0 ? (field = ExpensiveCompute()) : field;
}
private int ExpensiveCompute() => /* 비싼 계산 */ 42;
}
field가 0이면 비싼 계산을 한 번 실행하고 결과를 field에 캐싱, 두 번째부터는 캐시된 값을 반환. Lazy 초기화 패턴이 한 줄입니다.
// get_Computed — Lazy 패턴 IL
IL_0000: ldarg.0
IL_0001: ldfld <Computed>k__BackingField // field 첫 로드
IL_0006: brfalse.s IL_0010 // 0이면 분기 (계산 필요)
IL_0008: ldfld <Computed>k__BackingField // 0 아니면 캐시 값 반환
IL_000e: br.s IL_001f
IL_0010: call ExpensiveCompute() // 비싼 계산 실행
IL_0017: dup // 결과 스택 복제
IL_0018: stloc.0 // 임시 저장
IL_0019: stfld <Computed>k__BackingField // 캐시
IL_001e: ldloc.0 // 결과 반환
IL_001f: ret
옛 패턴이라면 다음과 같았겠죠:
// 옛 패턴 — 별도 백킹 필드 + 두 줄짜리 본문
private int _computed;
public int Computed
{
get
{
if (_computed == 0) _computed = ExpensiveCompute();
return _computed;
}
}
3줄 vs 1줄의 차이. C# 14의 field가 진짜로 빛나는 자리입니다.
⚠️ Lazy 패턴의 함정 — 0이 유효한 값일 때 위 코드는Computed의 초기값과 "계산 안 한 상태"를 구분하지 못합니다. 0이 유효한 값이라면 별도의_computed_initialized플래그가 필요합니다. 진짜로 thread-safe한 lazy가 필요하면Lazy<T>클래스 사용을 검토하세요.
5. 다른 패턴들 — field로 더 깔끔해지는 자리
패턴 1 — 범위 클램프
// 옛
private int _hp;
public int Hp
{
get => _hp;
set => _hp = Math.Clamp(value, 0, 100);
}
// C# 14
public int Hp
{
get;
set => field = Math.Clamp(value, 0, 100);
}
패턴 2 — 변경 감지 + 이벤트 발생
// 옛
private string _status;
public event Action<string>? StatusChanged;
public string Status
{
get => _status;
set
{
if (_status == value) return;
_status = value;
StatusChanged?.Invoke(value);
}
}
// C# 14
public event Action<string>? StatusChanged;
public string Status
{
get;
set
{
if (field == value) return;
field = value;
StatusChanged?.Invoke(value);
}
}
INotifyPropertyChanged 같은 데이터 바인딩 패턴에서 보일러플레이트가 크게 줄어듭니다.
패턴 3 — 정규화
// 옛
private string _email;
public string Email
{
get => _email;
set => _email = value?.Trim().ToLowerInvariant();
}
// C# 14
public string Email
{
get;
set => field = value?.Trim().ToLowerInvariant();
}
패턴 4 — init과 결합 (불변 객체)
public class Order
{
public string Id
{
get;
init => field = value ?? throw new ArgumentNullException(nameof(value));
}
}
var o = new Order { Id = "ABC123" }; // OK, null 검증
// o.Id = "X"; // ❌ init이라 객체 초기화 후 변경 불가
init 접근자(C# 9, PART 7 #5)와 결합하면 객체 초기화 시점에만 검증된 값을 한 번 설정하는 패턴이 한 줄로 만들어집니다.
6. 실전 적용 — Unity의 INotifyPropertyChanged·바인딩 패턴
Unity UI Toolkit이나 MVVM 프레임워크에서 데이터 바인딩을 쓰면 INotifyPropertyChanged를 자주 구현해야 합니다.
Before/After
// ❌ Before — C# 13 이하, 보일러플레이트 가득
public class HealthViewModel : INotifyPropertyChanged
{
private int _hp;
public int Hp
{
get => _hp;
set
{
if (_hp == value) return;
_hp = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hp)));
}
}
private float _stamina;
public float Stamina
{
get => _stamina;
set
{
if (Math.Abs(_stamina - value) < 0.001f) return;
_stamina = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Stamina)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
// ✅ After — C# 14 field 키워드 + 헬퍼 메서드
public class HealthViewModel : INotifyPropertyChanged
{
public int Hp
{
get;
set => SetField(ref field, value, nameof(Hp));
}
public float Stamina
{
get;
set => SetField(ref field, value, nameof(Stamina));
}
public event PropertyChangedEventHandler? PropertyChanged;
private void SetField<T>(ref T storage, T value, string propertyName)
{
if (EqualityComparer<T>.Default.Equals(storage, value)) return;
storage = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
ViewModel의 프로퍼티 정의가 6줄에서 4줄로 줄고, 백킹 필드 이름을 짓는 부담이 사라집니다. 10개 프로퍼티가 있으면 누적 차이가 큽니다.
Unity 모바일 GC 특수성
field 키워드 자체는 GC와 완전 무관합니다 — 컴파일러가 만드는 백킹 필드는 옛날 명시 선언과 IL 본문이 동일하니까요. 다만 setter 안의 알록(new PropertyChangedEventArgs(...) 같은)은 매 호출 GC 부담이 됩니다.
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터)의 조합이라 알록 한 번이 5~15ms 프레임 스파이크입니다. 핫패스의 setter는 PropertyChangedEventArgs를 캐싱하거나 변경 감지로 빈도를 줄이는 패턴이 표준입니다.
7. 함정과 주의사항
함정 1 — field라는 이름의 실제 필드와 충돌
public class MyClass
{
private int field = 100; // 사용자가 만든 필드
public int Value
{
get => field; // ⚠️ 새 키워드로 해석 — 자동 생성 백킹 필드
set => field = value;
}
}
C# 14 컴파일러는 접근자 본문 안에서 field를 새 키워드로 해석합니다. 위 코드의 get => field는 사용자가 만든 private int field가 아니라 컴파일러 자동 생성 백킹 필드를 가리키게 됩니다. 의도와 다른 동작이죠.
// ✅ 옛 식별자 명확히 참조 — @field 또는 this.field
public int Value
{
get => @field; // 사용자 필드
set => this.field = value; // 사용자 필드
}
@ 접두사는 키워드를 일반 식별자로 강제하는 C# 문법(예약어인 class도 @class로 변수명 가능)이고, this.field는 명시적 인스턴스 멤버 접근. 둘 다 사용자가 만든 field를 참조합니다.
가장 좋은 방법은 변수 이름을 바꾸는 것 — field라는 이름을 일반 식별자로 쓰지 않는 게 가장 안전합니다.
함정 2 — 접근자 본문 밖에서는 field가 일반 식별자
public class MyClass
{
public void DoSomething(int field) // ✅ 매개변수 이름 OK
{
var field2 = field + 1; // ✅ 일반 식별자로 자유롭게 사용
}
}
field는 상황별 키워드라 프로퍼티 접근자 본문 밖에서는 일반 식별자입니다. 매개변수·지역 변수 이름 등 어디든 자유롭게 사용 가능. 다만 가독성을 위해 피하시는 걸 권장합니다.
함정 3 — field 키워드는 자동 프로퍼티에서만 의미
public int X
{
get => field; // ✅ 자동 백킹 필드 사용
set => field = value;
}
public int Y
{
get => _y; // ❌ 자동 백킹 필드를 안 쓰면 field 키워드도 의미 없음
set => _y = value; // (오류는 아니지만 _y와 field가 별도 필드)
}
setter나 getter 한쪽에서 field를 쓰면 컴파일러가 백킹 필드를 자동 생성합니다. 다른 쪽에서 _y 같은 사용자 필드를 쓰면 두 필드가 별도로 존재하는 이상한 상태가 됩니다. 한 프로퍼티 안에서는 일관성을 유지하세요.
함정 4 — Unity 직렬화는 자동 백킹 필드를 인식 못 함
public class Player : MonoBehaviour
{
public int Hp // ⚠️ 인스펙터에 안 보임
{
get;
set => field = Math.Clamp(value, 0, 100);
}
}
Unity의 직렬화 시스템은 자동 백킹 필드(<Hp>k__BackingField)를 인식하지 못합니다. 인스펙터 노출이 필요하면 [SerializeField] private + 별도 프로퍼티 패턴(PART 7 #3 §8)을 그대로 사용해야 합니다.
// ✅ Unity 표준 패턴 — field 키워드와 무관
[SerializeField] private int _hp = 100;
public int Hp
{
get => _hp;
set => _hp = Math.Clamp(value, 0, 100);
}
함정 5 — Lazy 패턴의 0/null 모호성
public int Computed
{
get => field == 0 ? (field = ExpensiveCompute()) : field;
}
ExpensiveCompute()가 0을 반환하면 영원히 재계산이 일어납니다. 0이 유효한 값이 될 수 있다면 별도 플래그가 필요합니다.
public int Computed
{
get
{
if (!_computedSet) { field = ExpensiveCompute(); _computedSet = true; }
return field;
}
}
private bool _computedSet;
또는 nullable 타입(int?) 사용:
public int Computed
{
get => field ??= ExpensiveCompute();
}
// 단, public 프로퍼티 타입을 int?로 바꿔야 함
8. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | 명시 백킹 필드 + 수동 get/set 본문 | 기본 |
| 3.0 | 자동 구현 프로퍼티 { get; set; } |
백킹 필드 자동 |
| 6.0 | 표현식 본문 => _x |
|
| 6.0 | { get; } 읽기 전용 (initonly) |
PART 7 #3 |
| 7.0 | 표현식 본문 setter set => _x = value |
|
| 9.0 | init 접근자 { get; init; } |
PART 7 #5 |
| 11.0 | required 멤버 |
PART 7 #6 |
| 14.0 | field 키워드 — 백킹 필드 직접 사용 |
가독성 향상 |
C# 14의 field가 자동 프로퍼티 진화의 마지막 퍼즐 조각이라고 볼 수 있습니다. C# 3.0에서 시작해 11번의 마이너 업데이트를 거쳐 마침내 "검증 로직 한 줄을 위해 백킹 필드를 직접 선언해야 했던 보일러플레이트"가 사라졌죠.
C# 13 미리 보기 vs C# 14 정식 field 키워드는 C# 13에서 미리 보기로 도입되었고, C# 14에서 정식 기능이 됐습니다. .NET 10 SDK 이상에서 사용 가능합니다. Unity는 6.0 이후 점진적으로 C# 14를 지원하기 시작합니다.
9. 정리
- [ ]
field키워드는 프로퍼티 접근자 본문 안에서 자동 생성된 백킹 필드를 참조하는 C# 14 신문법. - [ ] 상황별 키워드(Contextual Keyword) — 접근자 본문 밖에서는 일반 식별자.
- [ ] 백킹 필드 이름은 자동 프로퍼티와 동일 (
<Property>k__BackingField) — IL 본문도 옛 명시 선언 패턴과 100% 동일. - [ ] 순수 가독성 신택스 슈가 — 런타임 비용 0, 단지 코드 한 줄을 줄여 줌.
- [ ] 가장 빛나는 자리는 Lazy 초기화 패턴 —
field == 0 ? (field = Compute()) : field한 줄. - [ ]
?? throw패턴이 IL에서는dup+brtrue+throw로 풀림 — null 검증 비용이 매우 작음. - [ ] 자주 쓰는 패턴: 범위 클램프, 변경 감지 + 이벤트 발생, 정규화(Trim·ToLower),
init과 결합한 불변 검증. - [ ] 함정 1 —
field이름의 사용자 필드와 충돌:@field또는this.field로 명시. 가장 좋은 해결책은 변수 이름 변경. - [ ] 함정 2 — Unity 직렬화는 자동 백킹 필드 인식 X — 인스펙터 노출은
[SerializeField] private패턴 그대로. - [ ] 함정 3 — Lazy 패턴의 0 모호성 — 0이 유효한 값이면 별도 플래그 또는 nullable 타입.
- [ ] INotifyPropertyChanged·MVVM 같은 반복 setter 코드의 보일러플레이트 감소에 최적.
- [ ] C# 14 정식 기능, .NET 10 SDK 필요. Unity는 6.x 이후 점진 지원.
- [ ] 더 깊은 신문법(
init접근자·required멤버·Primary Constructor)은 PART 7 #5·#6·#11에서 다룬다.
'C# 기초' 카테고리의 다른 글
| [PART7.클래스와 객체 입문(6/21)] required 멤버 — 반드시 초기화해야 하는 프로퍼티 (C# 11) (0) | 2026.05.01 |
|---|---|
| [PART7.클래스와 객체 입문(5/21)] `init` 접근자 — 객체 초기화자의 편의성과 불변성을 동시에 (C# 9) (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(3/21)] 프로퍼티 — `get`/`set`이 IL에서 만드는 메서드 한 쌍 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(2/21)] 필드와 접근 제한자 — 7가지 한정자가 IL에서 만드는 6개 집합 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(1/21)] 클래스 선언과 객체 생성 — `new` 한 줄에 숨은 다섯 단계 (0) | 2026.05.01 |