[PART7.클래스와 객체 입문(2/21)] 필드와 접근 제한자 — 7가지 한정자가 IL에서 만드는 6개 집합
필드는 객체 상태 저장소 / 접근 제한자 7개 = CLR 6개 한정자 + C# 11 file / public/private/internal/protected/protected internal/private protected/file / IL 매핑: public/private/assembly/family/famorassem/famandassem + 해시 변조 / public 필드는 IL stfld 한 줄, public 자동 프로퍼티는 <X>k__BackingField private 필드 + get_X/set_X 메서드 / Unity [SerializeField] private는 캡슐화와 직렬화의 직교
목차
1. public 필드 한 줄이 일으키는 사고
신입이 자주 작성하는 Unity 코드 한 줄로 시작합니다.
public class Player : MonoBehaviour
{
public int Health = 100; // 인스펙터에서 보고 싶어서 public으로
}
// 다른 스크립트
void OnDamage(Player p)
{
p.Health -= 20; // ⚠️ 검증 없이 직접 변경 가능
}
void Cheat(Player p)
{
p.Health = -999; // ⚠️ 음수도 그대로 들어감
p.Health = int.MaxValue; // ⚠️ 오버플로 위험
}
public 필드는 누구나 읽고 쓸 수 있는 데이터입니다. 음수 체력, 정수 오버플로, 동시성 충돌 — 모든 사고의 출발점이 되죠. 그리고 더 큰 문제는 나중에 검증 로직을 넣고 싶어 프로퍼티로 바꾸면 이 필드를 참조하던 모든 외부 어셈블리를 다시 컴파일해야 합니다(필드와 프로퍼티는 IL 레벨에서 호환되지 않음).
이 글은 그래서 필드는 항상 private으로 시작하고, 접근 제한자 7개를 정확히 이해해 적절한 자리에 쓰는 것을 다룹니다. 그리고 IL 레벨에서 이 7개가 어떻게 표현되는지, Unity의 [SerializeField] private 패턴이 왜 정답인지까지.
2. 필드란 무엇인가
비유 — 사물함과 자물쇠
필드는 객체의 사물함입니다. 사물함은 데이터를 그대로 보관하는 공간이고, 자물쇠가 접근 제한자입니다. 자물쇠가 없으면(public 필드) 누구나 사물함을 열어 안의 물건을 꺼내거나 바꿔 넣을 수 있습니다. 자물쇠가 있으면(private 필드) 사물함 주인(같은 클래스)만 접근 가능하고, 외부에서는 주인이 만든 창구(메서드/프로퍼티)를 통해 부탁해야 합니다.
IL에서 필드는 .field 한 줄
public class Player
{
public int Health;
private string _name;
}
.field public int32 Health // 누구나 접근 가능
.field private string _name // 같은 클래스만 접근
필드는 IL에서 .field <한정자> <타입> <이름> 한 줄의 메타데이터입니다. 객체 인스턴스가 생성되면 이 필드들이 객체 헤더 뒤에 차례로 박힙니다. 메서드와 달리 코드(IL 명령어 시퀀스)가 없는 순수 데이터 슬롯이죠.
필드 vs 프로퍼티의 IL 레벨 차이
이게 이 글에서 가장 중요한 한 줄입니다.
public class A
{
public int X; // 필드
}
public class B
{
public int X { get; set; } // 자동 프로퍼티
}
// A.X — 필드 한 줄로 끝
.field public int32 X
// B.X — 백킹 필드(private) + getter + setter + 프로퍼티 메타데이터
.field private int32 '<X>k__BackingField' // ← private 필드를 컴파일러가 만든다
.custom CompilerGeneratedAttribute
.custom DebuggerBrowsableAttribute(Never)
.method public hidebysig specialname instance int32 get_X() cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld int32 B::'<X>k__BackingField'
IL_0006: ret
}
.method public hidebysig specialname instance void set_X(int32 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld int32 B::'<X>k__BackingField'
IL_0007: ret
}
.property instance int32 X()
{
.get instance int32 B::get_X()
.set instance void B::set_X(int32)
}
같은 public int X처럼 보이지만 IL은 완전히 다릅니다.
| 항목 | 필드 | 프로퍼티 |
|---|---|---|
| IL 항목 수 | 1 (.field) |
4 (.field + get_X + set_X + .property) |
| 외부에서 접근 | stfld/ldfld 단일 명령 |
call set_X/call get_X 메서드 호출 |
| 검증 로직 | 불가 — 직접 메모리 접근 | 가능 — setter 안에 자유롭게 |
| 인터페이스 정의 | 불가 | 가능 |
| 데이터 바인딩(WPF/Unity) | 불가 | 가능 |
| 바이너리 호환성 | 별도 — 필드↔프로퍼티 변경 시 외부 재컴파일 | — |
결론: 외부에 노출할 데이터는 처음부터 프로퍼티로. 자세한 내용은 PART 7 #3에서 다룹니다.
3. 접근 제한자 7가지
C#은 7개의 접근 한정자를 제공합니다 — public/private/internal/protected/protected internal/private protected(C# 7.2)/file(C# 11).
위에서 아래로 갈수록 접근 범위가 좁아집니다. 캡슐화의 첫 원칙은 "가장 좁은 범위에서 시작해 필요한 만큼만 넓힌다"입니다.
각 한정자의 한 줄 요약:
public: 어셈블리 안팎 모두 자유. 정말 외부에 보여야 하는 API에만.protected internal: 같은 어셈블리 OR 다른 어셈블리의 파생 클래스. 합집합.internal: 같은 어셈블리만. 라이브러리 내부 헬퍼에 가장 자주 쓴다.protected: 자신 + 모든 파생 클래스. 상속을 위한 계약.private protected(C# 7.2): 같은 어셈블리 AND 파생 클래스. 교집합.file(C# 11): 같은 소스 파일 안에서만. Source Generator의 안전망.private: 같은 클래스만. 모든 필드의 기본값(명시 안 하면 멤버 기본 한정자가private).
4. IL로 본 6개 한정자 매핑 (+ file은 이름 변조)
C#의 7개 한정자 중 6개는 CLR(Common Language Runtime — .NET 코드를 실행하는 가상 머신)이 IL 레벨에서 직접 지원하는 한정자입니다. 나머지 하나(file)는 컴파일러 트릭입니다.
public class AccessDemo
{
public int PublicField;
private int _privateField;
internal int InternalField;
protected int ProtectedField;
protected internal int ProtectedInternalField;
private protected int PrivateProtectedField;
}
file class FileScopedHelper { public int X; }
// AccessDemo — 6개 한정자가 IL로 어떻게 변환되는가
.field public int32 PublicField // C# public → IL public
.field private int32 _privateField // C# private → IL private
.field assembly int32 InternalField // C# internal → IL assembly
.field family int32 ProtectedField // C# protected → IL family
.field famorassem int32 ProtectedInternalField // C# protected internal → IL famorassem
.field famandassem int32 PrivateProtectedField // C# private protected → IL famandassem
// file class — 컴파일러가 이름을 변조한다 (해시값 + 원본 이름)
.class private auto ansi beforefieldinit
'<Program>F0721549592A5AC2F76ACCD4AF4C6B2B4BAFE3B9CCA3D7B82C9188FA17FC77B4B__FileScopedHelper'
extends System.Object
매핑 표:
| C# 한정자 | IL 한정자 | 의미 (집합 연산) |
|---|---|---|
public |
public |
모든 곳 |
private |
private |
같은 클래스만 |
internal |
assembly |
같은 어셈블리 |
protected |
family |
자신 + 파생 클래스 |
protected internal |
famorassem |
family OR assembly (합집합) |
private protected |
famandassem |
family AND assembly (교집합) |
file |
private (이름 변조) |
같은 파일 안에서만 |
private protected가 C# 7.2에야 들어온 이유famandassem자체는 CLR 1.0부터 존재했지만 C# 언어가 키워드를 노출하지 않았다. 다른 언어(C++/CLI)에서는 일찍부터 쓸 수 있었던 기능을 C#이 17년 만에 따라잡은 것 — 새 기능이 아니라 새 키워드 노출이다. C# 7.2의private protected도 같은 맥락.protected보다 좁고private보다 넓은 자리에 정확한 이름을 부여한 것.
file한정자는 컴파일러 트릭 CLR에는file같은 한정자가 없다. C# 컴파일러가 클래스 이름 앞에 소스 파일 경로의 SHA-256 해시값을 붙여 변조한다.<Program>F072...4B__FileScopedHelper같은 이름이라 같은 어셈블리 다른 파일에 같은 이름의 클래스가 있어도 충돌하지 않는다. Source Generator가 안전하게 헬퍼를 생성할 수 있는 메커니즘.
5. 자동 프로퍼티의 백킹 필드 — IL이 보여주는 진짜 정체
public class B
{
public int X { get; set; } // 자동 프로퍼티
}
// 1) private 백킹 필드
.field private int32 '<X>k__BackingField'
.custom CompilerGeneratedAttribute
.custom DebuggerBrowsableAttribute(Never)
// 2) get_X 메서드 — IL 7바이트
.method public hidebysig specialname instance int32 get_X() cil managed
{
IL_0000: ldarg.0 // this
IL_0001: ldfld int32 B::'<X>k__BackingField' // 백킹 필드 로드
IL_0006: ret
}
// 3) set_X 메서드 — IL 8바이트
.method public hidebysig specialname instance void set_X(int32 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1 // value 파라미터
IL_0002: stfld int32 B::'<X>k__BackingField' // 백킹 필드에 저장
IL_0007: ret
}
// 4) 프로퍼티 메타데이터 (.property)
.property instance int32 X()
{
.get instance int32 B::get_X()
.set instance void B::set_X(int32)
}
핵심 두 가지:
<X>k__BackingField라는 이름: 꺾쇠 괄호와k__접두사는 C# 식별자로 합법이 아닙니다. 컴파일러가 사용자 코드와의 충돌을 원천 차단하기 위해 일부러 부적합한 이름을 씁니다.- JIT 인라이닝: 자동 프로퍼티의
get_X/set_X는 너무 단순해서 JIT가 거의 항상 인라이닝합니다. 결과적으로 호출 측의 IL은stfld/ldfld와 동등한 코드가 되어 런타임 비용이 필드와 사실상 같습니다. 검증 로직이 들어 있는 프로퍼티는 인라이닝이 안 될 수 있어 메서드 호출 비용이 남습니다.
자세한 프로퍼티 사용법은 PART 7 #3에서 다룹니다.
6. 실전 적용 — Unity의 [SerializeField] private 패턴
문제: public 필드의 두 얼굴
Unity에서 디자이너가 인스펙터에서 값을 조정하려면 필드가 직렬화되어야 합니다. 가장 쉬운 방법은 public이지만, 이는 캡슐화를 정면으로 위반합니다.
// ❌ Before — public 필드, 캡슐화 위반
public class Player : MonoBehaviour
{
public int health = 100; // 인스펙터에 보이지만 다른 스크립트도 마음대로 수정 가능
public float speed = 5f;
}
// 다른 스크립트에서 이런 짓이 가능
void Cheat(Player p) { p.health = int.MaxValue; } // ⚠️ 검증 없음
void Bug(Player p) { p.speed = -1f; } // ⚠️ 음수 속도
해결: [SerializeField] private
// ✅ After — private + SerializeField로 캡슐화 + 직렬화 동시 달성
public class Player : MonoBehaviour
{
[SerializeField] private int health = 100;
[SerializeField] private float speed = 5f;
// 외부 노출이 필요하면 읽기 전용 프로퍼티
public int Health => health;
public float Speed => speed;
// 변경은 검증 로직이 있는 메서드를 통해서만
public void TakeDamage(int amount)
{
if (amount <= 0) return;
health = Math.Max(0, health - amount);
if (health == 0) Die();
}
}
IL이 보여주는 동작
// [SerializeField] private int health
.field private int32 health
.custom UnityEngine.SerializeField::.ctor() // ← 단순 메타데이터 표식
// public int Health => health
.field private int32 '<Health>k__BackingField' // 자동 프로퍼티 백킹 필드 (다른 필드)
.method public hidebysig specialname instance int32 get_Health() cil managed
핵심:
SerializeField는 IL.custom속성 — 런타임 동작에 영향 없음. Unity 에디터가 reflection으로 이 속성을 읽어 인스펙터에 노출할 뿐.- C# 컴파일러는
[SerializeField]를 모름 — 단순히 어셈블리 메타데이터에 박아 둘 뿐. 이게 캡슐화(private)와 직렬화(SerializeField)가 IL 레벨에서 완전히 직교하는 메커니즘.
이 패턴 덕분에:
- 코드 레벨:
private→ 다른 스크립트가 직접 변경 불가 - 에디터 레벨:
SerializeField→ 인스펙터에 노출, 디자이너가 자유롭게 조정 - 외부 읽기:
public int Health => health같은 읽기 전용 프로퍼티 - 외부 변경:
TakeDamage같은 검증 메서드를 통해서만
더 강한 패턴 — internal + [InternalsVisibleTo]로 테스트 친화
// Player.cs (게임 어셈블리)
public class Player
{
internal int Health; // 같은 어셈블리에서만 직접 접근, 테스트는 OK
public int GetHealth() => Health;
}
// AssemblyInfo.cs
[assembly: InternalsVisibleTo("MyGame.Tests")]
internal + [InternalsVisibleTo] 조합으로 외부에는 숨기되 테스트 어셈블리에는 노출 가능. Unity에서 Assembly Definition Files(.asmdef)로 어셈블리를 분리한 프로젝트라면 이 패턴이 매우 유용합니다.
Unity 모바일 GC 특수성
필드 접근(stfld/ldfld)은 IL 단일 명령이라 GC와 무관합니다. 하지만 프로퍼티 setter 안에 string.Format이나 LINQ가 있으면 매 호출 GC 부담이 생기죠. Update 안에서 자주 호출되는 setter는 검증 로직 안의 알록을 의심하시기 바랍니다.
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터)의 조합이라 한 번의 GC가 5~15ms 프레임 스파이크입니다. 단순 데이터 접근은 필드/자동 프로퍼티가 거의 같지만, 검증 로직이 들어가는 순간 자리가 갈립니다.
7. 함정과 주의사항
함정 1 — 필드 → 프로퍼티 변경은 바이너리 깨짐
// v1.0
public class Config { public int Timeout; } // 필드
// v2.0 — 검증 추가하고 싶어서 프로퍼티로 변경
public class Config { public int Timeout { get; set; } } // 프로퍼티
소스 코드는 같아 보이지만 IL이 완전히 달라(stfld vs call set_Timeout) 참조하던 모든 외부 어셈블리가 다시 컴파일되지 않으면 MissingFieldException 이 납니다. Unity 어셈블리 분리·플러그인 DLL을 쓴다면 치명적이죠. 외부에 노출할 데이터는 처음부터 프로퍼티로.
함정 2 — 멤버 한정자 안 쓰면 private, 클래스 한정자 안 쓰면 internal
class Helper // 클래스 한정자 없음 → internal
{
int _data; // 멤버 한정자 없음 → private
void Process() { } // 한정자 없음 → private
}
규칙이 둘로 갈라집니다 — 클래스(파일·네임스페이스 직속)는 internal이 기본, 멤버는 private이 기본. 명시하는 습관을 들이면 헷갈리지 않습니다.
함정 3 — protected는 다른 어셈블리 파생 클래스도 접근 가능
// MyLib.dll
public class Base { protected int _data; }
// OtherProject.exe
class Derived : Base
{
void Hack() { _data = 999; } // ✅ 다른 어셈블리지만 파생 클래스라 접근 가능
}
protected는 어셈블리 경계를 넘는 한정자입니다. 정말로 같은 어셈블리 안에서만 상속을 허용하고 싶다면 private protected(C# 7.2)를 써야 합니다. 라이브러리 작성 시 자주 헷갈리는 자리.
함정 4 — public 필드를 인스펙터용으로 노출
이미 본 함정. 반드시 [SerializeField] private 패턴을 쓰시고, 외부 읽기는 public T Prop => _field 같은 읽기 전용 프로퍼티로.
함정 5 — file 한정자 남용
// ❌ 의미 없음 — 일반 클래스 파일에 file 한정자
file class MyHelper { ... }
file은 Source Generator가 자동 생성하는 헬퍼 코드 전용으로 설계된 한정자입니다. 사람이 직접 작성하는 코드에서는 internal이나 private(중첩 클래스)이 거의 항상 적합합니다. file은 "이 파일 안에서만 의미가 있는 임시 헬퍼"라는 매우 좁은 시그널을 남기는 자리에서만 의미 있죠.
8. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | public/private/internal/protected/protected internal |
CLR이 지원하던 6개 중 5개 노출 |
| 3.0 | 자동 프로퍼티 { get; set; } |
백킹 필드 자동 생성 |
| 7.2 | private protected |
CLR famandassem 키워드 노출 (CLR 1.0부터 존재) |
| 9.0 | init 접근자 |
PART 7 #5 |
| 11.0 | required 멤버 |
PART 7 #6 |
| 11.0 | file 한정자 |
Source Generator 안전 지원, 컴파일러 이름 변조 |
| 14.0 | field 키워드 (백킹 필드 직접 사용) |
PART 7 #4 |
C# 7.2의 private protected와 11.0의 file 두 한정자가 가장 최근의 추가입니다. private protected는 라이브러리 설계자, file은 Source Generator 작성자가 주 타겟입니다. 일반 애플리케이션 개발자는 거의 만질 일이 없는 한정자죠.
9. 정리
- [ ] 필드는 객체의 데이터 저장소, 프로퍼티는 데이터에 접근하는 통제된 메서드.
- [ ] IL에서 필드는
.field한 줄, 프로퍼티는.field(백킹) +get_*+set_*+.property네 항목. - [ ] 외부 노출 데이터는 처음부터 프로퍼티로 — 필드 → 프로퍼티 변경은 바이너리 호환성 깨짐.
- [ ] C#의 7가지 접근 한정자 → CLR의 6가지 한정자 매핑:
public→publicprivate→privateinternal→assemblyprotected→familyprotected internal→famorassem(OR)private protected→famandassem(AND, C# 7.2)file→ 컴파일러 이름 변조 (C# 11)
- [ ]
private protected는 새 기능이 아니라 새 키워드 노출 — CLR 1.0부터 존재한famandassem한정자를 17년 만에 C#이 노출. - [ ]
file한정자는 Source Generator 안전망 — 같은 어셈블리 다른 파일의 동명 클래스 충돌 방지. - [ ] 자동 프로퍼티 백킹 필드는
<X>k__BackingField— 사용자 코드와 충돌 불가능한 이름. JIT가 인라이닝해 필드와 비용 사실상 같음. - [ ] Unity
[SerializeField] private패턴:private(캡슐화) +[SerializeField](직렬화 메타데이터)가 IL 레벨에서 직교. 외부 읽기는public T Prop => _field. - [ ] 클래스 기본 한정자는
internal, 멤버 기본 한정자는private— 명시하는 습관. - [ ]
protected는 어셈블리 경계를 넘음 — 같은 어셈블리 안에서만 제한하려면private protected. - [ ] 캡슐화 첫 원칙: 가장 좁은 범위에서 시작해 필요한 만큼만 넓힌다.
- [ ] 더 깊은 프로퍼티 동작과 신문법(
init·required·field키워드)은 PART 7 #3~#6에서 다룬다.