동적 Linq 연산 #2 – Where

지난 포스트에 이어…

지난 포스트에서 런타임에 동적으로 결정되는 속성 이름을 사용한 시퀀스 정렬과 CreateDelegate 메서드를 사용한 최적화 방법에 대해 살펴봤습니다. 정렬 작업과 더불어 Linq에서 가장 많이 사용되는 연산은 필터링 작업입니다. 이번 포스트에서는 Where 연산에 속성 이름을 적용하는 방법을 정리하겠습니다.

Where 연산 구현

지난 포스트OrderBy 메서드는 Linq에서 제공하는 OrderBy 연산의 래퍼였습니다. 입력된 속성이름을 기반으로 속성 값을 가져와 이 값을 그대로 정렬 키로 사용했죠. 반면 Where 메서드는 속성 값을 가져와 바로 사용하기 보다는 이 속성에 지정된 조건 식을 적용시켜야 합니다. 그러려면 속성의 이름에 더해 조건식을 입력받도록 메서드 인터페이스를 설계할 필요가 있습니다.

public static IEnumerable<TSource> Where<TSource, TProperty>(
    this IEnumerable<TSource> s,
    string propertyName,
    Func<TProperty, bool> predicate)
{
    ...
}

입력받은 Func<TProperty, bool> 형식의 대리자와 속성 접근자를 조합해 predicate 매개변수로 사용해 Linq의 Where 메서드를 호출합니다. OrderBy 연산처럼 우선 리플렉션을 사용해서 구현해 보겠습니다.

public static IEnumerable<TSource> Where<TSource, TProperty>(this IEnumerable<TSource> s, string propertyName, Func<TProperty, bool> predicate)
{
    var prop = typeof(TSource).GetProperty(propertyName);
    if (prop == null)
        throw new InvalidOperationException("Property Not Found");
    return s.Where(e => predicate((TProperty)prop.GetValue(e)));
}

다음은 CreateDelegate를 사용해 성능을 개선한 버전입니다.

* 성능을 높이기 위해 CreateDelegate를 사용하는 이유는 이전 포스트를 참조하세요.

private static MethodInfo whereTemplate = typeof(Enumerable)
    .GetMember("Where")
    .OfType<MethodInfo>()
    .Where(m => m.IsGenericMethodDefinition && m.GetParameters().Length == 2)
    .Single(m => m.GetParameters()[1].ParameterType.GetGenericArguments().Length == 2);

private static Delegate GetGetter<T>(PropertyInfo prop)
{
    var funcType = typeof(Func<,>).MakeGenericType(typeof(T), prop.PropertyType);
    return Delegate.CreateDelegate(funcType, prop.GetGetMethod());
}

public static IEnumerable<TSource> Where<TSource, TProperty>(this IEnumerable<TSource> s, string propertyName, Func<TProperty, bool> predicate)
{
    var prop = typeof(TSource).GetProperty(propertyName);
    if (prop == null)
        throw new InvalidOperationException("Property Not Found");
    var operation = whereTemplate.MakeGenericMethod(typeof(TSource));
    var selector = (Func<TSource, TProperty>)GetGetter<TSource>(prop);
    Func<TSource, bool> componentPredicate = e => predicate(selector(e));
    return (IEnumerable<TSource>)operation.Invoke(null, new object[] { s, componentPredicate });
}

형식 제약

위에서 구현한 Where 메서드는 속성 형식에 대한 제약이 있습니다. 이와 관련된 두 가지 이슈가 있는데 첫 번째는 컴파일러가 두 개의 제네릭 형식 매개변수를 알 수 있도록 해줘야 한다는 것입니다. 한 가지 방법은 Where 메서드를 호출할 때 명시적으로 형식을 지정해 주는 것입니다.

var s = Enumerable.Range(0, n).Select(i => Tuple.Create(i, r.Next(100, 200), r.NextDouble())).ToArray();

foreach (var t in s.Where<Tuple<int, int, double>, int>("Item2", p => p < 150))
    Console.WriteLine(t);

소스 컬렉션 요소 형식과 속성 형식을 직접 코딩해 컴파일러에게 알려줍니다. 이 방법은 컬렉션 요소 형식의 이름이 길면 코드가 길어지고 가독성이 낮다는 단점이 있습니다. 사실 컬렉션 요소 형식은 소스 컬렉션 매개변수를 통해 추론될 수 있습니다. 하지만 제네릭 메서드를 사용할 때 제네릭 매개변수의 일부만 추론될 수는 없습니다. 모든 형식 매개변수가 추론되거나 또는 모든 형식 매개변수를 명시적으로 지정해주거나 해야하죠. 그러니 컴파일러가 컬렉션 요소 형식을 추론하게 하려면 속성 형식도 함께 추론할 수 있게 도와줘야 합니다. 람다식 매개변수 형식을 지정해 주는 것이 하나의 방법입니다.

foreach (var t in s.Where("Item2", (int p) => p < 150))
    Console.WriteLine(t);

이전보다 간결해진 코드를 확인할 수 있습니다.

속성 형식에 대한 두 번째 이슈는 속성 이름은 동적으로 처리될 수 있지만 같은 형식의 속성들에 대해서만 가능하다는 것입니다. 컬렉션 요소가 int 형식의 Value1 속성과 double 형식의 Value2 속성을 가진다고 가정해 봅시다. 속성 값이 0보다 크거나 같은 경우에 대한 필터링을 동적으로 속성 이름을 입력받아 처리할 경우 p >= 0 람다식의 매개변수 p의 형식은 컴파일 타임에 결정되어야 합니다. 그렇기 때문에 Value1 속성과 Value2 속성 모두를 동적으로 처리하는 것은 불가능합니다.

dynamic

C#은 정적 형식 기반의 컴파일 언어이지만 일부 동적 언어의 특징도 포함하고 있습니다. 우리가 직면한 문제를 해결하기에 dynamic은 검토해 볼만한 도구입니다. 앞서 구현한 버전에서 사용한 predicate 매개변수의 형식을 Func<TProperty, bool>에서 Func<dynamic, bool>로 변경하면 컴파일 타임에 속성 형식이 결정되지 않은 람다식을 작성할 수 있습니다.

public static Func<TSource, bool> MakeComponentPredicate<TSource, TProperty>(Func<TSource, TProperty> getter, Func<dynamic, bool> propertyPredicate)
{
    return (Func<TSource, bool>)(e => propertyPredicate(getter(e)));
}

public static IEnumerable<T> Where<T>(this IEnumerable<T> s, string propertyName, Func<dynamic, bool> predicate)
{
    var prop = typeof(T).GetProperty(propertyName);
    if (prop == null)
        throw new InvalidOperationException("Property Not Found");
    var operation = whereTemplate.MakeGenericMethod(typeof(T));
    var selector = GetGetter<T>(prop);
    var componentPredicate = typeof(DynamicLinqOperations)
        .GetMethod("MakeComponentPredicate")
        .MakeGenericMethod(typeof(T), prop.PropertyType)
        .Invoke(null, new object[] { selector, predicate });
    return (IEnumerable<T>)operation.Invoke(null, new object[] { s, componentPredicate });
}

dynamic이 적용된 버전의 Where 확장 메서드는 속성 형식이 런타임에 결정되기 때문에 이전에 우리가 고민했던 제네릭 매개변수 추론에 대한 문제도 사라집니다. 이제 클라이언트 코드는 아래와 같이 수정될 수 있습니다.

foreach (var t in s.Where("Item2", p => p < 150))
    Console.WriteLine(t);

코드가 좀 더 간결하고 유연해 졌네요. 위 코드는 < 연산자가 적용될 수 있는 모든 형식(int, double, decimal, …)의 속성에 대해 동작합니다.

그런데 dynamic 버전 Where 메서드가 장점만을 가지고 있을까요?

성능

C#에 있어서 동적 언어 특징은 유연한 코드 작성을 도와주지만 정적 형식 코드에 비해 많은 비용이 요구됩니다. 요즘의 JavaScript 수준의 고성능 동적 코드를 기대하기는 어렵습니다. 여전히 C#은 정적 형식을 사용하는 코드에 최적화된 언어입니다. 적어도 이 포스트가 작성되고 있는 시점에서는 그렇습니다. 제 노트북에서 100만개의 요소를 가진 컬렉션에 대해 테스트 했을 때 대리자와 정적 속성 형식을 사용한 방법을 기준으로 dynamic 버전은 약 4배 정도의 비용이 발생합니다.

비용(ms)
Reflection 490
Delegate 29
Dynamic 109

아직 Where 연산 구현에 있어서 성능과 유연한 코드 두 마리 토끼를 잡을 수 있는 방법을 발견하지 못했습니다. 식 트리(expression trees)를 이용한 방법을 시도하고 있지만 낙관적이지는 않습니다. 그나마 한 가지 다행이라면 정적 버전과 동적 버전의 메서드 시그니처가 다르다는 점입니다. 두 버전을 모두 유지하며 클라이언트 측에서 상황에 적합한 쪽을 선택하게 하는 것은 가능합니다.

결론

가장 많이 사용되는 Linq 연산인 OrderByWhere를 런타임에 결정되는 속성에 대해 사용하게 해주는 방법을 포스트 두 개에 걸쳐 살펴봤습니다. 여기에 등장한 코드가 다양한 환경의 모든 요구를 만족시키지는 못하지만 CreateDelegate, MakeGenericMethod, dynamic 등의 기법을 이해하고 활용하면 문제를 해결하는 과정에 좋은 힌트가 될 수 있을 것입니다. 사용된 모든 코드는 여기에서 확인할 수 있습니다.

Advertisements

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중