반응형

[PART12.제네릭·델리게이트·람다·LINQ(14/18)] LINQ 쿼리 구문 — from ... where ... select ...

SQL처럼 읽히는 문법 / 메서드 구문과 IL 레벨에서 동일 / 다중 from·let·join·group 시나리오에서 가독성 우위


1. [문제 제기] 메서드 체이닝이 SQL처럼 읽히지 않는다

직전 [13]에서 살펴본 LINQ 메서드 구문은 한 줄에 끝나는 매력이 있지만, 데이터 소스가 두 개 이상 얽히는 순간 가독성이 급격히 떨어집니다. Unity에서 "플레이어 목록과 펫 목록을 이름으로 매칭"하는 코드를 메서드 구문으로 쓰면 다음과 같습니다.

C#
List<Player> players = ...;
List<Pet> pets = ...;

var result = players
    .Join(
        pets,
        player => player.Name,                      // outerKeySelector
        pet => pet.OwnerName,                       // innerKeySelector
        (player, pet) => new { player.Name, pet.PetName });  // resultSelector

Join 메서드 시그니처 자체가 인자 4개(내부 소스, 외부 키, 내부 키, 결과 선택자)를 요구해서 람다가 세 번 등장합니다. 익명 타입 변환까지 끼면 "원본 두 컬렉션을 키로 매칭한다"는 단순한 의도가 람다 더미에 묻혀버립니다. 같은 코드를 SQL 개발자가 보면 즉시 이해되는 형태로 적을 수 있다면 어떨까요.

C#
var result = from player in players
             join pet in pets on player.Name equals pet.OwnerName
             select new { player.Name, pet.PetName };

C# 3.0(2007년)에서 도입된 LINQ 쿼리 구문(query syntax) 은 컴파일러가 위 형태를 받아서 Join 메서드 호출로 자동 변환합니다. 두 코드는 IL 레벨에서 한 명령어도 다르지 않습니다. 즉 쿼리 구문은 런타임 비용이 0인 순수 문법 설탕(syntactic sugar)입니다.

이 글은 쿼리 구문의 모든 절(from·where·select·orderby·let·join·group ... into) 을 한 번씩 펼쳐 보이고, 컴파일러가 이를 메서드 구문으로 어떻게 desugar하는지 IL로 직접 증명합니다. 마지막으로 실무에서 메서드 구문과 쿼리 구문 중 어느 쪽이 더 자주 쓰이는지, 그리고 Unity 핫패스에서 똑같이 적용되는 함정도 짚습니다.

참고: 본 PART의 LINQ 시리즈는 [13] 메서드 구문 → [14] 쿼리 구문(이 글) → [15] 지연 실행 순서로 이어집니다. 메서드 구문의 기본 메커니즘과 지연 실행은 인접 글에서 깊게 다루므로, 이 글은 쿼리 구문 자체의 문법과 desugar 규칙에 집중합니다.

2. [개념 정의] 쿼리 구문은 SQL 모양으로 적은 메서드 호출이다

2.1 비유 — 같은 회로의 두 가지 스위치 패널

쿼리 구문과 메서드 구문의 관계는 한 회로에 달린 두 개의 스위치 패널과 같습니다. 한쪽 패널은 가전제품 디자이너가 쓰기 좋은 그래픽 UI(SQL을 닮은 쿼리 구문)이고, 다른 쪽 패널은 전기 기술자가 쓰는 토글 스위치 모음(메서드 구문)입니다. 어느 쪽 스위치를 눌러도 켜지는 전등은 같습니다 — 둘 다 결국 같은 회로(같은 IL)를 흐르게 하기 때문입니다.

from ... in ... — 범위 변수 선언 쿼리의 데이터 소스와 각 요소를 가리키는 이름(범위 변수, range variable)을 정의한다. SQL의 FROM과 같지만 변수 이름이 먼저 오고 소스가 뒤에 온다(for x in xs 형태).
예시: from p in playersplayers 컬렉션을 순회하며 각 요소를 p라는 이름으로 참조

2.2 시각화 — 모든 절의 데이터 흐름

쿼리 구문 절의 일반 순서

2.3 모든 절을 한 번씩 — 자기완결 예시

아래는 Unity의 Player/Pet 데이터 구조를 가정해 일곱 절을 모두 사용한 예시입니다. 그대로 컴파일·실행 가능합니다.

C#
using System;
using System.Collections.Generic;
using System.Linq;

public record Player(string Name, int Level, string Class);
public record Pet(string OwnerName, string PetName);

public static class QueryDemo
{
    public static void Run()
    {
        var players = new List<Player>
        {
            new("Knight", 100, "Warrior"),
            new("Mage", 105, "Wizard"),
            new("Archer", 100, "Hunter"),
            new("Healer", 110, "Priest"),
            new("Berserker", 95, "Warrior")
        };
        var pets = new List<Pet>
        {
            new("Knight", "Dragon"),
            new("Mage", "Golem")
        };

        // from + let + where + orderby + select + join + group ... into 를 모두 사용
        var report = from p in players                          // (1) from   — 데이터 소스
                     join pet in pets on p.Name equals pet.OwnerName  // (2) join   — 펫과 매칭 (펫 없는 플레이어는 제외)
                     let score = p.Level * 100                  // (3) let    — 중간 변수
                     where score >= 10000                       // (4) where  — 필터
                     orderby score descending                   // (5) orderby — 점수 내림차순
                     select new { p.Name, pet.PetName, score };// (6) select  — 결과 형태

        foreach (var r in report)
            Console.WriteLine($"{r.Name}({r.PetName}): {r.score}");

        // group ... into : 클래스별 인원 수
        var byClass = from p in players
                      group p by p.Class into g                 // (7) group ... into — 그룹화 후 이어쓰기
                      orderby g.Count() descending
                      select new { Class = g.Key, Count = g.Count() };

        foreach (var r in byClass)
            Console.WriteLine($"{r.Class}: {r.Count}명");
    }
}

쿼리 표현식의 두 가지 강제 규칙은 코드를 읽을 때 항상 기억해 두는 것이 좋습니다.

  1. 반드시 from으로 시작한다. 다른 절로 시작할 수 없습니다.
  2. 반드시 select 또는 group ... by로 끝난다. 중간에 끊을 수 없습니다.

이 규칙 덕분에 컴파일러는 표현식의 첫 줄과 마지막 줄만 보고도 desugar할 메서드 체인의 형태를 결정할 수 있습니다.

2.4 IL — 가장 단순한 형태부터

쿼리 구문이 정말로 메서드 구문과 같은 IL을 만드는지부터 확인합니다. 아래 두 함수는 의미가 동일합니다.

C#
public static IEnumerable<int> SimpleQuery(List<int> list)
{
    return from x in list
           where x > 0
           select x * 2;
}

public static IEnumerable<int> SimpleMethod(List<int> list)
{
    return list.Where(x => x > 0).Select(x => x * 2);
}

/il-analysis로 돌린 결과의 핵심 부분을 발췌합니다.

IL
// SimpleQuery (쿼리 구문)
IL_0020: call ... Enumerable::Where<int32>(...)    // ← Where 호출
IL_0044: call ... Enumerable::Select<int32, int32>(...)  // ← Select 호출
IL_0049: ret

// SimpleMethod (메서드 구문)
IL_0020: call ... Enumerable::Where<int32>(...)    // ← Where 호출
IL_0044: call ... Enumerable::Select<int32, int32>(...)  // ← Select 호출
IL_0049: ret

명령어 시퀀스가 바이트 단위로 동일합니다(컴파일러가 캐시한 람다 델리게이트의 정적 필드 이름 인덱스만 <>9__2_0 vs <>9__3_0으로 다를 뿐, 명령어와 그 순서는 같습니다). 즉 쿼리 구문은 런타임 비용이 0이며, 두 형태는 완전히 등가입니다. 어느 쪽을 쓸지는 오직 가독성 문제입니다.


3. [내부 동작] 컴파일러가 절을 메서드로 번역하는 규칙

3.1 비유 — 외국어 통역사처럼 어순을 바꾼다

쿼리 구문에서 메서드 구문으로의 번역은 한국어를 영어로 통역할 때 어순이 뒤집히는 것과 비슷합니다. 한국어로 "나는 어제 책을 샀다"고 적으면 통역사는 머릿속에서 "I bought a book yesterday"로 어순을 재배치합니다. 컴파일러도 마찬가지로 읽기 좋은 순서(from 먼저) 의 쿼리 구문을 받아서 호출 순서(Where 먼저) 의 메서드 체인으로 어순을 뒤집어 번역합니다.

이 번역은 컴파일 초기 단계(구문 분석 직후) 에 일어나며, 의미 분석은 번역된 메서드 구문을 대상으로 진행됩니다. 그래서 쿼리 구문에서 발생한 오류 메시지가 종종 메서드 이름(Where·Select)을 언급하는 것입니다.

3.2 시각화 — 절별 desugar 규칙

쿼리 구문 → 메서드 구문 번역 규칙

3.3 단순 쿼리의 변환 — from + where + select

C#
// 쿼리 구문
var q = from x in list
        where x > 0
        select x * 2;

// 컴파일러가 만드는 메서드 구문
var q = list.Where(x => x > 0).Select(x => x * 2);

번역은 끝에서부터 안쪽으로 감싸 들어갑니다.

  1. 마지막 select x * 2.Select(x => x * 2)로 바깥을 만듭니다.
  2. 그 안의 where x > 0.Where(x => x > 0)Select 앞에 붙습니다.
  3. from x in listlist가 가장 안쪽 소스가 됩니다.

3.4 let 절의 변환 — 직접 대응 메서드가 없다

let은 쿼리 구문 절 중 유일하게 메서드 구문에 직접 대응이 없습니다. 대신 컴파일러가 익명 타입(anonymous type) 을 만들어서 원래 변수와 새 변수를 함께 담는 트릭을 씁니다.

C#
// 쿼리 구문
var q = from p in players
        let score = p.Level * 100   // 중간 변수
        where score > 10000
        select $"{p.Name}:{score}";

// 컴파일러가 만드는 메서드 구문 (개념적)
var q = players
    .Select(p => new { p, score = p.Level * 100 })  // ← 익명 타입에 둘 다 담음
    .Where(t => t.score > 10000)                    //   t.p / t.score 로 접근
    .Select(t => $"{t.p.Name}:{t.score}");

let이 중요한 이유는 반복 계산을 피해주기 때문입니다. 메서드 구문으로 같은 효과를 내려면 score 식을 whereselect 양쪽에 두 번 적어야 하거나, 위처럼 직접 익명 타입을 만들어야 합니다. 둘 다 가독성이 떨어집니다.

이 익명 타입은 컴파일러가 <>f__AnonymousType0<TPlayer, TInt32> 같은 이름으로 클래스를 자동 생성하며, 참조 타입(class) 이라 힙에 할당됩니다. 즉 let을 쓰면 매 요소마다 객체 하나가 새로 만들어집니다 — Unity 핫패스에서는 이 점을 반드시 기억해야 합니다(5절에서 다시 다룹니다).

3.5 IL — let이 정말 익명 타입을 만드는가

C#
public static IEnumerable<string> LetSample(List<Player> players)
{
    return from p in players
           let score = p.Level * 100
           where score > 10000
           select $"{p.Name}:{score}";
}

/il-analysis 결과의 핵심:

IL
// 첫 번째 Select — 람다 시그니처에 익명 타입이 등장
IL_000f: ldftn instance class '<>f__AnonymousType0`2'<class Player, int32>
         '<>c'::'<LetSample>b__4_0'(class Player)
IL_0020: call Enumerable::Select<Player, '<>f__AnonymousType0`2'<Player, int32>>(...)
         // ← 입력은 Player, 출력은 익명 타입(Player, int32)

// Where — 익명 타입을 그대로 받음
IL_0033: ldftn instance bool '<>c'::'<LetSample>b__4_1'(class '<>f__AnonymousType0`2'<...>)
IL_0044: call Enumerable::Where<'<>f__AnonymousType0`2'<...>>(...)

// 마지막 Select — 익명 타입에서 string 으로 투영
IL_0057: ldftn instance string '<>c'::'<LetSample>b__4_2'(class '<>f__AnonymousType0`2'<...>)
IL_0068: call Enumerable::Select<'<>f__AnonymousType0`2'<...>, string>(...)
IL_006d: ret

<>f__AnonymousType0\2<Player, int32> 라는 익명 타입이 컴파일러에 의해 생성되어 파이프라인 중간에 흐르고 있습니다. 첫 SelectPlayer → 익명 타입(p, score)으로 변환하고, Where가 그 익명 타입을 받아 필터링하며, 마지막 Select가 익명 타입에서 string을 뽑아냅니다. 메서드 구문으로 "한 줄"인 것처럼 보이는 let이 사실은 **Select → Where → Select` 세 번의 호출과 익명 타입 한 종류**로 펼쳐진다는 사실을 IL이 그대로 보여줍니다.

3.6 join의 변환 — 4-인자 메서드 호출

C#
public static IEnumerable<string> JoinSample(List<Player> players, List<Pet> pets)
{
    return from p in players
           join pet in pets on p.Name equals pet.OwnerName
           select $"{p.Name} owns {pet.PetName}";
}
IL
IL_0010: ldftn '<JoinSample>b__5_0' (class Player)              // outerKeySelector: p => p.Name
IL_002f: ldftn '<JoinSample>b__5_1' (class Pet)                 // innerKeySelector: pet => pet.OwnerName
IL_004e: ldftn '<JoinSample>b__5_2' (class Player, class Pet)   // resultSelector: (p, pet) => $"..."
IL_005f: call Enumerable::Join<Player, Pet, string, string>(...) // ← 단일 Join 호출
IL_0064: ret

세 람다(외부 키, 내부 키, 결과 선택자)가 하나의 Join 호출에 인자로 전달되는 게 한눈에 보입니다. 메서드 구문으로 직접 적었을 때와 IL이 정확히 같습니다 — 이 글 도입부의 Join 메서드 구문 예시는 위 IL과 동일한 결과를 만듭니다.

3.7 group by의 변환 — GroupBy 한 번 호출

C#
public static IEnumerable<string> GroupSample(List<Player> players)
{
    return from p in players
           group p by p.Class into g
           select $"{g.Key}:{g.Count()}";
}
IL
IL_000f: ldftn '<GroupSample>b__6_0' (Player)                                // 키 선택자: p => p.Class
IL_0020: call Enumerable::GroupBy<Player, string>(...)                       // ← GroupBy 호출
IL_0033: ldftn '<GroupSample>b__6_1' (IGrouping<string, Player>)             // 그룹별 투영
IL_0044: call Enumerable::Select<IGrouping<string, Player>, string>(...)     // ← Select 로 마무리
IL_0049: ret

group ... by ... into gGroupBy(키 선택자)를 호출해 IEnumerable<IGrouping<TKey, TElement>>를 만들어 g에 넘기고, 이후 절은 그 그룹 시퀀스 위에서 다시 펼쳐집니다. g.Key는 그룹의 키, g.Count()는 그룹 멤버 개수입니다.

3.8 다중 from의 변환 — SelectMany

C#
public static IEnumerable<int> CrossJoinSample(int[] a, int[] b)
{
    return from x in a
           from y in b
           select x * y;
}
IL
IL_0039: call Enumerable::SelectMany<int32, int32, int32>(...)  // ← 단일 SelectMany 호출
IL_003e: ret

두 컬렉션의 데카르트 곱(Cartesian product)이 필요할 때 from을 두 번 적으면 컴파일러가 SelectMany로 평탄화합니다. 메서드 구문으로 같은 코드를 적으면 다음과 같습니다.

C#
return a.SelectMany(x => b, (x, y) => x * y);

람다가 두 개나 들어가서 의도가 한눈에 들어오지 않습니다. 다중 from이 더 자연스러운 이유가 여기 있습니다.


4. [실전 적용] 어느 쪽이 더 자주 쓰이는가

4.1 결론을 먼저 — 실무는 메서드 구문이 우세

대부분의 실무 코드 베이스(특히 GitHub의 오픈소스 C# 프로젝트, Microsoft 공식 샘플, Unity 게임 코드)에서는 메서드 구문이 더 자주 사용됩니다. 이유는 단순합니다.

  1. Count()·First()·Any()·ToList() 같은 종결 연산은 쿼리 구문에 키워드가 없습니다. 결국 어차피 메서드 호출을 끝에 붙여야 합니다.
  2. 단순 조회는 한 줄로 쓸 수 있다. players.Where(p => p.Level > 100).Count()from ... select ... .Count()보다 짧습니다.
  3. 체이닝이 IDE에서 자동 완성에 잘 잡힙니다. .Where(...) 다음에 .Select가 자동 완성되지만, where 키워드 다음에는 컴파일러가 절 종류별로 다르게 동작해 자동 완성 도움이 약합니다.

4.2 그러나 쿼리 구문이 압도적으로 우월한 시나리오

다중 from·let·join·group ... into 가 결합된 시나리오에서는 쿼리 구문의 가독성이 훨씬 높습니다. Unity에서 흔한 "두 개 컬렉션을 키로 매칭하면서 중간 계산 변수를 도입" 케이스를 비교해 봅니다.

같은 작업, 다른 구문 — 가독성 비교

요약하면 선택 기준은 단순합니다.

시나리오 권장 구문
단순 필터·투영(Where/Select만) 메서드 구문
종결 연산(Count·First·Any)이 필요 메서드 구문
다중 from(데카르트 곱·중첩 컬렉션 평탄화) 쿼리 구문
let으로 중간 계산 결과를 재사용 쿼리 구문
join/group ... into로 SQL 같은 흐름 쿼리 구문
한 표현식에 위 두 그룹이 섞임 쿼리 구문 + 끝에 .Count()처럼 메서드 합성

4.3 Unity 실전 — 보스 처치 보고서 만들기

Unity에서 보스 전투 종료 후 "참가한 플레이어 + 그들의 펫" 보고서를 만드는 코드를 비교합니다. 메서드 구문 Before vs 쿼리 구문 After입니다.

C#
// ❌ Before — 메서드 구문, 가독성 떨어짐
public List<string> BuildBossReport(List<Player> players, List<Pet> pets)
{
    return players
        .Where(p => p.Level >= 100)
        .Join(
            pets,
            p => p.Name,
            pet => pet.OwnerName,
            (p, pet) => new { p, pet })
        .Select(t => new { t.p, t.pet, score = t.p.Level * 100 })
        .OrderByDescending(t => t.score)
        .Select(t => $"{t.p.Name}({t.pet.PetName}) → {t.score}점")
        .ToList();
}
C#
// ✅ After — 쿼리 구문, 의도가 한눈에
public List<string> BuildBossReport(List<Player> players, List<Pet> pets)
{
    var query = from p in players
                where p.Level >= 100
                join pet in pets on p.Name equals pet.OwnerName
                let score = p.Level * 100
                orderby score descending
                select $"{p.Name}({pet.PetName}) → {score}점";

    return query.ToList();   // 종결 연산은 메서드로
}

After 코드는 위에서 아래로 한 번만 읽어도 "레벨 100 이상 플레이어를 펫과 매칭하고, 점수를 매기고, 정렬해서 문자열로 만든다"는 흐름이 그대로 보입니다. 두 코드의 IL은 거의 동일합니다(쿼리 구문이 결과 셀렉터의 형태에 따라 익명 타입을 한 번 더 끼우거나 하는 정도의 차이만 있음).


5. [함정과 주의사항]

5.1 ❌ 쿼리 구문이라고 해서 람다 캡처/할당이 사라지진 않는다

쿼리 구문은 메서드 구문과 IL이 동일하므로 메서드 구문의 모든 함정이 그대로 적용됩니다. 신입이 가장 자주 오해하는 부분입니다.

C#
// ❌ 잘못된 패턴 — Update에서 매 프레임 LINQ 쿼리
public class EnemySpawner : MonoBehaviour
{
    public List<Enemy> enemies;
    public Player player;

    void Update()
    {
        // 쿼리 구문이지만 매 프레임 람다 클로저(player 캡처) + IEnumerable 객체 할당
        var nearby = from e in enemies
                     where Vector3.Distance(e.transform.position, player.transform.position) < 10f
                     select e;

        foreach (var e in nearby)
            e.Aggro();
    }
}

이 코드의 IL은 enemies.Where(e => Vector3.Distance(...) < 10f)와 완전히 같습니다. player라는 외부 변수를 캡처한 람다는 컴파일러가 만든 클로저 클래스(<>c__DisplayClass...)의 인스턴스로 매 프레임 힙에 새로 할당됩니다. 60FPS면 초당 60개의 클로저 객체가 GC 힙에 쌓이고, 곧 GC 스파이크(garbage collection spike, 가비지 수거가 일어나는 순간 프레임이 잠깐 멈추는 현상)로 이어집니다.

C#
// ✅ 올바른 패턴 — 핫패스에서는 LINQ 대신 for 루프
void Update()
{
    Vector3 playerPos = player.transform.position;
    for (int i = 0; i < enemies.Count; i++)
    {
        if (Vector3.SqrMagnitude(enemies[i].transform.position - playerPos) < 100f) // 10*10
            enemies[i].Aggro();
    }
}

for 루프와 SqrMagnitude(제곱근 계산을 피한 거리 비교)로 바꾸면 람다 캡처도, IEnumerable 객체 할당도 발생하지 않습니다.

핵심: 쿼리 구문이라는 외관이 메서드 호출의 비용을 가려주지 않습니다. 쿼리 구문 = 메서드 구문 = 같은 IL = 같은 비용.

5.2 ❌ let은 매 요소마다 익명 타입을 힙에 할당한다

let을 자주 쓰면 가독성은 좋아지지만 요소 개수만큼 익명 타입 객체가 힙에 만들어집니다.

C#
// ❌ 핫패스 + let — 1000개 요소면 1000개의 익명 타입 객체 할당
void Update()
{
    var top = from e in enemies
              let dist = (e.transform.position - player.transform.position).sqrMagnitude
              where dist < 100f
              orderby dist
              select e;

    foreach (var e in top.Take(3)) e.Highlight();
}

IL은 3.5절의 LetSample과 같은 형태로, Select가 익명 타입을 만들어 시퀀스에 흘립니다. 1000개의 적이 있다면 1000개의 익명 타입 인스턴스가 매 프레임 GC 힙에 잠깐 생겼다가 사라집니다.

C#
// ✅ 핫패스에서는 직접 거리 캐싱
void Update()
{
    Vector3 playerPos = player.transform.position;
    int found = 0;
    for (int i = 0; i < enemies.Count && found < 3; i++)
    {
        float sqr = (enemies[i].transform.position - playerPos).sqrMagnitude;
        if (sqr < 100f)
        {
            enemies[i].Highlight();
            found++;
        }
    }
}

5.3 ❌ 지연 실행은 쿼리 구문에서도 동일하게 적용된다

쿼리 구문으로 정의한 var query = from ...은 그 자리에서 실행되지 않습니다 — 결과를 foreach/ToList/Count로 소비할 때 비로소 실행됩니다(상세는 [15] 참조).

C#
// ❌ 함정 — 쿼리 후 원본을 수정
var bigEnemies = from e in enemies where e.HP > 1000 select e;

enemies.Add(new Enemy(HP: 9999));   // 원본에 추가

foreach (var e in bigEnemies)        // 새로 추가한 Enemy도 결과에 포함됨!
    Console.WriteLine(e.Name);       //   쿼리는 enumerate 시점에 실행되기 때문
C#
// ✅ 즉시 평가가 필요하면 ToList/ToArray 로 고정
var bigEnemies = (from e in enemies where e.HP > 1000 select e).ToList();
enemies.Add(new Enemy(HP: 9999));   // 새 적은 결과에 안 들어감

5.4 ❌ select/group으로 끝나지 않으면 컴파일 오류

쿼리 표현식의 강제 규칙입니다.

C#
// ❌ where 로 끝나려는 시도 — CS0742: A query body must end with a select or group clause
var bad = from p in players
          where p.Level > 100;

// ✅ select 를 붙여 끝낸다
var good = from p in players
           where p.Level > 100
           select p;

신입에게는 의외로 자주 발생하는 오류입니다. SQL의 WHERE만 적고 끝낼 수 없는 것과 같은 이유로, 쿼리 구문도 결과 형태를 명시하지 않으면 끝낼 수 없습니다.

5.5 ❌ join 키 비교는 equals 키워드만 — == 가 아니다

C#
// ❌ join 절에서 == 사용 — CS1003 syntax error
var bad = from p in players
          join pet in pets on p.Name == pet.OwnerName   // 컴파일 오류
          select new { p, pet };

// ✅ equals 키워드를 써야 한다
var good = from p in players
           join pet in pets on p.Name equals pet.OwnerName
           select new { p, pet };

equals는 쿼리 구문 전용 키워드로, 일반 표현식에서는 사용할 수 없습니다. 또한 equals 양쪽의 값은 같은 타입이어야 합니다(p.Namestring이면 pet.OwnerNamestring).


6. [C# 버전별 변화]

6.1 도입 — C# 3.0(2007년)에서 한꺼번에 등장

LINQ 자체와 그를 지원하는 모든 언어 기능이 C# 3.0에서 동시에 도입되었습니다. 쿼리 구문을 문법적으로 가능하게 한 것은 다음 다섯 가지 신기능의 결합입니다.

C# 3.0 신기능 LINQ에서의 역할
확장 메서드(extension methods) Where/SelectIEnumerable<T>에 "마치 멤버처럼" 호출
람다식(x => x > 0) 쿼리 절의 조건·셀렉터를 간결하게 작성
익명 타입(new { x, y }) let·다중 from·select new {...}의 중간 결과 표현
암시적 타입 지역 변수(var) 익명 타입 결과를 변수에 받기
표현식 트리(Expression<Func<...>>) IQueryable로 SQL/EF 변환 가능

6.2 C# 3.0 이전과 이후

C#
// ❌ C# 2.0(2005) 이전 — for 루프와 임시 컬렉션
List<int> result = new List<int>();
foreach (int x in list)
    if (x > 0)
        result.Add(x * 2);
C#
// ✅ C# 3.0 이후 — 쿼리 구문 한 표현식
var result = from x in list where x > 0 select x * 2;

이후 C# 4.0 ~ 13까지 쿼리 구문 자체에는 새로운 절이 추가되지 않았습니다. 대신 LINQ 메서드 측에서 Enumerable.Chunk(C# 9 시기, .NET 6), Enumerable.MaxBy/MinBy(.NET 6), Index/Order(.NET 9) 등이 추가되었습니다 — 이들은 쿼리 구문 키워드가 없으므로 메서드 구문으로만 호출 가능합니다. 이 점도 실무에서 메서드 구문이 우세한 이유 중 하나입니다.

6.3 IL 레벨에서의 안정성

쿼리 구문의 desugar 규칙은 C# 3.0 이후 변하지 않았습니다. 같은 쿼리는 어느 버전의 컴파일러에서도 같은 메서드 호출 시퀀스로 변환됩니다 — 단, 람다 델리게이트 캐싱 패턴이 C# 7 즈음부터 정적 필드(<>9__N_M)에 저장되도록 최적화되어, 외부 변수를 캡처하지 않는 람다는 한 번만 만들어 재사용하게 되었습니다. 3.4절의 IL에서 본 <>c::'<>9__2_0' 같은 정적 필드가 그 캐시입니다.


7. [정리]

이 글에서 본 내용을 핵심만 압축합니다.

  • 쿼리 구문은 SQL 모양으로 적은 메서드 호출이다. 컴파일러가 컴파일 초기에 메서드 구문으로 desugar하며, IL 레벨에서 두 형태는 동일하다(런타임 비용 0).
  • 모든 절은 대응하는 메서드가 있다 — let만 빼고. from(첫 번째)→Where/Select로 시작, 다중 fromSelectMany, whereWhere, selectSelect, orderbyOrderBy/OrderByDescending, group ... byGroupBy, joinJoin, join ... intoGroupJoin.
  • let은 컴파일러가 익명 타입(투명 식별자)을 만들어 흉내낸다. 매 요소마다 객체가 힙에 할당되므로 핫패스에서 주의.
  • 쿼리 표현식은 from으로 시작, select 또는 group ... by로 끝나야 한다. 중간 절(where·orderby·let·join)은 0개 이상 자유롭게 배치.
  • 실무는 메서드 구문이 우세하다. Count/First/Any 같은 종결 연산이 쿼리 구문 키워드에 없고, IDE 자동 완성 친화도가 더 높기 때문.
  • 다중 from·let·join·group ... into가 결합되면 쿼리 구문이 압도적으로 읽기 좋다. Join 메서드의 4-인자 시그니처가 SQL 한 줄로 펴진다.
  • Unity 핫패스 함정은 쿼리 구문에서도 동일하다. 람다 캡처 → 클로저 객체 할당, let → 익명 타입 할당, 지연 실행 → 원본 수정 시 결과 변동. 매 프레임 호출되는 코드에서는 for 루프로 대체.
  • C# 3.0(2007) 이후 쿼리 구문 자체엔 새 절이 추가된 적 없다. 새 LINQ 연산자(MaxBy·Chunk·Index 등)는 모두 메서드 구문 전용이다.

다음 글 [15]에서는 이 글에서 짧게 짚고 넘어간 지연 실행(deferred execution) 의 메커니즘을 IL 수준에서 깊게 파헤칩니다. 어떤 LINQ 연산자가 즉시 실행이고 어떤 것이 지연 실행인지, 같은 쿼리를 두 번 foreach하면 무엇이 두 번 일어나는지 등을 다룹니다.

반응형

+ Recent posts