LINQ(Language INtegrated Query)는 일련의 data를 대상으로 filtering 및 sorting 하고 다른 형태로 결과를 투영할 수 있는 언어확장 도구입니다.
1. 왜 LINQ인가?
(1) 명령형및 선언형 언어의 기능 비교
LINQ는 2008년 .NET 3.0과 .NET Framework 3.0과 함께 도입되었습니다. 그전에 C#및 .NET개발자는 명령형이라고 하는 절차적 code문을 사용해 예를 들어 loop처럼 일련의 item들을 처리하곤 했습니다.
- 첫 번째 item에 대한 현재 위치를 설정합니다.
- 지정한 값과 하나 또는 그 이상의 속성을 비교 비교하여 예를 들어 가격이 50 이상이어야 한다거나 수량이 동일한지등과 같은 경우처럼 처리해야 하는 item인지를 확인합니다.
- 2번에서 조건과 일치하는 경우라면 item을 처리합니다. 예를 들어 사용자에게 하나 또는 그 이상의 속성을 표시하거나 새로운 값으로 update 할 수 있고 또는 item을 제거하거나 counting과 summing처럼 값을 집계할 수도 있을 것입니다.
- 다음 item으로 이동합니다. 모든 item이 처리될때까지 이 과정을 반복합니다.
명령형이라고 하는 절차적 code는 compiler에게 '이렇게 하라. 저렇게 하라'와 같이 주어진 목표를 어떻게 달성할 수 있는지를 말해줍니다. Compiler는 우리가 달성하고자 하는 목표자체를 알 수 없기 때문에 그 자체로 우리를 도울 수는 없습니다. 모든 방법적 절차가 정확하게 맞다는 것에 대한 모든 책임은 100% 개발자가에게 있습니다.
LINQ는 이러한 일반적인 작업들을 오류를 최소화하면서 더 쉽게 code를 만들 수 있도록 해줍니다. Move나 read, update 기타 등등 각각의 동작에 대한 상태를 명시적으로 설명하는 대신 LINQ는 개발자가 선언적 혹은 기능적인 style의 문을 작성할 수 있도록 합니다.
선언적 code는 compiler에게 달성하기 위한 목표가 무엇인지를 말해주며 compiler는 가장 최적의 방법을 통해 목표를 달성하게 됩니다. 또한 해당 문은 기존보다 더 간소하면서도 깔끔한 상태를 유지할 수 있습니다.
LINQ를 제대로 이해하지 않으면 작성한 code는 약간의 자체적인 bug를 갖게 될 수 있습니다. 최근 잘 알려진 code teaser는 이러한 일련의 작업과 실행에 대한 이해를 돕고 있습니다. (https://twitter.com/amantinband/status/1559187912218099714). 해당 부분과 관련하여 대부분의 숙련된 개발자도 잘못 알고 있는 부분이 있는데 솔직히 말하자면 이것은 LINQ의 동작과 multi-threading 동작이 결합된 것으로 사람들이 가장 혼란스러워 하는 부분입니다. 따라서 LINQ동작 때문에 왜 code가 위험해지는지를 이해하는 것도 중요합니다.
2. LINQ 표현식
이전 글을 통해 우리는 이미 간단하게나마 LINQ를 사용해 본적이 있습니다.
그러나 위의 글에 대한 핵심은 LINQ가 아니므로 LINQ가 어떻게 동작하는지에 대해서는 생략했습니다. 이제 LINQ의 표현식을 이해하기 위한 시간을 가져보고자 합니다.
(1) 무엇이 LINQ를 만드는가?
LINQ는 몇가지 부분으로 나뉠 수 있는데 일부는 필수적이지만 일부는 선택적입니다.
- Extension method(필수적) : 여기에는 Where나 OrderBy 그리고 Select를 포함한 LINQ의 기능을 제공합니다.
- LINQ provider(필수적) : 여기에는 memory상에서 개체를 표현하기 위한 LINQ to Objects와 외부 database에 저장된 data와 EF Core를 통한 model을 처리하기 위한 LINQ to Entities, 그리고 XML로 저장된 data를 처리하기 위한 LINQ to XML을 포함합니다. 이들 provider는 다양한 유형의 data에 대해 특정한 방식으로 LINQ표현식을 실행합니다.
- Lambda 표현식(선택적) : LINQ query를 간소화하기 위해 명명된 method대신 예를 들어 filtering를 위한 Where method를 조건부 논리와 같이 사용할 수 있습니다.
- LINQ query comprehension 구문(선택적) : 여기에는 from, in, where, orderby, descending, select와 같은 C# keyword가 존재합니다. 이들은 몇몇 LINQ 표현 method에 대한 별칭에 해당하며 특히 이미 SQL(Structured Query Language)과 같이 다른 query언어를 경험해 본 적이 있다면 이들을 더욱 친숙하게 사용할 수 있을 것입니다. 처음 LINQ를 사용할 때는 종종 LINQ query comprehension 구문을 LINQ라고 오해하는 경우도 있는데 이것은 선택적으로 사용하는 LINQ의 여러 부분 중 하나에 해당할 뿐입니다.
(2) 열거 가능한 class에 LINQ 표현식 적용하기
Where나 Select와 같은 LINQ 확장 method는 Enumerable static class에 의해 IEnumerable<T>를 구현하는 모든 sequence type에 추가되었습니다.
예를 들어 모든 type의 array는 IEnumerable<T> class를 구현하며 여기서 T는 array에 속하는 item에 대한 type을 의미합니다. 다시 말해 모든 array는 질의와 이들의 조작하는데 대한 LINQ를 지원한다고 할 수 있습니다.
List<T>, Dictionary<TKey, TValue>, Stack<T>, Queue<T>와 같은 모든 generic collection은 IEnumerable<T>를 구현하므로 이들도 역시 LINQ를 통해 data질의하고 변경할 수 있습니다.
Enumerable은 아래 표에서 요약된것처럼 50여 개 이상의 method를 정의하고 있습니다.
아래 표는 향후 참고용으로 유용할 수 있지만 지금은 간단하게 어떤 method가 존재하고 나중에 어떤 method를 사용하는 것이 적절한지 확인하는 차원에서 살펴볼 수 있습니다.
Method | Description |
First, FirstOrDefault, Last, LastOrDefault | Sequence에서 첫번째 혹은 마지막 item을 가져오며 예외 혹은 type에 대한 기본값을 반환할 수 있습니다. 예를 들어 첫번째 혹은 마지막 item이 존재하지 않는 경우 int type이면 0을 참조 type이라면 null을 반환합니다. |
Where | 지정한 filter와 일치하는 item에 대한 sequence를 반환합니다. |
Single, SingleOrDefault | 지정한 filter와 일치하는 item을 반환하거나 아니면 예외 혹은 일치하는 것이 존재하지 않는다면 type에 대한 기본값을 반환합니다. |
ElementAt, ElementAtOrDefault | 지정한 index위치의 item을 반환하거나 아니면 예외 혹은 position에 해당하는 item이 존재하지 않는 경우 type의 기본값을 반환합니다. .NET 6에서 도입된것은 int대신 Index를 전달할 수 있게 overload되어 Span<T> sequences에서 더 효휼적입니다. |
Select, SelectMany | Item을 다른 type으로 투영하며 item의 중첩된 계층을 평탄화합니다. |
OrderBy, OrderByDescending, ThenBy, ThenByDescending | 지정된 field또는 속성을 통해 item을 정렬합니다. |
Order, OrderDescending | .NET 7에 도입된 것으로 item을 item자체별로 정렬합니다. |
Reverse | Item의 순서를 뒤집습니다. |
GroupBy, GroupJoin, Join | 두개의 sequences를 group화 하거나 join합니다. |
Skip, SkipWhile | Item 특정 수만큼 skip하거나 표현식이 true일때까지 skip합니다. |
Take, TakeWhile | Item을 특정 수만큼 가져오거나 표현식이 true일땨까지 가져옵니다. .NET 6에서 도입된 것은 Range를 전달할 수 있도록 overload되었는데 예를 들어 Take(range: 3..^5)은 3번째 item부터 시작하여 5번째 item에서 끝나는 부분집합을 가져온다는 것을 의미합니다. 또는 Skip(4)대신 Take(4..)와 같이 사용할 수도 있습니다. |
Aggregate, Average, Count, LongCount, Max, Min, Sum | 전체값 계산 |
TryGetNonEnumeratedCount | Count()는 Count속성이 sequence하에서 구현되었는지를 확인하고 그 값을 반환하거나 item을 count하기 위해 전체 sequence를 열거합니다. .NET 6에서 도입된 것은 해당 method로 오로지 Count만을 확인하여 없는 경우 잠재적인 저성능 운용을 동작을 방지하기 위해 false를 반환하며 out매개변수를 0으로 설정합니다. |
All, Any, Contains | Item이 filter에 대해 전체 혹은 일부와 일치하거나 sequence가 지정한 item을 포함하고 있다면 true를 반환합니다. |
Cast<T> | 지정한 type으로 item을 형변환합니다. 이것은 비 generic개체를 generic type으로 변환할때 유용하게 사용될 수 있습니다. |
Distinct | 중복 item을 제거합니다. |
DistinctBy, ExceptBy, IntersectBy, UnionBy, MinBy, MaxBy | Item의 전체가 아닌 일부만을 사용해 비교를 수행할 수 있습니다. 예를 들어 전체 Person개체를 비교함으로서 Distinct를 통해 중복 item을 제거하는 대신 LastName과 DateOfBirth만 비교함으로서 DistinctBy를 통해 중복 item을 제거할 수 있습니다. |
Chunk | 크기가 지정된 batch로 sequence를 분할합니다. |
Append, Concat, Prepend | Sequence별 결합동작을 수행합니다. |
Zip | Items의 위치를 기반으로 2개 혹은 3개의 sequence에 대한 일치 작업을 수행합니다. 예를 들어 첫번째 sequence에서 position이 1인 item은 두번째 sequence의 position 1인 item과 일치합니다. |
ToArray, ToList, ToDictionary, ToHashSet, ToLookup | Sequence를 array나 collection으로 변환합니다. 이들은 지연된 실행을 기다라기 보다는 LINQ표현식의 즉각적인 실행을 강제하는 유일한 method입니다. |
Enumerable class는 또한 확장 method가 아닌 아래 표에서 설명한 바와 같이 다른 몇몇 method들도 가지고 있습니다.
Method | Description |
Empty<T> | 지정된 type T에 대한 빈 sequence를 반환합니다. 대게 IEnumerable<T>를 필요로 하는 method에서 빈 sequence를 전달할때 유용하게 사용될 수 있습니다. |
Range | Count item을 통한 시작값으로 부터 정수 sequence를 반환합니다. 예를 들어 Enumerable.Range(start: 5, count: 3)는 정수 5, 6 그리고 7을 포함합니다. |
Repeat | Count횟수만큼 반복되는 같은 요소를 포함하는 sequence를 반환합니다. 예를 들어 Enumerable.Repeat(element: "5", count: 3)은 문자열값 "5", "5", "5"를 포함합니다. |
(3) 지연된 실행(deferred execution)
LINQ는 지연된 실행을 사용합니다. 이는 위에서 언급한 확장 method 대부분을 호출하면 query를 실행하지 않고 결과를 가져온다는 것을 이해하는데 중요한 부분이며 이때 이들 확장 method의 대부분은 답이 아닌 질문을 나타내는 LINQ 표현식을 반환합니다.
Visual Studio 2022에서 csStudy11 solution을 생성하고 여기에 LinqWithObjects이름의 console app project를 추가합니다.
이제 Program.cs에서 기존의 문을 모두 제거하고 아래와 같이 특정 인물에 대한 문자열값의 sequence를 정의합니다.
string[] names = new[] { "이순신", "이도", "신사임당", "홍길동", "김종서", "박혁거세", "김좌진", "윤봉길" };
// 질문 : 이름이 '이'자로 시작하는 사람은 누구인가?
//확장 method 사용
var query1 = names.Where(name => name.StartsWith("이"));
//LINQ query comprehension syntax 사용
var query2 = from name in names where name.StartsWith("이") select name;
질문에 대한 답을 얻으려면 즉, query를 실행하려면 다음과 같이 ToArray나 ToLookup과 같은 To method 중 하나를 호출하거나 query를 열거해야 합니다.
//질문의 답을 포함하는 문자열 arrary를 반환
string[] result1 = query1.ToArray();
//질문의 답을 포함하는 문자열의 list를 반환
List<string> result2 = query2.ToList();
//결과를 열거함으로서 질문의 답을 반환
foreach (string name in query1)
{
Console.WriteLine(name);
names[1] = "유관순"; //이도를 유관순으로 변경 -> 유관순은 '이'자로 시작하지 않는다.
}
위 예제를 실행하면 다음과 같은 결과를 표시하게 됩니다.
지연된 실행때문에 첫 번째 결과인 '이순신'을 출력한 이후 본래 배열의 값이 바뀌게 되면 loop로 다시 돌아갈 때쯤 '이도'가 '유관순'으로 변경되었으므로 더 이상 일치하는 것이 없게 되고 따라서 오로지 '이순신'만 표시되는 것입니다.
세부적으로 깊게 들어가기 전에 조금 속도를 늦추고 몇몇 일반적인 확장 method와 이를 어떻게 사용할지를 한번에 하나씩 알아보겠습니다.
(4) Where를 통한 entity filtering
LINQ를 사용하는 가장 일반적인 이유는 Where확장 method를 사용해 sequence안에서 item을 filtering하기 위한 것입니다. 예제를 통해 sequence를 정의하고 여기에 LINQ동작을 적용함으로써 filtering을 사용해 보겠습니다.
우선 Project file(*.csproj)에서 아래와 같이 global로 자동으로 import 되는 System.Linq를 제거하기 위한 요소를 추가합니다.
<ItemGroup>
<Using Remove="System.Linq" />
</ItemGroup>
그리고 Program.cs에서 names에 Where 확장 method를 호출하도록 합니다. 이때 Where를 입력하기 위해 typing울 시도하면 아래와 같이 IntelliSense에 의해 array에서 사용가능한 member들이 표시되는데
이상한 점은 여기에 표시되는 member들 중에서는 Where가 존재하지 않는다는 것입니다. 이는 Where가 확장 method이기 때문이며 array type에는 기본적으로 존재하지 않는 member이기 때문입니다. 따라서 Where 확장 method를 사용하려면 System.Linq namespace를 import 해야 합니다. .NET 6부터는 암시적으로 import 되는 것이 기본이지만 위에서 우리는 이미 System.Linq가 import 되지 않도록 Remove 했기 때문에 사용할 수 없게 되었습니다.
다시 Project file에서 위에서 추가한 System.Linq Remove요소를 삭제하고 names에서 Where method를 호출하도록 시도하면
IntelliSense는 Enumerable class에 의해 추가된 extension method를 추가한 member들을 표시할 것입니다. 또한 Where method에서 IntelliSense는 Where method를 호출하기 위해 Func<string, bool> delegate의 instance를 전달해야 함을 말해주고 있습니다.
Func<string, bool> delegate의 새로운 instance를 생성하기 위한 표현식을 입력합니다. 하지만 다음 단계에서 method의 이름을 정의할 것이기 때문에 아직까지는 method이름을 제공하지 않았습니다. (오류발생)
var query = names.Where(new Func<string, bool>());
Func<string, bool> delegate는 우리에게 각 string변수에 method로 전달되고 method는 반드시 bool값을 반환해야 함을 말해주고 있습니다. Method가 true를 반환한다면 이것은 결과에서 해당 string을 포함하고 있음을 의미하며 그렇지 않으면 포함하고 있지 않음을 의미합니다.
(5) 명명된 method 사용하기
Project에서 Function.cs 이름의 class file을 추가하고 여기에 partial Program class를 정의한 뒤 아래와 같이 3글자 이상의 이름만을 포함시킬 method를 정의합니다.
partial class Program
{
static bool NameLongerThanFour(string name)
{
return name.Length > 3;
}
}
그리고 Program.cs로 돌아와 위에서 정의했던 Func<string, bool> delegate로 method의 이름을 전달하고
var query = names.Where(new Func<string, bool>(NameLongerThanFour));
해당 query를 열거하도록 하면
foreach (var name in query)
{
Console.WriteLine(name);
}
다음과 같은 결과를 표시할 것입니다.
(6) Delegate를 제거하여 code 간소화하기
사실 Func<string, bool> delegate의 instance는 명시적으로 제거할 수 있습니다. 그러면 code를 더욱 간소화할 수 있는데 이는 C# compiler가 편의상 delegate를 instance화 할 수 있기 때문입니다.
이전의 문을 복사한 뒤 그대로 주석처리하고 복사된 code를 다시 넣습니다. 그런 뒤 붙여 넣은 code에서 아래와 같이 method이름만 남겨두고 delegate의 instance를 제거합니다.
//var query = names.Where(new Func<string, bool>(NameLongerThanFour));
var query = names.Where(NameLongerThanFour);
예제를 실행하면 이전과 동일한 결과를 표시할 것입니다.
(7) Lambda 표현식 사용하기
예제의 code를 더 간소화할 수 있는 방법으로 명명된 method를 사용하는 대신 lambda expression을 사용할 수 있습니다.
비록 이러한 방법이 처음에는 복잡하고 어려워 보일 수 있으나 lambda expression은 이름이 없는 함수로서 => 기호(goes to라고 함)를 사용해 반환값을 표현할 수 있습니다.
위의 예제를 다시 복사하여 기존의 문을 주석처리한 뒤 복사한 code를 붙여 넣어 아래와 같이 변경합니다.
//var query = names.Where(new Func<string, bool>(NameLongerThanFour));
//var query = names.Where(NameLongerThanFour);
var query = names.Where(name => name.Length > 3);
예제를 보면 NameLongerThanFour method의 모든 중요한 부분이 lambda 식에 포함되어 있는 것을 알 수 있고 또 이것이 전부입니다. lambda 식은 크게 아래 2가지를 정의하고 있는데
- 입력 매개변수의 이름 : name
- 반환 값 표현식 : name.length > 3
여기서 name 입력 매개변수의 type은 sequence가 포함하고 있는 문자열값으로 추론되며 Where에서 가능한 delegate의 정의에 따라 반환값은 bool이 됩니다. 따라서 => 기호 이후 표현식에서는 반드시 bool값을 반환하도록 해야 합니다.
Compiler는 우리를 위해 대부분의 작업을 수행하므로 code는 가능한 한 간소화할 수 있습니다. 예제를 실행하면 이전과 동일한 결과를 표시할 것입니다.
(8) Entity 정렬
다른 일반적으로 사용되는 method로 OrderBy와 ThenBy 등이 있으며 이들은 Sequence를 정렬하기 위해 사용됩니다. 또한 확장 method는 이전 method가 IEnumerable<T> interface를 구현하는 type인 다른 sequence를 반환하는 경우라면 연결하여 사용할 수 있습니다.
● OrderBy를 사용한 단일 속성으로 정렬하기
아래 예제와 같이 OrderBy method를 query의 마지막에 추가해 줍니다.
var query = names
.Where(name => name.Length > 1)
.OrderBy(name => name.Length);
각각의 확장 method마다 line을 분리하여 형식화하면 좀 더 code를 읽기 쉽게 만들 수 있습니다.
예제를 실행하면 이름이 짧은 순서대로 정렬된 결과를 다음과 같이 표시할 것입니다.
반대로 name이 긴 것 순으로 정렬하고자 한다면 OrderByDescending method를 사용할 수 있습니다.
● ThenBy를 사용한 다수의 속성으로 정렬하기
하나 이상의 속성을 통해 정렬하고자 한다면 예를 들어 alphabet이나 가나다순에서 같은 길이의 name으로 정렬하고자 한다면 아래 예제와 같이 ThenBy를 사용할 수 있습니다.
var query = names
.Where(name => name.Length > 1)
.OrderBy(name => name.Length)
.ThenBy(name => name);
예제를 실행하면 이전과 약간 다른 차이를 알 수 있을 것입니다. 같은 길이의 name으로 정렬된 상태에서 가나다순으로 정렬되었으므로 이도를 제외한 김종서와 김좌진이 상위에 있게 됩니다.
(9) Item 자체에서 정렬하기
.NET 7에서는 Order와 OrderDescending라는 확장 method가 도입되었으며 간단하게 item자체에서 졍렬을 수행할 수 있습니다. 예를 들어 string값의 sequence가 있다면 .NET 7이전에 OrderBy method를 호출하고 item자체를 선택하는 lambda식을 전달해야 했지만
var query = names.OrderBy(name => name);
.NET 7부터는 아래와 같이 간단하게 정렬을 수행할 수 있습니다.
var query = names.Order();
OrderDescending 역시 역순으로 정렬한다는 점을 제외하면 Order와 동일하게 사용할 수 있습니다.
(10) var나 특정 type을 사용해 query 선언하기
LINQ 표현식을 작성하는 동안 query개체를 선언하기 위해 var keyword를 사용하는 것은 LINQ 표현식이 동작하는 것마다 type이 자주 바뀔 수 있으므로 매우 일반적인 방법입니다. 예를 들어 예제에서의 query는 IEnumerable<string>으로서 시작되었으나 현재는 IOrderedEnumerable<string>이 되었습니다.
Mouse를 var keyword에 올려두면 type이 IOrderedEnumerable<string>이라는 것을 표시할 것입니다.
따라서 var는 실제 type으로 아래와 같이 바꾸는 것이 가능합니다.
IOrderedEnumerable<string> query = names
.Where(name => name.Length > 1)
.OrderBy(name => name.Length)
.ThenBy(name => name);
일단 query에 대한 작업을 끝내고 나면 var에서부터 선언된 type을 실제 type으로 바꿀 수 있고 이는 type이 정확히 어떤 것인지를 명확히 이해하는데 도움이 될 수 있습니다.
(11) Type filtering
Where 확장 method는 text나 숫자와 같이 값을 통해 filtering 할 수 있지만 sequence가 여러 type을 가지고 있고 이때 상속 계정을 따르면서 특정 type으로 filtering 해야 한다면 다른 방법을 사용해야 합니다.
예를 들어 예외에 대한 sequence가 있다고 가정하면 아래 그림과 같이 복잡한 계층구조로 부터 수백 가지의 예외 type이 형셩될 수 있습니다.
그럼 실제 type에 대한 filtering을 예제를 통해 구현해 보겠습니다.
Progam.cs에서 아래와 같이 예외별 파생 개체를 정의합니다.
List<Exception> exceptions = new()
{
new ArgumentException(),
new SystemException(),
new IndexOutOfRangeException(),
new InvalidOperationException(),
new NullReferenceException(),
new InvalidCastException(),
new OverflowException(),
new DivideByZeroException(),
new ApplicationException()
};
이 상태에서 OfType<T> 확장 method를 사용해 arithmetic exception이 아닌 예외를 삭제하고 해당 예외만 console에 표시하는 문을 아래와 같이 작성합니다.
IEnumerable<ArithmeticException> arithmeticExceptionsQuery = exceptions.OfType<ArithmeticException>();
foreach (ArithmeticException exception in arithmeticExceptionsQuery)
{
Console.WriteLine(exception);
}
위 예제를 실행하면 ArithmeticException 혹은 ArithmeticException으로부터 파생된 type만이 표시됨을 볼 수 있습니다.
(12) LINQ를 사용한 set과 bag
집합은 수학에서 가장 기본적인 개념 중의 하나이며 set은 하나 또는 그 이상의 중복되지 않은 개체의 collection입니다. 반면 Bag라고 하는 multiset(중복집합)은 중복되는 하나 또는 그 이상의 개체에 대한 collection에 해당합니다.
이와 관련하여 학교에서 우리는 이미 Venn diagram이라는 것을 배운 적이 있는데 공통집합 연산에는 집합 간의 교집합 또는 합집합을 포함합니다.
아래 예제에서는 취미별 학생들에 대한 3개의 문자열 값의 array를 정의하고 이들에 대한 공통집합과 합집합을 수행합니다.
우선 project에서 Helpers.cs이름의 class file을 partial class Program으로 추가하고 여기에 아래와 같이 method를 정의합니다. 해당 method는 문자열 변수의 모든 sequence를 comma(,)로 분리한 단일 문자열을 선택적으로 전달하는 설명과 함께 console에 출력하도록 합니다.
partial class Program
{
static void Output(IEnumerable<string> cohort, string description = "")
{
if (!string.IsNullOrEmpty(description))
{
Console.WriteLine(description);
}
Console.Write(" ");
Console.WriteLine(string.Join(", ", cohort.ToArray()));
Console.WriteLine();
}
}
Program.cs에서는 학생이름에 대한 3개의 문자열 array를 정의하고 이들을 출력한 뒤 다양한 집합연산을 수행하는 문을 아래와 같이 작성합니다.
string[] reading = new[] { "홍길동", "박남일", "김신영", "조정석" };
string[] music = new[] { "윤태진", "박길영", "서만진", "윤태진", "김영수" };
string[] exercise = new[] { "최동만", "이해영", "이해영", "윤태진", "한봉남" };
Output(reading, "독서");
Output(music, "음암감상");
Output(exercise, "운동");
Output(music.Distinct(), "music.Distinct()");
Output(music.DistinctBy(name => name.Substring(0, 2)), "music.DistinctBy(name => name.Substring(0, 2)):");
Output(music.Union(exercise), "music.Union(exercise)");
Output(music.Concat(exercise), "music.Union(exercise)");
Output(music.Intersect(exercise), "music.Intersect(exercise)");
Output(music.Except(exercise), "music.Union(exercise)");
Output(reading.Zip(music, (c1, c2) => $"{c1} matched with {c2}"), "reading.Zip(music)");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
예제에서 Zip은 2개의 sequences에서 item의 수가 동일하지 않다면 일부 item에서 mathing 되지 않을 수 있습니다. 따라서 music에 '김영수'는 결과에 포함되지 않았습니다.
DistinctBy는 전체이름을 비교하여 중복을 제거하는 대신 이름에서 처음 2글자만 비교하여 제거하기 위해 lambda key selector를 정의하였고 따라서 2명의 '윤태진'중 하나는 제거되었습니다.
지금까지 우리는 LINQ to Objects 공급자를 사용하여 in-memory 개체를 대상으로 예제를 구현하였습니다. 이후부터는 LINQ to Entities 공급자를 사용하여 database에 저장된 entity를 대상으로 예제를 구현해 볼 것입니다.
3. EF Core와 LINQ
위에서는 LINQ query를 통해 filter와 sort를 수행해 보았습니다. 하지만 sequence의 item 형태는 어떤 것도 변함이 없습니다. 이것은 projection이라고 하는 것으로 어떤 형태의 item을 다른 형태로 projecting 하는 것이기 때문입니다.
Projection에 관해 더 알아보려면 좀 더 복잡한 type을 가지고 해 보는 것도 좋은 방법이므로 다음 project에서는 string sequences를 사용하는 대신 이전에 설치했었던 Northwind sample database의 entity sequences를 사용할 것입니다.
(1) EF Core model build
일단 작업의 대상이 될 database와 table을 표현하기 위해 EF Core model을 정의해야 합니다. 따라서 우선은 Categories와 Categories table사이에 자동적으로 관계가 정의되는 것을 막고 완전한 제어권을 가져오기 위해 model을 수동적으로 정의할 것입니다. 그런 다음 LINQ를 사용하여 이 둘의 entity를 join 해 보고자 합니다.
csStudy11 solution에서 LinqWithEFCore이름의 console app project를 생성합니다. 그리고 LinqWithEFCore project에서 SQL Server에 대한 EF Core provider package를 설치합니다. 실제 database는 아래 글에서 이미 설치하였으므로 database를 생성하는 과정은 다루지 않을 것입니다.
[.NET/C#] - [C# 11 과 .NET 7] 10. Entity Framework Core
이제 project에 Northwind.cs, Category.cs, Product.cs라는 3개의 class file을 추가할 것입니다. 우선 Northwind.cs file은 아래와 같이 추가합니다.
public class Northwind : DbContext
{
public DbSet<Category> Categories { get; set; } = null!;
public DbSet<Product> Products { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connection = "Server=localhost;user id=sa;password=!123;Database=Northwind;MultipleActiveResultSets=true;TrustServerCertificate=true";
optionsBuilder.UseSqlServer(connection);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
if ((Database.ProviderName is not null))
{
modelBuilder.Entity<Product>().Property(product => product.UnitPrice).HasConversion<double>();
}
}
}
Category.cs는 아래와 같이 추가하고
public class Category
{
public int CategoryId { get; set; }
[Required]
[StringLength(15)]
public string CategoryName { get; set; } = null!;
public string? Description { get; set; }
}
Product.cs는 아래와 같이 추가합니다.
public class Product
{
public int ProductId { get; set; }
[Required]
[StringLength(40)]
public string ProductName { get; set; } = null!;
public int? SupplierId { get; set; }
public int? CategoryId { get; set; }
[StringLength(20)]
public string? QuantityPerUnit { get; set; }
[Column(TypeName = "money")]
public decimal? UnitPrice { get; set; }
public short? UnitsInStock { get; set; }
public short? UnitsOnOrder { get; set; }
public short? ReorderLevel { get; set; }
public bool Discontinued { get; set; }
}
(2) Sequence filtering 하고 sorting 하기
Table로부터 sequence를 filtering 하고 sorting 하기 위해 project에 Functions.cs이름의 class를 file을 추가하고 partial Program class를 정의한 뒤 product를 filter 하고 sort 하는 method를 아래와 같이 추가합니다.
static void FilterAndSort()
{
using (Northwind db = new())
{
DbSet<Product> allProducts = db.Products;
IQueryable<Product> filteredProducts = allProducts.Where(product => product.UnitPrice < 10M);
IOrderedQueryable<Product> sortedAndFilteredProducts = filteredProducts.OrderByDescending(product => product.UnitPrice);
Console.WriteLine("Products that cost less than $10:");
foreach (Product p in sortedAndFilteredProducts)
{
Console.WriteLine("{0}: {1} costs {2:$#,##0.00}", p.ProductId, p.ProductName, p.UnitPrice);
}
Console.WriteLine();
}
}
예제에서 DbSet<T>는 IEnumerable<T>를 구현하고 있으므로 LINQ는 EF Core를 위해 만들어진 model의 entity를 query 하고 조작하기 위해 사용할 수 있습니다. (실제 T대신 TEntity라고 할 수 있지만 해당 generic type의 이름은 기능적인 면을 가지고 있지 않으며 단지 type이 class라는 것만 필요할 뿐입니다. 이름은 그저 class가 entity model이 될 것이라는 예상을 나타낼 뿐입니다.)
Sequence는 또한 IEnumerable<T>혹은 IOrderedEnumerable<T>대신 IQueryable<T> (또는 정렬 LINQ method를 호출하고 나면 IOrderedQueryable<T>)를 구현하고 있습니다. 이는 표현식 tree를 사용해 query를 build 하는 LINQ provider를 사용한다는 것을 나타냅니다. 이들은 tree와 같은 data구조를 나타내고 있으며 동적 query의 생성을 가능하게 하는데 SQLite와 같은 외부 data 공급자를 위해 LINQ query를 build 하는데 유용하게 사용될 수 있습니다.
LINQ표현식은 SQL과 같은 다른 query언어로 변환될 수 있습니다. foreach나 ToArray와 같은 method를 호출하여 query를 열거하게 되면 query를 실행하게 되고 결과를 구체화하게 됩니다.
Program.cs에서 FilterAndSort method를 아래와 같이 호출합니다.
비록 해당 query가 목적한 바에 대한 결과를 표시하고 있기는 하지만 우리가 필요한 3개의 column대신 product table로부터 모든 column들을 가져오고 있기 때문에 상당히 비효휼적이라고 할 수 있습니다. 실제 생성된 SQL문을 확인해 보기 위해 FilterAndSort method를 아래와 같이 수정하고
IOrderedQueryable<Product> sortedAndFilteredProducts = filteredProducts.OrderByDescending(product => product.UnitPrice);
Console.WriteLine(sortedAndFilteredProducts.ToQueryString());
예제를 다시 실행해 보면 product에 대한 filtering결과를 표시하기 전에 실행된 SQL을 다음과 같이 확인할 수 있습니다.
(3) Sequence를 새로운 type으로 projecting 하기
실제 projection을 구현하기 전에 우선은 개체 초기화 구문을 다시 검토해 볼 필요가 있습니다. 정의된 class가 있다면 class명이나 new()를 사용해 해당 개체의 instance를 생성할 수 있으며 괄호를 통해 field나 속성의 초기값을 설정할 수 있습니다.
public class Person
{
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
}
Person knownTypeObject = new()
{
Name = "dongjunkim",
DateOfBirth = new(year: 1976, month: 11, day: 14)
};
C# 3.0 이부터는 instance에서 익명 type을 허용하고 있으므로 var keyword를 아래와 같이 사용할 수 있습니다.
var knownTypeObject = new()
{
Name = "dongjunkim",
DateOfBirth = new(year: 1976, month: 11, day: 14)
};
비록 type을 명시하지는 않았지만 compiler는 Name과 DateOfBirth라는 이름의 2개의 속성에 대한 설정으로부터 익명 type을 추론할 수 있습니다.
이러한 기법은 특히 새로운 type을 명시적으로 정의하지 않고 기존 type에서 새로운 type으로 project 하기 위해 LINQ query를 작성할 때 도움이 될 수 있습니다. Type은 익명이기에 var 선언된 지역변수에서만 작동할 수 있습니다.
아래 예제는 Product class의 instance를 새로운 익명 type으로 투영하기 위한 select method의 호출을 추가함으로써 database table을 상대로 SQL명령을 더 효휼적으로 실행하는 방식을 보여주고 있습니다.
Functions.cs의 FilterAndSort method에서 실제 필요로 하는 세 개의 속성(table의 column에 해당하는)만을 반환하는 Select method를 사용하기 위해 LINQ query를 확장하는 문을 아래와 같이 추가하고 var keyword와 projection LINQ 표현식을 사용하도록 아래와 같이 foreach문을 변경합니다.
IOrderedQueryable<Product> sortedAndFilteredProducts = filteredProducts.OrderByDescending(product => product.UnitPrice);
var projectedProducts = sortedAndFilteredProducts.Select(product => new { product.ProductId, product.ProductName, product.UnitPrice });
Console.WriteLine(projectedProducts.ToQueryString());
Console.WriteLine("Products that cost less than $10:");
foreach (var p in projectedProducts)
{
Console.WriteLine("{0}: {1} costs {2:$#,##0.00}", p.ProductId, p.ProductName, p.UnitPrice);
}
이때 Select method에서 new keyword와 foreach문의 var keyword에 mouse를 올려두면 이것이 익명 type임을 다음과 같이 표시할 것입니다.
예제를 실행하면 이전과 같은 결과를 표시하지만 생성된 SQL은 더 효휼적으로 바뀌었음을 알 수 있습니다.
(4) Sequence에 대한 join과 grouping
Joining과 grouping을 위해 사용할 수 있는 확장 method로 아래와 같은 2가지가 있습니다.
- Join : 해당 method는 join 하고자 하는 sequence 및 연결시킬 left sequence와 right sequence에 해당하는 속성, 그리고 projection인 4개의 매개변수를 필요로 합니다.
- GroupJoin : 해당 method 역시 같은 매개변수를 필요로 하지만 일치하는 항목들을 일치하는 값에 대한 key속성과 다중 일치항목에 대한 IEnumerable<T> type을 가진 group개체로 결합합니다.
● Sequence Join
예를 들어 Categories와 Products라는 2개의 table을 join 하고자 하는 경우 Functions.cs에서 categories와 products를 select 하고 이들을 join 하여 출력하는 method를 아래와 같이 추가할 수 있습니다.
static void JoinCategoriesAndProducts()
{
using (Northwind db = new())
{
//Products를 Categories와 join함
var queryJoin = db.Categories.Join(
inner: db.Products,
outerKeySelector: category => category.CategoryId,
innerKeySelector: product => product.CategoryId,
resultSelector: (c, p) => new { c.CategoryName, p.ProductName, p.ProductId }
);
foreach (var item in queryJoin)
{
Console.WriteLine("{0}: {1} is in {2}.", arg0: item.ProductId, arg1: item.ProductName, arg2: item.CategoryName);
}
}
}
Join에서는 outer와 inner라는 2개의 sequence가 존재합니다. 위의 예제에서 categories는 outer sequence가 되고 products는 inner sequence가 됩니다.
Program.cs에서는 JoinCategoriesAndProducts method를 호출하도록 한 뒤 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
query의 끝에서 CategoryName에 대한 정렬을 위해 OrderBy method를 아래와 같이 호출할 수 있습니다.
var queryJoin = db.Categories.Join(
inner: db.Products,
outerKeySelector: category => category.CategoryId,
innerKeySelector: product => product.CategoryId,
resultSelector: (c, p) => new { c.CategoryName, p.ProductName, p.ProductId }
).OrderBy(cp => cp.CategoryName);
결과를 실행해 보면 처음에는 Beverages category에 대한 모든 product들이 나오고 그다음 Condiments category에 대한 것들 등.. 의 순서로 결과가 표시됨을 알 수 있습니다.
● Sequence group-Joining
위에서 join 하기에 사용했던 Categories와 Products 이 2개의 table에 대해 group-joining을 해봄으로써 약간의 차이를 비교해 볼 수 있습니다.
Functions.cs에서 group과 join을 수행하고 group name과 각 group에서의 모든 item을 표시하는 method를 아래와 같이 추가합니다.
static void GroupJoinCategoriesAndProducts()
{
using (Northwind db = new())
{
var queryGroup = db.Categories.AsEnumerable().GroupJoin(
inner: db.Products,
outerKeySelector: category => category.CategoryId,
innerKeySelector: product => product.CategoryId,
resultSelector: (c, matchingProducts) => new
{
c.CategoryName,
Products = matchingProducts.OrderBy(p => p.ProductName)
});
foreach (var category in queryGroup)
{
Console.WriteLine("{0} has {1} products.", arg0: category.CategoryName, arg1: category.Products.Count());
foreach (var product in category.Products)
{
Console.WriteLine($" {product.ProductName}");
}
}
}
}
만약 예제에서처럼 AsEnumerable method를 호출하지 않는다면 다음과 같은 runtime 예외가 발생할 것입니다.
이는 모든 LINQ 확장 method가 표현식 tree로부터 SQL과 같은 일부 다른 query문으로 변환되지 않기 때문입니다. 이 경우 예제는 application으로 data를 가져오기 위한 LINQ to EF Core사용을 위해 query처리를 강제하고 memory에서 더 복잡한 처리를 실행하기 위한 LINQ to Objects를 사용하는 AsEnumerable method를 호출함으로써 IQueryable<T>에서 IEnumerable<T>로 변환하고 있습니다. 하지만 대게 이러한 처리는 효율성면에서 더 떨어질 수 있습니다.
Program.cs에서 GroupJoinCategoriesAndProducts method를 호출하도록 한 뒤 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
결과를 보면 각 category에 포함된 product들이 query에서 정의된 것처럼 이름순으로 정렬되어 있음을 알 수 있습니다.
(5) Sequences 집계하기
LINQ 확장 method 중에는 집계기능을 수행하는 Average나 Sum과 같은 것들이 존재합니다. 아래 예제를 통해 이들 method를 사용하여 Products에 대한 집계정보를 확인하는 방법을 알 수 있습니다.
Functions.cs에서 다음과 같이 다양한 집계 확장 method를 사용하는 method를 추가합니다.
static void AggregateProducts()
{
using (Northwind db = new())
{
if (db.Products.TryGetNonEnumeratedCount(out int countDbSet))
{
Console.WriteLine("{0,-25} {1,10}", arg0: "Product count from DbSet:", arg1: countDbSet);
}
else
{
Console.WriteLine("Products DbSet does not have a Count property.");
}
List<Product> products = db.Products.ToList();
if (products.TryGetNonEnumeratedCount(out int countList))
{
Console.WriteLine("{0,-25} {1,10}", arg0: "Product count from list:", arg1: countList);
}
else
{
Console.WriteLine("Products list does not have a Count property.");
}
//aggregation extension methods
Console.WriteLine("{0,-25} {1,10}", arg0: "Product count:", arg1: db.Products.Count());
Console.WriteLine("{0,-27} {1,8}", arg0: "Discontinued product count:", arg1: db.Products.Count(product => product.Discontinued));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", arg0: "Highest product price:", arg1: db.Products.Max(p => p.UnitPrice));
Console.WriteLine("{0,-25} {1,10:N0}", arg0: "Sum of units in stock:", arg1: db.Products.Sum(p => p.UnitsInStock));
Console.WriteLine("{0,-25} {1,10:N0}", arg0: "Sum of units on order:", arg1: db.Products.Sum(p => p.UnitsOnOrder));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", arg0: "Average unit price:", arg1: db.Products.Average(p => p.UnitPrice));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", arg0: "Value of units in stock:", arg1: db.Products.Sum(p => p.UnitPrice * p.UnitsInStock));
}
}
Count를 확인하는 것은 단순한 동작처럼 보이지만 처리비용이 많이 들 수 있습니다. Products와 같은 DbSet<T>는 Count 속성을 가지고 있지 않으므로 TryGetNonEnumeratedCount는 false를 반환합니다. 하지만 products 같은 List<T>는 ICollection을 구현하므로 Count속성을 가지고 있고 따라서 TryGetNonEnumeratedCount는 true를 반환합니다. (이 경우 그 자체로 비용이 많이 드는 list를 instance화 해야 했지만 이미 list가 있고 item의 수를 알아야 한다면 이것이 더 효율적일 수 있습니다.) DbSet<T>에서 언제나 Count() 호출할 수 있지만 이는 sequence를 열거해야 하므로 느릴 수 있으며 lambda 식을 Count()에 전달하여 sequence의 어떤 item이 count 되어야 하는지 filter 할 수 있습니다.
Program.cs에서 AggregateProducts method를 호출하도록 하고 예제를 실행하면 다음과 같은 결과를 볼 수 있습니다.
(6) Count에서의 주의점
Amichai Mantinband은 Microsoft의 software engineer로서 C#과 .NET 개발자 stack의 흥미로운 부분에 대한 주목할만한 일을 하고 있습니다.
최근 그는 Twitter, LinkedIn, YouTube에 개발자들이 이 code가 무엇을 할 것이라고 생각하는지를 알아보기 위한 설문과 함께 code teaser를 post 했습니다.
그의 code는 이렇습니다.
IEnumerable<Task> tasks = Enumerable.Range(0, 2).Select(_ => Task.Run(() => Console.WriteLine("*")));
await Task.WhenAll(tasks);
Console.WriteLine($"{tasks.Count()} stars!");
위 code의 결과는 무엇일까요?
- **2 stars!
- **2 stars!**
- ****2 stars!
- 이외 다른 것
그런데 설문결과 대부분 틀린 것을 골랐습니다. 여기서 말하고자 하는 핵심이 바로 이것이며 위와 같은 까다로운 질문에서 task와 함께 multi-threadiing의 세부적인 부분 까지는 아니더라도 LINQ부분을 살펴볼 수 있습니다. 위 code에서는 LINQ부분을 이해하기 위해 아래와 같이 code를 분리해 보겠습니다.
Enumerable.Range(0, 2) | 0과 1의 두 정수에 대한 sequence를 반환합니다. code를 좀더 명확히 하기위해 이 부분에서 명명된 매개변수를 아래와 같이 추가할 수도 있을 것입니다. Enumerable.Range(start: 0, count: 2) |
Select(_ => Task.Run(...) | 두 숫자 각각에서 자체 thread를 가진 task를 생성합니다. 이때 _ 매개변수는 숫자값을 버리게 되며 각각의 task는 console로 *을 표시하게 됩니다. |
await Task.WhenAll(tasks); | 두 task가 완료될때까지 main thread를 block하게 되며 2개의 *문자가 console에 표시됩니다. |
tasks.Count() | 해당 scenario에서 LINQ Count() method가 작동하려면 sequence를 열거해야 하며 이렇게 하면 2개의 task를 다시 실행시킬 수 있습니다. 다만 이 2개의 task가 실행될 시점을 알 수 없고 2의 값이 호출한 method로 부터 반환됩니다. |
Console.WriteLine( $"... stars!"); | '2 stars!'가 console에 출력됩니다. |
따라서 우리는 **이 console에 먼저 출력되고 그다음 하나 또는 2개의 task가 *을 출력하고 그 뒤 '2 stars!'가 출력된다는 것을 알 수 있습니다. 마지막으로 하나 또는 2개의 task가 이전에 수행할 시간이 없거나 main thread가 종료되어 task가 *을 출력하기 전에 console app을 종료할 수 있는 경우 *을 출력할 수 있으므로 결과를 다음과 같을 것입니다.
**[각 task는 여기에 *을 출력할 수도 있음]2 starts![각 task는 여기에 *을 출력할 수 있음] |
따라서 Amichai의 teaser에 대한 가장 정확한 답은 'Something else'일 것입니다.
반환값을 계산하기 위해 sequence전체를 열거해야 하는 Count 등의 LINQ 확장 method를 호출할 때는 주의가 필요합니다. Task와 같이 실행가능한 개체의 sequence를 사용하지 않는다 하더라도 sequence의 재열 거는 비효율적일 수 있습니다.
(7) LINQ를 사용한 paging
Skip과 Take 확장 method를 사용하면 paging을 구현할 수 있습니다.
Functions.cs에서 아래와 같이 array로 전달한 products의 table을 console에 출력하는 method를 추가합니다.
static void OutputTableOfProducts(Product[] products, int currentPage, int totalPages)
{
string line = new('-', count: 73);
string lineHalf = new('-', count: 30);
Console.WriteLine(line);
Console.WriteLine("{0,4} {1,-40} {2,12} {3,-15}", "ID", "Product Name", "Unit Price", "Discontinued");
Console.WriteLine(line);
foreach (Product p in products)
{
Console.WriteLine("{0,4} {1,-40} {2,12:C} {3,-15}", p.ProductId, p.ProductName, p.UnitPrice, p.Discontinued);
}
Console.WriteLine("{0} Page {1} of {2} {3}", lineHalf, currentPage + 1, totalPages + 1, lineHalf);
}
일반적인 programming세계에서 순서는 0부터 시작합니다. 따라서 currentPage count와 totalPages count에 대한 값을 사용자에게 표시하기 전 1을 더해줄 필요가 있습니다.
계속해서 products에 대한 page를 만드는 LINQ query를 생성하고 여기서 만들어진 SQL을 출력한 뒤 products의 table을 표시하는 method에서 products의 array로 해당 결과를 전달하는 method를 아래와 같이 추가합니다.
static void OutputPageOfProducts(IQueryable<Product> products, int pageSize, int currentPage, int totalPages)
{
// skip과 take전에 data를 정렬하여 각 page에서 data가 임의의 순서로 나오지 않도록 해야 합니다.
var pagingQuery = products.OrderBy(p => p.ProductId).Skip(currentPage * pageSize).Take(pageSize);
OutputTableOfProducts(pagingQuery.ToArray(), currentPage, totalPages);
}
마지막으로 사용자가 왼쪽(left) 혹은 오른쪽(right) 화살표를 눌러 database의 products를 한 번에 한 page씩 표시하도록 순환하는 method를 같이 추가합니다.
static void PagingProducts()
{
using (Northwind db = new())
{
int pageSize = 10;
int currentPage = 0;
int productCount = db.Products.Count();
int totalPages = productCount / pageSize;
while (true)
{
OutputPageOfProducts(db.Products, pageSize, currentPage, totalPages);
Console.Write("Press <- to page back, press -> to page forward, any key to exit.");
ConsoleKey key = Console.ReadKey().Key;
if (key == ConsoleKey.LeftArrow)
if (currentPage == 0)
currentPage = totalPages;
else
currentPage--;
else if (key == ConsoleKey.RightArrow)
if (currentPage == totalPages)
currentPage = 0;
else
currentPage++;
else
break; // loop 탈출
Console.WriteLine();
}
}
}
Program.cs에서는 PagingProducts method를 호출하도록 하고 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
위 예제의 결과에서 ORDER BY, LIMIT, OFFSET 등을 사용해 products의 page를 효휼적으로 가져오기 위해 사용된 SQL문의 표시는 제외하였습니다.
왼쪽, 오른쪽 key를 누르면 page를 전환할 수 있고 그 외 다른 key를 누르면 program을 빠져나오게 됩니다.
Paging을 위해 Skip과 Take를 호출하는 경우라면 그전에 data를 정렬해야 합니다. 이는 query가 실행될 때마다 ProductId순이나 UnitPrice순 혹은 심지어 random처럼 특정 기준으로의 정렬이 요청될 수 있으므로 LINQ 공급자가 같은 순서의 data가 반환된다는 것을 보증하지 않기 때문입니다. 관계형 database에서 대게는 primary key상의 index순으로 사용하는 경우가 많습니다.
4. LINQ 구문의 향상기법
C# 3.0에서는 2008년에 SQL경험을 가진 개발자가 LINQ query를 더 쉽게 만들기 위한 몇 가지 새로운 언어 keyword를 도입하였으며 이를 LINQ query 이해(comprehension) 구문이라고 합니다.
예를 들어 다음과 같은 문자열형식의 array가 존재할 때
string[] names = new[] { "이순신", "이도", "신사임당", "홍길동", "김종서", "박혁거세", "김좌진", "윤봉길" };
이름별로 filter 하고 정렬하기 위해 아래와 같이 확장 method와 lambda 식을 사용할 수 있습니다.
var query = names
.Where(name => name.Length > 3)
.OrderBy(name => name.Length)
.ThenBy(name => name);
또는 같은 결과를 만들어내기 위해 아래와 같이 query 이해 문을 사용할 수도 있습니다.
var query = from name in names
where name.Length > 3
orderby name.Length, name
select name;
이러한 방식이 작동할 수 있는 이유는 compiler가 query 이해 문을 이전예제와 동일하게 확장 method와 lambda 식으로 변환하기 때문입니다.
select keyword는 LINQ query 이해 구문에서 반드시 필요한 존재합니다. 하지만 Select 확장 method는 확장 method와 lambda 식을 사용할 때 Select를 호출하지 않으면 item전체가 암시적으로 선택되므로 선택적으로 사용할 수 있습니다.
모든 확장 method가 C# keyword와 동일하게 연결되지는 않습니다. 예를 들어 Skip과 Take 확장 method는 일반적으로 많은 data에서 paging을 구현하는 데 사용되곤 합니다. Query comprehense만으로 작성될 수 없는 skip과 take query는 모두 확장 method를 사용해 다음과 같이 작성할 수 있습니다.
var query = names
.Where(name => name.Length > 3)
.Skip(80)
.Take(10);
또는 괄호를 통해 query 이해 문을 감싸고 난 후 이를 확장 method로 연결하여 사용할 수도 있습니다.
var query = (from name in names
where name.Length > 4
select name)
.Skip(80)
.Take(10);
5. 사용자 LINQ 확장 method 정의하기
아래 글을 통해서 우리는 이미 자신만의 확장 method를 생성하는 방법을 알아보았습니다.
[.NET/C#] - [C# 11 과 .NET 7] 6. Interface와 Class상속
LINQ 확장 method를 만들기 위해 해야 할 것은 IEnumerable<T> type을 확장하는 것이 전부입니다.
직접 정의한 method는 별도의 class file을 만들어 분리해 두면 자체 assembly나 NuGet package 등으로 배포하기 수훨해 질 수 있습니다.
예를 들어 우리는 평균을 아래 3가지 중 하나가 될 수 있음을 말할 수 있는데
- Mean : 수를 합산하고 건수로 나눈 것
- Mode : 가장 일반적인 숫자
- Median : 정렬되고 난 후의 숫자 중에서 중간값
Microsoft는 Average 확장 method를 mean으로 계산되도록 구현하였으므로 여기서 Average method를 위에서 언급한 Mode와 Median계산이 가능하도록 해야 한다면 다음과 같은 방법으로 method를 정의할 수 있습니다.
Project에 MyLinqExtensions.cs file을 아래와 같이 추가합니다.
namespace LinqWithEFCore
{
public static class MyLinqExtensions
{
// chain LINQ extension method
public static IEnumerable<T> ProcessSequence<T>(this IEnumerable<T> sequence)
{
// you could do some processing here
return sequence;
}
public static IQueryable<T> ProcessSequence<T>(this IQueryable<T> sequence)
{
// you could do some processing here
return sequence;
}
// scalar LINQ extension methods
public static int? Median(this IEnumerable<int?> sequence)
{
var ordered = sequence.OrderBy(item => item);
int middlePosition = ordered.Count() / 2;
return ordered.ElementAt(middlePosition);
}
public static int? Median<T>(this IEnumerable<T> sequence, Func<T, int?> selector)
{
return sequence.Select(selector).Median();
}
public static decimal? Median(this IEnumerable<decimal?> sequence)
{
var ordered = sequence.OrderBy(item => item);
int middlePosition = ordered.Count() / 2;
return ordered.ElementAt(middlePosition);
}
public static decimal? Median<T>(this IEnumerable<T> sequence, Func<T, decimal?> selector)
{
return sequence.Select(selector).Median();
}
public static int? Mode(this IEnumerable<int?> sequence)
{
var grouped = sequence.GroupBy(item => item);
var orderedGroups = grouped.OrderByDescending(group => group.Count());
return orderedGroups.FirstOrDefault()?.Key;
}
public static int? Mode<T>(this IEnumerable<T> sequence, Func<T, int?> selector)
{
return sequence.Select(selector)?.Mode();
}
public static decimal? Mode(this IEnumerable<decimal?> sequence)
{
var grouped = sequence.GroupBy(item => item);
var orderedGroups = grouped.OrderByDescending(group => group.Count());
return orderedGroups.FirstOrDefault()?.Key;
}
public static decimal? Mode<T>(this IEnumerable<T> sequence, Func<T, decimal?> selector)
{
return sequence.Select(selector).Mode();
}
}
}
Class가 별도의 class library로 분리되어 있다면 LINQ 확장 method를 사용하기 위해서는 그저 class library assembly를 참조하기만 하면 됩니다. 참고로 System.Linq namespace는 이미 암시적으로 import 되는 것이 기본이기 때문에 대부분 별다른 추가작업이 필요 없지만 그렇지 않다면 System.Linq도 import 되어야 합니다.
상기 예제의 모든 method 중 하나를 제외하고 그 외 다른 method는 SQL과 같은 query언어로 변환하는 것까지 구현하지 않았기 때문에 LINQ to SQLite 또는 LINQ to SQL Server에서 사용된 것과 같은 IQueryable sequence와 함께 사용할 수 없습니다.
(1) Chainable extension method
상기 예제에서 처음 2개의 method는 ProcessSequence method와의 chaining이 가능한 method입니다.
Functions.cs의 FilterAndSort method에서 Products에 대한 LINQ query를 아래와 같이 변경하여 예제의 확장 method를 호출하도록 합니다.
DbSet<Product> allProducts = db.Products;
IQueryable<Product> processedProducts = allProducts.ProcessSequence();
IQueryable<Product> filteredProducts = processedProducts.Where(product => product.UnitPrice < 10M);
Program.cs에서는 FilterAndSort method를 호출하도록 하고 예제를 실행하면 이전과 같은 결과를 볼 수 있을 것입니다. 여기서는 예제의 ProcessSequence method가 실제 sequence를 변경하는 것은 아니지만 이러한 구현을 통해 LINQ표현식을 위 method처럼 확장할 수 있다는 것을 알 수 있습니다.
● Mode와 median method 구현
다른 종류의 평균을 계산하기 위한 Mode and Median method를 사용하기 위해 Functions.cs에서 products의 UnitPrice와 UnitsInStock에 대한 mean, median, mode를 이전 예제에서 구현한 확장 method와 기본 내장 Average extension method를 사용해 출력하는 method를 아래와 같이 추가합니다.
static void CustomExtensionMethods()
{
using (Northwind db = new())
{
Console.WriteLine("{0,-25} {1,10:N0}", "Mean units in stock:", db.Products.Average(p => p.UnitsInStock));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", "Mean unit price:", db.Products.Average(p => p.UnitPrice));
Console.WriteLine("{0,-25} {1,10:N0}", "Median units in stock:", db.Products.Median(p => p.UnitsInStock));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", "Median unit price:", db.Products.Median(p => p.UnitPrice));
Console.WriteLine("{0,-25} {1,10:N0}", "Mode units in stock:", db.Products.Mode(p => p.UnitsInStock));
Console.WriteLine("{0,-25} {1,10:$#,##0.00}", "Mode unit price:", db.Products.Mode(p => p.UnitPrice));
}
}
Program.cs에서는 CustomExtensionMethods를 호출하도록 하고 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
참고로 $18.00인 products는 4개이고 재고가 0개인 products는 5개입니다.
6. LINQ to XML
LINQ to XML은 query에 대한 결과를 XML로 가져올 수 있도록 하는 LINQ 공급자입니다.
(1) LINQ to XML을 사용한 XML 생성
예를 들어 Products table을 XML로 변환하고자 한다면 products를 XML형식으로 출력하는 method를 다음과 같이 추가할 수 있습니다.
static void OutputProductsAsXml()
{
using (Northwind db = new())
{
Product[] productsArray = db.Products.ToArray();
XElement xml = new("products",
from p in productsArray
select new XElement("product", new XAttribute("id", p.ProductId), new XAttribute("price", p.UnitPrice), new XElement("name", p.ProductName)));
Console.WriteLine(xml.ToString());
}
}
Program.cs에서 OutputProductsAsXml method를 호출하도록 한 뒤 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
생성된 XML의 구조는 XML 요소와 위 예제에서 선언적으로 서술된 LINQ to XML문의 attribute와 일치합니다.
(2) LINQ to XML을 사용한 XML parsing
LINQ to XML을 사용하면 XML file을 쉽게 질의하거나 처리할 수 있습니다.
Project에 settings.xml이름의 XML file을 아래와 같이 추가합니다.
<?xml version="1.0" encoding="utf-8" ?>
<memberSettings>
<add key="Name" value="hong" />
<add key="Age" value="30" />
<add key="WorkGroup" value="IT" />
</memberSettings>
Visual Studio 2022는 compile 된 application이 bin\Debug\net7.0 folder에서 실행되므로 추가한 XML을 해당 folder로 복사되도록 해야 합니다. 따라서 XML file을 선택하고 'Copy to Output Directory'의 속성을 'Copy always'로 변경해야 합니다.
Functions.cs에서는 아래와 같은 method를 추가합니다.
static void ProcessSettings()
{
string path = Path.Combine(Environment.CurrentDirectory, "settings.xml");
Console.WriteLine($"Settings file path: {path}");
XDocument doc = XDocument.Load(path);
var memberSettings = doc.Descendants("memberSettings").Descendants("add")
.Select(node => new
{
Key = node.Attribute("key")?.Value,
Value = node.Attribute("value")?.Value
}).ToArray();
foreach (var item in memberSettings)
{
Console.WriteLine($"{item.Key}: {item.Value}");
}
}
위 method는 XML file을 load 하여 LINQ to XML을 사용해 memberSettings이름의 요소와 하위 add요소를 찾습니다. 그런 다음 XML을 Key와 Value 속성을 가진 익명 type의 array로 구체화한뒤 이를 열거하도록 합니다.
Program.cs에서 ProcessSettings method를 호출하고 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
'.NET > C#' 카테고리의 다른 글
[C# 12와 .NET 8] 3. 흐름제어, Type 변환, 예외 처리 (0) | 2024.01.17 |
---|---|
[C# 12와 .NET 8] 2. C# (0) | 2024.01.12 |
[C# 11 과 .NET 7] 10. Entity Framework Core (0) | 2023.08.11 |
[C# 11 과 .NET 7] 9. File, Streams, Serialization (0) | 2023.07.27 |
[C# 11 과 .NET 7] 8. 공용 .NET Type (0) | 2023.07.16 |