Expression Tree는 코드 구조를 트리로 표현해 런타임에 안전하게 조립하고, 델리게이트로 컴파일해 실행할 수 있는 기능입니다. Reflection.Emit처럼 복잡한 바이트코드 생성 없이, 타입 안정성과 도구 친화성을 유지하며 동적 코드를 만들 수 있습니다.
1. Expression Tree란?
Expression Tree는 변수, 상수, 연산, 호출 같은 요소를 노드로 갖는 트리입니다. Expression.Lambda로 델리게이트 형태로 만든 뒤 Compile()하여 실행할 수 있습니다. 또한 EF Core 등은 Expression Tree를 해석해 SQL로 변환하기도 합니다.
2. 기본 예제: 수식 동적 생성과 실행
x => x * x + 1 형태의 함수를 런타임에 생성하고 실행하는 예제입니다.
using System;
using System.Linq.Expressions;
class Program
{
static void Main()
{
// 매개변수 x
var x = Expression.Parameter(typeof(int), "x");
var one = Expression.Constant(1);
// x * x + 1
var square = Expression.Multiply(x, x);
var add = Expression.Add(square, one);
var lambda = Expression.Lambda<Func<int, int>>(add, x);
// 컴파일 및 실행
var f = lambda.Compile();
Console.WriteLine(f(3)); // 10
// 디버그(식 문자열)
Console.WriteLine(lambda.ToString()); // x => ((x * x) + 1)
}
}
3. 실전: 동적 필터(Predicate) 만들기
사용자 입력(min, max)에 따라 Product.Price 범위 필터를 동적으로 생성합니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
public class Product
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
class Program
{
static void Main()
{
var products = new List<Product>
{
new() { Name = "A", Price = 9.9m },
new() { Name = "B", Price = 12m },
new() { Name = "C", Price = 20m },
};
decimal min = 10m, max = 15m;
var predicate = BuildPriceBetweenPredicate(min, max).Compile();
var result = products.Where(predicate).ToList();
// B만 필터링됩니다.
Console.WriteLine(string.Join(", ", result.Select(p => p.Name))); // B
}
static Expression<Func<Product, bool>> BuildPriceBetweenPredicate(decimal min, decimal max)
{
var p = Expression.Parameter(typeof(Product), "p");
var price = Expression.Property(p, nameof(Product.Price));
var ge = Expression.GreaterThanOrEqual(price, Expression.Constant(min));
var le = Expression.LessThanOrEqual(price, Expression.Constant(max));
var body = Expression.AndAlso(ge, le);
return Expression.Lambda<Func<Product, bool>>(body, p);
}
}
4. 실전: 속성명으로 OrderBy 만들기
문자열 속성명으로 정렬 키 선택자를 동적으로 빌드합니다. 메모리 내 컬렉션에서는 object 변환으로 간단히 구현할 수 있습니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
static class DynamicKeySelector
{
public static Func<T, object> Build<T>(string propertyName)
{
var param = Expression.Parameter(typeof(T), "x");
var prop = Expression.PropertyOrField(param, propertyName);
var body = Expression.Convert(prop, typeof(object)); // boxing 허용
return Expression.Lambda<Func<T, object>>(body, param).Compile();
}
}
// 사용 예
// var ordered = products.OrderBy(DynamicKeySelector.Build<Product>("Price"));
참고: EF Core 같은 LINQ provider에 전달할 때는 object 변환이 번역되지 않을 수 있습니다. 이 경우 제네릭 TKey를 런타임에 결정하거나, 미리 허용된 키 타입에 한해 오버로드를 제공하는 전략을 권장합니다.
5. 재사용을 위한 캐싱 전략
Compile()은 비용이 있으므로 동일한 구조의 식은 캐싱해 성능을 높입니다.
using System;
using System.Collections.Concurrent;
using System.Linq.Expressions;
public static class PredicateFactory
{
private static readonly ConcurrentDictionary<(decimal min, decimal max), Func<Product, bool>> Cache = new();
public static Func<Product, bool> PriceBetween(decimal min, decimal max)
{
return Cache.GetOrAdd((min, max), key =>
{
var p = Expression.Parameter(typeof(Product), "p");
var price = Expression.Property(p, nameof(Product.Price));
var ge = Expression.GreaterThanOrEqual(price, Expression.Constant(key.min));
var le = Expression.LessThanOrEqual(price, Expression.Constant(key.max));
var body = Expression.AndAlso(ge, le);
return Expression.Lambda<Func<Product, bool>>(body, p).Compile();
});
}
}
6. ExpressionVisitor로 식 결합/변환하기
두 개의 Predicate를 하나로 합치는 전형적인 패턴입니다. 서로 다른 매개변수를 하나로 통일한 뒤 AndAlso로 결합합니다.
using System;
using System.Linq.Expressions;
public class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _from;
private readonly ParameterExpression _to;
public ParameterReplacer(ParameterExpression from, ParameterExpression to)
=> (_from, _to) = (from, to);
protected override Expression VisitParameter(ParameterExpression node)
=> node == _from ? _to : base.VisitParameter(node);
}
static class PredicateComposer
{
public static Expression<Func<T, bool>> AndAlso<T>(Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T), "p");
var leftBody = new ParameterReplacer(left.Parameters[0], param).Visit(left.Body)!;
var rightBody = new ParameterReplacer(right.Parameters[0], param).Visit(right.Body)!;
var body = Expression.AndAlso(leftBody, rightBody);
return Expression.Lambda<Func<T, bool>>(body, param);
}
}
// 사용 예
// Expression<Func<Product, bool>> p1 = p => p.Price >= 10m;
// Expression<Func<Product, bool>> p2 = x => x.Name.StartsWith("A");
// var combined = PredicateComposer.AndAlso(p1, p2).Compile();
7. 성능/주의 사항 체크리스트
- Compile()은 비쌉니다. 동일 구조는 캐시하거나, .Compile(preferInterpretation: true)로 인터프리터 경로를 선택해 시작 비용을 낮출 수 있습니다(런타임 속도는 다소 느립니다).
- Expression Tree는 타입 안전합니다. 단, 문자열 속성명은 오타에 취약하므로 nameof를 사용하거나 화이트리스트를 강제하세요.
- 외부 입력으로 메서드 호출/멤버 접근을 무분별하게 허용하지 마세요. 허용 목록을 두고 검증해야 합니다.
- EF Core 등으로 전달 시 번역 가능한 노드만 사용해야 합니다. Convert(object) 등은 번역 실패할 수 있습니다.
- Reflection보다 빠를 때가 많지만, 상황에 따라 사전 생성된 델리게이트나 소스 제너레이터가 더 적합할 수 있습니다.
8. 언제 Expression Tree를 쓰면 좋은가
- 사용자 정의 필터/정렬/프로젝션을 안전하게 조립해야 할 때
- 규칙 엔진, 검색 DSL, 빌더 API 등에서 실행 계획을 만들어야 할 때
- LINQ provider에 전달할 쿼리를 동적으로 만들 때
Expression Tree는 "동적이지만 안전한" 코드를 만드는 실용적인 도구입니다. 작은 블록부터 조립하고, Compile 비용은 캐시로 상쇄하며, 대상 런타임(EF 등)의 제약에 맞춰 노드 구성을 선택하면 안정적이고 빠른 동적 코드를 구현할 수 있습니다.
'C#' 카테고리의 다른 글
| C# 값 튜플(ValueTuple) vs Tuple 성능 비교 (0) | 2026.05.12 |
|---|---|
| C# Immutable Collections 사용과 장점 (0) | 2026.05.11 |
| C# AssemblyLoadContext로 플러그인 아키텍처 만들기 (0) | 2026.05.08 |
| C# 애플리케이션에서 메모리 풀(Object Pool) 구현하기 (0) | 2026.05.08 |
| C# 반응형 프로그래밍(Rx.NET) 개념과 Observable 활용 (0) | 2026.05.07 |