콘텐츠로 이동

C# LINQ 내부 동작 원리

LINQ(Language Integrated Query)는 C# 3.0에서 도입된 기능으로, 컬렉션·데이터베이스·XML 등 다양한 데이터 소스를 일관된 문법으로 조회·변환합니다. 단순한 편의 문법이 아니라 IEnumerable<T>와 익스텐션 메서드, 람다 표현식, 표현식 트리를 조합한 정교한 시스템입니다.


LINQ는 두 가지 문법으로 동일한 연산을 표현합니다.

// 쿼리 표현식 (Query Expression)
var result1 = from n in numbers
where n > 3
select n * 2;
// 메서드 체인 (Method Chain)
var result2 = numbers
.Where(n => n > 3)
.Select(n => n * 2);

컴파일러는 쿼리 표현식을 메서드 체인으로 변환합니다. 두 형태는 완전히 동일한 IL 코드를 생성합니다.


LINQ의 가장 중요한 특성은 지연 실행입니다. 쿼리를 정의하는 시점에는 실제 연산이 일어나지 않고, 결과를 소비할 때(열거할 때) 비로소 실행됩니다.

var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 이 시점에는 아무 연산도 일어나지 않음
var query = numbers.Where(n => {
Console.WriteLine($"Checking {n}");
return n > 3;
});
Console.WriteLine("쿼리 정의 완료");
// foreach를 만나는 순간 실제 실행
foreach (var n in query)
{
Console.WriteLine($"Result: {n}");
}

출력:

쿼리 정의 완료
Checking 1
Checking 2
Checking 3
Checking 4
Result: 4
Checking 5
Result: 5

ToList(), ToArray(), Count(), First(), Sum() 등은 즉시 실행을 강제합니다.

// 즉시 실행: 이 시점에 전체 파이프라인 실행
var list = numbers.Where(n => n > 3).ToList();

LINQ 연산자는 IEnumerable<T>를 반환하는 익스텐션 메서드입니다. 각 연산자는 이전 시퀀스를 감싸는 래퍼 이터레이터를 반환합니다.

numbers → WhereIterator → SelectIterator → 소비자(foreach/ToList)

Where 메서드의 단순화된 내부 구현:

public static IEnumerable<T> Where<T>(
this IEnumerable<T> source,
Func<T, bool> predicate)
{
foreach (var item in source)
{
if (predicate(item))
yield return item; // 상태 머신 기반 이터레이터
}
}

yield return은 컴파일러가 상태 머신 클래스로 변환합니다. 각 MoveNext() 호출마다 다음 요소를 생성합니다.


IEnumerable<T> 기반으로 동작하며 메모리 내 컬렉션을 처리합니다. 람다 표현식이 Func<T, bool> 델리게이트로 컴파일됩니다.

// 람다 → Func<int, bool> 델리게이트로 컴파일
var result = numbers.Where(n => n > 3);

IQueryable<T> 기반으로 동작합니다. 람다 표현식이 표현식 트리(Expression Tree) 로 컴파일되어 SQL로 변환됩니다.

// 람다 → Expression<Func<User, bool>> 표현식 트리로 컴파일
IQueryable<User> query = db.Users.Where(u => u.Age > 20);
// SQL: SELECT * FROM Users WHERE Age > 20

IEnumerable<T>IQueryable<T>의 혼동은 N+1 문제를 일으킵니다.

// 위험: AsEnumerable()이 모든 데이터를 메모리로 로드 후 필터링
var bad = db.Users.AsEnumerable().Where(u => u.Age > 20);
// 올바름: DB에서 WHERE 절로 필터링
var good = db.Users.Where(u => u.Age > 20);

표현식 트리는 코드를 데이터 구조로 표현합니다. LINQ to SQL, EF Core, 동적 쿼리 빌더가 이를 분석해 SQL을 생성합니다.

using System.Linq.Expressions;
// Expression<Func<int, bool>>: 표현식 트리
Expression<Func<int, bool>> expr = n => n > 3;
// 트리 구조 분석
var binary = (BinaryExpression)expr.Body;
Console.WriteLine(binary.NodeType); // GreaterThan
Console.WriteLine(binary.Left); // n
Console.WriteLine(binary.Right); // 3
// 컴파일하여 델리게이트로 실행
var func = expr.Compile();
Console.WriteLine(func(5)); // True

var words = new[] { "Hello World", "Foo Bar" };
// 각 문자열을 단어로 분리 후 평탄화
var allWords = words.SelectMany(s => s.Split(' '));
// 결과: ["Hello", "World", "Foo", "Bar"]
var people = new[]
{
new { Name = "Alice", Dept = "HR" },
new { Name = "Bob", Dept = "IT" },
new { Name = "Carol", Dept = "HR" },
};
var byDept = people.GroupBy(p => p.Dept);
// 내부적으로 Lookup<TKey, TElement> 딕셔너리 구조 생성
foreach (var group in byDept)
{
Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(p => p.Name))}");
}
// Join: SQL INNER JOIN
var joined = orders.Join(
customers,
o => o.CustomerId,
c => c.Id,
(o, c) => new { o.Amount, c.Name }
);
// GroupJoin: SQL LEFT OUTER JOIN
var grouped = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, orders) => new { c.Name, Orders = orders }
);

// 나쁜 예: query를 두 번 열거하면 파이프라인이 두 번 실행됨
var query = numbers.Where(ExpensiveFilter);
var count = query.Count(); // 첫 번째 실행
var list = query.ToList(); // 두 번째 실행
// 좋은 예: 한 번만 열거
var list = numbers.Where(ExpensiveFilter).ToList();
var count = list.Count;
// 나쁜 예: Select 후 Where → 불필요한 객체 생성
var bad = items.Select(i => new ExpensiveDTO(i)).Where(d => d.IsValid);
// 좋은 예: Where 먼저 적용
var good = items.Where(i => i.IsValid).Select(i => new ExpensiveDTO(i));

LINQ는 가독성이 뛰어나지만 각 연산자마다 델리게이트 호출 오버헤드가 있습니다. 성능 크리티컬한 경로에서는 Span<T>, for 루프, PLINQ를 고려합니다.

// PLINQ: 병렬 LINQ
var result = numbers.AsParallel()
.Where(n => ExpensiveFilter(n))
.ToList();

IEnumerable<T> 익스텐션 메서드로 자신만의 LINQ 연산자를 정의할 수 있습니다.

public static class LinqExtensions
{
// 배치 처리: 시퀀스를 size 크기 청크로 분할
public static IEnumerable<IEnumerable<T>> Batch<T>(
this IEnumerable<T> source, int size)
{
var batch = new List<T>(size);
foreach (var item in source)
{
batch.Add(item);
if (batch.Count == size)
{
yield return batch;
batch = new List<T>(size);
}
}
if (batch.Count > 0)
yield return batch;
}
}
// 사용
var chunks = Enumerable.Range(1, 10).Batch(3);
// [[1,2,3], [4,5,6], [7,8,9], [10]]

개념핵심
지연 실행쿼리 정의 ≠ 실행. 열거할 때 실행됨
IEnumerable메모리 내 처리. 람다 → 델리게이트
IQueryableDB 처리. 람다 → 표현식 트리 → SQL
yield return상태 머신 기반 지연 이터레이터
즉시 실행ToList/ToArray/Count/First 등

LINQ는 단순 편의 기능이 아닌, C# 타입 시스템 전반을 활용한 강력한 추상화입니다. 지연 실행과 표현식 트리의 동작을 이해하면 성능 문제를 예방하고 더 표현력 있는 코드를 작성할 수 있습니다.