소프트웨어 개발 과정에서 사용자의 입력이나 설정 파일에 따라 복잡한 쿼리 조건을 실시간으로 생성해야 하는 경우가 빈번합니다. 특히 데이터베이스 검색이나 메모리 내 컬렉션 필터링 시, 조건의 개수나 연산자가 유동적이라면 고정된 형태의 LINQ 쿼리만으로는 대응하기 어렵습니다. 이때 C#의 Expression Tree(익스프레션 트리)를 사용하면 프로그래밍 방식으로 Lambda 식을 동적으로 구성하여 유연한 쿼리 시스템을 구축할 수 있습니다.
동적 쿼리 요구사항 예시
사용자 정보(User) 모델을 대상으로 다양한 필터를 적용해야 하는 상황을 가정해 보겠습니다. 프런트엔드에서 다음과 같은 구조의 JSON 데이터를 전달하여 필터링을 요청할 수 있습니다.
{
"filterGroups": [
[
{
"property": "UserName",
"operator": "In",
"values": ["Alice", "Bob"],
"isInclude": true
},
{
"property": "Age",
"operator": "GreaterThan",
"values": [20],
"isInclude": true
}
],
[
{
"property": "Email",
"operator": "Contains",
"values": ["gmail.com"],
"isInclude": true
}
]
]
}
위 데이터 구조는 내부 배열 요소 간에는 AND 연산을, 외부 배열 간에는 OR 연산을 수행해야 함을 의미합니다. 이러한 동적 논리를 코드로 구현하기 위해서는 식(Expression)을 직접 조립해야 합니다.
Expression Tree를 이용한 논리 결합
기본적으로 LINQ의 Where 메서드는 Expression<Func<T, bool>> 형태의 조건자를 인자로 받습니다. 여러 조건을 하나로 합치기 위해 OR 연산을 수행하는 확장 메서드를 다음과 같이 정의할 수 있습니다.
public static Expression<Func<T, bool>> CombineOr<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
{
var parameter = Expression.Parameter(typeof(T), "x");
var leftVisitor = new ParameterReplacer(first.Parameters[0], parameter);
var left = leftVisitor.Visit(first.Body);
var rightVisitor = new ParameterReplacer(second.Parameters[0], parameter);
var right = rightVisitor.Visit(second.Body);
return Expression.Lambda<Func<T, bool>>(Expression.OrElse(left, right), parameter);
}
// 매개변수 일치를 위한 ExpressionVisitor 구현
internal class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _oldParam;
private readonly ParameterExpression _newParam;
public ParameterReplacer(ParameterExpression oldParam, ParameterExpression newParam)
{
_oldParam = oldParam;
_newParam = newParam;
}
protected override Expression VisitParameter(ParameterExpression node)
{
return node == _oldParam ? _newParam : base.VisitParameter(node);
}
}
주요 연산자별 Expression 생성 방법
1. 동등 비교 (Equality)
특정 속성의 값이 지정된 값과 일치하는지 확인하는 식입니다.
public static Expression<Func<T, bool>> CreateEquality<T>(string propertyName, object val, bool isPositive)
{
var param = Expression.Parameter(typeof(T), "entity");
var member = Expression.PropertyOrField(param, propertyName);
var constant = Expression.Constant(val);
// member.Equals(constant) 호출 생성
var body = Expression.Call(member, member.Type.GetMethod("Equals", new[] { member.Type }), constant);
Expression finalExpression = isPositive ? body : Expression.Not(body);
return Expression.Lambda<Func<T, bool>>(finalExpression, param);
}
2. 문자열 포함 (String Contains)
문자열 필드에서 특정 키워드가 포함되어 있는지 검색할 때 사용합니다.
public static Expression<Func<T, bool>> CreateContains<T>(string propertyName, string searchStr)
{
var param = Expression.Parameter(typeof(T), "entity");
var member = Expression.PropertyOrField(param, propertyName);
var method = typeof(string).GetMethod("Contains", new[] { typeof(string) });
var constant = Expression.Constant(searchStr, typeof(string));
var call = Expression.Call(member, method, constant);
return Expression.Lambda<Func<T, bool>>(call, param);
}
3. 목록 포함 여부 (In List)
컬렉션 내에 해당 속성 값이 포함되어 있는지 확인하는 IN 절과 유사한 기능을 구현합니다.
public static Expression<Func<T, bool>> CreateInList<T, V>(string propertyName, List<V> values)
{
var param = Expression.Parameter(typeof(T), "entity");
var member = Expression.PropertyOrField(param, propertyName);
var listConstant = Expression.Constant(values);
var containsMethod = typeof(List<V>).GetMethod("Contains");
var call = Expression.Call(listConstant, containsMethod, member);
return Expression.Lambda<Func<T, bool>>(call, param);
}
Expression Tree의 구조와 동작 원리
Expression Tree는 코드를 데이터 구조로 표현한 것입니다. item => item.Id == 100이라는 람다 식을 빌드하는 과정은 다음과 같은 단계를 거칩니다.
- ParameterExpression: 타입 파라미터 정의 (예:
User item) - MemberExpression: 속성 또는 필드 접근 정의 (예:
item.Id) - ConstantExpression: 비교할 상수 값 정의 (예:
100) - BinaryExpression: 연산 수행 (예:
Equal연산자로 멤버와 상수 결합) - LambdaExpression: 최종적으로 파라미터와 바디를 결합하여 람다 식 완성
// 수동 빌드 예시
var paramExp = Expression.Parameter(typeof(User), "u");
var propExp = Expression.Property(paramExp, "Id");
var constExp = Expression.Constant(100);
var equalExp = Expression.Equal(propExp, constExp);
var finalLambda = Expression.Lambda<Func<User, bool>>(equalExp, paramExp);
// 실행 가능한 델리게이트로 컴파일
Func<User, bool> compiledFunc = finalLambda.Compile();
요약 및 활용 이점
Expression Tree를 활용하면 단순한 리플렉션보다 강력하고 효율적인 프로그래밍이 가능합니다.
- 동적 쿼리 생성: 런타임 조건에 따라 복잡한 필터링 로직을 유연하게 조합할 수 있습니다.
- 성능 최적화: 한 번 컴파일된 람다 식은 일반 메서드 호출과 유사한 성능을 내며, 리플렉션에 비해 실행 속도가 매우 빠릅니다.
- 추상화: 데이터 원천이 SQL 데이터베이스이든 메모리 내 리스트이든 상관없이 동일한 인터페이스로 쿼리 로직을 작성할 수 있습니다.