.NET 환경에서 C# LINQ를 처음 다뤄보면 메서드는 외웠는데 정작 어디서 멈춰야 하는지 감이 안 오는 순간이 옵니다.
Where 뒤에 ToList()를 박을지 말지, Count()가 왜 갑자기 DB 호출을 두 번 때리는지 모르면 운영에서 응답시간이 분 단위로 늘어집니다.
본업으로 .NET을 매일 다루는 풀스택 입장에서 LINQ를 실서비스 코드에 박으면서 자주 본 패턴과 함정을 정리했습니다. 이 순서대로 가면 LINQ 12가지 실전 패턴 + 성능 함정 5가지 + 벤치마크 판단 기준이 끝납니다.

왜 이 12개인가
원본 글에서 다뤘던 모범 사례 8개에 본업에서 매일 손이 가는 4개(GroupBy 집계 · SelectMany · Aggregate · Zip)를 더했습니다. 12개는 "이거 하나로 끝"이 아니라, Where·Select·OrderBy 같은 기본기를 이미 아는 사람이 한 단계 더 갈 때 막히는 지점들입니다.
선별 원칙은 단순합니다.
- 본업 .NET 코드에서 월 1회 이상 등장하는 패턴
- 잘못 쓰면 응답시간·메모리에 즉시 영향이 가는 패턴
Microsoft Learn공식 문서에 명시된 메서드만 (확장 메서드 자체 구현 제외)
Microsoft Learn의 LINQ 시작 가이드에 정리된 메서드 카탈로그 중 실무 빈도 상위만 골랐다고 보면 됩니다.
| # | 패턴 | 핵심 메서드 | 주 용도 |
|---|---|---|---|
| 1 | 가독성 우선 | Where / Select |
반복문 대체 |
| 2 | 지연 실행 인지 | IEnumerable<T> |
실행 시점 제어 |
| 3 | 머터리얼라이즈 | ToList / ToArray |
스냅샷 + 재열거 방지 |
| 4 | 메서드 체이닝 | Where → OrderBy → Select |
복잡 쿼리 |
| 5 | 단일 항목 | First / FirstOrDefault / Single |
0~1개 반환 |
| 6 | 존재 검사 | Any |
Count() > 0 대체 |
| 7 | 집계 | Sum / Average / Aggregate |
합·평균·커스텀 |
| 8 | 그룹핑 | GroupBy |
카테고리별 집계 |
| 9 | 사전 변환 | ToDictionary |
조회용 인덱스 |
| 10 | 계층 펼치기 | SelectMany |
1:N → 평탄화 |
| 11 | 집합 연산 | Distinct / Union / Except |
중복·차집합 |
| 12 | 시퀀스 결합 | Zip |
인덱스 동기 결합 |
표가 메뉴판이라면, 다음 섹션부터는 각 항목에서 놓치기 쉬운 지점만 풀어 쓰겠습니다.

패턴 1~4 — 기본기와 지연 실행
1. 가독성 우선 — Where·Select
// 권장 - 의도가 한 줄에 드러납니다
var activeUsers = users.Where(u => u.IsActive).ToList();
// 비권장 - 5줄 + 임시 컬렉션 직접 관리
var activeUsers = new List<User>();
foreach (var user in users)
{
if (user.IsActive) activeUsers.Add(user);
}
가독성이 떨어지는 두 번째 코드를 만나면 리뷰에서 C# LINQ로 바꿔달라고 요청합니다. 단, 후술할 "대용량 컬렉션" 함정에 해당하면 반복문이 더 빠를 때가 있으니 일괄로 LINQ로 통일하지는 않습니다.
2. 지연 실행 — 실행 시점이 따로 있습니다
// 이 시점엔 쿼리가 아직 실행되지 않습니다 (IEnumerable<T> 반환)
var query = numbers.Where(n => n % 2 == 0);
// foreach 들어가는 순간 실행됩니다
foreach (var n in query) Console.WriteLine(n);
Where·Select·OrderBy 같은 메서드는 결과를 반환하는 게 아니라 "나중에 실행할 쿼리 정의"를 반환합니다. ToList() / ToArray() / foreach / Count() 같은 머터리얼라이즈 시점에 비로소 본체가 돕니다.
본업에서 가장 자주 보는 실수는 다음 코드입니다.
// 함정 - 같은 query를 두 번 열거
var query = dbContext.Orders.Where(o => o.IsPaid);
var count = query.Count(); // DB 호출 1
var first = query.FirstOrDefault(); // DB 호출 2
EF Core 환경이라면 같은 조건의 SELECT가 두 번 나갑니다. 같은 데이터를 두 번 보고 싶으면 한 번 머터리얼라이즈하고 메모리에서 다뤄야 합니다.
3. ToList / ToArray / ToDictionary — 무엇을 선택할까
| 메서드 | 언제 쓰나 | 비용 |
|---|---|---|
ToList() |
추가/삭제 필요, 인덱스 접근 자주 | 가장 흔함 |
ToArray() |
크기 고정, 메모리 작게 | List보다 약간 가벼움 |
ToDictionary(k => k.Id) |
조회용 인덱스 필요 | 키 중복 시 예외 — 주의 |
본업 코드에서는 결과를 한 번 받아 여러 메서드에 넘기는 경우가 많아서 ToList() 비중이 가장 큽니다. ToDictionary는 키 중복이 들어오면 ArgumentException이 나니까, 신뢰 못 하는 데이터는 ToLookup을 대신 씁니다.
4. 메서드 체이닝 — 복잡할수록 쪼개기
var result = users
.Where(u => u.IsActive)
.OrderBy(u => u.LastName)
.ThenBy(u => u.FirstName)
.Select(u => new { u.FullName, u.Email })
.ToList();
한 줄에 다 박지 말고 줄바꿈으로 끊으면 PR 리뷰에서 변경점 찾기가 훨씬 수월합니다. 메서드 체이닝의 장점은 읽기 좋다는 것이지 짧다는 게 아닙니다.
이전 글 시니어 .NET 개발자가 전하는 17가지 핵심 팁에서 다룬 코드 스타일과 같은 맥락입니다.
패턴 5~8 — 단일 항목·존재 검사·집계·그룹핑
5. 단일 항목 — First / FirstOrDefault / Single 구분
| 메서드 | 0개일 때 | 1개일 때 | 2개 이상일 때 |
|---|---|---|---|
First |
예외 | 반환 | 첫 번째 반환 |
FirstOrDefault |
default |
반환 | 첫 번째 반환 |
Single |
예외 | 반환 | 예외 |
SingleOrDefault |
default |
반환 | 예외 |
"비즈니스 룰상 0개나 1개여야 한다"는 보장이 있으면 SingleOrDefault를 씁니다. 만약 2개 이상이 들어오면 예외로 잡혀서 데이터 정합성 버그를 일찍 드러내 줍니다. FirstOrDefault로 박으면 버그가 묻혀버립니다.
6. 존재 검사 — Any 우선
// 권장
if (users.Any(u => u.IsAdmin)) { /* ... */ }
// 비권장 - 전체 카운트를 끝까지 계산합니다
if (users.Count(u => u.IsAdmin) > 0) { /* ... */ }
Any는 조건에 맞는 항목을 하나 찾는 즉시 종료합니다. Count는 전체를 다 세야 끝나는데, 대용량 컬렉션에서는 이 차이가 응답시간에 그대로 박힙니다.
7. 집계 — Sum·Average·Aggregate
// 표준 집계
var totalAmount = orders.Sum(o => o.Amount);
var avgScore = students.Average(s => s.Score);
// 커스텀 집계 - Aggregate
var concatenated = words.Aggregate((a, b) => a + ", " + b);
Aggregate는 Sum·Average로 표현 안 되는 누적 연산에 씁니다. 단, 초기값 없는 형태(Aggregate((a,b) => ...))는 컬렉션이 비어 있으면 InvalidOperationException을 던지니까 시드 인자가 있는 오버로드(Aggregate(seed, ...))를 우선 고려합니다.
8. 그룹핑 — GroupBy로 카테고리 집계
var salesByCategory = orders
.GroupBy(o => o.Category)
.Select(g => new
{
Category = g.Key,
TotalAmount = g.Sum(o => o.Amount),
OrderCount = g.Count()
})
.OrderByDescending(x => x.TotalAmount)
.ToList();
대시보드 데이터를 만들 때 가장 자주 쓰는 조합입니다. 메모리 LINQ에서는 한 번에 처리되지만, EF Core 환경에서는 GroupBy가 클라이언트 평가로 떨어지는 경우가 있어서 SQL이 생성된 모양을 한 번 확인해야 합니다. 비효율적인 SQL이 나오면 차라리 dbContext.Database.SqlQuery<T> 같은 raw 쿼리로 빼는 게 낫습니다.
패턴 9~12 — 사전 변환·평탄화·집합·결합
9. ToDictionary — 조회용 인덱스
// O(1) 조회를 위해 사전으로 변환
var userById = users.ToDictionary(u => u.Id);
// 사용
if (userById.TryGetValue(targetId, out var user)) { /* ... */ }
users.First(u => u.Id == targetId)를 반복문 안에서 N번 호출하면 O(N²)이 됩니다. 한 번 ToDictionary로 만들어두면 O(N + 호출 횟수)로 줄어듭니다.
10. SelectMany — 1:N 관계 평탄화
public class Customer
{
public string Name { get; set; }
public List<Order> Orders { get; set; }
}
// 모든 고객의 모든 주문을 단일 시퀀스로
var allOrders = customers.SelectMany(c => c.Orders).ToList();
// 고객 이름까지 함께
var orderWithCustomer = customers
.SelectMany(c => c.Orders, (c, o) => new { Customer = c.Name, Order = o })
.ToList();
Select로 받으면 IEnumerable<List<Order>>가 되어 한 단계 더 풀어야 하는데, SelectMany는 한 번에 평탄화합니다. 두 번째 오버로드는 부모(c)와 자식(o)을 동시에 결과 객체에 박을 때 유용합니다.
11. 집합 연산 — Distinct·Union·Except·Intersect
var uniqueIds = orders.Select(o => o.UserId).Distinct().ToList();
var bothLists = listA.Union(listB).ToList(); // 합집합
var onlyInA = listA.Except(listB).ToList(); // 차집합
var inBoth = listA.Intersect(listB).ToList(); // 교집합
객체에 대해 Distinct를 쓰려면 기본은 참조 비교라서 결과가 의도와 다릅니다. .NET 6부터 추가된 DistinctBy(o => o.UserId)를 쓰면 키 단위 중복 제거를 한 줄에 끝낼 수 있습니다.
12. Zip — 두 시퀀스를 인덱스 동기로 결합
var names = new[] { "Alice", "Bob", "Carol" };
var scores = new[] { 90, 85, 78 };
var pairs = names.Zip(scores, (n, s) => $"{n}: {s}").ToList();
// "Alice: 90", "Bob: 85", "Carol: 78"
길이가 다른 두 시퀀스를 Zip하면 짧은 쪽 기준으로 잘립니다. 길이 일치 검증이 필요하면 Zip 전에 Count를 비교하거나 .NET 6의 3-튜플 Zip 오버로드를 활용합니다.
Microsoft Learn의 LINQ 메서드 카탈로그에는 Chunk·MaxBy·MinBy 같은 비교적 신규 메서드도 정리돼 있으니, 본업 코드 베이스의 SDK 버전을 확인하고 가져다 쓰면 됩니다. C# LINQ는 .NET 6~9를 지나면서 메서드 카탈로그가 꾸준히 늘어왔다는 점을 기억해두면 좋습니다.

성능 — 벤치마크와 함정 5가지
벤치마크 — LINQ vs for문
BenchmarkDotNet으로 100만 개 정수 컬렉션에 대해 측정한 결과입니다 (원본 글 데이터, .NET 8 기준).
| 메서드 | 평균 (μs) | StdDev (μs) | 비고 |
|---|---|---|---|
SumWithLinq |
463.7 | 4.31 | 거의 차이 없음 |
SumWithLoop |
395.8 | 2.63 | 약 15% 빠름 |
FilterWithLinq |
10,523.3 | 95.78 | 임시 컬렉션 비용 |
FilterWithLoop |
5,837.7 | 38.27 | 약 45% 빠름 |
단순 합산은 LINQ와 반복문이 거의 같습니다. 반면 필터링 + ToList 머터리얼라이즈가 들어가면 반복문이 두 배 가까이 빠릅니다. 다만 마이크로초 단위라서, 본업 코드의 90%는 가독성을 위해 LINQ를 쓰는 게 맞다고 봅니다. 핫패스(요청당 수십만 번 호출되는 경로)일 때만 반복문으로 내려가는 게 합리적입니다.
함정 1 — 같은 쿼리를 여러 번 열거
증상: 같은 데이터를 두 번 조회하는데 DB 쿼리는 두 번 나간다 (또는 외부 API 호출이 두 번 발생).
원인: IEnumerable<T>는 지연 실행이라 Count() → FirstOrDefault() 식으로 두 번 호출하면 본체가 두 번 실행됨.
해결: 결과를 한 번만 쓰면 그대로, 두 번 이상 쓰면 ToList()로 머터리얼라이즈 후 사용.
함정 2 — Count() > 0 vs Any()
증상: 대용량 컬렉션에서 "있는지만 확인"하는 코드가 비정상적으로 느리다.
원인: Count(predicate) > 0은 끝까지 다 세고 나서 비교한다. 100만 건 컬렉션에 첫 항목이 매치돼도 끝까지 순회.
해결: 존재 여부만 필요하면 Any(predicate). 첫 매치에서 즉시 종료된다.
함정 3 — ToList() 중복
증상: 메모리 사용량이 갑자기 튀어 GC 압박이 심해진다.
원인: 체이닝 중간마다 ToList()를 박아 매 단계 임시 컬렉션을 만들고 있다. 예: users.Where(...).ToList().Select(...).ToList().OrderBy(...).ToList().
해결: 머터리얼라이즈는 마지막 한 번만. 중간 단계는 IEnumerable<T> 그대로 흘려보낸다.
함정 4 — Where → Select 순서
증상: 큰 객체에서 작은 필드만 뽑는데 처리 시간이 줄지 않는다.
원인: Select로 먼저 변환하고 그 결과를 Where로 필터링하면, 전체 항목에 대해 변환 비용이 먼저 들어간다.
해결: 필터 먼저, 변환 나중에. .Where(predicate).Select(projection) 순서를 지키면 변환 대상이 줄어 성능 이득.
함정 5 — PLINQ 오용
증상: 성능 좋게 하려고 AsParallel()을 박았는데 오히려 느려진다.
원인: 항목당 처리 비용이 작거나 컬렉션이 작으면 스레드 분할·결합 오버헤드가 처리 이득보다 크다. 또한 순서 보장이 필요하면 AsOrdered()를 안 박으면 결과가 뒤섞인다.
해결: PLINQ는 항목당 처리 비용이 크고(IO 포함 아님) 컬렉션이 크다는 조건을 동시에 만족할 때만. 의심되면 PLINQ 빼고 측정 → 박고 측정 두 번 비교.
사용 시 의사결정 — 한 줄 매트릭스
| 상황 | 권장 |
|---|---|
| 작은~중간 컬렉션, 일반 비즈니스 로직 | LINQ (가독성 우선) |
| 핫패스, 마이크로초가 중요 | for문 + 필요 메서드만 |
| EF Core 쿼리 | LINQ + 생성 SQL 확인 |
| 두 번 이상 열거 | ToList() 1회 |
| 존재 검사 | Any() |
| 0~1개 보장 데이터 | SingleOrDefault() |
| 1:N 평탄화 | SelectMany |
| 카테고리 집계 | GroupBy + Select |
| 항목당 비용 큰 병렬 처리 | PLINQ (측정 후 결정) |
이 매트릭스를 기준으로 정하면 대부분의 LINQ 결정은 1분 안에 끝납니다.

마무리 — 결정 기준 한 줄
본업으로 .NET을 매일 다루는 입장에서 한 줄로 결론을 내리면 — "읽기 쉬운 게 디폴트, 성능이 진짜 문제가 되면 반복문" 입니다. 마이크로 최적화로 빠지지 말고, 측정 도구(BenchmarkDotNet·dotnet-counters)로 병목을 먼저 확인한 다음 C# LINQ를 반복문으로 바꾸는 순서가 맞습니다.
본업 코드 베이스에서 C# LINQ 함정 5가지만 안 밟아도 응답시간 이슈의 절반은 사라집니다. 동일한 톤으로 정리한 .NET 성능 저하 안티 패턴 10가지도 같이 보면 LINQ 외 다른 성능 함정까지 한 번에 잡힙니다.
Q&A — 자주 보는 질문 5개
Q. C# LINQ 사용법은 어디서부터 시작해야 하나요?
A. Where → Select → OrderBy 세 개로 충분합니다. 이 세 메서드로 90% 시나리오가 커버되니까, 나머지는 필요한 시점에 한 개씩 익혀가는 게 낫습니다. C# LINQ를 처음 잡는 단계라면 메서드 카탈로그 전체를 외우려 하지 말고 본업 코드에서 등장하는 순서대로 익히는 편이 빠릅니다.
Q. LINQ가 반복문보다 느린데 그래도 써야 하나요?
A. 본업 코드의 대부분에서는 써야 합니다. 측정해보면 LINQ와 for문의 차이는 마이크로초 단위인데, 가독성·유지보수 비용 차이는 분 단위입니다. 다만 요청당 수십만 번 호출되는 핫패스에서는 반복문이 정답일 수 있으니, 그 경로만 골라서 최적화하는 게 맞습니다.
Q. LINQ 지연 실행이 정확히 뭔가요?
A. Where·Select·OrderBy 같은 메서드는 결과를 만드는 게 아니라 "나중에 실행할 쿼리 정의"를 반환합니다. foreach·ToList()·Count() 같은 종료 연산이 호출돼야 비로소 본체가 돕니다. 같은 쿼리를 여러 번 열거하면 본체도 여러 번 실행되니까, 두 번 이상 쓰면 ToList()로 머터리얼라이즈하는 게 안전합니다.
Q. ToList()는 언제 써야 하나요?
A. 두 가지 경우입니다. ① 같은 쿼리 결과를 두 번 이상 사용할 때 (재열거 방지), ② 시점 스냅샷이 필요할 때 (원본 변경과 무관하게 고정). 그 외에는 마지막 한 번만 박고, 중간 체이닝에서는 IEnumerable<T>를 그대로 흘려보내는 게 메모리에 유리합니다.
Q. PLINQ는 언제 효과가 있나요?
A. 항목당 처리 비용이 크고 컬렉션이 큰 두 조건을 동시에 만족할 때만입니다. 단순 합산·필터링은 PLINQ를 박으면 오히려 느려집니다. IO 작업(DB·HTTP)은 PLINQ가 아니라 async/await 영역이라는 점도 자주 헷갈리는 부분입니다. 의심되면 BenchmarkDotNet으로 PLINQ 적용 전후를 측정한 다음 결정하면 됩니다.
📚 참고 자료: 이 글은 hgko-dev.tistory.com/503 — C# LINQ 모범 사례를 본업으로 .NET을 다루는 풀스택 관점에서 재구성·확장한 버전입니다. 메서드 카탈로그와 동작 정의는 Microsoft Learn 공식 문서를 기준으로 검증했습니다.
조건이 일치하면 위 흐름이 가장 빠릅니다.
설치 환경: Windows 11, .NET 8 LTS, C# 12, BenchmarkDotNet 0.13.x
'Language > C#' 카테고리의 다른 글
| C# 13 및 .NET 9 필수 기능 소개 (0) | 2025.01.09 |
|---|---|
| 효율적인 .NET 개발을 위한 4가지 필수 라이브러리 소개 (0) | 2024.11.19 |
| C# 개발에 도움을 주는 기본 개념 7가지 (0) | 2024.10.28 |
| [C#] async, await 기능을 사용한 비동기 프로그래밍 (0) | 2024.10.21 |
| .NET Core로 고성능 API 빌드하기 (0) | 2024.10.18 |
IT 기술과 개발 내용을 포스팅하는 블로그
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!