공부/C#

쿼리 연산자 체이닝, 지연 실행의 장점

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

LINQ Fluent Syntax

개요

  • Fluent Syntax는 LINQ의 가장 기본적이고 유연한 쿼리 작성 방식임
  • 확장 메서드와 람다 식을 사용하여 쿼리를 작성함
  • 쿼리 연산자를 체이닝하여 복잡한 쿼리를 구성할 수 있음

쿼리 연산자 체이닝

  • 체이닝은 여러 쿼리 연산자를 연결하여 복잡한 쿼리를 만드는 방법임
  • 데이터는 왼쪽에서 오른쪽으로 흐르며 순차적으로 처리됨
    • 실제 변수가 저장되는게 아니라 해당 구성의 ‘재료’들을 가지고 있는것이고, 이것을 얻기위해 계속 반복을 돌리는 것이다.
using System;
using System.Collections.Generic;
using System.Linq;

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };

IEnumerable<string> query = names
    .Where(n => n.Contains("a"))     // "a"를 포함하는 이름 필터링
    .OrderBy(n => n.Length)          // 길이순으로 정렬
    .Select(n => n.ToUpper());       // 대문자로 변환

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

// 실행 결과:
// JAY
// MARY
// HARRY

쿼리 연산자의 구현과 체이닝

  • Where, OrderBy, Select는 System.Linq 네임스페이스의 확장 메서드
  • 람다 식의 변수 n은 각각의 람다 식에서 독립적으로 스코프가 지정됨
  • 쿼리 연산자는 이터레이터를 사용하여 간단하게 구현할 수 있음:
// Select 연산자의 간단한 구현 예제
//TSource : 입력용 일반화 인자, TResult : 출력용 인반화 인자.
public static IEnumerable<TResult> MySelect<TSource,TResult>
    (this IEnumerable<TSource> source, Func<TSource,TResult> selector) //TResult : 반환형
{
    foreach (TSource element in source)
        yield return selector(element);
}

데코레이터 시퀀스와 실행 원리

  • 데코레이터 시퀀스는 입력 시퀀스를 감싸는 래퍼(wrapper) 객체임
  • 일반적인 컬렉션과 달리 데코레이터는 자체 데이터 저장소를 가지지 않음
  • 대신 런타임에 제공되는 입력 시퀀스에 영구적으로 의존함

데코레이터 시퀀스 예제

// 다음과 같은 쿼리가 있다고 가정:
IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where(n => n < 10);

  • 위 쿼리의 실행 구조:
    1. Where는 데코레이터 객체만 생성함
      1. lessThanTen은 int 배열을 참조한다.
      2. 가비지 컬렉션이 동작한다.
    2. 데코레이터는 입력 배열과 람다 식(n => n < 10)을 참조로 저장
    3. lessThanTen을 열거할 때만 실제로 필터링이 수행됨

데코레이터 체이닝

// 여러 연산자를 체이닝한 쿼리:
IEnumerable<int> query = new int[] { 5, 12, 3 }
    .Where(n => n < 10)
    .OrderBy(n => n)
    .Select(n => n * 10);

  • 각 연산자는 새로운 데코레이터를 만들어 이전 시퀀스를 감쌈
    • 데코레이터 : 실행의 결과를 다음 연산이 감싸는 식으로 진행된다.
  • 실행 순서:
    1. Where 데코레이터가 원본 배열을 감쌈
    2. OrderBy 데코레이터가 Where의 결과를 감쌈
    3. Select 데코레이터가 OrderBy의 결과를 감쌈
    4. 최종적으로 러시안 인형처럼 중첩된 데코레이터 체인이 형성됨

지연 실행(Deferred Execution)의 장점

  • 쿼리 구성과 실행이 분리됨
  • 쿼리를 여러 단계로 구성할 수 있음
  • 데이터베이스 쿼리 등 원격 데이터 소스 지원이 가능해짐
  • 필요할 때만 실제 데이터를 처리하므로 메모리 효율적임
  • 주의해야 할 점: 쿼리 실행 전에 원본 데이터가 변경되면, 쿼리 결과에 영향을 줄 수 있음. 쿼리 결과를 즉시 확정하려면 ToList() 또는 ToArray() 같은 즉시 실행 메서드 사용이 필요.
    • 원본 데이터가 변경되면 쿼리 실행 결과도 바뀌게된다.
    • 안바뀌게 하려면? : List나 Array처럼 즉시 실행 메서드가 필요하다.
// 단계별 쿼리 구성 예제:
var numbers = new List<int>() { 1, 2 };
IEnumerable<int> query = numbers.Select(n => n * 10); // 쿼리 구성

numbers.Add(3);              // 원본 데이터 수정
foreach (int n in query)     // 이 시점에 실제 실행
    Console.Write(n + "|");  // 출력: 10|20|30|

  • query는 10, 20이 저장되는게 아니라 1, 2와 *10을 하는 메서드가 같이 저장이 되는 것이다.
    • 이후, 인자를 호출할때 메서드가 실행되어 호출되는 것이다.
  • 같은 쿼리를 단계별로 작성할 수도 있음:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };

IEnumerable<string> filtered = names.Where(n => n.Contains("a"));
IEnumerable<string> sorted = filtered.OrderBy(n => n.Length);
IEnumerable<string> finalQuery = sorted.Select(n => n.ToUpper());

foreach (string name in filtered)
  Console.Write (name + "|");        // Harry|Mary|Jay|

Console.WriteLine();
foreach (string name in sorted)
  Console.Write (name + "|");        // Jay|Mary|Harry|

Console.WriteLine();
foreach (string name in finalQuery)
  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()
    );

람다 식 작성

  • 람다 식은 쿼리 연산자에 로직을 제공하는 방법임
  • 각 연산자마다 람다 식의 목적이 다름:
    • Where: 요소를 포함할지 결정하는 조건 (bool 반환)
      • T형 매개변수를 받아야한다.
    • OrderBy: 정렬 키 지정
      • 값을 받으면 그 값을 기준으로 정렬되는 것이다.
    • Select: 각 요소의 변환 방법 지정
      • 원하는 결과물의 데이터형을 람다식에 연결해서 사용한다.

람다 식과 타입 매개변수

  • 표준 쿼리 연산자는 다음과 같은 타입 매개변수 명명 규칙을 사용함:
    • TSource: 입력 시퀀스의 요소 타입
    • TResult: 출력 시퀀스의 요소 타입 (TSource와 다른 경우)
    • TKey: 정렬, 그룹화 또는 조인에 사용되는 키의 타입

Select 연산자의 타입 추론 예제

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" }; //TSource

// 문자열을 정수로 변환하는 예제
IEnumerable<int> query = names.Select(n => n.Length); //TResult

foreach (int length in query)
    Console.Write(length + "|"); // 3|4|5|4|3|

  • TSource는 입력 시퀀스(names)로부터 string으로 결정됨
  • TResult는 람다 식의 반환값(n.Length)으로부터 int로 추론됨

Where 연산자의 특징

  • Where는 입력과 출력의 요소 타입이 동일함
  • 요소를 필터링만 하고 변환하지 않기 때문임:
public static IEnumerable<TSource> Where<TSource>
    (this IEnumerable<TSource> source, Func<TSource,bool> predicate)

OrderBy 연산자의 특징

  • OrderBy는 정렬 키의 타입을 별도로 지정할 수 있음:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> sortedByLength, sortedAlphabetically;

sortedByLength = names.OrderBy(n => n.Length);      // int 키 / int 정렬
sortedAlphabetically = names.OrderBy(n => n);       // string 키 / string 정렬

람다 식과 Func 시그니처

  • 표준 쿼리 연산자는 제네릭 Func 대리자를 사용함
  • Func의 타입 인자는 람다 식과 동일한 순서로 나타남:
    • Func<TSource,bool>는 TSource => bool 람다 식과 일치
    • Func<TSource,TResult>는 TSource => TResult 람다 식과 일치
  • 컴파일러는 람다 식의 반환값으로부터 TResult 타입을 자동으로 추론함

입력 순서

  • 입력 시퀀스의 입력 순서가 LINQ에서 중요함
  • Take, Skip, Reverse와 같은 연산자는 이 순서에 의존함:
    • 위의 3개는 LINQ의 연산자다.
    • Take(count - 갯수) : 갯수만큼 반환하는 것이다.
    • Skip(갯수) : 갯수만큼 건너뛰어 반환한다.
    • Reverse() : 역순정렬
int[] numbers = { 10, 9, 8, 7, 6 };

// 처음 3개 요소 선택
IEnumerable<int> firstThree = numbers.Take(3); // { 10, 9, 8 }

// 처음 3개를 제외한 나머지 선택
IEnumerable<int> lastTwo = numbers.Skip(3);    // { 7, 6 }

// 순서 뒤집기
IEnumerable<int> reversed = numbers.Reverse();  // { 6, 7, 8, 9, 10 }

기타 연산자

  • 시퀀스를 반환하지 않는 연산자들도 있음
  • 요소 연산자: 입력 시퀀스에서 단일 요소를 추출함
    • First() : 첫번쨰 요소 반환
    • Last() : 마지막 요소 반환
    • ElementAt(인덱스) : 인덱스의 요소 반환
    • 이렇게 LINQ를 쓰지 않는게 좋다.
      • 그냥 배열의 인덱스로 써서 호출해라
      • 인덱서가 없는 경우에만 사용하는 것이다.
int[] numbers = { 10, 9, 8, 7, 6 };

int firstNumber = numbers.First();     // 10
int lastNumber = numbers.Last();       // 6
int secondNumber = numbers.ElementAt(1); // 9

  • 집계 연산자: 보통 숫자 형식의 스칼라 값을 반환함:
    • 반환 데이터형이 IEnumerable이라면 그에 맞는 결과가 반환된다.
int count = numbers.Count();  // 5
int min = numbers.Min();      // 6

  • 한정자: bool 값을 반환함:
    • 컬렉션에 따라서 없는 컬렉션도 있으니 그때 사용하면 좋은 연산자들이다.
    • Any : 비어있지 않으면 true
    • Any(bool 반환 람다식) : 람다식이 true라면 true 반환
bool hasTheNumberNine = numbers.Contains(9);         // true
bool hasMoreThanZeroElements = numbers.Any();        // true
bool hasAnOddElement = numbers.Any(n => n % 2 != 0); // true

  • 두 개의 입력 시퀀스를 받는 연산자들도 있음:
    • Concat : 연결 함수 - 중복 허용
    • Union : 합치는 함수 - 중복 제거
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 하위 쿼리, let 키워드  (0) 2025.09.09
LINQ 질의식, LINQ 지연 실행  (0) 2025.09.09
LINQ, 쿼리 연산자, 람다 식  (0) 2025.09.09
Dictionary, Comparer, StringComparer  (2) 2025.08.28
Array Class, Lists, Queues, Stacks, Sets  (7) 2025.08.26