---
title: "LINQ 는 새로운 문법이 아니다"
description: "확장 메서드와 yield 와 람다 셋을 합친 것입니다. 셋을 알면 동작도 함정도 저절로 설명됩니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#"]
canonical: https://blog.unialgames.com/posts/csharp-linq/
---
점수 목록에서 60점 이상만 골라 높은 순으로 이름을 뽑는 코드입니다.

```csharp
var 결과 = new List<string>();
foreach (var s in 학생들)
{
    if (s.점수 >= 60)
        결과.Add(s.이름);
}
결과.Sort();
```

한 줄로 쓸 수 있습니다.

```csharp
var 결과 = 학생들.Where(s => s.점수 >= 60)
                 .OrderByDescending(s => s.점수)
                 .Select(s => s.이름);
```

새로 배울 것이 있어 보이지만 앞에서 다 나온 것들입니다.

> **`Where` 와 `Select` 는 확장 메서드이고, 안은 `yield return` 이고, 넘기는 것은 람다입니다.**

## 자주 쓰는 것만 추리면

| 하는 일 | 메서드 |
| --- | --- |
| 거르기 | `Where` |
| 모양 바꾸기 | `Select` |
| 줄 세우기 | `OrderBy` `OrderByDescending` `ThenBy` |
| 하나 꺼내기 | `First` `FirstOrDefault` `Single` |
| 개수와 합 | `Count` `Sum` `Max` `Min` `Average` |
| 확인 | `Any` `All` `Contains` |
| 묶기 | `GroupBy` `ToDictionary` |
| 굳히기 | `ToList` `ToArray` |

`FirstOrDefault` 와 `First` 의 차이는 알아 둘 만합니다.
`First` 는 없으면 예외를 던지고 `FirstOrDefault` 는 기본값을 돌려줍니다. 참조 타입이면 `null` 입니다.
없을 수도 있는 자리라면 `FirstOrDefault` 를 쓰고 `null` 검사를 합니다.

## 전부 확장 메서드다

`List<T>` 를 열어 봐도 `Where` 는 없습니다. `System.Linq.Enumerable` 이라는 static 클래스에 있습니다.

```csharp
// 실제 모양에 가깝게 줄이면
public static IEnumerable<T> Where<T>(this IEnumerable<T> 원본, Func<T, bool> 조건)
```

앞에서 본 확장 메서드 그대로입니다. 첫 매개변수 앞에 `this` 가 있고, 담는 클래스가 static 입니다.

그래서 `using System.Linq;` 가 필요합니다. 이걸 안 적으면 `Where` 가 자동완성에 안 나옵니다.
확장 메서드는 네임스페이스를 `using` 해야 보인다고 한 그 규칙입니다.

그리고 받는 타입이 `IEnumerable<T>` 입니다. 앞에서 `foreach` 로 돌 수 있는 것이 그것이었습니다.
배열이든 `List` 든 `Dictionary` 든 우리가 `yield` 로 만든 것이든 전부 이 자리에 들어갑니다.

## 안은 yield 다

`Where` 를 직접 써 보면 이렇게 생겼습니다.

```csharp
public static IEnumerable<T> 내Where<T>(this IEnumerable<T> 원본, Func<T, bool> 조건)
{
    foreach (T x in 원본)
    {
        if (조건(x))
            yield return x;
    }
}
```

열 줄이 안 됩니다. 그리고 `yield` 니까 **부른다고 실행되지 않습니다.**

```csharp
var q = 학생들.Where(s => s.점수 >= 60);   // 아무도 안 걸렀습니다
foreach (var s in q) { ... }               // 여기서 처음 돕니다
```

이게 LINQ 의 동작 전부를 설명합니다.

## 한 명씩 파이프를 통과한다

`Where` 다음에 `Select` 를 이으면 "전부 거르고 나서 전부 바꾸는" 것이 아닙니다.
**한 명이 끝까지 갔다가 다음 사람이 출발합니다.**

<x-flow>
<script type="application/json">
{
  "label": "누가 언제 도는가",
  "lines": [
    "var q = 학생들.Where(s => s.점수 >= 60)",
    "              .Select(s => s.이름);",
    "",
    "foreach (var 이름 in q)",
    "    Console.WriteLine(이름);"
  ],
  "dwell": 1.3,
  "cases": [
    {
      "state": "학생들 = 민수(80) · 영희(50) · 철수(70)",
      "path": [
        { "line": 0, "why": "Where 를 불렀지만 아무도 안 걸렀습니다. 부품만 만들어 둡니다." },
        { "line": 1, "why": "Select 도 마찬가지입니다. 여기까지 계산은 0회입니다." },
        { "line": 3, "why": "돌기 시작합니다. 이제서야 첫 사람을 꺼냅니다.", "state": "지금 보는 사람: 민수(80)" },
        { "line": 0, "why": "민수는 80 이라 통과합니다.", "state": "민수 · 조건 통과", "verdict": "true" },
        { "line": 1, "why": "바로 이름으로 바뀝니다. 영희는 아직 손도 안 댔습니다.", "state": "민수 → \"민수\"" },
        { "line": 4, "why": "민수 하나가 끝까지 갔습니다.", "out": "민수" },
        { "line": 0, "why": "다음 사람. 영희는 50 이라 여기서 버려집니다.", "state": "영희 · 조건 탈락", "verdict": "false" },
        { "line": 0, "why": "Select 까지 가지도 않고 다음 사람으로 넘어갑니다. 철수는 70 입니다.", "state": "철수 · 조건 통과", "verdict": "true" },
        { "line": 1, "why": "철수를 이름으로 바꿉니다.", "state": "철수 → \"철수\"" },
        { "line": 4, "why": "끝. Select 는 세 번이 아니라 두 번만 돌았습니다.", "out": "철수" }
      ],
      "note": "탈락한 영희는 Select 를 거치지 않습니다. 미리 다 거르고 나서 바꾸는 게 아니기 때문입니다."
    }
  ]
}
</script>
</x-flow>

중간 리스트가 하나도 안 생깁니다. `Where` 결과를 담는 배열도, `Select` 결과를 담는 배열도 없습니다.
`yield` 라서 그렇습니다.

## 지연 실행이 만드는 함정 두 가지

**하나. 돌 때마다 다시 계산합니다.**

```csharp
var 합격 = 학생들.Where(s => s.점수 >= 60);

Console.WriteLine(합격.Count());     // 한 번 돕니다
foreach (var s in 합격) { ... }      // 또 돕니다
Console.WriteLine(합격.Count());     // 또 돕니다
```

세 번 썼으니 세 번 걸렀습니다. 두 번 이상 쓸 것이면 굳혀 둡니다.

```csharp
var 합격 = 학생들.Where(s => s.점수 >= 60).ToList();
```

`ToList` 나 `ToArray`, `Count`, `First`, `Sum` 처럼 **값을 하나 돌려주는 것**들이 그 자리에서 전부 돕니다.
`Where`, `Select`, `OrderBy` 처럼 `IEnumerable` 을 돌려주는 것들은 안 돕니다. 이 둘을 가르면 헷갈릴 일이 줄어듭니다.

**둘. 원본이 바뀌면 결과도 바뀝니다.**

```csharp
var 큰수 = 목록.Where(n => n > 10);
목록.Add(99);

foreach (var n in 큰수)   // 99 가 들어 있습니다
```

거를 때 목록을 복사한 것이 아니라 **돌 때 원본을 봅니다.** 위에서 만든 것은 값이 아니라 질문입니다.

## OrderBy 만 다르다

`Where` 와 `Select` 는 한 명씩 흘려보냅니다. 그런데 `OrderBy` 는 그럴 수 없습니다.
줄을 세우려면 **전부 봐야** 첫 번째가 누구인지 알 수 있습니다.

그래서 `OrderBy` 는 첫 값을 요구받는 순간 원본을 전부 읽어 배열로 만들고 정렬합니다.
할당이 한 번 일어난다는 뜻입니다. 그리고 무한 수열에 `OrderBy` 를 걸면 영원히 안 끝납니다.

## 쿼리 구문

같은 것을 SQL 처럼 쓸 수도 있습니다.

```csharp
var 결과 = from s in 학생들
           where s.점수 >= 60
           orderby s.점수 descending
           select s.이름;
```

컴파일러가 위의 메서드 호출로 바꿔 씁니다. 완전히 같은 코드입니다.
`join` 이나 여러 `from` 이 겹칠 때는 이쪽이 읽기 쉽고, 짧은 것은 메서드 쪽이 낫습니다. 취향껏 고르면 됩니다.

## 게임 루프에서는 조심한다

LINQ 는 짧아 보이지만 공짜가 아닙니다.

```csharp
void Update()
{
    var 가까운적 = 적들.Where(e => e.거리 < 10f)
                        .OrderBy(e => e.거리)
                        .FirstOrDefault();
}
```

이 한 줄에서 열거자 객체들과 람다를 담는 객체, `OrderBy` 의 정렬용 배열이 생깁니다.
한 번은 아무것도 아닙니다. 초에 예순 번 도는 자리에서는 쓰레기가 계속 쌓입니다.
앞에서 박싱을 볼 때와 같은 이야기입니다. 값이 비싸서가 아니라 **횟수** 때문입니다.

그래서 기준은 이렇게 잡습니다.

- 매 프레임 도는 자리는 손으로 쓴 `for` 가 낫습니다
- 그 밖에는 LINQ 가 낫습니다. 읽기 쉬운 쪽이 대부분 이깁니다

먼저 재 보지도 않고 전부 `for` 로 바꾸는 것은 손해입니다.

## 정리

- `Where`, `Select` 는 `IEnumerable<T>` 에 붙은 **확장 메서드**입니다. `using System.Linq` 가 필요합니다
- 안은 `yield` 라 **부른다고 실행되지 않습니다.** 돌아야 돕니다
- 한 명이 파이프를 끝까지 통과한 뒤 다음 사람이 출발합니다. 중간 리스트가 안 생깁니다
- 두 번 이상 쓸 것이면 `ToList()` 로 굳힙니다. 안 그러면 쓸 때마다 다시 계산합니다
- `OrderBy` 만은 전부 읽어야 하므로 여기서 할당이 한 번 생깁니다
- 매 프레임 도는 자리가 아니면 읽기 쉬운 쪽이 대개 이깁니다

LINQ 한 줄에서 열거자와 람다를 담는 객체가 매번 생긴다고 했습니다.
박싱에서도, 문자열에서도 같은 말을 했습니다. **쌓이면 GC 가 돈다**고요.