콘텐츠로 이동

C# Expression Tree 심화

Expression Tree는 코드를 데이터 구조(트리)로 표현합니다. 람다를 Func<T>가 아닌 Expression<Func<T>>로 받으면 코드를 분석·변환·원격 실행할 수 있습니다.

// Func<int, bool>: 즉시 실행 가능한 델리게이트
Func<int, bool> func = x => x > 5;
// Expression<Func<int, bool>>: 분석 가능한 트리 구조
Expression<Func<int, bool>> expr = x => x > 5;
// 트리 구조 출력
Console.WriteLine(expr); // x => (x > 5)
Console.WriteLine(expr.Body); // (x > 5)
Console.WriteLine(expr.Body.NodeType); // GreaterThan
Expression<Func<int, bool>> expr = x => x > 5 && x < 100;
var binary = (BinaryExpression)expr.Body; // AndAlso
var left = (BinaryExpression)binary.Left; // x > 5
var right = (BinaryExpression)binary.Right; // x < 100
Console.WriteLine(left.Left); // x (ParameterExpression)
Console.WriteLine(left.Right); // 5 (ConstantExpression)
// x => x.Age > minAge && x.Name.Contains(keyword) 동적 생성
static Expression<Func<User, bool>> BuildFilter(int? minAge, string? keyword) {
var param = Expression.Parameter(typeof(User), "x");
Expression body = Expression.Constant(true);
if (minAge.HasValue) {
var ageProp = Expression.Property(param, nameof(User.Age));
var ageConst = Expression.Constant(minAge.Value);
var ageFilter = Expression.GreaterThan(ageProp, ageConst);
body = Expression.AndAlso(body, ageFilter);
}
if (!string.IsNullOrEmpty(keyword)) {
var nameProp = Expression.Property(param, nameof(User.Name));
var containsMethod = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!;
var nameFilter = Expression.Call(nameProp, containsMethod, Expression.Constant(keyword));
body = Expression.AndAlso(body, nameFilter);
}
return Expression.Lambda<Func<User, bool>>(body, param);
}
var filter = BuildFilter(minAge: 18, keyword: "Alice");
var results = dbContext.Users.Where(filter).ToList();

Expression을 컴파일하면 델리게이트가 됩니다. 컴파일 비용이 크므로 캐싱이 필수입니다.

private static readonly ConcurrentDictionary<string, Func<User, bool>> _cache = new();
public IEnumerable<User> Filter(int? minAge, string? keyword) {
string key = $"{minAge}_{keyword}";
var compiled = _cache.GetOrAdd(key, _ => BuildFilter(minAge, keyword).Compile());
return _users.Where(compiled);
}

ExpressionVisitor를 상속하면 트리를 순회하며 특정 노드를 변환할 수 있습니다.

// 모든 문자열 비교를 대소문자 무시로 변환
class CaseInsensitiveVisitor : ExpressionVisitor {
protected override Expression VisitMethodCall(MethodCallExpression node) {
if (node.Method.Name == nameof(string.Equals) &&
node.Method.DeclaringType == typeof(string)) {
return Expression.Call(
node.Object!,
typeof(string).GetMethod(nameof(string.Equals),
new[] { typeof(string), typeof(StringComparison) })!,
node.Arguments[0],
Expression.Constant(StringComparison.OrdinalIgnoreCase));
}
return base.VisitMethodCall(node);
}
}
var visitor = new CaseInsensitiveVisitor();
var modified = (Expression<Func<User, bool>>)visitor.Visit(originalExpr);

EF Core 같은 ORM이 Expression Tree를 SQL로 변환하는 원리입니다.

// IQueryable<T>.Where()는 Expression을 저장만 함
var query = dbContext.Users
.Where(u => u.Age > 18) // Expression 추가
.OrderBy(u => u.Name) // Expression 추가
.Select(u => u.Name); // Expression 추가
// 아직 DB 호출 없음
var result = query.ToList(); // 여기서 전체 트리를 SQL로 변환 후 실행
  • Expression<Func<T>>는 코드를 데이터로 표현 → 분석·변환·SQL 변환 가능
  • 동적 필터 빌더: 조건을 런타임에 조합해 IQueryable에 전달
  • 컴파일(Compile())은 비용이 크므로 결과 캐싱 필수
  • ExpressionVisitor로 기존 트리를 변환해 동작 수정 가능
  • EF Core, Dapper 등 ORM의 LINQ 지원이 이 메커니즘을 사용함