[PART14.고급 언어 기능(3/5)] 표현식 트리 — 코드를 데이터로 다루는 원리
람다를 실행하지 않고 분석한다 / Expression<T>와 Func<T>의 결정적 차이 / IQueryable의 비밀 / IL2CPP 환경에서의 생존 전략
문제 제기
Unity 프로젝트에서 이런 코드를 본 적이 있을 것이다.
// 리플렉션으로 컴포넌트 프로퍼티에 접근
string propName = "Health";
var value = playerComponent.GetType().GetProperty(propName)?.GetValue(playerComponent);
프로퍼티 이름을 문자열로 넘기는 이 패턴은 위험하다. "Health"를 "Helath"로 오타를 내도 컴파일러는 아무 경고도 하지 않는다. 런타임이 되어서야 null이 반환되거나 NullReferenceException이 터진다.
이 문제를 해결할 방법이 있다. 코드 자체를 데이터로 바꿔서 컴파일러가 구조를 검증하게 만드는 것이다. C#에는 이를 위한 전용 메커니즘이 있다 — 표현식 트리(Expression Tree)다.
표현식 트리를 이해하면 Entity Framework가 C# 람다를 SQL로 변환하는 원리도, nameof 이전에 프로퍼티명을 안전하게 추출하던 패턴도, 동적 쿼리를 타입-안전하게 조합하는 방법도 모두 설명된다.
개념 정의
비유 — 레시피 카드와 요리
일상에서 "파스타를 만든다"는 두 가지 의미가 될 수 있다.
- 직접 요리한다 — 냄비에 물을 끓이고 면을 넣는 행위 자체
- 레시피 카드를 작성한다 — "물 끓이기 → 면 투입 → 소스 볶기" 순서가 적힌 종이
Func<T>는 1번이다. 코드를 즉시 실행할 수 있는 델리게이트(delegate, 메서드 참조를 담는 객체)다. Expression<Func<T>>는 2번이다. 같은 로직이지만 실행하지 않고 구조를 기록한 데이터다.
레시피 카드가 있으면 그걸 읽고 분석할 수 있다 — "이 레시피에 소스 볶기 단계가 있나?", "면 투입 전에 물 끓이기가 있나?" 같은 질문에 답할 수 있다. 나아가 레시피를 변환할 수도 있다 — 한국어 레시피를 영어로 번역하듯, C# 람다를 SQL 쿼리로 변환하는 것이다.
표현식 트리의 구조

왼쪽의 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()메서드로 델리게이트로 변환해야 한다.
핵심 차이를 코드로 확인
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로 변환한다는 것이 핵심이다.
// ── 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 분석 포인트:
GetFunc— 델리게이트 캐싱: 컴파일러가<>c클래스의 static 필드<>9__0_0에 델리게이트를 캐싱한다. 외부 변수를 캡처하지 않는 람다는 첫 호출 시 한 번만newobj로 생성하고, 이후에는ldsfld→brtrue로 재사용한다. 힙 할당이 최초 1회로 제한된다.GetExpression— 트리 조립 코드: 같은x => x > 5인데 IL이 완전히 다르다.Expression.Parameter(),Expression.Constant(),Expression.GreaterThan(),Expression.Lambda()팩토리 메서드를 순차 호출하여 객체 트리를 조립한다. 호출할 때마다newobj가 아닌 팩토리 메서드 내부에서 객체가 생성되므로 매 호출마다 힙 할당이 발생한다.box명령어 (IL_0018): 상수5를Expression.Constant(object, Type)에 넘기기 위해int→object박싱이 발생한다. 표현식 트리의ConstantExpression은 값을object로 보관하기 때문이다.
내부 동작
컴파일러의 변환 과정
C# 컴파일러는 Expression<TDelegate> 타입에 할당된 람다를 만나면 일반 IL 코드를 생성하지 않는다. 대신 System.Linq.Expressions.Expression 클래스의 팩토리 메서드를 호출하는 코드를 emit한다.

이 과정을 더 구체적으로 풀면, 컴파일러가 Expression<Func<int, bool>> expr = x => x > 5;를 만나면 내부적으로 아래와 동등한 코드를 생성한다.
// 컴파일러가 실제로 생성하는 코드 (개념적 표현)
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; }형태다. 표현식 트리로 자동 변환되는 것은 식 람다뿐이다.
// 식 람다 → 컴파일 성공
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가 있다.
// 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이지만:
IEnumerable의Where는Func<Player, bool>을 받는다. 모든 데이터를 메모리에 올린 뒤 C# 코드로 하나씩 필터링한다.IQueryable의Where는Expression<Func<Player, bool>>을 받는다. Query Provider가 표현식 트리를 분석하여SELECT * FROM Players WHERE Level > 10이라는 SQL을 생성한다.
데이터베이스에 100만 건이 있을 때, IEnumerable 방식은 100만 건을 모두 메모리에 올려야 한다. IQueryable 방식은 데이터베이스에서 조건에 맞는 행만 가져온다. 이 차이가 가능한 이유가 바로 표현식 트리다.
Before: 매직 스트링으로 프로퍼티 접근
// ❌ 문자열로 프로퍼티 접근 — 오타 시 런타임 오류
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 → 런타임 오류!
// ── 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 변수에 할당
// ✅ 표현식 트리로 프로퍼티명 추출 — 컴파일 타임 검증
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); // 컴파일 오류! 프로퍼티 없음
// ── 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 분석 포인트:
isinst+ 패턴 매칭:expression.Body is MemberExpression이 IL에서isinst명령어로 변환된다. 런타임 타입 검사를 수행하지만, 리플렉션의GetProperty(string)처럼 메타데이터를 검색하는 것이 아니라 단순 타입 캐스팅이므로 비용이 훨씬 적다.Compile()미사용: 이 코드는 표현식 트리의 구조를 읽기만 한다.get_Body(),get_Member(),get_Name()으로 트리 노드의 프로퍼티에 접근할 뿐,Compile()을 호출하지 않는다. 따라서 IL2CPP(Unity의 AOT 컴파일러) 환경에서도 안전하다.- 컴파일 타임 안전성:
p => p.Helath처럼 존재하지 않는 프로퍼티를 쓰면 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으로 변환됨
// ── 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
}
주의: BuildFilter는 propertyName을 문자열로 받으므로 매직 스트링 문제가 여전히 남아 있다. 이를 표현식 트리와 조합하면 해결할 수 있다.
// 타입-안전한 동적 필터 빌더
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 모바일 게임의 가장 큰 함정이다.
// ❌ 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)에서는 정상 동작하지만 빌드하면 터진다 — 이것이 함정이다.
// ✅ 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 부담
// ❌ 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개 이상의 힙 할당이 발생한다.
// ✅ 시작 시 한 번만 생성하고 캐싱
private static readonly Expression<Func<Enemy, bool>> AliveNearbyFilter
= e => e.IsAlive && e.Distance < 10f;
void Update()
{
// 캐싱된 표현식 사용 — 추가 힙 할당 없음
var enemies = enemyQuery.Where(AliveNearbyFilter);
}
함정 3: 문 람다는 자동 변환 불가
// ❌ 문 람다 → 컴파일 오류
Expression<Func<int, string>> fail = x =>
{
if (x > 0) return "양수";
return "음수 또는 0";
};
// 오류 CS0834: 문 본문이 있는 람다 식은 식 트리로 변환할 수 없습니다
// ✅ 삼항 연산자로 식 람다 유지
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# 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# 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"상수 문자열로 치환
// 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).
LambdaExpression→BinaryExpression→ParameterExpression/ConstantExpression같은 트리 구조로 코드의 의미를 표현한다. - IQueryable은 표현식 트리 덕분에 동작한다. Entity Framework가 C# 람다를 SQL로 변환할 수 있는 이유다.
- IL2CPP 환경에서
Compile()은 금지. 표현식 트리의 구조를 읽는 것(Inspection)은 안전하지만,Compile()로 코드를 생성하면 런타임에 크래시한다. - 표현식 트리 생성은 힙 할당을 유발한다. Unity 핫패스(Update, FixedUpdate 등 매 프레임 호출되는 경로)에서 매번 생성하지 말고 캐싱한다.
- 식 람다만 자동 변환된다. 문 람다(
{ }블록)는Expression<T>에 할당할 수 없으므로 삼항 연산자로 식 람다를 유지한다. - C# 6.0 이후 단순 프로퍼티명 추출에는
nameof가 더 적합하다. 표현식 트리는 동적 쿼리 빌더·트리 분석·변환 등 고급 시나리오에 사용한다.
'C# 심화' 카테고리의 다른 글
| [PART14.고급 언어 기능(5/5)] partial 클래스와 partial 메서드 — 코드 생성기와 함께 쓰는 방법 (0) | 2026.04.17 |
|---|---|
| [PART14.고급 언어 기능(4/5)] Source Generator — 컴파일 타임 코드 생성 (0) | 2026.04.17 |
| [PART14.고급 언어 기능(2/5)] 리플렉션 — 타입을 런타임에 분석하는 원리 (0) | 2026.04.17 |
| [PART14.고급 언어 기능(1/5)] Attribute — 코드에 메타데이터를 붙이는 방법 (1) | 2026.04.17 |
| [PART13.패턴 매칭과 현대 C#(4/4)] 범위와 인덱스 — ^와 ..는 무엇인가 (0) | 2026.04.15 |