반응형

[PART12.제네릭·델리게이트·람다·LINQ(13/18)] LINQ 메서드 구문 — Where · Select · OrderBy · Take · First · Any · Count

가장 많이 쓰는 메서드 8~10개 / list.Where(x => x > 0).Select(x => x * 2).ToList() 체이닝 / 메서드 시그니처와 체이닝 패턴


1. [문제 제기] for 루프 다섯 줄을 한 줄로 줄이고 싶다

Unity에서 적(Enemy) 리스트를 필터링하고 변환하는 코드를 떠올려보겠습니다. for 루프와 if 문을 조합하면 다음과 같습니다.

C#
// "체력이 0보다 큰 적의 ID에 2를 곱한 값"을 구하고 싶다
List<Enemy> enemies = ...;
List<int> result = new List<int>();
for (int i = 0; i < enemies.Count; i++)
{
    if (enemies[i].health > 0)
    {
        result.Add(enemies[i].id * 2);
    }
}

C# 입문 단계에서 익숙한 패턴이지만, 컬렉션을 다루는 코드 곳곳에 비슷한 골격이 반복됩니다. "조건에 맞는 요소만 필터다른 형태로 변환개수 세기·첫 번째 가져오기" 같은 작업은 모든 데이터 처리 코드의 90% 이상을 차지하기 때문에, C#은 이것을 한 줄로 표현할 수 있는 표준 도구를 제공합니다. 그것이 LINQ 메서드 구문입니다.

C#
List<int> result = enemies.Where(e => e.health > 0).Select(e => e.id * 2).ToList();

같은 일을 한 줄에 끝낸다는 점은 단순한 미적 차이가 아닙니다. LINQ는 내부적으로 컬렉션 타입을 인지해 O(1) 단축 경로를 타거나, 중간 리스트를 만들지 않고 한 번의 순회로 필터+변환을 끝내는 등 수동 for 루프보다 더 영리하게 동작하는 경우가 있습니다. 반대로, Unity의 Update() 같은 핫패스에서 무심코 사용하면 매 프레임 가비지를 쏟아내는 함정이 되기도 합니다.

이 글에서는 가장 자주 쓰는 LINQ 메서드 8~10개의 시그니처·동작·체이닝 패턴을 정리하고, IL(Intermediate Language, C# 컴파일러가 만드는 중간 언어 — .NET 런타임이 실제로 실행하는 명령어 집합) 수준에서 LINQ가 어떤 최적화를 거는지, 그리고 Unity 핫패스에서 어떤 함정을 피해야 하는지를 다룹니다.

참고: LINQ는 본 PART에서 세 글로 나누어 다룹니다. 이 글 [13]은 메서드 구문 자체에 집중하고, [14]에서 SQL을 닮은 from ... select 쿼리 구문을, [15]에서 Where/Select 체이닝의 핵심 메커니즘인 지연 실행(Deferred Execution)을 깊게 파헤칩니다. 이 글에서는 지연 실행 개념을 한 절에서만 짧게 짚고 넘어갑니다.

2. [개념 정의] LINQ 메서드는 IEnumerable<T>에 붙은 확장 메서드다

2.1 비유: 컨베이어 벨트 위의 가공 단계

LINQ 메서드 체이닝은 공장 컨베이어 벨트와 닮았습니다. 원본 박스(컬렉션)가 한 개씩 굴러 들어오면, 각 단계마다 작업자(Where, Select, OrderBy 등)가 자기 일을 합니다. Where는 불량품을 빼내고, Select는 박스의 라벨을 새로 붙이고, OrderBy는 순서를 다시 정렬합니다. 마지막에 트럭(ToList, First, Count)이 와서 최종 결과를 픽업해 갑니다.

이 비유에서 중요한 두 가지 사실은 다음과 같습니다.

  1. 트럭이 오기 전까지는 컨베이어 벨트가 돌지 않습니다. WhereSelect는 호출하는 순간엔 "어떻게 처리할지" 계획만 세우고, 실제 실행은 ToList()·foreach·First() 같은 종결 연산이 호출될 때 시작됩니다(지연 실행 — [15]에서 상세히 다룹니다).
  2. 각 작업자는 박스 하나가 도착할 때마다 처리합니다. Where가 통과시킨 박스를 Select가 즉시 받아 가공합니다. 중간에 임시 컬렉션이 만들어지지 않습니다.

2.2 시각화 — 메서드 체이닝의 데이터 흐름

list.Where(x => x > 0).Select(x => x * 2).ToList()

2.3 가장 자주 쓰는 메서드 한 장 정리

LINQ 메서드는 종류가 많아 보이지만, 실무에서 90% 이상을 차지하는 것은 아래 표에 담긴 메서드들입니다. 글 전체에서 이 표를 기준으로 설명을 펼쳐 갑니다.

분류 메서드 시그니처 (간략) 반환 타입 실행 시점 한 줄 설명
필터 Where Where(Func<T, bool>) IEnumerable<T> 지연 조건에 맞는 요소만 통과
투영 Select Select(Func<T, TResult>) IEnumerable<TResult> 지연 각 요소를 다른 형태로 변환
평탄화 SelectMany SelectMany(Func<T, IEnumerable<TResult>>) IEnumerable<TResult> 지연 1:N 컬렉션을 1차원으로 펼침
정렬 OrderBy / OrderByDescending OrderBy(Func<T, TKey>) IOrderedEnumerable<T> 지연 키 기준 오름·내림차순 정렬
정렬(보조) ThenBy / ThenByDescending ThenBy(Func<T, TKey>) IOrderedEnumerable<T> 지연 1차 정렬 결과에 2차 기준 추가
수량 제한 Take / Skip Take(int count) IEnumerable<T> 지연 앞에서 N개 가져오기·건너뛰기
단일 요소 First / FirstOrDefault First() T 즉시 첫 번째 요소 (없으면 예외 / 기본값)
단일 요소 Single / SingleOrDefault Single() T 즉시 정확히 하나만 있어야 함
존재 Any / All Any(Func<T, bool>) bool 즉시 하나라도 / 모두 만족하는가
집계 Count / Sum / Min / Max Count() int 즉시 개수·합·최소·최대
주의 — First vs Single, Any vs Count() > 0
  • First는 하나라도 찾으면 반환하지만, Single은 두 개 이상이면 예외를 던집니다. "유일성"을 코드로 강제하고 싶을 때는 Single을 씁니다.
  • 비어 있는지 확인할 때 Count() > 0보다 Any()가 빠릅니다(아래 4.2절에서 IL로 증명).
=> — 람다 연산자 (Lambda operator) 왼쪽에 매개변수, 오른쪽에 식 또는 본문을 두어 익명 함수를 표현합니다. LINQ는 Func<T, ...> 델리게이트(메서드를 값처럼 다루는 타입)를 받기 위해 거의 항상 람다와 함께 쓰입니다.
예시: list.Where(x => x > 0)x를 받아 x > 0인지 판정하는 함수

2.4 가장 기본적인 체이닝 코드 — Where → Select → ToList

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

class Program
{
    static List<int> Chain(List<int> list)
    {
        return list.Where(x => x > 0).Select(x => x * 2).ToList();
    }
}

이 코드의 동작:

  • Where(x => x > 0)은 즉시 실행되지 않고, "양수만 통과시키는 이터레이터(반복자 — 컬렉션 요소를 한 개씩 꺼내 주는 객체)"를 반환합니다.
  • Select(x => x * 2)도 마찬가지로 "각 요소를 두 배로 만드는 이터레이터"를 반환합니다.
  • ToList()가 호출되는 순간, 비로소 원본 리스트에서 요소가 한 개씩 흘러나와 WhereSelect를 거친 뒤 새 List<int>에 추가됩니다.

이제 이 한 줄이 IL 수준에서 어떻게 펼쳐지는지 다음 섹션에서 살펴보겠습니다.


3. [내부 동작] 컴파일러는 LINQ 체인을 어떻게 변환하는가

3.1 람다는 <>c 캐시 클래스로 모인다

Chain 메서드를 컴파일하면, 컴파일러는 두 람다(x => x > 0x => x * 2)를 Program 클래스 안에 자동 생성한 <>c라는 중첩 클래스의 메서드로 변환합니다. 그리고 이 람다를 가리키는 Func<> 델리게이트 인스턴스를 정적 필드에 캐싱합니다.

람다 캐싱 — 캡처가 없는 정적 람다는 한 번만 생성된다

실제 IL을 보면 이 캐싱 로직이 명확히 드러납니다.

IL
// Chain 메서드 IL (요약)
.method private hidebysig static
    class List`1<int32> Chain(class List`1<int32> list)
{
    // === 첫 번째 람다: x => x > 0 ===
    IL_0000: ldarg.0                              // list를 스택에 (Where의 첫 인수)
    IL_0001: ldsfld   class Func`2<int32, bool>
                      Program/'<>c'::'<>9__0_0'   // 캐시된 Func 로드
    IL_0006: dup                                  // 스택 상단 복제 (null 검사용)
    IL_0007: brtrue.s IL_0020                     // null이 아니면 점프 (이미 캐싱됨)
    
    IL_0009: pop                                  // null이면 한 번만 실행되는 경로
    IL_000a: ldsfld   class Program/'<>c' Program/'<>c'::'<>9'
    IL_000f: ldftn    instance bool 
                      Program/'<>c'::'<Chain>b__0_0'(int32)  // 람다 메서드 포인터
    IL_0015: newobj   instance void Func`2<int32, bool>::.ctor(...)
    IL_001a: dup
    IL_001b: stsfld   ... '<>9__0_0'              // 캐시 슬롯에 저장
    
    IL_0020: call     ... Enumerable::Where<int32>(...)  // Where 호출
    
    // === 두 번째 람다: x => x * 2 (동일한 캐싱 패턴) ===
    IL_0025: ldsfld   ... '<>9__0_1'
    IL_002a: dup
    IL_002b: brtrue.s IL_0044
    // ... (동일한 캐싱 로직)
    IL_0044: call     ... Enumerable::Select<int32, int32>(...)
    
    // === 종결 연산 ===
    IL_0049: call     ... Enumerable::ToList<int32>(...)
    IL_004e: ret
}

이 IL이 알려주는 것:

  1. brtrue.s IL_0020이 핵심입니다. 캐시 필드 <>9__0_0이 이미 채워져 있으면 Func<> 인스턴스를 새로 만들지 않고 바로 Where로 점프합니다. 같은 메서드를 여러 번 호출해도 람다 자체는 한 번만 힙에 할당되므로, 외부 변수를 캡처하지 않는 람다는 GC 압력이 없습니다.
  2. <>c 클래스는 모든 람다의 컨테이너입니다. 두 람다 <Chain>b__0_0(필터)과 <Chain>b__0_1(변환)이 모두 같은 <>c 싱글턴 위에 메서드로 붙어 있습니다.
  3. WhereSelectToList는 그냥 정적 메서드 호출입니다. System.Linq.Enumerable의 정적 확장 메서드를 차례로 호출하면서 결과를 다음 호출의 첫 인수로 넘기는, 함수 합성 패턴입니다.

3.2 중간 컬렉션 없이 한 번에 흐른다 — WhereSelectListIterator

Where가 반환하는 것은 단순한 List<T>가 아니라, .NET 런타임이 내부적으로 정의한 이터레이터 클래스의 인스턴스입니다. 더 흥미로운 점은, Where(...)의 결과에 다시 Select(...)를 호출하면 .NET이 이를 감지하고 WhereSelectListIterator 같은 결합된 단일 이터레이터로 융합한다는 사실입니다.

중간 리스트 없이 한 번의 순회로 끝난다

이런 융합은 .NET 런타임이 Where의 결과 객체 타입을 검사해서 "내부 WhereListIterator라면 Select를 받았을 때 WhereSelectListIterator로 만들자"는 식의 패턴 매칭으로 구현됩니다. 사용자는 이 사실을 모르고 그냥 .Where().Select()를 쓰지만, 수동 for 루프와 비교했을 때 메모리 측면에서 거의 동일한 효율이 나오는 이유가 여기에 있습니다(델리게이트 호출 오버헤드는 남습니다).

지연 실행과 이 융합 메커니즘의 자세한 동작 — IEnumerator<T> 상태 머신, MoveNext() 호출 흐름, yield 기반 구현 — 은 다음 글 [15] 지연 실행에서 깊게 다룹니다. 이 글에서는 "메서드 체이닝이 한 번의 순회로 효율적으로 처리된다"는 결론만 알면 충분합니다.

4. [실전 적용] 메서드 선택의 판단 기준

4.1 First vs FirstOrDefault — 비어 있을 가능성이 있는가

가장 자주 헷갈리는 한 쌍입니다. 동작은 거의 같지만, 빈 시퀀스에서의 처리가 다릅니다.

  시퀀스에 요소가 있을 때 시퀀스가 비어 있을 때
First() 첫 요소 반환 InvalidOperationException 던짐
FirstOrDefault() 첫 요소 반환 default(T) 반환 (참조형: null, int: 0)

판단 기준:

  • "결과가 반드시 있어야 한다 — 없으면 버그다"라면 First(). 비어 있을 경우 즉시 예외로 알려주는 편이 디버깅에 유리합니다.
  • "결과가 없을 수도 있고, 그건 정상 흐름이다"라면 FirstOrDefault(). 반환값을 null 검사로 분기 처리합니다.
C#
// 판단 기준이 잘못된 사용 — null이 반환될 수 있는데 First를 씀
Item firstWeapon = inventory.First(i => i.IsWeapon);
// 인벤토리에 무기가 없으면 InvalidOperationException — 게임 크래시

// 올바른 사용 — 없을 가능성이 정상 흐름
Item firstWeapon = inventory.FirstOrDefault(i => i.IsWeapon);
if (firstWeapon == null) {
    ShowMessage("무기가 없습니다");
    return;
}

두 메서드의 IL을 비교하면 정적 메서드 호출 한 줄 차이입니다.

IL
// First
IL_0000: ldarg.0
IL_0001: call !!0 [System.Linq]System.Linq.Enumerable::First<int32>(...)
IL_0006: ret

// FirstOrDefault
IL_0000: ldarg.0
IL_0001: call !!0 [System.Linq]System.Linq.Enumerable::FirstOrDefault<int32>(...)
IL_0006: ret

호출하는 메서드 이름만 다릅니다. 진짜 차이는 System.Linq.Enumerable의 구현 안에서 일어납니다 — 둘 다 IEnumerator<T>.MoveNext()를 한 번 호출하고, 결과가 false(시퀀스 끝)일 때 First는 예외를 던지고 FirstOrDefaultdefault(T)를 반환합니다.

C# 12 신규 오버로드 — FirstOrDefault(defaultValue): .NET 6 / C# 10부터 FirstOrDefault에 두 번째 인수로 사용자 지정 기본값을 넣을 수 있게 되었습니다(C# 12에서도 그대로 사용 가능).

C#
// 과거 방식
int firstScore = scores.FirstOrDefault();
if (firstScore == 0) firstScore = -1;   // "0이 데이터인지 빈 시퀀스인지" 분간 불가

// .NET 6+ 방식
int firstScore = scores.FirstOrDefault(-1);  // 비어 있으면 -1을 명시적으로 반환

값 타입에서 0이나 false가 의미 있는 데이터일 때 특히 유용합니다.

4.2 Any() vs Count() > 0 — 존재 확인의 정석

비어 있는지 확인할 때, 신입 개발자가 자주 쓰는 패턴은 if (list.Count() > 0)입니다. 동작은 맞지만 의도와 성능 모두 Any()가 우월합니다.

  list.Any() list.Count() > 0
의도 "하나라도 있나?" — 명확 "개수가 양수인가?" — 우회
IEnumerable<T> (예: Where 결과) MoveNext() 한 번이면 끝 끝까지 순회해야 개수 측정
List<T> 같은 ICollection<T> .Count 속성 즉시 확인 .Count 속성 즉시 확인

핵심은 Where(...) 같은 LINQ 결과에 Count() > 0을 쓰면 전체를 순회한다는 점입니다. Any()는 첫 요소를 발견하는 즉시 true를 반환하고 멈춥니다.

C#
// 100만 개 중 양수가 하나라도 있는지만 확인하고 싶을 때
bool hasPositive_BAD  = numbers.Where(n => n > 0).Count() > 0;  // 100만 개 다 검사
bool hasPositive_GOOD = numbers.Any(n => n > 0);                // 첫 양수에서 즉시 종료

4.3 Count() vs list.Count — 컬렉션 단축 경로

List<T> 같은 객체에 LINQCount() 확장 메서드를 호출하면, .NET 런타임이 내부적으로 객체 타입을 검사합니다. 만약 그 객체가 ICollection<T>(혹은 ICollection)을 구현하고 있으면, 전체를 순회하지 않고 .Count 속성을 직접 읽는 단축 경로를 탑니다 — O(n)이 아니라 O(1)입니다.

IL로 보면 차이가 명확합니다.

IL
// CountLinq: list.Count()
IL_0000: ldarg.0
IL_0001: call int32 [System.Linq]System.Linq.Enumerable::Count<int32>(
                 class IEnumerable`1<!!0>)
IL_0006: ret

// CountProperty: list.Count
IL_0000: ldarg.0
IL_0001: callvirt instance int32 List`1<int32>::get_Count()
IL_0006: ret

CountPropertyList<T>get_Count() 게터를 callvirt(가상 호출)로 직접 부르고, CountLinqEnumerable::Count의 정적 헬퍼를 거칩니다. 그 헬퍼 안에서 is ICollection<T> col로 캐스팅을 시도하고 성공하면 col.Count를 반환합니다. 결과적으로 두 호출의 시간 복잡도는 같지만 list.Count 쪽이 한 번의 메서드 호출 + 캐스팅 검사를 절약합니다.

C#
// 같은 결과, 같은 O(1) — 그러나 list.Count 쪽이 미세하게 빠르고 의도가 더 명확
int n1 = list.Count();   // LINQ 확장 메서드 + ICollection 단축 경로
int n2 = list.Count;     // 직접 프로퍼티 접근

핵심 규칙: 컬렉션 타입을 알고 있으면 .Count 속성을, IEnumerable<T> 추상 타입만 보일 때는 .Count()를 씁니다. 단, Count(predicate) 오버로드(예: list.Count(x => x > 0))는 람다 검사 때문에 단축 경로를 못 타고 항상 전체 순회합니다 — 이때는 Where(...).Count()나 직접 for 루프와 동일한 O(n) 비용입니다.

4.4 OrderBy vs OrderByDescending + ThenBy로 다중 키 정렬

C#
// Before — 두 번 정렬해서 잘못된 순서가 나오는 흔한 실수
var sorted_BAD = products.OrderBy(p => p.Price).OrderBy(p => p.Category);
// 마지막 OrderBy가 1차 키가 되어 가격 정렬이 무의미해짐

// After — 1차 정렬 후 ThenBy로 보조 키 적용
var sorted_GOOD = products
    .OrderBy(p => p.Category)            // 1차: 카테고리 오름차순
    .ThenByDescending(p => p.Price);     // 2차: 가격 내림차순

OrderBy를 두 번 부르면 두 번째 호출이 1차 키를 덮어씌웁니다. 이는 OrderByIEnumerable<T>를 받기 때문입니다 — 이전 OrderBy의 결과도 그냥 IEnumerable<T>로 보고 처음부터 다시 정렬합니다.

ThenBy/ThenByDescendingIOrderedEnumerable<T>만 받는 별도 시그니처를 갖고 있어, 이전 정렬 결과를 인지하고 보조 키를 추가합니다. LINQ의 정렬은 안정 정렬(Stable Sort)이므로, 같은 키를 가진 요소들끼리는 원본 순서가 보존됩니다.

4.5 Single vs First — 유일성을 강제할 때만 Single

C#
// userId로 사용자 찾기 — DB 키라서 둘 이상 있으면 데이터 무결성 버그
User user = users.Single(u => u.Id == userId);

// "조건에 맞는 첫 번째 적" 같은 일반적 검색
Enemy nearest = enemies.First(e => e.IsActive);

Single은 첫 번째 요소를 찾은 뒤에도 두 번째 매치가 있는지 확인하기 위해 컬렉션을 한 번 더 훑습니다. 따라서 단순히 "첫 번째 요소"가 필요한 경우에는 First가 더 빠릅니다. Single은 비즈니스 규칙으로 "유일해야 한다"가 보장된 곳에서만 씁니다.

4.6 TakeSkip — 페이지네이션

C#
// 페이지 단위로 끊어 가져올 때
const int PageSize = 20;
int pageIndex = 2;  // 3번째 페이지

var pageItems = items
    .OrderBy(i => i.CreatedAt)
    .Skip(pageIndex * PageSize)   // 앞의 40개 건너뛰고
    .Take(PageSize)               // 그 다음 20개만
    .ToList();

DB 쿼리(EF Core 등)에서는 Skip/Take가 SQL의 OFFSET/LIMIT으로 번역되어 DB 측에서 처리되므로, 메모리에 전체를 올리지 않습니다. 메모리상의 컬렉션이라면 Skip(40)은 "앞에서 40번 MoveNext() 호출"로 단순히 건너뛰는 동작이라 O(n)입니다.

4.7 SelectMany — 1:N 관계의 평탄화

C#
class Class { public List<Student> Students; }
Class[] classes = ...;

// Before — 중첩 리스트
List<List<Student>> nested = classes.Select(c => c.Students).ToList();

// After — 평탄화된 단일 리스트
List<Student> allStudents = classes.SelectMany(c => c.Students).ToList();

Select는 각 원소를 무엇으로든 변환할 수 있지만, 그 변환 결과가 또 다른 컬렉션이면 IEnumerable<IEnumerable<T>>라는 중첩 구조가 됩니다. SelectMany는 그 중첩을 한 단계 풀어 평탄한 시퀀스를 만듭니다. SQL의 JOIN과 유사한 역할을 합니다.


5. [함정과 주의사항] Unity 핫패스의 LINQ는 GC 파괴자다

5.1 ❌ Update() 안에서 LINQ + ToList — 매 프레임 가비지

Unity의 Update(), FixedUpdate(), LateUpdate()매 프레임 호출되는 핫패스입니다. 이런 곳에서 람다가 외부 변수를 캡처하면, 캡처를 담을 클로저 클래스의 인스턴스가 매 프레임 새로 힙에 할당됩니다. Unity의 Boehm GC(IL2CPP 빌드 기준의 가비지 컬렉터 — 마크-앤-스윕 방식의 컴팩트하지 않은 GC)는 이런 작은 할당이 누적되면 GC 스파이크(특정 프레임에서 16ms를 넘기는 끊김)를 만들어냅니다.

C#
class Enemy { public float health; public bool IsActive; }

class Game : MonoBehaviour
{
    List<Enemy> enemies;

    void UpdateBefore()
    {
        int minHealth = 10;  // 지역 변수
        var lowHealth = enemies
            .Where(e => e.health < minHealth)  // minHealth를 캡처
            .ToList();                          // 새 List 매 프레임 생성

        for (int i = 0; i < lowHealth.Count; i++) { /* 처리 */ }
    }
}

이 코드의 IL을 까보면 두 가지 할당이 매 호출마다 발생합니다.

IL
// UpdateBefore 메서드 IL (요약)
IL_0000: newobj instance void Program/'<>c__DisplayClass2_0'::.ctor()  // ← (1) 클로저 객체 할당
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: ldc.i4.s 10
IL_0009: stfld   int32 Program/'<>c__DisplayClass2_0'::minHealth        // 캡처값 저장
IL_000e: ldsfld  ... Program::enemies
IL_0013: ldloc.0
IL_0014: ldftn   instance bool Program/'<>c__DisplayClass2_0'::'<UpdateBefore>b__0'(...)
IL_001a: newobj  instance void Func`2<class Enemy, bool>::.ctor(...)    // ← (2) Func 델리게이트 할당
IL_001f: call    ... Enumerable::Where<class Enemy>(...)
IL_0024: call    ... Enumerable::ToList<class Enemy>(...)               // ← (3) 새 List 할당

<>c__DisplayClass2_0은 컴파일러가 자동 생성한 클로저 클래스(외부 지역 변수 minHealth를 담아 람다에 전달하기 위한 박스)입니다. <>c(정적 캐싱 클래스)와 이름이 비슷하지만 역할이 정반대입니다 — <>c__DisplayClass호출마다 new로 만들어지는 인스턴스입니다. 결과적으로 Update가 60fps로 호출되면 1초에 클로저 60개 + Func 60개 + List 60개가 GC 큐에 쌓입니다.

5.2 ✅ 캐시된 리스트 + for 루프

C#
class Game : MonoBehaviour
{
    List<Enemy> enemies;
    List<Enemy> lowHealthCache = new List<Enemy>();  // 멤버 변수로 한 번만 할당

    void UpdateAfter()
    {
        const int minHealth = 10;
        lowHealthCache.Clear();  // 내용만 비움 — 내부 배열은 재사용

        for (int i = 0; i < enemies.Count; i++)
        {
            var e = enemies[i];
            if (e.health < minHealth)
                lowHealthCache.Add(e);
        }

        for (int i = 0; i < lowHealthCache.Count; i++) { /* 처리 */ }
    }
}

이 IL은 클로저도 Func도 새로 만들지 않습니다 — for 루프 두 개와 List.Clear() 호출, List.Add() 호출만 보입니다. 모든 객체는 첫 호출 시 한 번만 할당된 enemieslowHealthCache이며, 매 프레임 가비지는 0바이트입니다.

항목 Before (LINQ + ToList) After (캐시 + for)
매 프레임 힙 할당 클로저 + Func + List 0
GC 스파이크 위험 높음 없음
코드 가독성 한 줄 — 직관적 여러 줄 — 명시적
적용 영역 시작 시 1회·UI 이벤트 등 Update/FixedUpdate

판단 기준: "매 프레임 호출되는가?"가 유일한 기준입니다. Awake/Start/OnEnable한 번만 실행되는 곳에서는 LINQ를 마음껏 쓰세요 — 가독성 이득이 훨씬 큽니다. Update 계열에서만 for 루프로 손수 짭니다.

5.3 ❌ IEnumerable<T> 반환의 지연 실행 함정

LINQ의 지연 실행이 의도치 않은 동작을 만들기도 합니다. 다음 코드는 함수가 호출될 때마다 전체 컬렉션을 다시 순회합니다.

C#
class EnemyManager : MonoBehaviour
{
    List<Enemy> allEnemies;

    // 반환 타입이 IEnumerable<Enemy> — 지연 실행되는 쿼리
    public IEnumerable<Enemy> GetActiveEnemies() => 
        allEnemies.Where(e => e.IsActive);

    void Update()
    {
        var active = GetActiveEnemies();
        int count = active.Count();          // 1회 순회
        Enemy first = active.First();        // 또 1회 순회 (중간에 다시 처음부터)
        bool any = active.Any();             // 또 1회 (조기 종료라 비용은 적음)
        // 같은 시퀀스를 3번 평가 — 매번 전체 컬렉션을 다시 검사
    }
}

문제의 본질: Where의 결과는 "어떻게 처리할지의 계획"일 뿐이라, Count()·First()·Any() 같은 종결 연산이 호출될 때마다 처음부터 다시 평가됩니다. 호출 측 입장에서 active가 컬렉션처럼 보이지만 실제로는 매번 새로 계산되는 실행 가능한 쿼리 객체입니다.

5.4 ✅ 한 번 평가하고 결과를 변수로 보관

C#
public List<Enemy> GetActiveEnemies() =>            // 반환 타입을 List로 명시
    allEnemies.Where(e => e.IsActive).ToList();

void Update()
{
    var active = GetActiveEnemies();  // 한 번만 평가 (그러나 List 새로 할당)
    int count = active.Count;
    Enemy first = active[0];
    bool any = active.Count > 0;
}

핫패스가 아니라면 위 패턴이 안전합니다. 하지만 Update() 안에서는 ToList()가 매 프레임 새 리스트를 만들기 때문에 5.2처럼 캐시 리스트 멤버 변수 + 수동 채우기가 정답입니다.

핵심: 메서드 반환 타입에 IEnumerable<T>를 쓰면 호출자가 "필요할 때 한 번만 순회" 할 책임을 갖습니다. Unity의 핫패스 코드에서는 모호함이 곧 버그이므로, 반환 타입은 List<T> 같은 구체 타입을 쓰거나, 즉시 평가된 결과를 캐시 멤버에 저장하는 편이 안전합니다.

5.5 자주 마주치는 그 외 함정 한 줄 정리

  • Single(predicate)은 두 번째 매치를 확인하기 위해 끝까지 순회합니다 — 진짜 유일성이 보장된 곳에서만 쓰세요.
  • Count(predicate)는 람다가 끼어들어 ICollection<T> 단축 경로를 못 탑니다. 단순 비어있는지만 확인할 때는 Any(predicate).
  • Sum/Min/Max는 빈 시퀀스에서 동작이 다릅니다. Sum0을 반환하지만 Min/Max참조 타입에서만 null 반환, 값 타입은 예외를 던집니다.
  • OrderBy를 두 번 호출하면 마지막 호출이 1차 키가 됩니다 — 다중 키 정렬에는 반드시 ThenBy를 씁니다.
  • LINQ 메서드는 IEnumerable<T>에 붙어 있어, IEnumerable<T>를 구현한 모든 타입(List<T>, T[], HashSet<T>, Dictionary<K,V> 등)에서 동일하게 사용됩니다.

6. [C# 버전별 변화] FirstOrDefault 기본값 오버로드 (.NET 6 / C# 10) 등

LINQ의 핵심 메서드 자체는 .NET 3.5 시절(2007)부터 거의 그대로지만, 사용성과 성능을 개선하는 오버로드가 꾸준히 추가되어 왔습니다.

6.1 .NET 6 / C# 10 — FirstOrDefault(defaultValue), MaxBy/MinBy, 인덱스 받는 Take

Before (.NET 5 이전):

C#
// 사용자 지정 기본값을 쓰려면 한 번 더 검사해야 했다
int firstScore = scores.FirstOrDefault();
if (firstScore == 0) firstScore = -1;  // 0이 데이터인지 빈 시퀀스인지 모호

// 키로 최대를 찾으려면 정렬 후 First 또는 수동 비교
Player topPlayer = players.OrderByDescending(p => p.Score).First();

After (.NET 6+):

C#
// 기본값 직접 지정
int firstScore = scores.FirstOrDefault(-1);

// MaxBy / MinBy — 키 기준 최대·최소 요소 자체를 반환
Player topPlayer = players.MaxBy(p => p.Score);  // 정렬 비용 없이 O(n)으로 최대 찾기

MaxBy/MinBy는 정렬을 거치지 않고 한 번의 순회로 최대·최소 요소를 찾기 때문에 OrderByDescending(...).First() 대비 훨씬 빠릅니다(O(n log n)O(n)).

6.2 .NET 6 / C# 10 — Take(Range) (C#의 범위 연산자 활용)

After (.NET 6+):

C#
// 범위 연산자(..)로 슬라이싱
var firstFive = list.Take(..5);            // 처음 5개
var middle = list.Take(2..7);              // 인덱스 2~6
var lastThree = list.Take(^3..);           // 끝에서 3개

기존 Skip(N).Take(M) 조합을 한 메서드로 줄여줍니다. 단 IEnumerable<T>는 음수 인덱스(^)를 지원하기 위해 내부에서 한 번 길이를 확인해야 하므로, IList<T>가 아닌 컬렉션에서는 약간의 오버헤드가 있습니다.

6.3 .NET 9 — CountBy, AggregateBy, Index() (.NET 9 / 2024)

After (.NET 9+):

C#
// 키로 그룹별 개수를 한 번에
foreach (var (category, count) in products.CountBy(p => p.Category))
{
    Debug.Log($"{category}: {count}개");
}

// foreach에 인덱스 추가 — Python의 enumerate와 유사
foreach (var (index, item) in items.Index())
{
    Debug.Log($"[{index}] {item.Name}");
}

LINQ는 매 .NET 메이저 버전마다 사용성을 다듬는 추가 오버로드가 들어옵니다. Unity의 경우 사용 가능한 .NET 버전이 엔진 버전에 묶여 있으므로(Unity 6는 .NET Standard 2.1 / IL2CPP), 위 신규 메서드들의 사용 가능 여부는 프로젝트의 컴파일러·런타임 설정에 따라 다릅니다.

메서드 자체보다 동작이 더 자주 바뀐다. .NET 7~8에서는 Where().Select() 같은 체인이 결합 이터레이터로 융합되는 내부 최적화도 개선되어 왔습니다. 외부 API는 똑같지만 IL이 호출하는 Enumerable 내부 구현은 매년 빨라지고 있다고 보면 됩니다.

7. [정리] 이것만 기억하라

  • 체이닝의 본질: list.Where(...).Select(...).ToList()Enumerable의 정적 메서드를 차례로 호출하는 함수 합성입니다. 람다는 컴파일러가 만든 <>c 캐시 클래스의 메서드로 변환되며, 캡처가 없으면 처음 한 번만 할당되고 재사용됩니다.
  • Where().Select()는 한 번에 흐른다: 중간 리스트를 만들지 않고 결합된 이터레이터(WhereSelectListIterator 등)로 융합되어 원본을 한 번만 순회합니다.
  • 즉시 실행 vs 지연 실행: First/FirstOrDefault/Any/All/Count/ToList는 즉시 실행. Where/Select/OrderBy/Take/Skip은 지연 실행 — IEnumerable<T>로 머물러 있다가 종결 연산에서 평가됩니다.
  • 메서드 선택의 판단 기준:
    • 빈 시퀀스가 정상 흐름이면 FirstOrDefault, 버그면 First.
    • 유일성을 강제하려면 Single, 일반 검색은 First.
    • 비어있는지만 확인하려면 항상 Any() (절대 Count() > 0 금지).
    • List<T>의 개수는 list.Count 속성, IEnumerable<T>이면 list.Count().
    • 다중 키 정렬은 OrderBy().ThenBy() (절대 OrderBy().OrderBy() 금지).
  • Unity 핫패스 규칙:
    • Update/FixedUpdate/LateUpdate 안의 LINQ는 람다 캡처 클로저 + Func 델리게이트 + ToList 결과 리스트로 매 프레임 가비지를 만듭니다.
    • 매 프레임 호출되는 곳에서는 캐시 리스트 멤버 + for 루프 + List.Clear() 패턴.
    • Awake/Start/OnEnable/UI 이벤트처럼 1회 호출되는 곳에서는 LINQ를 마음껏 사용.
  • 메서드 반환 타입: IEnumerable<T>를 반환하면 호출자가 매번 재평가되므로, Unity 핫패스 호출 측에서는 List<T>로 명시적 즉시 평가하거나 캐시 멤버에 저장합니다.
  • 다음 글 예고:
    • [14] LINQ 쿼리 구문 — from x in list where x > 0 select x * 2 SQL 형 표기. 이 글의 메서드 구문과 1:1 대응됩니다.
    • [15] 지연 실행(Deferred Execution) — Where/Select가 즉시 실행되지 않는 메커니즘, IEnumerator<T> 상태 머신, yield return, 다중 평가 함정을 IL과 함께 깊게 다룹니다.
반응형

+ Recent posts