[.NET] List/Array Data Control using LINQ: C# 컬렉션 데이터를 다루는 실무 예제 정리
C#에서 List<T>와 배열은 거의 매일 만나는 자료구조입니다. API에서 받은 목록을 필터링하고, 화면에 보여줄 형태로 바꾸고, 가격순으로 정렬하고, 카테고리별로 묶고, 필요한 경우 Dictionary로 바꾸는 작업은 백엔드와 클라이언트 개발 모두에서 자주 등장합니다.
이때 LINQ를 사용하면 반복문을 길게 쓰지 않고도 데이터를 읽기 좋은 방식으로 다룰 수 있습니다. 예를 들어 Where로 조건을 걸고, Select로 화면용 모델을 만들고, OrderBy로 정렬한 뒤 ToList로 결과를 확정하는 식입니다.
다만 LINQ를 “데이터를 직접 수정하는 도구”로 이해하면 오해가 생깁니다. LINQ는 기본적으로 컬렉션을 조회하고 변환하는 쿼리 도구에 가깝습니다. 데이터를 실제로 변경해야 한다면 LINQ로 대상을 찾은 뒤, 명시적인 foreach나 별도 로직으로 수정하는 편이 더 안전하고 읽기 좋습니다.
핵심 요약
- LINQ는
List<T>, 배열,IEnumerable<T>데이터를 필터링·정렬·변환·집계하는 데 유용합니다. Where는 조건 필터링,Select는 형태 변환,OrderBy는 정렬,GroupBy는 그룹화에 사용합니다.ToList와ToArray는 쿼리 결과를 즉시 실행하고 현재 시점의 결과로 고정할 때 사용합니다.- LINQ는 지연 실행되는 경우가 많기 때문에, 언제 실행되는지 이해하는 것이 중요합니다.
- 실제 객체 값을 수정해야 할 때는 LINQ로 대상을 찾고, 수정은 명시적인 반복문에서 처리하는 것이 읽기 좋습니다.
- 대용량 데이터에서는 중복 순회, 불필요한
ToList, 잘못된Contains사용이 성능 문제가 될 수 있습니다.
1. 예제에서 사용할 데이터
먼저 예제에서 사용할 간단한 상품 목록을 만들겠습니다. 실무에서는 DB에서 조회한 결과, API 응답, 캐시 데이터, 화면 바인딩 데이터가 이 역할을 합니다.
public record Product(
int Id,
string Name,
string Category,
decimal Price,
int Stock,
bool IsActive);
var products = new List<Product>
{
new(1, "Keyboard", "Device", 89000, 12, true),
new(2, "Mouse", "Device", 39000, 0, true),
new(3, "Monitor", "Display", 279000, 5, true),
new(4, "USB Cable", "Accessory", 9000, 30, true),
new(5, "Old Speaker", "Audio", 49000, 2, false),
new(6, "Headset", "Audio", 79000, 7, true)
};
이 데이터를 기준으로 필터링, 변환, 정렬, 그룹화, 조인, Dictionary 변환을 차례대로 살펴보겠습니다.
2. Where: 조건에 맞는 데이터만 필터링하기
가장 자주 쓰는 LINQ 메서드는 Where입니다. 활성화된 상품 중 재고가 있는 상품만 가져오고 싶다면 다음처럼 작성할 수 있습니다.
var availableProducts = products
.Where(p => p.IsActive && p.Stock > 0)
.ToList();
foreach (var product in availableProducts)
{
Console.WriteLine($"{product.Name} / Stock: {product.Stock}");
}
반복문으로 작성해도 되지만, 조건이 명확한 경우에는 Where가 의도를 더 잘 보여줍니다. “활성화되어 있고 재고가 있는 상품만 가져온다”는 문장이 코드에 그대로 드러납니다.
3. Select: 화면이나 API 응답용 형태로 바꾸기
원본 데이터 구조와 화면에 보여줄 데이터 구조는 다를 때가 많습니다. 이럴 때 Select를 사용하면 필요한 필드만 뽑거나, 표시용 문자열을 만들어 새로운 형태로 변환할 수 있습니다.
var productCards = products
.Where(p => p.IsActive)
.Select(p => new
{
p.Id,
DisplayName = $"{p.Name} ({p.Category})",
PriceText = $"{p.Price:N0}원",
StockStatus = p.Stock > 0 ? "판매 가능" : "품절"
})
.ToList();
foreach (var card in productCards)
{
Console.WriteLine($"{card.DisplayName} - {card.PriceText} - {card.StockStatus}");
}
Select는 DTO를 만들 때 특히 많이 사용합니다. 엔티티 전체를 그대로 반환하지 않고, 화면에 필요한 데이터만 골라 보내면 응답 크기도 줄고 코드 의도도 분명해집니다.
4. OrderBy, Skip, Take: 정렬과 페이징 처리
목록 화면에서는 정렬과 페이징이 거의 항상 필요합니다. 아래 예제는 활성 상품을 가격 높은 순으로 정렬한 뒤, 첫 번째 페이지에 해당하는 3개만 가져옵니다.
int page = 1;
int pageSize = 3;
var pageItems = products
.Where(p => p.IsActive)
.OrderByDescending(p => p.Price)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
foreach (var item in pageItems)
{
Console.WriteLine($"{item.Name} / {item.Price:N0}원");
}
메모리 안의 작은 컬렉션이라면 이런 방식이 간단하고 좋습니다. 하지만 데이터베이스에서 가져오는 데이터라면 가능한 한 DB 쿼리 단계에서 정렬과 페이징이 처리되도록 작성해야 합니다. 전체 데이터를 메모리로 가져온 뒤 Skip, Take를 적용하면 성능이 나빠질 수 있습니다.
5. FirstOrDefault, Any, All: 조건 확인하기
목록에서 특정 데이터 하나를 찾거나, 특정 조건을 만족하는 데이터가 있는지 확인할 때는 FirstOrDefault, Any, All을 자주 사용합니다.
var target = products.FirstOrDefault(p => p.Id == 3);
if (target is not null)
{
Console.WriteLine($"Found: {target.Name}");
}
bool hasSoldOut = products.Any(p => p.IsActive && p.Stock == 0);
bool allHaveName = products.All(p => !string.IsNullOrWhiteSpace(p.Name));
Console.WriteLine($"Has sold out item: {hasSoldOut}");
Console.WriteLine($"All products have name: {allHaveName}");
단순히 존재 여부만 확인할 때는 Count() > 0보다 Any()가 더 자연스럽습니다. Any는 조건에 맞는 요소를 찾으면 더 이상 순회하지 않아도 되기 때문입니다.
6. GroupBy: 카테고리별 집계 만들기
관리자 화면이나 리포트에서는 카테고리별 개수, 재고 합계, 평균 가격 같은 집계가 필요합니다. 이럴 때 GroupBy와 Sum, Average, Count를 함께 사용합니다.
var summaryByCategory = products
.Where(p => p.IsActive)
.GroupBy(p => p.Category)
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
TotalStock = g.Sum(p => p.Stock),
AveragePrice = g.Average(p => p.Price)
})
.OrderBy(x => x.Category)
.ToList();
foreach (var item in summaryByCategory)
{
Console.WriteLine(
$"{item.Category}: {item.Count}개, 재고 {item.TotalStock}개, 평균 {item.AveragePrice:N0}원");
}
GroupBy는 코드가 길어지기 쉬우므로, 결과 객체의 필드 이름을 명확히 붙이는 것이 좋습니다. 그래야 나중에 이 코드를 읽는 사람이 어떤 집계를 만드는지 바로 이해할 수 있습니다.
7. ToDictionary, ToLookup: 빠르게 찾을 수 있는 구조로 바꾸기
ID로 상품을 자주 찾아야 한다면 매번 FirstOrDefault로 전체 목록을 검색하는 것보다 Dictionary로 바꿔두는 것이 좋습니다. 반대로 하나의 키에 여러 값이 매핑될 수 있다면 ToLookup이 편합니다.
var productMap = products.ToDictionary(p => p.Id);
if (productMap.TryGetValue(3, out var monitor))
{
Console.WriteLine(monitor.Name);
}
var productsByCategory = products.ToLookup(p => p.Category);
foreach (var item in productsByCategory["Audio"])
{
Console.WriteLine(item.Name);
}
ToDictionary는 키가 중복되면 예외가 발생합니다. 그래서 실제 데이터에서 키 중복 가능성이 있다면 먼저 중복을 제거하거나, GroupBy로 정리한 뒤 Dictionary로 변환하는 방식이 안전합니다.
8. Join: 두 목록을 연결하기
주문 목록과 상품 목록처럼 서로 다른 두 컬렉션을 연결해야 할 때는 Join을 사용할 수 있습니다. SQL의 INNER JOIN과 비슷한 느낌으로 이해하면 됩니다.
public record Order(int OrderId, int ProductId, int Quantity);
var orders = new List<Order>
{
new(1001, 1, 2),
new(1002, 3, 1),
new(1003, 6, 3)
};
var orderDetails = orders
.Join(
products,
order => order.ProductId,
product => product.Id,
(order, product) => new
{
order.OrderId,
product.Name,
order.Quantity,
TotalPrice = product.Price * order.Quantity
})
.ToList();
foreach (var detail in orderDetails)
{
Console.WriteLine(
$"Order #{detail.OrderId}: {detail.Name} x {detail.Quantity} = {detail.TotalPrice:N0}원");
}
메모리 컬렉션끼리 조인하는 경우에는 데이터 크기를 주의해야 합니다. 데이터가 매우 크다면 데이터베이스에서 조인하는 편이 더 적절할 수 있습니다.
9. Array에서도 LINQ는 똑같이 사용할 수 있다
LINQ는 List<T>뿐 아니라 배열에도 사용할 수 있습니다. 배열은 IEnumerable<T>로 순회할 수 있기 때문에 Where, Select, OrderBy 같은 메서드를 그대로 쓸 수 있습니다.
int[] scores = { 91, 67, 82, 100, 76, 88 };
var passed = scores
.Where(score => score >= 80)
.OrderByDescending(score => score)
.ToArray();
Console.WriteLine(string.Join(", ", passed)); // 100, 91, 88, 82
최종 결과가 배열이어야 한다면 ToArray를 사용하고, 이후 추가·삭제가 필요한 목록이라면 ToList를 사용하는 식으로 선택하면 됩니다.
10. 지연 실행: LINQ에서 가장 자주 헷갈리는 부분
LINQ를 처음 사용할 때 가장 많이 헷갈리는 부분이 지연 실행입니다. 많은 LINQ 쿼리는 쿼리를 작성하는 순간 실행되지 않고, 실제로 순회되는 시점에 실행됩니다.
var numbers = new List<int> { 1, 2, 3 };
var query = numbers.Where(n => n >= 2);
numbers.Add(4);
// query는 foreach로 순회되는 시점에 실행됩니다.
Console.WriteLine(string.Join(", ", query)); // 2, 3, 4
// 현재 시점의 결과를 고정하고 싶다면 ToList()나 ToArray()를 사용합니다.
var snapshot = numbers.Where(n => n >= 2).ToList();
numbers.Add(5);
Console.WriteLine(string.Join(", ", snapshot)); // 2, 3, 4
위 예제에서 query는 선언 시점에 결과가 고정되지 않습니다. 나중에 foreach나 string.Join처럼 실제로 순회할 때 현재 컬렉션 상태를 기준으로 실행됩니다. 반대로 ToList를 호출하면 그 시점의 결과가 리스트로 고정됩니다.
11. LINQ로 찾고, 수정은 명시적으로 처리하기
LINQ 안에서 객체 상태를 바꾸는 코드를 넣을 수도 있지만, 권장하고 싶지는 않습니다. 조회와 변경이 섞이면 코드 의도가 흐려지고, 나중에 버그를 찾기 어려워집니다. 그래서 저는 보통 LINQ로 수정 대상을 찾고, 실제 변경은 따로 처리합니다.
public class CartItem
{
public int ProductId { get; set; }
public string Name { get; set; } = "";
public int Quantity { get; set; }
public bool IsSelected { get; set; }
}
var cart = new List<CartItem>
{
new() { ProductId = 1, Name = "Keyboard", Quantity = 1, IsSelected = true },
new() { ProductId = 2, Name = "Mouse", Quantity = 2, IsSelected = false },
new() { ProductId = 3, Name = "Monitor", Quantity = 1, IsSelected = true }
};
// LINQ로 대상을 찾고, 실제 변경은 명시적인 foreach에서 처리합니다.
var selectedItems = cart.Where(item => item.IsSelected).ToList();
foreach (var item in selectedItems)
{
item.Quantity += 1;
}
이렇게 작성하면 “선택된 아이템을 찾는다”와 “수량을 변경한다”는 두 단계가 분리됩니다. 코드가 조금 길어져도 유지보수 관점에서는 더 안전합니다.
12. Contains와 HashSet: 선택된 ID 목록으로 필터링하기
체크박스에서 선택된 ID 목록으로 데이터를 필터링하는 경우가 많습니다. 데이터가 작으면 Contains만으로 충분하지만, 데이터가 많거나 반복 조회가 많으면 HashSet으로 바꾸는 편이 좋습니다.
var selectedIds = new[] { 1, 3, 6 };
// 데이터가 작으면 Contains만으로 충분합니다.
var selectedProducts = products
.Where(p => selectedIds.Contains(p.Id))
.ToList();
// 데이터가 많거나 반복 조회가 많다면 HashSet을 고려합니다.
var selectedIdSet = selectedIds.ToHashSet();
var selectedProductsFast = products
.Where(p => selectedIdSet.Contains(p.Id))
.ToList();
작은 목록에서는 차이가 거의 느껴지지 않습니다. 하지만 수천 건, 수만 건 단위로 반복 조회한다면 이런 작은 차이가 전체 응답 시간에 영향을 줄 수 있습니다.
13. null 처리도 LINQ 안에서 명확하게
실제 데이터에는 null이 자주 섞입니다. LINQ에서 null 가능성이 있는 값을 다룰 때는 Where로 먼저 걸러내거나, 기본값을 지정하는 방식이 좋습니다.
public record User(int Id, string? Name, string? Email);
var users = new List<User>
{
new(1, "Minjun", "minjun@example.com"),
new(2, null, "unknown@example.com"),
new(3, "Jisoo", null)
};
var validEmails = users
.Where(u => !string.IsNullOrWhiteSpace(u.Email))
.Select(u => u.Email!)
.ToList();
foreach (var email in validEmails)
{
Console.WriteLine(email);
}
nullable reference type을 사용하는 프로젝트라면 컴파일러 경고를 무시하지 않는 것이 좋습니다. ! 연산자는 “여기서는 null이 아니다”라고 컴파일러에게 알려주는 도구이지만, 남용하면 실제 null 문제를 숨길 수 있습니다.
14. LINQ 사용 시 자주 하는 실수
| 실수 | 문제점 | 개선 방향 |
|---|---|---|
불필요한 ToList() 남발 |
메모리 사용량 증가, 불필요한 즉시 실행 | 결과를 고정해야 할 때만 사용 |
| 같은 쿼리를 여러 번 순회 | 계산이 반복될 수 있음 | 반복 사용 결과는 ToList()로 캐싱 |
| LINQ 내부에서 상태 변경 | 부작용 때문에 코드 이해가 어려움 | 조회와 변경 로직 분리 |
| 대용량 데이터를 메모리에서 페이징 | 성능 저하 | DB 쿼리에서 정렬·페이징 처리 |
키 중복을 고려하지 않고 ToDictionary 사용 |
예외 발생 가능 | 중복 확인 또는 ToLookup 사용 |
15. 마무리
LINQ는 C#에서 컬렉션 데이터를 다루는 가장 편한 도구 중 하나입니다. Where, Select, OrderBy, GroupBy, Join만 익숙해져도 대부분의 List와 Array 데이터 처리를 훨씬 간결하게 만들 수 있습니다.
하지만 LINQ를 잘 쓰려면 메서드 이름을 많이 아는 것보다 실행 시점을 이해하는 것이 더 중요합니다. 지연 실행, ToList, ToArray, 중복 순회, 대용량 데이터 처리 기준을 알고 있어야 실무에서 성능 문제를 피할 수 있습니다.
정리하면 LINQ는 “짧게 쓰는 기술”이 아니라 “의도를 읽기 쉽게 표현하는 기술”입니다. 데이터 조회와 변환은 LINQ로 명확하게 쓰고, 실제 데이터 변경은 별도 로직으로 분리하면 유지보수하기 좋은 C# 코드를 만들 수 있습니다.
이 글의 코드는 설명을 위한 예제이며, 실제 서비스 적용 전에는 반드시 테스트와 성능 검토를 거쳐야 합니다. LINQ to Objects와 LINQ to Entities처럼 데이터 소스에 따라 실행 방식이 달라질 수 있으므로, 데이터베이스 쿼리에서는 실제 생성되는 SQL과 실행 계획을 함께 확인하는 것이 좋습니다.
FAQ
Q1. LINQ는 List에서만 사용할 수 있나요?
아닙니다. 배열, List<T>, IEnumerable<T>, IQueryable<T> 등 다양한 데이터 소스에서 사용할 수 있습니다. 다만 데이터 소스에 따라 실행 방식과 성능 특성이 달라질 수 있습니다.
Q2. ToList와 ToArray는 언제 사용하나요?
쿼리 결과를 현재 시점에 고정하거나, 여러 번 반복해서 사용할 때 사용합니다. 결과를 계속 추가·삭제해야 한다면 ToList, 고정된 배열이 필요하다면 ToArray가 적합합니다.
Q3. LINQ는 항상 반복문보다 느린가요?
항상 그렇지는 않습니다. 작은 데이터에서는 차이가 거의 없고, 가독성 면에서 LINQ가 더 좋은 경우가 많습니다. 다만 매우 성능에 민감한 코드나 대용량 반복 처리에서는 실제 측정 후 반복문이 더 적합할 수 있습니다.
Q4. LINQ로 객체 값을 수정해도 되나요?
가능은 하지만 권장하지 않습니다. LINQ는 조회와 변환을 표현하는 데 적합합니다. 실제 수정은 LINQ로 대상을 찾은 뒤, 별도의 foreach에서 처리하는 편이 더 읽기 좋습니다.
Q5. GroupBy와 ToLookup은 어떻게 다른가요?
GroupBy는 그룹화된 결과를 쿼리 형태로 다룰 때 많이 사용하고, ToLookup은 특정 키로 여러 값을 반복 조회해야 할 때 편합니다. 같은 키에 여러 값이 있을 수 있다면 ToDictionary보다 ToLookup이 안전할 수 있습니다.
참고자료
- LINQ 표준 쿼리 연산자 개요
Microsoft Learn - Standard query operators overview - C# LINQ 소개
Microsoft Learn - Language Integrated Query - LINQ 쿼리 작성 방법
Microsoft Learn - Write LINQ queries - LINQ 지연 실행 설명
Microsoft Learn - Deferred execution and lazy evaluation - Enumerable.ToList 공식 문서
Microsoft Learn - Enumerable.ToList - Enumerable.ToArray 공식 문서
Microsoft Learn - Enumerable.ToArray