공부/C#

LINQ 질의식, LINQ 지연 실행

월러비 2025. 9. 9. 18:41

LINQ 질의식

Query Expressions 개요

  • 질의식(Query Expression)은 LINQ 쿼리를 작성하는 C#의 선언적 구문이며, 가독성을 높인 구문임.
  • 데이터 컬렉션을 다루는 간결한 방식을 제공함.
  • 질의식은 컴파일러에 의해 Fluent Syntax(메서드 호출 구문)로 변환됨.
  • 예를 들어, 다음 Query Expression은:
from n in names
select n

다음과 같은 Fluent Syntax로 변환됨.

names.Select(n => n)

기본 구문

  • 모든 질의식은 from 절로 시작하고 select 나 group 절로 끝나야 함.
  • from 절은 데이터 소스를 지정하고 범위 변수를 정의함.
  • where 절은 필터링 조건을 지정함.
  • orderby 절은 정렬 기준을 지정함.
  • select 절은 결과로 반환할 데이터를 정의함.
    • from과 마지막 사이는 서로간의 순서는 중요하지 않다.
  • 예제:
using System;
using System.Collections.Generic;
using System.Linq;

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" }; // 배열 초기화

IEnumerable<string> query =
	from n in names // names 배열에서 각 요소를 n으로 가져옴
	where n.Contains("a") // n에 "a"가 포함된 요소만 필터링
	orderby n.Length // n의 길이를 기준으로 오름차순 정렬
	select n.ToUpper(); // n을 대문자로 변환하여 선택

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

// 출력:
// JAY
// MARY
// HARRY

컴파일러의 Query Expression 처리

  • 컴파일러는 Query Expression을 Fluent Syntax로 변환함.
IEnumerable<string> query = names
	.Where(n => n.Contains("a"))
	.OrderBy(n => n.Length)
	.Select(n => n.ToUpper());

  • Where, OrderBy, Select는 System.Linq 네임스페이스의 확장 메서드로 해석됨.
  • 컴파일러는 Query Expression을 Where, OrderBy, Select 등의 메서드 호출로 변환하며, 이 메서드들은 IEnumerable<T> 인터페이스를 구현하는 객체의 확장 메서드로 호출됨.

범위 변수(Range Variable)

  • from 키워드 다음의 식별자를 범위 변수라고 함.
  • 범위 변수는 입력 시퀀스의 각 요소를 순회하며 가리키는 변수임.
  • 범위 변수는 쿼리의 각 절에서 서로 다른 시퀀스를 열거함.
from n in names    // n은 원본 배열에서 직접 가져옴
where n.Contains("a") // n은 필터링되기 전 결과에서 가져옴
orderby n.Length    // n은 정렬 전 결과에서 가져옴
select n.ToUpper()    // n은 정렬된 결과에서 가져옴

  • 범위 변수는 각 람다 식에서 개별적으로 범위가 지정됨.
    • 같은 이름이지만 참조 변수 입장에서는 각자 다른 변수로 취급해야한다.
names.Where(n => n.Contains("a"))    // n은 이 람다 식에서만 유효
	.OrderBy(n => n.Length)        // n은 이 람다 식에서만 유효
	.Select(n => n.ToUpper())        // n은 이 람다 식에서만 유효

Query Syntax vs Fluent Syntax

Query Syntax 장점:

  • let 절을 사용해 새 변수를 도입할 때 더 명확함.
    • let : 쿼리 안에서 사용할 지역변수같은 느낌이다.
    • let절이 없다면 같은연산을 한 변수선언 안에 여러번 해줘야한다.
// Query Syntax
//let절이 없을경우
var query = from n in names
			where n.ToUpper().Contains("A")
			select n.ToUpper();
			
//let절 사용하는 경우
var query = from n in names
			let uppercaseName = n.ToUpper()
			where uppercaseName.Contains("A")
			select uppercaseName;

// Fluent Syntax
//주의 : n으로 계속 이름을 바꾸지 않고 사용하는 경우 -> 지역변수가 사라지지않아 오류가 날 수 있다.
var query = names.Select(n => n.ToUpper())
				 .Where(uppercaseName => uppercaseName.Contains("A"));

  • SelectMany, Join, GroupJoin 뒤에 외부 범위 변수 참조가 필요할 때 유용함.
  • 복잡한 쿼리를 더 읽기 쉽게 표현할 수 있음.

Fluent Syntax 장점:

  • 단일 연산자만 사용하는 간단한 쿼리에 더 간결함.
    • 예: names.Where(n => n.Contains("a"))
  • Query Syntax에서 지원하지 않는 연산자(Count, First, Aggregate 등)를 직접 사용할 수 있음.

Query Syntax가 지원하는 연산자:

  • Where, Select, SelectMany
  • OrderBy, ThenBy(오름차순으로 정렬 후, 추가로 적용할 오름차순 정렬을 정의함), OrderByDescending, ThenByDescending
  • GroupBy, Join, GroupJoin

혼합 구문 쿼리(Mixed-Syntax Queries)

  • Query Syntax와 Fluent Syntax를 함께 사용할 수 있음.
  • 제한사항: 각 쿼리 식은 완전해야 함(from으로 시작하고 select/group으로 끝나야 함).
  • 시퀀스를 넣어 시퀀스를 받을 수 있고, 시퀀스를 넣어 시퀀스의 시퀀스를 반환으로 받을 수 있다.
  • 예제:
string[] names = { "Tom", "Dick", "Harry", "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

// GroupBy와 Count를 함께 사용하는 예시
var groups = (from n in names
			  group n by n.Length into g
			  select new { Length = g.Key, Count = g.Count() });

  • 동일한 쿼리를 Fluent Syntax로 표현:
int matches = names.Where(n => n.Contains("a")).Count(); // 3
string first = names.OrderBy(n => n).First(); // Dick
var groups = names.GroupBy(n => n.Length)
				 .Select(g => new { Length = g.Key, Count = g.Count() });

주요 참고사항

  • Query Expression을 사용하려면 System.Linq 네임스페이스를 반드시 import 해야 함.
  • Query Expression은 컴파일러에 의해 먼저 Fluent Syntax로 변환된 후 컴파일됨.
  • Query Syntax와 Fluent Syntax 중 어느 것이 더 좋다고 단정할 수 없음. 쿼리의 복잡성, 가독성, 개인의 선호도 등을 고려하여 선택하는 것이 중요함.
  • 두 구문을 혼합해서 사용할 때 각각의 장점을 활용하면 가장 명확하고 효율적인 코드를 작성할 수 있음.

질의문

  • 정렬(정렬 기준).ThenBy(정렬 기준) : 1차 정렬로 정렬된 결과를 2차 정렬 기준에 맞춰 다시 정렬한다.
    • 질의문으로는 orderby에 ,로 정렬기준을 연결해서 작성한다.

LINQ 지연 실행

  • 따로 항목이 있다는것은 지연실행이 안되는 오퍼레이터도 있다는 의미이다.

지연 실행의 개념

  • 대부분의 쿼리 연산자는 생성될 때가 아닌 열거될 때 실행됨
  • 지연 실행은 쿼리 생성과 실행을 분리하여 유연성을 제공함
  • 이는 delegate의 동작 방식과 유사함

기본 예제

var numbers = new List<int> { 1 };
IEnumerable<int> query = numbers.Select(n => n * 10); // 쿼리 생성
numbers.Add(2);  // 추가 요소 삽입
foreach (int n in query)
    Console.Write(n + "|");  // 출력: 10|20|

  • 쿼리 생성 후 리스트에 추가된 숫자 2도 결과에 포함됨
  • 실제 필터링이나 정렬은 foreach 문이 실행될 때 발생

즉시 실행되는 연산자

다음 연산자들은 예외적으로 즉시 실행됨:

  • 단일 요소나 스칼라 값을 반환하는 연산자
    • First
    • Count
  • 변환 연산자 - 특정 컬렉션으로 반환하는 함수들이다.
    • ToArray
    • ToList
    • ToDictionary
    • ToLookup
    • ToHashSet
  • 특징 : 반환형이 IEnumerable이 아닌 연산자들이다.

예제:

int matches = numbers.Where(n => n <= 2).Count();  // 즉시 실행됨

쿼리 재평가

  • 지연 실행 쿼리는 재열거시 재평가됨
  • 이는 때로는 장점이 될 수 있고, 때로는 단점이 될 수 있음
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> timesTen = numbers
    .Select(n => n * 10)
    .ToList();  // 즉시 실행되어 List<int>에 저장

numbers.Clear();
Console.WriteLine(timesTen.Count);  // 여전히 2

캡처된 변수(Captured Variables)

  • 쿼리의 람다식이 외부 변수를 캡처하면, 쿼리는 실행 시점의 변수 값을 사용함
  • 람다식으로 캡처된 결과가 캡처되는 것이다.
int[] numbers = { 1, 2 };
int factor = 10;
IEnumerable<int> query = numbers.Select(n => n * factor);
factor = 20;
foreach (int n in query) Console.Write(n + "|");  // 출력: 20|40|

for 루프에서의 주의사항

잘못된 예:

IEnumerable<char> query = "Not what you might expect";
string vowels = "aeiou";

for (int i = 0; i < vowels.Length; i++) //5번 반복
    query = query.Where(c => c != vowels[i]); //람다식에 반복 변수를 써서 캡처가 되었다.

//반복이 5에 마지막 i++까지 들어가서 배열의 끝이 넘어가게된다.
foreach (char c in query) Console.Write(c);  // IndexOutOfRangeException 발생

올바른 예:

for (int i = 0; i < vowels.Length; i++)
{
    char vowel = vowels[i];  // 루프 내부에서 새 변수 선언
    query = query.Where(c => c != vowel);
}

또는 foreach 사용:

foreach (char vowel in vowels)
    query = query.Where(c => c != vowel);

지연 실행의 내부 동작 방식

  • 쿼리 연산자는 데코레이터 시퀀스를 반환함
  • 데코레이터 시퀀스는 자체 저장소 없이 입력 시퀀스를 래핑함
  • 데이터 요청시 입력 시퀀스에 요청을 전달함

예제:

IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where(n => n < 10);
  • 직접 데코레이터 시퀀스 구현도 C# 이터레이터로 쉽게 가능함
public static IEnumerable<TResult> MySelect<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
    foreach (TSource element in source)
        yield return selector(element);
}

데코레이터 체이닝

쿼리 연산자를 체이닝하면 데코레이터가 계층화됨:

IEnumerable<int> query = new int[] { 5, 12, 3 }
    .Where(n => n < 10)
    .OrderBy(n => n)
    .Select(n => n * 10);
  • 각 쿼리 연산자는 이전 시퀀스를 래핑하는 새로운 데코레이터를 인스턴스화함
  • 이 객체 모델은 열거 전에 완전히 구성됨

쿼리 실행 방식

  • foreach는 가장 바깥쪽 연산자(Select)의 데코레이터에서 GetEnumerator를 호출
  • 결과적으로 데코레이터 시퀀스의 체인과 일치하는 열거자 체인이 생성됨
  • LINQ 쿼리는 요구 기반 풀 모델(demand-driven pull model)을 따름
  • 이는 공급 기반 푸시 모델(supply-driven push model)과 대비됨

생산 라인 비유

LINQ 쿼리를 생산 라인에 비유하면 다음과 같음:

  • 쿼리는 컨베이어 벨트들로 구성된 생산 라인과 같음
  • 지연 실행은 이 생산 라인이 "게으른" 특성을 가짐을 의미함
    • 벨트들은 요청이 있을 때만 요소들을 이동시킴
  • 쿼리 구성은 생산 라인 설치에 해당함
    • 모든 것이 준비되어 있지만 아직 움직이지 않음
  • 쿼리 열거시:
    • 가장 오른쪽 벨트가 먼저 활성화됨
    • 이는 다른 벨트들의 연쇄적 활성화를 유발함
    • 입력 시퀀스의 요소들이 필요할 때만 이동함

이러한 요구 기반 풀 모델(demand-driven pull model)의 특징:

  • 요소들은 실제로 필요할 때만 처리됨
  • 메모리 효율성이 높음
  • SQL 데이터베이스 쿼리로의 확장을 자연스럽게 가능하게 함

쿼리 실행의 예

IEnumerable<int> query = new int[] { 5, 12, 3 }
    .Where(n => n < 10)
    .OrderBy(n => n)
    .Select(n => n * 10);

foreach (int n in query) Console.Write(n + "|");  // 출력: 30|50|

'공부 > C#' 카테고리의 다른 글

LINQ 연산자  (0) 2025.09.09
LINQ 하위 쿼리, let 키워드  (0) 2025.09.09
쿼리 연산자 체이닝, 지연 실행의 장점  (0) 2025.09.09
LINQ, 쿼리 연산자, 람다 식  (0) 2025.09.09
Dictionary, Comparer, StringComparer  (2) 2025.08.28