본문 바로가기

C#

C# Expression Tree로 동적 LINQ 쿼리 빌드하기

사용자 필터가 자주 바뀌는 검색 화면에서는 컴파일 타임 안전성과 성능을 지키면서 동적 LINQ를 만들어야 합니다. C# Expression Tree를 이용하면 문자열 연결식이 아닌 타입 안전한 방식으로 Where/OrderBy 등을 조립할 수 있습니다.

1. Expression Tree 핵심

람다식을 코드로 조립하여 Expression<Func<T,bool>> 형태로 만듭니다.

using System;
using System.Linq.Expressions;

// 간단 예: x => x > 10
Expression<Func<int, bool>> expr1 = x => x > 10;

// 수동 조립
var p = Expression.Parameter(typeof(int), "x");
var body = Expression.GreaterThan(p, Expression.Constant(10));
var expr2 = Expression.Lambda<Func<int, bool>>(body, p);
Console.WriteLine(expr2); // x => (x > 10)

2. 속성명 기반 동적 비교식 만들기

UI에서 넘어온 속성명/값으로 동적 Equal 비교식을 생성합니다.

using System;
using System.Linq.Expressions;

static Expression<Func<T, bool>> BuildEqual<T>(string property, object value)
{
    var param = Expression.Parameter(typeof(T), "x");
    var member = Expression.PropertyOrField(param, property);
    var constant = ToConstant(value, member.Type);
    var body = Expression.Equal(member, constant);
    return Expression.Lambda<Func<T, bool>>(body, param);
}

static ConstantExpression ToConstant(object value, Type targetType)
{
    if (value == null) return Expression.Constant(null, targetType);
    var t = Nullable.GetUnderlyingType(targetType) ?? targetType;
    if (t.IsEnum)
    {
        if (value is string s) value = Enum.Parse(t, s, true);
        else value = Enum.ToObject(t, value);
    }
    else if (t != value.GetType())
    {
        value = Convert.ChangeType(value, t);
    }
    return Expression.Constant(value, targetType);
}

3. 조건 결합(AND/OR)과 파라미터 정리

서로 다른 파라미터를 하나로 통일한 뒤 AndAlso/OrElse로 결합합니다.

using System;
using System.Linq.Expressions;

class ParameterReplacer : ExpressionVisitor
{
    private readonly ParameterExpression _from;
    private readonly ParameterExpression _to;
    public ParameterReplacer(ParameterExpression from, ParameterExpression to)
    {
        _from = from; _to = to;
    }
    protected override Expression VisitParameter(ParameterExpression node)
        => node == _from ? _to : base.VisitParameter(node);
}

static Expression<Func<T, bool>> True<T>() => x => true;
static Expression<Func<T, bool>> False<T>() => x => false;

static Expression<Func<T, bool>> And<T>(Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
    var param = left.Parameters[0];
    var rightBody = new ParameterReplacer(right.Parameters[0], param).Visit(right.Body);
    return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(left.Body, rightBody!), param);
}

static Expression<Func<T, bool>> Or<T>(Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
    var param = left.Parameters[0];
    var rightBody = new ParameterReplacer(right.Parameters[0], param).Visit(right.Body);
    return Expression.Lambda<Func<T, bool>>(Expression.OrElse(left.Body, rightBody!), param);
}

4. 문자열 검색(Contains/StartsWith)

EF Core에서 잘 번역되는 Contains/StartsWith를 사용합니다. 대소문자 무시는 ToLower로 통일합니다.

using System;
using System.Linq.Expressions;

static Expression<Func<T, bool>> Contains<T>(string property, string keyword, bool ignoreCase = true)
{
    var param = Expression.Parameter(typeof(T), "x");
    var member = Expression.PropertyOrField(param, property);
    var notNull = Expression.NotEqual(member, Expression.Constant(null, typeof(string)));

    Expression memberExpr = member;
    Expression keywordExpr = Expression.Constant(keyword, typeof(string));

    if (ignoreCase)
    {
        var toLower = typeof(string).GetMethod(nameof(string.ToLower), Type.EmptyTypes);
        memberExpr = Expression.Call(memberExpr, toLower!);
        keywordExpr = Expression.Call(keywordExpr, toLower!);
    }

    var contains = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) });
    var call = Expression.Call(memberExpr, contains!, keywordExpr);
    var body = Expression.AndAlso(notNull, call);
    return Expression.Lambda<Func<T, bool>>(body, param);
}

static Expression<Func<T, bool>> StartsWith<T>(string property, string prefix, bool ignoreCase = true)
{
    var param = Expression.Parameter(typeof(T), "x");
    var member = Expression.PropertyOrField(param, property);
    var notNull = Expression.NotEqual(member, Expression.Constant(null, typeof(string)));

    Expression memberExpr = member;
    Expression prefixExpr = Expression.Constant(prefix, typeof(string));

    if (ignoreCase)
    {
        var toLower = typeof(string).GetMethod(nameof(string.ToLower), Type.EmptyTypes);
        memberExpr = Expression.Call(memberExpr, toLower!);
        prefixExpr = Expression.Call(prefixExpr, toLower!);
    }

    var starts = typeof(string).GetMethod(nameof(string.StartsWith), new[] { typeof(string) });
    var call = Expression.Call(memberExpr, starts!, prefixExpr);
    var body = Expression.AndAlso(notNull, call);
    return Expression.Lambda<Func<T, bool>>(body, param);
}

5. 동적 정렬(OrderBy/ThenBy)

속성명을 받아 OrderBy/ThenBy를 Expression.Call로 생성합니다.

using System;
using System.Linq;
using System.Linq.Expressions;

static IOrderedQueryable<T> ApplyOrderBy<T>(IQueryable<T> source, string property, bool descending = false)
{
    var param = Expression.Parameter(typeof(T), "x");
    var member = Expression.PropertyOrField(param, property);
    var keySelector = Expression.Lambda(member, param);

    var method = descending ? nameof(Queryable.OrderByDescending) : nameof(Queryable.OrderBy);

    var call = Expression.Call(
        typeof(Queryable),
        method,
        new Type[] { typeof(T), member.Type },
        source.Expression,
        Expression.Quote(keySelector)
    );

    return (IOrderedQueryable<T>)source.Provider.CreateQuery<T>(call);
}

static IOrderedQueryable<T> ApplyThenBy<T>(IOrderedQueryable<T> source, string property, bool descending = false)
{
    var param = Expression.Parameter(typeof(T), "x");
    var member = Expression.PropertyOrField(param, property);
    var keySelector = Expression.Lambda(member, param);

    var method = descending ? nameof(Queryable.ThenByDescending) : nameof(Queryable.ThenBy);

    var call = Expression.Call(
        typeof(Queryable),
        method,
        new Type[] { typeof(T), member.Type },
        source.Expression,
        Expression.Quote(keySelector)
    );

    return (IOrderedQueryable<T>)source.Provider.CreateQuery<T>(call);
}

6. 전체 예제: 제품 검색 필터

가격 범위, 키워드, 카테고리 목록, 정렬을 조합한 예제입니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

class Product
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
}

static IQueryable<Product> FilterProducts(
    IQueryable<Product> source,
    decimal? minPrice,
    decimal? maxPrice,
    string keyword,
    IEnumerable<string> categories,
    string sortBy = "Price",
    bool desc = false)
{
    var predicate = True<Product>();

    if (minPrice.HasValue)
    {
        var p = Expression.Parameter(typeof(Product), "p");
        var body = Expression.GreaterThanOrEqual(
            Expression.PropertyOrField(p, nameof(Product.Price)),
            Expression.Constant(minPrice.Value));
        var e = Expression.Lambda<Func<Product, bool>>(body, p);
        predicate = And(predicate, e);
    }

    if (maxPrice.HasValue)
    {
        var p = Expression.Parameter(typeof(Product), "p");
        var body = Expression.LessThanOrEqual(
            Expression.PropertyOrField(p, nameof(Product.Price)),
            Expression.Constant(maxPrice.Value));
        var e = Expression.Lambda<Func<Product, bool>>(body, p);
        predicate = And(predicate, e);
    }

    if (!string.IsNullOrWhiteSpace(keyword))
    {
        predicate = And(predicate, Contains<Product>(nameof(Product.Name), keyword));
    }

    if (categories != null && categories.Any())
    {
        // p => categories.Contains(p.Category)
        var p = Expression.Parameter(typeof(Product), "p");
        var member = Expression.PropertyOrField(p, nameof(Product.Category));
        var listConst = Expression.Constant(categories);
        var contains = typeof(Enumerable).GetMethods()
            .First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2)
            .MakeGenericMethod(typeof(string));
        var body = Expression.Call(contains, listConst, member);
        var e = Expression.Lambda<Func<Product, bool>>(body, p);
        predicate = And(predicate, e);
    }

    var query = source.Where(predicate);

    if (!string.IsNullOrWhiteSpace(sortBy))
    {
        try
        {
            query = ApplyOrderBy(query, sortBy, desc);
        }
        catch
        {
            // 잘못된 속성명이면 기본 정렬로 fallback
            query = ApplyOrderBy(query, nameof(Product.Price), desc);
        }
    }

    return query;
}

// 예시 사용
var data = new List<Product>
{
    new Product { Name = "Book A", Price = 10m, Category = "Book" },
    new Product { Name = "Phone X", Price = 799m, Category = "Electronics" },
    new Product { Name = "Book B", Price = 15m, Category = "Book" }
}.AsQueryable();

var result = FilterProducts(
    data,
    minPrice: 10,
    maxPrice: 100,
    keyword: "book",
    categories: new[] { "Book" },
    sortBy: "Name",
    desc: false
).ToList();

7. EF Core 호환 및 성능 팁

- 번역 가능한 메서드만 사용합니다: Contains/StartsWith/EndsWith/ToLower 등은 안전합니다. 커스텀 메서드는 번역되지 않습니다.

- 값 캡처 대신 Expression.Constant로 트리에 값을 주입합니다.

- 동일한 필터 형태는 Expression/Lambda를 캐싱하면 컴파일 오버헤드를 줄일 수 있습니다.

- 널 안전: 문자열 비교 전 NotNull 체크를 추가합니다.

- 정렬 속성은 화이트리스트로 검증하여 예외/Fallback을 대비합니다.

8. 마무리

Expression Tree를 이용하면 런타임 조건을 타입 안전하게 조립할 수 있습니다. 위 헬퍼(Equals/Contains/And/Or/OrderBy)를 조합하면 유지보수 가능한 동적 LINQ 레이어를 빠르게 구축할 수 있습니다.