LINQ는 컬렉션을 읽기 좋게 다루는 방법이다
LINQ는 Language Integrated Query의 줄임말이다. C# 코드 안에서 목록, 배열, 딕셔너리 같은 데이터를 조회하고 가공하는 문법이다.
지금까지 우리는 컬렉션을 처리할 때 주로 foreach와 if를 사용했다.
List<int> numbers = [1, 2, 3, 4, 5, 6];
List<int> evenNumbers = [];
foreach (int number in numbers)
{
if (number % 2 == 0)
{
evenNumbers.Add(number);
}
}
foreach (int number in evenNumbers)
{
Console.WriteLine(number);
}실행 결과:
2
4
6같은 코드를 LINQ로 쓰면 다음처럼 바꿀 수 있다.
List<int> numbers = [1, 2, 3, 4, 5, 6];
var evenNumbers = numbers.Where(number => number % 2 == 0);
foreach (int number in evenNumbers)
{
Console.WriteLine(number);
}Where(number => number % 2 == 0)은 "짝수인 값만 골라라"라는 뜻이다. 반복문을 직접 작성하지 않아도 조건에 맞는 값만 얻을 수 있다.
이런 방식을 선언적 코드라고 부른다. 선언적 코드는 "어떻게 반복할지"보다 "무엇을 원하는지"를 더 분명하게 표현한다.
AI 생성 이미지
예제 데이터 준비하기
이번 장에서는 판매 목록을 사용한다. LINQ는 숫자 목록에도 쓸 수 있지만, 실제 프로그램에서는 보통 객체 목록을 많이 다룬다.
List<Sale> sales =
[
new Sale { Product = "Keyboard", Category = "Device", Quantity = 2, UnitPrice = 50000, IsPaid = true },
new Sale { Product = "Mouse", Category = "Device", Quantity = 3, UnitPrice = 20000, IsPaid = true },
new Sale { Product = "Monitor", Category = "Device", Quantity = 1, UnitPrice = 180000, IsPaid = false },
new Sale { Product = "Book", Category = "Book", Quantity = 5, UnitPrice = 15000, IsPaid = true },
new Sale { Product = "USB Cable", Category = "Device", Quantity = 10, UnitPrice = 8000, IsPaid = true }
];
class Sale
{
public string Product { get; set; } = "";
public string Category { get; set; } = "";
public int Quantity { get; set; }
public int UnitPrice { get; set; }
public bool IsPaid { get; set; }
}Sale 클래스는 하나의 판매 기록을 나타낸다.
Product: 상품명Category: 분류Quantity: 판매 수량UnitPrice: 단가IsPaid: 결제 여부
최신 .NET 콘솔 프로젝트에서는 보통 System.Linq가 자동으로 포함된다. 만약 Where나 Select를 찾을 수 없다는 오류가 나오면 파일 위쪽에 다음 코드를 추가한다.
using System.Linq;Where, Select, OrderBy 사용하기
LINQ에서 가장 자주 쓰는 세 가지는 Where, Select, OrderBy다.
Where: 조건에 맞는 값만 고른다.Select: 값을 다른 형태로 바꾼다.OrderBy: 값을 오름차순으로 정렬한다.
예를 들어 결제 완료된 장비 판매만 골라 상품명과 판매 금액을 출력해 보자.
var paidDevices = sales
.Where(sale => sale.IsPaid && sale.Category == "Device")
.OrderBy(sale => sale.Product)
.Select(sale => $"{sale.Product}: {sale.Quantity * sale.UnitPrice:N0}원");
foreach (string item in paidDevices)
{
Console.WriteLine(item);
}실행 결과:
Keyboard: 100,000원
Mouse: 60,000원
USB Cable: 80,000원이 코드는 세 단계로 읽으면 된다.
.Where(sale => sale.IsPaid && sale.Category == "Device")결제 완료이고 분류가 Device인 판매만 고른다.
.OrderBy(sale => sale.Product)상품명을 기준으로 정렬한다.
.Select(sale => $"{sale.Product}: {sale.Quantity * sale.UnitPrice:N0}원")Sale 객체를 출력용 문자열로 바꾼다.
여기서 sale => sale.IsPaid 같은 문법은 이전 장에서 배운 람다식이다. LINQ는 람다식을 자주 사용한다. 람다식 덕분에 "각 판매 기록을 어떻게 검사할지", "각 값을 어떻게 바꿀지"를 짧게 전달할 수 있다.
Count와 Sum으로 집계하기
데이터를 다룰 때는 목록을 출력하는 것뿐 아니라 개수나 합계도 자주 구한다.
Count는 개수를 구한다.
int paidCount = sales.Count(sale => sale.IsPaid);
Console.WriteLine($"결제 완료 건수: {paidCount}");실행 결과:
결제 완료 건수: 4조건 없이 전체 개수를 구할 수도 있다.
int totalCount = sales.Count();다만 List<T>에는 이미 Count 프로퍼티가 있다.
int totalCount = sales.Count;List<T>의 전체 개수만 필요하다면 Count() 메서드보다 Count 프로퍼티가 더 직접적이다.
Sum은 합계를 구한다.
int paidTotal = sales
.Where(sale => sale.IsPaid)
.Sum(sale => sale.Quantity * sale.UnitPrice);
Console.WriteLine($"결제 완료 총액: {paidTotal:N0}원");실행 결과:
결제 완료 총액: 315,000원여기서는 먼저 결제 완료된 판매만 고른 뒤, 각 판매의 수량 × 단가를 모두 더했다.
GroupBy로 묶어서 계산하기
GroupBy는 같은 기준을 가진 데이터끼리 묶는다. 예를 들어 분류별 판매 금액을 구할 수 있다.
var categorySummaries = sales
.GroupBy(sale => sale.Category)
.Select(group => new
{
Category = group.Key,
Count = group.Count(),
Total = group.Sum(sale => sale.Quantity * sale.UnitPrice)
})
.OrderByDescending(summary => summary.Total);
foreach (var summary in categorySummaries)
{
Console.WriteLine($"{summary.Category}: {summary.Count}건, {summary.Total:N0}원");
}실행 결과:
Device: 4건, 420,000원
Book: 1건, 75,000원GroupBy(sale => sale.Category)는 Category 값이 같은 판매 기록끼리 묶는다. 묶인 결과에서 group.Key는 그룹의 기준값이다. 이 예제에서는 "Device", "Book" 같은 분류명이 된다.
그다음 Select에서 각 그룹을 요약 정보로 바꾼다.
new
{
Category = group.Key,
Count = group.Count(),
Total = group.Sum(sale => sale.Quantity * sale.UnitPrice)
}이 문법은 익명 타입을 만든다. 익명 타입은 이름이 없는 임시 객체 타입이다. 필요한 데이터만 뽑아서 잠깐 사용할 때 편하다.
익명 타입과 투영
LINQ에서 Select로 원하는 모양의 결과를 만드는 일을 투영이라고 한다. 어렵게 들리지만 뜻은 간단하다. 원본 전체가 아니라 필요한 값만 뽑아 새 형태로 바꾸는 것이다.
var saleCards = sales.Select(sale => new
{
sale.Product,
Amount = sale.Quantity * sale.UnitPrice,
PaidText = sale.IsPaid ? "결제 완료" : "미결제"
});
foreach (var card in saleCards)
{
Console.WriteLine($"{card.Product} / {card.Amount:N0}원 / {card.PaidText}");
}실행 결과:
Keyboard / 100,000원 / 결제 완료
Mouse / 60,000원 / 결제 완료
Monitor / 180,000원 / 미결제
Book / 75,000원 / 결제 완료
USB Cable / 80,000원 / 결제 완료익명 타입은 화면 출력, 중간 계산, 간단한 보고서 작성에 유용하다. 하지만 여러 메서드 밖으로 넘겨야 하는 중요한 데이터라면 이름 있는 클래스를 만드는 편이 좋다. 익명 타입은 가까운 코드 안에서 간단히 쓸 때 가장 읽기 쉽다.
메서드 구문과 쿼리 구문 비교하기
LINQ는 두 가지 스타일로 작성할 수 있다.
지금까지 본 방식은 메서드 구문이다.
var methodQuery = sales
.Where(sale => sale.UnitPrice >= 50000)
.OrderBy(sale => sale.Product)
.Select(sale => sale.Product);같은 코드를 쿼리 구문으로 쓰면 다음과 같다.
var querySyntax =
from sale in sales
where sale.UnitPrice >= 50000
orderby sale.Product
select sale.Product;둘 다 결과는 같다.
foreach (string product in methodQuery)
{
Console.WriteLine(product);
}실행 결과:
Keyboard
Monitor쿼리 구문은 SQL과 비슷하게 읽힌다. 데이터 조회문처럼 보이기 때문에 여러 조건을 나열할 때 편하게 느껴질 수 있다.
하지만 C# 코드에서는 메서드 구문도 매우 많이 쓰인다. 특히 Count, Sum, Any, FirstOrDefault 같은 메서드와 이어 쓰기 좋다. 이 책에서는 기본적으로 메서드 구문을 중심으로 설명한다. 다만 회사 코드나 예제에서 쿼리 구문을 만나도 같은 LINQ라고 이해하면 된다.
지연 실행 이해하기
LINQ에서 초보자가 가장 자주 놀라는 부분은 지연 실행이다. 지연 실행은 쿼리를 만든 순간 바로 실행하지 않고, 실제로 결과가 필요할 때 실행하는 방식이다. .NET의 LINQ 기능은 이런 지연 실행을 널리 사용한다1.
다음 코드를 보자.
List<int> numbers = [1, 2, 3, 4];
var evenNumbers = numbers.Where(number => number % 2 == 0);
numbers.Add(6);
foreach (int number in evenNumbers)
{
Console.WriteLine(number);
}실행 결과:
2
4
6evenNumbers를 만든 시점에는 numbers에 6이 없었다. 그런데 출력 결과에는 6이 포함된다.
이유는 Where가 실행된 시점이 이 줄이 아니기 때문이다.
var evenNumbers = numbers.Where(number => number % 2 == 0);이 줄은 "나중에 짝수만 고르겠다"는 쿼리를 만든다. 실제 검사는 foreach로 값을 하나씩 꺼낼 때 일어난다.
결과를 지금 바로 고정하고 싶다면 ToList()를 사용한다.
List<int> numbers = [1, 2, 3, 4];
var evenNumbers = numbers
.Where(number => number % 2 == 0)
.ToList();
numbers.Add(6);
foreach (int number in evenNumbers)
{
Console.WriteLine(number);
}실행 결과:
2
4ToList()는 그 순간 쿼리를 실행해서 결과를 새 리스트로 만든다. 이후 원본 목록이 바뀌어도 이미 만들어진 리스트는 바뀌지 않는다.
단, ToList()를 습관처럼 모든 곳에 붙이면 메모리를 더 쓰고 불필요한 계산이 늘 수 있다. 결과를 여러 번 반복해서 사용할 때, 또는 원본이 바뀌기 전에 스냅샷이 필요할 때 사용하면 좋다.
LINQ를 남용하면 읽기 어려워질 수 있다
LINQ는 코드를 짧게 만들지만, 짧다고 항상 좋은 코드는 아니다.
다음 코드는 한 줄에 너무 많은 의미가 들어 있다.
var result = sales.Where(s => s.IsPaid && s.Quantity * s.UnitPrice >= 80000).OrderByDescending(s => s.Quantity * s.UnitPrice).Select(s => new { s.Product, Amount = s.Quantity * s.UnitPrice }).Take(3).ToList();동작은 하지만 읽는 사람이 멈춰서 해석해야 한다. 이런 경우에는 줄을 나누고, 조건에 이름을 붙이면 훨씬 낫다.
bool IsLargePaidSale(Sale sale)
{
return sale.IsPaid && sale.Quantity * sale.UnitPrice >= 80000;
}
var topSales = sales
.Where(IsLargePaidSale)
.OrderByDescending(sale => sale.Quantity * sale.UnitPrice)
.Select(sale => new
{
sale.Product,
Amount = sale.Quantity * sale.UnitPrice
})
.Take(3)
.ToList();LINQ 안에 복잡한 계산, 상태 변경, 출력문을 모두 넣는 것도 피하는 편이 좋다.
var query = sales.Where(sale =>
{
Console.WriteLine($"검사 중: {sale.Product}");
return sale.IsPaid;
});이 코드는 보기에는 바로 출력할 것 같지만, 지연 실행 때문에 실제 출력은 foreach가 실행될 때 일어난다. 같은 쿼리를 두 번 반복하면 출력도 두 번 일어난다. LINQ의 람다식은 가능하면 값을 검사하거나 변환하는 순수한 코드로 두고, 출력이나 저장 같은 작업은 별도의 foreach에서 처리하는 편이 안전하다.
또 하나의 흔한 성능 문제는 같은 쿼리를 여러 번 실행하는 것이다.
var paidSales = sales.Where(sale => sale.IsPaid);
Console.WriteLine(paidSales.Count());
foreach (Sale sale in paidSales)
{
Console.WriteLine(sale.Product);
}작은 리스트에서는 큰 문제가 아니다. 하지만 데이터베이스, 파일, 네트워크처럼 비용이 큰 데이터라면 Count()에서 한 번, foreach에서 또 한 번 실행될 수 있다.
결과를 여러 번 사용할 계획이라면 리스트로 만들어 두는 편이 명확하다.
var paidSales = sales
.Where(sale => sale.IsPaid)
.ToList();
Console.WriteLine(paidSales.Count);
foreach (Sale sale in paidSales)
{
Console.WriteLine(sale.Product);
}흔한 실수
Where와 Select를 헷갈리지 말자. Where는 개수를 줄이고, Select는 모양을 바꾼다.
// 결제 완료된 판매만 고름
var paidSales = sales.Where(sale => sale.IsPaid);
// 판매를 상품명 문자열로 바꿈
var productNames = sales.Select(sale => sale.Product);LINQ 결과가 항상 List<T>라고 생각하는 것도 실수다.
var paidSales = sales.Where(sale => sale.IsPaid);이 변수의 타입은 보통 IEnumerable<Sale>로 다루어진다. 리스트처럼 인덱스로 접근해야 하거나 결과를 고정해야 한다면 ToList()를 붙인다.
var paidSales = sales
.Where(sale => sale.IsPaid)
.ToList();마지막으로 LINQ 체인을 너무 길게 만들지 말자. 세 단계 정도는 자연스럽지만, 조건과 변환이 계속 이어져 읽기 어렵다면 중간 변수나 메서드로 나누는 것이 좋다.
작은 연습 문제
sales목록에서Category가"Device"인 판매만 출력해 보자.결제되지 않은 판매만 골라 상품명과 금액을 출력해 보자.
UnitPrice가 높은 순서대로 정렬해 상품명을 출력해 보자. 힌트:OrderByDescending을 사용한다.전체 판매 금액이 100,000원 이상인 판매만 골라 익명 타입으로
Product,Amount를 만들어 보자.GroupBy를 사용해 결제 여부별 판매 건수와 총액을 구해 보자.지연 실행을 확인해 보자. 쿼리를 만든 뒤 원본 리스트에 새 판매 기록을 추가하면 결과가 어떻게 달라지는지 출력해 보자.
LINQ는 반복문을 없애는 문법이 아니다. 반복문으로 하던 데이터 처리를 더 읽기 좋은 형태로 표현하는 도구다. 간단한 필터링, 정렬, 변환, 집계에는 LINQ가 잘 맞는다. 반대로 과정 자체가 중요하거나 상태 변경이 많다면 일반 반복문이 더 분명할 수 있다. 좋은 코드는 짧은 코드가 아니라 의도가 잘 보이는 코드다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.