[PART12.제네릭·델리게이트·람다·LINQ(14/18)] LINQ 쿼리 구문 — from ... where ... select ...
SQL처럼 읽히는 문법 / 메서드 구문과 IL 레벨에서 동일 / 다중 from·let·join·group 시나리오에서 가독성 우위
목차
1. [문제 제기] 메서드 체이닝이 SQL처럼 읽히지 않는다
직전 [13]에서 살펴본 LINQ 메서드 구문은 한 줄에 끝나는 매력이 있지만, 데이터 소스가 두 개 이상 얽히는 순간 가독성이 급격히 떨어집니다. Unity에서 "플레이어 목록과 펫 목록을 이름으로 매칭"하는 코드를 메서드 구문으로 쓰면 다음과 같습니다.
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 개발자가 보면 즉시 이해되는 형태로 적을 수 있다면 어떨까요.
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 players—players컬렉션을 순회하며 각 요소를p라는 이름으로 참조
2.2 시각화 — 모든 절의 데이터 흐름

2.3 모든 절을 한 번씩 — 자기완결 예시
아래는 Unity의 Player/Pet 데이터 구조를 가정해 일곱 절을 모두 사용한 예시입니다. 그대로 컴파일·실행 가능합니다.
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}명");
}
}
쿼리 표현식의 두 가지 강제 규칙은 코드를 읽을 때 항상 기억해 두는 것이 좋습니다.
- 반드시
from으로 시작한다. 다른 절로 시작할 수 없습니다. - 반드시
select또는group ... by로 끝난다. 중간에 끊을 수 없습니다.
이 규칙 덕분에 컴파일러는 표현식의 첫 줄과 마지막 줄만 보고도 desugar할 메서드 체인의 형태를 결정할 수 있습니다.
2.4 IL — 가장 단순한 형태부터
쿼리 구문이 정말로 메서드 구문과 같은 IL을 만드는지부터 확인합니다. 아래 두 함수는 의미가 동일합니다.
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로 돌린 결과의 핵심 부분을 발췌합니다.
// 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
// 쿼리 구문
var q = from x in list
where x > 0
select x * 2;
// 컴파일러가 만드는 메서드 구문
var q = list.Where(x => x > 0).Select(x => x * 2);
번역은 끝에서부터 안쪽으로 감싸 들어갑니다.
- 마지막
select x * 2→.Select(x => x * 2)로 바깥을 만듭니다. - 그 안의
where x > 0→.Where(x => x > 0)이Select앞에 붙습니다. from x in list→list가 가장 안쪽 소스가 됩니다.
3.4 let 절의 변환 — 직접 대응 메서드가 없다
let은 쿼리 구문 절 중 유일하게 메서드 구문에 직접 대응이 없습니다. 대신 컴파일러가 익명 타입(anonymous type) 을 만들어서 원래 변수와 새 변수를 함께 담는 트릭을 씁니다.
// 쿼리 구문
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 식을 where와 select 양쪽에 두 번 적어야 하거나, 위처럼 직접 익명 타입을 만들어야 합니다. 둘 다 가독성이 떨어집니다.
이 익명 타입은 컴파일러가 <>f__AnonymousType0<TPlayer, TInt32> 같은 이름으로 클래스를 자동 생성하며, 참조 타입(class) 이라 힙에 할당됩니다. 즉 let을 쓰면 매 요소마다 객체 하나가 새로 만들어집니다 — Unity 핫패스에서는 이 점을 반드시 기억해야 합니다(5절에서 다시 다룹니다).
3.5 IL — let이 정말 익명 타입을 만드는가
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 결과의 핵심:
// 첫 번째 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> 라는 익명 타입이 컴파일러에 의해 생성되어 파이프라인 중간에 흐르고 있습니다. 첫 Select가 Player → 익명 타입(p, score)으로 변환하고, Where가 그 익명 타입을 받아 필터링하며, 마지막 Select가 익명 타입에서 string을 뽑아냅니다. 메서드 구문으로 "한 줄"인 것처럼 보이는 let이 사실은 **Select → Where → Select` 세 번의 호출과 익명 타입 한 종류**로 펼쳐진다는 사실을 IL이 그대로 보여줍니다.
3.6 join의 변환 — 4-인자 메서드 호출
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_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 한 번 호출
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_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 g는 GroupBy(키 선택자)를 호출해 IEnumerable<IGrouping<TKey, TElement>>를 만들어 g에 넘기고, 이후 절은 그 그룹 시퀀스 위에서 다시 펼쳐집니다. g.Key는 그룹의 키, g.Count()는 그룹 멤버 개수입니다.
3.8 다중 from의 변환 — SelectMany
public static IEnumerable<int> CrossJoinSample(int[] a, int[] b)
{
return from x in a
from y in b
select x * y;
}
IL_0039: call Enumerable::SelectMany<int32, int32, int32>(...) // ← 단일 SelectMany 호출
IL_003e: ret
두 컬렉션의 데카르트 곱(Cartesian product)이 필요할 때 from을 두 번 적으면 컴파일러가 SelectMany로 평탄화합니다. 메서드 구문으로 같은 코드를 적으면 다음과 같습니다.
return a.SelectMany(x => b, (x, y) => x * y);
람다가 두 개나 들어가서 의도가 한눈에 들어오지 않습니다. 다중 from이 더 자연스러운 이유가 여기 있습니다.
4. [실전 적용] 어느 쪽이 더 자주 쓰이는가
4.1 결론을 먼저 — 실무는 메서드 구문이 우세
대부분의 실무 코드 베이스(특히 GitHub의 오픈소스 C# 프로젝트, Microsoft 공식 샘플, Unity 게임 코드)에서는 메서드 구문이 더 자주 사용됩니다. 이유는 단순합니다.
Count()·First()·Any()·ToList()같은 종결 연산은 쿼리 구문에 키워드가 없습니다. 결국 어차피 메서드 호출을 끝에 붙여야 합니다.- 단순 조회는 한 줄로 쓸 수 있다.
players.Where(p => p.Level > 100).Count()이from ... select ... .Count()보다 짧습니다. - 체이닝이 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입니다.
// ❌ 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();
}
// ✅ 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이 동일하므로 메서드 구문의 모든 함정이 그대로 적용됩니다. 신입이 가장 자주 오해하는 부분입니다.
// ❌ 잘못된 패턴 — 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, 가비지 수거가 일어나는 순간 프레임이 잠깐 멈추는 현상)로 이어집니다.
// ✅ 올바른 패턴 — 핫패스에서는 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을 자주 쓰면 가독성은 좋아지지만 요소 개수만큼 익명 타입 객체가 힙에 만들어집니다.
// ❌ 핫패스 + 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 힙에 잠깐 생겼다가 사라집니다.
// ✅ 핫패스에서는 직접 거리 캐싱
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] 참조).
// ❌ 함정 — 쿼리 후 원본을 수정
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 시점에 실행되기 때문
// ✅ 즉시 평가가 필요하면 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으로 끝나지 않으면 컴파일 오류
쿼리 표현식의 강제 규칙입니다.
// ❌ 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 키워드만 — == 가 아니다
// ❌ 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.Name이 string이면 pet.OwnerName도 string).
6. [C# 버전별 변화]
6.1 도입 — C# 3.0(2007년)에서 한꺼번에 등장
LINQ 자체와 그를 지원하는 모든 언어 기능이 C# 3.0에서 동시에 도입되었습니다. 쿼리 구문을 문법적으로 가능하게 한 것은 다음 다섯 가지 신기능의 결합입니다.
| C# 3.0 신기능 | LINQ에서의 역할 |
|---|---|
| 확장 메서드(extension methods) | Where/Select를 IEnumerable<T>에 "마치 멤버처럼" 호출 |
람다식(x => x > 0) |
쿼리 절의 조건·셀렉터를 간결하게 작성 |
익명 타입(new { x, y }) |
let·다중 from·select new {...}의 중간 결과 표현 |
암시적 타입 지역 변수(var) |
익명 타입 결과를 변수에 받기 |
표현식 트리(Expression<Func<...>>) |
IQueryable로 SQL/EF 변환 가능 |
6.2 C# 3.0 이전과 이후
// ❌ C# 2.0(2005) 이전 — for 루프와 임시 컬렉션
List<int> result = new List<int>();
foreach (int x in list)
if (x > 0)
result.Add(x * 2);
// ✅ 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로 시작, 다중from→SelectMany,where→Where,select→Select,orderby→OrderBy/OrderByDescending,group ... by→GroupBy,join→Join,join ... into→GroupJoin. 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하면 무엇이 두 번 일어나는지 등을 다룹니다.