[C#] LINQ(Language INtegrated Query) - 1. 기본 표현식
LINQ(Language INtegrated Query)는 일련의 배열을 정렬하거나 필터링하는 확장 언어입니다.
1. LINQ 표현식
LINQ는 다음과 같은 부분으로 구성됩니다.
- Extension methods : Where나 OrderBy, Select와 같은 메서드를 포함하는데 LINQ에 대한 기능적인 부분을 제공합니다.
- LINQ providers : 메모리에서 객체를 다루기 위한 LINQ to Objects, 외부 데이터베이스에 데이터를 저장을 처리하기 위한 LINQ to Entities, XML을 다루기 위한 LINQ to XML 등을 의미하며 이들 공급자(providers)는 LINQ 표현식을 각 데이터에 맞는 특정한 방법으로 질의할 수 있도록 합니다.
- Lambda expressions : 메서드 대신 LINQ 질의 방식을 통해 데이터를 질의하는 것이며 필요에 따라 선택적으로 사용됩니다.
- LINQ query comprehension syntax : from, in, where, orderby, descending, select와 같은 C#키워드 사용을 의미합니다. LINQ 확장 메서드의 별칭이며 SQL(Structured Query Language)과 비슷한 방법으로 질의문을 작성할 수 있습니다.
LINQ를 통해 질의문을 작성하는 것과 LINQ 표현식이 같은 것이라고 오해할 수 있으나 LINQ query는 LINQ의 여러 부분 중 하나에 해당할 뿐입니다.
(1) Enumerable 클래스를 통한 LINQ 표현식의 이해
Where나 Select와 같은 LINQ 확장 메서드는 IEnumerable <T> 인터페이스를 구현하는 Enumerable 정적 클래스에 의해 추가되었습니다. IEnumerable<T>에서 T는 배열의 Type인데 이는 모든 배열이 LINQ를 통한 질의과 데이터 변경 작업을 지원한다는 의미이기도 합니다. 또한 List<T>, Dictionary<TKey, TValue>, Stack<T>, Queue<T>와 같은 제네릭 컬렉션도 IEnumerable<T>를 구현하고 있으므로 이들에게도 동일하게 LINQ를 적용할 수 있습니다.
Enumerable은 다음과 같이 대략 50개 이상의 확장 메서드를 정의하고 있습니다.
확장메서드 | 기능 |
First, FirstOrDefault, Last, LastOrDefault | 배열에서 첫번째 혹은 마지막 항목을 가져옵니다. 이때 항목이 존재하지 않으면 예외를 발생시키게 되는데 -Default메서드를 사용한 경우라면 예외대신 Type의 기본값을 반환하게 됩니다. Type의 기본값이라면 int의 경우 0을 반환하거나 참조타입인 경우 null반환하는 식입니다. |
Where | 지정한 필터와 일치하는 배열의 항목을 반환합니다. |
Single, SingleOrDefault | 지정한 필터와 일치하는 배열의 항목을 가져오거나 예외를 발생시킵니다. 단, -default메서드가 사용된 경우 Type의 기본값을 가져오는데 Item이 하나도 없거나 하나 이상인 경우에만 해당합니다. 해당 메서드는 반드시 단 하나의 Item만 반환되어야 합니다. |
ElementAt, ElementAtOrDefault | 지정한 Index에 해당하는 항목을 가져오거나 지정한 Index의 항목이 존재하지 않으면 예외를 발생시킵니다. 단, -Default메서드가 사용된 경우 가져올 항목이 없을때 Type의 기본값을 반환합니다. .NET 6에서는 Index를 지정할때 int형식대신 Index형식을 지정하도록 되어 있으며 이는 Span<T>를 통해 작업시 더 높은 효휼성을 제공할 수 있습니다. |
Select, SelectMany | 다른 Type으로 항목을 출력하도록 합니다. 이때 중첩된 항목을 평탄화시킬 수 있습니다. |
OrderBy, OrderByDescending, ThenBy, ThenByDescending | 지정한 필터, 혹은 속성으로 항목을 정렬합니다. |
Reverse | 항목의 순서를 뒤집습니다. |
GroupBy, GroupJoin, Join | 2개의 배열을 그룹화하거나 join합니다. |
Skip, SkipWhile | 지정한 수 만큼 또는 사용한 표현식이 true값을 가지는 동안의 항목을 스킵합니다. |
Take, TakeWhile | 지정한 수 만큼 또는 사용한 표현식이 true값을 가지는 동안의 항목을 가져옵니다. .NET 6에서 Take는 Range를 사용해 오버로드할 수 있으므로 Take(range: 3..^5)처럼 3개 항목부터 시작해서 5개 항목까지의 부분집합을 가져오거나 Take(4)대신 Skip(4)를 사용할 수도 있습니다. |
Aggregate, Average, Count, LongCount, Max, Min, Sum | 총계값을 계산합니다. |
TryGetNonEnumeratedCount | 배열에서 Count속성이 구현되었는지를 확인하고 해당 값을 반환하거나 Count하기위한 주요항목을 열거합니다. .NET 6에서는 배열에서 단지 Count여부만 확인하고 Count가 존재하지 않음을 뜻하는 false가 반환되는 경우라면 잠재적인 성능저하를 회피하기 위해 out 매개변수에 0값을 설정합니다. |
All, Any, Contains | 지정한 필터와 일치하는 항목이 존재하는 경우 true를 반환합니다. |
Cast | 아이템을 특정한 Type으로 형변환을 시도합니다. |
OfType | 지정한 Type과 일치하지 않는 Type의 항목을 제거합니다. |
Distinct | 중복항목을 제거합니다. |
Except, Intersect, Union | 집합을 반환하는 동작을 수행합니다. 비록 모든 유형의 배열이 동작대상이 될 수 있고 중복항목을 가질 수 있지만 결과는 중복항목을 가질 수 없습니다. |
Chunk | 배열을 배치크기로 나눕니다. |
Append, Concat, Prepend | 배열을 결합합니다. |
Zip | 항목의 위치를 기반으로 하여 2개의 배열을 비교합니다. 예를 들어 첫번째 배열의 첫번째 항목이 두번째 배열을 첫번째 항목과 같은지를 비교하는 것입니다. .NET 6에서는 3개의 배열을 비교하여 이전에 같은 목적을 달성하기 위해 이중으로 실행해야 했던 비효휼성을 개선하였습니다. |
ToArray, ToList, ToDictionary, ToHashSet, ToLookup | 다른 컬렉션이나 배열로 항목을 반환합니다. 이들은 LINQ식을 실행하는 유일한 확장메서드입니다. |
DistinctBy, ExceptBy, IntersectBy, UnionBy, MinBy, MaxBy | .NET 6에서 새롭게 추가된 확장메서드입니다. 항목전체보다는 항목의 세부속성을 통해 비교를 수행하는 메서드인데 예를 들어 Person객체의 주요항목을 비교해 중복되는 항목을 제거하는 대신 LastName이나 DateOfBirth와 같은 값만을 비교해 중복을 제거할 수 있습니다. |
아래 메서드는 확장 메서드가 아닌 Enumerable클래스의 고유한 메서드입니다.
메서드 | 기능 |
Empty<T> | 지정한 Type T의 빈배열을 반환합니다. 이 메서드는 IEnumerable<T>의 형식을 요구하는 메서드에 임의의 배열을 전달하는데 유용하게 사용될 수 있습니다. |
Range | start부터 시작해 Count만큼의 항목을 반환합니다. 예를 들어 Range(start: 2, count: 3)이라 함은 2번째 항목부터 시작해 3개의 항목인 2, 3, 4번째 항목을 반환합니다. |
Repeat | count만큼 반복된 같은 element를 포함하는 배열을 반환합니다. 예를 들어 Repeat(element: "2", count: 3)이라 함은 "2", "2", "2"라는 문자열을 의미합니다. |
● 지연된 실행
LINQ는 지연된 실행을 사용합니다. 이 말은 대부분의 확장 메서드는 메서드가 호출되었다고 해서 결괏값을 얻기 위해 그 즉시 질의를 실행하지 않는다는 걸 의미합니다. 확장 메서드는 LINQ 표현식을 반환하며 이는 질의 자체를 나타내는 것일 뿐 결과가 아닙니다.
string[] students = new[] { "홍길동", "홍길순", "홍길영", "홍길만", "홍길석" };
var query1 = students.Where(x => x.Contains("길만"));
var query2 = from student in students where student.Contains("길만") select student;
위 예제에서 query1은 확장 메서드를 통해 학생 이름 중에서 '길만'이라는 이름이 포함된 학생이 누구인지를 질의하는 구문이 지정되었으며 query2는 query1와 같은 결과를 내지만 LINQ 확장 메서드가 아닌 LINQ 질의 구문을 작성하여 표현한 것입니다.
여기서 질의라고 표현한 이유는 각 구문이 즉시 결과를 가져오는 것이 아닌 그저 질의할 내용만을 생성하기 때문입니다. 이렇게 생성된 질의를 실제 실행하려면 To로 시작하는 ToArray 나 ToLookup과 같은 메서드를 호출하거나 질의를 명시적으로 열거해야 합니다.
foreach(var item in query1.ToList())
{
Console.WriteLine(item);
}
students[3] = "홍길병";
foreach(var item in query2)
{
Console.WriteLine(item);
}
//홍길만
지연된 실행으로 인해 query2에서는 어떠한 값도 나오지 않게 됩니다. 처음 query2가 선언되었을 때 값을 가져오는 경우라면 이미 '홍길만'이라는 이름이 query2를 열거할 때 나와야 하겠지만 query2가 선언될 때는 아직 값을 가져오는 게 아닌 질의 자체를 저장하고 있는 것이고 때문에 query2를 열거할 때야 비로소 질의가 실행됨으로서 '홍길병'으로 변경된 배열이 적용되는 것입니다.
(2) Where를 통한 Entity 필터링
LINQ를 사용하는 가장 일반적인 이유는 배열에서 item을 필터링할 수 있다는 것입니다. 실제 배열에서 Where메서드를 호출하는 시도를 할때 만약 자동완성(ntelliSense)을 통해 Where메서드가 제대로 표시되지 않으면 System.Linq Namespace를 선언하거나 프로젝트 파일(csproj)에서 아래와 같이 ImplicitUsings 엘리먼트가 enable 되어 있는지를 확인합니다.
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
Where는 기존 배열에는 존재하지 않는 메서드로서 System.Linq의 확장 메서드이며 따라서 해당 Namespace가 필요합니다. 다만 .NET 6에서는 암시적으로 import된 기본 네임스페이스에 해당하기에 위에서 처럼 ImplicitUsings이 enable로 설정되어 있다면 명시적인 System.Linq의 Namespace선언을 필요로 하지 않게 됩니다.
Where메서드는 필터링을 실행하기 위해 델리게이트의 인스턴스를 필요로 합니다. 따라서 위 예제에서 사용된 students배열에서 Where메서드를 적용하려면 다음과 같이 Func<string, bool> 델리게이트의 인스턴스를 전달할 수 있습니다.
string[] students = new[] { "홍길동", "홍길순", "홍길영", "홍길만", "홍길석" };
var query = students.Where(new Func<string, bool>());
Func<string, bool>델리게이트는 배열의 각 string값이 메서드로 전달되며 그 결과로 bool형식의 값을 반환한다는 것을 알려주고 있습니다. 따라서 만약 메서드가 true를 반환한다면 결과에 해당 string값을 포함시킬 것이며 false를 반환한다면 해당 string은 결과에서 제외할 것입니다.
위와 같이 델리게이트의 인스턴스를 지정하고 나면 다음으로 델리게이트에 전달할 동작 메서드를 아래와 같이 정의할 수 있습니다.
static bool FindName(string name)
{
return name == "홍길동";
}
예제에서 FindName메서드는 매개변수로 전달된 string값이 '홍길동'인 경우 true를 그렇지 않으면 false를 반환하는데 이 메서드의 이름을 위에서 Where메서드에 지정한 델리게이트에 전달합니다.
var query = students.Where(new Func<string, bool>(FindName));
static bool FindName(string name)
{
return name == "홍길동";
}
그리고 query를 통해 배열 순회를 시도하면 FindName을 통해 필터링된 결과를 확인할 수 있습니다.
foreach(var item in query)
{
Console.WriteLine(item);
}
//홍길동
(3) 델리게이트의 인스턴스 제거
앞서 Where에서는 Func<string, bool>의 인스턴스를 전달했는데 C# 컴파일러는 델리게이트의 인스턴스를 알아서 생성해 줄 수 있으므로 좀 더 코드를 간소화하기 위해 Func<string, bool>을 삭제하고 메서드 이름만 전달해 줄 수 있습니다.
var query = students.Where(FindName);
(4) 람다식 사용
Where에서 메서드의 이름을 전달하여 메서드 자체를 지정해 줄 수 있다면 이를 좀 더 간소화하여 람다식을 통해 메서드를 지정해 주는 것도 가능합니다. 람다식은 이름이 없는 메서드를 구현하는 것인데 => 기호 문자를 통해 메서드의 실체를 구현하고 반환 값을 지정합니다.
var query = students.Where(x => x == "홍길동");
예제에서는 x를 통해 배열의 각 항목을 전달받도록 하고 있습니다. 여기서 x가 입력 메 개변수에 해당하며 => 기호 문자를 통해 실행될 메서드의 본체를 구현하고 있습니다. 메서드는 전달된 항목의 x값이 '홍길동'인지를 판단하고 그에 따른 bool값을 반환합니다.
입력 매개변수 x(이름은 y나 name과 같이 임의로 지정해 줄 수 있습니다.)의 type이 string형식인지는 배열에 존재하는 각 항목의 type에 따라 유추됩니다. 그리고 Where에서 필요로 하는 델리게이트의 형식에 따라 반환 형식은 bool이 되어야 하므로 익명 메서드에서는 반드시 bool형식의 결과를 반환해야 합니다.
(5) Entity 정렬하기
LINQ에서 배열을 정렬하기 위해 가장 많이 사용되는 메서드로는 OrderBy와 ThenBy메서드가 있습니다.
● 단일 속성을 사용한 OrderBy 정렬
확장 메서드는 이전에 실행되는 메서드가 IEnumerable인터페이스를 구현하는 열거 형식의 배열을 반환하는 경우 메서드 체인을 통해 여러 메서드를 연결할 수 있으므로 다음과 같은 구현이 가능합니다.
string[] students = new[] { "홍길동", "홍길순", "홍길영", "홍길만", "홍길석" };
var query = students
.Where(x => x == "홍길영" || x == "홍길석" )
.OrderBy(x => x);
예제는 이전 Where메서드에서 '홍길영'과 '홍길석'이라는 이름을 가져와 배열로 반환하게 되고 뒤이은 OrderBy메서드에서는 이 결과를 받아 이름을 기준으로 정렬을 수행합니다.
참고로 만약 역순의 정렬이 필요하다면 OrderByDescending메서드를 사용합니다.
● 하나 이상의 속성을 사용하기 위한 ThenBy
위 예제는 이름을 기준으로 한 정렬을 구현하였는데 때로는 2가지 이상의 속성을 사용한 구현이 필요할 수 있습니다. 예를 들어 학생의 나이를 기준으로 정렬하되 그 안에서 다시 이름으로 정렬해야 한다면 ThenBy메서드는 다음과 같이 사용될 수 있습니다.
var students = new[] {
new { Age = 25, Name = "홍길동" },
new { Age = 24, Name = "홍길석" },
new { Age = 25, Name = "홍길영" },
new { Age = 24, Name = "홍길만" },
new { Age = 24, Name = "홍길순" }
};
var query = students
.OrderBy(x => x.Age)
.ThenBy(x => x.Name);
(6) Type 필터링
문자열이나 숫자 값을 통한 필터링에는 Where확장 메서드를 사용할 수 있지만 만약 배열이 상속계층을 이루는 여러 Type을 포함하고 있는 경우 특정 Type을 통한 필터링은 OfType을 사용할 수 있습니다. 예를 아래와 같이 하나의 인터페이스를 구현하는 여러 Type을
interface Car
{
}
class Truck : Car
{
public string Name = "트럭";
}
class Sedan : Car
{
public string Name = "승용차";
}
Car형식의 List에서 담고 있을 때 Truck Type의 요소만 가져와야 한다면 OfType은 아래와 같은 방법으로 사용될 수 있습니다.
List<Car> c = new List<Car>() { new Truck(), new Sedan() };
IEnumerable<Truck> f = c.OfType<Truck>();
foreach(var item in f)
{
Console.WriteLine(item.Name);
}
(7) 집합 메서드
LINQ는 특정 배열에 대해 여러 형태로의 집합을 구현할 수 있는 메서드를 제공하고 있습니다.
string[] animal1 = new[] { "강아지", "코끼리", "강아지" };
string[] animal2 = new[] { "고양이", "강아지", "사자" };
Console.WriteLine(string.Join(", ", animal1.ToArray()));
Console.WriteLine(string.Join(", ", animal2.ToArray()));
Console.WriteLine(string.Join(", ", animal3.ToArray()));
//중복제거
Console.WriteLine(string.Join(", ", animal1.Distinct()));
//필터링을 통한 중복제거
Console.WriteLine(string.Join(", ", animal1.DistinctBy(x => x.Substring(0, 1) == "강")));
//결합 (중복제거)
Console.WriteLine(string.Join(", ", animal1.Union(animal2).ToArray()));
//결합 (중복포함)
Console.WriteLine(string.Join(", ", animal1.Concat(animal2).ToArray()));
//교집합
Console.WriteLine(string.Join(", ", animal1.Intersect(animal2).ToArray()));
//유일값
Console.WriteLine(string.Join(", ", animal1.Except(animal2).ToArray()));
//순서매칭
Console.WriteLine(string.Join(", ", animal1.Zip(animal2).ToArray()));
// 강아지, 코끼리, 강아지
// 고양이, 강아지, 사자
// 호랑이, 고양이, 표범
// 강아지, 코끼리
// 강아지, 코끼리
// 강아지, 코끼리, 고양이, 사자
// 강아지, 코끼리, 강아지, 고양이, 강아지, 사자
// 강아지
// 코끼리
// (강아지, 고양이), (코끼리, 강아지), (강아지, 사자)
예제에서 Zip은 배열의 순서상 1:1로 매칭 되는 요소를 포함하고 있는데 만약 2개의 배열에서 하나가 더 많은 요소를 가진 경우처럼 1:1로 매칭 되지 않는 요소를 제외됩니다.