[PART12.제네릭·델리게이트·람다·LINQ(16/18)] 익명 타입 — new { Name = "A", Age = 10 }
LINQ Select의 결과에 임시로 모양을 붙일 때 / 언제는 record가 더 낫나
목차
1. [문제 제기] — 한 메서드 안에서만 쓸 "이름 없는 모양"이 필요할 때
게임 클라이언트를 만들다 보면 이런 상황이 자주 옵니다.
플레이어 인벤토리에서 "재고가 1개 이상 남은 아이템의 이름과 (단가 × 수량)"만 잠깐 뽑아 UI 라벨에 채우고 싶습니다. 이 정보는 그 메서드 안에서 한 번 쓰고 버립니다. 다른 곳에서 호출되지도 않고, DTO(Data Transfer Object — 계층 사이에서 데이터만 옮기는 객체)로 격상시킬 만한 가치도 없습니다.
이걸 처리하려고 매번 class ItemDisplay { public string Name; public decimal Total; } 같은 클래스를 만들어 두는 건 부담입니다. 클래스 하나가 늘어나면 파일이 늘어나고, 이름을 짓느라 시간이 들고, 막상 다른 메서드에서는 절대 안 쓰입니다. 그렇다고 Tuple<string, decimal>을 반환하면 Item1, Item2로 접근해야 해서 호출 코드를 읽기 어렵습니다.
C#은 이 틈을 위해 익명 타입(anonymous type) 이라는 문법을 제공합니다.
var label = new { Name = "Sword", Total = 1200m };
class 키워드도 없고, 타입 이름도 안 보입니다. new { ... } 한 줄에 모양·필드·생성자·ToString까지 전부 들어 있습니다. 컴파일러가 뒤에서 임시 클래스를 만들어 주기 때문입니다.
이 글에서 풀어야 할 질문은 셋입니다.
- 컴파일러는
new { ... }를 보고 정확히 무엇을 만드는가? (그래서 왜var만 받을 수 있는가) - LINQ
Select에서 익명 타입을 쓸 때 무엇이 같고 무엇이 다른가? (왜 이 패턴이 표준인가) record·ValueTuple이 있는데도 익명 타입을 쓸 자리는 어디인가? Unity의Update에서는 무엇을 골라야 하는가?
2. [개념 정의] — 컴파일러가 만들어 주는 "이름 없는 가벼운 클래스"
2.1 비유: 일회용 종이 라벨
회사에서 정식 명패는 책상마다 붙이지만, 회의실에 손님이 잠깐 올 때는 종이에 이름만 적어서 놓아둡니다. 회의가 끝나면 종이는 버립니다.
익명 타입은 그 일회용 종이 라벨입니다. 정식 클래스가 책상 명패라면, new { Name = "A", Age = 10 }는 회의실 종이 라벨입니다. 이름은 회의실(메서드) 바깥으로 나가지 않고, 회의가 끝나면(메서드가 반환되면) 버려집니다.
2.2 구조 시각화

왼쪽은 개발자가 적는 한 줄, 오른쪽은 컴파일러가 어셈블리에 실제로 박아 넣는 클래스의 모양입니다. 모든 필드가 readonly이고, Equals·GetHashCode·ToString이 자동으로 재정의됩니다.
2.3 IL로 직접 확인 — 가장 단순한 사용
C# 코드
class Program
{
static void Case1()
{
var user = new { Name = "A", Age = 10 };
System.Console.WriteLine(user.Name);
System.Console.WriteLine(user.Age);
}
}
var— 지역 변수 타입 추론 (Implicit local variable) 컴파일러가 우변의 타입을 보고 좌변 변수의 타입을 결정한다. 익명 타입처럼 개발자가 이름을 알 수 없는 타입을 받을 때는var만이 유일한 선택지다.
예시:var user = new { Name = "A" };user의 타입은 컴파일러가 만든 익명 타입 클래스가 된다.
IL — 컴파일러가 만든 익명 타입 클래스
먼저 클래스 선언부와 필드입니다.
.class private auto ansi sealed beforefieldinit '<>f__AnonymousType0`2'<'<Name>j__TPar', '<Age>j__TPar'>
extends [System.Runtime]System.Object
{
.custom instance void System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() // 컴파일러 자동 생성 표시
.custom instance void System.Diagnostics.DebuggerDisplayAttribute::.ctor(string) // "{ Name = {Name}, Age = {Age} }"
// Fields — 두 필드 모두 initonly (readonly)
.field private initonly !'<Name>j__TPar' '<Name>i__Field'
.field private initonly !'<Age>j__TPar' '<Age>i__Field'
생성자는 두 필드를 채우기만 합니다.
.method instance void .ctor(!'<Name>j__TPar' Name, !'<Age>j__TPar' Age) cil managed
{
IL_0000: ldarg.0
IL_0001: call instance void System.Object::.ctor() // base() 호출
IL_0006: ldarg.0
IL_0007: ldarg.1
IL_0008: stfld !0 class '<>f__AnonymousType0`2'<...>::'<Name>i__Field' // Name 필드에 저장
IL_000d: ldarg.0
IL_000e: ldarg.2
IL_000f: stfld !1 class '<>f__AnonymousType0`2'<...>::'<Age>i__Field' // Age 필드에 저장
IL_0014: ret
}
프로퍼티는 get만 있습니다 (set이 없음 = 외부에서 변경 불가).
.property instance !'<Name>j__TPar' Name()
{
.get instance !0 '<>f__AnonymousType0`2'::get_Name()
}
.property instance !'<Age>j__TPar' Age()
{
.get instance !1 '<>f__AnonymousType0`2'::get_Age()
}
호출 측 Case1 메서드의 IL입니다.
.method private hidebysig static void Case1() cil managed
{
.locals init (
[0] class '<>f__AnonymousType0`2'<string, int32> // 지역 변수는 참조 타입
)
IL_0001: ldstr "A" // 스택에 "A"
IL_0006: ldc.i4.s 10 // 스택에 10
IL_0008: newobj instance void class '<>f__AnonymousType0`2'<string, int32>::.ctor(!0, !1) // 힙 할당
IL_000d: stloc.0 // user 에 저장
IL_000e: ldloc.0
IL_000f: callvirt instance !0 class '<>f__AnonymousType0`2'<string, int32>::get_Name() // 프로퍼티 호출
IL_0014: call void System.Console::WriteLine(string)
}
IL 분석 포인트
1. private auto ansi sealed + CompilerGeneratedAttribute
클래스 자체가 어셈블리 외부에서 보이지 않는 private 가시성으로 박혀 있습니다. sealed이라 상속도 못 합니다. 즉 익명 타입은 만든 어셈블리 안에서만, 그것도 컴파일러를 통해서만 다룰 수 있도록 봉인되어 있습니다.
2. 클래스 이름 <>f__AnonymousType02`
<>로 시작하는 이름은 C# 식별자 규칙으로는 만들 수 없습니다. 즉 사람이 손으로 이 타입을 적을 방법이 없습니다. 이것이 "익명"이 가능한 이유이자, 메서드 시그니처에 못 쓰는 이유입니다(2.5절에서 다시).
3. .field private initonly
initonly는 IL 레벨의 readonly 필드입니다. 생성자 안에서 한 번 stfld로 채우고 나면 다시 못 씁니다. 그래서 익명 타입 인스턴스는 불변(immutable) 이 됩니다.
4. 제네릭 클래스 <TName, TAge>
컴파일러는 익명 타입을 제네릭 클래스 하나로 만듭니다. 같은 모양(이름·순서)이면서 타입만 다른 사용처는 모두 이 한 제네릭 클래스의 다른 인스턴스화로 처리합니다. 다음 절에서 이게 왜 중요한지 봅니다.
5. newobj — 힙에 인스턴스가 만들어진다
익명 타입은 class이므로 매번 newobj로 힙에 객체가 할당됩니다. Unity의 Update에서 매 프레임 new { ... }를 하면 그 횟수만큼 GC(Garbage Collector — 메모리를 자동으로 회수하는 런타임 구성요소) 부담이 누적됩니다. 5장에서 다시 다룹니다.
2.4 자동 생성되는 Equals / GetHashCode / ToString
세 메서드 모두 IL에 그대로 박혀 있습니다. Equals의 핵심만 보겠습니다.
.method public hidebysig virtual instance bool Equals(object 'value') cil managed
{
IL_0001: isinst class '<>f__AnonymousType0`2'<...> // 같은 익명 타입인지 확인
IL_0006: stloc.0
IL_0007: ldarg.0
IL_0008: ldloc.0
IL_0009: beq.s IL_0041 // 같은 참조면 곧장 true
IL_000e: call EqualityComparer`1<TName>::get_Default() // 기본 비교자
IL_0014: ldfld ...::'<Name>i__Field' // 내 Name
IL_001a: ldfld ...::'<Name>i__Field' // 상대 Name
IL_001f: callvirt EqualityComparer`1<TName>::Equals(!0, !0) // 값 비교
...
IL_0026: call EqualityComparer`1<TAge>::get_Default()
... // Age 도 같은 방식으로 비교
IL_0042: ret
}
핵심은 EqualityComparer<T>.Default.Equals로 각 필드를 1:1로 값 비교한다는 점입니다. 그래서 두 익명 타입 인스턴스의 모든 필드 값이 같으면 Equals는 true를 반환합니다. 이를 값 기반 동등성(value-based equality) 이라 부르며, LINQ의 GroupBy·Distinct가 익명 타입을 키로 받아도 자연스럽게 동작하는 이유입니다.
ToString도 IL에 보면 ldstr "{{ Name = {0}, Age = {1} }}"로 시작해서 두 필드를 String.Format에 넘깁니다. 그래서 디버거에 { Name = A, Age = 10 } 형태로 찍힙니다.
2.5 메서드 밖으로 못 나가는 이유 — 한 줄 요약
<>f__AnonymousType0라는 이름은 C# 코드에서 적을 수 없습니다. 메서드 시그니처에는 타입 이름이 필요한데 그 이름을 적을 수단이 없으니, 익명 타입은 메서드의 매개변수·반환 타입으로 등장할 수 없습니다. var는 컴파일러가 추론하는 지역 변수 한정이라 이 제약을 우회하지 못합니다.
// 컴파일 에러 — 익명 타입 이름은 적을 수 없음
// public new { string Name, int Age } GetUser() { ... }
// object 반환은 가능하지만 호출 측에서 Name·Age 에 접근하려면
// 리플렉션이나 dynamic 을 써야 한다. 타입 안전성을 잃는다.
public object GetUserAsObject()
{
return new { Name = "Bad", Age = 99 };
}
이 제약 자체가 익명 타입의 설계 의도를 드러냅니다 — "한 메서드 안에서만 쓰고 버리세요. 메서드 경계를 넘어 쓸 거면 정식 타입을 만드세요."
3. [내부 동작] — 같은 모양은 클래스 하나로 합쳐진다
3.1 어셈블리 단위 캐싱
컴파일러는 어셈블리 안에서 프로퍼티 이름·타입·순서가 완벽히 같은 익명 타입은 클래스 하나만 만들고 재사용합니다. 다르면 별개 클래스를 추가로 생성합니다.

이 동작을 IL로 확인합니다.
C# 코드
class Program
{
static void Case2()
{
var a = new { Name = "Tony", Age = 53 };
var b = new { Name = "Steve", Age = 105 };
System.Console.WriteLine(a.GetType() == b.GetType());
}
}
IL 코드
.method private hidebysig static void Case2() cil managed
{
.locals init (
[0] class '<>f__AnonymousType0`2'<string, int32>, // 두 변수 타입이 동일
[1] class '<>f__AnonymousType0`2'<string, int32> // 같은 클래스
)
IL_0001: ldstr "Tony"
IL_0006: ldc.i4.s 53
IL_0008: newobj instance void class '<>f__AnonymousType0`2'<string, int32>::.ctor(!0, !1) // a = new ...
IL_000d: stloc.0
IL_000e: ldstr "Steve"
IL_0013: ldc.i4.s 105
IL_0015: newobj instance void class '<>f__AnonymousType0`2'<string, int32>::.ctor(!0, !1) // b = new ... (같은 ctor!)
IL_001a: stloc.1
IL_001b: ldloc.0
IL_001c: callvirt instance class System.Type System.Object::GetType()
IL_0021: ldloc.1
IL_0022: callvirt instance class System.Type System.Object::GetType()
IL_0027: call bool System.Type::op_Equality(class System.Type, class System.Type)
IL_002c: call void System.Console::WriteLine(bool)
// 출력: True
}
IL 분석 포인트
1. .locals init 두 슬롯 모두 같은 타입
a와 b의 지역 변수 슬롯이 둘 다 <>f__AnonymousType02'<string, int32>`로 선언됩니다. 컴파일러는 두 익명 타입을 별개의 클래스로 만들지 않았습니다. 같은 제네릭 클래스의 같은 인스턴스화를 재사용합니다.
2. op_Equality(Type, Type) 결과 = True
두 인스턴스의 GetType()이 같은 Type 객체를 반환합니다. 따라서 다음과 같은 LINQ 패턴은 안전합니다.
// 한 어셈블리 안에서 같은 모양의 익명 타입 두 결과를 합쳐도
// 컴파일러가 같은 클래스를 쓰므로 Concat 가능
var fromA = listA.Select(x => new { x.Id, x.Name });
var fromB = listB.Select(x => new { x.Id, x.Name });
var merged = fromA.Concat(fromB); // 같은 익명 타입이라 OK
3. 어셈블리 단위 — 다른 어셈블리는 별개
이 캐싱은 어셈블리 단위입니다. 라이브러리 어셈블리에서 만든 익명 타입과 게임 어셈블리에서 만든 익명 타입은 이름·타입·순서가 같아도 다른 클래스입니다. 그래서 서로 다른 어셈블리 사이에서 익명 타입을 주고받을 수 없는 것은 문법적 제약뿐 아니라 런타임 정체성도 다르기 때문입니다.
3.2 속성 이름 추론 — 같은 IL이 나온다 (C# 7.1+)
C# 7.1부터 new { x.Id }처럼 적으면 new { Id = x.Id }와 같은 의미로 처리됩니다. 정말로 똑같이 컴파일되는지 IL로 확인합니다.
C# 코드
class Person
{
public int Id { get; set; }
public string Name { get; set; }
}
class Program
{
static void Case3()
{
var p = new Person { Id = 1, Name = "A" };
var inferred = new { p.Id, p.Name }; // 추론
var explicitly = new { Id = p.Id, Name = p.Name }; // 명시
System.Console.WriteLine(inferred.Id);
System.Console.WriteLine(explicitly.Id);
}
}
IL 코드
.locals init (
[0] class Person,
[1] class '<>f__AnonymousType1`2'<int32, string>, // inferred
[2] class '<>f__AnonymousType1`2'<int32, string> // explicitly — 같은 클래스
)
// inferred = new { p.Id, p.Name }
IL_001b: ldloc.0
IL_001c: callvirt instance int32 Person::get_Id()
IL_0021: ldloc.0
IL_0022: callvirt instance string Person::get_Name()
IL_0027: newobj instance void class '<>f__AnonymousType1`2'<int32, string>::.ctor(!0, !1)
IL_002c: stloc.1
// explicitly = new { Id = p.Id, Name = p.Name }
IL_002d: ldloc.0
IL_002e: callvirt instance int32 Person::get_Id()
IL_0033: ldloc.0
IL_0034: callvirt instance string Person::get_Name()
IL_0039: newobj instance void class '<>f__AnonymousType1`2'<int32, string>::.ctor(!0, !1)
IL_003e: stloc.2
IL 분석 포인트
1. 두 표현이 동일한 IL
inferred와 explicitly의 IL 시퀀스가 줄 단위로 정확히 같습니다. newobj로 호출되는 생성자도 동일한 <>f__AnonymousType12<int32, string>::.ctor입니다. 즉 추론 문법은 단순한 코드 단축일 뿐 런타임에 다른 동작을 만들지 않습니다.
2. <>f__AnonymousType1 — 케이스 1·2와는 다른 클래스
케이스 1·2의 익명 타입은 (Name, Age) = (string, int)였고, 케이스 3은 (Id, Name) = (int, string)입니다. 프로퍼티 이름과 타입 순서가 다르므로 컴파일러가 별개 제네릭 클래스 <>f__AnonymousType1을 추가로 만들었습니다.
3. 추론 가능한 식의 형태
이 추론은 단순 식별자·멤버 접근 식에만 동작합니다. 즉 new { x.Id }·new { post.Author }는 됩니다. 하지만 new { x.Id + 1 }이나 new { foo() }는 이름을 추론할 근거가 없으므로 컴파일 오류 — 이름을 명시해야 합니다.
4. [실전 적용] — LINQ Select에서 임시 모양을 만들 때
4.1 핵심 사용처: 데이터 형태 만들기 (Data Shaping)
익명 타입이 가장 빛나는 자리는 LINQ 쿼리의 Select 절입니다. 큰 객체에서 필요한 몇 개 필드만 뽑거나, 계산된 값을 곁들여 임시 형태를 만들 때 일회용 클래스를 선언하지 않아도 됩니다.
Before — 임시 모양을 위해 매번 클래스를 새로 만든다
// 한 메서드 안에서만 쓸 형태인데 클래스 파일이 늘어난다
public class ItemSummary
{
public string Name;
public decimal Total;
}
public IEnumerable<ItemSummary> GetItemSummaries(List<Item> items)
{
var result = new List<ItemSummary>();
foreach (var i in items)
{
if (i.Stock > 0)
result.Add(new ItemSummary { Name = i.Name, Total = i.Price * i.Stock });
}
return result;
}
문제는 이 ItemSummary 클래스가 다른 어디에서도 안 쓰인다는 점입니다. 비슷한 임시 형태가 필요할 때마다 클래스가 늘어납니다.
After — LINQ + 익명 타입으로 즉석에서 모양을 만든다
public void RenderItemLabels(List<Item> items)
{
var summaries = items
.Where(i => i.Stock > 0)
.Select(i => new {
i.Name, // 속성 이름 추론 (C# 7.1+)
Total = i.Price * i.Stock // 계산된 값
});
foreach (var s in summaries)
SetLabel($"{s.Name}: {s.Total:C}");
}
이 메서드 안에서만 쓰고 끝나는 형태이므로 익명 타입이 정확히 맞습니다. Select 안의 new { ... }는 매 항목마다 호출되며, 그 결과는 다른 메서드로 새어 나가지 않습니다.
4.2 IL 비교 — 익명 타입 vs ValueTuple
여러 값을 임시로 묶고 싶을 때 후보가 둘 더 있습니다 — ValueTuple (struct, 값 타입)과 record (정식 타입, 이름 있음). 익명 타입과 ValueTuple의 메모리·할당 차이는 IL을 보면 한눈에 들어옵니다.
C# 코드 — 같은 데이터를 두 방식으로
class Program
{
static void Case4Anon()
{
var x = new { Name = "A", Age = 10 }; // 익명 타입 (class)
System.Console.WriteLine(x.Name);
}
static void Case4Tuple()
{
var x = (Name: "A", Age: 10); // ValueTuple (struct)
System.Console.WriteLine(x.Name);
}
}
IL 코드
// 익명 타입 — 힙에 객체를 새로 만든다
.method private hidebysig static void Case4Anon() cil managed
{
.locals init (
[0] class '<>f__AnonymousType0`2'<string, int32> // 참조 타입 슬롯
)
IL_0001: ldstr "A"
IL_0006: ldc.i4.s 10
IL_0008: newobj instance void class '<>f__AnonymousType0`2'<string, int32>::.ctor(!0, !1) // ★ 힙 할당
IL_000d: stloc.0
IL_000e: ldloc.0
IL_000f: callvirt instance !0 class '<>f__AnonymousType0`2'<string, int32>::get_Name() // 가상 호출
IL_0014: call void System.Console::WriteLine(string)
}
// ValueTuple — 스택 슬롯 위에서 그대로 초기화한다
.method private hidebysig static void Case4Tuple() cil managed
{
.locals init (
[0] valuetype System.ValueTuple`2<string, int32> // 값 타입 슬롯
)
IL_0001: ldloca.s 0 // ★ 슬롯 주소
IL_0003: ldstr "A"
IL_0008: ldc.i4.s 10
IL_000a: call instance void valuetype System.ValueTuple`2<string, int32>::.ctor(!0, !1) // ★ newobj 아님, 슬롯 위에서 초기화
IL_000f: ldloc.0
IL_0010: ldfld !0 valuetype System.ValueTuple`2<string, int32>::Item1 // 필드 직접 읽기
IL_0015: call void System.Console::WriteLine(string)
}
IL 분석 포인트
1. newobj vs 슬롯 초기화
익명 타입은 newobj로 힙에 객체를 만듭니다. 이 객체는 GC가 회수해야 합니다. ValueTuple은 ldloca.s 0으로 이미 잡혀 있는 스택 슬롯의 주소를 받아 그 자리에서 생성자를 호출합니다. 힙 할당 0건, GC 부담 0건.
2. callvirt get_Name vs ldfld Item1
익명 타입은 프로퍼티 호출입니다(callvirt). ValueTuple은 필드를 바로 읽습니다(ldfld). JIT 입장에서 후자가 인라이닝하기 더 쉽습니다.
3. 메서드 경계로 넘기면 무엇이 달라지나
익명 타입 인스턴스를 메서드에 넘기면 참조(8바이트 포인터)만 복사됩니다 — 빠릅니다. ValueTuple을 넘기면 구조체 전체가 복사됩니다 — 큰 튜플(필드 6개 이상)이라면 복사 비용이 발생합니다. 작은 임시 데이터라면 ValueTuple이 거의 항상 유리하고, 익명 타입은 LINQ 같은 이미 제네릭/람다로 감싸진 맥락에서 쓰임새가 있습니다.
4.3 Unity 실전 — 핫패스에서 익명 타입을 피하는 패턴
Unity의 Update·FixedUpdate·LateUpdate는 매 프레임 호출됩니다. 60fps 기준 1초에 60번, 1분에 3,600번입니다. 여기서 일어나는 힙 할당은 GC 스파이크(GC가 한 번에 큰 메모리를 회수하느라 프레임이 끊기는 현상)의 직접 원인입니다.
Before — 매 프레임 익명 타입 newobj
public class HudUpdater : MonoBehaviour
{
void Update()
{
// 매 프레임 newobj 한 번 = 매초 60개 가비지 발생
var snapshot = new { Health = _player.HP, Mana = _player.MP };
UpdateLabels(snapshot.Health, snapshot.Mana);
}
void UpdateLabels(int health, int mana) { /* ... */ }
}
이 패턴은 모바일 환경(특히 IL2CPP — Unity가 C# 코드를 C++로 변환해 빌드하는 백엔드)에서 GC 동작이 메인 스레드에 영향을 주기 쉬우므로 더 위험합니다.
After — ValueTuple로 스택 위에서 처리
public class HudUpdater : MonoBehaviour
{
void Update()
{
// 스택 위에서 묶고 푼다. 힙 할당 없음.
var snapshot = (Health: _player.HP, Mana: _player.MP);
UpdateLabels(snapshot.Health, snapshot.Mana);
}
void UpdateLabels(int health, int mana) { /* ... */ }
}
판단 기준
| 상황 | 권장 |
|---|---|
| 한 번 호출되는 초기화·UI 빌드 코드 | 익명 타입 OK |
Update·FixedUpdate 등 매 프레임 호출 |
ValueTuple |
LINQ Select로 임시 모양을 만들 때 |
익명 타입 (가독성 우선) |
| 매 프레임 LINQ를 쓰는 핫패스 | LINQ 자체를 제거하거나, 결과를 캐싱 |
보충: LINQ 호출 자체도 IEnumerable<T> 객체 등 추가 할당을 일으킵니다. 매 프레임 LINQ는 익명 타입이 아니어도 GC 부담이 됩니다. LINQ 사용 가이드는 PART 12의 "지연 실행" 글을 참고하세요.
5. [함정과 주의사항]
5.1 ❌ 익명 타입을 메서드 밖으로 빼내려고 object 반환
Before — 타입 안전성을 잃는다
public object GetPlayerInfo()
{
return new { Name = _player.Name, HP = _player.HP };
}
// 호출 측 — 컴파일러가 익명 타입 모양을 모르므로 .Name 접근 불가
public void PrintInfo()
{
var info = GetPlayerInfo();
// System.Console.WriteLine(info.Name); // 컴파일 에러
// 리플렉션이나 dynamic 으로 우회해야 한다 — 둘 다 비싸고 위험
}
object로 반환된 익명 타입은 호출 측에서 멤버에 접근할 방법이 없습니다. dynamic을 쓰면 접근은 되지만 컴파일 타임 검사가 사라지고, 모바일 환경에서 IL2CPP는 dynamic을 거의 지원하지 않습니다(AOT — 미리 컴파일하는 방식이라 런타임 코드 생성을 못 함).
✅ After — record로 정식 타입 만들기
public record PlayerInfo(string Name, int HP);
public PlayerInfo GetPlayerInfo()
=> new(_player.Name, _player.HP);
public void PrintInfo()
{
var info = GetPlayerInfo();
System.Console.WriteLine(info.Name); // OK
}
원칙: 익명 타입을 메서드 밖으로 보내고 싶다는 욕구가 들면, 그 순간이 record로 격상해야 할 신호입니다.
5.2 ❌ Update에서 익명 타입을 LINQ 결과에 매번 만든다
Before — 매 프레임 newobj × N
void Update()
{
var visible = _enemies
.Where(e => e.IsVisible)
.Select(e => new { e.Name, Distance = (e.Position - transform.position).magnitude });
foreach (var v in visible)
DrawLabel(v.Name, v.Distance);
}
Where·Select 자체의 IEnumerable 객체 + 항목마다 익명 타입 객체 = 한 프레임에 보이는 적 수만큼의 가비지가 누적됩니다.
✅ After — foreach + 지역 변수로 풀어쓰기
void Update()
{
foreach (var e in _enemies)
{
if (!e.IsVisible) continue;
var distance = (e.Position - transform.position).magnitude; // 스택
DrawLabel(e.Name, distance);
}
}
LINQ의 가독성을 잃지만 GC 부담이 사라집니다. 핫패스에서는 거의 항상 이 거래가 옳습니다.
5.3 익명 타입은 가변이 아닙니다 — 그래서 with 가 필요
익명 타입의 모든 프로퍼티는 get만 있으므로 user.Name = "B"; 같은 직접 변경은 컴파일 오류입니다.
with— 비파괴적 변경 식 (Non-destructive mutation) 기존 객체를 그대로 두고, 일부 프로퍼티만 바꾼 새 인스턴스를 만드는 식.record에 처음 도입되었고, C# 10부터 익명 타입에도 적용된다.
예시:var p2 = p1 with { Age = 11 };p1은 그대로 두고 Age만 바뀐 새 인스턴스p2생성
// C# 10 이상
var p1 = new { Name = "A", Age = 10 };
var p2 = p1 with { Age = 11 }; // p1: Age=10, p2: Age=11
// C# 9 이하에서는 컴파일 에러 — record 로 바꿔서 처리해야 함
참고:with는 컴파일러가 내부적으로 새 인스턴스를newobj로 만들고 변경된 필드만 새 값으로, 나머지는 기존 값으로 채우는 코드를 펼쳐 줍니다. 즉with한 번마다 힙 할당 한 번이 발생합니다. 핫패스에서with를 반복 호출하는 것은 매번new { ... }하는 것과 같은 비용입니다.
5.4 Dictionary 키로 쓸 수는 있지만 — 어셈블리 경계를 넘기지 마세요
익명 타입은 Equals·GetHashCode가 자동으로 구현되어 있어 Dictionary<TKey, TValue>의 키로 쓸 수 있습니다.
var counter = new Dictionary<object, int>();
counter[new { Region = "KR", Tier = 5 }] = 100;
counter[new { Region = "KR", Tier = 5 }] += 1; // 같은 키로 인식
다만 키 타입을 object로 두면 어셈블리 경계 너머에서 같은 모양으로 만든 키와는 매칭되지 않습니다(3.1절 참고). 장기 보관용 컬렉션의 키로는 record·정식 클래스를 쓰는 것이 안전합니다.
6. [C# 버전별 변화]
| C# 버전 | 변화 |
|---|---|
| 3.0 (2007) | 익명 타입 도입. LINQ와 함께 등장. 모든 프로퍼티 readonly, Equals/GetHashCode/ToString 자동 생성 |
| 7.1 (2017) | 속성 이름 추론(new { x.Id } ⇔ new { Id = x.Id }) |
| 10 (2021) | 익명 타입에 with 표현식 지원 (이전까지는 record만 가능) |
6.1 C# 3.0 → 7.1 — 속성 이름 추론
Before C# (~3.0)
var v = new { Id = post.Id, Author = post.Author }; // 매번 풀어서 적음
After C# (7.1+)
var v = new { post.Id, post.Author }; // 멤버 이름이 그대로 프로퍼티 이름
IL — 양쪽이 동일
3.2절의 IL 분석에서 확인했듯, 두 표현은 줄 단위로 정확히 같은 IL로 컴파일됩니다. 즉 순수 문법 단축이며 런타임 비용 차이는 없습니다.
6.2 C# 9 → 10 — 익명 타입에도 with
C# 9에서 with 표현식이 record에 처음 도입되었고, C# 10에서 익명 타입에도 확장되었습니다.
// C# 9 이하 — 컴파일 에러
// var p2 = p1 with { Age = 11 };
// C# 10+ — 가능
var p1 = new { Name = "A", Age = 10 };
var p2 = p1 with { Age = 11 };
with로 만든 새 인스턴스는 newobj를 통해 힙에 새로 만들어집니다. 익명 타입이 불변인 성질은 그대로이고, "변경"은 항상 새 인스턴스 생성을 의미합니다.
주의: 이 글의 IL 분석은 .NET 10 SDK + C# 13 환경에서 수행했으므로with가 동작합니다. Unity의 기본 C# 버전이 9 이하라면with가 없을 수 있습니다 — 프로젝트의 C# 언어 버전을 확인하세요.
7. [정리]
이 글에서 다룬 핵심을 압축합니다.
| 항목 | 핵심 |
|---|---|
| 정체 | 컴파일러가 만드는 internal sealed class (제네릭). 모든 필드 readonly, Equals/GetHashCode/ToString 자동 override |
| 이름 | 사람이 적을 수 없는 형태(<>f__AnonymousType0). 그래서 메서드 시그니처 불가 — 메서드 안 지역 한정 |
| 재사용 | 같은 어셈블리 안에서 이름·타입·순서가 같으면 같은 클래스 인스턴스로 처리 |
| 할당 | class이므로 new { ... } 한 번 = 힙 할당 한 번. Unity Update에서는 GC 위험 |
| 주요 용도 | LINQ Select에서 임시 모양 만들기 |
| 속성 이름 추론 | C# 7.1+. new { x.Id } = new { Id = x.Id } (IL 동일) |
with |
C# 10+에서 가능. 새 인스턴스를 만들므로 newobj 한 번 발생 |
vs record |
메서드 밖으로 보낼 거면 record |
vs ValueTuple |
핫패스·작은 임시 묶음은 ValueTuple (스택 할당, GC 없음) |
결정 흐름

이 한 장으로 충분합니다 — 메서드 밖이면 record, 핫패스면 ValueTuple, 그 외 LINQ에서 임시 모양은 익명 타입.
마지막으로 기억할 한 줄
익명 타입은 컴파일러가 우리 대신 만드는internal sealed class다. 한 메서드 안에서만, LINQSelect의 결과에 모양을 입히는 용도로 쓰자. 메서드 밖으로 보낼 거면record,Update에서는ValueTuple.