사용자 필터가 자주 바뀌는 검색 화면에서는 컴파일 타임 안전성과 성능을 지키면서 동적 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 레이어를 빠르게 구축할 수 있습니다.
'C#' 카테고리의 다른 글
| C# Dictionary의 ValueComparer 커스터마이징 (0) | 2026.07.08 |
|---|---|
| C# IDisposable 구현 시 Finalizer와의 상호작용 (0) | 2026.07.07 |
| C# HttpClient에서 다중 요청 병렬 처리 구현 (0) | 2026.07.07 |
| C# Stopwatch를 활용한 코드 블록별 성능 분석 (0) | 2026.07.03 |
| C# StreamReader와 StreamWriter의 고급 활용 (0) | 2026.07.03 |