반응형

[PART12.제네릭·델리게이트·람다·LINQ(15/18)] 지연 실행(Deferred Execution) 감 잡기

Where를 호출한 순간엔 아직 아무 일도 일어나지 않는다 / ToList()·ToArray()가 실행을 "끝내는" 이유 / 다중 열거·캡처된 변수·부수 효과·IQueryable 네 가지 함정


1. [문제 제기] 같은 LINQ 코드가 두 번 다른 결과를 낸다

직전 [13][14]에서 우리는 LINQ를 두 가지 문법(메서드 구문·쿼리 구문)으로 적어 봤습니다. 그때 두 글 모두에서 짧게 짚고 미뤄둔 한 줄이 있습니다 — "Where는 호출되는 순간엔 아무것도 하지 않는다." 이 문장이 실제로 코드를 어떻게 비틀어 놓는지 보여주는 예제 하나로 시작합니다.

Unity 클라이언트에서 자주 마주치는 시나리오 — 적 목록에서 활성 상태인 적의 수를 세고, 같은 목록을 다시 순회하며 각 적의 위치를 출력하는 코드입니다.

C#
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가 발생하는 셈입니다.

또 다른 시나리오 — 임계값을 변수로 받아 필터를 만든 뒤, 임계값을 바꾸고 결과를 출력합니다.

C#
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를 쓰면 위와 같은 함정이 조용히 코드 안에서 자라납니다. 이 글은 다음을 다룹니다.

  1. 지연 실행이 정확히 무엇이며, 컴파일러는 이걸 어떻게 만들어내는가 (iterator 클래스와 MoveNext)
  2. 즉시 실행 연산자(ToList·ToArray·Count·First 등)는 무엇이 다른가
  3. 네 가지 대표 함정 — 다중 열거·캡처된 변수·부수 효과 람다·IQueryable의 SQL 변환 시점
  4. 해결 패턴 — 언제 ToList()로 결과를 "고정"해야 하는가
  5. Unity 핫패스에서의 비용 — 매 프레임 LINQ가 만드는 GC 압박

2. [개념 정의] LINQ는 호출되는 순간에 "레시피"만 만든다

2.1 비유 — 음식 레시피 카드와 실제 요리

지연 실행을 가장 정확하게 비유하는 것은 요리 레시피 카드입니다.

var query = list.Where(x => x > 2); 라는 한 줄을 적었다고 가정해 봅시다. 이 시점에 우리가 손에 쥔 것은 "감자 5개에서 무게 2kg 넘는 것을 골라낸다"라고 적힌 종이 한 장입니다. 감자를 실제로 고른 사람은 아무도 없습니다. 카드에는 재료(원본 리스트)와 조건(람다)만 적혀 있습니다.

foreachquery를 순회하기 시작하면, 그제야 누군가가 카드를 들고 부엌으로 가서 감자를 한 알씩 저울에 올립니다 — MoveNext()가 한 번 호출될 때마다 감자 한 알씩. 그래서 같은 카드를 두 사람이 각자 들고 부엌에 가면(다중 열거), 감자는 두 번 저울에 올라갑니다.

var list = query.ToList(); 라고 적으면 카드를 따라 요리를 한 번 끝까지 마치고, 결과물(고른 감자들)을 새 바구니에 담아둔 것과 같습니다. 그 뒤로는 카드를 또 보지 않아도 바구니에서 꺼내 쓰면 됩니다.

2.2 시각화 — 호출 시점과 실행 시점의 분리

시점 1 — 쿼리 선언 (호출 시점)

핵심은 두 시점이 분리된다는 것입니다. Where(...)라는 메서드 호출은 "Iterator 객체"라는 작은 박스 하나를 만들 뿐, 람다는 한 번도 호출하지 않습니다. 람다가 실행되는 시점은 누군가가 그 박스의 MoveNext()를 두드리기 시작할 때입니다.

2.3 기본 코드 — 호출 시점과 실행 시점이 다르다는 증거

C#
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을 보면, 어떤 데이터 처리도 일어나지 않는다는 것이 명확해집니다.

C#
public static void ToListBlock(List<int> list)
{
    var result = list.Where(x => x > 0).ToList();
}
IL
.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 소스).

C#
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가 내부에서 무엇을 하는지가 한눈에 보입니다.

C#
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 객체와 상태 머신의 동작

소비자 (foreach)

상태 머신의 핵심은 state 필드 입니다. MoveNext()는 호출될 때마다 현재 state를 보고 "이전에 어디까지 했는지"를 기억해 그 자리부터 이어서 실행합니다. yield return이 있던 자리는 "여기서 일단 멈추고, 다음 호출에서 이 자리부터 재개"하는 분기점이 됩니다. 이게 바로 한 번에 한 요소씩 꺼내는 "풀(pull) 모델" 의 정체입니다.

3.3 LINQ가 만드는 진짜 Iterator 클래스 — ListWhereIterator

LINQ는 한발 더 나아가 소스 타입별로 최적화된 iterator를 갖고 있습니다. 우리가 본 list.Where(...)의 반환 타입이 단순한 WhereIterator가 아니라 ListWhereIterator 였던 이유가 여기 있습니다.

C#
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 한 줄을 GetEnumeratorMoveNext 루프 → CurrentDispose 의 명시적 호출 묶음으로 풀어냅니다. 직접 IL을 떼어 보면 분명합니다.

C#
public static void ForeachBlock(IEnumerable<int> q)
{
    foreach (var x in q)
        System.Console.WriteLine(x);
}
IL
.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을 두 번 사용해서 람다가 두 번 실행됩니다.

C#
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배
}

AfterToList()로 한 번만 평가하고, 이후 둘 다 메모리에서 처리합니다.

C#
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회만)
}

Beforequery.Count()IEnumerable<T>의 확장 메서드 Count()를 호출하므로 전체를 한 번 더 열거합니다. (참고로 List<T>의 인스턴스 프로퍼티 Count는 O(1)이지만, IEnumerable<T>로 노출되는 순간 그 정보를 잃어버립니다.) Aftermaterialized.Count(인스턴스 프로퍼티)이므로 람다 호출 없이 즉시 값을 반환합니다.

4.3 IL 분석 — ToList가 끼면 람다 호출 시점이 한 곳으로 모인다

C#
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가 두 번 호출됩니다.

IL
// 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 기준).

C#
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;
}

여기서 sourceListWhereIterator<int>이고, 이 클래스는 ICollection<T>를 구현하지 않으므로 빠른 경로가 아닌 일반 경로로 빠집니다. 즉 Count 내부에서 foreach가 돌고, 그 foreachMoveNext를 호출할 때마다 우리가 준 람다가 실행됩니다. Count를 두 번 호출했으니 람다도 두 번씩 — 총 6번 실행되는 것입니다.

실측 결과 — 위 Multi() 메서드에서 list가 5개 요소면 counter가 10 으로 출력됩니다 (5 + 5). list가 100만 개라면 200만 번이 됩니다.

ToList()를 끼우면 IL의 모양이 다음처럼 바뀝니다.

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>에 담는 순간, 그 뒤로 등장하는 CountList<T>::get_Count() 의 인스턴스 호출로 바뀝니다. List<T>.Count는 내부 필드를 그대로 반환하는 O(1) 연산이고, 람다는 더 이상 호출되지 않습니다.

4.4 Unity 핫패스 — Update에서 LINQ가 만드는 GC 압박

Unity 모바일 게임에서 가장 자주 보이는 안티 패턴 중 하나가 Update에서 LINQ를 매 프레임 호출하는 코드입니다.

Before — 매 프레임 LINQ 사슬을 다시 만듭니다.

C#
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 루프 + 미리 캐시한 버퍼를 사용합니다.

C#
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는 기본 분석기로는 잡지 않으므로 손으로 의식해야 합니다.

C#
// ❌ 다중 열거 — 같은 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라는 클로저 클래스의 필드). 즉 람다가 실행되는 순간의 값을 사용합니다.

C#
// ❌ 변수 변경 후 실행
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과 결합되면 더 위험합니다 — 두 번째 열거 시점의 값이 첫 번째와 다를 수 있기 때문입니다.

C#
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()로 즉시 실행해서 결과를 고정합니다.

C#
// ✅ 즉시 실행 — 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+).

C#
static IEnumerable<int> FilterAbove(IEnumerable<int> source, int threshold)
    => source.Where(static (x, t) => x > t, threshold);   // 정적 람다 — 캡처 없음

(엄밀히 Where의 두 매개변수 오버로드는 인덱스용이므로 위 시그니처는 예시적 표기입니다. 실제로는 람다 안에서 외부 변수를 읽지 않도록 시그니처를 다듬는 패턴을 의미합니다.)

5.3 함정 3 — 부수 효과를 가진 람다는 호출 횟수가 모호해진다

람다 안에서 외부 상태를 변경하는 코드는 지연 실행과 결합되면 언제 몇 번 호출되는지 예측하기 어렵습니다.

C#
// ❌ 부수 효과 람다 — 호출 횟수가 호출자 코드에 따라 달라짐
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 변수에 대한 두 호출이 카운터를 다르게 증가시키므로 로그·통계·캐시 같은 것을 람다 안에서 갱신하는 패턴은 깨집니다.

C#
// ✅ 람다는 순수하게, 부수 효과는 명시적인 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이 만들어지고 실행되는가"가 코드 흐름에서 잘 안 보이기 때문입니다.

C#
// 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는 두 가지 중 하나를 합니다.

  1. 표현식을 SQL로 번역할 수 없으면 예외 (EF Core 3+ 기본 동작)
  2. 또는 자동 클라이언트 평가 — DB에서 모든 행을 받아온 뒤 메모리에서 필터링 (재앙급 성능)
C#
// ❌ 위험 — 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() 로 결과를 고정해서 반환합니다.

C#
// ✅ 레포지토리 계층은 항상 즉시 실행으로 결과를 고정해 반환
public List<User> GetActiveUsers(MyDbContext ctx)
    => ctx.Users.Where(u => u.IsActive).ToList();   // 메서드 종료 시점에 SQL 1회

5.5 함정 5 (Unity 보너스) — IEnumerable을 인자로 받아 두 번 순회

Unity 컴포넌트 코드에서 자주 보이는 패턴입니다.

C#
// ❌ 인자가 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> (지연 실행의 비동기 확장)

C#
// 비동기 지연 실행 — 한 요소씩 비동기로 꺼냄
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의 정적 람다는 컴파일러에게 "이 람다는 외부를 캡처하지 않는다"고 명시해서 캡처가 생기면 컴파일 에러를 발생시킵니다.

C#
// 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가 만들어내는 임시 타입의 정체와 한계를 다룹니다.

반응형

+ Recent posts