공부/C#

LINQ 하위 쿼리, let 키워드

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

LINQ 하위 쿼리

하위 쿼리의 개념

  • 하위 쿼리(Subquery)는 다른 쿼리의 람다 식 내부에 포함된 쿼리를 의미함
  • 일반적인 C# 식과 마찬가지로 람다 식의 오른쪽에 유효한 식이면 하위 쿼리로 사용할 수 있음
  • 하위 쿼리는 외부 람다 식의 식 범위에 비공개로 지정되며, 외부 람다 식의 매개변수를 참조할 수 있음
  • 시간과 메모리에 엄청난 손해가 일어난다.

하위 쿼리 예제

기본 하위 쿼리

  • 음악가들을 성(last name)으로 정렬하는 예제임
    • Last가 전체를 순회해서 마지막 요소를 반환하는 함수이다.
string[] musos = { "David Gilmour", "Roger Waters", "Rick Wright", "Nick Mason" };

IEnumerable<string> query = musos.OrderBy(m => m.Split().Last());

// 결과 출력
foreach (string name in query)
    Console.WriteLine(name);

실행 결과:

David Gilmour
Nick Mason
Roger Waters
Rick Wright

  • m.Split()은 각 문자열을 단어 컬렉션으로 변환함
  • Last()는 하위 쿼리로, 마지막 단어(성)를 선택함
  • n^2 의 시간복잡도 연산을 하게된다.

복잡한 하위 쿼리

  • 배열에서 가장 짧은 문자열의 길이와 같은 길이를 가진 모든 문자열을 찾는 예제임
    • 비교할때마다 전체를 순회하게 된다.
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };

IEnumerable<string> outerQuery = names
    .Where(n => n.Length == names.OrderBy(n2 => n2.Length)
                                .Select(n2 => n2.Length)
                                .First());

// 결과 출력
foreach (string name in outerQuery)
    Console.WriteLine(name);

실행 결과:

Tom
Jay

  • 쿼리 식으로 동일한 쿼리를 작성하면 다음과 같음:
IEnumerable<string> outerQuery =
    from n in names
    where n.Length ==
        (from n2 in names orderby n2.Length select n2.Length).First()
    select n;
  • 외부 범위 변수(n)가 하위 쿼리의 범위에 있으므로, 하위 쿼리의 범위 변수로 n을 재사용할 수 없음
  • 이것이 위 예제에서 하위 쿼리의 범위 변수를 n2로 지정한 이유임

Min 연산자를 사용한 간단화

  • 위의 복잡한 쿼리는 Min 집계 함수를 사용하여 다음과 같이 단순화할 수 있음:
IEnumerable<string> query =
    from n in names
    where n.Length == names.Min(n2 => n2.Length)
    select n;

하위 쿼리의 실행

  • 하위 쿼리는 외부 람다 식이 평가될 때마다 실행됨
  • 실행은 외부에서 내부로 진행됨(outside-in)
  • 로컬 쿼리(LINQ to Objects)에서는 실제로 각 반복마다 하위 쿼리가 실행됨:
    • 외부 쿼리가 데이터를 순회할 때마다 하위 쿼리가 새로 실행됨
    • 예를 들어 Where절 안의 하위 쿼리는 각 요소를 필터링할 때마다 다시 실행됨
  • 해석된 쿼리(예: 데이터베이스 쿼리)는 이와 달리 서버에서 최적화된 단일 쿼리로 변환되어 실행됨

로컬 쿼리의 하위 쿼리 실행

  • 로컬 쿼리에서 하위 쿼리는 외부 루프의 각 반복마다 실행됨
  • 이는 성능에 영향을 미칠 수 있음
  • 데이터베이스 쿼리와 달리 단일 유닛으로 처리되지 않음

하위 쿼리 최적화

  • 로컬 컬렉션을 쿼리할 때는 하위 쿼리를 별도로 실행하는 것이 일반적으로 더 효율적임:
  • 비교값이 하나로 고정된다면 먼저 구해놓고 비교하는게 더 효율적이다.
int shortest = names.Min(n => n.Length);
IEnumerable<string> query = from n in names
                           where n.Length == shortest
                           select n;

  • 이렇게 하면 하위 쿼리가 한 번만 실행됨
  • 단, 상관 하위 쿼리(correlated subquery, 외부 범위 변수를 참조하는 하위 쿼리)의 경우는 예외임
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
var query = from name in names
            where name.Length >
                  (from n in names
                   where n != name  // 여기서 외부 범위 변수 name을 참조
                   select n.Length).Average()
            select name;
// name보다 짧은 이름들의 평균 길이보다 긴 이름들을 선택

  • 위 예제에서 하위 쿼리는 외부의 name 변수를 참조하므로 분리할 수 없음
    • 이런 경우는 어쩔 수 없으니 이대로 써야한다.
  • 각 name에 대해 하위 쿼리가 다른 결과를 반환함

하위 쿼리와 지연 실행

  • 하위 쿼리 내에서 First, Count와 같은 즉시 실행 연산자를 사용해도 외부 쿼리는 지연 실행됨
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };

// 하위 쿼리에서 Count를 사용해도 전체 쿼리는 지연 실행됨
var query = names.Where(n => n.Length > names.Count()/2);

// 이 시점에서 실제로 쿼리가 실행됨
foreach (var name in query)
    Console.WriteLine(name);

Select에서의 하위 쿼리

  • Select절에서 하위 쿼리를 사용하면 각 요소마다 새로운 쿼리가 생성됨
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
var query = names.Select(n =>
    names.Where(other => other.Length == n.Length));

// 실행 결과는 각 이름별로 같은 길이의 이름들을 포함하는 시퀀스가 됨
foreach (var nameGroup in query)
    foreach (var name in nameGroup)
        Console.WriteLine(name);

LINQ 프로젝션 전략

객체 초기화자(Object Initializers)

개념

  • 지금까지는 select 절에서 스칼라 요소 형식만을 프로젝션함
    • 반환되는 시퀀스의 일반화 시퀀스가 정해지고, 반환되는 일반화가 정해지는 것이다.
  • C#의 객체 초기화자를 사용하면 더 복잡한 형식으로 프로젝션할 수 있음
  • 복잡한 데이터를 구조화하여 저장하고 처리할 때 유용함

활용 시나리오

  • 다단계 쿼리에서 중간 결과를 저장할 때 활용
  • 여러 관련 데이터를 하나의 객체로 그룹화할 때 사용
  • 후속 쿼리에서 필요한 데이터를 구조화할 때 유용함

예제: 모음을 제거한 문자열과 원본 문자열 함께 저장하기

  • 먼저 임시 프로젝션을 위한 클래스 정의:
class TempProjectionItem
{
    public string Original;    // 원본 이름
    public string Vowelless;   // 모음이 제거된 이름
}

  • 객체 초기화자를 사용하여 프로젝션:
string[] names = { "Tom", "Dick", "Harry", "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<string> query = from item in temp
                           where item.Vowelless.Length > 2
                           select item.Original;

// 결과:
// Dick
// Harry
// Mary

익명 형식(Anonymous Types)

개념

  • 익명 형식을 사용하면 특별한 클래스를 작성하지 않고도 중간 결과를 구조화할 수 있음
  • 컴파일러가 자동으로 임시 클래스를 생성함
  • 일회성 데이터 구조가 필요할 때 유용함
  • 코드 작성 시간을 절약하고 가독성을 향상시킬 수 있음

앞선 예제를 익명 형식으로 변경

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;

중요 사항

  • 중간 쿼리의 형식은 IEnumerable<컴파일러-생성-임의-이름> 임
  • 이러한 형식의 변수는 var 키워드로만 선언 가능함
  • var는 단순한 코드 줄임이 아닌 필수 요소임
  • IDE의 인텔리센스를 통해 생성된 익명 형식의 속성에 접근 가능함

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.Vowelless.Length > 2
            select temp.Original;

into 키워드의 특징

  • 쿼리 연속(Query Continuation)을 위해 사용됨
  • select나 group 절 다음에만 사용 가능함
  • 이전 범위의 변수들은 into 키워드 이후에 접근할 수 없음
    • 위에서는 n에 접근할 수 없다.
  • 즉, reset 하는 느낌이다.

let 키워드

개념

  • 쿼리 표현식에서 새로운 변수를 도입하는 키워드임
  • 범위 변수와 함께 새로운 변수를 사용할 수 있게 함
  • 복잡한 계산 결과를 재사용할 때 유용함
  • 즉, 지역변수를 선언하는 느낌이다.

예제: let을 사용한 모음 제거 쿼리

string[] names = { "Tom", "Dick", "Harry", "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의 장점

  • 새로운 요소를 기존 요소와 함께 프로젝션함
  • 쿼리 내에서 표현식을 반복 작성하지 않고도 여러 번 사용할 수 있음
  • select 절에서 원본 이름(n)이나 모음이 제거된 버전(vowelless) 모두 선택 가능함
  • 코드의 가독성과 유지보수성이 향상됨

let 사용 규칙

  • where 문 앞이나 뒤에 여러 개의 let 문을 작성할 수 있음
  • let 문은 이전 let 문에서 도입된 변수를 참조할 수 있음 (into 절의 경계를 넘지 않는 범위에서)
  • let 표현식은 스칼라 형식뿐만 아니라 하위 시퀀스 등도 가능함
  • let 표현식의 결과는 쿼리의 나머지 부분에서 재사용 가능함

컴파일러의 let 처리 방식

  • 컴파일러는 let 절을 임시 익명 형식으로 프로젝션하여 처리함
  • 범위 변수와 새로운 표현식 변수를 모두 포함하는 임시 익명 형식을 생성함
  • 이는 이전 예제의 익명 형식을 사용한 방식과 동일한 결과를 만들어냄