반응형

[PART14.고급 언어 기능(3/5)] 표현식 트리 — 코드를 데이터로 다루는 원리

람다를 실행하지 않고 분석한다 / Expression<T>와 Func<T>의 결정적 차이 / IQueryable의 비밀 / IL2CPP 환경에서의 생존 전략


문제 제기

Unity 프로젝트에서 이런 코드를 본 적이 있을 것이다.

C#
// 리플렉션으로 컴포넌트 프로퍼티에 접근
string propName = "Health";
var value = playerComponent.GetType().GetProperty(propName)?.GetValue(playerComponent);

프로퍼티 이름을 문자열로 넘기는 이 패턴은 위험하다. "Health""Helath"로 오타를 내도 컴파일러는 아무 경고도 하지 않는다. 런타임이 되어서야 null이 반환되거나 NullReferenceException이 터진다.

이 문제를 해결할 방법이 있다. 코드 자체를 데이터로 바꿔서 컴파일러가 구조를 검증하게 만드는 것이다. C#에는 이를 위한 전용 메커니즘이 있다 — 표현식 트리(Expression Tree)다.

표현식 트리를 이해하면 Entity Framework가 C# 람다를 SQL로 변환하는 원리도, nameof 이전에 프로퍼티명을 안전하게 추출하던 패턴도, 동적 쿼리를 타입-안전하게 조합하는 방법도 모두 설명된다.


개념 정의

비유 — 레시피 카드와 요리

일상에서 "파스타를 만든다"는 두 가지 의미가 될 수 있다.

  1. 직접 요리한다 — 냄비에 물을 끓이고 면을 넣는 행위 자체
  2. 레시피 카드를 작성한다 — "물 끓이기 → 면 투입 → 소스 볶기" 순서가 적힌 종이

Func<T>는 1번이다. 코드를 즉시 실행할 수 있는 델리게이트(delegate, 메서드 참조를 담는 객체)다. Expression<Func<T>>는 2번이다. 같은 로직이지만 실행하지 않고 구조를 기록한 데이터다.

레시피 카드가 있으면 그걸 읽고 분석할 수 있다 — "이 레시피에 소스 볶기 단계가 있나?", "면 투입 전에 물 끓이기가 있나?" 같은 질문에 답할 수 있다. 나아가 레시피를 변환할 수도 있다 — 한국어 레시피를 영어로 번역하듯, C# 람다를 SQL 쿼리로 변환하는 것이다.

표현식 트리의 구조

x => x > 5 — 표현식 트리 구조

왼쪽의 Func<int, bool>은 컴파일러가 람다를 IL(Intermediate Language, .NET 런타임이 실행하는 중간 언어) 명령어로 변환한 것이다. ldarg.1, ldc.i4.5, cgt, ret — 이 명령어들은 실행만 가능하고 "이 코드가 무엇을 하는가"를 분석할 수 없다.

오른쪽의 Expression<Func<int, bool>>은 같은 x => x > 5이지만 컴파일러가 IL 코드 대신 객체 트리를 생성한다. LambdaExpression 루트 아래 BinaryExpression(>)이 있고, 그 아래 ParameterExpression("x")ConstantExpression(5)이 달려 있다. 이 트리를 순회하면 "파라미터 x가 상수 5보다 큰지 비교한다"는 의미를 읽어낼 수 있다.

Expression<T> — 표현식 트리 (Expression Tree) 코드의 구조를 트리 형태의 데이터로 표현한 객체. System.Linq.Expressions 네임스페이스에 정의되어 있다. 실행하려면 Compile() 메서드로 델리게이트로 변환해야 한다.

핵심 차이를 코드로 확인

C#
using System;
using System.Linq.Expressions;

// 1. Func — 실행 가능한 코드
Func<int, bool> func = x => x > 5;
bool result1 = func(10); // true — 즉시 실행

// 2. Expression — 분석 가능한 데이터
Expression<Func<int, bool>> expr = x => x > 5;
// expr(10);  ← 컴파일 오류! 직접 실행 불가

// 실행하려면 Compile()으로 델리게이트 변환 필요
Func<int, bool> compiled = expr.Compile();
bool result2 = compiled(10); // true

컴파일러가 같은 x => x > 5 람다를 할당 대상 타입에 따라 완전히 다른 IL로 변환한다는 것이 핵심이다.

IL
// ── GetFunc: Func<int, bool> func = x => x > 5 ──
.method public hidebysig static
    class [System.Runtime]System.Func`2<int32, bool> GetFunc () cil managed
{
    .locals init (
        [0] class [System.Runtime]System.Func`2<int32, bool>,
        [1] class [System.Runtime]System.Func`2<int32, bool>
    )

    IL_0001: ldsfld class System.Func`2<int32, bool> Program/'<>c'::'<>9__0_0'  // 캐싱된 델리게이트 확인
    IL_0006: dup
    IL_0007: brtrue.s IL_0020  // 이미 있으면 재사용 (힙 할당 없음)

    IL_000a: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
    IL_000f: ldftn instance bool Program/'<>c'::'<GetFunc>b__0_0'(int32)  // 람다 본문 메서드 포인터
    IL_0015: newobj instance void class System.Func`2<int32, bool>::.ctor(object, native int)  // 델리게이트 생성
    IL_001b: stsfld class System.Func`2<int32, bool> Program/'<>c'::'<>9__0_0'  // static 필드에 캐싱

    IL_0020: stloc.0
    IL_0026: ret
}

// ── GetExpression: Expression<Func<int, bool>> expr = x => x > 5 ──
.method public hidebysig static
    class System.Linq.Expressions.Expression`1<class System.Func`2<int32, bool>> GetExpression () cil managed
{
    .locals init (
        [0] class System.Linq.Expressions.Expression`1<class System.Func`2<int32, bool>>,
        [1] class System.Linq.Expressions.ParameterExpression
    )

    IL_0001: ldtoken [System.Runtime]System.Int32                   // typeof(int) 토큰 로드
    IL_0006: call class System.Type System.Type::GetTypeFromHandle(...)
    IL_000b: ldstr "x"                                               // 파라미터 이름 "x" 문자열
    IL_0010: call class ParameterExpression Expression::Parameter(...) // ParameterExpression 생성
    IL_0015: stloc.1
    IL_0016: ldloc.1                                                  // 파라미터 x 로드
    IL_0017: ldc.i4.5                                                 // 상수 5
    IL_0018: box [System.Runtime]System.Int32                         // int → object 박싱 (상수를 객체로)
    IL_001d: ldtoken [System.Runtime]System.Int32
    IL_0022: call class System.Type System.Type::GetTypeFromHandle(...)
    IL_0027: call class ConstantExpression Expression::Constant(...)   // ConstantExpression 생성
    IL_002c: call class BinaryExpression Expression::GreaterThan(...)  // BinaryExpression(>) 생성
    IL_0031: ldc.i4.1
    IL_0032: newarr ParameterExpression                                // 파라미터 배열 생성
    IL_0037: dup
    IL_0038: ldc.i4.0
    IL_0039: ldloc.1
    IL_003a: stelem.ref
    IL_003b: call class Expression`1 Expression::Lambda<...>(...)      // LambdaExpression 생성
    IL_0040: stloc.0
    IL_0046: ret
}

IL 분석 포인트:

  1. GetFunc — 델리게이트 캐싱: 컴파일러가 <>c 클래스의 static 필드 <>9__0_0에 델리게이트를 캐싱한다. 외부 변수를 캡처하지 않는 람다는 첫 호출 시 한 번만 newobj로 생성하고, 이후에는 ldsfldbrtrue로 재사용한다. 힙 할당이 최초 1회로 제한된다.
  2. GetExpression — 트리 조립 코드: 같은 x => x > 5인데 IL이 완전히 다르다. Expression.Parameter(), Expression.Constant(), Expression.GreaterThan(), Expression.Lambda() 팩토리 메서드를 순차 호출하여 객체 트리를 조립한다. 호출할 때마다 newobj가 아닌 팩토리 메서드 내부에서 객체가 생성되므로 매 호출마다 힙 할당이 발생한다.
  3. box 명령어 (IL_0018): 상수 5Expression.Constant(object, Type)에 넘기기 위해 intobject 박싱이 발생한다. 표현식 트리의 ConstantExpression은 값을 object로 보관하기 때문이다.

내부 동작

컴파일러의 변환 과정

C# 컴파일러는 Expression<TDelegate> 타입에 할당된 람다를 만나면 일반 IL 코드를 생성하지 않는다. 대신 System.Linq.Expressions.Expression 클래스의 팩토리 메서드를 호출하는 코드를 emit한다.

컴파일러의 람다 변환 경로

이 과정을 더 구체적으로 풀면, 컴파일러가 Expression<Func<int, bool>> expr = x => x > 5;를 만나면 내부적으로 아래와 동등한 코드를 생성한다.

C#
// 컴파일러가 실제로 생성하는 코드 (개념적 표현)
var param = Expression.Parameter(typeof(int), "x");
var constant = Expression.Constant(5, typeof(int));
var body = Expression.GreaterThan(param, constant);
var expr = Expression.Lambda<Func<int, bool>>(body, param);

각 팩토리 메서드가 Expression 하위 클래스의 인스턴스를 힙에 생성하고, 이들이 트리 구조로 연결된다.

Expression 클래스 계층

System.Linq.Expressions.Expression은 추상 기본 클래스이며, 코드의 모든 구성 요소를 표현하는 하위 클래스들이 있다.

클래스 표현 대상 예시
ParameterExpression 매개변수 x
ConstantExpression 상수 값 5, "hello"
BinaryExpression 이항 연산 x > 5, a + b
UnaryExpression 단항 연산 !flag, (int)obj
MemberExpression 프로퍼티/필드 접근 player.Health
MethodCallExpression 메서드 호출 name.Contains("A")
ConditionalExpression 삼항 연산자 x > 0 ? x : -x
NewExpression 생성자 호출 new Player()
LambdaExpression 람다 전체 x => x > 5

모든 노드는 NodeType 프로퍼티(어떤 연산인지)와 Type 프로퍼티(결과 타입)를 갖는다. 이 두 프로퍼티만으로도 트리의 구조를 파악할 수 있다.

제약: 식 람다만 변환 가능

식 람다(expression lambda)와 문 람다(statement lambda) 식 람다는 본문이 단일 식인 x => x + 1 형태다. 문 람다는 중괄호가 있는 x => { var y = x + 1; return y; } 형태다. 표현식 트리로 자동 변환되는 것은 식 람다뿐이다.
C#
// 식 람다 → 컴파일 성공
Expression<Func<int, int>> ok = x => x + 1;

// 문 람다 → 컴파일 오류!
// Expression<Func<int, int>> fail = x => { return x + 1; };
// 오류: 문 본문이 있는 람다 식은 식 트리로 변환할 수 없습니다

C# 4.0에서 Expression.Block(), Expression.Loop() 등이 추가되어 API 레벨에서는 문 수준의 트리를 만들 수 있지만, 컴파일러의 자동 변환은 여전히 식 람다만 지원한다. 문 수준 트리가 필요하면 팩토리 메서드로 직접 조립해야 한다.


실전 적용

IQueryable — C# 람다가 SQL이 되는 원리

IQueryable<T> — 쿼리 가능 인터페이스 IEnumerable<T>의 확장으로, LINQ 연산을 표현식 트리로 받아 외부 데이터 소스(데이터베이스 등)에 대한 쿼리를 생성하는 인터페이스다. Entity Framework 등의 ORM(Object-Relational Mapping, 객체-관계 매핑 도구)이 이를 구현한다.

LINQ에는 두 가지 Where가 있다.

C#
// IEnumerable<T>.Where — Func 사용 (메모리 내 필터링)
IEnumerable<Player> memoryPlayers = players.Where(p => p.Level > 10);

// IQueryable<T>.Where — Expression 사용 (SQL 변환)
IQueryable<Player> dbPlayers = dbContext.Players.Where(p => p.Level > 10);

같은 p => p.Level > 10이지만:

  • IEnumerableWhereFunc<Player, bool>을 받는다. 모든 데이터를 메모리에 올린 뒤 C# 코드로 하나씩 필터링한다.
  • IQueryableWhereExpression<Func<Player, bool>>을 받는다. Query Provider가 표현식 트리를 분석하여 SELECT * FROM Players WHERE Level > 10이라는 SQL을 생성한다.

데이터베이스에 100만 건이 있을 때, IEnumerable 방식은 100만 건을 모두 메모리에 올려야 한다. IQueryable 방식은 데이터베이스에서 조건에 맞는 행만 가져온다. 이 차이가 가능한 이유가 바로 표현식 트리다.

Before: 매직 스트링으로 프로퍼티 접근

C#
// ❌ 문자열로 프로퍼티 접근 — 오타 시 런타임 오류
public static object GetValueByReflection(object obj, string propName)
{
    return obj.GetType().GetProperty(propName)!.GetValue(obj);
}

// 사용
var hp = GetValueByReflection(player, "Health");    // 동작
var bug = GetValueByReflection(player, "Helath");   // null → 런타임 오류!
IL
// ── GetValueByReflection IL ──
.method public hidebysig static
    object GetValueByReflection (object obj, string propName) cil managed
{
    IL_0001: ldarg.0
    IL_0002: callvirt instance class System.Type System.Object::GetType()  // 런타임 타입 정보 조회
    IL_0007: ldarg.1
    IL_0008: callvirt instance class PropertyInfo System.Type::GetProperty(string)  // 문자열로 프로퍼티 검색
    IL_000d: ldarg.0
    IL_000e: callvirt instance object PropertyInfo::GetValue(object)  // 리플렉션으로 값 읽기
    IL_0013: stloc.0
    IL_0016: ldloc.0
    IL_0017: ret
}

callvirt가 세 번 연쇄된다. GetType()GetProperty(string)GetValue(object). 모두 런타임 리플렉션(Reflection, 런타임에 타입 정보를 조회하는 메커니즘)이다. 프로퍼티명이 문자열이므로 컴파일러가 오타를 잡아줄 수 없고, 리플렉션은 호출마다 내부 캐시 조회와 보안 검사를 수행하므로 느리다.

After: 표현식 트리로 타입-안전한 프로퍼티명 추출

is — 패턴 매칭 연산자 (Pattern matching operator) 왼쪽 피연산자가 오른쪽 타입과 일치하는지 검사하고, 일치하면 해당 타입의 변수에 바인딩한다. C# 7.0에서 타입 패턴이 도입되었다.
예시: if (obj is string text) — obj가 string이면 text 변수에 할당
C#
// ✅ 표현식 트리로 프로퍼티명 추출 — 컴파일 타임 검증
public static string GetPropertyName<TSource, TProp>(
    Expression<Func<TSource, TProp>> expression)
{
    if (expression.Body is MemberExpression member)
        return member.Member.Name;
    if (expression.Body is UnaryExpression unary
        && unary.Operand is MemberExpression innerMember)
        return innerMember.Member.Name;
    throw new ArgumentException("Invalid expression");
}

// 사용
string name = GetPropertyName<Player, int>(p => p.Health);  // "Health"
// string bug = GetPropertyName<Player, int>(p => p.Helath); // 컴파일 오류! 프로퍼티 없음
IL
// ── GetPropertyName IL (핵심 부분) ──
.method public hidebysig static
    string GetPropertyName<TSource, TProp> (...) cil managed
{
    IL_0001: ldarg.0
    IL_0002: callvirt instance class Expression LambdaExpression::get_Body()  // 트리의 Body 접근
    IL_0007: isinst MemberExpression  // MemberExpression인지 타입 검사
    IL_000c: stloc.0
    IL_000d: ldloc.0
    IL_000e: ldnull
    IL_000f: cgt.un
    IL_0011: stloc.3
    IL_0013: brfalse.s IL_0024  // null이면 UnaryExpression 경로로

    IL_0015: ldloc.0
    IL_0016: callvirt instance class MemberInfo MemberExpression::get_Member()  // 멤버 정보 접근
    IL_001b: callvirt instance string MemberInfo::get_Name()  // 프로퍼티 이름 추출
    IL_0020: stloc.s 4
    IL_0022: br.s IL_0066
    // ... (UnaryExpression 처리 경로 생략)
}

IL 분석 포인트:

  1. isinst + 패턴 매칭: expression.Body is MemberExpression이 IL에서 isinst 명령어로 변환된다. 런타임 타입 검사를 수행하지만, 리플렉션의 GetProperty(string)처럼 메타데이터를 검색하는 것이 아니라 단순 타입 캐스팅이므로 비용이 훨씬 적다.
  2. Compile() 미사용: 이 코드는 표현식 트리의 구조를 읽기만 한다. get_Body(), get_Member(), get_Name()으로 트리 노드의 프로퍼티에 접근할 뿐, Compile()을 호출하지 않는다. 따라서 IL2CPP(Unity의 AOT 컴파일러) 환경에서도 안전하다.
  3. 컴파일 타임 안전성: p => p.Helath처럼 존재하지 않는 프로퍼티를 쓰면 C# 컴파일러가 즉시 오류를 낸다. 매직 스트링 방식에서는 불가능했던 보호를 얻는다.

동적 쿼리 빌더 — 런타임에 조건을 조합

검색 기능처럼 사용자 입력에 따라 조건이 달라지는 상황에서 표현식 트리를 동적으로 조립할 수 있다.

C#
// 런타임에 "propertyName == value" 필터를 생성
public static Expression<Func<T, bool>> BuildFilter<T>(
    string propertyName, object value)
{
    var param = Expression.Parameter(typeof(T), "x");
    var property = Expression.Property(param, propertyName);
    var constant = Expression.Constant(value);
    var equal = Expression.Equal(property, constant);
    return Expression.Lambda<Func<T, bool>>(equal, param);
}

// 사용 예: 런타임에 조건을 동적으로 결정
var filter = BuildFilter<Player>("Level", 10);
// 생성된 표현식: x => x.Level == 10
// IQueryable에 전달하면 SQL WHERE Level = 10으로 변환됨
IL
// ── BuildFilter IL ──
.method public hidebysig static
    class Expression`1<class Func`2<!!T, bool>> BuildFilter<T> (
        string propertyName, object 'value') cil managed
{
    IL_0001: ldtoken !!T  // 제네릭 타입 T의 타입 토큰
    IL_0006: call class Type Type::GetTypeFromHandle(...)
    IL_000b: ldstr "x"
    IL_0010: call class ParameterExpression Expression::Parameter(...)  // ParameterExpression 생성
    IL_0015: stloc.0
    IL_0016: ldloc.0
    IL_0017: ldarg.0  // propertyName
    IL_0018: call class MemberExpression Expression::Property(...)  // MemberExpression 생성
    IL_001d: stloc.1
    IL_001e: ldarg.1  // value
    IL_001f: call class ConstantExpression Expression::Constant(object)  // ConstantExpression 생성
    IL_0024: stloc.2
    IL_0025: ldloc.1
    IL_0026: ldloc.2
    IL_0027: call class BinaryExpression Expression::Equal(...)  // BinaryExpression(==) 생성
    IL_002c: stloc.3
    IL_002d: ldloc.3
    IL_002e: ldc.i4.1
    IL_002f: newarr ParameterExpression  // 파라미터 배열 힙 할당
    IL_0034: dup
    IL_0035: ldc.i4.0
    IL_0036: ldloc.0
    IL_0037: stelem.ref
    IL_0038: call class Expression`1 Expression::Lambda<...>(...)  // LambdaExpression 생성
    IL_003d: stloc.s 4
    IL_0041: ldloc.s 4
    IL_0043: ret
}

주의: BuildFilterpropertyName을 문자열로 받으므로 매직 스트링 문제가 여전히 남아 있다. 이를 표현식 트리와 조합하면 해결할 수 있다.

C#
// 타입-안전한 동적 필터 빌더
public static Expression<Func<T, bool>> BuildSafeFilter<T, TProp>(
    Expression<Func<T, TProp>> propertySelector, TProp value)
{
    var param = propertySelector.Parameters[0];
    var property = propertySelector.Body;
    var constant = Expression.Constant(value, typeof(TProp));
    var equal = Expression.Equal(property, constant);
    return Expression.Lambda<Func<T, bool>>(equal, param);
}

// 사용: 프로퍼티도 람다로 지정 → 컴파일 타임 검증
var filter = BuildSafeFilter<Player, int>(p => p.Level, 10);
// 생성된 표현식: p => p.Level == 10

함정과 주의사항

함정 1: IL2CPP에서 Compile() 호출

Unity 모바일 게임의 가장 큰 함정이다.

C#
// ❌ IL2CPP 환경에서 런타임 크래시
Expression<Func<int, int>> expr = x => x * 2;
Func<int, int> compiled = expr.Compile();  // ExecutionEngineException!
int result = compiled(5);
IL2CPP(Intermediate Language to C++) Unity의 AOT(Ahead-Of-Time) 컴파일러다. C# 코드를 IL로 변환한 뒤, 다시 C++ 코드로 변환하여 네이티브 바이너리를 생성한다. iOS, Android, WebGL, 콘솔 등 대부분의 빌드 타겟에서 사용된다.

Expression.Compile()은 내부적으로 System.Reflection.Emit을 사용하여 런타임에 새로운 IL 코드를 생성한다. 그런데 IL2CPP는 AOT 컴파일 환경이라 런타임 코드 생성이 금지되어 있다. Unity 에디터(Mono JIT)에서는 정상 동작하지만 빌드하면 터진다 — 이것이 함정이다.

C#
// ✅ IL2CPP 안전 — 분석(Inspection)만 수행
Expression<Func<Player, int>> expr = p => p.Health;
if (expr.Body is MemberExpression member)
{
    string propName = member.Member.Name;  // "Health" — Compile() 미사용
}

판단 기준:

  • 표현식 트리의 구조를 읽는 것(Body, Member, NodeType 접근) → IL2CPP 안전
  • 표현식 트리를 실행 가능한 코드로 변환(Compile()) → IL2CPP에서 크래시

함정 2: 표현식 트리의 GC 부담

C#
// ❌ Update()에서 매 프레임 표현식 트리 생성
void Update()
{
    Expression<Func<Enemy, bool>> filter = e => e.IsAlive && e.Distance < 10f;
    // 매 프레임 ParameterExpression, BinaryExpression, MemberExpression 등 객체 트리 생성
    // → 매 프레임 힙 할당 → GC 스파이크
}

앞서 IL에서 확인했듯이 표현식 트리 생성은 여러 Expression 객체를 힙에 할당한다. 단순한 x => x > 5에도 ParameterExpression, ConstantExpression, BinaryExpression, LambdaExpression, ParameterExpression[] 배열까지 최소 5개 이상의 힙 할당이 발생한다.

C#
// ✅ 시작 시 한 번만 생성하고 캐싱
private static readonly Expression<Func<Enemy, bool>> AliveNearbyFilter
    = e => e.IsAlive && e.Distance < 10f;

void Update()
{
    // 캐싱된 표현식 사용 — 추가 힙 할당 없음
    var enemies = enemyQuery.Where(AliveNearbyFilter);
}

함정 3: 문 람다는 자동 변환 불가

C#
// ❌ 문 람다 → 컴파일 오류
Expression<Func<int, string>> fail = x =>
{
    if (x > 0) return "양수";
    return "음수 또는 0";
};
// 오류 CS0834: 문 본문이 있는 람다 식은 식 트리로 변환할 수 없습니다
C#
// ✅ 삼항 연산자로 식 람다 유지
Expression<Func<int, string>> ok = x => x > 0 ? "양수" : "음수 또는 0";

조건 분기가 필요하면 삼항 연산자(? :)를 사용하여 식 람다를 유지한다. 복잡한 로직이 필요하면 메서드로 분리하되, Expression<T> 안에서는 해당 메서드를 호출하는 단일 식으로 작성한다.


C# 버전별 변화

C# 3.0 — 표현식 트리 도입

C# 3.0(2007)에서 LINQ와 함께 처음 도입되었다. 식 람다를 Expression<TDelegate>에 할당하면 컴파일러가 자동으로 트리를 생성한다. IQueryable<T>와 결합하여 Entity Framework의 LINQ to SQL이 가능해졌다.

C#
// C# 3.0: 기본 표현식 트리
Expression<Func<int, bool>> expr = x => x > 5;

// 트리 구조 접근
var binary = (BinaryExpression)expr.Body;
Console.WriteLine(binary.NodeType);  // GreaterThan
Console.WriteLine(binary.Left);      // x
Console.WriteLine(binary.Right);     // 5

C# 4.0 — 문 수준 노드 추가

C# 4.0(2010)에서 Expression.Block(), Expression.Loop(), Expression.TryCatch() 등이 추가되었다. DLR(Dynamic Language Runtime, IronPython/IronRuby 등 동적 언어 런타임)의 기반이 되었다.

C#
// C# 3.0에서는 불가능 — C# 4.0 API로 문 수준 트리 직접 생성
var param = Expression.Parameter(typeof(int), "x");
var result = Expression.Variable(typeof(string), "result");

// if (x > 0) result = "양수"; else result = "음수 또는 0";
var ifThenElse = Expression.IfThenElse(
    Expression.GreaterThan(param, Expression.Constant(0)),
    Expression.Assign(result, Expression.Constant("양수")),
    Expression.Assign(result, Expression.Constant("음수 또는 0"))
);

var block = Expression.Block(
    new[] { result },
    ifThenElse,
    result  // 마지막 식이 반환값
);

var lambda = Expression.Lambda<Func<int, string>>(block, param);
// lambda.Compile()(5) → "양수"  (JIT 환경에서만)

단, 컴파일러의 자동 변환은 여전히 식 람다만 지원한다. 위 코드처럼 문 수준 트리를 원하면 팩토리 메서드로 직접 조립해야 한다.

C# 6.0 이후 — nameof의 등장

C# 6.0(2015)에서 nameof 연산자가 도입되면서, 프로퍼티명 추출 용도로 표현식 트리를 사용할 필요가 크게 줄었다.

nameof — 이름 연산자 (Name-of operator) 변수·타입·멤버의 이름을 컴파일 타임에 문자열 상수로 변환한다. 런타임 비용이 전혀 없으며, 리팩토링(이름 변경) 시 자동으로 갱신된다.
예시: nameof(Player.Health)"Health" 컴파일러가 "Health" 상수 문자열로 치환
C#
// Before (C# 5.0 이하): 표현식 트리로 프로퍼티명 추출
string propName = GetPropertyName<Player, int>(p => p.Health);  // "Health"

// After (C# 6.0): nameof 사용 — 컴파일 타임에 문자열로 치환, 런타임 비용 없음
string propName = nameof(Player.Health);  // "Health"

nameof는 컴파일 타임에 상수 문자열로 치환되므로 런타임 비용이 전혀 없고, 리팩토링 시 자동으로 이름이 갱신된다. 단순 프로퍼티명 추출에는 nameof가 더 적합하다.

하지만 표현식 트리가 여전히 필요한 경우가 있다:

  • 프로퍼티의 타입 정보까지 필요할 때 (MemberExpression.Type)
  • 여러 프로퍼티의 접근 경로를 조합할 때 (p => p.Inventory.Weapon.Damage)
  • 동적 쿼리 빌더 등 트리를 분석·변환하는 용도

정리

표현식 트리는 "코드를 데이터로 다루는" 강력한 도구이지만, Unity 환경에서는 명확한 경계를 알고 써야 한다.

  • Func<T>는 실행 가능한 코드, Expression<Func<T>>는 분석 가능한 데이터다. 같은 람다라도 할당 대상에 따라 컴파일러가 완전히 다른 IL을 생성한다.
  • 표현식 트리 = 코드의 AST(Abstract Syntax Tree). LambdaExpressionBinaryExpressionParameterExpression / ConstantExpression 같은 트리 구조로 코드의 의미를 표현한다.
  • IQueryable은 표현식 트리 덕분에 동작한다. Entity Framework가 C# 람다를 SQL로 변환할 수 있는 이유다.
  • IL2CPP 환경에서 Compile()은 금지. 표현식 트리의 구조를 읽는 것(Inspection)은 안전하지만, Compile()로 코드를 생성하면 런타임에 크래시한다.
  • 표현식 트리 생성은 힙 할당을 유발한다. Unity 핫패스(Update, FixedUpdate 등 매 프레임 호출되는 경로)에서 매번 생성하지 말고 캐싱한다.
  • 식 람다만 자동 변환된다. 문 람다({ } 블록)는 Expression<T>에 할당할 수 없으므로 삼항 연산자로 식 람다를 유지한다.
  • C# 6.0 이후 단순 프로퍼티명 추출에는 nameof가 더 적합하다. 표현식 트리는 동적 쿼리 빌더·트리 분석·변환 등 고급 시나리오에 사용한다.
반응형

+ Recent posts