C# 6.0 완벽 가이드/ LINQ 질의

Contents

  • LINQ (Language Intergrated Query; 언어에 통합된 질의)는 지역 객체 컬렉션과 원격 자료 저장소에 대한 형식에 안전한 구조적 질의를 작성하는데 사용하는 C# 언어 기능들과 .NET Framework 기능들을 통칭하는 용어이다. LINQ는 C# 3.0과 .NET Framework 3.5에 도입되었다.
  • LINQ를 이용하면 IEnumerable<T>를 구현하는 임의의 컬렉션(목록, 배열)과 XML DOM에 대해 질의를 수행할 수 있으며, SQL Server 데이터베이스의 테이블과 같은 원격 자료 저장소에 대한 질의도 수행할 수 있다. LINQ는 컴파일 시점 형식 점검의 장점과 동적인 질의 작성의 장점을 모두 제공한다.

첫걸음

  • LINQ의 기본적인 자료 단위는 순차열(sequence)과 요소(element)이다. 순차열은 IEnumerable<T>를 구현하는 임의의 객체이고 요소는 그 순차열에 들어있는 항목이다. 다음 예에서 names는 순차열이고, “Tom”, “Dick”, “Harray”는 요소들이다.
    • 메모리 안에 있는 객체들의 지역 컬렉션이라는 점에서 이런 순차열을 지역 순차열이라고 부른다.
string[] names = { "Tom", "Dick", "Harray" };
  • 질의 연산자(query operator)는 순차열에 어떠한 변환(transformation) 연산을 적용하는 메서드이다. 전형적인 질의 연산자는 입력 순차열 하나를 받아서 출력 순차열을 산출한다. System.Linq의 Enumerable 클래스에는 약 40개의 질의 연산자가 있는데, 이들은 모두 정적 확장 메서드로 구현되어 있다. 이들을 통틀어 표준 질의 연산자라고 부른다.
    • 지역 순차열에 대해 작용하는 질의를 지역 질의(local query) 또는 객체 대상 LINQ 질의라고 부른다.
    • LINQ는 또한 SQL Server 데이터베이스 같은 원격 자료 저장소에서 동적으로 자료를 공급받는 순차열도 지원한다. 그런 순차열은 IQueryable<T> 인터페이스를 추가로 구현하는데, 이 인터페이스에 대응되는 일단의 표준 질의 연산자들이 Queryable 클래스에 있다.
  • LINQ에서 말하는 질의는 순차열들과 질의 연산자들로 이루어진 하나의 표현식이다. 그 표현식을 평가하면 순차열들이 연산자들에 의해 변환된다.
    • 예컨대 Where 연산자를 이용하면 이름들을 담은 배열에서 길이가 4개 이상인 이름만 추출할 수 있다.
string[] names = { "Tom", "Dick", "Harray" };
IEnumerable<string> filteredNames = System.Linq.Enumerable.Where(names, n => n.Length >= 4)

foreach (int n in filteredNames)
  Console.Write(n);  // Dick Harry

  • 표준 질의 연산자들은 확장 메서드로 구현되므로 Where를 마치 인스턴스 메서드인 것처럼 names에 대해 직접 호출할 수도 있다.
string[] names = { "Tom", "Dick", "Harray" };
IEnumerable<string> filteredNames = names.Where(names, n => n.Length >= 4)

foreach (int n in filteredNames)
  Console.Write(n);  // Dick Harry
  • 대부분의 질의 연산자는 람다식을 인수로 받는다. 그 람다식은 질의 수행의 지침이 되거나 질의의 형태를 결정한다.
    • 입력 순차열의 각 요소가 람다식의 입력 인수가 된다. 위의 예에서 입력 인수 n은 문자열 배열의 각 이름으로 그 형식은 string이다. Where 연산자에 지정하는 람다식은 반드시 하나의 bool 값을 돌려주어야 한다. 그 값이 true이면 Where는 해당 요소를 출력 순차열에 포함시킨다.
  • 지금까지는 확장 메서드와 람다식을 이용해 LINQ 질의를 만들었다. 이러한 전략을 사용하면 질의 연산자들의 연쇄(chaining)가 가능하므로 질의를 작성하기가 아주 편해진다. 이 책에서는 이러한 방식을 유창한 구문(fluent syntax)라고 부른다.
    • C#은 또한 질의 표현식 구문이라고 부르는 또 다른 질의 작성 구문을 제공한다. 유창한 구문과 질의 표현식 구문은 상호 보완적이다.
string[] names = { "Tom", "Dick", "Harray" };
IEnumerable<string> filteredNames = from n in names 
                                                            where n.Contains("a")
                                                            select n;

유창한 구문

  • 두 구문 중 유창한 구문이 더 유연하고 근본적이다.

질의 연산자 연쇄

  • 앞선 예에서는 질의 연산자를 하나만 사용했지만, 간단한 질의 표현식에 질의 연산자들을 더 추가해서 일종의 ‘사슬’을 형성함으로써 좀 더 복잡한 질의를 만들 수 있다.
    • 다음 질의는 문자열 배열에서 영문자 ‘a’가 있는 모든 문자열을 추출해서 길이순으로 정렬한 후 그 결과를 대문자로 변환한다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<string> query = names.Where(n => n.Contain("a")).OrderBy(n => n.Length).Select(n => n.ToUpper());

foreach (string name in query)
  Console.Write(name);  // JAY MARY HARRY
  • 자료는 연산자들의 사슬을 따라 왼쪽에서 오른쪽으로 흐르므로, 이 예의 경우는 자료는 먼저 선별되고, 정렬되고, 투영된다.
  • 이 예제처럼 질의 연산자들을 연결한 경우, 한 연산자의 출력 순차열이 그 다음 연산자의 입력 순차열로 쓰인다. 아래 그림과 같이 전체적인 질의는 공장의 컨베이어 벨트 조립라인과 비슷한 모습이다.

확장 메서드의 중요성

  • 확장 메서드 구문을 사용하지 않고 통상적인 정적 메서드 구문을 사용해서 질의 연산자들을 호출하는 것도 가능하다. 실제로 컴파일러는 확장 메서드 호출들을 이런 형태의 코드로 바꾸어서 컴파일한다.
    • 그러나 이처럼 확장 메서드 대신 정적 메서드를 사용하면 앞에서처럼 하나의 질의를 하나의 문장으로 표현하는 것이 불가능해진다. 확장 메서드를 사용하지 않으면 질의의 유창함이 사라진다.
IEnumerable<string> query = 
  Enumerable.Select(
    Enumerable.OrderBy (
      Enumerable.Where (
        names. n => n.Contains("a")
      ), n => n.Length
   ), n => n.ToUpper()
);

람다 표현식 작성

  • 값 하나를 받고 bool을 돌려주는 람다식을 술어(predicate)라고 부른다.
  • 질의 연산자의 인수로 지정하는 람다식의 용도는 질의 연산자마다 다르다.
    • Where 연산자의 경우 람다식은 주어진 요소를 출력 순차열에 포함시킬지의 여부를 결정한다.
    • OrderBy 연산자의 람다식은 입력 순차열의 각 요소를 해당 정렬 키에 대응시킨다.
    • Select 연산자의 람다식은 입력 순차열의 각 요소를 출력 순차열에 넣기 전에 변환하는 방식을 결정한다.
  • 질의 연산자의 람다식은 항상 입력 순차열의 개별 요소에 대해 작동한다. 순차열 전체에 대해 작동하는 것이 아니다.
  • 질의 연산자는 람다식을 요구에 따라 (on demand) 적용한다.
    • 보통의 경우 람다식은 입력 순차열의 요소당 한 번씩 실행된다. 람다식 덕분에 소비자는 질의 연산자에 자신만의 논리를 집어넣을 수 있다.
    • 결과적으로 질의 연산자가 다재다능해지며, 내부 구조도 단순해진다.
    • 다음은 Enumerable.Where의 전체 구현이다 (예외처리 생략)
public static IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
  foreach (TSource element in source)
    if (predicate (element))
      yield return element;
}

람다 표현식과 Func 대리자

  • 표준 질의 연산자는 제네릭 Func 대리자를 활용한다. Func는 System 이름공간에 있는 일단의 범용 제네릭 대리자들을 통칭하는 이름이다. 이들은 다음과 같은 의도로 정의 되었다.
    • Func의 형식 인수들은 람다식의 것들과 같은 순서로 나타난다.
  • 즉, Func<TSource, bool>은 TSource => bool 형식의 람다식, 즉 TSource 인수 하나를 받고 bool을 돌려주는 람다식과 부합한다.
    • 마찬가지로 Func<TSource, TResult>는 TSource => TResult 람다식과 부합한다.

람다 표현식과 요소 형식 결정

  • 표준 질의 연산자는 다음과 같은 형식 매개변수 이름들을 사용한다.
제네릭 형식 매개변수 의미
TSource 입력 순차열의 요소 형식
TResult 출력 순차열의 요소 형식
TKey 정렬, 분류, 결합에 쓰이는 키 요소의 형식

 

  • 컴파일러는 TSource를 입력 순차열에 따라 결정하고, TResult와 TKey는 일반적으로 람다식에서 추론한다.
  • 예컨대 Select 질의 연산자의 서명을 생각해 보자
public static IEnumerable<TResult> Select<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource, TResult> selector)
  • Funct<TSource, TResult>는 TSource => TResult 람다식, 즉 입력 요소를 출력 요소로 대응하는 람다식과 부합한다.
    • 그런데 TSource와 TResult가 서로 다른 형식일 수 있다. 이는 람다식에서 입력 요소의 형식과는 다른 형식의 요소를 산출할 수 있으며, 결과적으로 람다식이 출력 순차열 자체의 형식을 결정한다는 뜻이다.
    • 예컨대 다음 질의는 Select 연산자를 이용해서 문자열 요소들의 순차열을 정수 요소들의 순차열로 변환한다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<int> query = names.Select(n => n.Length);

foreach (int length in query)
  Console.Write(length + "|");  // 3 | 4 | 5 | 4 | 3 |
  • 컴파일러는 람다식의 반환 형식으로부터 TResult의 형식을 추론(inference) 할 수 있다. 위의 예에서 n.Length는 int 값을 돌려주므로 TResult는 int 형식으로 추론된다.
  • Func<TSource, TKey>는 주어진 입력 요소를 하나의 정렬 키(sorting key)로 사상한다. 컴파일러는 TKey를 람다식으로부터 추론한다.
    • 이 TKey는 입력 요소 형식이나 출력 요소 형식과는 다른 형식일 수 있다.
    • 예컨대 이름들의 목록을 길이 순으로(int 키) 정렬할 수도 있고 알파벳순으로(string 키) 정렬할 수도 있다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<string> sortedByLength, sortedAlphabetically;
sortedByLength = names.OrderBy(n => n.Length);
sortedAlphabetically = names.OrderBy(n => n);

자연스러운 순서

  • LINQ에서는 입력 순차열에 있는 요소들의 원래 순서가 중요하다. Take나 Skip, Reverse 같은 일부 질의 연산자들의 작동은 이 순서에 의존한다.
  • Take 연산자는 처음 x개의 요소를 출력하고 나머지는 무시한다.
int[] numbers = { 10, 9, 8, 7, 6 };
IEnumerable<int> firstThree = numbers.Take(3);  // { 10, 9, 8 }
  • Skip 연산자는 처음 x개의 요소를 무시하고 나머지를 출력한다.
int[] numbers = { 10, 9, 8, 7, 6 };
IEnumerable<int> lastTwo = numbers.Skip(3);  // { 7, 6 }
  • Reverse는 요소들의 순서를 뒤집는다.
int[] numbers = { 10, 9, 8, 7, 6 };
IEnumerable<int> reversed = numbers.Reverse;  // { 6, 7, 8, 9, 10 }
  • 지역 질의에서 Where나 Select 같은 연산자들은 입력 순차열의 원래 순서를 유지한다.
    • 사실 순서를 변경하는 것이 목표인 연산자를 제외한 모든 질의 연산자가 그런 성질을 갖고 있다.

기타 연산자

  • 순차열을 출력하지 않는 질의 연산자도 있다. 요소 연산자라고 부르는 질의 연산자들은 입력 순차열에서 요소를 딱 하나만 추출한다. First, Last, ElementAt이 그런 연산자에 속한다.
int[] numbers = { 10, 9, 8, 7, 6 };
int firstNumber = numbers.First();  // 10
int lastNumber = numbers.Last();  // 6
int secondNumber = numbers.ElementAt(1);  // 9
int secondLowest = numbers.OrderBy(n => n).Skip(1).First();  // 7
  • 집계(aggregation) 연산자는 스칼라값을 돌려준다.
int[] numbers = { 10, 9, 8, 7, 6 };
int count = numbers.Count();  // 5
int min = numbers.Min();  // 6
  • 한정사(quantifier) 연산자는 bool 값을 돌려준다.
int[] numbers = { 10, 9, 8, 7, 6 };
bool hasTheNumberNine = numbers.Contains(9);  // true
bool hasMoreThanZeroElements = numbers.Any();  // true
bool hasAnOddElement = numbers.Any(n => n % 2 != 0);  // true
  • 입력 순차열을 두 개 받는 질의 연산자도 있다.
int[] seq1 = { 1, 2, 3 };
int[] seq2 = { 3, 4, 5 };
IEnumerable<int> concat = seq1.Concat(seq2);  // { 1, 2, 3, 3, 4, 5 } 
IEnumerable<int> union = seq1.Union(seq2);  // { 1, 2, 3, 4, 5 }

질의 표현식

  • C#은 LINQ 질의를 좀 더 간결하게 표기할 수 있는 단축 구문을 지원하는데, 이를 질의 표현식(query expression) 구문이라고 부른다.
    • 흔한 오해와 달리 질의 표현식은 SQL 질의문을 C# 코드에 내장하는 수단이 아니다. 사실 질의 표현식은 기본적으로 LISP이나 Haskell 같은 함수형 프로그래밍 언어의 목록 함축(list comprehension)에서 영감을 얻은 것이다. 단, 구문의 겉모습에 SQL이 영향을 미치긴 했다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<string> query = 
  from n in names
  where n.Contains("a")
  orderby n.Length
  select n.ToUpper();

foreach (string name in query)
  Console.WriteLine(name);  // JAY MARY HARRY
  • 질의 표현식은 항상 from 절로 시작하고 select 절이나 group 절로 끝난다.
  • from 절은 입력 순차열의 모든 요소를 차례로 훑는 범위 변수(range variable; 위 예에서는 n)를 선언한다.
    • foreach의 범위 변수를 연상하면 이해하기 쉬울 것이다.
  • 아래 그림은 완전한 질의 구문을 철도 선로 도식(railroad diagram) 형태로 표현한 것이다.
    • 이 도식을 읽는 방법은 간단하다. 왼쪽 중간의 검은 사각형에서 시작해서 마치 자신이 기차인 것처럼 선로를 따라가면 된다.
    • 예컨대 반드시 있어야 하는 from 절을 지난 후에는 orderby나 where, let, join 절 중 하나로 나아간다.
    • 그 후에는 select나 group 절 중 하나로 가거나 아니면 다시 from이나 orderby, where, let, join 절로 돌아간다.

  • 컴파일러는 질의 표현식을 유창한 구문의 코드로 바꾸어서 컴파일한다.
    • 컴파일러는 foreach를 GetEnumerator 호출과 MoveNext 호출로 바꿀 때처럼 상당히 기계적으로 이러한 코드 변환을 수행한다.
    • 이는 질의 구문으로 작성할 수 있는 모든 것을 유창한 구문으로도 작성할 수 있다는 뜻이기도 하다.

범위 변수

  • from 키워드 바로 다음에 오는 식별자를 범위 변수라고 부른다. 범위 변수는 연산자들을 적용할 순차열의 현재 요소를 참조한다.
    • 아래 예에서 질의의 모든 절에 범위 변수 n이 나온다. 그렇지만 각 n들은 각 절에서 서로 다른 순차열을 훑게 된다.
    • 컴파일러가 이 코드를 유창한 구문으로 바꾼 결과를 보면 이 점이 명확해진다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<string> query = 
  from n in names
  where n.Contains("a")
  orderby n.Length
  select n.ToUpper();
  • 이 예에서 보듯이 각각의 n은 해당 람다식 안에서만 존재하는 지역 변수이다. 다음의 절들을 이용해서 질의 표현식에 새로운 범위 변수를 도입하는 것도 가능하다.
    • let
    • into
    • 또 다른 from 절
    • join

질의 구문 대 SQL 구문

  • 겉으로 보기에 질의 표현식은 SQL과 비슷하지만, 사실 둘은 많이 다르다. LINQ 질의는 하나의 C# 표현식으로 바뀌어서 컴파일되므로 표준적인 C# 규칙들을 따른다.
    • 예컨대 LINQ에서는 변수를 사용하는 코드가 그 변수를 선언하는 코드보다 뒤에 나와야 한다. 그러나 SQL에서는 FROM 절에서 정의하는 테이블 별칭을 FROM 절보다 앞에 있는 SELECT 절에서 사용할 수 있다.
  • LINQ의 부분 질의(subquery)는 또 다른 C# 표현식일 뿐이므로 특벼랗나 구문이 필요하지 않다. 그러나 SQL의 부분 질의에는 특별한 규칙이 적용된다.
  • LINQ에서는 질의 안에서 자료가 논리적으로 좌에서 우로 흐른다. SQL에서는 자료 흐름과 관련해서 그 순서가 덜 구조적이다.
  • LINQ 질의는 일련의 연산자들이 순차열을 입, 출력하는 하나의 컨베이어 벨트 또는 파이프라인을 형성하며, 순차열 안 요소들의 순서가 연산 결과에 영향을 미친다.
    • SQL 질의는 절들의 네트워크를 형성하며, 그 절들은 주로 순서 없는 자료 집합(unordered data set)을 다룬다.

질의 구문대 유창한 구문

  • 질의 구문과 유창한 구문에는 각자 나름의 장단점이 있다. 다음 두 조건 중 하나라도 해당하는 질의라면 질의 구문이 더 간단하다.
    • 범위 변수 이외의 변수를 let 절로 도입하는 질의
    • SelectMany나 Join, GroupJoin 다음에 외부 범위 변수 참조가 오는 질의
  • Where나 OrderBy, Select를 간단하게 사용하는 질의라면 어떤 구문이든 비슷하다. 선택은 개인 취향 문제이다.
  • 질의 연산자가 하나만 있는 질의라면 유창한 구문이 더 짧고 군더더기가 적다.
  • 마지막으로 질의 구문에는 해당하는 키워드가 없는 질의 연산자들이 많이 있다. 구체적으로 다음 질의 연산자들 이외의 질의 연산자들은 질의 구문의 키워드가 존재하지 않는다. 그런 연산자들을 사용하려면 적어도 부분적으로라도 유창한 구문을 사용해야 한다.
    • Where, Select, SelectMany, OrderBy, ThenBy, OrderByDescending, ThenByDescending, GroupBy, Join, GroupJoin

혼합 구문 질의

  • 질의 구문이 지원하지 않는 질의 연산자를 사용해야 하는 경우 질의 구문과 유창한 구문을 섞어 쓰는 것도 한 방법이다.
    • 이때 유일한 제약은 각각의 질의 구문 구성요소가 완결적이어야 한다는 것이다. (즉, 하나의 from 절로 시작해서 select나 group절로 끝나야 한다)
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };

// 영문자 'a'가 있는 이름들의 개수를 세는 혼합 구문 질의
int matches = (from n in names where n.Contains("a") select n).Count();  // 3

// 이름들을 알파벳순으로 정렬했을 때의 첫 번째 이름 추출
string first = (from n in names orderby n select n).First();  // Dick

지연된 실행

  • 대부분의 질의 연산자들의 한 가지 중요한 특징은 질의 연산자는 질의를 구축(생성)할 때가 아니라 열거할 때 (다시 말해 해당 열거자에 의해 MoveNext가 호출 될 때) 실행된다는 점이다.
var numbers = new List<int>();
numbers.Add(1);

IEnumerable<int> query = numbers.Select(n => n * 10);  // 질의를 구축한다.

numbers.Add(2);

foreach (int n in query)
  Console.Write (n + "|");  // 10|20|
  • 결과를 보면 질의를 구축한 다음에 목록에 집어 넣은 요소가 포함되어 있다. 이는 foreach 문이 실행될 때 비로소 질의 연산자가 실행되었음을 보여주는 증거이다.
    • 이런 방식을 지연된(deferred) 실행 또는 게으른(lazy) 실행이라고 부른다. 대리자에도 이런 지연된 실행이 적용된다.
Action a = () => Console.WriteLine("Foo");
// 콘솔에는 아직 아무것도 출력되지 않았다. 이제 대리자를 실제로 실행하자.
a();  // 지연된 실행!
  • 모든 표준 질의 연산자는 이런 실행 지연 기능을 제공한다. 단, 다음은 예외이다.
    • 하나의 요소나 스칼라값을 돌려주는 집계 연산자(First나 Count 등)
    • 다음과 같은 형식 변환 연산자(conversion operator)들
      • ToArray, ToList, ToDictionary, ToLookup
  • 이런 연산자들이 포함된 질의는 구축 즉시 실행된다. 이런 연산자의 결과 형식에는 실행 지연 기능을 제공하는 메커니즘이 없기 때문이다.
    • 예컨대 Count 메서드는 그냥 정수 하나를 돌려줄 뿐이며, 그 정수에 대해 또 다른 열거가 적용되지 않는다. 다음 질의는 즉시 실행된다.
int matches = numbers.Where(n => n < 2).Count();  // 1
  • 지연된 실행은 질의의 구축과 실행을 분리한다는 점에서 중요하다. 이러한 분리 덕분에 하나의 질의를 여러 단계로 구축할 수 있다. 또한 데이터베이스 질의가 가능한 것도 이러한 분리 덕분이다.
  • 부분 질의는 또 다른 수준의 간접층을 제공한다. 부분 질의의 모든 것에는 지연된 실행이 적용된다. 심지어 집계 연산자나 형식 변환 연산자도 지연 실행된다.

재평가

  • 지연된 실행의 또 다른 효과는 지연 실행 질의를 다시 열거하면 질의가 다시 평가 된다는 점이다.
    • 다음은 이 점을 보여주는 예이다.
var numbers = new List<int>() { 1, 2 };

IEnumerable<int> query = numbers.Select(n => n * 10);
foreach (int n in query) Console.Write(n + "|");  // 10|20|

numbers.Clear();
foreach (int n in query) Console.Write(n + "|");  // 출력 없음
  • 그런데 때에 따라서는 이런 재평가가 단점이 되기도 한다. 예를 들면 다음과 같다.
    • 시간상의 특정 시점에서의 실행 결과를 ‘동결’ 또는 보관하고 싶을 떄가 있다.
    • 계산량이 많은 (또는 원격 데이터베이스 질의에 의존하는) 질의를 쓸데없이 다시 수행하고 싶지는 않을 수 있다.
  • 재평가를 피하는 한 가지 방법은 질의 끝에서 ToArray나 ToList 같은 변환 연산자를 호출하는 것이다.
var numbers = new List<int>() { 1, 2 };

List<int> query = numbers.Select(n => n * 10).ToList();  // 질의를 즉시 실행해서 결과를 LIst<int>에 복사한다.
numbers.Clear();
Console.WriteLine(query.Count);  // 여전히 2

갈무리된 변수

  • 질의의 람다식이 외부 변수를 갈무리(capture)하는 경우, 질의 구축시 그 외부 변수의 값이 질의에 고정되지는 않는다. 외부 변수는 질의를 실행할 때 비로소 평가된다.
int[] numbers = { 1, 2 };

int factor = 10;
IEnumerable<int> query = numbers.Select(n => n * factor);
factore = 20;

foreach (int n in query)  Console.Write(n + "|");  // 20|40|
  • 그런데 for 루프 안에서 질의를 구축하다 보면 이 점이 문제가 될 수 있다. 예컨대 문자열에서 모든 영문자 모음을 제거한다고 하자.
IEnumerable<char> query = "Not what you might expect";

query = query.Where(c => c != 'a');
query = query.Where(c => c != 'e');
query = query.Where(c => c != 'i');
query = query.Where(c => c != 'o');
query = query.Where(c => c != 'u');

foreach (char c in query) Console.Write(c);  // Nt wht y mght xpct
  • 그러나 이를 for 루프를 이용해서 리팩터링하면 기대와 다른 결과가 나온다.
IEnumerable<char> query = "Not what you might expect";
string vowels = "aeiou"

for (int i = 0; i < vowels.Length; i++)
  query = query.Where(c => c != vowels[i]);

foreach (char c in query)  Console.Write(c);
  • 이 코드를 실행하면 질의 열거시 IndexOutOfRangeException 예외가 발생한다.
    • 그 이유는 컴파일러는 for 루프의 반복 변수를 루프 바깥 범위에서 선언된 것처럼 취급하기 때문이다.
    • 이 때문에 루프의 각 반복에서 닫힘(람다식)이 갈무리하는 변수 i는 모두 동일한 변수이며, 이후 질의를 실행(열거)하는 시점에서 이 변수의 값은 5이다.
  • 이 문제를 해결하려면 루프 변수를 반복문 내부에서 선언된 또 다른 변수에 배정해야 한다.
for (int i = 0; i < vowels.Length; i++)
{
  char vowel = vowels[i];
  query = query.Where(c => c != vowel);
}
  • 이러면 루프 반복마다 새로운 지역 변수가 람다식에 갈무리 된다.
  • C# 5.0부터는 이 문제의 또 다른 해결책이 생겼다. for 루프 대신 foreach 루프를 사용하는 것이다.

지연된 실행의 작동 방식

  • 질의 연산자는 장식자(decorator) 순차열을 돌려줌으로써 실행을 지연한다.
  • 배열이나 연결 목록 같은 전통적인 컬렉션 클래스와는 달리 장식자 순차열은 요소들을 저장할 내부 저장소를 따로 마련하지 않는다.
    • 대신 실행 시점에서 지정된 다른 순차열을 영구적으로 참조하면서 그 순차열에 대한 래퍼 역할만 수행한다. 장식자 순차열의 어떤 요소를 조회하면 장식자 순차열은 자신이 감싸고 있는 내부 순차열의 자료를 적절히 꾸며서 돌려준다.
    • 질의 연산자가 수행하는 변환은 ‘장식(decoration)’에 해당한다. 단, 요소들을 변환하지 않고 출력하는 경우는 장식자가 아니라 프록시라고 불러야 마땅하다.
  • 질의 연산자 Where는 입력 순차열을 감싸는 장식자 순차열을 돌려준다.
    • 그 장식자는 입력 순차열에 대한 참조와 람다식, 그리고 기타 인수들을 간직하고 있다.
    • 입력 순차열은 장식자가 열거될 때만 열거된다.
  • 아래 그림은 다음 질의를 도식화 한 것이다.
IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where(n => n < 10);

  • 이후 이 lessThanTen을 열거하면, 결과적으로는 Where의 장식자(주어진 정수 배열에 대해 Where가 돌려준 장식자 순차열)를 열거하게 된다.
  • 만일 독자가 질의 연산자를 직접 작성해야 한다면, 한 가지 좋은 소식이 있다. 바로 C# 반복자를 이용하면 장식자 순차열을 손쉽게 구현할 수 있다는 점이다.
    • 다음은 Select 메서드를 직접 작성한 것이다.
public static IEnumerable<TResult> Select<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
  foreach (TSource element in source)
    yield return selector(element);
}
  • yield return 문이 있으므로 이 메서드는 하나의 반복자이다. 개념적으로 이 메서드는 다음과 같은 메서드를 간결하게 표현한 것이다.
public static IEnumerable<TResult> Select<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
  return new SelectSequence(source, selector);
}
  • 여기서 SelectSequence는 반복자 메서드의 논리를 캡슐화한 열거자를 가진 순차열 클래스로, 컴파일 도중에 컴파일러가 작성한다.
  • 정리하자면 Select나 Where 같은 연산자를 호출하는 것은 그냥 입력 순차열을 장식하는 열거 가능 클래스의 인스턴스를 생성하는 것일 뿐이다.

장식자 연쇄

  • 질의 연산자들을 사슬처럼 연결하면 장식자들이 중첩된다. 다음 질의를 생각해 보자.
IEnumerable<int> query = new int[] { 5, 12, 3 }.Where(n => n < 10).OrderBy(n => n).Select(n => n * 10);
  • 각 질의 연산자는 이전 순차열을 감싸는 새로운 장식자 인스턴스를 생성한다. (겹겹이 겹쳐지는 러시아 인형을 생각하면 도움이 될 것이다) 이 질의의 객체 모형이 아래 그림에 나와 있다. 이 객체 모형은 열거를 수행하기 전에도 완전히 구축된다는 점을 주의하기 바란다.
    • 이 질의 끝에 ToList를 추가하면 이전의 연산자들이 즉시 실행되며 결과적으로 전체 객체 모형이 하나의 목록으로 축약된다.

  • 아래 그림은 같은 객체 구성을 UML 문법으로 표현한 것이다.
    • Select의 장식자는 OrderBy의 장식자를 참조하고, 그 장식자는 Where의 장식자를 참조하고, 그 장식자는 배열을 참조한다.
    • 지연된 실행 기능 덕분에, 다음과 같이 질의를 점진적으로 구축해도 이와 동일한 객체 모형을 얻게 된다.
IEnumerable<int> 
  source = new int[] { 5, 12, 3 },
  filtered = source.Where(n => n < 10),
  sorted = filtered.OrderBy(n => n),
  query = sorted.Select(n => n * 10);

질의가 실행되는 방식

  • 앞의 질의를 열거해 보자
foreach(int n in query) Console.WriteLine(n);  // 30 50
  • 내부적으로 foreach는 Select의 장식자에 대해 GenEnumerator를 호출한다. 그러면 실질적인 질의 실행 절차가 시작된다. 그 결과는 장식자 순차열들의 중첩 구조 또는 연쇄 구조를 반영한 열거자들의 사슬이다.
    • 아래 그림은 열거가 진행되는 동안의 실행의 흐름이 나와 있다.

  • 이번 장의 첫 절에서 질의를 컨베이어 벨트 조립라인에 비유했다. 그 비유를 연장해서 LINQ 질의는 게으른 조립라인, 즉 요청이 있을 때에만 (on demand) 돌아가는 컨베이어 벨트 조립라인이라 할 수 있다.
    • 질의를 구축하면 도느 설비가 갖추어진 조립라인이 만들어지지만, 벨트는 아직 돌아가지 않는다.
    • 소비자가 한 요소를 요청하면 (질의를 열거함으로써) 가장 오른쪽 컨베이어 벨트가 돌아가기 시작하며, 그러면 나머지 벨트들도 차례로 돌아가게 된다.
    • 더 이상 요청이 없으면 모든 벨트가 멈춘다.
  • LINQ는 공급이 주도하는 밀어 넣기 모형이 아니라 수요(요구)에 기초한 끌어오기 모형(pull model)을 따른다.
    • 이 점이 중요하다. LINQ를 SQL 데이터베이스 질의로까지 확장할 수 있는 것은 바로 이러한 모형 덕분이기 때문이다.

부분 질의

  • 다른 질의의 람다식 안에 포함된 질의를 부분 질의(subquery)라고 부른다.
    • 다음은 부분 질의를 이용해서 음악가들을 성(last name) 기준으로 정렬하는 예이다.
string[] musos = { "David Gilmour", "Roger Waters", "Rick Wright", "Nick Mason" };
IEnumerable<string> query = musos.OrderBy(m => m.Split().Last());
  • m.Split은 각 문자열을 단어들의 컬렉션으로 변환한다. 그에 대해 Last 질의 연산자를 호출해서 컬렉션의 마지막 단어를 얻는다. 람다식에 이는 m.Split().Last가 바로 부분 질의이다.
    • 부분 질의 관점에서 query는 외부 질의(outer query)이다.
  • 이러한 부분 질의에 어떤 특별한 메커니즘이 작용하는 것은 아니다. 람다식의 우변에는 유효한 C# 표현식이라면 그 어떤 표현식도 올 수 있는데, 부분 질의는 그냥 또 다른 C# 표현식일 뿐이다.
    • 다른 말로 하면, 부분 질의에 적용되는 규칙들은 그냥 람다 표현식에 적용되는 규칙들에서 비롯된 것일 뿐이다.
    • LINQ 이외의 분야에서 부분 질의는 좀 더 광범위한 의미를 가지고 있다. 이 책의 LINQ 관련 문맥에서는 오직 다른 질의의 람다식 안에 있는 질의만 부분 질의라고 부른다.
  • 부분 질의에는 그것이 포함된 람다식의 범위가 적용되며, 따라서 그 람다식의 매개변수들을(또한 질의 표현식의 범위 변수들도) 참조할 수 있다.
    • 다음 질의는 문자열 배열 중 가장 짧은 문자열과 같은 길이의 문자열들을 추출한다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };
IEnumerable<string> outerQuery = names.Where(n => n.Length == names.OrderBy(n2 => n2.Length).Select(n2 => n2.Length).First());  // { "Tom", "Jay" }
  • 외곽 범위 변수 n이 부분 질의의 범위 안에 있으므로, n을 부분 질의 자체의 범위 변수로 다시 사용할 수는 없다.
  • 부분 질의는 그것을 포함하는 람다식이 평가될 때마다 실행된다.
    • 이는 부분 질의가 그 외부 질의의 재량에 따라 ‘요구 기반’으로 실행됨을 뜻한다. 실행의 흐름이 바깥에서 안으로 진행된다고 말해도 좋을 것이다.
    • 지역 질의는 이러한 모형을 문자 그대로 따른다.
    • 반면 나중에 설명하는 해석식 질의(데이터베이스 질의 등)는 이 모형을 개념적으로만 따른다.
  • 부분 질의는 외부 질의가 자료를 요구할 때 비로소 실행된다. 지금 예의 부분 질의는 외부 루프가 반복될 때마다 한 번씩 실행된다.
    • 아래 그림에 이 점이 나와 있다.

  • 해석식 질의에서는 SQL 데이터베이스 테이블 같은 원격 자료 공급원에 대한 질의를 수행하는 방법을 설명한다.
    • 하나의 단위로 처리된다는 점과 데이터베이스 서버와의 왕복 통신 (round trip) 횟수가 단 1회라는 점에서, 지금 예의 질의는 데이터베이스 질의에 이상적이다.
    • 그러나 지역 컬렉션에는 이 질의가 비효율적이다. 외부 루프의 반복마다 부준 질의를 다시 계산해야 하기 때문이다.
    • 다음처럼 부분 질의를 따로 실행하면(따라서 더 이상 부분 질의가 아니게 만들면) 이러한 비효율성을 제거할 수 있다.
int shortest = names.Min(n => n.Length);
IEnumerable<string> query = from n in names
                                               where n.Length == shortest
                                               select n;
  • 지역 컬렉션을 질의할 떄는 이처럼 부분 질의를 밖으로 빼내는 것이 거의 항상 바람직하다.
    • 단 부분 질의와 외부 질의 사이에 상관관계(correlation)가 존재하는 경우, 쉽게 말해 부분 질의가 외부 범위 변수를 참조하는 경우는 예외이다.

부분 질의와 지연된 실행

  • 부분 질의에 요소 연산자(First 등)나 집계 연산자(Count 등)가 있어도 외부 질의가 즉시 실행되지는 않는다.
    • 외부 질의에는 여전히 지연된 실행이 적용된다. 이는 부분 질의가 간접적으로 호출되기 때문이다.
    • 지역 질의의 부분 질의는 대리자를 통해 호출되고 해석식 질의의 부준 질의는 표현식 트리를 통해서 호출된다.
  • 그런데 Select 표현식 안에 부분 질의를 포함하면 흥미로운 상황이 벌어진다.
    • 지역 질의의 경우 이는 질의들의 순차열을 투영하는 결과가 되어서 각각의 질의가 지연 실행된다.
      • 대체로 이 효과는 투명하며, 효율성을 좀 더 개선하는 역할을 한다.

질의 작성 전략

  • 이번 절에서는 좀 더 복잡한 질의를 구축하는 전략 세 가지를 설명한다.
    • 점진적인 질의 구축
    • into 키워드 활용
    • 질의 감싸기
  • 이들은 모두 연쇄(chaining) 전략이며, 실행시점에서 실제로 실행되는 질의는 모두 같다.

점진적인 질의 구축

  • 유창한 구문을 이용해서 질의를 점진적으로 구축하는 예
var filtered = source.Where(n => n.Contains("a"));
var sorted = filtered.OrderBy(n => n);
var query = sorted.Select(n => n.ToUpper());
  • 이 질의에 관여하는 모든 질의 연산자는 장식자 순차열을 돌려주므로, 최종적인 질의는 이를 그냥 하나의 표현식으로 작성했을 때와 동일한 장식자 사슬(또는 중첩)이다. 그러나 이처럼 질의를 단계적으로 구축하는데는 다음과 같은 잠재적인 장점 2가지가 있다.
    • 질의를 작성하기가 좀 더 쉬워진다.
    • 조건에 따라 질의 연산자를 선택적으로 추가할 수 있다. 예컨대 다음과 같다.
if (includeFilter) query = query.Where(...)
  • 이 방식에서는 includeFilter가 거짓일 때 추가적인 질의 연산자가 포함되지 않으므로, 다음과 같이 만든 질의보다 더 효율적이다.
query = query.Where(n => !includeFilter || <표현식>)
  • 점진적 접근방식은 질의 함축(query comprehension)에 유용한 경우가 많다.
    • 한 예로, 이름들의 목록에서 영문자 모음을 모두 제거한 후 길이가 세 글자 이상인 요소들을 알파벳 순으로 나열하는 질의를 작성한다고 하자.
    • 다음은 유창한 구문을 이용해서 그러한 질의를 표현식 하나로 작성한 것인데, 요소들을 Where 연산자로 선별하기 전에 Select 연산자로 투영한다는 점을 주목하기 바란다.
IEnumerable<string> query = names
  .Select(n => n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", ""))
  .Where(n => n.Length > 2)
  .OrderBy(n => n);

// { "Dck", "Hrry", "Mry" }
  • 모음들을 제거하기 위해 string의 Replace 메서드를 다섯 번 호출하는 대신 다음과 같은 정규 표션식을 사용하면 코드의 효율성이 좋아진다.
    • 단 string의 Replace 메서드에는 데이터베이스 질의에도 사용할 수 있다는 장점이 있다.
n => Regex.Replace(n, "[aeiou]", "")
  • 이 질의를 질의 표현식 구문으로 다시 쓴다면 select 절이 반드시 where 절과 orderby 절 뒤에 와야 하는데, 그렇게 하기가 쉽지 않다.
    • 만일 다음처럼 투영을 더 나중에 실행하도록 순서를 바꾼다면 이전과는 다른 결과가 나온다.
IEnumerable<string> query = 
  from n in names
  where n.Length > 2
  orderby n
  select n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "");

// { "Dck", "Hrry", "Jy", "Mry", "Tm" }
  • 다행히 질의 표현식 구문으로도 원래의 결과를 얻는 방법이 몇 가지 있다. 그중 하나가 다음처럼 질의를 점진적으로 구축하는 것이다.
IEnumerable<string> query = 
  from n in names
  select n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "");

query = from n in query where n.Length > 2 orderby n select n;

// { "Dck", "Hrry", "Mry" }

into 키워드 활용

  • into 키워드를 이용하면 투영 이후에도 질의를 계속 진행할 수 있다. 이를 질의 연속 (query continuation)이라고 부른다. 이런 능력 덕분에 이 키워드는 점진적 질의 구축 코드를 좀 더 간결하게 표기하는 수단이라 할 수 있다.
    • 다음은 앞의 질의를 into를 이용해서 다시 작성한 것이다.
IEnumerable<string> query = 
  from n in names
  select n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "");
  into noVowel
    where noVowel.Length > 2 orderby noVowel select noVowel;
  • into는 select 절이나 group 절 다음에만 올 수 있다. into는 질의를 ‘재시작’한다. 즉 into 다음에 새로운 where 절이나 orderby 절, select 절을 도입할 수 있다.

범위 규칙

  • 모든 범위 변수는 into 키워드까지만 유효하다. 예컨대 다음은 컴파일 되지 않는다.
var query = 
  from n1 in names
  select n1.ToUpper()
  into n2
    where n1.Contains("x")  // 여기서부터는 n2만 볼 수 있다.
    select n2;  // 위법: n1은 현재 범위에 없음
  • 이에 대응되는 유창한 구문 질의를 살펴보면 이런 범위 규칙이 이해가 될 것이다.
var query = names
  .Select(n1 => n1.ToUpper())
  .Where(n2 => n1.Contains("x"));  // 오류: n1은 현재 범위에 없음
  • 원래의 이름(n1)은 Where 필터가 시작되면서 사라진다. Where의 입력 순차열에는 대문자 이름들만 들어 있으므로, n1에 근거해서 요소들을 선별할 수는 없다.

질의 감싸기

  • 점진적으로 구축한 질의를, 한 잘의를 다른 질의로 감싸서(wrapping) 하나의 문장으로 만들 수 있다.
// 다음과 같은 형태의 질의를
var tempQuery = tempQueryExpr
var finalQuery = from ... in tempQuery ...

// 다음과 같이 통합할 수 있다.
var finalQuery = from ... in (tempQueryExpr)
  • 이러한 감싸기의 의미 자체는 점진적으로 구축한 질의나 into 키워드를 사용하는 질의(단, 임시 변수가 없는)와 동일하다.
    • 모든 경우에서 최종 겨로가는 그냥 질의 연산자들이 선형으로 이어진 사슬이다.
    • 예컨대 다음과 같은 질의를 생각해 보자.
IEnumerable<string> query = 
  from n in names
  select n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "");

query = from n in query where n.Length > 2 orderby n select n;
  • 이를 감싼 형태로 다시 표기하면 다음과 같다.
IEnumerable<string> query = 
  from n1 in
  (
    from n2 in names
    select n2.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  )
  where n1.Length > 2 orderby n1 select n1;
  • 이를 유창한 구문으로 변환하면 이전 예와 동일한 선형 질의 연산자 사슬이 된다.
IEnumerable<string> query = names
  .Select (n => n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  .Where (n => n.Length > 2)
  .OrderBy (n => n);

// 컴파일러가 무의미한 마지막 .Select(n=>n)을 생략했다는 점도 주목하기 바란다.
  • 이런 감싼 질의가 이전에 작성한 부분 질의와 모습이 비슷해서 혼동이 올 수도 있다. 둘 다 내부 질의와 외부 질의라는 개념이 있다는 점도 비슷하다. 그러나 유창한 구문으로 변환한 결과를 보면 둘의 차이가 드러난다.
    • 유창한 구문을 보면 감싸기는 그냥 연산자들을 차례로 잇는 한 전략일 뿐임을 알 수 있다. 반면 부분 질의를 변환한 결과는 내부 질의가 외부 질의의 람다식 안에 들어가 있는 형태이다.
    • 이전에 사용한 조립라인의 비유를 적용하자면, 질의 감싸기에서 ‘내부’ 질의는 이전 컨베이어 벨트에 해당한다. 그에 비해 부분 질의는 컨베이어 벨트를 타고 흘러가다가 컨베이어 벨트의 ‘람다’ 직공이 필요에 따라 화렁화 하는 것이라 할 수 있다.

투영 전략

객체 초기치

  • 지금까지 나온 모든 예제에서는 select 절이 결과를 스칼라 원소 형식에 투영했다. C#의 객체 초기치 구문을 이용하면 결과를 좀 더 복잡한 형식에 투영할 수 있다.
    • 예컨대 이름들의 목록에 대한 어떤 질의의 첫 단계에서 이름들의 영문자 모음(vowel)을 모두 제거하되, 이후의 질의들을 위해 원래의 이름들도 유지하고 싶다고 하자. 우선 다음과 같은 보조 클래스를 하나 작성한다.
class TempProjectionItem
{
  public string Original;  // 원래 이름
  public string Vowelless;  // 모음을 제거한 이름
}
  • 이제 객체 초기치를 이용해서 질의를 이 클래스에 투영한다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };

IEnumerable<TempProjectionItem> temp =
  from n in names
  select new TempProjectionItem
  {
    Original = n,
    Vowelless = n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  };
  • 결과는 IEnumerable<TempProjectionItem> 형식의 순차열이다. 이에 대해 다음과 같이 추가적인 질의를 수행할 수 있다.
IEnumerable<string> query = 
  from item in temp
  where item.Vowelless.Length > 2
  select item.Original;

// 결과는 { "Dick", "Harry", "Mary" }

익명 형식

  • 익명 형식 구문을 이용하면 앞에서처럼 특별한 클래스를 작성하지 않고도 중간 결과를 구조화할 수 있다. 다음은 TempProjectionItem 클래스를 없애고 익명 형식을 이용해서 질의를 다시 구축한 예이다.
var intermediate =
  from n in names
  select new
  {
    Original = n,
    Vowelless = n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  };

IEnumerable<string> query = 
  from item in intermediate
  where item.Vowelless.Length > 2
  select item.Original;
  • 이렇게 하면 일회용 클래스를 작성하지 않고도 앞에서와 같은 결과를 얻게 된다. 일회용 클래스는 컴파일러가 작성해 준다. 컴파일러는 투영의 구조에 부합하는 필드들을 가진 임시 클래스를 생성한다.
    • 앞의 방식과의 차이라면 그 클래스의 이름을 독자가 알 수 없다는 것이다. 즉, intermediate 질의의 형식은 다음과 같은 형태이다.
IEnumerable<컴파일러가-생성한-임의의-이름>
  • 이 형식의 변수를 선언하는 유일한 방법은 var 키워드를 사용하는 것이다. 이 경우 var는 단지 코드의 군더더기를 줄여주는 수단이 아니라 코드 작성에 꼭 필요한 수단이다.
  • 더 나아가서 into 키워드를 사용하면 전체 질의를 좀 더 간결하게 작성할 수 있다.
var query =
  from n in names
  select new
  {
    Original = n,
    Vowelless = n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  }
  into temp
  where temp.Vowellesss.Length > 2
  select temp.Original;
  • 또한 질의 표현식 구문은 이런 종류의 질의를 작성하는데 유용한 단축 수단을 제공한다. 바로 let 키워드이다.

let 키워드

  • let 키워드는 범위 변수 이외의 새로운 변수를 질의에 도입한다.
    • 다음은 앞의 예와 동일한 질의, 즉 모음을 제외한 글자 수보가 2보다 큰 모든 문자열을 추출하는 질의를 let을 이용해서 작성한 것이다.
string[] names = { "Tom", "Dick", "Harray", "Mary", "Jay" };

IEnumerable<string> query =
  from n in names
  let vowelless = n.Replace("a", "").Replace("e", "").Replace("i", "").Replace("o", "").Replace("u", "")
  where vowelless.Length > 2
  orderby vowelless
  select n;  // let 덕분에 n이 여전히 현재 범위에 살아 있다.
  • 컴파일러는 let 절을 만나면 범위 변수와 새 표현식 변수 모두를 담은 임시 익명 형식으로 투영하는 코드를 생성한다. 다른 말로 하면 컴파일러는 이 질의를 앞에서 본 예제 질의와 동일한 코드로 바꾸어서 컴파일 한다.
  • let 키워드는 2가지 용도로 쓰인다.
    • 기존 요소들과 함께 새 요소들을 투영하고자 할 때
    • 표현식을 중복해서 작성하지 않고 질의 안에서 여러 번 사용하려 할 때
  • select 절에서 원래의 이름(n)과 모음을 제거한 버전(vowelless)을 함께 투영할 수 잆다는 점에서, 이번 예제에는 let 접근방식이 특히나 유용하다.
  • where 문 전후에 얼마든지 많은 let 문을 둘 수 있다. 그리고 한 let 문에서 도입한 변수를 그 이후의 let 문에서 참조할 수 있다 (물론 into 절에 의한 경계를 넘지 않는 한에서). let은 기존의 모든 변수를 투명하게 재투영한다 (reproject).
  • let 문의 우변이 반드시 스칼라 형식으로 평가되는 표현식이어야 하는 것은 아니다. 이를테며녀 하나의 부분 순차열로 평가되는 표현식이 유용한 경우도 있다.

해석적 질의

  • LINQ는 서로 비슷한 두 가지 질의 구조를 제공한다. 하나는 지역 객체 컬렉션을 대상으로 하는 지역 질의(local query)이고, 또 하나는 원격 자료원(data source)을 대상으로 하는 해석식 질의(interpreted query)이다.
    • 지역 질의는 Enumerable 클래스의 질의 연산자들의 사슬로 환원되고, 그 사슬은 장식자 순차열들의 사슬로 환원된다. 질의에적용된 대리자는 다른 모든 C# 메서드처럼 전적으로 IL(Intermediate Language) 코드의 범위 안에서 실행된다.
    • 그와 달리 해석식 질의는 서술적(descriptive)이다. 해석식 질의는 IQueryable<T>를 구현하는 순차열에 대해 작동하며, Queryable 클래스의 질의 연산자들로 환원된다. 그 질의 연산자들은 표현식 트리를 산출하며, 그 트리를 실행시점에서 .NET Framework가 해석해서 실행한다.
    • Enumerable의 질의 연산자들을 IQueryable<T> 순차열에 사용하는 것도 가능하다. 그러나 그런 식으로 만든 질의는 항상 클라이언트에서 지역적으로 실행되므로 제대로 활용하기가 어렵다. Queryable 클래스로 또 다른 종류의 질의 연산자들을 제공하는 이유가 바로 이것이다.
  • IQueryable<T>를 구현하는 .NET Framework의 구성 요소는 다음 두 가지이다.
    • LINQ to SQL(SQL 대상 LINQ)
    • Entity Framework(EF)
  • 이들을 통칭해서 DB 대상 LINQ(LINQ-to-db)라고 부른다.
    • 둘의 LINQ 지원 정도는 비슷하다. 이 책에 나오는 DB 대상 LINQ 예제들은 특별한 언급이 없는 한 LINQ to SQL과 EF 모두에 대해 작동한다.
  • 보통의 열거 가능 컬렉션을 감싼 IQueryable<T> 래퍼를 얻는 것도 가능하다. AsQueryable 메서드가 그러한 컬렉션을 돌려주는데, 이에 관해서는 ‘질의 표현식 구축’ 장에서 이야기한다.
  • 이번 절에서는 LINQ to SQL을 이용해서 해석식 질의를 설명한다. LINQ to SQL 을 선택한 이유는, EF와 달리 EDM(Entity Data Model)을 작성하지 않고도 질의를 수행할 수 있기 때문이다. 그러나 예제 질의들은 EF에서도 잘 작동한다.
  • IQueryable<T>는 표현식 트리 구축을 위한 메서드들을 추가해서 IEnumerable<T>를 확장한 인터페이스이다. 대부분의 경우 추가된 메서드들의 세부사항은 몰라도 된다. 그 메서드들은 간접적으로 .NET Framework가 호출하기 때문이다.
  • 다음과 같은 SQL 스크립트를 이용해서 SQL Server에 간단한 고객 테이블을 하나 만들고 이름 몇 개를 채워 넣었다고 하자.
create table Customer
(
  ID int not null primary key,
  Name varchar(30)
)
insert Customer values (1, 'Tom')
insert Customer values (2, 'Dick')
insert Customer values (3, 'Harry')
insert Customer values (4, 'Mary')
insert Customer values (5, 'Jay')
  • 이런 테이블이 갖추어져 있다고 할 때, 다음은 이름에 영문자 ‘a’가 있는 고객들을 조회하는 해석식 LINQ 질의를 C#으로 작성한 것이다.
using System;
using System.Linq;
using System.Data.Linq;
using System.Data.Linq.Mapping;

[Table] public class Costomer
{
  [Column(IsPrimaryKey=true)] public int ID;
  [Column] public string Name;
}

class Test
{
  static void Main()
  {
    DataContext dataContext = new DataContext("연결 문자열");
    Table<Customer> customers = dataContext.GetTable<Customer>();

    IQueryable<string> query = from c in customers
      where c.Name.Contains("a")
      orderby c.Name.Length
      select c.Name.ToUpper();

      foreach(string name in query)  Console.WriteLine(name);
  }
}

// 결과 JAY MARRY HARRY

// LINQ to SQL은 이 질의를 다음과 같은 SQL 질의문으로 바꾼다.
SELECT UPPER([t0].[Name]) AS [value]
FROM [Customer] AS [t0]
WHERE [t0].[Name] LIKE @p0
ORDER BY LEN([t0].[Name])

해석식 질의의 작동 방식

  • 그럼 이 질의가 처리되는 과정을 살펴보자. 우선 컴파일러는 질의 구문을 다음과 같은 유창한 구문으로 바꾼다. 이는 지역 질의에서와 같다.
IQueryable<string> query = customers
  .Where (n => n.Name.Contain("a"))
  .OrderBy (n => n.Name.Length)
  .Select (n => n.Name.ToUpper());
  • 다음으로 컴파일러는 질의 연산자 메서드들의 바인딩을 결정한다. 여기서부터 지역 질의와 해석식 질의가 달라진다. 해석식 질의는 Enumerable 클래스가 아니라 Queryable 클래스의 질의 연산자들로 환원된다.
  • 왜 그런지 이해하려면 전체 질의의 입력인 customers 변수를 봐야 한다. customers의 형식은 Table<T>로 이것은 IQueryable<T>를 구현하는 클래스이다. 그리고 IQueryable<T>는 IEnumerable<T>의 파생 형식이다.
    • 따라서 Where를 묶을 수 있는 대상은 두 가지이다. 하나는 Enumerable의 확장 메서드이고, 또 하나는 Queryable의 다음과 같은 확장 메서드이다.
public static IQeuryable<TSource> Where<TSource> (this IQueryable<TSource> source, Expression <Func<TSource, bool>> predicate)
  • 컴파일러는 Queryable.Where를 선택하는데, 이는 이 메서드의 서명이 주어진 입력 순차열과 좀 더 구체적으로 부합하기 때문이다.
  • Queryable.Where는 Expression<TDelegate> 형식에 감싸인 하나의 술어(predicate)를 받는다.
    • 그 술어의 형식을 보고 컴파일러는 주어진 람다식을 통상적인 대리자가 아니라 표현식 트리(expression tree)로 변환해야 한다고 판단한다.
    • 표현식 트리는 System.Linq.Expression에 있는 형식들에 기초한 하나의 객체 모형으로, 실행시점에서 조사하고 해석할 수 있다는 특징을 가지고 있다. (LINQ to SQL이나 EF가 실행시점에서 이 트리로부터 SQL 질의문을 생성할 수 있는 것은 바로 이런 특징 덕분이다.)
  • Queryable.Where 역시 IQueryable<T>를 돌려주므로, 컴파일러는 그 다음의 OrderBy 연산자와 Select 연산자도 같은 방식으로 처리한다.
    • 최종 결과가 아래 그림에 나와있다. 그림 오른쪽의 점선으로 감싸인 영역에는 실행시점에서 운행(traversal) 할 수 있는 전체 질의를 서술하는 표현식 트리가 들어 있다.

실행

  • 지역 질의와 마찬가지로, 해석식 질의는 지연된 실행 모형을 따른다. 따라서 SQL 질의문은 질의를 실제로 열거해야 비로소 생성된다. 또한 같은 질의를 두 번 열거하면 데이터베이스 질의도 두 번 실행된다.
  • 내부로 더 들어가면 해석식 질의와 지역 질의는 그 실행 방식이 서로 다르다. 해석식 질의를 열거할 때 가장 바깥에 있는 순차열은 전체 표현식 트리를 운행하는 하나의 프로그램을 실행해서, 전체 트리를 하나의 단위로서 처리한다.
    • 지금 예에서 LINQ to SQL은 표현식 트리를 SQL 질의문으로 바꾸어서 데이터베이스에 대해 실행하고, 그 결과를 하나의 순차열에 담아서 돌려준다.
  • LINQ to SQL 질의가 제대로 작동하려면 데이터베이스 스키마에 대한 정보를 제공해야 한다. Customer 클래스에 부여한 Table, Column 특성들이 바로 그러한 역할을 한다.
  • 앞에서 LINQ 질의를 제품 조립라인에 비유했다. 그런데 지역 질의에서와 달리 IQueryable 컨베이어 벨트 하나를 열거해도 조립라인 전체가 돌아가지는 않는다. 그냥 해당 IQueryable 벨트만 돌아가며, 이때 특별한 열거자가 조립 관리자를 호출한다.
    • 그러면 관리자는 전체 조립라인을 점검하는데, 조립라인에는 당장 실행 가능한 컴파일된 코드가 아니라 표지판(메서드 호출 표현식)들만 있으며, 각 표지판에는 조립 명령들이 적힌 쪽지(표현식 트리)가 붙어 있다.
    • 관리자는 모든 표지판을 훑으면서 명령 쪽지를 SQL 질의문으로 바꾸어서 실행한 후 그 결과를 소비자에게 돌려준다. 실제로 돌아가는 벨트는 단 하나이고, 조립라인의 나머지는 그냥 해야 할 일이 적혀 있는 표지판들의 네트워크이다.
  • 이러한 구조는 실무에 여러 영향을 미친다. 예컨대 지역 질의에서는 프로그래머가 자신만의 질의 연산자(확장 메서드)를 작성해서 미리 정의된 질의 연산자들과 함께 사용할 수 있다.
    • 그러나 원격 질의에서는 그렇게 하기가 어려우며, 바람직하지도 않다. 예컨대 독자가 IQueryable<T>를 받는 MyWhere라는 확장 메서드를 작성한다는 것은 새로운 종류의 표지판을 만들어서 제품 조립라인에 배치하는 것에 해당한다. 그런데 조립 관리자는 그 표지판을 처리하는 방법을 알지 못한다.
    • 독자가 여기에 개입해서 구체적인 지시를 내린다고 해도, 그러한 접근방식은 LINQ to SQL 같은 특정 공급자에만 작동하고 그 외의 IQueryable 구현들에는 작동하지 않는 해결책이 될 가능성이 크다.
    • 표준적인 메서드들을 Queryable에 모아 둠으로써 생기는 한 가지 장점은 임의의 원격 컬렉션에 대한 질의에 사용할 수 이는 표준 어휘가 정의된다는 것이다. 그러나 그 어휘를 독자가 확장하려 하면 상호운용성을 기대할 수 없게 된다.
  • 이 모형의 또 다른 영향은 IQueryable 공급자에 따라서는 일부 질의를 제대로 수행하지 못할 수 있다는 것이다. 표준 메서드들만 사용한다고 해도 그렇다.
    • LINQ to SQL과 EF 모두, 그 능력은 데이터베이스 서버 자체의 능력에 제한된다.
    • 그리고 SQL로 옮기지 못하는 LINQ 질의도 존재한다. SQL에 익숙한 독자라면 그런 질의드를 직관적으로 파악할 수 있겠지만, 그렇다고 해도 실행시점 오류의 발생 여부를 통해서 여러 가지 질의를 시험해 봐야 할 것이다.

해석식 질의와 지역 질의의 조합

  • 하나의 질의에서 해석식 질의 연산자와 지역 질의 연산자를 함께 사용할 수도 있다. 그런 경우 지역 연산자들을 외부에 두고 해석식 연산자들은 내부에 두는 패턴이 흔히 쓰인다. 다른 말로 하면 해석식 질의의 결과를 지역 질의의 입려긍로 공급하는 것이다. 이 패턴은 DB 대상 LINQ 질의들에 잘 작동한다.
  • 예컨대 어떤 컬렉션의 문자열들을 두 개씩 한 싸응로 묶어서 출력한다고 하자. 이를 위한 확장 메서드를 작성한다면 다음과 같은 모습이 될 것이다.
public static IEnumerable<string> Pair (this IEnumerable<string> source)
{
  string firstHalf = null
  foreach (string element in source)
    if (firstHalf == null)
    {
      firstHalf = element;
    }
    else
    {
      yield return firstHalf + ", " + element;
      firstHalf = null;
    }
}
  • 다음은 이 확장 메서드를 LINQ to SQL 연산자들과 지역 연산자들을 함께 사용하는 질의에서 사용하는 예이다.
DataContext dataContext = new DataContext("연결 문자열");
Table<Customer> customers = dataContext.GetTable<Customer>();

IEnumerable<string> q = customers
  .Select (c => c.Name.ToUpper())
  .OrderBy (n => n)
  .Pair()  // 여기서부터는 지역 질의
  .Select((n, i) => "Pair " + i.ToString() + " = " + n);

foreach (string element in q) Console.WriteLine(element);

// 결과 
// Pair 0 = HARRY, MARY
// Pair 1 = TOM, DICK
  • customers는 IQueryable<T>를 구현하는 형식의 변수이므로, Select 연산자는 Queryable.Select로 환원된다. 이 메서드가 출력하는 순차열 역시 IQueryable<T>를 구현하는 형식이므로, 마찬가지 이유로 OrderBy 연산자는 Queryable.OrderBy 로 환원된다.
    • 그런데 그 다음 질의 연산자는 Pair에는 IQueryable<T>를 받는 중복적재 버전이 없다. 덜 구체적인 IEnumerable<T>를 받는 버전 뿐이다. 따라서 이 연산자는 지역 Pair 메서드로 환원된다.
    • 결과적으로 지역 질의가 해석식 질의를 감싸게 된다. Pair는 IEnumerable을 돌려주므로, 그 다음의 Select는 또 다른 지역 질의 연산자로 환원된다.
  • 이 질의의 LINQ to SQL 부분은 다음에 해당하는 SQL 문으로 변환된다.
SELECT UPPER (Name) FROM Customer ORDER BY UPPER (Name)
  • 나머지 작업은 지역에서 일어난다. 전체적으로 이 질의는 해석식 질의(내부)의 결과를 입력으로 받는 하나의 지역 질의(외부)라 할 수 있다.

AsEnumerable 메서드

  • 가장 간단한 질의 연산자는 바로 Enumerable.AsEnumerable 메서드이다. 이 메서드의 정의는 다음이 전부이다.
public static IEnumerable<TSource> AsEnumerable<TSource> (this IEnumerable<TSource> source)
{
  return source;
}
  • 이 메서드의 용도는 IQueryable<T> 순차열을 IEnumerable<T>로 캐스팅하는 것이다. 그러면 이후의 질의 연산자들은 Queryable의 연산자들이 아니라 Enumerable의 연산자들로 환원되며, 결과적으로 질의의 나머지 부분이 지역에서 실행된다.
  • 이 점을 보여주는 예로 SQL 서버에 의학 논문들을 담은 MedicalArticles 테이블에서 초록(abstract)이 100단어 미만인 독감(influenza) 논문들을 LINQ to SQL이나 EF 질의로 조회한다고 하자.
    • 우선 단어 수를 세는 술어를 만들려면 정규 표현식(regular expression)이 필요하다.
Regex wordCounter = new Regex(@"\b(\w|]-'])+\b");

var query = dataContext.MedicalArticles
  .Where(article => article.Topic == "influenza" && wordCounter.Matches(article.Abstract).Count < 100);
  • 그런데 SQL Server는 정규 표현식을 지원하지 않으므로 DB 대상 LINQ 공급자들은 해당 질의를 SQL로 변환할 수 없다는 뜻의 예외를 던질 것이다.
    • 해결책은 질의를 두 부분으로 나누는 것이다. 첫 질의는 LINQ to SQL 질의를 이용해서 독감 관련 논문을 모두 가져오고, 둘째 질의는 그중 초록이 100단어 미만인 것들을 지역에서 뽑는다.
Regex wordCounter = new Regex(@"\b(\w|]-'])+\b");

IEnumerable<MedicalArticle> sqlQuery = dataContext.MedicalArticles
  .Where(article => article.Topic == "influenza");

IEnuemrable<MedicalArticle> localQuery = sqlQuery
  .Where(article => wordCounter.Matches(article.Abstract).Count < 100);
  • sqlQuery의 형식은 IEnumerable<MedicalArticle>이므로 두 번째 질의는 지역질의 연산자들로 환원된다. 따라서 논문 선별 작업은 클라이언트에서 실행된다.
  • AsEnumerable 메서드를 이용하면 이를 하나의 질의로 수행할 수 있다.
Regex wordCounter = new Regex(@"\b(\w|]-'])+\b");

var query = dataContext.MedicalArticles
  .Where(article => article.Topic == "influenza")
  .AsEnumerable()
  .Where(article => wordCounter.Matches(article.Abstract).Count < 100);
  • AsEnumerable 대신 ToArray나 ToList를 호출할 수도 있다. 그러나 AsEnumerable에는 질의의 실행이 지연된다는 장점과 중간 결과를 담기 위한 자료구조가 생성되지 않는다는 장점이 있다.
  • 질의 처리를 데이터베이스 서버에서 클라이언트로 옮기면 성능이 떨어질 수 있다. 특히 필요 이상으로 많은 행을 조회한다면 그렇다.

LINQ to SQL과 Entity Framework

  • LINQ to SQL과 Entity Framework 모두 LINQ를 지원하는 객체-관계 매퍼(object-relational mapper)이다.
    • 둘의 본질적인 차이는 EF에서는 질의하고자 하는 데이터베이스 스키마와 클래스 사이의 결합을 아주 느슨하게 만들 수 있다는 점이다.
    • EF에서는 데이터베이스 스키마를 거의 그대로 따르는 클래스에 대해 질의를 수행하는 것이 아니라 EDM(Entity Data Model)으로 서술된 좀 더 높은 수준의 추상에 대해 질의를 수행한다. 이 덕분에 유연성은 커지지만, 성능상의 비용과 복잡성이 높아진다.
    • L2D는 C# 팀이 작성한 것으로, .NET Framework 3.5에서 도입되었다. EF는 ADO.NET 팀이 작성했고 서비스 팩 1의 일부로 제공되었다. 이후 L2S를 ADO.NET 팀이 맡게 되었는데, ADO.NET 팀이 EF에 좀 더 집중하다 보니 L2S는 사소한 부분만 개선되었다.
    • EF는 이후 버전들에서 상당히 개선되었지만, L2S도 여전히 나름의 장점이 있다. L2S는 사용하기 쉽고, 간단하고, 성능이 좋고 SQL 문의 품질이 좋다.
    • 한편 EF의 장점은 데이터베이스와 엔티티 클래스 사이의 대응 관계를 좀 더 정교하게 만들 수 있는 유연성이다.
    • 또한 EF에서는 공급자 모형(provider model)을 통해서 SQL Server 이외의 데이터베이스도 질의할 수 있다. (L2S에도 공급자 모형이 있지만 서드파티들이 EF에 집중하도록 권장하는 용도로 쓰일 뿐 일반 사용자에게까지 공개되지는 않았다.)
    • L2S는 LINQ로 데이터베이스를 질의하는 방법을 배우는 용도로 아주 좋다. 객체-관계 매핑 부분을 단순하게 유지하면서, EF에도 적용되는 원칙적인 질의 방법들에 집중할 수 있기 때문이다.

LINQ to SQL의 엔티티 클래스

  • 적절한 특성들을 부여하기만 한다면 그 어떤 클래스라도 L2S 질의를 위한 자료원으로 사용할 수 있다.
[Table]
public class Customer
{
  [Column(IsPrimaryKey=true)]
  public int ID;

  [Column]
  public string Name;
}
  • [Table]은 System.Data.Linq.Mapping 이름공간에 있는 특성이다. L2S는 이 특성이 지정된 형식을 데이터베이스 테이블의 한 행을 나타내는 형식으로 간주한다.
    • 기본적으로 L2S는 테이블 이름이 클래스 이름과 일치한다고 가정한다. 두 이름이 다르다면 다음처럼 테이블 이름을 명시적으로 지정하면 된다.
[Table (Name="Customers")]
  • [Table] 특성이 부여된 클래스를 L2S에서는 엔티티(entity)라고 부른다. 원격 질의에 제대로 활용하려면 엔티티 클래스의 구조가 데이터베이스 테이블의 구조와 밀접하게 또는 정확하게 부합해야 한다. 이 때문에 엔티티 클래스는 저수준 코드 요소로 분류된다.
  • [Column] 특성은 클래스의 필드나 속성을 테이블의 한 열에 대응시킨다. 열 이름과 필드 또는 속성 이름이 다르다면 다음처럼 열 이름을 명시적으로 지정하면 된다.
[Column (Name="FullName")]
public string Name;
  • [Column] 특성의 IsPrimaryKey 속성은 해당 필드가 테이블의 기본키를 구성하며 객체의 신원을 유지하는데 똑 필요한 열에 해당하는지의 여부를 뜻한다.
    • 이 속성을 true로 설정하면 엔티티에 가해진 변화를 다시 데이터베이스에 반영할 수 있게 되는 효과도 생긴다.
    • public 필드 대신, private 필드에 연결된 공용 속성에 [Column] 특성을 지정할 수도 있다. 그런 접근방식을 사용한 경우, 필요하다면 L2S에게 데이터베이스를 채울 때 속성 접근자를 무시하고 바탕 필드를 직접 기록하라고 지시하는 것도 가능하다.
string _name;

[Column (Storage="_name")]
public string Name { get { return _name; } set { _name = value; } }
  • Column(Storage=”_name”) 때문에 L2S는 개체를 채울 때 Name 속성이 아니라 _name 필드에 값을 직접 기록한다. L2S는 반영(reflection) 기능을 이용하기 때문에 이번 예처럼 전용 필드를 지정해도 문제가 되지 않는다.
  • 데이터베이스로부터 엔티티 클래스를 자동으로 생성할 수 있다. Visual Studio에서 프로젝트에 새 “LINQ to SQL 클래스” 항목을 추가하거나 명령줄 도구 SqlMetal을 이용하면 된다.

Entity Framework의 엔티티 클래스

  • L2S처럼 EF에서도 임의의 클래스로 자료를 표현할 수 있다.
    • 예컨대 다음은 고객을 나타내는 엔티티 클래스로 궁극적으로는 데이터베이스의 Customer 테이블에 대응된다.
// 이 코드를 컴파일 하려면 System.Data.Entity.dll을 참조해야 함
[EdmEntityType (NamespaceName = "NutshellModel", Name = "Customer")]
public partial class Customer
{
  [EdmScalarPeopertyAttribute (EntityKeyProperty=true, IsNullable=false)]
  public int ID { get; set; }

  [EdmScalarProperty (EntityKeyProperty=false, IsNullable=false)]
  public string Name { get; set; }
}
  • L2S와의 중요한 차이점은 이런 클래스만으로는 충분하지 않다는 것이다. EF에서는 데이터베이스를 직접 질의하는 것이 아니라 EDM이라는 고수준 모형을 질의한다는 점을 기억할 것이다. EDM을 서술하는 방법은 여러가지지만 가장 흔히 쓰이는 것은 확장자가 .edmx인 XML 파일을 작성하는 것이다. 그러한 XML 파일은 다음 세 부분으로 구성된다.
    • 데이터베이스와는 독립적으로 EDM을 서술하는 개념 모형(conceptual model)
    • 데이터베이스 스키마를 서술하는 저장소 모형(store model)
    • 개념 모형과 저장소 사이의 대응 관계(mapping)
  • .edmx 파일을 작성하는 가장 쉬운 방법은 Visual Studio에서 프로젝트에 새 ‘ADO.NET 엔티티 데이터 모델’을 추가하는 것이다.
    • Visual Studio가 제시하는 마법사 대화상자를 잘 따라하면 데이터베이스로부터 EDM이 만들어지는데, .edmx 파일 뿐만 아니라 엔티티 클래스들도 생성된다.
  • EF의 엔티티 클래스는 개념 모형에 대응된다. 개념 모형의 질의와 갱신을 지원하는 형식들을 통칭해서 객체 서비스(Object Services)라고 부른다.
  • 기본적으로 Visual Studio의 디자이너는 테이블들과 엔티티들이 일대일 대응된다고 가정하고 EDM을 생성한다. 일대일이 아닌 대응 관계를 원한다면 디자이너에서 직접 EDM을 수정하거나 생성된 .edmx 파일을 수정하면 된다. 이를테면 다음과 같은 관계를 형성할 수 있다.
    • 다수의 테이블을 하나의 엔티티에 대응
    • 하나의 테이블을 다수의 엔티티에 대응
    • 파생 형식을 테이블들에 대응(ORM 분야에서 흔히 쓰이는 세 가지 표준적인 전략에 따라)
  • 마지막 항목에서 언급한 세 가지 상속 전략은 다음과 같다.
    • 계통 구조당 테이블 하나
      • 하나의 테이블이 전체 클래스 계통구조에 대응된다. 이 경우 테이블에는 각 행이 어떤 형식에 대응되어야 하는지를 나타내는 구별용 열(dicriminator column)이 하나 있다.
    • 형식당 테이블 하나
      • 테이블 하나를 형식 하나에 대응시킨다. 따라서 파생 형식 하나가 다수의 테이블에 대응된다. 엔티티 질의 시 EF는 해당 형식의 모든 기반 형식을 병합하는 SQL JOIN 문을 생성한다.
    • 구체 형식당 테이블 하나
      • 각각의 구체 형식마다 개별적인 테이블을 대응시킨다. 따라서 기반 형식 하나가 다수의 테이블에 대응되며, 기반 형식의 엔티티를 질의할 때 EF는 SQL UNION  문을 생성한다.
  • 이와는 대조적으로 L2S는 ‘계통구조당 테이블 하나’ 전략만 지원한다.
  • 또한 EF에서는 LINQ를 사용하지 않고 대신 ESQL(Entity SQL)이라는 텍스트 언어를 이용해서 EDM을 질의할 수도 있다. 이 접근방식은 질의를 동적으로 구축하려 할 때 유용하다.

DataContext 클래스와 ObjectContext 클래스

  • 일단 엔티티 클래스를 (그리고 EF의 경우 EDM도) 정의했다면 이제 질의를 수행할 수 있다. 첫 단계는 DataContext(L2S)나 ObjectContext(EF)를 인스턴스화 하는 것이다. 이때 데이터베이스 또는 엔티티와의 연결에 필요한 연결 문자열을 지정한다.
var l2sContext = new DataContext("데이터베이스 연결 문자열");
var efContext = new ObjectContext("엔티티 연결 문자열");
  • 지금처럼 DataContext/ObjectContext를 직접 인스턴스화하는 것은 저수준 접근방식이며, 이 클래스들의 작동 방식을 설명하는 용도로는 좋지만 실무에서 흔히 쓰이지는 않는다. 그보다는 형식 있는 문맥(typed context) 클래스를 인스턴스화 하는 방식이 더 많이 쓰인다.
  • L2S에서는 데이터베이스 연결 문자열을 지정하고, EF에서는 엔티티 연결 문자열을 지정해야 한다. 엔티티 연결 문자열은 데이터베이스 연결 문자열에 EDM이 있는 위치를 추가한 것이다.
  • 다음으로 GetTable(L2S의 경우) 또는 CreateObjectSet(EF의 경우)을 호출해서 질의 가능 객체를 얻는다.
var context = new DataContext("데이터베이스 연결 문자열");
Table<Customer> customers = context.GetTable<Customer>();

Console.WriteLine(customer.Count());
Customer cust = customers.Single(c => c.ID == 2);
  • 다음은 같은 일을 EF로 수행하는 예이다.
var context = new ObjectContext("엔티티 연결 문자열");
context.DefaultContainerName = "NutshellEntities";
ObjectSet<Customer> customers = context.CreateObjectSet<Customer>();

Console.WriteLine(customer.Count());
Customer cust = customers.Single(c => c.ID == 2);
  • Single 연산자는 기본 키로 한 행을 조회할 때 아주 적합하다. First와는 달리 이 연산자는 만일 둘 이상의 요소가 반환되면 예외를 던진다.
  • DataContext/ObjectContext 객체는 두 가지 일을 한다.
    • 우선 이 객체들은 질의할 수 있는 객체들을 생성하는 공장 역할을 한다.
    • 둘째로 이 객체들은 엔티티에 가한 변경들을 추적한다(이후에 그 변경들을 실제 저장소에 반영할 수 있도록).
Customer cust = customers.OrderBy(c => c.Name).First();
cust.Name = "갱신된 이름";
context.SubmitChanges();
  • 갱신 메서드 이름이 SaveChanges라는 점만 제외하면 EF에서도 같은 코드로 고객을 갱신할 수 있다.
Customer cust = customers.OrderBy(c => c.Name).First();
cust.Name = "갱신된 이름";
context.SaveChanges();

형식 있는 문맥

  • 그런데 질의 가능 객체를 얻기 위해 매번 GetTable<Customer>()나 CreateObjectSet<Customer>()를 호출해야 한다는 것은 좀 번거롭다.
    • 더 나은 접근방식은 DataContext/ObjectContext를 구체적인 데이터베이스에 맞게 파생하고, 각 엔티티마다 질의 가능 객체를 얻어 주는 속성을 추가하는 것이다. 그런 클래스를 형식 있는 문맥 (typed context) 클래스라고 부른다.
class NutshellContext : DataContext  // L2S용 형식 있는 문맥
{
  public Table<Customer> Customers => GetTable<Customer>();
  // ... 데이터베이스의 테이블마다 이런 속성을 마련한다.
}

class NutshellContext : ObjectContext  // EF용 형식 있는 문맥
{
  public ObjectSet<Customer> Customers => CreateObjectSet<Customer>();
  // ... 개념 모형의 엔티티마다 이런 속성을 마련한다.
}
  • 이제 다음과 같은 간결한 코드가 가능하다.
var context = new NutshellContext("연결 문자열");
Console.WriteLine(context.Customers.Count());
  • Visual Studio를 이용해서 ‘LINQ to SQL 클래스’ 항목이나 ‘ADO.NET 엔티티 데이터 모델’을 생성했다면 형식 있는 문맥 클래스도 자동으로 생성된다.
    • Visual Studio의 디자이너는 또한 식별자들을 복수형으로 만드는 등의 추가적인 손질도 자동으로 처리해 준다.
    • 지금 예제에서 SQL 테이블과 엔티티 클래스 둘 다 Customer이지만, 속성 이름은 context.Customer가 아니라 ‘s’가 붙은 context.Customers이다.

DataContext/ObjectContext의 처분

  • 문맥 클래스 DataContext와 ObjectContext가 IDisposable을 구현하긴 하지만, 이런 문맥 클래스들을 사용할 때 인스턴스들을 명시적으로 처분(삭제)하는 경우는 별로 없다. 인스턴스를 처분하면 해당 문맥의 연결이 닫힌다.
    • 그런데 L2S와 EF는 질의의 결과를 다 조회하고 나면 자동으로 연결을 닫아주므로, 일반적으로 프로그래머가 그런 처리를 직접 해 줄 필요는 없다.
    • 오히려 문맥 인스턴스를 직접 처분하면 게으른 평가 때문에 문제가 생길 수 있다.
IQueryable<Customer> GetCustomers(string prefix)
{
  using (var dc = new NutshellContext("연결 문자열"))
    return dc.GetTable<Customer>().Where(c => c.Name.StartWith(prefix));
}

foreach (Customer c in GetCustomers("a"))
  Console.WriteLine(c.Name);
  • 이 코드는 제대로 작동하지 않는다. 질의는 열거될 때 비로소 평가되는데, 열거는 해당 DataContext가 처분된 이후의 foreach 문에서 일어나기 때문이다.
  • 그렇지만 문맥을 처분하는 것이 필요한 때도 있다.
    • 문맥 클래스들은 연결 객체에 대해 Close 메서드를 호출하면 연결 객체가 모든 비관리 자원(unmanaged resource)을 해제한다고 가정한다. SqlConnection의 경우에는 실제로 그렇지만, 이론적으로 서드파티 연결 객체의 경우 Close만 호출하고 Dispose를 호출하지 않으면 자원들이 계속 남아 있을 가능성이 있다 (이를 IDbConnection.Close가 정의하는 계약을 위반하는 것이라고 볼 수도 있겠지만)
    • 질의에 대해 GetEnumerator를 직접 호출해서(foreach를 사용하는 대신) 열거를 수행하다가 순차열을 다 소비하지 않고 열거를 끝내거나 중간에 열거자를 처분하면 연결이 열린 채로 남는다. 그런 경우 DataContext와 ObjectContext의 처분이 대비책이 된다.
    • 문맥을(그리고 IDisposable을 구현하는 모든 객체를) 직접 처분하는 것이 더 깔끔하다고 느끼는 사람들이 있다.
  • 문맥을 명시적으로 처분하고 싶다면, GetCustomers 같은 메서드를 호출할 때 DataContext/ObjectContext 인스턴스를 넘겨 주어야 앞에서 말한 문제점이 발생하지 않는다.

객체 추적

  • DataContext/ObjectContext 인스턴스는 인스턴스화 된 모든 엔티티를 추적한다. 이 덕분에 코드에서 테이블의 같은 행들을 요청할 때마다 같은 결과를 돌려준다.
    • 다른 말로 하면 한 문맥의 전체 수명에서 그 문맥이 테이블의 같은 행을 참조하는 서로 다른 두 엔티티를 돌려주는 경우는 없다.
  • 이런 추적을 비활성화하고 싶다면, L2S에서는 DataContext 인스턴스의 ObjectTrackingEnabled를 false로 설정하면 된다. EF에서는 개별 인스턴스가 아니라 형식 자체에 대해서만 객체 추적을 비활성화할 수 있다.
    • context.Customers.MergeOption = MergeOption.NoTracking;
    • 객체 추적을 비활성화 하면 갱신된 자료를 저장소에 제출하는 능력도 사라진다.
  • 객체 추적의 이해를 돕는 예로 알파벳순으로 이름이 첫 번째인 고객이 ID도 가장 작다는 규칙이 있는 테이블을 생각해 보자.
    • 다음 코드에서 a와 b는 같은 객체를 참조한다.
var context = new NutshellContext("연결 문자열");
Customer a = context.Customers.OrderBy(c => c.Name).First();
Customer b = context.Customers.OrderBy(c => c.ID).FIrst();
  • 이러한 시나리오에는 흥미로운 점이 두 가지 있다.
  • 첫째로 L2S나 EF가 두 번째 질의를 수행할 때 어떤 일이 생길지 생각해 보자.
    • 문맥은 먼저 데이터베이스를 질의해서 하나의 행을 얻는다. 그런 다음 그 행의 기본 키를 읽어서 문맥의 엔티티 캐시를 조회한다. 기본 키에 부합하는 기존 객체가 캐시에 있으므로 문맥은 그 어떤 값도 갱신하지 않고 그 객체를 돌려준다.
    • 따라서 다른 어떤 사용자가 방금 데이터베이스에서 그 고객의 Name 열을 갱신했다면, 현재 코드는 갱신된 값을 보지 못한다.
    • 이런 행동 방식은 예상치 못한 부작용을 피하는데 꼭 필요하며, 동시성을 관리하는 데도 꼭 필요하다. 만일 Customer 객체의 속성들을 변경했는데 아직 SubmitChanges나 SaveChanges를 호출하지 않는 상황에서 변경된 속성들이 자동으로 덮어 쓰이길 원하지는 않을 것이다.
  • 데이터베이스에서 최신 정보를 얻으려면 새 문맥 객체를 인스턴스화하거나 문맥 인스턴스에 대해 Refresh 메서드를 호출해야 한다.
  • 또 다른 흥미로운 점은 행의 일부 열들만 선택하기 위해 엔티티 형식에 대해 명시적 투영을 수행하면 문제가 발생할 수 있다는 것이다.
    • 예컨대 고객의 이름만 얻고 싶을 때 다음 접근방식들은 모두 유효하다.
customers.Select(c => c.Name);
customers.Select(c => new { Name = c.Name } );
customers.Select(c => new MyCustomType { Name = c.Name });
  • 그러나 다음은 그렇지 않다.
customers.Select(c => new Customer { Name = c.Name });
  • 왜냐하면 Customer 엔티티들이 부분적으로만 채워질 수 있기 때문이다.
    • 즉 다음에 고객의 모든 열을 요청하는 질의를 수행하면 캐시에 있던 기존의 Customer 객체가 반환되는데, 그 객체에는 Name 속성만 채워져 있으므로 모든 열을 얻고자 하는 목적을 달성할 수 없다.
  • 다층(multitier) 응용 프로그램에서, 중간층에 DataContext나 ObjectContext는 정적 인스턴스 하나만 두고 그것으로 모든 요청을 처리하는 구조는 바람직하지 않다. 이는 문맥 객체가 스레드에 안전하지 않기 때문이다.
    • 대신 중간층 메서드는 반드시 클라이언트 요청마다 새로운 문맥 인스턴스를 생성해야 한다. 그러면 동시적인 갱신들을 처리하는 부담이 데이터베이스 서버에 넘어가므로 그리고 애초에 데이터베이스 서버는 그런 일을 더 잘 처리할 수 있는 능력을 갖추고 있으므로 전체적으로 이득이 된다.
    • 예컨대 데이터베이스 서버는 트랜잭션 격리 수준(transaction isolation-level) 의미론을 적용한다.

연관 관계

  • 엔티티 클래스 자동 생성 도구들은 또 다른 유용한 작업을 수행해 준다. 이 도구들은 데이터베이스에 정의되어 있는 관계마다 그 관계를 질의하는데 사용할 수 있는 속성들을 관계의 양쪽에 만들어 준다.
    • 예컨대 다음과 같이 일대다 관계로 연관된(associated) 고객 테이블과 구매(purchase) 테이블이 있다고 하자.
create table Customer
{
  ID int not null primary key,
  Name varchar(3) not null
}

create table Purchase
{
  ID int not null primary key,
  CustomerID int references Customer (ID),
  Description varchar(30) not null,
  Price decimal not null
}
  • 자동으로 생성된 엔티티 클래스들을 이용하면 다음과 같은 질의를 작성할 수 있다.
var context = new NutshellContext("연결 문자열");

// 알파벳순으로 이름이 첫 번쨰인 고객의 모든 주문 정보를 조회한다.
Customer cust1 = context.Customer.OrderBy(c => c.Name).FIrst();
foreach(Purchase p in cust1.Purchases)
  Console.WriteLine(p.Price);

// 구매 금액이 가장 작은 고객을 조회한다.
Purchase cheapest = context.Purchases.OrderBy(p => p.Price).First();
Customer cust2 = cheapest.Customer;
  • 또한 만일 cust1과 cust2가 같은 고객을 참조한다면 c1과 c2도 같은 객체를 참조한다. 즉 cust1==cust2는 true를 돌려준다.
  • 자동으로 생성된 Customer 엔티티 클래스의 Purchases 속성의 서명은 다음과 같다.
// L2S의 경우
[Association (Storage="_Purchases", OtherKey="CustomerID")]
public EntitySet <Purchase> Purchases { get { ... } set { ... } }

// EF의 경우
[EdmRelationshipNavigationProperty ("NutshellModel", "FK...", "Purchases")]
public EntityCollection<Purchase> Purchases { get { ... } set { ... } }
  • EntitySet이나 EntityCollection은 연관된 엔티티들을 추출하는 where 절이 있는, 미리 정의된 질의에 비유할 수 있다.
    • [Association] 특성은 L2S에게 SQL 질의문을 작성해야 한다고 알려주는 역할을 한다.
    • [EdmRelationshipNavigation Property] 특성은 EF에게 EDM에서 해당 관계에 대한 정보가 있는 위치를 알려준다.
  • 다른 종류의 질의와 마찬가지로 L2S와 EF의 질의는 지연 실행된다.
    • L2S의 경우 EntitySet은 열거를 실행해야 비로소 채워지며, EF의 경우 EntityCollection은 명시적으로 Load 메서드를 호출해야 채워진다.
  • 다음은 L2S의 Purchases.Customer 속성이다.
[Association (Storage="_Customer", ThisKey="CustomerID", IsForeignKey=true)]
public Customer Customer { get { ... } set { ... } }
  • 이 속성의 형식은 Customer이지만 바탕 필드(_Customer)의 형식은 EntityRef이다. EntityRef 형식은 지연된 적재 (deferred loading)를 구현하므로, 관련 Customer 속성은 실제로 접근이 일어나야 비로소 데이터베이스에서 조회된다.
  • EF도 마찬가지 방식으로 작동한다. 단, 그냥 접근하기만 한다고 속성이 채워지지는 않는다. 해당 EntityReference 객체에 대해 명시적으로 Load를 호출해야 한다. 이 때문에 EF의 문맥은 실제 부모 객체와 해당 EntityReference 래퍼 모두에 대해 속성들을 노출해야 한다.
[EdmRelationshipNavigationProperty ("NutshellModel", "FK...", "Customer")]
public Customer Customer { get { ... } set { ... } }

public EntityReference<Customer> CustomerReference { get; set; }
  • L2S처럼 EF에서도 속성에 접근하기만 하면 EntityCollection과 EntityReference가 채워지게 할 수 있다.
    • context.ContextOptions.DeferredLoadingEnabled = true;

L2s와 EF의 지연된 실행

  • 지역 질의들처럼 L2S와 EF의 질의들에는 지연된 실행이 적용된다. 따라서 질의를 점진적으로 구축할 수 있다.
  • 그런데 L2S/EF에는 특별한 지연 실행 의미론이 존재한다. 좀 더 구체적으로 Select 표현식 안에 부분 질의가 있을 떄는 다음과 같은 차이가 생긴다.
    • 지역 질의에서는 실행이 이중으로 지연된다. 기능적인 관점에서 볼 때 그런 Select 절은 질의들의 순차열을 선택하는 것이기 때문이다. 즉 외곽의 결과 순차열을 열거하되 안쪽 순차열을 열거하지 않는다면 부분 질의는 전혀 실행되지 않는다.
    • L2S/EF에서는 부분 질의가 주된 외곽 질의와 함꼐 실행된다. 이 덕분에 서버와의 불필요한 왕복 통신이 방지된다.
  • 예컨대 다음 질의는 첫 foreach 문에 도달했을 때 왕복 1회로 실행된다.
var context = new NutshellContext("연결 문자열");

var query = from c in context.Customers
  select 
    from p in c.Purchases
    select new { c.Name, p.Price };

foreach (var customerPurchaseResults in query)
  foreach (var namePrice in customerPurchaseResults)
    Console.WriteLine(namePrice.Name + " spent " + namePrice.Price);
  • 명시적으로 투영한 모든 EntitySet이나 EntityCollection은 왕복 1회로 완전히 채워진다.
var query = from c in context.Customers
  select new { c.Name, c.Purchases };

foreach (var row in query)
  foreach (Purchase p in row.Purchases)  // 추가 왕복 없음
    Console.WriteLine(row.Name + " spent " + p.Price);
  • 그러나 이처럼 명시적으로 투영하지 않고 EntitySet/EntityCollection 속성을 열거하면 통상적인 지연 실행 규칙들이 적용된다.
    • 다음 예에서 L2S와 EF는 루프 반복마다 Purchases 질의를 수행한다.
context.ContextOptions.DefferedLoadingEnabled = true;  // EF에만 필요함

foreach (Customer c in context.Customers)
  foreach (Purchase p in c.Purchases)  // 데이터베이스와의 추가 왕복
    Console.WriteLine(c.Name + " spent " + p.Price);
  • 이러한 접근방식은 내부 루프를 클라이언트에서만 판정할 수 있는 조건에 기초해서 선택적으로 실행하려 할 때 유용하다.
foreach (Customer c in context.Customers)
  if (myWebService.HadBadCreditHistory(c.ID))
    foreach (Purchase p in c.Purchases)  // 데이터베이스와의 추가 왕복
      Console.WriteLine(...);

DataLoadOptions 클래스

  • DataLoadOptions 클래스는 L2S에만 해당한다. 이 클래스의 주된 용도는 다음 두 가지이다.
    • EntitySet 연관 관계들을 위한 필터를 미리 지정한다(AssociateWith 메서드)
    • 왕복을 줄이기 위해 EntitySet을 즉시 적재한다(LoadWith 메서드)

필터를 미리 지정

  • 이전의 예제를 다음과 같이 고쳐 보자.
foreach (Customer c in context.Customers)
  if (myWebService.HadBadCreditHistory(c.ID))
    ProcessCustomer(c);
  • ProcessCustomer 메서드는 다음과 같이 정의한다.
void ProcessCustomer(Customer c)
{
  Console.WriteLine(c.ID + " " + c.Name);
  foreach (Purchase p in c.Purchases)
    Console.WriteLine(" - purchased a " + p.Description);
}
  • 이제 각 고객의 구매 정보 중 일부만, 이를테면 고가 구매 정보만 ProcessCustomer에 공급한다고 하자. 이를 이런식으로 해결할 수도 있다.
foreach (Customer c in context.Customers)
  if (myWebService.HadBadCreditHistory(c.ID))
    ProcessCustomer(c.ID, c.Name, c.Purchases.Where(p => p.Price > 1000));

...

void ProcessCustomer(int custID, string custName, IEnumerable<Purchase> purchases)
{
  Console.WriteLine(custID + " " + custName);
  foreach (Purchase p in purchases)
    Console.WriteLine(" - purchased a " + p.Description);
}
  • 그러나 이 코드는 지저분하다. 만일 ProcessCustomer가 Customer의 다른 필드들도 고려한다면 코드가 더욱 지저분해질 것이다. 더 나은 해법은 다음처럼 DataLoadOptions의 AssociateWith 메서드를 사용하는 것이다.
DataLoadOptions options = new DataLoadOptions();
options.AssociateWith<Customer> (c => c.Purchases.Where(p => p.Price > 1000));
context.LoadOptions = options;
  • 이렇게 하면 DataContext 인스턴스는 항상 주어진 술어로 Customer의 Purchases를 선별하게 된다. 이제 원래 버전의 ProcessCustomer를 사용해서 질의를 수행하면 원하는 결과가 나온다.
    • AssociateWith가 지연된 실행 의미론을 변경하지는 않는다. 이 메서드는 특정 관계가 쓰일 때 그냥 특정 필터를 암묵적으로 공식에 추가하라고 지시하는 역할을 할 뿐이다.

즉시 적재

  • DataLoadOptions의 둘째 용도는 특정 EntitySet을 그 부모와 함께 즉시 적재하게 만드는 것이다.
    • 예컨대 모든 고객과 그 구매 정보를 데이터베이스와의 왕복 통신 1회로 적재하고 싶다고 하자. 다음이 바로 그러한 일을 수행하는 질의이다.
DataLoadOptions options = new DataLoadOptions();
options.LoadWith<Customers> (c => c.Purchases);
context.LoadOptions = options;

foreach (Customer c in context.Customers)  // 왕복 통신 1회
  foreach (Purchase p in c.Purchases)
    Console.WriteLine(c.Name + " bought a " + p.Description);
  • 이 예의 LoadWith 호출은 Customer를 조회할 때마다 해당 Purchases도 함께 조회하라고 지시한다. LoadWith를 AssociateWith와 함께 사용할 수도 있다.
    • 다음은 고객을 조회할 때마다 고가 구매 정보도 같은 왕복에서 조회하라고 지시하는 예이다.
options.LoadWith<Customers> (c => c.Purchases);
options.AssociateWith<Customer> (c => c.Purchases.Where(p => p.Price > 1000));

EF의 즉시 적재

  • EF에서 연관 관계들을 즉시 적재하려면 Include 메서드를 호출하면 된다. 다음은 SQL 질의문 하나만 생성해서 각 고객의 구매 정보를 열거하는 예이다.
foreach (Customer c in context.Customers.Include("Purchases"))
  foreach (Purchase p in c.Purchases)
    Console.WriteLine(p.Description);
  • Include를 얼마든지 더 깊고 넓게 사용할 수 있다. 예컨대 Purchase 마다 PurchaseDetails와 SalesPersons라는 추가적인 내비게이션 속성들이 있다고 할 때, 다음은 내포된 계통구조 전체를 즉시 적재하는 예이다.
context.Customers.Include("Purchases.PurchaseDetails").Include("Purchases.SalesPersons")

갱신

  • L2S와 EF는 프로그램이 엔티티에 가한 모든 변경을 추적한다. 이후 DataContext 객체에 대해 SubmitChanges 메서드를 호출하거나 ObjectContext 객체에 대해 SaveChanges 메서드를 호출하면 그 변경들이 데이터베이스에 기록된다.
  • L2S의 Table<T> 클래스는 테이블에 행을 삽입하는 InsertOnSubmit 메서드와 테이블에서 행을 삭제하는 DeleteOnSubmit 메서드를 제공한다. EF의 ObjectSet<T> 클래스는 마찬가지 일을 하는 AddObject 메서드와 DeleteObject 메서드를 제공한다.
    • 다음은 행을 하나 삽입하는 방법을 보여주는 예제이다.
var context = new NutshellContext("연결 문자열");

Customer cust = new Customer { ID=1000, Name"Bloggs" };
context.Customers.InsertOnSubmit(cust);  // EF에서는 AddObject
context.SubmitChanges();  // EF에서는 SaveChanges
  • 다음은 그 행을 나중에 조회하고, 갱신하고, 삭제하는 예이다.
var context = new NutshellContext("연결 문자열");

Customer cust = context.Customers.Single(c => c.ID == 1000);
cust.Name = "Bloggs2";
context.SubmitChanges();  // 고객 정보를 갱신한다.

context.Customers.DeleteOnSubmit(cust);  // EF에서는 DeleteObject
context.SubmitChanges();  // 이제 고객을 삭제한다.
  • SubmitChange/SaveChanges는 문맥이 생성된 후 엔티티에 가해진 모든 변경을 취합한 후 그것을 데이터베이스에 기록하는 적절한 SQL 질의문을 실행한다. 이때 유효한 모든 트랜잭션 범위(TransactionScope 클래스를 이용한)가 반영된다.
    • 트랜잭션 범위가 없으면 이 메서드들은 모든 SQL 명령을 하나의 새 트랜잭션 범위 안에서 실행한다.
  • 새 행 또는 기존 행을 EntitySet/EntityCollection에 추가할 때는 Add 메서드를 사용한다. 이때 L2S와 EF는 foreign key들을 자동으로 채워준다. (SubmitChanges나 SaveChanges를 호출한 후에)
Purchase p1 = new Purchase { ID=100, Description="Bike", Price=500 };
Purchase p2 = new Purchase { ID=101, Description="Tools", Price=100 };

Customer cust = context.Customers.Single(c => c.ID == 1);

cust.Purchases.Add(p1);
cust.Purchases.Add(p2);

context.SubmitChanges();  // EF에서는 SaveChanges
  • 고유키를 만들어서 할당하는 것이 부담스럽다면 자동 증가 필드(auto-incrementing field)나 Guid를 기본키에 사용하면 된다.
  • 이 예에서 L2S/EF는 각각의 새 구매 행의 CustomerID 열에 자동으로 1을 기록한다. (L2S는 앞에서 정의한 Purchases 속성에 부여된 특성에 기초해서 이런 일을 수행한다. EF는 EDM에 있는 정보에 기초해서 이런 일을 수행한다)
[Association (Storage="_Purchases", OtherKey="CustomerID")]
public EntitySet<Purchase> Purchases { get { ... } set { ... } }
  • 만일 Customer 엔티티와 Purchase 엔티티를 Visual Studio 디자이너나 명령줄 도구 SqlMetal을 이용해서 생성했다면, 생성된 클래스들에는 관계의 양쪽 편을 동기화 해주는 코드도 포함되어 있다.
    • 다른 말로 하면 Purchase.Customer 속성에 값을 배정하면 Customer.Purchases 엔티티 집합에 새 고객 정보가 추가되고, 그 역도 마찬가지이다.
    • 다음은 이를 시험해 보기 위해 이전의 예제를 수정한 코드이다.
var context = new NutshellContext("연결 문자열");

Customer cust = context.Customers.Single(c => c.ID == 1);
new Purchase { ID=100, Description="Bike", Price=500, Customer=cust };
new Purchase { ID=101, Description="Tools", Price=100, Customer=cust };

context.SubmitChanges();  // EF에서는 SaveChanges
  • EntitySet이나 EntityCollection에서 한 행을 제거하면 해당 외래 키 필드에 자동으로 null이 설정된다. 다음 코드는 방금 추가한 두 구매 정보를 해당 고객 정보에서 떼어내는 예이다.
var context = new NutshellContext("연결 문자열");

Customer cust = context.Customers.Single(c => c.ID == 1);
cust.Purchases.Remove (cust.Purchases.Single(p => p.ID == 100);
cust.Purchases.Remove (cust.Purchases.Single(p => p.ID == 101);

context.SubmitChanges();  // EF에서는 SaveChanges
  • 이 코드는 각 구매 정보의 CustomerID 필드를 null로 설정하려하므로 데이터베이스 테이블에서 Purchase.CustomerID에 해당하는 열은 반드시 널을 허용하도록 설정되어 있어야 한다. 그렇지 않으면 예외가 발생한다.
  • 자식 엔티티들을 아예 삭제하려면 Table<T> 또는 ObjectSet<T>에서 제거해야 한다.
// L2S 의 경우
var c = context;
c.Purchases.DeleteOnSubmit(c.Purchases.Single(p => p.ID == 100));
c.Purchases.DeleteOnSubmit(c.Purchases.Single(p => p.ID == 101));
c.SubmitChanges();

// EF의 경우
var c = context;
c.Purchases.DeleteObject(c.Purchases.Single(p => p.ID == 100));
c.Purchases.DeleteObject(c.Purchases.Single(p => p.ID == 101));
c.SaveChanges();

L2S와 EF의 API 차이

용도 LINQ to SQL Entity Framework
모든 CRUD(생성, 읽기, 갱신, 삭제) 연산의 관문에 해당하는 클래스 DataContext ObjectContext
저장소에서 주어진 형식의 모든 엔티티를 조회하는 (게으른 방식으로) 메서드 GetTable CreateObjectSet
위 메서드의 반환 형식 Table<T> ObjectSet<T>
엔티티 객체의 모든 변경(추가, 수정, 삭제) 사항을 적용해서 저장소를 갱신하는 메서드 SubmitChanges SaveChanges
문맥 갱신 시 새 엔티티를 저장소에 추가하는 메서드 InsertOnSubmit AddObject
문맥 갱신 시 저장소에서 엔티티를 삭제하는 메서드 DeleteOnSubmit DeleteObject
일대다 관계 속성에서 ‘다’에 해당하는 쪽을 나타내는 형식 EntitySet<T> EntityCollection<T>
일대다 관계 속성에서 ‘일’에 해당하는 쪽을 나타내는 형식 EntityRef<T> EntityReference<T>
관계 속성의 기본 적재 전략 게으른 적재 명시적 적재(즉시 적재)
즉시 적재를 활성화 하는 수단 DataLoadOptions .Include()

 

질의 표현식 구축

대리자 대 표현식 트리

  • 질의 안에 내장된 람다식 자체는 Enumerable 연산자에 묶이든 Queryable 연산자에 묶이든 다를바가 없어 보인다.
IEnumerable<Product> q1 = localProducts.Where (p => !p.Discontinued);
IQueryable<Product> q2 = sqlProducts.Where (p => !p.Discontinued);
  • 그러나 람다식을 임시 변수에 배정할 때는 구체적인 형식을 지정해야 한다. 즉, 람다식을 대리자(Func<>)에 대응시킬 것인지 아니면 표현식 트리(Expression <Func<>>)에 대응시킬 것인지를 명시적으로 밝혀야 한다.
    • 다음 예에서 predicate1과 predicate2는 그대로 맞바꾸어 사용할 수 없는 술어들이다.
Func<Product, bool> predicate1 = p => !p.Discontinued;
IEnumerable<Product> q1 = localProducts.Where(predicate1);

Expression<Func<Product, bool>> predicate2 = p => !p.Discontinued;
IQueryable<Product> q2 = sqlProducts.Where (predicated2);

표현식 트리의 컴파일

  • 그러나 표현식 트리를 대리자로 바꾸는 것은 가능하다. 표현식 트리에 대해 Compile 메서드를 호출하면 된다. 이 방법은 재사용할 표현식을 돌려주는 메서드를 작성할 때 특히나 유용하다.
    • 이 점을 보여주는 예로 우선 만일 제품이 단종되지 않았으며 지난 30일간 팔린 적이 있으면 true로 평가되는 술어를 돌려주는 정적 메서드를 앞의 product 클래스에 추가해 보자.
public partial class Product
{
  public static Expressoin<Func<Product, bool>> IsSelling()
  {
    return p => !p.Discontinued && p.LastSale > DateTime.Now.AddDays(-30);
  }
}
  • 다음 예에서 보듯이 이 메서드를 해석식 질의와 지역 질의 모두에서 사용할 수 있다.
void Test()
{
  var dataContext = new NutshellContext("연결 문자열");
  Product[] localProducts = dataContext.Products.ToArray();

  IQueryable<Product> sqlQuery = dataContext.Products.Where(Product.IsSelling());

  IEnumerable<Product> localQuery = localProducts.Where(Product.IsSelling.Compile());
}

AsQueryable 연산자

  • AsQueryable 연산자를 이용하면 하나의 질의 전체를 지역 순차열과 원격 순차열 모두에 사용할 수 있다.
IQueryable<Product>  FilterSortProducts(IQueryable<Product> input)
{
  return from p in input
    where ...
    order by ...
    select p;
}

void Test()
{
  var dataContext = new NutshellContext("연결 문자열");
  Product[] localProducts = dataContext.Products.ToArray();

  var sqlQuery = FilterSortProducts(dataContext.Products);
  var localQuery = FilterSortProducts(localProducts.AsQueryable());
}
  • AsQueryable은 지역 순차열을 IQueryable<T>로 감싼다. 따라서 이후의 질의 연산자들은 표현식 트리로 환원된다. 나중에 결과 순차열을 열거하면 그 표현식 트리가 암묵적으로 컴파일되며(이때 성능이 약간 감소한다) 결국에는 지역 순차열이 보통의 경우와 마찬가지로 열거된다.

표현식 트리

  • 앞서 람다식에서 Expression<TDelegate>로의 암묵적 변환이 일어날 때 C# 컴파일러가 표현식 트리를 구축하는 코드를 산출한다고 말했다.
    • 약간의 프로그래밍 노력을 동원하면 그런 과정을 실행시점에서 명시적으로 진행할 수 있다. 다른 말로 하면 표현식 트리를 C# 코드로 직접 구축하는 것이 가능하다.
    • 그렇게 구축한 표현식 트리를 Expression<TDelegate>로 캐스팅할 수 있으며, 그런 다음에는 DB 대상 LINQ 질의에 사용하거나 Compile을 호출해서 보통의 대리자로 컴파일 할 수 있다.

표현식 DOM

  • 표현식 트리는 일종의 축소판 코드 DOM이다. 트리의 각 노드는 System.Linq.Expression 이름공간에 있는 형식들로 표현된다.
  • .NET Framework 4.0에서 코드 블록 안에 나타날 수 있는 언어 요소들을 지원하는 추가적인 표현식 형식들과 메서드들이 이 이름공간에 추가되었다. 이들은 람다식이 아니라 DLR을 위한 것이다. 다른 말로 하면, 코드 블록 스타일의 람다식을 표현식 트리로 변환하는 것은 여전히 불가능하다.
Expression<Func<Custom er, bool>> invalid = c => { return true; }  // 코드 블록은 허용되지 않음
  • 모든 노드 형식의 기반 형식은 비제네릭 Expression 클래스이다. 제네릭 Expression<TDelegate> 클래스는 사실 ‘형식 있는 람다식’을 의미하며, 다음 예처럼 코드가 너무 지저분해지는 문제만 없다면 이름을 LambdaExpression<TDelegate>로 하는 것이 나았을 것이다.
LambdaExpression<Func<Customer, bool>> f = ...
  • Expression<T>의 기반 형식은 비제네릭 LambdaExpression 클래스이다. Lambda Expression은 람다 표현식 트리들을 위한 형식 통합 능력을 제공한다. 특히 임의의 형식 있는 Expression<T>를 LambdaExpression으로 캐스팅할 수 있다.
    • LambdaExpression과 보통의 Expression의 차이는 람다 표현식에는 매개변수들이 있다는 점이다.

  • 표현식 트리를 만들 때 이 노드 형시들을 직접 인스턴스화 할 필요는 없다. 대신 Expression 클래스가 제공하는 정적 메서드들을 사용하는 것이 바람직하다.
Add ElementInit MakeMemberAccess Or
AddChecked Equal MakeUnary OrElse
And ExclusiveOr MemberBind Prameter
AndAlso Field MemberInit Power
ArrayIndex GreaterThan Modulo Property
ArrayLength GreaterThanOrEqual Multiply PropertyOrField
Bind Invoke MultiplyChecked Quote
Call Lambda Negate RightShift
Coalesce LeftShift NegateChecked Subtract
Condition LessThan New SubtractChecked
Constant LessThanOrEqual NewArrayBounds TypeAs
Convert ListBind NewArrayInit TypeIs
ConvertChecked ListInit Not UnaryPlus
Divide MakeBinary NotEqual

 

  • 아래 그림은 다음 배정문이 만들어 내는 표현식 트리를 나타낸 것이다.
Expression<Func<string, bool>> f = s => s.Length < 5;

  • 트리의 몇몇 노드를 다음과 같이 확인해 볼 수 있다.
Console.WriteLine(f.Body.NodeType);  // LessThan
Console.WriteLine(((BinaryExpression)f.Body).Right);  // 5
  • 이제 이 표현식 트리를 처음부터 직접 구축해 보자. 원칙은 트리의 제일 아래 노드들에서 시작해서 뿌리를 향해 올라가면서 표현식 트리를 구축한다는 것이다.
    • 지금 트리에서 가장 밑에 있는 것은 string 형식의 람다 표현식 매개변수 “s”를 나타내는 ParameterExpression 노드이다.
ParameterExpression p = Expression.Parameter(typeof (string), "s");
  • 다음으로 그 위 수준에 있는 MemberExpression 노드와 ConstantExpression 노드를 생성한다.
    • 전자를 위해서는 매개변수 “s”의 Length 속성에 접근해야 함을 주목하기 바란다.
MemberExpression stringLength = Expression.Property(p, "Length");
ConstantExpression five = Expression.Constant(5);
  • 다음으로 이 둘에 대해 LessThan 비교를 수행하는 BinaryExpression 노드를 만든다. 이 노드가 표현식의 본문(body)에 해당한다.
BinaryExpression comparison = Expression.LessThan(stringLength, five);
  • 마지막으로 표현식 본문에 매개변수 컬렉션(p)을 연결해서 하나의 람다 표현식 트리를 생성한다.
Expression<Func<string, bool>> lambda = Expression.Lambda<Func<string, bool>> (comparison, p);
  • 그럼 이 람다 표현식 트리를 시험해 보자. 먼저 대리자로 컴파일 하면 다루기가 편하다.
Func<string, bool> runnable = lambda.Compile();

Console.WriteLine(runnable("kangaroo"));  // false
Console.WriteLine(runnable("dog"));  // true
  • 표현식 트리를 구축할 때 적절한 표현식 노드 형식을 알아내는 가장 쉬운 방법은 기존 람다 표현식을 Visual Studio의 디버거로 살펴보는 것이다.
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.