반응형

[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) 이라는 문법을 제공합니다.

C#
var label = new { Name = "Sword", Total = 1200m };

class 키워드도 없고, 타입 이름도 안 보입니다. new { ... } 한 줄에 모양·필드·생성자·ToString까지 전부 들어 있습니다. 컴파일러가 뒤에서 임시 클래스를 만들어 주기 때문입니다.

이 글에서 풀어야 할 질문은 셋입니다.

  1. 컴파일러는 new { ... }를 보고 정확히 무엇을 만드는가? (그래서 왜 var만 받을 수 있는가)
  2. LINQ Select에서 익명 타입을 쓸 때 무엇이 같고 무엇이 다른가? (왜 이 패턴이 표준인가)
  3. record·ValueTuple이 있는데도 익명 타입을 쓸 자리는 어디인가? Unity의 Update에서는 무엇을 골라야 하는가?

2. [개념 정의] — 컴파일러가 만들어 주는 "이름 없는 가벼운 클래스"

2.1 비유: 일회용 종이 라벨

회사에서 정식 명패는 책상마다 붙이지만, 회의실에 손님이 잠깐 올 때는 종이에 이름만 적어서 놓아둡니다. 회의가 끝나면 종이는 버립니다.

익명 타입은 그 일회용 종이 라벨입니다. 정식 클래스가 책상 명패라면, new { Name = "A", Age = 10 }는 회의실 종이 라벨입니다. 이름은 회의실(메서드) 바깥으로 나가지 않고, 회의가 끝나면(메서드가 반환되면) 버려집니다.

2.2 구조 시각화

개발자가 쓰는 코드

왼쪽은 개발자가 적는 한 줄, 오른쪽은 컴파일러가 어셈블리에 실제로 박아 넣는 클래스의 모양입니다. 모든 필드가 readonly이고, Equals·GetHashCode·ToString이 자동으로 재정의됩니다.

2.3 IL로 직접 확인 — 가장 단순한 사용

C# 코드

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 — 컴파일러가 만든 익명 타입 클래스

먼저 클래스 선언부와 필드입니다.

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'

생성자는 두 필드를 채우기만 합니다.

IL
.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이 없음 = 외부에서 변경 불가).

IL
.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입니다.

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의 핵심만 보겠습니다.

IL
.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로 값 비교한다는 점입니다. 그래서 두 익명 타입 인스턴스의 모든 필드 값이 같으면 Equalstrue를 반환합니다. 이를 값 기반 동등성(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는 컴파일러가 추론하는 지역 변수 한정이라 이 제약을 우회하지 못합니다.

C#
// 컴파일 에러 — 익명 타입 이름은 적을 수 없음
// 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# 코드

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 코드

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 두 슬롯 모두 같은 타입

ab의 지역 변수 슬롯이 둘 다 <>f__AnonymousType02'<string, int32>`로 선언됩니다. 컴파일러는 두 익명 타입을 별개의 클래스로 만들지 않았습니다. 같은 제네릭 클래스의 같은 인스턴스화를 재사용합니다.

2. op_Equality(Type, Type) 결과 = True

두 인스턴스의 GetType()같은 Type 객체를 반환합니다. 따라서 다음과 같은 LINQ 패턴은 안전합니다.

C#
// 한 어셈블리 안에서 같은 모양의 익명 타입 두 결과를 합쳐도
// 컴파일러가 같은 클래스를 쓰므로 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# 코드

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 코드

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

inferredexplicitly의 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 — 임시 모양을 위해 매번 클래스를 새로 만든다

C#
// 한 메서드 안에서만 쓸 형태인데 클래스 파일이 늘어난다
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 + 익명 타입으로 즉석에서 모양을 만든다

C#
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# 코드 — 같은 데이터를 두 방식으로

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 코드

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

C#
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로 스택 위에서 처리

C#
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 — 타입 안전성을 잃는다

C#
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로 정식 타입 만들기

C#
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

C#
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 + 지역 변수로 풀어쓰기

C#
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#
// 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>의 키로 쓸 수 있습니다.

C#
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)

C#
var v = new { Id = post.Id, Author = post.Author };   // 매번 풀어서 적음

After C# (7.1+)

C#
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#
// 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다. 한 메서드 안에서만, LINQ Select의 결과에 모양을 입히는 용도로 쓰자. 메서드 밖으로 보낼 거면 record, Update에서는 ValueTuple.
반응형

+ Recent posts