본문 바로가기

C#

C# Expression Tree로 동적 코드 생성하기

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 등)의 제약에 맞춰 노드 구성을 선택하면 안정적이고 빠른 동적 코드를 구현할 수 있습니다.