[PART7.클래스와 객체 입문(3/21)] 프로퍼티 — get/set이 IL에서 만드는 메서드 한 쌍
프로퍼티는 IL 레벨에서 메서드 / public int X { get; set; } 한 줄 = <X>k__BackingField private 필드 + get_X(7B) + set_X(8B) + .property 메타데이터 / { get; } = initonly 백킹 필드 / { get; private set; } = setter만 private / 호출 측은 callvirt get_X 메서드 호출 (필드는 ldfld 단일 명령) / JIT가 자동 프로퍼티 인라이닝해 필드와 동등 / 검증 로직 있는 setter는 IL 3배 → 인라이닝 실패 가능
목차
1. 필드를 그대로 쓰면 안 되는 이유
신입 코드에 자주 보이는 패턴.
public class Player
{
public int Health = 100; // 필드
public string Name;
}
// 다른 코드
player.Health = -999; // ⚠️ 음수도 들어감
player.Health = int.MaxValue; // ⚠️ 오버플로
player.Name = null; // ⚠️ null 체크 없음
public 필드는 누구나 검증 없이 변경 가능. 음수 체력, null 이름, 오버플로 — 모든 사고의 출발점입니다. 더 큰 문제는 나중에 검증 로직을 넣고 싶어 프로퍼티로 바꾸면 외부 어셈블리가 모두 깨집니다 (PART 7 #2에서 다룬 바이너리 호환성 함정).
해결책은 처음부터 프로퍼티로 작성하는 것.
public class Player
{
public int Health { get; set; } = 100; // 자동 프로퍼티
public string Name { get; set; } = ""; // null 방지
private int _hp;
public int Hp // 검증 로직 있는 프로퍼티
{
get => _hp;
set => _hp = Math.Clamp(value, 0, 100);
}
}
이 글은 프로퍼티가 IL 레벨에서 어떻게 컴파일되는지, 자동 프로퍼티의 백킹 필드 메커니즘, JIT 인라이닝과 성능, 그리고 Unity의 SerializeField 패턴까지 한자리에서 다룹니다.
2. 프로퍼티란 무엇인가
비유 — 은행 창구와 직접 금고 접근
필드는 금고의 자물쇠를 풀고 직접 손을 넣는 것이고, 프로퍼티는 은행 창구입니다. 창구 직원(getter/setter)이 신분증을 확인하고, 한도를 검증하고, 거래 기록을 남깁니다. 같은 인출이라도 직접 금고 접근(필드)과 창구 거래(프로퍼티)는 신뢰성·감사 가능성이 완전히 다릅니다.
C#의 프로퍼티는 외부에서는 필드처럼 보이지만 내부적으로는 메서드입니다. obj.Health = 100이라는 한 줄이 컴파일 후에는 obj.set_Health(100)이라는 메서드 호출이 됩니다.
자동 구현 프로퍼티 — 가장 단순한 형태
public class Player
{
public int Auto { get; set; } // 자동 프로퍼티
}
이 한 줄은 컴파일러가 세 가지 항목을 자동 생성합니다.
세 가지를 모두 본 IL이 다음 섹션입니다.
3. IL로 본 자동 프로퍼티의 진짜 정체
public class PropertyDemo
{
public int Auto { get; set; }
}
// 1) 백킹 필드 — private + initonly 없음 (set이 있으니 변경 가능)
.field private int32 '<Auto>k__BackingField'
.custom CompilerGeneratedAttribute // 컴파일러가 만들었다는 표식
.custom DebuggerBrowsableAttribute(Never) // 디버거에서 숨김
// 2) get_Auto — IL 7바이트
.method public hidebysig specialname instance int32 get_Auto() cil managed
{
IL_0000: ldarg.0 // this
IL_0001: ldfld int32 PropertyDemo::'<Auto>k__BackingField' // 백킹 필드 로드
IL_0006: ret
}
// 3) set_Auto — IL 8바이트
.method public hidebysig specialname instance void set_Auto(int32 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1 // value 파라미터
IL_0002: stfld int32 PropertyDemo::'<Auto>k__BackingField' // 백킹 필드에 저장
IL_0007: ret
}
// 4) 프로퍼티 메타데이터
.property instance int32 Auto()
{
.get instance int32 PropertyDemo::get_Auto()
.set instance void PropertyDemo::set_Auto(int32)
}
핵심 다섯 가지:
- 백킹 필드 이름
<Auto>k__BackingField: 꺾쇠 괄호<>와k__접두사는 C# 식별자로 합법이 아닙니다. 컴파일러가 사용자 코드와의 충돌을 원천 차단하기 위해 일부러 부적합한 이름을 씁니다. 즉 사용자가 같은 이름의 필드를 만들 가능성이 0. specialname플래그: getter/setter 메서드는 모두specialname플래그가 붙어 컴파일러가 "이건 프로퍼티 접근자다"를 인식할 수 있게 합니다. 직접get_Auto()메서드를 호출하면 컴파일러가 막습니다..property메타데이터: getter/setter 메서드만으로는 프로퍼티가 아닙니다..property항목이 두 메서드를 한 쌍으로 묶어 "이건 Auto라는 프로퍼티다"를 선언해야 IDE/리플렉션이 인식합니다.[CompilerGeneratedAttribute]+[DebuggerBrowsable(Never)]: 컴파일러가 만든 백킹 필드라는 표식과, 디버거가 사용자에게 보여주지 않도록 하는 표식. 개발자 경험을 깔끔하게 유지하기 위한 메타데이터.- getter 7바이트, setter 8바이트: 매우 작은 본문이라 JIT 인라이닝 대상.
4. 호출 측 IL — 필드 ldfld vs 프로퍼티 callvirt
public class A { public int X; } // 필드
public class B { public int X { get; set; } } // 자동 프로퍼티
public static int UseField(A d) => d.X;
public static int UseProperty(B d) => d.X;
// UseField — 필드 접근
IL_0000: ldarg.0
IL_0001: ldfld int32 A::X // ← IL 단일 명령 (5바이트)
IL_0006: ret
// UseProperty — 프로퍼티 접근
IL_0000: ldarg.0
IL_0001: callvirt instance int32 B::get_X() // ← 메서드 호출 (5바이트)
IL_0006: ret
IL 명령어 자체가 다릅니다. 두 가지 의미:
- 소스 코드는 동일해도 IL은 호환되지 않음 —
public int X;를public int X { get; set; }로 바꾸면 외부 어셈블리는 모두 다시 컴파일해야 합니다. PART 7 #2에서 본 바이너리 호환성 함정의 IL 차원 원인. - JIT 인라이닝의 결과 차이 — JIT 컴파일러는 자동 프로퍼티의 단순 getter/setter를 거의 항상 인라이닝합니다. 인라이닝 후 기계 코드는 필드 접근과 동등해지지만, IL 차원에서는 항상 다릅니다.
callvirt vs call의 작은 차이
callvirt는 가상 메서드 호출 명령어이지만, 실제로는 null 체크를 강제로 수행하는 안전성 메커니즘입니다. d.X에서 d가 null이면 callvirt가 NullReferenceException을 던집니다. 필드 접근(ldfld)도 null이면 같은 예외를 던지지만 명령어 차원에서 약간 다른 경로입니다. 일반적인 코드에서는 차이를 신경 쓸 필요 없지만, JIT 최적화 결과가 미세하게 갈리는 자리가 됩니다.
5. 읽기 전용 프로퍼티 — { get; } vs { get; private set; }
C# 6.0 이전에는 진정한 의미의 읽기 전용 자동 프로퍼티가 없었고, { get; private set; } 패턴이 표준이었습니다. C# 6.0이 { get; }을 추가했습니다.
public class Order
{
public int Id { get; } // C# 6+ 진짜 읽기 전용
public DateTime Created { get; private set; } // 옛 패턴
public Order(int id) { Id = id; Created = DateTime.UtcNow; }
}
IL 차이
// Id — { get; } : 백킹 필드가 initonly (= readonly)
.field private initonly int32 '<Id>k__BackingField' // ← initonly 키워드
.method public ... instance int32 get_Id() { ... ldfld ... }
// setter 메서드 자체가 만들어지지 않는다!
// Created — { get; private set; } : setter는 있지만 private
.field private int32 '<Created>k__BackingField' // initonly 없음
.method public ... instance DateTime get_Created() { ... }
.method private ... instance void set_Created(DateTime) { ... } // ← 메서드는 private
핵심 차이 두 가지:
| 항목 | { get; } |
{ get; private set; } |
|---|---|---|
| 백킹 필드 | initonly (readonly) |
일반 |
| setter 메서드 | 없음 | private로 존재 |
| 클래스 내부에서 변경 | 생성자/이니셜라이저에서만 | 어디서든 변경 가능 |
| 진정한 불변성 | ✓ | ✗ |
{ get; }은 진정한 불변 객체의 첫걸음입니다. 클래스 내부의 다른 메서드도 변경할 수 없으므로 객체가 만들어진 후 상태가 절대 바뀌지 않음을 보장합니다. 멀티스레드 안전, 캐싱 친화, Dictionary 키로 안전 — 이런 모든 이점이 따라옵니다.
C# 9의 init 접근자(PART 7 #5)가 이 자리를 더 정교하게 만듭니다. 객체 초기화자(new Order { Id = 1 })에서도 값을 설정할 수 있게 하면서 그 이후로는 불변성을 유지하는 패턴이죠.
6. 검증 로직 있는 프로퍼티 — IL이 길어진다
public class Player
{
private int _hp;
public int Hp
{
get => _hp;
set => _hp = value < 0 ? 0 : (value > 100 ? 100 : value);
}
}
// set_Hp — 24바이트 (자동 프로퍼티 8바이트의 3배)
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldc.i4.0
IL_0003: blt.s IL_0011 // value < 0 분기
IL_0005: ldarg.1
IL_0006: ldc.i4.s 100
IL_0008: bgt.s IL_000d // value > 100 분기
IL_000a: ldarg.1
IL_000b: br.s IL_000f
IL_000d: ldc.i4.s 100 // value > 100 → 100
IL_000f: br.s IL_0013
IL_0011: ldc.i4.0 // value < 0 → 0
IL_0013: stfld int32 Player::_hp
IL_0017: ret
JIT 인라이닝 가능성이 떨어집니다. JIT 컴파일러는 메서드 본문 크기에 임계값을 두고, 너무 크면 인라이닝을 포기합니다. 24바이트는 안전선 근처라 상황에 따라 인라이닝이 안 될 수 있고, 메서드 호출 비용이 그대로 남습니다.
Update 안에서 매 프레임 obj.Hp = newVal을 한다면:
- 인라이닝 됨 → 분기 +
stfld로 끝 - 인라이닝 안 됨 → 메서드 호출 + 분기 +
stfld+ 리턴
큰 차이는 아니지만 핫패스에서 누적되면 의미가 있습니다. 검증 로직을 setter에 넣을 가치가 있는지 한 번 더 고민하시고, 검증이 단순하지 않다면 (SetHp(int newVal) 같은) 명시적 메서드가 더 명확할 수 있습니다.
7. 표현식 본문 프로퍼티 — 가독성 신문법
C# 6.0이 표현식 본문(=>) 형태를 도입했고, C# 7.0이 setter까지 확장했습니다.
// 옛 방식
public class Order
{
private int _qty;
public int Quantity
{
get { return _qty; }
set { _qty = value; }
}
public decimal Total
{
get { return _qty * UnitPrice; }
}
}
// C# 6+ / 7+ — 표현식 본문
public class Order
{
private int _qty;
public int Quantity
{
get => _qty; // C# 6+
set => _qty = value; // C# 7+
}
public decimal Total => _qty * UnitPrice; // 계산된 프로퍼티
}
IL은 동일합니다. 순수 가독성 신문법으로, 한 줄짜리 본문에서 중괄호와 return 키워드를 생략할 수 있게 했습니다.
Total => _qty * UnitPrice 같은 계산된 프로퍼티(getter만 있고 백킹 필드 없음)는 _qty나 UnitPrice가 변경될 때마다 계산을 다시 합니다. 호출 비용이 0이 아니므로 매 프레임 호출되는 자리에서 비싼 계산이 들어 있다면 캐싱을 고려하시기 바랍니다.
8. 실전 적용 — Unity의 [SerializeField] private + 프로퍼티 패턴
Unity에서 인스펙터 노출과 캡슐화를 동시에 만족시키는 표준 패턴입니다.
Before/After: 인스펙터 노출과 캡슐화 동시 달성
// ❌ Before — public 필드, 캡슐화 위반
public class Player : MonoBehaviour
{
public int health = 100; // 인스펙터에 보이지만 외부 스크립트도 마음대로 변경
public int score;
}
// ✅ After — [SerializeField] private + 프로퍼티
public class Player : MonoBehaviour
{
[SerializeField] private int health = 100;
[SerializeField] private int score;
// 외부 읽기는 표현식 본문 프로퍼티로
public int Health => health;
public int Score => score;
// 외부 변경은 검증 메서드를 통해서만
public void TakeDamage(int amount)
{
if (amount <= 0) return;
health = Mathf.Max(0, health - amount);
if (health == 0) Die();
}
public void AddScore(int points)
{
if (points <= 0) return;
score += points;
}
}
핵심 두 가지:
[SerializeField] private+public T Prop => _field— 캡슐화(private)와 직렬화(SerializeField)와 외부 읽기(public프로퍼티)가 IL 레벨에서 모두 직교.- 변경은 검증 메서드를 통해서만 —
TakeDamage/AddScore같은 의도가 명확한 메서드로 외부 인터페이스를 만들면 코드 의도가 살아납니다. setter에 검증 로직을 넣는 것보다 메서드 이름이 의도를 더 잘 표현합니다.
자동 프로퍼티 + 직렬화 — Unity의 한계
Unity의 직렬화 시스템은 자동 프로퍼티의 백킹 필드를 직렬화하지 못합니다. <Health>k__BackingField 같은 이름은 Unity 직렬화기가 인식하지 못하기 때문이죠. 그래서 인스펙터 노출이 필요한 데이터는 항상 명시적 필드(private int health)로 작성하고, 외부 인터페이스는 별도 프로퍼티로 노출하는 패턴이 표준입니다.
Unity 모바일 GC 특수성
프로퍼티 자체는 GC와 무관합니다(JIT 인라이닝되면 비용 0). 하지만 setter 안에 string.Format이나 LINQ가 있으면 매 호출 GC 부담이 생기죠.
// ❌ 매 호출 GC 알록 — string.Format이 setter 안에 숨음
public string DisplayName
{
get => _displayName;
set => _displayName = string.Format("[{0}] {1}", _level, value); // 매 set마다 알록
}
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터)의 조합이라 5~15ms 프레임 스파이크가 누적됩니다. setter 안의 알록을 항상 의식하시기 바랍니다.
9. 함정과 주의사항
함정 1 — out/ref 매개변수에 프로퍼티 전달 불가
public int Health { get; set; }
void Reset(out int x) { x = 0; }
Reset(out player.Health); // ❌ 컴파일 오류
int temp;
Reset(out temp);
player.Health = temp; // ✅ 임시 변수 경유
프로퍼티는 메모리 위치가 아니라 메서드 호출이므로 out/ref로 전달 불가. 변수 주소가 필요한 자리에서는 임시 변수를 거쳐야 합니다.
함정 2 — 자동 프로퍼티의 ++ 연산은 두 번의 메서드 호출
player.Health++; // ⚠️ 실제로는 get_Health() + 1 + set_Health(...)
++/+= 같은 복합 연산자는 getter 호출 → 계산 → setter 호출의 3단계가 됩니다. 일반적인 자동 프로퍼티는 인라이닝되어 비용 차이 없지만, 검증 로직 있는 setter라면 분기·계산이 두 번 일어나는 셈입니다. 핫패스에서는 _field++를 직접 쓰는 게 더 단순합니다.
함정 3 — 표현식 본문 프로퍼티에서 매번 새 객체 만들기
// ❌ 매 호출 새 List 만듦
public List<int> ActiveItems => _items.Where(i => i.IsActive).ToList();
// ✅ 캐싱 또는 IEnumerable 반환
private List<int> _activeCache;
public IReadOnlyList<int> ActiveItems => _activeCache ??= _items.Where(i => i.IsActive).ToList();
표현식 본문은 짧고 깔끔해 보이지만 호출할 때마다 본문이 실행됩니다. LINQ나 새 객체 생성이 들어가면 매 호출 GC 부담이 생기죠. 핫패스에서는 결과를 캐싱하거나 반환 타입을 IEnumerable<T>로 바꾸는 등의 최적화가 필요합니다.
함정 4 — 자동 프로퍼티의 readonly 필드 흉내
public class Order
{
public DateTime Created { get; } // C# 6+ 진짜 읽기 전용
public Order() { Created = DateTime.UtcNow; } // ✅ 생성자에서만 설정 가능
}
void Foo(Order o)
{
o.Created = DateTime.UtcNow; // ❌ 컴파일 오류
}
{ get; }이 IL의 initonly 백킹 필드와 매핑되어, 생성자 또는 필드 이니셜라이저에서만 값을 설정할 수 있습니다. 다른 메서드(클래스 내부라도)에서 변경 시도 시 컴파일 오류. 진정한 불변성을 원할 때 가장 단순한 도구입니다.
함정 5 — 인터페이스에 프로퍼티 vs 필드
// ❌ 인터페이스에 필드 정의 불가
public interface IDamageable
{
int Health; // 컴파일 오류
}
// ✅ 인터페이스는 프로퍼티만 가능
public interface IDamageable
{
int Health { get; set; }
}
인터페이스는 멤버를 선언할 뿐 데이터를 가질 수 없으므로 필드는 정의 불가. 프로퍼티(메서드 한 쌍)만 가능합니다. 이 차이가 인터페이스 기반 설계에서 항상 프로퍼티를 쓰게 만드는 이유 중 하나입니다.
10. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | 프로퍼티 (get { } set { } 본문 명시) |
기본 |
| 3.0 | 자동 구현 프로퍼티 { get; set; } |
백킹 필드 자동 |
| 6.0 | 읽기 전용 자동 프로퍼티 { get; } (initonly 백킹 필드) |
진짜 불변 |
| 6.0 | 표현식 본문 getter => ... |
|
| 6.0 | 자동 프로퍼티 이니셜라이저 { get; set; } = 100; |
|
| 7.0 | 표현식 본문 setter set => ... |
가독성 |
| 9.0 | init 접근자 { get; init; } |
PART 7 #5 — 객체 초기화자 후 불변 |
| 11.0 | required 멤버 required public ... |
PART 7 #6 — 누락 시 컴파일 오류 |
| 14.0 | field 키워드 — 백킹 필드 직접 사용 |
PART 7 #4 — set => field = ... |
C# 3.0의 자동 구현 프로퍼티가 결정적 변화였습니다. 이전엔 private int _x; public int X { get { return _x; } set { _x = value; } } 같은 보일러플레이트를 한 줄로 줄였죠. C# 6.0의 { get; }이 진정한 불변 프로퍼티를 가능하게 했고, C# 9의 init과 11의 required가 객체 초기화 패턴을 더 정교하게 만듭니다.
11. 정리
- [ ] 프로퍼티는 IL 레벨에서 메서드 —
public int X { get; set; }한 줄 =<X>k__BackingFieldprivate 필드 +get_X+set_X+.property. - [ ] 백킹 필드 이름
<X>k__BackingField: 사용자 코드와 절대 충돌 불가능한 이름. - [ ] 호출 측 IL은 필드
ldfldvs 프로퍼티callvirt get_X— 명령어 자체가 다르며, 이게 바이너리 호환성 함정의 원인. - [ ] JIT 인라이닝: 자동 프로퍼티의 단순 getter/setter는 거의 항상 인라이닝되어 기계 코드는 필드와 동등.
- [ ]
{ get; }은initonly백킹 필드 — 생성자에서만 설정 가능, 진정한 불변. C# 6+ 추가. - [ ]
{ get; private set; }은 setter만 private — 클래스 내부 어디서든 변경 가능. - [ ] 검증 로직 있는 setter는 IL 3배 — JIT 인라이닝 실패 가능, 핫패스에서 비용 누적.
- [ ] 표현식 본문 프로퍼티(
=> _field)는 IL이 자동 프로퍼티와 동등 — 가독성 신문법. - [ ] 계산된 프로퍼티(
=> X + Y)는 호출마다 본문 실행 — 비싼 계산은 캐싱 고려. - [ ] Unity 표준 패턴:
[SerializeField] private+public T Prop => _field+ 검증 메서드. 자동 프로퍼티 백킹 필드는 Unity 직렬화 안 됨. - [ ]
out/ref매개변수에 프로퍼티 직접 전달 불가 — 임시 변수 경유. - [ ] 인터페이스는 필드 X, 프로퍼티만 정의 가능 — 메서드 한 쌍이라서.
- [ ] 더 깊은 신문법(
field키워드·init접근자·required멤버)은 PART 7 #4·#5·#6에서 다룬다.