[PART12.제네릭·델리게이트·람다·LINQ(15/18)] 지연 실행(Deferred Execution) 감 잡기
Where를 호출한 순간엔 아직 아무 일도 일어나지 않는다 / ToList()·ToArray()가 실행을 "끝내는" 이유 / 다중 열거·캡처된 변수·부수 효과·IQueryable 네 가지 함정
목차
1. [문제 제기] 같은 LINQ 코드가 두 번 다른 결과를 낸다
직전 [13][14]에서 우리는 LINQ를 두 가지 문법(메서드 구문·쿼리 구문)으로 적어 봤습니다. 그때 두 글 모두에서 짧게 짚고 미뤄둔 한 줄이 있습니다 — "Where는 호출되는 순간엔 아무것도 하지 않는다." 이 문장이 실제로 코드를 어떻게 비틀어 놓는지 보여주는 예제 하나로 시작합니다.
Unity 클라이언트에서 자주 마주치는 시나리오 — 적 목록에서 활성 상태인 적의 수를 세고, 같은 목록을 다시 순회하며 각 적의 위치를 출력하는 코드입니다.
List<Enemy> enemies = GetEnemies();
int counter = 0;
var activeEnemies = enemies.Where(e =>
{
counter++; // 디버그용 — 람다가 몇 번 실행되는지 확인
return e.IsActive;
});
int activeCount = activeEnemies.Count(); // (1) 활성 적 수 집계
foreach (var enemy in activeEnemies) // (2) 활성 적 위치 출력
{
Debug.Log($"{enemy.Name} at {enemy.Position}");
}
Debug.Log($"람다 실행 횟수: {counter}");
상식적으로는 enemies.Count가 100이라면 Where 안의 람다가 100번 실행되고 끝나야 할 것 같습니다. 그런데 위 코드를 돌려 보면 출력은 200입니다. 같은 activeEnemies를 두 번 사용했을 뿐인데 람다가 두 번 실행됩니다. 만약 Where 안에서 데이터베이스 쿼리를 던지거나 파일을 읽었다면, 우리가 의도하지 않은 두 번의 IO가 발생하는 셈입니다.
또 다른 시나리오 — 임계값을 변수로 받아 필터를 만든 뒤, 임계값을 바꾸고 결과를 출력합니다.
int threshold = 2;
var query = new[] { 1, 2, 3, 4, 5 }.Where(x => x > threshold);
threshold = 4; // 변수만 바꿨을 뿐
foreach (var x in query) Console.WriteLine(x); // 출력: 5
query 변수를 만든 시점에는 threshold가 2였으니 3, 4, 5가 나와야 할 것 같지만, 실제로는 5 하나만 출력됩니다. 쿼리는 foreach가 시작될 때서야 평가되며, 그 시점의 threshold 값(=4)을 사용합니다.
이 두 사례를 관통하는 한 가지 사실 — LINQ의 Where·Select·OrderBy·Skip·Take 같은 연산자는 호출 즉시 데이터를 처리하지 않습니다. 대신 "어떻게 처리할지에 대한 계획"만 담은 객체를 돌려주고, 실제 처리는 누군가가 그 계획을 열거(enumerate) 하기 시작할 때까지 미뤄집니다. 이것이 지연 실행(deferred execution) 입니다.
지연 실행을 모르고 LINQ를 쓰면 위와 같은 함정이 조용히 코드 안에서 자라납니다. 이 글은 다음을 다룹니다.
- 지연 실행이 정확히 무엇이며, 컴파일러는 이걸 어떻게 만들어내는가 (iterator 클래스와
MoveNext) - 즉시 실행 연산자(
ToList·ToArray·Count·First등)는 무엇이 다른가 - 네 가지 대표 함정 — 다중 열거·캡처된 변수·부수 효과 람다·IQueryable의 SQL 변환 시점
- 해결 패턴 — 언제
ToList()로 결과를 "고정"해야 하는가 - Unity 핫패스에서의 비용 — 매 프레임 LINQ가 만드는 GC 압박
2. [개념 정의] LINQ는 호출되는 순간에 "레시피"만 만든다
2.1 비유 — 음식 레시피 카드와 실제 요리
지연 실행을 가장 정확하게 비유하는 것은 요리 레시피 카드입니다.
var query = list.Where(x => x > 2); 라는 한 줄을 적었다고 가정해 봅시다. 이 시점에 우리가 손에 쥔 것은 "감자 5개에서 무게 2kg 넘는 것을 골라낸다"라고 적힌 종이 한 장입니다. 감자를 실제로 고른 사람은 아무도 없습니다. 카드에는 재료(원본 리스트)와 조건(람다)만 적혀 있습니다.
foreach로 query를 순회하기 시작하면, 그제야 누군가가 카드를 들고 부엌으로 가서 감자를 한 알씩 저울에 올립니다 — MoveNext()가 한 번 호출될 때마다 감자 한 알씩. 그래서 같은 카드를 두 사람이 각자 들고 부엌에 가면(다중 열거), 감자는 두 번 저울에 올라갑니다.
var list = query.ToList(); 라고 적으면 카드를 따라 요리를 한 번 끝까지 마치고, 결과물(고른 감자들)을 새 바구니에 담아둔 것과 같습니다. 그 뒤로는 카드를 또 보지 않아도 바구니에서 꺼내 쓰면 됩니다.
2.2 시각화 — 호출 시점과 실행 시점의 분리

핵심은 두 시점이 분리된다는 것입니다. Where(...)라는 메서드 호출은 "Iterator 객체"라는 작은 박스 하나를 만들 뿐, 람다는 한 번도 호출하지 않습니다. 람다가 실행되는 시점은 누군가가 그 박스의 MoveNext()를 두드리기 시작할 때입니다.
2.3 기본 코드 — 호출 시점과 실행 시점이 다르다는 증거
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
System.Console.WriteLine("[A] Where 호출 직전");
var query = list.Where(x =>
{
System.Console.WriteLine($" 람다 평가: x={x}");
return x > 2;
});
System.Console.WriteLine("[B] Where 호출 직후 (아직 람다 미실행)");
System.Console.WriteLine($"[C] query 타입: {query.GetType().Name}");
System.Console.WriteLine("[D] foreach 시작");
foreach (var v in query) { /* nothing */ }
System.Console.WriteLine("[E] foreach 종료");
}
}
이 코드를 .NET 10에서 돌리면 다음과 같이 출력됩니다.
[A] Where 호출 직전
[B] Where 호출 직후 (아직 람다 미실행)
[C] query 타입: ListWhereIterator`1
[D] foreach 시작
람다 평가: x=1
람다 평가: x=2
람다 평가: x=3
람다 평가: x=4
람다 평가: x=5
[E] foreach 종료
[A]와 [B] 사이에서 Where는 호출됐지만 람다는 한 번도 실행되지 않았습니다. 람다가 처음 실행되는 시점은 [D]의 foreach가 시작된 다음입니다. query의 실제 타입도 List<int>가 아니라 ListWhereIterator<int> — LINQ가 내부적으로 만들어놓은 Iterator 클래스의 인스턴스입니다.
yield return— 반복자 반환 (yield키워드) 메서드가 한 번에 모든 결과를 반환하는 대신, 호출자가 다음 값을 요청할 때마다 한 개씩 결과를 돌려주도록 만든다. 컴파일러가 이 메서드를 자동으로 상태 머신(state machine) 클래스로 변환한다.
예시:yield return item;— 현재 값을 반환하고 실행을 일시정지, 다음 호출 시 그 자리부터 재개
2.4 IL 분석 — Where 호출은 정적 메서드 한 번에 끝난다
아래 코드에서 Where(...) 호출까지만 떼어낸 IL을 보면, 어떤 데이터 처리도 일어나지 않는다는 것이 명확해집니다.
public static void ToListBlock(List<int> list)
{
var result = list.Where(x => x > 0).ToList();
}
.method public hidebysig static void ToListBlock(class List`1<int32> list) cil managed
{
// (1) 람다 인스턴스 캐싱 — 비캡처 람다는 정적 필드에 한 번만 생성
IL_0000: ldarg.0
IL_0001: ldsfld class Func`2<int32, bool> Sample/'<>c'::'<>9__1_0'
IL_0006: dup
IL_0007: brtrue.s IL_0020 // 이미 캐시되어 있으면 점프
IL_0009: pop
IL_000a: ldsfld class Sample/'<>c' Sample/'<>c'::'<>9'
IL_000f: ldftn instance bool Sample/'<>c'::'<ToListBlock>b__1_0'(int32)
IL_0015: newobj instance void Func`2<int32, bool>::.ctor(object, native int)
IL_001a: dup
IL_001b: stsfld class Func`2<int32, bool> Sample/'<>c'::'<>9__1_0'
// (2) Where 호출 — 정적 메서드 호출 한 번. 데이터 처리 없음
IL_0020: call class IEnumerable`1<!!0> Enumerable::Where<int32>(...)
// (3) ToList 호출 — 여기서야 실제 열거가 시작됨
IL_0025: call class List`1<!!0> Enumerable::ToList<int32>(...)
IL_002a: pop
IL_002b: ret
}
Where는 IL_0020 한 줄의 정적 메서드 호출입니다. 이 호출이 반환하는 것은 IEnumerable<int>라는 인터페이스 참조이고, 런타임에는 앞서 본 ListWhereIterator<int> 인스턴스가 들어 있습니다. Where 자체에는 어떤 반복문도, 어떤 분기도 없습니다. 그저 "원본 리스트와 람다를 묶어 놓은 작은 객체 하나"를 만들고 끝내는 것입니다.
진짜 일이 시작되는 곳은 IL_0025의 ToList 입니다. ToList의 내부 구현은 대략 이렇습니다 (System.Linq 소스).
public static List<TSource> ToList<TSource>(this IEnumerable<TSource> source)
{
var list = new List<TSource>();
foreach (TSource item in source) // ← 여기서 GetEnumerator + MoveNext 시작
list.Add(item);
return list;
}
ToList가 내부에서 foreach를 도는 동안, 그제야 원본 List<int>가 한 요소씩 꺼내져서 람다(x > 0)로 검사를 받게 됩니다. Where 호출과 ToList 호출 사이의 시간 동안에는, 우리가 만든 람다는 한 번도 호출되지 않습니다.
2.5 즉시 실행 연산자 — 누가 열거를 "시작"시키는가
지연 실행 연산자가 "레시피 카드만 추가"한다면, 즉시 실행(immediate execution) 연산자는 "레시피를 들고 부엌에 들어가는" 역할을 합니다. 즉시 실행 연산자의 공통점은 자기가 직접 foreach를 돈다는 것입니다.
| 분류 | 대표 연산자 | 반환 타입 | 호출 즉시 일어나는 일 |
|---|---|---|---|
| 지연 실행 | Where, Select, OrderBy*, Skip, Take, SelectMany, Distinct, Concat, Reverse, Cast |
IEnumerable<T> |
Iterator 객체 생성만, 데이터 처리 없음 |
| 즉시 실행 — 컬렉션 반환 | ToList, ToArray, ToDictionary, ToHashSet, ToLookup |
List<T>, T[], Dictionary<,> 등 |
끝까지 열거하여 새 컬렉션에 담음 |
| 즉시 실행 — 단일 값 반환 | Count, Sum, Average, Min, Max, First, Single, Last, Any, All, Contains, Aggregate |
int, T, bool 등 |
필요한 만큼 열거하여 결과 반환 |
OrderBy에 별표를 단 이유 — OrderBy는 반환 타입은 IOrderedEnumerable<T>(지연) 이지만, 실제로 열거가 시작되면 모든 요소를 한 번에 메모리에 올린 뒤 정렬합니다. "결과의 첫 번째 요소를 꺼내려면 모든 요소를 먼저 봐야 한다"는 정렬의 본질 때문입니다. 그래서 호출 시점은 지연이지만 열거 시점에 모든 요소가 평가됩니다.
First·Any처럼 부분 열거만으로 끝나는 연산자도 있습니다. First()는 첫 요소가 나올 때까지만 MoveNext()를 호출하고 멈춥니다. 그래서 list.Where(...).First()처럼 사슬을 만들면, 람다는 평균적으로 원본의 일부에만 호출됩니다.
암기 팁 — IEnumerable<T>을 반환하면 지연, 다른 타입(컬렉션·숫자·요소 자체)을 반환하면 즉시 실행. 단순한 규칙입니다.
3. [내부 동작] Iterator 클래스와 상태 머신
3.1 컴파일러는 yield return을 상태 머신으로 변환한다
LINQ의 지연 실행은 마법이 아닙니다. C# 2.0(2005년)에 도입된 yield return 키워드 와 컴파일러의 자동 변환이 그 뿌리입니다. 우리가 직접 단순화된 Where를 만들어 보면 LINQ가 내부에서 무엇을 하는지가 한눈에 보입니다.
public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
{
yield return item;
}
}
}
이 메서드를 호출했을 때 람다는 한 번도 실행되지 않습니다. 컴파일러가 이 메서드 본문을 그대로 실행하지 않기 때문입니다. 컴파일러는 위 메서드를 발견하면 본문을 들어내고, 같은 동작을 흉내 내는 상태 머신(state machine) 클래스를 자동으로 만든 뒤, 메서드 본문은 "그 클래스의 인스턴스 하나를 만들어 반환"하는 것으로 바꿔치기합니다.
3.2 시각화 — Iterator 객체와 상태 머신의 동작

상태 머신의 핵심은 state 필드 입니다. MoveNext()는 호출될 때마다 현재 state를 보고 "이전에 어디까지 했는지"를 기억해 그 자리부터 이어서 실행합니다. yield return이 있던 자리는 "여기서 일단 멈추고, 다음 호출에서 이 자리부터 재개"하는 분기점이 됩니다. 이게 바로 한 번에 한 요소씩 꺼내는 "풀(pull) 모델" 의 정체입니다.
3.3 LINQ가 만드는 진짜 Iterator 클래스 — ListWhereIterator
LINQ는 한발 더 나아가 소스 타입별로 최적화된 iterator를 갖고 있습니다. 우리가 본 list.Where(...)의 반환 타입이 단순한 WhereIterator가 아니라 ListWhereIterator 였던 이유가 여기 있습니다.
var list = new List<int> { 1, 2, 3, 4, 5 };
var query = list.Where(x => x > 2);
Console.WriteLine(query.GetType().FullName);
// 출력: System.Linq.Enumerable+ListWhereIterator`1[[System.Int32, ...]]
IEnumerable<int> ie = list; // 인터페이스로 캐스팅
var query2 = ie.Where(x => x > 2);
Console.WriteLine(query2.GetType().FullName);
// 출력: System.Linq.Enumerable+IEnumerableWhereIterator`1[[System.Int32, ...]]
같은 Where 호출이지만 소스가 List<T>로 정적으로 알려져 있으면 ListWhereIterator (인덱서 직접 접근으로 더 빠름), 일반 IEnumerable<T>로 알려져 있으면 IEnumerableWhereIterator (안전하게 GetEnumerator 사용)로 갈라집니다.
Iterator클래스 명명 규칙(.NET 9+) 변경점 과거 .NET Framework / .NET Core 시절에는WhereListIterator·WhereEnumerableIterator라는 이름이었습니다. .NET 9에서 LINQ 내부가 리팩터링되며ListWhereIterator·IEnumerableWhereIterator로 이름이 바뀌었습니다. 동작 원리는 동일하지만, 코드에서GetType().Name으로 체크하는 진단 코드를 짜면 런타임 버전에 따라 결과가 다를 수 있습니다 — 운영 환경에서 의존하지 마십시오.
3.4 foreach는 IL에서 어떻게 풀어지는가
foreach는 문법 설탕입니다. 컴파일러는 foreach 한 줄을 GetEnumerator → MoveNext 루프 → Current → Dispose 의 명시적 호출 묶음으로 풀어냅니다. 직접 IL을 떼어 보면 분명합니다.
public static void ForeachBlock(IEnumerable<int> q)
{
foreach (var x in q)
System.Console.WriteLine(x);
}
.method public hidebysig static void ForeachBlock(class IEnumerable`1<int32> q) cil managed
{
.locals init ([0] class IEnumerator`1<int32>)
// (1) GetEnumerator 호출 — Iterator 객체를 받아옴 (Iterator는 자기 자신을 반환)
IL_0000: ldarg.0
IL_0001: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator()
IL_0006: stloc.0
.try
{
IL_0007: br.s IL_0014 // 첫 진입은 MoveNext부터
IL_0009: ldloc.0
IL_000a: callvirt instance !0 IEnumerator`1<int32>::get_Current() // (3) Current 읽기
IL_000f: call void Console::WriteLine(int32)
IL_0014: ldloc.0
IL_0015: callvirt instance bool IEnumerator::MoveNext() // (2) MoveNext 호출
IL_001a: brtrue.s IL_0009 // true면 루프 계속
IL_001c: leave.s IL_0028
}
finally
{
// (4) Dispose 호출 — IDisposable이면 반드시 정리
IL_001e: ldloc.0
IL_001f: brfalse.s IL_0027
IL_0021: ldloc.0
IL_0022: callvirt instance void IDisposable::Dispose()
IL_0027: endfinally
}
}
foreach 한 줄을 풀면 IL_0001(GetEnumerator) → IL_0015(MoveNext) → IL_000a(Current)의 호출 사이클이 보입니다. 이 사이클의 매 회전마다 우리가 만든 람다가 한 번씩 실행됩니다 (정확히는 MoveNext가 람다를 호출). 그래서 같은 IEnumerable을 두 번 foreach 돌리면 람다는 두 배만큼 실행되는 것입니다 — 다음 함정 절에서 자세히 다룹니다.
finally에 들어 있는 Dispose 호출도 중요한 포인트입니다. iterator는 보통 내부에 다른 enumerator(원본 소스의 enumerator)를 들고 있고, 이걸 정리해야 자원 누수가 없습니다. foreach가 이걸 자동으로 해 준다는 것이 foreach를 직접 while로 풀어 쓰는 것보다 안전한 이유입니다.
4. [실전 적용] 언제 ToList()로 결과를 고정해야 하는가
4.1 판단 기준 — 결과가 한 번 쓰이는가, 여러 번 쓰이는가
지연 실행을 잘 활용하는 핵심 기준은 단 한 가지입니다.
그 쿼리 결과를 몇 번 쓸 것인가?
| 사용 패턴 | 권장 |
|---|---|
결과를 단 한 번 foreach로 순회한다 |
그대로 둠 (지연 실행 유지) |
같은 결과로 Count + foreach 두 번 한다 |
ToList() 또는 ToArray()로 즉시 실행 |
DB 쿼리(IQueryable) 결과를 두 번 본다 |
반드시 ToList() (DB 왕복 1회로 고정) |
| 큰 컬렉션을 필터링해서 일부만 첫 매치 찾기 | 그대로 둠 — First()가 부분 열거로 빠르게 끝냄 |
Unity Update에서 매 프레임 같은 LINQ |
Awake/Start에서 한 번 ToList() 후 재사용 |
4.2 Before/After — 다중 열거를 ToList()로 고정
Before — 같은 IEnumerable을 두 번 사용해서 람다가 두 번 실행됩니다.
public static int CountAndDump(List<int> list)
{
int counter = 0;
var query = list.Where(x =>
{
counter++; // 카운터로 람다 실행 횟수 측정
return x > 0;
});
int count = query.Count(); // 1차 열거: counter += list.Count
foreach (var v in query) // 2차 열거: counter += list.Count
{
// 사용
}
return counter; // list.Count의 2배
}
After — ToList()로 한 번만 평가하고, 이후 둘 다 메모리에서 처리합니다.
public static int CountAndDumpFixed(List<int> list)
{
int counter = 0;
var materialized = list.Where(x =>
{
counter++;
return x > 0;
}).ToList(); // 즉시 1회 열거 후 List로 고정
int count = materialized.Count; // List.Count: O(1), 람다 호출 없음
foreach (var v in materialized) // 메모리 List 순회: 람다 호출 없음
{
// 사용
}
return counter; // list.Count와 동일 (1회만)
}
Before의 query.Count()는 IEnumerable<T>의 확장 메서드 Count()를 호출하므로 전체를 한 번 더 열거합니다. (참고로 List<T>의 인스턴스 프로퍼티 Count는 O(1)이지만, IEnumerable<T>로 노출되는 순간 그 정보를 잃어버립니다.) After는 materialized.Count(인스턴스 프로퍼티)이므로 람다 호출 없이 즉시 값을 반환합니다.
4.3 IL 분석 — ToList가 끼면 람다 호출 시점이 한 곳으로 모인다
public static void Multi()
{
var list = new List<int> { 1, 2, 3 };
int counter = 0;
var query = list.Where(x => { counter++; return x > 1; });
var c1 = query.Count();
var c2 = query.Count();
}
위 코드를 컴파일하면 IL의 핵심부에는 Where가 한 번, Count가 두 번 호출됩니다.
// query = list.Where(...) — Iterator 객체 1개 생성
call class IEnumerable`1<!!0> Enumerable::Where<int32>(...)
stloc.s query
// c1 = query.Count() — 첫 번째 호출
ldloc.s query
call int32 Enumerable::Count<int32>(class IEnumerable`1<!!0>)
// c2 = query.Count() — 두 번째 호출
ldloc.s query
call int32 Enumerable::Count<int32>(class IEnumerable`1<!!0>)
Count의 내부 구현은 다음과 같습니다 (개념적, .NET 10 기준).
public static int Count<TSource>(this IEnumerable<TSource> source)
{
if (source is ICollection<TSource> col) return col.Count; // 빠른 경로
int n = 0;
foreach (var _ in source) n++; // 일반 경로: 전체 열거
return n;
}
여기서 source는 ListWhereIterator<int>이고, 이 클래스는 ICollection<T>를 구현하지 않으므로 빠른 경로가 아닌 일반 경로로 빠집니다. 즉 Count 내부에서 foreach가 돌고, 그 foreach가 MoveNext를 호출할 때마다 우리가 준 람다가 실행됩니다. Count를 두 번 호출했으니 람다도 두 번씩 — 총 6번 실행되는 것입니다.
실측 결과 — 위 Multi() 메서드에서 list가 5개 요소면 counter가 10 으로 출력됩니다 (5 + 5). list가 100만 개라면 200만 번이 됩니다.
ToList()를 끼우면 IL의 모양이 다음처럼 바뀝니다.
// query = list.Where(...).ToList() — 즉시 1회 열거
call class IEnumerable`1<!!0> Enumerable::Where<int32>(...)
call class List`1<!!0> Enumerable::ToList<int32>(...)
stloc.s materialized
// c1 = materialized.Count — List<T>의 인스턴스 프로퍼티 (인라인)
ldloc.s materialized
callvirt int32 List`1<int32>::get_Count() // 람다 호출 없음
// c2 = materialized.Count
ldloc.s materialized
callvirt int32 List`1<int32>::get_Count() // 람다 호출 없음
ToList가 한 번 열거를 마치고 결과를 List<int>에 담는 순간, 그 뒤로 등장하는 Count는 List<T>::get_Count() 의 인스턴스 호출로 바뀝니다. List<T>.Count는 내부 필드를 그대로 반환하는 O(1) 연산이고, 람다는 더 이상 호출되지 않습니다.
4.4 Unity 핫패스 — Update에서 LINQ가 만드는 GC 압박
Unity 모바일 게임에서 가장 자주 보이는 안티 패턴 중 하나가 Update에서 LINQ를 매 프레임 호출하는 코드입니다.
Before — 매 프레임 LINQ 사슬을 다시 만듭니다.
public class EnemyManager : MonoBehaviour
{
[SerializeField] private List<Enemy> _allEnemies;
private void Update()
{
// 매 프레임: WhereIterator 1개 + 람다용 클로저(필요 시) 1개 할당
var nearby = _allEnemies
.Where(e => Vector3.Distance(e.transform.position, transform.position) < 10f)
.ToList(); // 매 프레임 List<Enemy> 1개 더 할당
foreach (var e in nearby) e.Tick();
}
}
이 코드의 한 프레임 동안 발생하는 힙 할당:
WhereIterator<Enemy>인스턴스 1개Func<Enemy, bool>람다 인스턴스 (캡처가 있으므로 매번 새로 —transform을 캡처)- 캡처 클로저 클래스(
<>c__DisplayClass) 인스턴스 1개 ToList가 만드는List<Enemy>1개 + 내부 배열 1개 (Enemy[])foreach가 호출하는List<Enemy>.Enumerator는 구조체이므로 boxing 없음 (다행)
60FPS면 초당 약 240+ 개의 작은 객체가 힙에 쏟아지고, 그만큼 GC.Alloc이 누적됩니다. Unity의 Boehm GC는 stop-the-world 방식이라 누적된 작은 객체들이 한 번에 GC를 트리거하면 프레임 스파이크(수 ms~수십 ms) 가 발생합니다. iOS/Android에서 IL2CPP로 빌드해도 GC는 여전히 Boehm이며, IL2CPP가 LINQ를 사라지게 만드는 것은 아닙니다.
After — LINQ 자체를 빼고 for 루프 + 미리 캐시한 버퍼를 사용합니다.
public class EnemyManager : MonoBehaviour
{
[SerializeField] private List<Enemy> _allEnemies;
private readonly List<Enemy> _nearbyBuffer = new List<Enemy>(64); // 재사용 버퍼
private void Update()
{
var origin = transform.position;
_nearbyBuffer.Clear(); // 용량 유지, 길이만 0으로
// 매 프레임 0 byte 할당
for (int i = 0; i < _allEnemies.Count; i++)
{
var e = _allEnemies[i];
if ((e.transform.position - origin).sqrMagnitude < 100f) // sqrMagnitude로 sqrt 회피
_nearbyBuffer.Add(e);
}
for (int i = 0; i < _nearbyBuffer.Count; i++)
_nearbyBuffer[i].Tick();
}
}
After 버전은 매 프레임 0바이트 할당입니다. 거리 비교도 Vector3.Distance(내부에 Mathf.Sqrt가 있음) 대신 sqrMagnitude로 제곱 비교를 해서 더 빨라집니다. 초기에 _nearbyBuffer = new List<Enemy>(64) 로 적당한 용량을 확보해두면 Add에서도 내부 배열 재할당이 거의 일어나지 않습니다.
Unity 핫패스의 일반 원칙 —Update·FixedUpdate·LateUpdate·OnTriggerStay·OnCollisionStay같이 매 프레임/매 틱 호출되는 메서드 안에서는 LINQ를 호출하지 마십시오.Awake·Start처럼 한 번만 도는 곳, 또는 이벤트 응답처럼 빈도가 낮은 곳에서는 LINQ를 자유롭게 써도 됩니다.
5. [함정과 주의사항] 지연 실행이 만드는 네 가지 함정
5.1 함정 1 — 다중 열거 (Multiple Enumeration)
가장 흔한 함정입니다. 4.2 절의 Before가 정확히 이 사례입니다. 핵심은 IEnumerable<T> 변수를 두 번 사용하면 그 안의 람다·연산이 두 번 실행된다는 것입니다.
ReSharper / Rider는 이 패턴을 발견하면 "Possible multiple enumeration of IEnumerable" 경고를 띄웁니다. Visual Studio는 기본 분석기로는 잡지 않으므로 손으로 의식해야 합니다.
// ❌ 다중 열거 — 같은 query를 여러 번 사용
public static void ProcessOrders(IEnumerable<Order> orders)
{
var pending = orders.Where(o => o.Status == OrderStatus.Pending);
Console.WriteLine($"대기: {pending.Count()}"); // 1차 열거
if (pending.Any()) // 2차 열거
{
foreach (var o in pending) Notify(o); // 3차 열거
}
}
// ✅ 한 번만 평가하고 List로 고정
public static void ProcessOrdersFixed(IEnumerable<Order> orders)
{
var pending = orders.Where(o => o.Status == OrderStatus.Pending).ToList();
Console.WriteLine($"대기: {pending.Count}"); // List.Count: O(1)
if (pending.Count > 0)
{
foreach (var o in pending) Notify(o);
}
}
orders 자체가 DB나 네트워크 소스라면 ❌는 같은 쿼리를 세 번 던집니다. ✅는 한 번에 끝납니다.
매개변수 타입의 신호 — IEnumerable<T>로 받는다는 것
메서드 매개변수를 IEnumerable<T>로 받는 것은 의도적으로 "한 번만 순회하겠다"는 약속입니다. 만약 메서드 안에서 두 번 순회하려면 매개변수 타입을 IReadOnlyCollection<T> 또는 IReadOnlyList<T> 로 바꾸거나, 메서드 첫 줄에서 var local = source as IList<T> ?? source.ToList(); 처럼 List화 후 사용하는 것이 안전합니다.
5.2 함정 2 — 캡처된 변수가 실행 시점에 달라진다
람다는 외부 변수를 값이 아닌 참조로 캡처합니다 (정확히는 컴파일러가 만든 <>c__DisplayClass라는 클로저 클래스의 필드). 즉 람다가 실행되는 순간의 값을 사용합니다.
// ❌ 변수 변경 후 실행
int threshold = 2;
var query = new[] { 1, 2, 3, 4, 5 }.Where(x => x > threshold);
threshold = 4;
foreach (var x in query) Console.WriteLine(x); // 출력: 5만 (3, 4 누락!)
이 함정은 함정 1과 결합되면 더 위험합니다 — 두 번째 열거 시점의 값이 첫 번째와 다를 수 있기 때문입니다.
int threshold = 2;
var query = list.Where(x => x > threshold);
threshold = 0;
var first = query.ToList(); // threshold = 0으로 평가
threshold = 100;
var second = query.ToList(); // threshold = 100으로 평가 (다른 결과!)
해결책 1 — 캡처를 의도적으로 끊으려면 ToList()로 즉시 실행해서 결과를 고정합니다.
// ✅ 즉시 실행 — threshold가 변해도 결과 불변
int threshold = 2;
var snapshot = new[] { 1, 2, 3, 4, 5 }.Where(x => x > threshold).ToList();
threshold = 4;
foreach (var x in snapshot) Console.WriteLine(x); // 출력: 3, 4, 5
해결책 2 — 람다 안에서 임계값을 인자처럼 받아 외부 변수에 의존하지 않게 만듭니다 (정적 람다, C# 9+).
static IEnumerable<int> FilterAbove(IEnumerable<int> source, int threshold)
=> source.Where(static (x, t) => x > t, threshold); // 정적 람다 — 캡처 없음
(엄밀히 Where의 두 매개변수 오버로드는 인덱스용이므로 위 시그니처는 예시적 표기입니다. 실제로는 람다 안에서 외부 변수를 읽지 않도록 시그니처를 다듬는 패턴을 의미합니다.)
5.3 함정 3 — 부수 효과를 가진 람다는 호출 횟수가 모호해진다
람다 안에서 외부 상태를 변경하는 코드는 지연 실행과 결합되면 언제 몇 번 호출되는지 예측하기 어렵습니다.
// ❌ 부수 효과 람다 — 호출 횟수가 호출자 코드에 따라 달라짐
int processedCount = 0;
var query = orders.Select(o =>
{
processedCount++; // 부수 효과
return Process(o);
});
var first = query.First(); // 부분 열거 — processedCount는 1 정도
var all = query.ToList(); // 전체 열거 — processedCount는 list.Count + 1
First()는 첫 매치만 찾으면 멈춥니다. ToList()는 전체를 봅니다. 같은 query 변수에 대한 두 호출이 카운터를 다르게 증가시키므로 로그·통계·캐시 같은 것을 람다 안에서 갱신하는 패턴은 깨집니다.
// ✅ 람다는 순수하게, 부수 효과는 명시적인 foreach 안에서
var processed = orders.Select(Process).ToList(); // 람다는 변환만, 부수 효과 없음
foreach (var p in processed)
{
processedCount++; // 부수 효과는 여기서만
Log(p);
}
원칙 — LINQ 람다는 순수 함수(같은 입력에 같은 출력, 외부 상태 변경 없음) 로 작성합니다. 외부 상태를 바꿔야 하면 LINQ 사슬 밖의 명시적foreach또는for안으로 옮깁니다.
5.4 함정 4 — IQueryable의 SQL 변환 시점
LINQ는 두 종류의 소스를 다룹니다.
IEnumerable<T>(LINQ to Objects): 인메모리 컬렉션. 람다는 C# 코드 그대로 실행.IQueryable<T>(Entity Framework / LINQ to SQL): 원격 데이터베이스. 람다가 표현식 트리(Expression Tree) 로 분석되어 SQL로 번역되어 DB에 전송.
IQueryable도 지연 실행 입니다 — 그리고 그 함정은 훨씬 큽니다. "언제 SQL이 만들어지고 실행되는가"가 코드 흐름에서 잘 안 보이기 때문입니다.
// EF Core 예
public IEnumerable<User> GetActiveUsers(MyDbContext ctx)
{
return ctx.Users.Where(u => u.IsActive); // SQL 아직 미발송. IQueryable<User> 반환
}
// 호출자
var users = GetActiveUsers(ctx);
foreach (var u in users) { ... } // 여기서 SQL 발송 (1회)
foreach (var u in users) { ... } // 여기서 SQL 또 발송 (1회 더!)
게다가 람다 안에 DB가 모르는 C# 메서드가 들어가면 EF Core는 두 가지 중 하나를 합니다.
- 표현식을 SQL로 번역할 수 없으면 예외 (EF Core 3+ 기본 동작)
- 또는 자동 클라이언트 평가 — DB에서 모든 행을 받아온 뒤 메모리에서 필터링 (재앙급 성능)
// ❌ 위험 — IsValidName이 DB에 없으면 전체 행을 클라이언트로 가져온 뒤 필터
var users = ctx.Users.Where(u => IsValidName(u.Name)).ToList();
// ✅ 안전 — DB에서 처리 가능한 식만 사용
var users = ctx.Users.Where(u => u.Name != null && u.Name.Length > 0).ToList();
IQueryable에서의 ToList() 의무
IQueryable 결과를 메서드 밖으로 노출할 때, 그대로 IQueryable<T>/IEnumerable<T>로 반환하면 호출자가 어디서 SQL이 발송되는지 모르는 상태가 됩니다. 또한 호출자가 DbContext를 이미 dispose한 상태에서 enumerate하면 런타임 예외(ObjectDisposedException)가 납니다. 그래서 레포지토리 계층은 거의 항상 ToList()/ToArray() 로 결과를 고정해서 반환합니다.
// ✅ 레포지토리 계층은 항상 즉시 실행으로 결과를 고정해 반환
public List<User> GetActiveUsers(MyDbContext ctx)
=> ctx.Users.Where(u => u.IsActive).ToList(); // 메서드 종료 시점에 SQL 1회
5.5 함정 5 (Unity 보너스) — IEnumerable을 인자로 받아 두 번 순회
Unity 컴포넌트 코드에서 자주 보이는 패턴입니다.
// ❌ 인자가 IEnumerable<T>인데 두 번 순회
public void SpawnEffects(IEnumerable<Vector3> positions)
{
if (!positions.Any()) return; // 1차 열거
foreach (var p in positions) Spawn(p); // 2차 열거 — 람다 두 번
}
// ✅ 매개변수 타입을 IReadOnlyList<T>로 강제하거나, 진입 시점에 List로 고정
public void SpawnEffects(IReadOnlyList<Vector3> positions)
{
if (positions.Count == 0) return; // O(1)
for (int i = 0; i < positions.Count; i++) Spawn(positions[i]); // GC.Alloc 0
}
매개변수 타입을 IReadOnlyList<T>/IReadOnlyCollection<T>로 받으면 호출자에게 "여러 번 순회할 거니까 List나 배열로 넘겨라" 는 의도가 시그니처에 박힙니다. Unity 모바일 핫패스에서는 IEnumerable<T> 매개변수를 의식적으로 피하는 것이 일반적입니다.
6. [C# 버전별 변화] 지연 실행은 LINQ와 함께 등장했다
지연 실행 자체는 C# 2.0(2005)에서 yield return이 도입되면서 시작됐습니다. 이후 LINQ가 C# 3.0(2007)에서 등장하며 표준 라이브러리 차원에서 본격적으로 활용됐습니다. 이후의 변화는 "지연 실행 자체"가 바뀐 것이 아니라, "지연 실행과 잘 어울리는 패턴이 추가"된 흐름입니다.
| C# 버전 | 변화 | 지연 실행과의 관계 |
|---|---|---|
| 2.0 (2005) | yield return / yield break |
지연 실행의 기반 — 컴파일러가 상태 머신 자동 생성 |
| 3.0 (2007) | LINQ, 람다, 확장 메서드, 익명 타입 | 지연 실행을 표준 라이브러리에 적용 — Where/Select 등 |
| 5.0 (2012) | async/await |
별개 메커니즘이지만 같은 "상태 머신 변환" 원리를 사용 |
| 8.0 (2019) | IAsyncEnumerable<T> / await foreach |
지연 실행 + 비동기 — 한 요소씩 비동기로 끌어옴 |
| 9.0 (2020) | 정적 람다(static x => ...) |
캡처 없는 람다임을 명시 — 함정 2/3 방지에 유용 |
| 10.0 (2021) | 람다 자연 타입·반환 타입 | LINQ 람다 작성 편의성 향상 |
| 13.0 (2024) | 부분 LINQ 메서드 컴파일 시 최적화 강화 | 동작 변화 없음, 성능 개선만 |
6.1 C# 8.0 — IAsyncEnumerable<T> (지연 실행의 비동기 확장)
// 비동기 지연 실행 — 한 요소씩 비동기로 꺼냄
public static async IAsyncEnumerable<Order> StreamOrdersAsync(int channelId)
{
await foreach (var line in ReadLinesAsync(channelId)) // 한 줄씩 비동기 수신
{
if (TryParseOrder(line, out var order))
yield return order; // 비동기 yield
}
}
// 호출자
await foreach (var order in StreamOrdersAsync(42))
{
Process(order);
}
IAsyncEnumerable<T>은 "한 요소씩 비동기로 끌어오는" 지연 실행입니다. 네트워크 스트림·SignalR·gRPC 스트리밍 등에서 핵심적으로 쓰이며, 같은 함정(다중 열거·캡처 변수)이 그대로 적용됩니다.
6.2 C# 9.0 — 정적 람다 (static 람다)
지연 실행 함정 중 캡처와 관련된 함정 2/3는 람다가 외부 변수를 캡처할 때 발생합니다. C# 9.0의 정적 람다는 컴파일러에게 "이 람다는 외부를 캡처하지 않는다"고 명시해서 캡처가 생기면 컴파일 에러를 발생시킵니다.
// Before (C# 8 이하) — 실수로 캡처해도 모름
int threshold = 2;
var q = list.Where(x => x > threshold); // threshold 캡처
// After (C# 9+) — 정적 람다로 캡처 방지
var q = list.Where(static x => x > 0); // ✅ 캡처 없음 — 컴파일러가 보장
// var q2 = list.Where(static x => x > threshold); // ❌ 컴파일 에러: 정적 람다는 threshold를 캡처할 수 없음
정적 람다는 클로저 클래스 인스턴스를 만들지 않으므로 GC 할당도 0이고, 캡처 함정도 발생하지 않습니다. Unity 핫패스에서 LINQ를 어쩔 수 없이 써야 한다면 람다에 static을 붙이는 것이 작은 안전망이 됩니다.
7. [정리] 지연 실행 핵심 체크리스트
| # | 항목 | 핵심 |
|---|---|---|
| 1 | Where/Select 호출 시점에는 아무 일도 안 일어남 |
호출 결과는 "레시피 카드"(Iterator 객체)일 뿐, 람다는 한 번도 실행 안 됨 |
| 2 | 실제 실행은 누군가가 MoveNext()를 부를 때 시작 |
foreach, ToList, Count 같은 즉시 실행 연산자가 트리거 |
| 3 | 즉시 실행 식별법: 반환 타입이 IEnumerable<T>가 아니면 즉시 실행 |
ToList/ToArray/Count/First/Any/Sum 등 모두 즉시 실행 |
| 4 | OrderBy는 호출은 지연이지만 실행 시 전체 열거 |
정렬은 첫 요소 반환 전에 모든 요소가 필요 |
| 5 | 함정 1 — 다중 열거: 같은 IEnumerable을 두 번 사용하면 람다도 두 번 실행 |
ReSharper 경고 의식, ToList()로 고정 |
| 6 | 함정 2 — 캡처 변수: 람다가 캡처한 외부 변수는 실행 시점의 값을 사용 | 변경 가능한 변수를 캡처하지 말거나 정적 람다 사용 |
| 7 | 함정 3 — 부수 효과 람다: 호출 횟수가 호출자에 따라 달라짐 | 람다는 순수 함수로, 부수 효과는 명시적 foreach에서 |
| 8 | 함정 4 — IQueryable: SQL 변환·전송이 열거 시점에 일어남 |
레포지토리는 ToList()로 고정해서 반환 |
| 9 | Unity 핫패스에서는 LINQ 금지 | Update/FixedUpdate에서는 for + 재사용 버퍼 |
| 10 | 결과를 두 번 이상 쓸 거면 무조건 ToList() |
한 번만 쓸 거면 그대로 두기 |
7.1 한 줄 요약
Where는 약속이고,ToList는 정산이다. 약속만 하면 청구서가 안 날아오지만, 정산을 시작하는 순간 그동안 쌓인 모든 약속(람다·필터·소스)이 한 번에 실행된다. 같은 약속을 두 번 정산하면 청구서도 두 번 날아온다.
7.2 다음 글 예고
지연 실행을 이해했다면 그다음 자연스러운 질문은 "LINQ로 매번 임시 객체를 만드는데, 그 객체에 이름이 없는 형태(new { player.Name, pet.PetName })로 자주 등장하는 이건 뭔가?" 입니다. 다음 글 [16] 익명 타입(Anonymous Type) 에서 LINQ가 만들어내는 임시 타입의 정체와 한계를 다룹니다.