반응형

[PART12.제네릭·델리게이트·람다·LINQ(10/18)] 람다 자연 타입·반환 타입·특성 (C# 10)

람다는 더 이상 "타입 없는 코드 조각"이 아니다 / var에 대입할 수 있고, 반환 타입을 선언할 수 있고, 특성도 붙일 수 있다


1. [문제 제기] — 람다는 왜 var에 못 담겼는가

Unity에서 버튼 콜백을 등록하는 코드를 떠올려 봅니다. 우리는 람다를 매일 씁니다.

C#
button.onClick.AddListener(() => StartGame());

여기까지는 자연스럽습니다. 그런데 이 람다를 잠깐 변수에 담아 두고 싶다고 가정합니다. C# 9까지는 이런 코드가 컴파일 에러였습니다.

C#
// C# 9 이전 — 에러
var handler = () => StartGame();
//   ^^^^^^^
// CS0815: 암시적으로 형식화된 변수에 람다 식을 할당할 수 없습니다

왜 에러일까요. 이유는 단순합니다. 같은 람다 한 줄이라도 받는 쪽이 무엇인지에 따라 의미가 달라지기 때문입니다.

C#
Action a = () => StartGame();          // void 반환 델리게이트
Func<Task> f = () => StartGame();      // Task 반환 델리게이트
UnityAction u = () => StartGame();     // Unity가 정의한 별도 델리게이트
Expression<Action> e = () => StartGame(); // 표현식 트리

람다 자체에는 타입이 없었습니다. 컴파일러는 좌변의 타입 힌트를 보고서야 람다를 어떤 델리게이트로 변환할지 결정할 수 있었습니다. 좌변이 var이면 힌트가 사라지므로 결정 자체가 불가능했죠.

여기서 두 번째 문제가 따라옵니다. Unity 신입 개발자가 자주 마주치는 상황입니다.

C#
// 메서드 인자로 람다를 넘길 때, 컴파일러가 어떤 오버로드를 골라야 할까?
Schedule(() => null);   // null만 보고는 반환 타입을 모름
Schedule(b => b ? 1 : null); // 가지마다 타입이 다름

세 번째 문제는 더 미묘합니다. 메서드라면 당연히 [MethodImpl(AggressiveInlining)] 같은 특성을 붙여 JIT(Just-In-Time, 런타임에 IL을 기계어로 변환하는 컴파일러) 힌트를 줄 수 있습니다. 그런데 람다에는 못 붙였습니다. 내부적으로는 람다도 메서드인데, 문법적으로 길이 막혀 있었던 겁니다.

C# 10은 이 세 가지를 한꺼번에 풀었습니다. 자연 타입(natural type), 명시적 반환 타입(explicit return type), 람다 특성(lambda attribute) 이 그것입니다.

참고: 람다식 자체의 기본 문법, 클로저, 정적 람다(C# 9), 폐기 매개변수(C# 9)는 PART 12의 [06]~[09]에서 다뤘습니다. 이번 글은 C# 10에서 새로 추가된 세 가지 기능만 깊이 파고듭니다.

2. [개념 정의] — 자연 타입·명시적 반환 타입·람다 특성

2.1 자연 타입 (Natural Type)

비유부터 시작합니다. 식당에서 "스테이크 주세요"라고 주문하면 점원이 굽기·소스·사이드를 묻습니다. 옛날 람다는 이 점원과 같았습니다. 좌변이 시그니처를 알려 주지 않으면 주문을 받지 못했죠. 자연 타입은 점원이 시그니처를 보고 가장 자연스러운 메뉴를 알아서 골라 주는 기능입니다.

람다 표현식
C#
// C# 10 — 자연 타입 추론으로 var 대입이 가능
var addOne = (int x) => x + 1;       // Func<int, int>로 추론
var greet  = () => System.Console.WriteLine("Hi"); // Action으로 추론
var sum    = (int a, int b) => a + b;              // Func<int, int, int>로 추론

System.Console.WriteLine(addOne(5)); // 6 출력

매개변수 타입을 명시한 점에 주목합니다. 컴파일러가 시그니처를 알 수 있어야 자연 타입이 추론됩니다.

2.2 명시적 반환 타입 (Explicit Return Type)

자연 타입이 만능은 아닙니다. 반환 값의 타입이 모호한 경우 — 예컨대 조건식의 두 가지가 서로 다른 타입을 내거나, null/default가 섞여 있을 때 — 컴파일러는 단일 반환 타입을 결정하지 못합니다. 이때 람다 매개변수 목록 에 반환 타입을 직접 적어 결정해 줍니다.

C#
// 매개변수 목록 (...) 앞에 int? 라고 반환 타입 선언
var choose = int? (bool b) => b ? 1 : null;
//           ^^^^
//           명시적 반환 타입

System.Console.WriteLine(choose(true));  // 1
System.Console.WriteLine(choose(false)); // (null)
int? — 널 가능 값 형식 (Nullable value type) 값 형식인 intnull 상태를 추가로 허용하는 타입. 내부적으로는 Nullable<int> 구조체로 컴파일되며, 값이 없음을 표현해야 할 때 사용합니다.
예시: int? score = null; — score는 정수이거나 null

이 문법이 없던 C# 9에서는 위 람다를 받으려면 항상 좌변에 명시적 델리게이트 타입(Func<bool, int?>)을 써야 했습니다. C# 10에서는 람다 자체가 자기 반환 타입을 표현할 수 있게 된 셈입니다.

2.3 람다 특성 (Lambda Attribute)

세 번째는 가장 직관적입니다. 람다 앞에 대괄호로 특성을 붙입니다. 컴파일러는 람다를 일반 메서드로 변환할 때 그 특성을 메서드에 그대로 붙입니다.

[MethodImpl(AggressiveInlining)] — 적극적 인라이닝 힌트 JIT 컴파일러에게 "이 메서드를 호출 위치에 직접 펼쳐 넣어 호출 오버헤드를 없애라"고 요청하는 특성. 핫패스의 작은 함수에 사용합니다.
예시: 매 프레임 호출되는 짧은 계산 함수에 부착
C#
using System.Runtime.CompilerServices;

// 람다 매개변수 목록 앞에 [...] 로 특성 부착
var doubleIt = [MethodImpl(MethodImplOptions.AggressiveInlining)] (int x) => x * 2;

System.Console.WriteLine(doubleIt(7)); // 14

매개변수에도 따로 부착할 수 있습니다.

C#
using System.Diagnostics.CodeAnalysis;

var len = ([NotNull] string? s) => s.Length;
// s가 null이 아님을 분석기에 알리는 특성을 람다 매개변수에 적용

세 기능의 공통점은 명확합니다. 람다를 메서드와 동등하게 취급하기 시작했다는 점입니다. 이 변화는 단순한 문법 설탕이 아니라 컴파일러가 람다를 다루는 방식 자체에 영향을 줍니다. 다음 섹션에서 IL(Intermediate Language, C# 컴파일러가 생성하는 .NET 중간 언어)을 직접 들여다봅니다.


3. [내부 동작] — 컴파일러가 만들어 내는 <>c 디스플레이 클래스

C# 10에서 추가된 세 기능은 모두 이미 존재하던 컴파일러 변환 위에서 동작합니다. 람다는 옛날부터 컴파일러가 <>c라는 숨겨진 클래스 안의 메서드로 변환했습니다. C# 10은 그 변환 결과에 약간의 메타데이터를 더 얹는 것뿐입니다.

원본 C# 코드

위 SVG의 흐름을 실제 IL로 확인합니다. 다음 세 가지 예시를 한 파일에 모아 컴파일했습니다.

C#
using System;
using System.Runtime.CompilerServices;

public class Program
{
    public static void Main()
    {
        // 1. 자연 타입 추론
        var addOne   = (int x) => x + 1;

        // 2. 명시적 반환 타입
        var getLength = int (string s) => s.Length;

        // 3. 람다에 [MethodImpl] 특성 부착
        var doubleIt  = [MethodImpl(MethodImplOptions.AggressiveInlining)] (int x) => x * 2;

        Console.WriteLine(addOne(5));
        Console.WriteLine(getLength("hello"));
        Console.WriteLine(doubleIt(7));
    }
}

ilspycmd로 디컴파일한 IL은 다음과 같습니다(핵심만 발췌).

IL
.class nested private auto ansi sealed beforefieldinit '<>c'
{
    .custom instance void [System.Runtime]CompilerGeneratedAttribute::.ctor() = ( ... )

    // 캐싱 필드 — 람다마다 하나씩
    .field public static initonly class Program/'<>c' '<>9'
    .field public static class Func`2<int32, int32>  '<>9__0_0'   // addOne
    .field public static class Func`2<string, int32> '<>9__0_1'   // getLength
    .field public static class Func`2<int32, int32>  '<>9__0_2'   // doubleIt

    // 1) 자연 타입 — 평범한 인스턴스 메서드
    .method assembly hidebysig instance int32 '<Main>b__0_0' (int32 x) cil managed
    {
        IL_0000: ldarg.1    // x
        IL_0001: ldc.i4.1   // 1
        IL_0002: add
        IL_0003: ret
    }

    // 2) 명시적 반환 타입(int) — 시그니처에 int32가 그대로 박힘
    .method assembly hidebysig instance int32 '<Main>b__0_1' (string s) cil managed
    {
        IL_0000: ldarg.1
        IL_0001: callvirt instance int32 [System.Runtime]System.String::get_Length()
        IL_0006: ret
    }

    // 3) [MethodImpl(AggressiveInlining)] — IL 메서드 헤더에 aggressiveinlining 플래그가 그대로 부착
    .method assembly hidebysig instance int32 '<Main>b__0_2' (int32 x) cil managed aggressiveinlining
    {
        IL_0000: ldarg.1
        IL_0001: ldc.i4.2
        IL_0002: mul
        IL_0003: ret
    }
}
IL
// Main 메서드의 첫 람다 호출 부분 — addOne(5)
IL_0000: ldsfld     class Func`2<int32, int32> Program/'<>c'::'<>9__0_0'  // 캐시 로드
IL_0005: dup
IL_0006: brtrue.s   IL_001f                                                // 캐시가 있으면 점프
IL_0008: pop
IL_0009: ldsfld     class Program/'<>c' Program/'<>c'::'<>9'              // 싱글톤 인스턴스
IL_000e: ldftn      instance int32 Program/'<>c'::'<Main>b__0_0'(int32)   // 메서드 포인터
IL_0014: newobj     instance void Func`2<int32, int32>::.ctor(...)        // 첫 호출 1회만 할당
IL_0019: dup
IL_001a: stsfld     class Func`2<int32, int32> Program/'<>c'::'<>9__0_0'  // 캐시에 저장
IL_001f: ldc.i4.5
IL_0020: callvirt   instance !1 Func`2<int32, int32>::Invoke(!0)

세 가지 핵심을 짚습니다.

  1. 자연 타입의 실체: addOnevar로 선언됐지만, IL에서 캐싱 필드의 타입은 정확히 Func<int32,int32>로 박혀 있습니다. 컴파일러는 람다 시그니처를 보고 Func<int,int>라고 결정한 뒤 그 결과를 변수 타입으로 사용했습니다.
  2. 명시적 반환 타입의 효과: getLength도 동일한 패턴이지만, 시그니처가 Func<string,int32>로 결정됐습니다. int라고 람다 앞에 적은 결과가 IL의 <Main>b__0_1 메서드 반환 타입으로 그대로 박혔습니다.
  3. 특성 부착의 결과: <Main>b__0_2 메서드 헤더 끝에 aggressiveinlining 플래그가 붙었습니다. 람다 앞에 쓴 [MethodImpl]이 컴파일러가 만든 숨겨진 메서드의 IL 메타데이터로 그대로 옮겨진 것입니다.

세 가지 모두 <>c 캐싱 패턴은 동일하다는 점이 중요합니다. 캐싱 패턴은 외부 변수를 캡처하지 않는 람다(=비캡처 람다)에만 적용됩니다. 클로저가 있으면 다른 클래스(<>c__DisplayClass)가 매 호출 시 힙에 할당되지만, 그 이야기는 PART 12 [07] 클로저 글에서 이미 다뤘습니다. C# 10의 세 기능 자체는 캐싱 동작을 바꾸지 않습니다.


4. [실전 적용] — Unity에서 자연스럽게 더 잘 쓰기

4.1 Before/After — UI 콜백 등록

Unity 프로젝트에서 흔한 패턴입니다. 버튼 콜백을 만들고, 나중에 해제할 수 있도록 변수에 보관합니다.

C#
// Before — C# 9 이전: 항상 좌변 타입을 명시
public class StartScreen : MonoBehaviour
{
    public Button startButton;

    private UnityAction _handler;

    void OnEnable()
    {
        _handler = () => GameManager.Instance.StartNewGame();
        startButton.onClick.AddListener(_handler);
    }

    void OnDisable()
    {
        startButton.onClick.RemoveListener(_handler);
    }
}
C#
// After — C# 10: var로 자연 타입 추론. UnityAction이 아닌 Action으로 추론되지만,
// AddListener/RemoveListener는 UnityAction을 요구하므로 이 경우엔 좌변 타입을 그대로 유지.
// 단, 자체 등록·해제 로직을 우리가 만들 때는 var로 충분.

public class EventBus
{
    private System.Action<int> _onScoreChanged;

    public void Subscribe()
    {
        // C# 10 — 매개변수 타입만 적으면 컴파일러가 Action<int>로 추론
        var handler = (int score) => UnityEngine.Debug.Log($"Score: {score}");
        _onScoreChanged += handler;
    }
}

After 코드의 IL은 위 섹션 3에서 본 캐싱 패턴 그대로입니다. Subscribe() 메서드를 100번 호출해도 handler 델리게이트 인스턴스는 첫 호출에서 한 번만 할당됩니다(외부 변수를 캡처하지 않으므로).

IL
// Subscribe 호출 시 — 캐시가 있으면 newobj가 실행되지 않음
IL_0000: ldsfld    class Action`1<int32> EventBus/'<>c'::'<>9__N_0'
IL_0005: brtrue.s  IL_001f    // 캐시 히트 — 점프
// ... 캐시 미스 시에만 newobj 실행

요지는 단순합니다. 자연 타입 덕분에 var로 람다를 잡아도 캐싱 동작은 그대로입니다. Unity 핫패스에서 안심하고 사용할 수 있다는 뜻입니다.

4.2 Before/After — 핫패스 인라이닝 힌트

매 프레임 호출되는 변환 함수를 람다로 분리하고 싶을 때, 호출 오버헤드가 걱정되는 경우가 있습니다. C# 10에서는 람다에 직접 [MethodImpl]을 붙일 수 있습니다.

C#
// Before — 람다에 특성을 붙일 수 없으니 별도 static 메서드로 분리해야 했음
public class PlayerMover : MonoBehaviour
{
    public float speed = 5f;

    [System.Runtime.CompilerServices.MethodImpl(
        System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
    private static float ScaleAxis(float axis, float speed, float dt) => axis * speed * dt;

    void Update()
    {
        float x = ScaleAxis(Input.GetAxis("Horizontal"), speed, Time.deltaTime);
        float z = ScaleAxis(Input.GetAxis("Vertical"),   speed, Time.deltaTime);
        transform.Translate(new Vector3(x, 0, z));
    }
}
C#
// After — C# 10: 람다 자체에 특성을 붙여 메서드 분리 없이 동일 효과
using System.Runtime.CompilerServices;
using UnityEngine;

public class PlayerMover : MonoBehaviour
{
    public float speed = 5f;

    void Update()
    {
        // 람다 본문 메서드(<Update>b__N_0)에 aggressiveinlining 플래그가 부착됨
        var scale = [MethodImpl(MethodImplOptions.AggressiveInlining)]
                    (float axis) => axis * speed * Time.deltaTime;
        // 주의: speed/Time.deltaTime은 캡처되므로 이 람다는 비캡처가 아님 → 매 Update에서 클로저 할당 발생.
        // GC를 피하려면 매개변수로 모두 받는 비캡처 형태로 다시 정리해야 함.

        float x = scale(Input.GetAxis("Horizontal"));
        float z = scale(Input.GetAxis("Vertical"));
        transform.Translate(new Vector3(x, 0, z));
    }
}

After의 람다 본문 IL을 확인하면 메서드 헤더 끝에 정확히 aggressiveinlining 플래그가 붙어 있습니다.

IL
.method assembly hidebysig instance float32 '<Update>b__N_0' (float32 axis) cil managed aggressiveinlining
{
    // ... axis * speed * Time.deltaTime
}

다만 코드 주석에 적은 대로, speedTime.deltaTime은 외부 캡처입니다. Update마다 클로저 인스턴스(<>c__DisplayClass)가 힙에 할당됩니다. 특성으로 인라이닝 힌트를 줘 봐야 클로저 할당이 더 큰 비용입니다. Unity 핫패스에서는 캡처를 없애기 위해 매개변수를 늘리거나, 처음부터 일반 메서드로 분리하는 쪽이 정석입니다. 람다 특성은 비캡처 람다에서만 의미가 있다고 기억합니다.

4.3 명시적 반환 타입 — 조건식이 모호한 콜백

데이터 로드 콜백처럼 성공·실패에 따라 다른 타입을 반환해야 할 때, 명시적 반환 타입이 깔끔하게 풀어 줍니다.

C#
// Before — 컴파일러가 b ? 1 : null 의 공통 타입을 추론하지 못해 에러
// var pickItemId = (bool ok) => ok ? 1 : null; // CS8917
C#
// After — 람다 앞에 int? 명시
var pickItemId = int? (bool ok) => ok ? 1 : null;
//                ^^^^
// pickItemId의 타입은 Func<bool, int?>

System.Console.WriteLine(pickItemId(true)  ?? -1); // 1
System.Console.WriteLine(pickItemId(false) ?? -1); // -1

이 코드를 실제로 컴파일해 IL을 보면, 컴파일러가 만든 <Main>b__0_X 메서드의 반환 타입이 `valuetype System.Nullable1<int32>`로 정확히 결정돼 있습니다. 명시적 반환 타입을 적지 않으면 intnull`의 공통 상위 타입을 유추할 단서가 없어 추론 자체가 실패합니다.


5. [함정과 주의사항]

5.1 매개변수 타입을 빼면 자연 타입 추론은 실패한다

신입 개발자가 가장 자주 만나는 실수입니다.

C#
// ❌ 매개변수 타입 없음 — 컴파일러가 x의 타입을 모름
var f = (x) => x + 1;
//      ^^^^^^^^^^^^^
// CS8917: 대리자 형식을 유추할 수 없습니다
C#
// ✅ 매개변수 타입을 명시하면 추론 성공
var f = (int x) => x + 1;        // Func<int, int>

이유는 단순합니다. C# 10의 자연 타입 추론은 람다의 시그니처를 가지고 표준 Func/Action 중 하나를 고르는 방식입니다. 매개변수 타입이 없으면 시그니처 자체가 없으므로 고를 후보가 사라집니다. 컴파일러가 호출자(좌변)의 타입을 보면서 매개변수까지 역추론해 주는 케이스는 메서드 인자로 넘길 때만 가능합니다(타깃 타이핑). var 대입은 좌변에 단서가 없으므로 매개변수 타입을 직접 적어야 합니다.

5.2 람다 특성과 람다 본문 사이에 매개변수 괄호가 와야 한다

문법 위치를 헷갈리는 실수입니다.

C#
// ❌ 매개변수 괄호 없이 특성 → 식 람다 본문 — 잘못된 구문
// var f = [MethodImpl(MethodImplOptions.AggressiveInlining)] x => x + 1;
C#
// ✅ 매개변수 괄호가 반드시 필요 — 매개변수가 하나여도 () 로 감싸야 한다
var f = [MethodImpl(MethodImplOptions.AggressiveInlining)] (int x) => x + 1;

특성을 붙인 람다는 "단일 매개변수 + 괄호 생략" 단축 문법(x => x + 1)을 쓸 수 없습니다. 컴파일러는 [...] 다음에 매개변수 목록 (...)을 기대합니다.

5.3 클로저 캡처가 있는 람다의 특성은 효과가 제한적이다

Unity 핫패스에서 람다에 [MethodImpl(AggressiveInlining)]을 붙였는데도 GC(Garbage Collector, 사용하지 않는 힙 객체를 자동으로 회수하는 런타임 구성요소) 스파이크가 사라지지 않는 사례가 있습니다.

C#
// ❌ 외부 변수(speed)를 캡처하므로 매 호출에 <>c__DisplayClass 할당
public class Foo : UnityEngine.MonoBehaviour
{
    public float speed = 5f;
    void Update()
    {
        var scale = [System.Runtime.CompilerServices.MethodImpl(
            System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
            (float a) => a * speed; // ← speed 캡처
        var x = scale(1f);
    }
}

특성을 붙여도 클로저 할당은 막지 못합니다. 인라이닝은 호출 오버헤드만 줄여 줄 뿐, 객체 할당은 별개 비용입니다.

C#
// ✅ 캡처를 없애면 <>c 캐싱 패턴이 적용되어 GC 0 — 특성도 의미를 가진다
void Update()
{
    var scale = [System.Runtime.CompilerServices.MethodImpl(
        System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
        (float a, float s) => a * s;   // 외부 캡처 없음
    var x = scale(1f, speed);
}

After 코드는 PART 12 [08] 정적 람다 글의 패턴과 같은 효과를 냅니다. 추가로 static 키워드를 함께 붙여 캡처를 컴파일 타임에 막는 것이 더 안전합니다.

C#
var scale = [System.Runtime.CompilerServices.MethodImpl(
    System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
    static (float a, float s) => a * s;  // 정적 람다 + 특성 결합

5.4 IL2CPP에서는 인라이닝 보장이 아니라 힌트일 뿐이다

Unity 모바일 빌드에서 사용하는 IL2CPP(IL을 C++로 변환해 AOT(Ahead-Of-Time, 빌드 시점에 미리 기계어로 컴파일하는 방식) 컴파일하는 백엔드)에서도 [MethodImpl(AggressiveInlining)]요청일 뿐 강제가 아닙니다. 메서드 크기·재귀 여부·호출 횟수 등 백엔드 휴리스틱이 인라인 여부를 최종 결정합니다. Unity Profiler로 실제 인라인 여부를 확인하기 전까지는 "이걸 붙였으니 빨라졌을 것"이라고 단정하지 않습니다.


6. [C# 버전별 변화]

버전 변화
C# 1~2 익명 메서드(delegate { ... }) 도입. 좌변 타입 필수.
C# 3 람다 표현식(x => x + 1) 도입. 표현식 트리(Expression<T>) 변환 가능. 자연 타입 없음.
C# 7 폐기 변수 _ 도입(람다 매개변수에서는 C# 9).
C# 9 정적 람다(static x => x), 폐기 매개변수((_, _) => ...) — PART 12 [08]·[09]에서 다룸. 자연 타입은 여전히 없음.
C# 10 자연 타입 추론, 명시적 반환 타입, 람다 특성(이번 글)
C# 11 제네릭 특성을 람다에도 사용 가능 — PART 12 [17]에서 다룸.

C# 10 이전과 이후를 코드로 비교합니다.

C#
// Before — C# 9
// 1) var 대입 불가
// var f = (int x) => x + 1; // CS0815

// 2) 모호한 반환 타입 해소 수단이 좌변 타입 명시뿐
Func<bool, int?> g = (bool b) => b ? 1 : null;

// 3) 람다에 특성 부착 불가 — 별도 static 메서드로 분리해야 했음
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static int Triple(int x) => x * 3;
C#
// After — C# 10
// 1) var 대입 가능
var f = (int x) => x + 1;

// 2) 람다 앞에 반환 타입 직접 표기
var g = int? (bool b) => b ? 1 : null;

// 3) 람다 자체에 특성 부착
var triple = [MethodImpl(MethodImplOptions.AggressiveInlining)] (int x) => x * 3;

세 가지 모두 IL 수준에서는 기존 람다 변환과 같은 <>c 패턴 위에서 동작합니다. 컴파일러가 람다를 더 "메서드답게" 다룰 수 있도록 표면 문법을 확장한 것이지, 새로운 런타임 메커니즘을 도입한 것은 아닙니다.


7. [정리]

이 글에서 본 내용을 한 줄씩 다시 점검합니다.

  • 자연 타입(natural type): 람다의 시그니처(매개변수 타입 + 본문)가 결정되면 컴파일러가 Func/Action 중 가장 자연스러운 델리게이트 타입을 추론한다. var 대입과 Delegate/object 등 상위 타입 대입이 가능해진다.
  • 추론 실패 신호 — CS8917: 매개변수 타입을 생략하거나, 반환 타입이 모호하면 "대리자 형식을 유추할 수 없습니다" 에러가 난다. 매개변수 타입을 적거나 명시적 반환 타입을 추가해 해결한다.
  • 명시적 반환 타입: var f = int? (bool b) => b ? 1 : null; 처럼 람다 매개변수 목록 앞에 반환 타입을 직접 적는다. null/default/조건식 가지 충돌이 있을 때 사용한다.
  • 람다 특성: var f = [MethodImpl(MethodImplOptions.AggressiveInlining)] (int x) => x * 2; 처럼 람다 앞에 대괄호로 부착한다. 단축 문법(x =>)은 사용 불가. 매개변수 괄호 (...)가 필수.
  • IL 검증: 모든 변환 결과는 <>c 디스플레이 클래스 안의 <Method>b__N_M 메서드로 들어간다. 비캡처 람다는 <>9__N_M 정적 필드에 캐싱돼 두 번째 호출부터 GC 할당이 0이다.
  • Unity 핫패스 체크: 람다 특성은 비캡처 람다에서만 의미가 있다. 클로저 캡처가 있으면 매 호출 <>c__DisplayClass 할당이 더 큰 비용이다. static 람다와 함께 쓰는 것이 안전하다.
  • IL2CPP 주의: AggressiveInlining은 강제가 아니라 힌트다. Unity Profiler로 실제 인라인 여부를 확인하고 최적화 효과를 측정한다.

이 세 기능을 쓰면 람다 코드가 더 짧고, 더 정확하고, 메서드와 동등하게 동작합니다. 특히 Unity 콜백·이벤트 시스템·AOT 환경에서 안전한 패턴을 알고 쓰면 GC 스파이크를 만들지 않으면서도 깔끔한 코드를 유지할 수 있습니다.

반응형

+ Recent posts