[PART7.클래스와 객체 입문(11/21)] Primary Constructor — 클래스 헤더에 매개변수를 적는다 (C# 12)
클래스 본문 전체가 매개변수의 스코프 / 일반 class는 자동 프로퍼티가 안 만들어진다 / record와 형태는 같아도 의미는 다르다
목차
1. [문제 제기] — 같은 이름을 네 번 쓰는 게 정상인가
Unity에서 캐릭터 컨트롤러나 매니저 클래스를 만들 때, 신입 개발자가 가장 먼저 마주치는 풍경은 이렇습니다.
public class Player
{
private readonly string _name;
private readonly int _age;
public Player(string name, int age)
{
_name = name;
_age = age;
}
public string Greet() => $"Hi, I'm {_name}, {_age} years old.";
}
name이라는 이름이 클래스 안에 네 번 등장합니다. 필드 선언 한 번, 생성자 매개변수 한 번, 생성자 본문 대입 한 번, 메서드에서 사용 한 번. age도 마찬가지입니다. 클래스가 의존성을 5개쯤 받으면 상단 절반이 전부 보일러플레이트가 됩니다.
public class CombatService
{
private readonly IRandom _random;
private readonly StatTable _statTable;
private readonly DamageFormula _formula;
private readonly ILogger _logger;
private readonly IEffectPlayer _effects;
public CombatService(IRandom random, StatTable statTable, DamageFormula formula, ILogger logger, IEffectPlayer effects)
{
_random = random;
_statTable = statTable;
_formula = formula;
_logger = logger;
_effects = effects;
}
public int Calculate(Attacker a, Defender d) { /* ... */ }
}
본문(Calculate)을 보기까지 화면을 한 번 스크롤해야 합니다. 더 큰 문제는 추가입니다. 의존성 하나가 늘면 ① 필드 선언 ② 생성자 매개변수 ③ 생성자 대입 — 세 곳을 동시에 고쳐야 합니다. 빠뜨리면 NullReferenceException이 런타임에 터집니다.
C# 12의 Primary Constructor(기본 생성자) 는 이 의식을 한 줄로 줄입니다.
public class CombatService(IRandom random, StatTable statTable, DamageFormula formula, ILogger logger, IEffectPlayer effects)
{
public int Calculate(Attacker a, Defender d) { /* random·statTable·... 직접 사용 */ }
}
클래스 이름 뒤에 매개변수를 적으면 클래스 본문 전체에서 그 이름을 변수처럼 쓸 수 있습니다. 보일러플레이트 9줄이 사라집니다.
다만 이 문법은 함정도 같이 가져옵니다. 비슷하게 생긴 record와는 의미가 다르고, readonly를 강제할 수 없으며, 매개변수를 메서드에서 수정하면 인스턴스 상태가 변합니다. 이 글에서는 컴파일러가 이 한 줄을 어떻게 바꿔치기하는지 IL 레벨에서 확인하고, record와 무엇이 다르며, Unity 신입이 어디서 발을 헛디딜지 차례로 봅니다.
2. [개념 정의] — 클래스 헤더가 곧 생성자다
비유 — "이 부서에 공동 비품"
회사 부서 하나를 새로 만든다고 생각해 봅시다. 부서 입구에 안내판을 붙이는데, 부서 이름 옆에 이 부서가 가진 공동 비품 목록(프린터, 회의실, 코드 사인)을 같이 적어 둡니다. 부서 안의 모든 직원은 이 비품을 따로 신청하지 않고 즉시 사용할 수 있습니다.
Primary Constructor도 같은 발상입니다. 클래스 이름 옆에 적은 매개변수(공동 비품)는 그 클래스의 모든 메서드·프로퍼티·필드 초기화(직원)가 별다른 절차 없이 즉시 사용할 수 있게 됩니다.
구조 — 매개변수의 스코프
핵심은 스코프입니다. 일반 생성자에서는 매개변수 name이 생성자 본문({ ... }) 안에서만 살아있고, 메서드에서는 보이지 않습니다. 그래서 별도로 _name 필드에 옮겨 담아야 합니다. Primary Constructor에서는 name이 클래스 본문 전체에서 살아있어 메서드·프로퍼티·필드 초기화 어디서든 직접 쓸 수 있습니다.
기본 코드
public class Player(string name, int age)
{
public string Greet() => $"Hi, I'm {name}, {age} years old.";
}
// 사용
var p = new Player("Hong", 30);
Console.WriteLine(p.Greet()); // Hi, I'm Hong, 30 years old.
Player 안 어디에도 _name·_age 같은 필드 선언이 없습니다. name·age는 클래스 헤더에만 있고, Greet 메서드에서 그대로 쓰입니다. 객체를 만들 때(new Player(...))는 일반 생성자처럼 인자를 넘깁니다 — 컴파일러가 클래스 헤더의 매개변수를 받는 생성자를 자동으로 만들어 줍니다.
class Type(...)— Primary Constructor (기본 생성자) 클래스/구조체 이름 뒤에 괄호로 매개변수를 적으면, 그 매개변수들이 클래스 본문 전체의 스코프에서 살아있는 변수가 된다. 컴파일러가 동일한 시그니처의 생성자를 자동 생성하고, 메서드에서 매개변수를 쓰면 비공개 백킹 필드를 합성해 값을 보관한다.
예시:class Player(string name) { public string Greet() => $"Hi, {name}"; }별도 필드 선언 없이name을 메서드에서 직접 사용
IL — 컴파일러가 실제로 만드는 것
위 Player 클래스를 .NET 8 / LangVersion 12로 Release 빌드한 뒤 IL을 디스어셈블한 결과입니다.
.class public auto ansi beforefieldinit Player
{
// 컴파일러가 합성한 비공개 캡처 필드
.field private string '<name>P'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
.field private int32 '<age>P'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
// 매개변수를 받는 생성자 — 자동 생성
.method public hidebysig specialname rtspecialname
instance void .ctor(string name, int32 age) cil managed
{
ldarg.0
ldarg.1
stfld string Player::'<name>P' // this.<name>P = name
ldarg.0
ldarg.2
stfld int32 Player::'<age>P' // this.<age>P = age
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
// Greet 메서드 — name·age를 캡처 필드에서 읽어 옴
.method public hidebysig instance string Greet() cil managed
{
// ... DefaultInterpolatedStringHandler 생성 ...
ldarg.0
ldfld string Player::'<name>P' // name 사용 → this.<name>P 로딩
// ... AppendFormatted ...
ldarg.0
ldfld int32 Player::'<age>P' // age 사용 → this.<age>P 로딩
// ... ToStringAndClear ...
ret
}
}
핵심 두 가지를 IL이 증명합니다.
<name>P,<age>P라는 이름의 비공개 필드가 합성됩니다. 꺾쇠(<>)가 들어간 이름은 C# 식별자 규칙으로는 만들 수 없으므로 소스 코드에서 직접 접근 불가합니다. 끝의P는 Primary constructor의 약자로 추정되는 컴파일러 합성 명명 규칙입니다.- 메서드 안의
name·age는ldfld(필드 로드) 명령으로 변환됩니다. 즉 매개변수처럼 보였지만 실제로는 인스턴스 필드 접근입니다. 메서드를 호출할 때마다this.<name>P를 읽어 옵니다.
이 두 가지를 합치면 다음 결론에 이릅니다 — Primary Constructor 매개변수는 "이름만 매개변수처럼 보이는 인스턴스 필드" 입니다. 다음 섹션에서는 컴파일러가 어떤 조건에서 이 필드를 만들고 어떤 조건에서 만들지 않는지를 봅니다.
3. [내부 동작] — 캡처가 일어날 때만 필드를 만든다
세 가지 사용 패턴
Primary Constructor 매개변수는 어떻게 사용되느냐에 따라 컴파일러가 다른 코드를 만듭니다. 패턴은 세 가지입니다.
코드와 IL — 패턴 ① 미사용
public class NoCapture(int unused);
위 클래스의 IL입니다.
.class public auto ansi beforefieldinit NoCapture
{
// 필드 선언이 한 줄도 없음
.method public hidebysig specialname rtspecialname
instance void .ctor(int32 'unused') cil managed
{
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
}
.field 선언이 한 줄도 없습니다. 매개변수는 받지만 캡처되지 않으므로 메모리에 남길 필요가 없습니다. 다만 컴파일러는 이런 경우 CS9113 경고를 띄웁니다 — "unused 매개변수를 읽지 않았습니다." 의도적이라면 이름을 _로 바꾸거나 경고를 무시합니다.
코드와 IL — 패턴 ② 이니셜라이저에서만 사용
public class Greeter(string greeting)
{
public string Message = greeting + "!"; // 필드 이니셜라이저에서만 사용
}
.class public auto ansi beforefieldinit Greeter
{
// Message 필드만 — 캡처 필드 <greeting>P 없음
.field public string Message
.method public hidebysig specialname rtspecialname
instance void .ctor(string greeting) cil managed
{
ldarg.0
ldarg.1
ldstr "!"
call string [System.Runtime]System.String::Concat(string, string)
stfld string Greeter::Message // this.Message = greeting + "!"
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
}
<greeting>P 같은 캡처 필드가 없습니다. 생성자 안에서 greeting을 읽어 Message에 대입한 뒤로는 매개변수가 더 이상 필요 없기 때문입니다. 즉, 이니셜라이저는 "값 복사"이고 메서드는 "값 보관"입니다. 이 차이를 모르면 "Primary Constructor를 쓰면 항상 인스턴스가 무거워진다"는 오해에 빠지기 쉽습니다.
코드와 IL — 패턴 ③ 메서드에서 사용
이미 섹션 2에서 본 Player 가 이 패턴이고, 거기서 <name>P·<age>P 필드가 합성되는 것을 확인했습니다.
가변성 — 매개변수가 진짜로 가변이다
가장 중요하고 가장 위험한 사실은 이 캡처 필드가 readonly가 아니라는 점입니다. 메서드 안에서 매개변수를 수정할 수 있고, 수정하면 인스턴스 상태가 바뀝니다.
public class Counter(int start)
{
public void Increment() => start++;
public int Get() => start;
}
var c = new Counter(10);
c.Increment();
c.Increment();
Console.WriteLine(c.Get()); // 12
start는 매개변수처럼 보이지만 실제로는 인스턴스 필드라서 Increment()의 변경이 다음 호출까지 살아남습니다. IL을 보면 이게 명확합니다.
.method public hidebysig instance void Increment() cil managed
{
ldarg.0
ldarg.0
ldfld int32 Counter::'<start>P' // 현재 <start>P 읽기
ldc.i4.1
add // +1
stfld int32 Counter::'<start>P' // <start>P 에 다시 쓰기
ret
}
ldfld로 읽어와 stfld로 다시 씁니다. 영락없는 인스턴스 필드 갱신입니다. 일반 매개변수(메서드 매개변수)였다면 메서드가 끝나는 순간 사라지지만, Primary Constructor 매개변수는 클래스가 살아있는 동안 함께 살아있고 수정 가능합니다. 이 함정은 섹션 5에서 다시 다룹니다.
메서드와 이니셜라이저를 섞으면
public class Mixed(int x)
{
public int Initial = x; // 이니셜라이저: 한 번만 사용
public int Current() => x; // 메서드: 호출마다 사용 → 캡처 필드 필요
}
이 경우 컴파일러는 메서드에서의 사용을 보고 캡처 필드 <x>P를 만들어 둡니다. Initial과 <x>P는 별개의 필드입니다 — 즉 int 값이 두 군데 저장됩니다. 인스턴스 크기가 4 bytes 더 늘어납니다. 모르고 쓰면 의도치 않은 메모리 중복이 발생할 수 있다는 점을 기억해 두면 좋습니다.
4. [실전 적용] — 의존성 주입에 가장 잘 어울린다
Primary Constructor가 가장 빛나는 자리는 생성자에서 받은 의존성을 메서드에서 그대로 사용하는 패턴 입니다. Unity에서 순수 C# 서비스 클래스를 만들 때 자주 등장합니다.
Before/After — 5개 의존성 주입 서비스
// ❌ Before — C# 11 이전, 보일러플레이트 9줄
public class CombatService
{
private readonly IRandom _random;
private readonly StatTable _stats;
private readonly DamageFormula _formula;
private readonly ILogger _logger;
private readonly IEffectPlayer _effects;
public CombatService(IRandom random, StatTable stats, DamageFormula formula,
ILogger logger, IEffectPlayer effects)
{
_random = random;
_stats = stats;
_formula = formula;
_logger = logger;
_effects = effects;
}
public int Attack(Attacker a, Defender d)
{
int roll = _random.Next(100);
int dmg = _formula.Calculate(_stats.Of(a), _stats.Of(d), roll);
_logger.Log($"Damage {dmg}");
_effects.Play("hit", d.Position);
return dmg;
}
}
// ✅ After — C# 12 Primary Constructor, 보일러플레이트 0줄
public class CombatService(IRandom random, StatTable stats, DamageFormula formula,
ILogger logger, IEffectPlayer effects)
{
public int Attack(Attacker a, Defender d)
{
int roll = random.Next(100);
int dmg = formula.Calculate(stats.Of(a), stats.Of(d), roll);
logger.Log($"Damage {dmg}");
effects.Play("hit", d.Position);
return dmg;
}
}
이름의 _ 접두사도 사라집니다. After 쪽이 매개변수 이름과 사용처 이름이 같아 IDE의 "Find References"가 더 정확하게 동작합니다. 단, 이 코드는 매개변수가 가변임을 잊으면 사고가 납니다. 섹션 5에서 패턴을 보강합니다.
IL 비교
Before 쪽 IL은 우리가 직접 선언한 _random·_stats·... 5개의 private string 필드와 5번의 stfld를 가진 생성자가 나옵니다. After 쪽 IL은 컴파일러가 이름만 다른 <random>P·<stats>P·... 5개의 필드와 5번의 stfld를 자동으로 생성합니다.
// After (Primary Constructor) 의 .ctor 발췌
.method public hidebysig specialname rtspecialname
instance void .ctor(class IRandom random, ...) cil managed
{
ldarg.0
ldarg.1
stfld class IRandom CombatService::'<random>P'
ldarg.0
ldarg.2
stfld class StatTable CombatService::'<stats>P'
// ... 나머지 3개 동일 패턴 ...
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
런타임 비용은 Before와 After가 정확히 같습니다. 필드 5개에 5번 stfld. 차이는 오직 소스 코드 라인 수입니다 — Primary Constructor는 소스의 보일러플레이트를 줄이는 컴파일러 기능일 뿐, 런타임 성능에는 영향을 주지 않습니다.
record와의 차이 — 형태는 비슷, 의미는 다르다
매우 흔한 오해는 "Primary Constructor가 record를 일반 class로 가져온 것"이라는 생각입니다. 절반만 맞습니다. 문법은 똑같이 생겼지만, 컴파일러가 만드는 결과물이 완전히 다릅니다.
// 일반 class
public class PlayerC(string name, int age);
// record class — 같은 모양이지만 의미가 다르다
public record PlayerR(string Name, int Age);
코드로 다시 봅니다.
public class PlayerC(string name, int age);
public record PlayerR(string Name, int Age);
var c = new PlayerC("A", 10);
// Console.WriteLine(c.name); // ❌ 컴파일 에러 — name은 멤버가 아님
// Console.WriteLine(c.Name); // ❌ Name 같은 프로퍼티 자체가 없음
var r = new PlayerR("A", 10);
Console.WriteLine(r.Name); // ✅ "A" — 자동 프로퍼티
var r2 = r with { Age = 11 }; // ✅ 새 인스턴스 (Name="A", Age=11)
Console.WriteLine(r == r2); // ✅ False — 값 비교
Console.WriteLine(c == new PlayerC("A", 10)); // ✅ False — 참조 비교 (다른 객체)
IL 비교 (record 쪽)
record PlayerR(string Name, int Age) 의 IL을 디스어셈블하면 다음이 나옵니다(발췌).
.class public auto ansi beforefieldinit PlayerR extends System.Object
implements [System.Runtime]System.IEquatable`1<class PlayerR>
{
// ✓ 자동 생성된 프로퍼티 백킹 필드
.field private initonly string '<Name>k__BackingField'
.field private initonly int32 '<Age>k__BackingField'
// ✓ public string Name { get; init; }
.property instance string Name() { ... }
// ✓ public int Age { get; init; }
.property instance int32 Age() { ... }
// ✓ Equals (값 비교)
.method public hidebysig virtual instance bool Equals(class PlayerR other) ...
// ✓ GetHashCode
.method public hidebysig virtual instance int32 GetHashCode() ...
// ✓ ToString
.method public hidebysig virtual instance string ToString() ...
// ✓ Deconstruct
.method public hidebysig instance void Deconstruct(string& Name, int32& Age) ...
// ✓ <Clone>$ — with 표현식이 호출하는 메서드
.method public hidebysig newslot virtual instance class PlayerR '<Clone>$'() ...
}
PlayerC(class)에는 이 중 어느 것도 자동 생성되지 않습니다. 같은 한 줄을 적었는데 record는 6~7개의 멤버를 만들어 주고, class는 매개변수만 살려 둡니다.
판단 기준 — 어느 쪽을 쓸 것인가
| 상황 | 선택 |
|---|---|
| 불변 데이터 모델 (DTO·메시지·이벤트) | record |
값으로 동등성 비교가 필요 (Dictionary 키, 비교) |
record |
with 표현식으로 일부만 바꿔 새 인스턴스 만들고 싶음 |
record |
| 의존성 주입 서비스 클래스 (메서드가 핵심) | class + Primary Constructor |
| 매개변수를 외부에 노출할 필요가 없음 (캡슐화) | class + Primary Constructor |
Unity MonoBehaviour / ScriptableObject |
둘 다 X — 일반 class + Inspector 필드 |
핵심: 데이터 노출이면 record, 동작 캡슐화면 Primary Constructor.
Unity 실전 — 어디서 쓰고 어디서 피할 것인가
Unity 게임 클라이언트에서 Primary Constructor가 빛나는 자리와 절대 쓰면 안 되는 자리가 명확합니다.
// ✅ Unity 좋은 예 — 순수 C# 서비스 클래스 (POCO)
public class DamageCalculator(StatTable stats, IRandom rng)
{
public int Calculate(in AttackContext ctx)
{
int baseDmg = stats.AttackOf(ctx.Attacker);
int variance = rng.Next(-5, 6);
return Math.Max(1, baseDmg + variance);
}
}
// MonoBehaviour 가 의존성 주입
public class CombatController : MonoBehaviour
{
private DamageCalculator _calc;
private void Awake()
{
var stats = new StatTable(/* ... */);
var rng = new SystemRandom();
_calc = new DamageCalculator(stats, rng); // 일반 객체처럼 생성
}
}
// ❌ Unity 나쁜 예 — MonoBehaviour 에 Primary Constructor
public class PlayerHealth(int maxHp) : MonoBehaviour // 컴파일은 되지만 동작 X
{
public void TakeDamage(int dmg) { /* maxHp 사용 */ }
}
MonoBehaviour·ScriptableObject·NetworkBehaviour 같은 Unity 엔진 타입은 엔진이 직접 생성하기 때문에 사용자 정의 생성자를 호출하지 않습니다. Unity는 Awake()·OnEnable()·Start() 라이프사이클로 초기화하므로 Primary Constructor를 써도 매개변수에 값이 전달되지 않습니다. maxHp는 항상 기본값(0)이거나 알 수 없는 상태로 남습니다.
규칙은 단순합니다.
MonoBehaviour 계열에는 절대 Primary Constructor를 쓰지 않는다. 순수 C# 클래스(데이터·서비스·매니저 모델)에서만 쓴다.
VContainer·Zenject 같은 DI 컨테이너로 순수 C# 서비스를 주입하는 패턴에서 Primary Constructor가 가장 자연스럽게 어울립니다.
5. [함정과 주의사항] — 매개변수가 살아있다는 사실을 잊는 순간
함정 1 — 가변 매개변수가 인스턴스 상태를 깬다
가장 흔한 사고입니다. 매개변수가 일반 매개변수처럼 보여서 무심코 수정합니다.
// ❌ 잘못된 패턴 — 매개변수를 메서드에서 수정
public class ScoreBoard(int initialScore)
{
public void AddBonus(int bonus)
{
initialScore += bonus; // 무심코 수정 — 인스턴스 상태가 변함
}
public int CurrentScore() => initialScore;
}
var sb = new ScoreBoard(100);
sb.AddBonus(50);
sb.AddBonus(30);
Console.WriteLine(sb.CurrentScore()); // 180 — initialScore 라는 이름이 거짓말이 됨
이름은 initial인데 값이 계속 변합니다. 다음 개발자가 코드를 읽을 때 initialScore를 "초기값"으로 오해해 버그가 생깁니다. IL을 보면 진실이 드러납니다.
.method public hidebysig instance void AddBonus(int32 bonus) cil managed
{
ldarg.0
ldarg.0
ldfld int32 ScoreBoard::'<initialScore>P' // 현재 값 읽기
ldarg.1
add // + bonus
stfld int32 ScoreBoard::'<initialScore>P' // 캡처 필드에 다시 쓰기
ret
}
ldfld/stfld 쌍은 명백히 인스턴스 필드 갱신입니다. 컴파일러는 막아 주지 않습니다 — Primary Constructor 매개변수에는 readonly 한정자를 붙일 수 없기 때문입니다.
// ✅ 올바른 패턴 — 명시적 readonly 필드로 캡처해 불변 보장
public class ScoreBoard(int initialScore)
{
private readonly int _initial = initialScore; // 명시적 캡처
private int _current = initialScore;
public void AddBonus(int bonus) => _current += bonus;
public int InitialScore() => _initial;
public int CurrentScore() => _current;
}
_initial은 readonly 필드라 컴파일러가 메서드에서의 수정을 차단합니다. "초기값"과 "현재값"이 분리되어 의미가 명확합니다. IL에서 _initial 필드는 initonly 플래그가 붙어 생성자 외부에서 수정 불가능하다는 사실이 검증됩니다.
.field private initonly int32 _initial // initonly = readonly
.field private int32 _current
readonly— 읽기 전용 필드 한정자 필드를 생성자(또는 선언 시점) 안에서만 대입할 수 있고, 그 이후에는 수정 불가능하게 만든다. IL에서는initonly플래그로 표시되어 런타임이 강제한다. 불변 데이터를 보장하고 싶을 때 사용한다.
예시:private readonly int _initial = initialScore;한 번 대입한 뒤 메서드에서_initial = ...하면 컴파일 에러
규칙: 의존성처럼 절대 변하지 않아야 하는 값은 반드시 명시적인 readonly 필드로 다시 받는다. Primary Constructor의 편의는 보일러플레이트 제거이지, 불변성 보장이 아닙니다.
함정 2 — 다른 생성자를 만들면 반드시 : this(...)
Primary Constructor가 있는 클래스에 추가 생성자(오버로드)를 만들 때 규칙이 다릅니다.
// ❌ 잘못된 패턴 — base()로 우회 시도
public class Player(string name, int age)
{
public Player(string name) : base() // ❌ CS8862 — primary constructor가 있으면 this(...) 필수
{
// age 가 어떻게 초기화될지 보장이 없음
}
}
컴파일 에러: CS8862: 기본 생성자가 있는 형식의 생성자에는 'this' 생성자 이니셜라이저가 있어야 합니다.
이유는 단순합니다 — Primary Constructor의 캡처 필드(<name>P·<age>P)는 반드시 Primary Constructor 경로로만 초기화되기 때문입니다. 다른 경로를 허용하면 <age>P가 초기화되지 않은 상태로 남을 수 있습니다.
// ✅ 올바른 패턴 — : this(...) 로 Primary Constructor를 거친다
public class Player(string name, int age)
{
public Player(string name) : this(name, 0) { } // age=0 으로 Primary 호출
public Player() : this("Unknown", 0) { } // 둘 다 기본값으로
public string Greet() => $"Hi, I'm {name}, {age} years old.";
}
: this(...)는 다른 생성자 호출이 아니라 Primary Constructor를 한 번 거쳐 캡처 필드를 초기화하는 의식입니다. 이 규칙을 알면 "왜 base()를 쓸 수 없지?"라는 의문이 풀립니다.
함정 3 — 상속에서 같은 매개변수 이름의 섀도잉
Primary Constructor가 있는 base와 derived 양쪽이 같은 이름의 매개변수를 갖고, derived가 base에 그 매개변수를 그대로 넘기면 데이터가 두 번 저장됩니다.
// ❌ 위험한 패턴 — 같은 이름이 base와 derived에 모두 캡처됨
public class Animal(string name)
{
public string GetName() => name;
}
public class Dog(string name, string breed) : Animal(name)
{
public string Describe() => $"{name} is a {breed}"; // Dog의 name 사용 → Dog에 <name>P 캡처
}
var d = new Dog("Rex", "Labrador");
Console.WriteLine(d.GetName()); // "Rex" — Animal.<name>P
Console.WriteLine(d.Describe()); // "Rex is a Labrador" — Dog.<name>P
표면상 문제없어 보입니다. 그러나 Dog의 IL을 보면 base의 <name>P 와 별개로 Dog 자체에도 <name>P 가 합성됩니다.
.class public auto ansi beforefieldinit Dog extends Animal
{
.field private string '<name>P' // ← Dog 자체에도 별도 캡처 필드
.field private string '<breed>P'
// ...
}
name 문자열의 참조가 두 곳에 저장됩니다(reference type이라 메모리는 8 bytes만 늘지만, value type이라면 그만큼 인스턴스가 커집니다). 더 큰 문제는 둘 중 하나가 변경되면 다른 쪽과 동기화가 깨진다는 점입니다.
// ✅ 안전한 패턴 — derived에서는 base 멤버를 통해 접근
public class Animal(string name)
{
public string Name => name; // 명시적 프로퍼티로 노출
}
public class Dog(string name, string breed) : Animal(name)
{
public string Describe() => $"{Name} is a {breed}"; // base의 Name 프로퍼티 사용
}
Dog 안에서 name을 직접 참조하지 않으면 Dog.<name>P가 만들어지지 않습니다. base의 Name 프로퍼티 한 곳에서만 데이터를 들고 있어 동기화 문제도 사라집니다.
함정 4 — Unity Inspector 직렬화
[SerializeField]로 Inspector에 노출하려는 필드를 Primary Constructor 매개변수로 만들면 동작하지 않습니다.
// ❌ Inspector에 노출되지 않음
public class Enemy(int maxHp) : MonoBehaviour
{
// maxHp 는 캡처 필드 <maxHp>P 가 되지만 [SerializeField] 를 붙일 수 없고,
// 무엇보다 Unity가 MonoBehaviour의 생성자를 호출하지 않으므로 maxHp 는 항상 0
}
Inspector 필드는 반드시 일반 필드로 선언해야 Unity 직렬화 시스템이 인식합니다. MonoBehaviour는 Primary Constructor 자체를 쓰면 안 된다는 규칙(섹션 4)으로 회귀합니다.
6. [C# 버전별 변화] — record에서 일반 class로 확장된 여정
C# 9 — record class에 처음 등장 (2020)
// C# 9 — record에서만 가능
public record PersonR(string Name, int Age);
var p = new PersonR("A", 10);
Console.WriteLine(p.Name); // ✅ 자동 프로퍼티
이 시절 일반 class에서는 동일 문법이 컴파일 에러였습니다. record는 매개변수를 자동 init 프로퍼티로 변환하고 값 기반 비교를 추가하는 패키지였고, primary constructor 문법은 그 패키지의 일부였습니다.
C# 10 — record struct (2021)
// C# 10 — record struct 추가, 동일 문법
public record struct Vec2(float X, float Y);
값 타입 버전 record가 추가됐지만 일반 class·struct에서는 여전히 사용 불가.
C# 12 — 일반 class·struct로 확장 (2023)
// C# 12 — 처음으로 일반 class에서도 가능
public class CombatService(IRandom random, StatTable stats)
{
public int Attack() { /* random·stats 사용 */ return 0; }
}
// struct도 동일
public struct Vector(float x, float y)
{
public float Length() => MathF.Sqrt(x * x + y * y);
}
문법은 record와 똑같이 생겼지만 자동 프로퍼티는 만들어지지 않습니다. 이 차이가 record와 일반 class를 구분하는 본질입니다(섹션 4 참조). struct에 적용할 때는 매개변수 없는 추가 생성자가 있다면 반드시 : this(...) 로 Primary Constructor를 호출해 모든 캡처 필드가 초기화되도록 강제됩니다.
C# 14 — partial 확장 (2025)
C# 14에서는 partial 클래스의 여러 선언 중 하나에만 Primary Constructor를 둘 수 있다는 제약이 정리됐습니다. 코드 생성기와의 협업에서 유용합니다.
// 파일 A — 사용자 작성
public partial class ViewModel(IService service)
{
public void OnClick() { /* service 사용 */ }
}
// 파일 B — Source Generator가 생성, 여기엔 (...) 없음
public partial class ViewModel
{
private void GeneratedHandler() { /* ... */ }
}
이 경우 Source Generator가 Primary Constructor를 모르고도 partial 부분을 추가할 수 있습니다.
IL 변천 — 컴파일러 동작 비교
// C# 9 record PersonR(string Name, int Age) — 발췌
.class public auto ansi beforefieldinit PersonR
{
.field private initonly string '<Name>k__BackingField' // 자동 프로퍼티 백킹
.property instance string Name() { ... } // ✓ 자동 프로퍼티
.method virtual instance bool Equals(class PersonR) ... // ✓ 값 비교
.method virtual instance int32 GetHashCode() ...
}
// C# 12 class PersonC(string name, int age) — 발췌
.class public auto ansi beforefieldinit PersonC
{
.field private string '<name>P' // 캡처 필드만 (메서드에서 사용 시)
// 프로퍼티·Equals·GetHashCode 자동 생성 X
}
같은 한 줄을 적었는데 record 쪽 IL은 6~7개의 멤버를, class 쪽 IL은 캡처 필드만 보여 줍니다. 버전이 올라가며 문법이 확장된 것이 아니라, "비슷하게 생긴 다른 기능"이 새 영역(class·struct)에 적용된 것이라는 사실을 IL이 명확하게 증명합니다.
7. [정리]
- Primary Constructor 는 클래스 헤더에 매개변수를 적어 클래스 본문 전체의 스코프로 만드는 C# 12 기능이다.
class Player(string name) { ... }. 별도 필드 선언 없이 메서드에서name을 직접 사용한다. - 컴파일러 동작은 사용 패턴에 따라 다르다.
- 미사용 → 캡처 필드 0개 (그냥 버림)
- 이니셜라이저에서만 사용 → 캡처 필드 0개 (값을 한 번 복사)
- 메서드/프로퍼티에서 사용 →
<name>P같은 비공개 캡처 필드 합성
- 매개변수는 readonly가 아니다. 메서드 안에서 수정 가능하고, 수정하면 인스턴스 상태가 바뀐다. 불변이 필요하면
private readonly string _name = name;처럼 명시 캡처한다. - record와 문법은 같지만 의미는 다르다. record는 자동 init 프로퍼티·값 비교·
with·Deconstruct를 만들어 주지만, 일반 class는 매개변수만 살려 둔다. 데이터 노출이 필요하면 record, 동작 캡슐화가 목적이면 class + Primary Constructor. - 추가 생성자는 반드시
: this(...)로 Primary Constructor를 거쳐야 한다. 컴파일러가 모든 캡처 필드의 초기화를 강제하기 때문이다.: base(...)로 우회하면 CS8862 에러. - 상속 시 같은 매개변수 이름은 섀도잉을 만든다. base와 derived가 같은 이름의 매개변수를 캡처하면 데이터가 두 번 저장된다. derived에서는 base의 멤버(프로퍼티)를 사용하는 편이 안전하다.
- Unity 규칙: MonoBehaviour·ScriptableObject 계열에는 절대 쓰지 않는다. 엔진이 생성자를 호출하지 않아 매개변수가 초기화되지 않는다. 순수 C# 서비스/모델/매니저 클래스에서만 쓴다.
- 런타임 비용은 일반 생성자와 동일하다. Primary Constructor는 소스의 보일러플레이트를 줄이는 컴파일러 기능이지, 성능 향상 기능이 아니다.
'C# 기초' 카테고리의 다른 글
| [PART7.클래스와 객체 입문(13/21)] 구조체의 매개변수 없는 생성자와 필드 이니셜라이저 (C# 10) / 자동 기본 구조체 (C# 11 (0) | 2026.05.01 |
|---|---|
| [PART7.클래스와 객체 입문(12/21)] struct 기초 — 값 타입을 직접 정의하기 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(10/21)] Target-typed new — 왼쪽 타입을 반복하지 않는 생성 (C# 9) (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(9/21)] 객체 초기화자 — 생성과 설정을 한 표현식으로 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(8/21)] 정적(static) 멤버 — 인스턴스 없이 호출하는 코드와 데이터 (0) | 2026.05.01 |