IEnumerable<T> 데이터 소스와 IQueryable<T> 데이터 소스를 구분하라
IQueryable<T>와 IEnumerable<T>는 거의 동일한 API 정의를 가진다. 따라서 이 두 인터페이스는 상호 교환 가능하다고 생각할 것이며 실제로도 대부분 그렇다. 이는 사실 의도한 설계이기도 하다. 하지만 시퀀스는 그냥 시퀀스일 뿐이어서 항상 이 둘을 서로 대체하여 사용할 수 있는 것은 아니다. 사실 이 둘은 동작 방식도 매우 다르고 성능 차이도 크게 난다. 다음의 예를 살펴보자.
// 첫번째 예
var q = from c in dbContext.Customers where c.City == "London" select c;
var finalAnswer = from c in q orderby c.Name select c;
// 두번째 예
var q = (from c in dbContext.Customers where c.City == "London" select c).AsEnumerable();
var finalAnswer = from c in q orderby c.Name select c;
두 예제의 결괏값은 동일하다. 하지만 그 동작 방식은 매우 상이하다. 첫 번째 예는 일반적인 LINQ to SQL 쿼리이며 IQueryable<T>의 기능을 사용한다. 두 번째 예는 데이터베이스 객체를 IEnumerable<T> 시퀀스로 변경하기 때문에 데이터베이스가 아니라 로컬 머신에서 더 많은 작업을 수행하게 된다. 이 쿼리는 지연 평가와 LINQ to SQL 내의 IQueryable<T>를 동시에 사용한다.
첫 번째 예의 경우 LINQ to SQL 라이브러리가 모든 쿼리문을 결합하여 단번에 SQL 결과를 생성한다. 앞의 예제의 경우 where 절과 order 절이 모두 결합된 달일 T-SQL 구문을 만들어서 단 한 차례 데이터베이스를 호출한다.
두 번째 예의 경우 첫 번째 수행된 쿼리문이 IEnumerable<T> 시퀀스를 반환하므로 그다음 작업은 LINQ to Objects 구현체와 델리게이트를 이용하여 수행된다. 쿼리문이 수행되면 데이터베이스에 쿼리를 전달하여 City 값이 London인 모든 레코드를 가져오게 된다. 이후 가져온 레코드들을 Name 필드에 따라 정렬한다. 정렬 작업은 로컬 머신에서 수행된다.
대부분의 경우 쿼리 작업을 수행할 때 IEnumerable<T>를 사용하는 것보다 IQueryable<T>를 사용하는 편이 훨씬 효율적이기 때문에 이 둘의 차이점을 잘 알아둬야 한다. 또한 IQueryable<T>와 IEnumerable<T>의 차이로 인해 일부 쿼리들은 둘 중 어느 한쪽에 대해서만 올바르게 동작하는 경우도 있다.
사실 이 둘은 개별 단계만을 비교해도 매우 다르게 동작한다.
Enumerable<T> 확장 메서드는 쿼리식 내의 람다 표현식과 함수 매개변수를 나타내기 위해서 델리게이트를 사용한다. 반면 Queryable<T>는 동일한 함수라 하더라도 표현식 트리를 이용하여 이를 처리한다. 표현식 트리란 쿼리 내의 동작들을 표현하기 위한 일종의 데이터 구조다.
Enumerable<T> 내의 모든 메서드는 로컬 머신에서 수행된다. 람다 표현식은 메서드로 컴파일되고, 이렇게 컴파일된 메서드가 로컬 머신에서 수행되는 것은 어찌 보면 당연하다. 그런데 이로 인해 모든 데이터를 로컬 응용프로그램의 메모리로 가져와야 하며, 따라서 상대적으로 더 많은 데이터를 가져와야 한다. 이 중 필요 없는 데이터들은 나중에 제거한다.
반면, Queryble<T>는 표현식 트리를 분석한 후 분석된 로직을 제공자(Provider)에 적합한 형태로 변경한 다음 이를 데이터가 실제 위치하고 있는 컴퓨터에서 수행한다. 따라서 로컬 컴퓨터로 가져와야 할 데이터의 양이 상대적으로 적을 뿐 아니라 전체적으로 시스템의 성능도 개선된다.
일반적인 경우라면 가장 저수준의 공통 클래스나 인터페이스를 이용하는 메서드를 작성하는 것이 올바른 방법이지만 IEnumerable<T>나 IQueryable<T>의 경우는 예외적이다. 이 둘은 거의 동일한 기능을 가지고 있지만 개별 인터페이스의 구현상의 차이가 명확하기 때문에 어떤 데이터 소스를 사용하느냐에 따라 반드시 그에 부합하는 인터페이스를 사용해야 한다. 특정 데이터 소스가 IQueryable<T>를 지원하는지 아니면 IEnumerable<T>만을 지원하는지를 확인할 수 있으므로 만약 데이터 소스가 IQueryable<T>를 구현한다면 반드시 이를 사용해야 한다.
public static IEnumerable<Product> ValidProducts(this IEnumerable<Product> products)
=> from p in products
where p.ProductName.LastIndexOf('C') == 0
select p;
public static IQueryable<Product> ValidProducts(this IQueryable<Product> products)
=> from p in products
where p.ProductName.LastIndexOf('C') == 0
select p;
이 경우 코드 중복이 발생할 가능성이 높다. 이럴 때는 AsQueryable()을 사용하여 IEnumerable<T>를 IQueryable<T>로 변경하면 중복을 제거할 수 있다.
public static IEnumerable<Product> ValidProducts(this IEnumerable<Product> products)
=> from p in products.AsQueryable()
where p.ProductName.LastIndexOf('C') == 0
select p;
AsQueryable()은 시퀀스의 런타임 타입을 확인한다. 만약 시퀀스의 런타임 타입이 IQueryable이라면 IQueryable을 반환할 것이고, 반대로 IEnumerable 타입이라면 LINQ to Objects를 사용하여 IQueryable을 구현한 래퍼를 생성하여 반환한다. 이 경우 Enumerable 구현체를 얻기는 하겠지만 IQueryable 타입으로 래핑된 객체를 얻게 된다.
앞의 코드를 살펴보면 string.LastIndexOf()라는 메서드를 호출하는 부분이 있따. 이 메서드는 LINQ to SQL 라이브러리를 통해 올바르게 해석 가능한 메서드 중 하나이므로 LINQ to SQL 쿼리에서도 그대로 사용할 수 있다. 하지만 각각의 제공자들은 각기 제공하는 기능이 다르므로 모든 IQueryProvider 구현체가 이 메서드를 구현할 것이라고 가정해서는 안 된다.
IQueryable<T>와 IEnumerable<T>는 동일한 기능을 제공하는 것처럼 보인다. 하지만 이 둘은 매우 다르며 특히 쿼리 패턴을 구현하는 방법이 매우 상이하다. 따라서 데이터 원본이 어떤 인터페이스를 제공하느냐에 따라 올바르게 쿼리를 구성해야 한다.
함께 읽으면 좋은 글
출처
'프로그래밍 > Effective C#' 카테고리의 다른 글
Effective C# Item 36 : 쿼리 표현식과 메서드 호출 구문이 어떻게 대응되는지 이해하라 (1) | 2024.02.13 |
---|---|
Effective C# Item 17 : 표준 Dispose 패턴을 구현하라 (1) | 2023.11.03 |
Effective C# Item 13 : 정적 클래스 멤버를 올바르게 초기화하라 (0) | 2023.10.27 |
Effective C# Item 8 : 이벤트 호출 시에는 null 조건 연산자를 사용하라. (1) | 2023.10.26 |
Effective C# Item 16 : 생성자 내에서는 절대로 가상 함수를 호출하지 말라 (0) | 2023.10.25 |
댓글