14장. LINQ 기초¶
프로그래밍 언어의 역사에서 데이터 쿼리(Data Query)의 통합은 오랫동안 해결되지 않은 근본적인 문제였습니다. 1970년대 Edgar F. Codd의 관계형 모델(Relational Model) 제안과 이를 구현한 SQL(Structured Query Language)의 등장 이후, 데이터베이스 쿼리는 선언적(Declarative) 스타일의 강력함을 보여주었습니다. 그러나 일반 프로그래밍 언어에서 컬렉션을 다루는 방식은 여전히 명령형(Imperative) 패러다임에 머물러 있었습니다. 개발자들은 데이터베이스를 다룰 때는 SQL을, 메모리 상의 컬렉션을 다룰 때는 반복문을, XML을 다룰 때는 또 다른 API를 사용해야 했으며, 이는 코드의 일관성을 저해하고 학습 곡선을 가파르게 만들었습니다.
**LINQ(Language Integrated Query)**는 이러한 문제를 해결하기 위해 .NET 3.5와 C# 3.0에서 2007년에 도입된 혁명적인 기능입니다. Microsoft Research의 수석 연구원이자 함수형 프로그래밍의 대가인 Erik Meijer가 주도한 이 프로젝트는, SQL의 선언적 쿼리 스타일과 Haskell, ML 같은 함수형 언어의 강력함을 C#에 통합하여, 다양한 데이터 소스를 일관된 방식으로 다룰 수 있게 했습니다. LINQ는 단순히 편리한 문법적 설탕(Syntactic Sugar)을 넘어서, 프로그래밍 패러다임의 근본적인 변화를 가져왔습니다.
LINQ의 역사적 맥락과 진화:
LINQ의 개념은 하루아침에 탄생한 것이 아니라, 컴퓨터 과학의 여러 분야에서 축적된 지식의 결정체입니다:
-
1970년대 - 관계형 모델의 등장: Edgar F. Codd의 관계형 모델은 데이터를 수학적 집합(Set)으로 다루는 선언적 접근을 제시했습니다. SQL은 이를 실용화하여, "무엇을(What)" 가져올지만 명시하면 "어떻게(How)" 가져올지는 데이터베이스 엔진이 최적화하는 혁신을 가져왔습니다.
-
1980-90년대 - 함수형 프로그래밍의 성숙: Lisp, Scheme, ML, Haskell 등의 함수형 언어는 고차 함수(Higher-order Functions), 람다 계산법(Lambda Calculus), 타입 시스템의 이론적 기반을 확립했습니다. 특히 map, filter, reduce 같은 리스트 처리 함수들은 LINQ 연산자의 직접적인 영감이 되었습니다.
-
1990년대 후반 - ORM의 등장: Hibernate(Java), ActiveRecord(Ruby) 같은 객체-관계 매핑(Object-Relational Mapping) 도구들은 데이터베이스 레코드를 객체로 다루려는 시도를 보여주었지만, 타입 안정성과 컴파일 타임 검증의 부족이라는 한계가 있었습니다.
-
2000년대 초반 - 모나드와 표현식 트리: Haskell의 모나드(Monad) 개념과 Lisp의 매크로 시스템이 보여준 "코드를 데이터로 다루기(Code as Data)"는 LINQ의 표현식 트리(Expression Trees) 설계에 큰 영향을 미쳤습니다.
-
2007년 - LINQ의 탄생: 이러한 모든 개념들이 융합되어 LINQ가 탄생했습니다. Erik Meijer는 범주론(Category Theory)의 개념을 활용하여, 다양한 데이터 소스를 추상화하는 통합된 쿼리 모델을 설계했습니다.
LINQ 이전 시대의 문제점:
LINQ가 해결하려 했던 근본적인 문제들을 구체적으로 살펴보면:
// LINQ 이전: 데이터베이스 쿼리 (문자열 기반, 타입 불안정)
string sql = "SELECT * FROM Customers WHERE City = 'Seoul'";
// 문제점:
// 1. 컴파일 타임에 오류 검출 불가 (오타, 컬럼명 변경 등)
// 2. IntelliSense 지원 없음
// 3. SQL Injection 공격에 취약
// 4. 결과를 수동으로 객체에 매핑해야 함
// 메모리 컬렉션 처리 (명령형, 반복적)
List<Customer> seoulCustomers = new List<Customer>();
foreach (Customer c in allCustomers)
{
if (c.City == "Seoul")
{
seoulCustomers.Add(c);
}
}
// 문제점:
// 1. 보일러플레이트 코드 과다
// 2. 의도가 명확하지 않음 (구현 세부사항에 집중)
// 3. 실수하기 쉬움 (인덱스 오류, 누락 등)
// 4. 병렬 처리가 어려움
// XML 처리 (복잡한 API)
XmlDocument doc = new XmlDocument();
doc.Load("data.xml");
XmlNodeList nodes = doc.SelectNodes("//Customer[@City='Seoul']");
// 문제점:
// 1. DOM API의 복잡성
// 2. XPath 문자열의 타입 불안정성
// 3. 다른 데이터 소스와 일관성 없음
LINQ의 철학적 기반:
LINQ는 다음과 같은 핵심 설계 원칙을 기반으로 합니다:
-
통합성(Integration): 쿼리가 언어의 일급 구성 요소(First-class Construct)가 되어야 합니다. 이는 문자열로 쿼리를 작성하는 것이 아니라, 컴파일러가 이해하고 검증할 수 있는 코드로 작성함을 의미합니다. 이를 통해 리팩토링 도구, IntelliSense, 컴파일 타임 오류 검출 등 언어의 모든 인프라를 활용할 수 있습니다.
-
타입 안정성(Type Safety): 쿼리의 모든 부분이 강타입(Strongly-typed)이어야 합니다. 이는 런타임 오류를 컴파일 타임으로 이동시켜, 버그를 조기에 발견하고 수정 비용을 획기적으로 줄입니다. 이는 C#의 정적 타입 시스템(Static Type System)의 강점을 극대화합니다.
-
선언적 프로그래밍(Declarative Programming): "무엇을(What)" 할지만 명시하고, "어떻게(How)"는 라이브러리나 런타임에 위임합니다. 이는 관계형 데이터베이스의 쿼리 최적화기(Query Optimizer)처럼, LINQ 프로바이더가 최적의 실행 계획을 선택할 수 있게 합니다. 예를 들어, LINQ to SQL은 C# 쿼리를 최적화된 SQL로 변환하고, PLINQ는 자동으로 병렬화합니다.
-
지연 실행(Deferred Execution): 쿼리 정의와 실행을 분리합니다. 이는 Haskell의 게으른 평가(Lazy Evaluation)에서 영감을 받은 개념으로, 불필요한 계산을 피하고 쿼리 조합(Query Composition)을 가능하게 합니다. 쿼리를 변수에 저장하고, 다른 쿼리와 결합하고, 필요한 시점에 실행할 수 있습니다.
-
합성 가능성(Composability): 작은 쿼리들을 조합하여 복잡한 쿼리를 만들 수 있어야 합니다. 이는 함수형 프로그래밍의 함수 합성(Function Composition) 개념을 쿼리에 적용한 것입니다. LINQ 연산자는 모두
IEnumerable<T>를 입력으로 받고 출력으로 반환하여, 무한히 체이닝할 수 있습니다.
LINQ의 기술적 기반 - C# 3.0의 혁신:
LINQ는 C# 3.0에서 도입된 여러 혁신적인 언어 기능들의 시너지로 작동합니다. 이러한 기능들은 각각 독립적으로도 유용하지만, LINQ라는 하나의 목표를 위해 조화롭게 설계되었습니다:
-
람다 식(Lambda Expressions): Alonzo Church의 람다 계산법을 C#에 도입하여, 익명 함수를 간결하게 표현합니다.
x => x * 2와 같은 표현은 수학의 λx.x×2와 동일한 의미를 가지며, 코드를 데이터처럼 다룰 수 있게 합니다. -
확장 메서드(Extension Methods): 기존 타입(
IEnumerable<T>)을 수정하지 않고도 새 메서드를 추가할 수 있게 합니다. 이는 SOLID 원칙의 개방-폐쇄 원칙(Open-Closed Principle)을 실현하며, LINQ의 모든 연산자(Where,Select등)는 확장 메서드로 구현되어 있습니다. -
익명 타입(Anonymous Types): 일회성 데이터 구조를 즉석에서 정의할 수 있게 합니다.
new { Name = "John", Age = 30 }처럼 쿼리 결과를 원하는 형태로 투영(Projection)할 때 매우 유용하며, 보일러플레이트 클래스 정의를 제거합니다. -
표현식 트리(Expression Trees): 코드를 컴파일 가능한 데이터 구조로 표현합니다.
Expression<Func<int, bool>>는 람다 식을 AST(Abstract Syntax Tree)로 만들어, 런타임에 분석하고 다른 언어(예: SQL)로 변환할 수 있게 합니다. 이는 Lisp의 호모아이코닉(Homoiconic) 특성을 C#에 부분적으로 도입한 것입니다. -
타입 추론(Type Inference):
var키워드로 복잡한 타입 선언을 생략할 수 있게 합니다. LINQ 쿼리는 종종 복잡한 제네릭 타입을 반환하므로(IEnumerable<IGrouping<string, Customer>>등), 타입 추론은 코드 가독성을 크게 향상시킵니다. -
쿼리 구문(Query Syntax): SQL과 유사한 선언적 문법을 C#에 직접 통합합니다. 이는 단순한 문법적 설탕이 아니라, 컴파일러가 메서드 호출로 변환하는 **쿼리 컴프리헨션(Query Comprehension)**입니다. Haskell의 do-notation이나 Scala의 for-comprehension과 유사한 개념입니다.
LINQ의 아키텍처와 공급자 모델:
LINQ는 3계층 아키텍처로 설계되어 있습니다:
[쿼리 표현 계층]
↓ 컴파일러 변환
[표준 쿼리 연산자 계층] (IEnumerable<T> / IQueryable<T>)
↓ 공급자 구현
[실행 계층] (메모리, SQL, XML, 외부 서비스 등)
이러한 계층화된 설계는 **공급자 모델(Provider Model)**을 가능하게 합니다. 각 LINQ 공급자(Provider)는 표준 쿼리 연산자를 특정 데이터 소스에 맞게 구현합니다:
- LINQ to Objects:
IEnumerable<T>기반, 메모리 내 컬렉션을 직접 순회하며 처리 - LINQ to SQL / Entity Framework:
IQueryable<T>기반, 표현식 트리를 SQL로 변환하여 데이터베이스에 전송 - LINQ to XML:
XDocument,XElement등의 특화된 타입으로 XML 트리 탐색 - PLINQ: 자동으로 쿼리를 병렬화하여 멀티코어 CPU 활용
이러한 공급자들은 모두 동일한 LINQ 구문을 사용하지만, 내부적으로는 각 데이터 소스에 최적화된 방식으로 실행됩니다.
이 장에서 배울 내용¶
이 장을 통해 독자 여러분은 LINQ(Language Integrated Query)의 핵심 개념과 실전 활용법을 체계적으로 학습하게 됩니다. LINQ는 단순한 편의 기능이 아니라, 데이터 처리 패러다임의 근본적인 변화를 가져온 혁신적인 기술입니다. 이 장의 내용을 완전히 이해하면, 더 간결하고 표현력 있으며 유지보수하기 쉬운 코드를 작성할 수 있게 됩니다.
-
LINQ의 개념과 원리의 심층 이해: LINQ가 무엇이며 왜 프로그래밍 역사에서 중요한 이정표인지, 어떤 실무 문제를 해결하는지 이해합니다. 명령형 프로그래밍에서 선언적 프로그래밍으로의 패러다임 전환, 타입 안정성의 중요성, 그리고 지연 실행(Deferred Execution)의 의미와 장점을 배웁니다.
-
쿼리 구문과 메서드 구문의 완벽한 이해: LINQ를 표현하는 두 가지 방식인 쿼리 구문(Query Syntax)과 메서드 구문(Method Syntax)의 차이점과 각각의 장단점을 배웁니다. 컴파일러가 쿼리 구문을 메서드 호출로 변환하는 과정을 이해하고, 상황에 따라 적절한 구문을 선택하는 능력을 기릅니다.
-
기본 LINQ 연산자의 숙달: 데이터 처리의 핵심인 필터링(Where), 투영(Select), 정렬(OrderBy/OrderByDescending) 연산자들을 깊이 있게 학습합니다. 각 연산자의 내부 동작 원리, 시간 복잡도, 성능 특성을 이해하고, 실무에서 자주 마주치는 다양한 시나리오에 적용하는 방법을 익힙니다.
-
집계 연산자를 통한 데이터 요약: Count, Sum, Average, Min, Max, Aggregate 등 데이터를 단일 값으로 축약하는 연산자들을 학습합니다. 특히 범용 집계 연산자인 Aggregate의 활용법과, 함수형 프로그래밍의 reduce/fold 개념과의 연결을 이해합니다.
학습 목표: - 선언적 프로그래밍 스타일로 데이터 처리 로직을 표현하는 능력 배양 - 지연 실행과 즉시 실행의 차이를 이해하고 적절히 활용 - 복잡한 데이터 변환 파이프라인을 LINQ 메서드 체이닝으로 구축 - 코드의 가독성과 유지보수성을 향상시키는 LINQ 활용 패턴 습득 - 15장의 고급 LINQ 기능을 학습하기 위한 탄탄한 기초 확립
14.1 LINQ란?¶
LINQ(Language Integrated Query, 링크)는 C#과 .NET에 내장된 강력한 쿼리 기능으로, 다양한 데이터 소스에 대해 일관된 방식으로 쿼리를 작성할 수 있게 해줍니다. "Language Integrated"라는 이름이 시사하듯, LINQ는 단순히 라이브러리가 아니라 언어의 핵심 기능으로 통합되어 있으며, 컴파일러, 타입 시스템, IDE가 모두 LINQ를 완전히 지원합니다. 이는 SQL 스타일의 선언적 구문으로 컬렉션, 데이터베이스, XML 등을 쉽게 조회하고 변환할 수 있게 하며, 데이터 처리 코드의 패러다임을 근본적으로 변화시킵니다.
LINQ의 본질적 가치:
LINQ의 진정한 혁신은 단순히 편리한 문법을 제공하는 것을 넘어서, 다음과 같은 근본적인 가치를 실현한 데 있습니다:
-
타입 안정성(Type Safety)과 컴파일 타임 검증: SQL 문자열이나 XPath 표현식은 런타임에만 오류를 발견할 수 있지만, LINQ 쿼리는 컴파일 시점에 모든 타입 오류, 멤버 접근 오류, 메서드 시그니처 불일치 등을 검출합니다. 이는 "Fail Fast" 원칙을 실현하여, 버그 수정 비용을 극적으로 줄입니다. Martin Fowler가 제시한 "리팩터링(Refactoring)"도 타입 안정성이 있어야 안전하게 수행할 수 있습니다.
-
IntelliSense와 도구 지원: Visual Studio와 VS Code는 LINQ 쿼리 작성 시 실시간으로 사용 가능한 연산자, 속성, 메서드를 제안합니다. 이는 API 문서를 찾아볼 필요를 줄이고, 개발 속도를 크게 향상시킵니다. 특히 복잡한 도메인 객체를 다룰 때, IntelliSense는 객체의 구조를 탐색하는 강력한 도구가 됩니다.
-
코드 간결성과 표현력: LINQ는 복잡한 데이터 처리 로직을 매우 간결하게 표현할 수 있게 합니다. 20-30줄의 명령형 코드가 3-4줄의 LINQ 쿼리로 축약되는 경우가 흔하며, 더 중요한 것은 코드의 **의도(Intent)**가 명확해진다는 점입니다. "어떻게(How)"가 아닌 "무엇을(What)"에 집중하게 되어, 코드 리뷰와 유지보수가 훨씬 수월해집니다.
-
쿼리의 재사용성과 조합: LINQ 쿼리는 일급 객체(First-class Object)입니다. 변수에 저장하고, 매개변수로 전달하고, 다른 쿼리와 조합할 수 있습니다. 이는 **쿼리 조합(Query Composition)**을 가능하게 하여, 작은 재사용 가능한 쿼리들로 복잡한 로직을 구축할 수 있게 합니다. 이는 함수형 프로그래밍의 핵심 원칙인 합성 가능성(Composability)을 데이터 쿼리에 적용한 것입니다.
-
지연 실행을 통한 성능 최적화: 대부분의 LINQ 연산자는 지연 실행(Deferred Execution)됩니다. 쿼리를 정의하는 것만으로는 아무 일도 일어나지 않으며, 실제로 결과를 열거(Enumerate)할 때 실행됩니다. 이는 불필요한 계산을 피하고, 쿼리를 조합할 때 중간 결과를 만들지 않아 메모리를 절약합니다. 또한 데이터베이스 공급자의 경우, 여러 LINQ 연산을 하나의 최적화된 SQL 쿼리로 변환할 수 있습니다.
LINQ의 생태계 - 다양한 공급자:
LINQ의 공급자 모델(Provider Model)은 동일한 쿼리 구문으로 다양한 데이터 소스를 다룰 수 있게 합니다. 각 공급자는 표준 쿼리 연산자를 자신의 데이터 소스에 최적화된 방식으로 구현합니다:
-
LINQ to Objects: 메모리 상의 컬렉션(List, Array, IEnumerable 등)을 쿼리합니다. 이는 가장 기본적이고 자주 사용되는 LINQ 형태로,
IEnumerable<T>인터페이스를 구현한 모든 컬렉션에서 동작합니다. 이 장에서 주로 다루는 내용이며, 모든 LINQ의 기초가 됩니다. -
LINQ to SQL / Entity Framework: 관계형 데이터베이스를 쿼리합니다. 놀랍게도, LINQ 쿼리가 SQL로 변환되어 데이터베이스에 전송됩니다. 이는 표현식 트리(Expression Trees)를 분석하여 SQL 문을 생성하는 방식으로 작동하며, ORM(Object-Relational Mapping)의 강력한 기능을 제공합니다. 예를 들어,
customers.Where(c => c.City == "Seoul")은SELECT * FROM Customers WHERE City = 'Seoul'로 변환됩니다. -
LINQ to XML: XML 문서를 쿼리합니다. 전통적인 DOM API나 XPath에 비해 훨씬 직관적이고 타입 안전한 방식으로 XML을 탐색하고 조작할 수 있습니다.
XDocument,XElement등의 타입과 함께 사용하여, 계층적 XML 구조를 쉽게 다룹니다. -
Parallel LINQ (PLINQ): 표준 LINQ 쿼리를 자동으로 병렬화하여 멀티코어 CPU를 활용합니다. 단순히
.AsParallel()을 추가하는 것만으로 데이터 처리를 여러 스레드에 분산시킬 수 있으며, Task Parallel Library(TPL)를 기반으로 작동합니다. 대용량 데이터 처리 시 성능을 크게 향상시킬 수 있습니다. -
LINQ to Entities: Entity Framework Core에서 사용하는 공급자로, LINQ to SQL보다 더 발전된 형태입니다. 더 복잡한 쿼리 최적화, 변경 추적(Change Tracking), 지연 로딩(Lazy Loading) 등의 고급 기능을 제공합니다.
-
커스텀 LINQ 공급자:
IQueryable<T>인터페이스를 구현하여 자신만의 LINQ 공급자를 만들 수 있습니다. 실제로 MongoDB, ElasticSearch, Azure Cosmos DB 등 많은 NoSQL 데이터베이스들이 LINQ 공급자를 제공하여, 동일한 C# 구문으로 다양한 데이터 소스를 쿼리할 수 있게 합니다.
전통적인 방식 vs LINQ - 심층 비교:
LINQ 이전과 이후의 코드를 비교하면, 패러다임의 차이가 명확히 드러납니다:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// ============================================
// 전통적인 명령형 방식 (Imperative Style)
// ============================================
// "어떻게(How)" 하는지에 집중
// 단계별로 명시적으로 제어 흐름을 기술
List<int> evenNumbers = new List<int>();
foreach (int num in numbers)
{
if (num % 2 == 0)
{
evenNumbers.Add(num);
}
}
Console.WriteLine("짝수: " + string.Join(", ", evenNumbers));
// 출력: 짝수: 2, 4, 6, 8, 10
// 문제점 분석:
// 1. 보일러플레이트 코드: 빈 리스트 생성, foreach, if, Add 등 반복적 패턴
// 2. 가변 상태(Mutable State): evenNumbers 리스트가 점진적으로 변경됨
// 3. 구현 세부사항 노출: 반복문, 조건문 등 "어떻게" 구현되는지가 드러남
// 4. 오류 가능성: 잘못된 리스트에 추가, 조건 오류 등 실수 가능
// 5. 병렬화 어려움: 코드를 완전히 재작성해야 함
// ============================================
// LINQ 선언적 방식 (Declarative Style)
// ============================================
// "무엇을(What)" 원하는지에 집중
// 원하는 결과의 특성만 명시
var evenNumbersLinq = numbers.Where(n => n % 2 == 0);
Console.WriteLine("짝수: " + string.Join(", ", evenNumbersLinq));
// 출력: 짝수: 2, 4, 6, 8, 10
// 장점 분석:
// 1. 코드 간결성: 한 줄로 의도를 명확히 표현
// 2. 불변성(Immutability): 원본 컬렉션은 변경되지 않음
// 3. 선언적 표현: "짝수를 선택한다"는 의도가 코드에 직접 드러남
// 4. 타입 안정성: 컴파일러가 n의 타입과 % 연산의 유효성을 검증
// 5. 쉬운 병렬화: .AsParallel()만 추가하면 자동으로 병렬 처리
더 복잡한 예제로 차이 확인:
class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
}
List<Product> products = new List<Product>
{
new Product { Name = "노트북", Price = 1500000, Category = "전자제품" },
new Product { Name = "마우스", Price = 35000, Category = "전자제품" },
new Product { Name = "의자", Price = 250000, Category = "가구" },
new Product { Name = "책상", Price = 450000, Category = "가구" },
new Product { Name = "키보드", Price = 120000, Category = "전자제품" }
};
// ============================================
// 전통적인 방식: 50만원 미만 전자제품, 가격순 정렬
// ============================================
List<Product> affordableElectronics = new List<Product>();
foreach (Product p in products)
{
if (p.Category == "전자제품" && p.Price < 500000)
{
affordableElectronics.Add(p);
}
}
// 정렬을 위한 별도 단계
affordableElectronics.Sort((p1, p2) => p1.Price.CompareTo(p2.Price));
foreach (Product p in affordableElectronics)
{
Console.WriteLine($"{p.Name}: {p.Price:C0}");
}
// 출력:
// 마우스: ₩35,000
// 키보드: ₩120,000
// ============================================
// LINQ 방식: 체이닝을 통한 우아한 표현
// ============================================
var affordableElectronicsLinq = products
.Where(p => p.Category == "전자제품")
.Where(p => p.Price < 500000)
.OrderBy(p => p.Price);
foreach (Product p in affordableElectronicsLinq)
{
Console.WriteLine($"{p.Name}: {p.Price:C0}");
}
// 출력:
// 마우스: ₩35,000
// 키보드: ₩120,000
// 또는 더 간결하게:
var result = products
.Where(p => p.Category == "전자제품" && p.Price < 500000)
.OrderBy(p => p.Price)
.Select(p => $"{p.Name}: {p.Price:C0}");
Console.WriteLine(string.Join("\n", result));
지연 실행(Deferred Execution)의 심층 이해:
LINQ의 가장 독특하고 강력한 특성 중 하나는 지연 실행(Deferred Execution) 또는 **게으른 평가(Lazy Evaluation)**입니다. 이는 Haskell 같은 순수 함수형 언어에서 영감을 받은 개념으로, 쿼리를 정의하는 시점과 실제로 실행하는 시점을 분리합니다.
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
// 쿼리 정의 시점 - 아직 실행되지 않음!
// 이 시점에서는 "무엇을 할지"에 대한 레시피만 저장됨
var query = numbers.Where(n => n > 3);
Console.WriteLine("쿼리 정의 완료 (아직 실행되지 않음)");
// 원본 데이터 수정 - 쿼리는 이미 정의되었지만 실행되지 않았으므로
// 이후의 변경사항도 반영됨
numbers.Add(6);
numbers.Add(7);
// 실제 실행 시점 - foreach로 열거할 때 비로소 실행
Console.Write("결과: ");
foreach (int num in query)
{
Console.Write($"{num} "); // 여기서 Where 조건이 평가됨
}
// 출력: 결과: 4 5 6 7 (나중에 추가된 6, 7도 포함!)
// 즉시 실행(Immediate Execution)하려면:
// ToList(), ToArray(), Count(), Sum(), First() 등의 변환 연산자 사용
List<int> immediateResult = numbers.Where(n => n > 3).ToList();
// 이 시점에서 쿼리가 실행되고 결과가 List에 저장됨
지연 실행의 장점과 실무 활용:
-
성능 최적화: 필요한 데이터만 처리합니다. 예를 들어
Take(10)으로 10개만 가져온다면, 전체 컬렉션을 처리하지 않습니다. -
쿼리 조합(Query Composition): 여러 쿼리를 조합하여 복잡한 파이프라인을 만들 수 있으며, 최종적으로 한 번만 실행됩니다.
-
메모리 효율성: 중간 결과를 메모리에 저장하지 않고 스트리밍 방식으로 처리합니다.
var numbers = Enumerable.Range(1, 1000000); // 백만 개의 숫자
// 지연 실행: 10개만 처리 (매우 빠름)
var first10Even = numbers
.Where(n => n % 2 == 0) // 지연 실행
.Take(10); // 지연 실행
// 여기까지는 아무 일도 일어나지 않음!
// 실제 실행: 10개를 찾으면 중단
foreach (var n in first10Even)
{
Console.Write($"{n} "); // 2 4 6 8 10 12 14 16 18 20
}
// 백만 개를 모두 확인하지 않고 20개 정도만 확인하고 중단
14.2 쿼리 구문 vs 메서드 구문¶
LINQ는 동일한 쿼리를 작성하는 두 가지 문법을 제공합니다: 쿼리 구문(Query Syntax)**과 **메서드 구문(Method Syntax)**입니다. 이는 C# 언어 설계자들의 심사숙고한 결정으로, 서로 다른 배경을 가진 개발자들이 자신에게 익숙한 스타일로 LINQ를 사용할 수 있게 하기 위함입니다. 중요한 것은, 두 방식은 컴파일 시점에 동일한 IL(Intermediate Language) 코드로 변환되므로 **성능 차이는 전혀 없습니다. 이는 순수하게 가독성과 개발자 경험(Developer Experience)을 위한 선택입니다.
쿼리 컴프리헨션(Query Comprehension)의 이해:
쿼리 구문은 단순한 문법적 설탕(Syntactic Sugar)이 아니라, 컴파일러가 수행하는 쿼리 컴프리헨션(Query Comprehension) 변환입니다. 이는 Haskell의 do-notation, Python의 list comprehension, Scala의 for-comprehension과 유사한 개념으로, 모나드(Monad) 패턴의 특별한 표현입니다.
// 쿼리 구문 (개발자가 작성)
var query = from n in numbers
where n % 2 == 0
select n * 2;
// 컴파일러가 변환한 메서드 구문 (실제 IL 코드)
var query = numbers
.Where(n => n % 2 == 0)
.Select(n => n * 2);
// 두 코드는 완전히 동일한 IL로 컴파일됨
쿼리 구문(Query Syntax) - SQL 친화적 접근:
쿼리 구문은 SQL의 SELECT, FROM, WHERE 등의 키워드와 유사한 문법을 사용하여, 관계형 데이터베이스에 익숙한 개발자들에게 직관적입니다. 특히 복잡한 조인(join)이나 그룹화(groupby) 작업 시 가독성이 뛰어납니다.
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 쿼리 구문: SQL 스타일의 선언적 표현
var evenNumbers = from n in numbers
where n % 2 == 0
select n;
Console.WriteLine("짝수: " + string.Join(", ", evenNumbers));
// 출력: 짝수: 2, 4, 6, 8, 10
// 쿼리 구문의 장점: 복잡한 쿼리의 가독성
var query = from n in numbers
where n > 3
orderby n descending
select new { Number = n, Square = n * n };
// 이러한 다단계 작업을 하나의 표현으로 읽기 쉽게 작성
쿼리 구문이 지원하는 키워드:
LINQ 쿼리 구문은 제한된 키워드만 지원합니다. 이는 SQL의 핵심 작업에 집중하기 위한 설계입니다:
from: 데이터 소스 지정 (필수, 시작 키워드)where: 필터링 조건select: 결과 투영 (필수, 종료 키워드)orderby,descending: 정렬group by: 그룹화join,on,equals: 조인 연산into: 쿼리 연속 (query continuation)let: 중간 변수 정의
메서드 구문(Method Syntax) - 함수형 프로그래밍 접근:
메서드 구문은 확장 메서드와 람다 식을 사용하는 함수형 스타일로, Haskell, Scala, F# 같은 함수형 언어에 익숙한 개발자들에게 자연스럽습니다. 더 간결하고 유연하며, **모든 LINQ 연산자**를 사용할 수 있습니다.
// 메서드 구문: 함수형 스타일의 체이닝
var evenNumbers = numbers.Where(n => n % 2 == 0);
Console.WriteLine("짝수: " + string.Join(", ", evenNumbers));
// 출력: 짝수: 2, 4, 6, 8, 10
// 메서드 체이닝의 강력함: 파이프라인 구축
var result = numbers
.Where(n => n > 3)
.OrderByDescending(n => n)
.Select(n => new { Number = n, Square = n * n })
.Take(3); // Take는 쿼리 구문에서 직접 지원 안 됨!
// 각 단계가 명확히 분리되어 디버깅과 수정이 용이
두 구문의 심층 비교:
| 측면 | 쿼리 구문 | 메서드 구문 |
|---|---|---|
| 문법 스타일 | SQL-like, 선언적 | 함수형, 체이닝 |
| 가독성 | 복잡한 쿼리에서 우수 | 단순 변환에서 우수 |
| 표현력 | 일부 연산자만 지원 | 모든 LINQ 연산자 지원 |
| IntelliSense | 제한적 (컨텍스트에 따라) | 완벽 (각 메서드마다) |
| 디버깅 | 중단점 설정 어려움 | 각 메서드에 중단점 가능 |
| 타입 추론 | 자동 (컴파일러가 처리) | var 필요 |
| 학습 곡선 | SQL 경험자에게 쉬움 | 함수형 프로그래밍 이해 필요 |
| 권장 사용 | 조인, 그룹화, 복잡한 쿼리 | 단순 변환, 체이닝, 모든 연산자 |
컴파일러 변환 과정 이해:
// 원본 쿼리 구문
var query = from student in students
where student.Age >= 18
orderby student.Name
select student.Name;
// 컴파일러 변환 1단계: from과 where
var temp1 = students.Where(student => student.Age >= 18);
// 2단계: orderby
var temp2 = temp1.OrderBy(student => student.Name);
// 3단계: select
var query = temp2.Select(student => student.Name);
// 최종 체인 형태
var query = students
.Where(student => student.Age >= 18)
.OrderBy(student => student.Name)
.Select(student => student.Name);
메서드 구문의 구조:
// 메서드 구문 (위와 동일한 쿼리)
var query = fruits
.Where(fruit => fruit.Contains("과"))
.OrderBy(fruit => fruit);
Console.WriteLine("'과' 포함 과일: " + string.Join(", ", query));
// 출력: '과' 포함 과일: 사과, 포도
혼합 사용:
실무에서는 두 구문을 혼합하여 사용하는 경우가 많습니다. 쿼리 구문으로 시작하여 메서드 구문으로 추가 연산을 수행할 수 있습니다.
List<int> scores = new List<int> { 85, 92, 78, 95, 88, 73, 91 };
// 쿼리 구문 + 메서드 구문 혼합
var topScores = (from score in scores
where score >= 80
orderby score descending
select score)
.Take(3); // 메서드 구문 추가
Console.WriteLine("상위 3개 점수: " + string.Join(", ", topScores));
// 출력: 상위 3개 점수: 95, 92, 91
복잡한 예제 - 객체 컬렉션:
class Student
{
public string Name { get; set; }
public int Age { get; set; }
public double GPA { get; set; }
}
List<Student> students = new List<Student>
{
new Student { Name = "김철수", Age = 20, GPA = 3.5 },
new Student { Name = "이영희", Age = 22, GPA = 4.0 },
new Student { Name = "박민수", Age = 21, GPA = 3.8 },
new Student { Name = "최지혜", Age = 19, GPA = 3.9 }
};
// 쿼리 구문
var query1 = from s in students
where s.GPA >= 3.8
orderby s.GPA descending
select s.Name;
Console.WriteLine("우수 학생 (쿼리 구문): " + string.Join(", ", query1));
// 출력: 우수 학생 (쿼리 구문): 이영희, 최지혜, 박민수
// 메서드 구문
var query2 = students
.Where(s => s.GPA >= 3.8)
.OrderByDescending(s => s.GPA)
.Select(s => s.Name);
Console.WriteLine("우수 학생 (메서드 구문): " + string.Join(", ", query2));
// 출력: 우수 학생 (메서드 구문): 이영희, 최지혜, 박민수
선택 가이드:
- 쿼리 구문 사용 권장:
- 복잡한 조인(join) 연산
- 여러 데이터 소스를 다루는 경우
- SQL에 익숙한 팀원들과 협업
-
쿼리의 의도를 명확히 표현하고 싶을 때
-
메서드 구문 사용 권장:
- 단순한 필터링, 정렬, 투영
- 메서드 체이닝으로 가독성을 높일 수 있는 경우
Take,Skip,Distinct같이 쿼리 구문에 없는 연산자 사용- 람다 식에 익숙한 경우
14.3 기본 LINQ 연산자¶
LINQ의 진정한 강력함은 표준 쿼리 연산자(Standard Query Operators)라 불리는 풍부한 연산자 세트에서 나옵니다. 이들은 함수형 프로그래밍의 고전적 패턴인 map(변환), filter(필터링), **reduce(축약)**를 C#에 적용한 것으로, 1950-60년대 Lisp에서 시작된 리스트 처리 함수들의 현대적 구현입니다. 모든 LINQ 연산자는 IEnumerable<T> 또는 IQueryable<T> 인터페이스에 대한 **확장 메서드**로 구현되어 있으며, 이는 8장에서 학습한 확장 메서드의 가장 성공적인 실전 활용 사례입니다.
연산자의 분류:
LINQ 연산자는 기능에 따라 여러 범주로 분류됩니다:
- 필터링(Filtering): Where, OfType - 조건에 맞는 요소만 선택
- 투영(Projection): Select, SelectMany - 요소를 다른 형태로 변환
- 정렬(Ordering): OrderBy, OrderByDescending, ThenBy, Reverse - 요소 순서 변경
- 집합(Set): Distinct, Union, Intersect, Except - 집합 연산
- 분할(Partitioning): Take, Skip, TakeWhile, SkipWhile - 요소 일부 선택
- 조인(Join): Join, GroupJoin - 여러 시퀀스 결합
- 그룹화(Grouping): GroupBy - 키 기준 그룹 생성
- 집계(Aggregation): Count, Sum, Average, Min, Max, Aggregate - 단일 값으로 축약
- 변환(Conversion): ToList, ToArray, ToDictionary, ToLookup - 다른 컬렉션 타입으로 변환
이 섹션에서는 가장 기본적이면서도 가장 자주 사용되는 연산자들을 깊이 있게 학습합니다.
14.3.1 Where (필터링)¶
Where 연산자는 조건(Predicate)을 만족하는 요소만 선택하는 필터링 연산자입니다. 이는 함수형 프로그래밍의 filter 함수에 해당하며, SQL의 WHERE 절, Python의 filter(), JavaScript의 Array.filter(), Haskell의 filter와 동일한 역할을 합니다. LINQ 연산자 중 가장 많이 사용되며, 데이터 처리 파이프라인의 첫 번째 단계로 자주 등장합니다. 실제로 LINQ 쿼리의 약 70% 이상이 Where를 포함한다는 통계가 있을 정도로 핵심적입니다.
함수형 프로그래밍의 필터 패턴:
Where는 함수형 프로그래밍의 고전적인 필터(filter) 고차 함수의 구현입니다. 이 패턴은 1958년 John McCarthy가 Lisp에서 처음 구현한 이후, 거의 모든 함수형 언어와 현대 프로그래밍 언어에 채택되었습니다. 핵심 아이디어는 술어 함수(Predicate Function)를 매개변수로 받아, 그 함수가 true를 반환하는 요소만 새로운 컬렉션에 포함시키는 것입니다.
시그니처와 내부 동작:
// 기본 시그니처
public static IEnumerable<TSource> Where<TSource>(
this IEnumerable<TSource> source,
Func<TSource, bool> predicate)
// 인덱스 포함 오버로드
public static IEnumerable<TSource> Where<TSource>(
this IEnumerable<TSource> source,
Func<TSource, int, bool> predicate)
내부 구현 메커니즘:
Where는 **지연 실행(Deferred Execution)**을 사용하는 대표적인 연산자입니다. 호출 시점에는 실제로 필터링이 수행되지 않고, 이터레이터 블록(Iterator Block)을 반환합니다. 실제 필터링은 foreach 등으로 열거할 때 이루어지며, 각 요소는 술어 함수를 통과할 때만 다음 단계로 전달됩니다. 이는 **스트리밍 방식(Streaming)**으로 작동하여 메모리 효율적입니다.
// Where의 개념적 구현 (실제는 최적화되어 있음)
public static IEnumerable<T> Where<T>(
this IEnumerable<T> source,
Func<T, bool> predicate)
{
foreach (T element in source)
{
if (predicate(element))
{
yield return element; // 조건을 만족하는 요소만 반환
}
}
}
성능 특성과 최적화:
- 시간 복잡도: O(n) - 모든 요소를 한 번씩 검사해야 함
- 공간 복잡도: O(1) - 중간 컬렉션을 생성하지 않음 (지연 실행 덕분)
- 지연 실행: 쿼리를 여러 번 열거하면 매번 재평가됨
- 단락 평가(Short-circuit):
Take와 결합 시 필요한 개수만 평가하고 중단
// 성능 최적화 예제
var numbers = Enumerable.Range(1, 1000000);
// 비효율적: 모든 요소를 필터링한 후 10개만 선택
var bad = numbers.Where(n => n % 2 == 0).ToList().Take(10);
// 효율적: 10개를 찾으면 즉시 중단
var good = numbers.Where(n => n % 2 == 0).Take(10);
기본 예제:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 짝수만 선택
var evenNumbers = numbers.Where(n => n % 2 == 0);
Console.WriteLine("짝수: " + string.Join(", ", evenNumbers));
// 출력: 짝수: 2, 4, 6, 8, 10
// 5보다 큰 수
var greaterThanFive = numbers.Where(n => n > 5);
Console.WriteLine("5보다 큰 수: " + string.Join(", ", greaterThanFive));
// 출력: 5보다 큰 수: 6, 7, 8, 9, 10
// 3의 배수
var multiplesOfThree = numbers.Where(n => n % 3 == 0);
Console.WriteLine("3의 배수: " + string.Join(", ", multiplesOfThree));
// 출력: 3의 배수: 3, 6, 9
인덱스를 활용한 필터링:
Where의 두 번째 오버로드는 요소의 인덱스를 함께 전달합니다. 이는 위치 기반 필터링이 필요할 때 유용하며, 특히 UI 테이블에서 짝수/홀수 행에 다른 스타일을 적용하거나, 배치 처리에서 특정 위치의 요소만 선택할 때 활용됩니다.
string[] words = { "사과", "바나나", "오렌지", "포도", "딸기" };
// 인덱스가 짝수인 위치의 요소만 선택
var evenIndexWords = words.Where((word, index) => index % 2 == 0);
Console.WriteLine("짝수 인덱스: " + string.Join(", ", evenIndexWords));
// 출력: 짝수 인덱스: 사과, 오렌지, 딸기
// 처음 3개를 제외한 나머지
var skipFirst3 = words.Where((word, index) => index >= 3);
Console.WriteLine("처음 3개 제외: " + string.Join(", ", skipFirst3));
// 출력: 처음 3개 제외: 포도, 딸기
복잡한 조건과 논리 연산자:
실무에서는 여러 조건을 결합하여 복잡한 필터링 로직을 구현합니다. C#의 논리 연산자(&&, ||, !)를 활용하여 명확하게 표현할 수 있습니다.
class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public string Category { get; set; }
}
List<Product> products = new List<Product>
{
new Product { Name = "노트북", Price = 1500000, Stock = 5, Category = "전자제품" },
new Product { Name = "마우스", Price = 35000, Stock = 0, Category = "전자제품" },
new Product { Name = "키보드", Price = 120000, Stock = 3, Category = "전자제품" },
new Product { Name = "책상", Price = 300000, Stock = 2, Category = "가구" }
};
// 여러 조건 결합 (AND)
var affordableInStock = products.Where(p => p.Price < 500000 && p.Stock > 0);
foreach (var product in affordableInStock)
{
Console.WriteLine($"{product.Name}: {product.Price:C0} (재고: {product.Stock})");
}
// 출력:
// 키보드: ₩120,000 (재고: 3)
// 책상: ₩300000 (재고: 2)
// 복합 조건 (OR와 AND)
var highEndOrLowStock = products.Where(
p => (p.Price > 1000000 && p.Category == "전자제품") || p.Stock < 3
);
// 부정 조건 (NOT)
var notFurniture = products.Where(p => p.Category != "가구");
메서드 추출을 통한 재사용과 가독성:
복잡한 술어 로직은 별도 메서드로 추출하면 재사용성과 가독성이 향상됩니다. 이는 **전략 패턴(Strategy Pattern)**의 구현이기도 합니다.
List<int> scores = new List<int> { 85, 92, 78, 95, 88, 73, 91, 67 };
// 술어(Predicate) 메서드로 분리
bool IsHighScore(int score) => score >= 90;
bool IsPassingScore(int score) => score >= 60;
bool IsFailingScore(int score) => score < 60;
var highScores = scores.Where(IsHighScore);
var passingScores = scores.Where(IsPassingScore);
var failingScores = scores.Where(IsFailingScore);
Console.WriteLine("고득점: " + string.Join(", ", highScores));
// 출력: 고득점: 92, 95, 91
Console.WriteLine("합격 점수: " + string.Join(", ", passingScores));
// 출력: 합격 점수: 85, 92, 78, 95, 88, 73, 91, 67
Console.WriteLine("불합격: " + string.Join(", ", failingScores));
// 출력: 불합격: (없음)
Where 체이닝과 가독성:
여러 Where 절을 체이닝하는 것은 하나의 복잡한 조건을 작성하는 것과 논리적으로 동일하지만, 가독성과 유지보수성이 향상됩니다.
// 하나의 복잡한 Where
var result1 = products
.Where(p => p.Category == "전자제품" && p.Price < 500000 && p.Stock > 0);
// 여러 Where 체이닝 (더 읽기 쉬움)
var result2 = products
.Where(p => p.Category == "전자제품")
.Where(p => p.Price < 500000)
.Where(p => p.Stock > 0);
// 두 결과는 동일하지만, result2가 각 필터링 단계를 명확히 보여줌
실무 패턴: 동적 필터 구축:
사용자 입력에 따라 필터를 동적으로 적용해야 하는 경우가 많습니다.
// 동적 필터링 예제
IEnumerable<Product> FilterProducts(
IEnumerable<Product> products,
string category = null,
decimal? maxPrice = null,
bool? inStockOnly = null)
{
var query = products.AsEnumerable();
if (category != null)
query = query.Where(p => p.Category == category);
if (maxPrice.HasValue)
query = query.Where(p => p.Price <= maxPrice.Value);
if (inStockOnly == true)
query = query.Where(p => p.Stock > 0);
return query;
}
// 사용
var filtered = FilterProducts(
products,
category: "전자제품",
maxPrice: 500000,
inStockOnly: true
);
주의사항과 모범 사례:
-
술어의 순수성: 술어 함수는 부작용(Side Effects)이 없어야 합니다. 외부 상태를 변경하거나 의존하면 예측 불가능한 결과가 발생할 수 있습니다.
-
null 안정성: 술어 내에서 null 검사를 적절히 수행하세요.
// 나쁜 예
var result = people.Where(p => p.Name.Length > 3); // Name이 null이면 예외!
// 좋은 예
var result = people.Where(p => p.Name != null && p.Name.Length > 3);
// 또는 C# 8.0+
var result = people.Where(p => p.Name?.Length > 3);
- 지연 실행 이해:
Where는 즉시 실행되지 않으므로, 원본 컬렉션이 변경되면 결과도 영향을 받습니다.
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var query = numbers.Where(n => n > 3); // 아직 실행 안 됨
numbers.Add(6); // 원본 수정
// 이제 실행 - 6도 포함됨!
Console.WriteLine(string.Join(", ", query)); // 출력: 4, 5, 6
14.3.2 Select (투영)¶
Select 연산자는 각 요소를 변환(Transform)하는 투영(Projection) 연산자입니다. 이는 함수형 프로그래밍의 map 함수에 해당하며, SQL의 SELECT 절, Haskell의 map, JavaScript의 Array.map(), Python의 map()과 동일한 개념입니다. "투영(Projection)"이라는 용어는 고차원 데이터를 저차원으로 변환하는 수학적 개념에서 유래했으며, 데이터베이스 이론에서는 관계(Relation)의 특정 열(Column)만 선택하는 것을 의미합니다.
함수형 프로그래밍의 맵 패턴:
Select는 함수형 프로그래밍의 핵심 패턴인 맵(map) 고차 함수를 구현합니다. 이 패턴의 본질은 **구조를 보존하면서 내용만 변환**하는 것입니다. 리스트의 각 요소에 함수를 적용하여 같은 길이의 새로운 리스트를 생성하되, 리스트의 구조(순서, 개수)는 유지됩니다. 이는 범주론(Category Theory)의 펑터(Functor) 개념과 연결되며, 수학적으로 엄밀한 기반을 가집니다.
시그니처와 제네릭 타입 변환:
// 기본 시그니처
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
// 인덱스 포함 오버로드
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, int, TResult> selector)
내부 구현과 지연 실행:
Select도 Where와 마찬가지로 지연 실행되며, iterator 패턴을 사용합니다. 중요한 점은 Select가 요소를 변환하지만 원본은 절대 변경하지 않는다는 것입니다(불변성, Immutability). 이는 함수형 프로그래밍의 핵심 원칙입니다.
// Select의 개념적 구현
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
foreach (TSource element in source)
{
yield return selector(element); // 각 요소를 변환하여 반환
}
}
성능 특성:
- 시간 복잡도: O(n) - 모든 요소에 변환 함수 적용
- 공간 복잡도: O(1) - 지연 실행으로 중간 컬렉션 미생성
- 타입 변환:
TSource에서TResult로 완전히 다른 타입으로 변환 가능 - 구조 보존: 입력과 출력의 요소 개수가 동일 (1:1 매핑)
기본 변환 예제:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
// 각 숫자를 제곱 (int → int)
var squares = numbers.Select(n => n * n);
Console.WriteLine("제곱: " + string.Join(", ", squares));
// 출력: 제곱: 1, 4, 9, 16, 25
// 타입 변환 (int → string)
var strings = numbers.Select(n => n.ToString());
Console.WriteLine("문자열: " + string.Join(", ", strings));
// 출력: 문자열: 1, 2, 3, 4, 5
// 문자열 보간 (int → string)
var withUnit = numbers.Select(n => $"{n}개");
Console.WriteLine("단위 추가: " + string.Join(", ", withUnit));
// 출력: 단위 추가: 1개, 2개, 3개, 4개, 5개
// 복잡한 계산 (int → double)
var fahrenheit = numbers.Select(celsius => celsius * 9.0 / 5.0 + 32);
Console.WriteLine("화씨: " + string.Join(", ", fahrenheit.Select(f => $"{f:F1}°F")));
// 출력: 화씨: 33.8°F, 35.6°F, 37.4°F, 39.2°F, 41.0°F
객체 속성 투영 - 데이터 쉐이핑:
실무에서 가장 흔한 패턴은 복잡한 객체에서 필요한 속성만 추출하는 것입니다. 이를 데이터 쉐이핑(Data Shaping) 또는 **DTO(Data Transfer Object) 패턴**이라고 합니다.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string City { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
}
List<Person> people = new List<Person>
{
new Person { Name = "김철수", Age = 25, City = "서울", Email = "kim@example.com", BirthDate = new DateTime(1999, 3, 15) },
new Person { Name = "이영희", Age = 30, City = "부산", Email = "lee@example.com", BirthDate = new DateTime(1994, 7, 22) },
new Person { Name = "박민수", Age = 28, City = "대구", Email = "park@example.com", BirthDate = new DateTime(1996, 11, 5) }
};
// 단일 속성 추출
var names = people.Select(p => p.Name);
Console.WriteLine("이름: " + string.Join(", ", names));
// 출력: 이름: 김철수, 이영희, 박민수
// 계산된 속성
var ages = people.Select(p => p.Age);
Console.WriteLine("나이: " + string.Join(", ", ages));
// 출력: 나이: 25, 30, 28
익명 타입을 활용한 복합 투영:
C# 3.0에서 도입된 익명 타입은 Select와 완벽한 조합을 이룹니다. 일회성 데이터 구조를 즉석에서 정의하여 필요한 정보만 담을 수 있습니다.
// 여러 속성을 선택하여 새 익명 객체 생성
var summary = people.Select(p => new
{
FullName = p.Name,
Info = $"{p.Age}세, {p.City}",
ContactMasked = p.Email.Substring(0, 3) + "***" // 개인정보 마스킹
});
foreach (var item in summary)
{
Console.WriteLine($"{item.FullName}: {item.Info} ({item.ContactMasked})");
}
// 출력:
// 김철수: 25세, 서울 (kim***)
// 이영희: 30세, 부산 (lee***)
// 박민수: 28세, 대구 (par***)
인덱스를 활용한 변환:
두 번째 오버로드는 요소의 인덱스를 제공하여, 위치 정보를 변환에 포함시킬 수 있습니다.
string[] fruits = { "사과", "바나나", "오렌지" };
// 인덱스와 함께 변환
var indexed = fruits.Select((fruit, index) => $"{index + 1}. {fruit}");
Console.WriteLine(string.Join("\n", indexed));
// 출력:
// 1. 사과
// 2. 바나나
// 3. 오렌지
// 인덱스 기반 조건부 변환
var withPosition = fruits.Select((fruit, index) => new
{
Position = index,
Name = fruit,
Type = index == 0 ? "첫번째" : index == fruits.Length - 1 ? "마지막" : "중간"
});
Where와 Select의 조합 - 파이프라인:
실무에서 가장 흔한 패턴은 필터링 후 변환하는 것입니다. 이는 데이터 처리 파이프라인의 기본 패턴입니다.
List<int> scores = new List<int> { 85, 92, 78, 95, 88, 73, 91, 67 };
// 90점 이상인 점수를 등급으로 변환
var highGrades = scores
.Where(s => s >= 90) // 1단계: 필터링
.Select(s => $"{s}점 (A등급)"); // 2단계: 변환
Console.WriteLine(string.Join("\n", highGrades));
// 출력:
// 92점 (A등급)
// 95점 (A등급)
// 91점 (A등급)
// 복잡한 파이프라인
var processedScores = scores
.Where(s => s >= 60) // 합격 점수만
.OrderByDescending(s => s) // 높은 순으로 정렬
.Select((s, index) => new { Rank = index + 1, Score = s }) // 순위 부여
.Take(5); // 상위 5개
복잡한 비즈니스 로직 변환:
class Order
{
public int OrderId { get; set; }
public string Product { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public DateTime OrderDate { get; set; }
}
List<Order> orders = new List<Order>
{
new Order { OrderId = 1, Product = "노트북", Quantity = 2, UnitPrice = 1500000, OrderDate = DateTime.Now.AddDays(-5) },
new Order { OrderId = 2, Product = "마우스", Quantity = 5, UnitPrice = 35000, OrderDate = DateTime.Now.AddDays(-2) },
new Order { OrderId = 3, Product = "키보드", Quantity = 3, UnitPrice = 120000, OrderDate = DateTime.Now.AddDays(-1) }
};
// 복잡한 비즈니스 로직을 담은 투영
var orderSummary = orders.Select(o => new
{
o.OrderId,
o.Product,
TotalPrice = o.Quantity * o.UnitPrice,
Tax = o.Quantity * o.UnitPrice * 0.1m, // 10% 세금
FinalPrice = o.Quantity * o.UnitPrice * 1.1m,
DaysAgo = (DateTime.Now - o.OrderDate).Days,
Status = (DateTime.Now - o.OrderDate).Days < 3 ? "최근" : "이전"
});
foreach (var order in orderSummary)
{
Console.WriteLine($"주문 #{order.OrderId}: {order.Product}");
Console.WriteLine($" 금액: {order.TotalPrice:C0} + 세금 {order.Tax:C0} = {order.FinalPrice:C0}");
Console.WriteLine($" 주문: {order.DaysAgo}일 전 ({order.Status})");
}
SelectMany - 평탄화(Flattening):
Select의 고급 변형인 SelectMany는 중첩된 컬렉션을 평탄화합니다. 각 요소가 컬렉션을 반환할 때 유용합니다.
class Team
{
public string Name { get; set; }
public List<string> Members { get; set; }
}
var teams = new List<Team>
{
new Team { Name = "개발팀", Members = new List<string> { "김철수", "이영희" } },
new Team { Name = "디자인팀", Members = new List<string> { "박민수", "최지혜", "정다은" } }
};
// Select: 팀 목록 → 멤버 리스트의 리스트
var nestedMembers = teams.Select(t => t.Members);
// 결과: List<List<string>> - 이중 구조
// SelectMany: 평탄화하여 모든 멤버를 하나의 시퀀스로
var allMembers = teams.SelectMany(t => t.Members);
Console.WriteLine("전체 멤버: " + string.Join(", ", allMembers));
// 출력: 전체 멤버: 김철수, 이영희, 박민수, 최지혜, 정다은
실무 패턴 - DTO 변환:
API 응답이나 뷰 모델 생성 시 자주 사용되는 패턴입니다.
class User
{
public int Id { get; set; }
public string Username { get; set; }
public string PasswordHash { get; set; } // 민감 정보
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}
class UserDto
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string MemberSince { get; set; }
}
// 엔티티 → DTO 변환
List<User> users = GetUsersFromDatabase();
var userDtos = users.Select(u => new UserDto
{
Id = u.Id,
Username = u.Username,
Email = u.Email,
MemberSince = u.CreatedAt.ToString("yyyy-MM-dd")
// PasswordHash는 의도적으로 제외 (보안)
});
주의사항과 모범 사례:
- 순수 함수 사용: 선택자 함수는 부작용이 없어야 합니다.
// 나쁜 예 - 외부 상태 변경
int counter = 0;
var bad = numbers.Select(n =>
{
counter++; // 부작용! 피해야 함
return n * 2;
});
// 좋은 예 - 순수 함수
var good = numbers.Select(n => n * 2);
- null 안정성: 변환 중 null 참조를 주의하세요.
- 성능 고려: 복잡한 계산은 캐싱을 고려하세요.
// 비효율적: 매번 재계산
var result1 = data.Select(x => ExpensiveCalculation(x));
foreach (var item in result1) { } // 첫 번째 열거
foreach (var item in result1) { } // 다시 계산!
// 효율적: 결과 캐싱
var result2 = data.Select(x => ExpensiveCalculation(x)).ToList();
foreach (var item in result2) { } // 캐시된 결과 사용
foreach (var item in result2) { } // 재계산 없음
14.3.3 OrderBy / OrderByDescending (정렬)¶
OrderBy와 OrderByDescending 연산자는 요소를 정렬하는 연산자입니다. SQL의 ORDER BY 절과 동일한 역할을 하며, 정렬은 데이터 분석과 사용자 인터페이스에서 필수적인 연산입니다. LINQ의 정렬 연산자는 안정 정렬(Stable Sort) 알고리즘을 사용하여, 같은 키를 가진 요소들의 상대적 순서를 보존합니다. 이는 다중 기준 정렬 시 특히 중요합니다.
정렬 알고리즘의 역사와 LINQ의 선택:
정렬은 컴퓨터 과학에서 가장 오래되고 잘 연구된 문제 중 하나입니다. LINQ는 내부적으로 IntroSort 알고리즘을 사용합니다. IntroSort는 David Musser가 1997년에 제안한 하이브리드 알고리즘으로, 빠른 정렬(QuickSort)로 시작하되 재귀 깊이가 깊어지면 힙 정렬(HeapSort)로 전환하여 O(n log n)의 최악 시간 복잡도를 보장합니다. 또한 작은 부분 배열에서는 삽입 정렬(InsertionSort)을 사용하여 실전 성능을 최적화합니다.
시그니처와 반환 타입:
// 오름차순 정렬
public static IOrderedEnumerable<TSource> OrderBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector)
// 내림차순 정렬
public static IOrderedEnumerable<TSource> OrderByDescending<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector)
// 사용자 정의 비교자 사용
public static IOrderedEnumerable<TSource> OrderBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IComparer<TKey> comparer)
IOrderedEnumerable
반환 타입이 IEnumerable<T>가 아닌 IOrderedEnumerable<T>인 점에 주목하세요. 이는 후속 정렬(ThenBy, ThenByDescending)을 지원하기 위한 특별한 인터페이스입니다. 이를 통해 다중 키 정렬이 가능합니다.
성능 특성과 비용:
- 시간 복잡도: 평균 O(n log n), 최악 O(n log n) (IntroSort)
- 공간 복잡도: O(n) - 전체 컬렉션을 메모리에 로드해야 함
- 즉시 실행:
OrderBy는 지연 실행이지만, 내부적으로 전체 컬렉션을 버퍼링합니다 - 안정성: 동일한 키를 가진 요소의 원래 순서 유지
// 정렬은 전체 컬렉션을 필요로 하므로 스트리밍 불가능
var numbers = Enumerable.Range(1, 1000000);
var sorted = numbers.OrderBy(n => n); // 여기서는 아직 실행 안 됨
var first = sorted.First(); // 이 시점에 전체 정렬 수행!
기본 정렬 예제:
List<int> numbers = new List<int> { 5, 2, 8, 1, 9, 3, 7, 4, 6 };
// 오름차순 정렬 (Ascending)
var ascending = numbers.OrderBy(n => n);
Console.WriteLine("오름차순: " + string.Join(", ", ascending));
// 출력: 오름차순: 1, 2, 3, 4, 5, 6, 7, 8, 9
// 내림차순 정렬 (Descending)
var descending = numbers.OrderByDescending(n => n);
Console.WriteLine("내림차순: " + string.Join(", ", descending));
// 출력: 내림차순: 9, 8, 7, 6, 5, 4, 3, 2, 1
// 람다 없이 정렬 (identity function)
var simple = numbers.OrderBy(n => n); // n => n은 각 요소를 그대로 키로 사용
문자열 정렬과 문화권:
문자열 정렬은 문화권(Culture)에 따라 다를 수 있습니다. 한글, 영어, 숫자가 섞인 경우 기본 정렬 동작을 이해해야 합니다.
string[] names = { "이영희", "김철수", "박민수", "최지혜", "Alice", "Bob" };
// 기본 문화권 정렬 (현재 시스템 문화권 사용)
var sorted = names.OrderBy(name => name);
Console.WriteLine("기본 정렬: " + string.Join(", ", sorted));
// 출력: 기본 정렬: Alice, Bob, 김철수, 박민수, 이영희, 최지혜
// 길이 기준 정렬
var byLength = names.OrderBy(name => name.Length);
Console.WriteLine("길이순: " + string.Join(", ", byLength));
// 출력: 길이순: Bob, Alice, 김철수, 이영희, 박민수, 최지혜
// 대소문자 무시 정렬
var caseInsensitive = names.OrderBy(name => name, StringComparer.OrdinalIgnoreCase);
객체 속성으로 정렬:
실무에서 가장 흔한 패턴은 객체의 특정 속성을 기준으로 정렬하는 것입니다.
class Student
{
public string Name { get; set; }
public int Score { get; set; }
public DateTime EnrollDate { get; set; }
}
List<Student> students = new List<Student>
{
new Student { Name = "김철수", Score = 85, EnrollDate = new DateTime(2022, 3, 1) },
new Student { Name = "이영희", Score = 92, EnrollDate = new DateTime(2021, 9, 1) },
new Student { Name = "박민수", Score = 78, EnrollDate = new DateTime(2022, 3, 1) },
new Student { Name = "최지혜", Score = 95, EnrollDate = new DateTime(2021, 9, 1) }
};
// 점수 기준 내림차순
var byScore = students.OrderByDescending(s => s.Score);
Console.WriteLine("점수순:");
foreach (var student in byScore)
{
Console.WriteLine($"{student.Name}: {student.Score}점");
}
// 출력:
// 최지혜: 95점
// 이영희: 92점
// 김철수: 85점
// 박민수: 78점
// 이름 기준 오름차순
var byName = students.OrderBy(s => s.Name);
Console.WriteLine("\n이름순:");
foreach (var student in byName)
{
Console.WriteLine($"{student.Name}: {student.Score}점");
}
// 출력:
// 김철수: 85점
// 박민수: 78점
// 이영희: 92점
// 최지혜: 95점
다중 키 정렬 - ThenBy / ThenByDescending:
복잡한 정렬 규칙을 구현할 때는 ThenBy와 ThenByDescending을 사용합니다. 이들은 IOrderedEnumerable<T>에만 적용 가능합니다.
class Employee
{
public string Department { get; set; }
public string Name { get; set; }
public int Salary { get; set; }
public int YearsOfService { get; set; }
}
List<Employee> employees = new List<Employee>
{
new Employee { Department = "개발", Name = "김철수", Salary = 5000, YearsOfService = 3 },
new Employee { Department = "개발", Name = "이영희", Salary = 6000, YearsOfService = 5 },
new Employee { Department = "영업", Name = "박민수", Salary = 4500, YearsOfService = 2 },
new Employee { Department = "영업", Name = "최지혜", Salary = 5500, YearsOfService = 4 },
new Employee { Department = "개발", Name = "정다은", Salary = 5000, YearsOfService = 2 }
};
// 복잡한 다중 정렬: 부서 → 연봉 (내림차순) → 근속연수 (내림차순) → 이름
var sorted = employees
.OrderBy(e => e.Department) // 1차: 부서 오름차순
.ThenByDescending(e => e.Salary) // 2차: 연봉 내림차순
.ThenByDescending(e => e.YearsOfService) // 3차: 근속연수 내림차순
.ThenBy(e => e.Name); // 4차: 이름 오름차순
Console.WriteLine("부서별 > 연봉순 > 근속연수순 > 이름순:");
foreach (var emp in sorted)
{
Console.WriteLine($"{emp.Department} - {emp.Name}: ₩{emp.Salary:N0} ({emp.YearsOfService}년)");
}
// 출력:
// 개발 - 이영희: ₩6,000 (5년)
// 개발 - 김철수: ₩5,000 (3년)
// 개발 - 정다은: ₩5,000 (2년)
// 영업 - 최지혜: ₩5,500 (4년)
// 영업 - 박민수: ₩4,500 (2년)
안정 정렬(Stable Sort)의 중요성:
LINQ의 안정 정렬은 동일한 키를 가진 요소들의 원래 순서를 보존합니다. 이는 다중 정렬에서 특히 중요합니다.
var data = new[]
{
new { Name = "Alice", Score = 85, Id = 1 },
new { Name = "Bob", Score = 90, Id = 2 },
new { Name = "Charlie", Score = 85, Id = 3 },
new { Name = "David", Score = 90, Id = 4 }
};
// Score로 정렬하되, 같은 점수 내에서는 원래 순서(Id) 유지
var sorted = data.OrderBy(x => x.Score);
foreach (var item in sorted)
{
Console.WriteLine($"{item.Name}: {item.Score} (ID: {item.Id})");
}
// 출력:
// Alice: 85 (ID: 1) ← Score 85 중에서
// Charlie: 85 (ID: 3) ← 원래 순서 유지 (1 → 3)
// Bob: 90 (ID: 2) ← Score 90 중에서
// David: 90 (ID: 4) ← 원래 순서 유지 (2 → 4)
사용자 정의 비교자 (Custom Comparer):
특수한 정렬 규칙이 필요한 경우 IComparer<T>를 구현하여 사용할 수 있습니다.
// 길이 기준 비교자
class StringLengthComparer : IComparer<string>
{
public int Compare(string x, string y)
{
if (x == null && y == null) return 0;
if (x == null) return -1;
if (y == null) return 1;
int lengthCompare = x.Length.CompareTo(y.Length);
if (lengthCompare != 0) return lengthCompare;
// 길이가 같으면 사전순
return string.Compare(x, y, StringComparison.Ordinal);
}
}
string[] words = { "apple", "pie", "banana", "cat", "dog" };
var sorted = words.OrderBy(w => w, new StringLengthComparer());
Console.WriteLine(string.Join(", ", sorted));
// 출력: cat, dog, pie, apple, banana
Reverse - 순서 역전:
이미 정렬된 시퀀스의 순서를 뒤집을 때는 Reverse()를 사용합니다.
var numbers = new[] { 1, 2, 3, 4, 5 };
var reversed = numbers.Reverse();
Console.WriteLine(string.Join(", ", reversed));
// 출력: 5, 4, 3, 2, 1
// OrderByDescending과 Reverse는 다름
var desc = numbers.OrderByDescending(n => n); // 정렬 알고리즘 사용
var rev = numbers.OrderBy(n => n).Reverse(); // 정렬 후 역순 (비효율적)
성능 최적화 팁:
- 불필요한 정렬 피하기: 정렬은 비용이 큽니다.
// 나쁜 예: 첫 5개만 필요한데 전체 정렬
var bad = data.OrderBy(x => x.Score).Take(5);
// 개선: 부분 정렬 (사용자 정의 로직 필요)
// 또는 Min/Max 연산자 사용
- ToList() 활용: 같은 정렬 결과를 여러 번 사용하면 캐싱하세요.
var sortedQuery = data.OrderBy(x => x.Value); // 지연 실행
foreach (var item in sortedQuery) { } // 정렬 수행
foreach (var item in sortedQuery) { } // 다시 정렬! (비효율)
var sortedList = data.OrderBy(x => x.Value).ToList(); // 즉시 실행 및 캐싱
foreach (var item in sortedList) { } // 캐시 사용
foreach (var item in sortedList) { } // 캐시 사용 (빠름)
실무 패턴 - 동적 정렬:
사용자 입력에 따라 정렬 기준을 동적으로 변경해야 하는 경우가 많습니다.
IEnumerable<Student> SortStudents(IEnumerable<Student> students, string sortBy, bool descending)
{
IOrderedEnumerable<Student> query = sortBy switch
{
"Name" => descending
? students.OrderByDescending(s => s.Name)
: students.OrderBy(s => s.Name),
"Score" => descending
? students.OrderByDescending(s => s.Score)
: students.OrderBy(s => s.Score),
_ => students.OrderBy(s => s.Name) // 기본값
};
return query;
}
// 사용
var result = SortStudents(students, "Score", descending: true);
주의사항:
- null 키 처리: 정렬 키가 null일 수 있으면 주의하세요.
// 위험: Name이 null이면 예외
var sorted = people.OrderBy(p => p.Name);
// 안전: null을 특별히 처리
var safe = people.OrderBy(p => p.Name ?? string.Empty);
- 비교 불가능한 타입: 정렬 키가
IComparable을 구현하지 않으면 런타임 오류가 발생합니다.
// 오류: MyCustomClass가 IComparable을 구현하지 않으면 실패
var sorted = objects.OrderBy(o => o.CustomProperty);
// 해결: 명시적 키 선택 또는 비교자 제공
var fixed = objects.OrderBy(o => o.CustomProperty.SomeComparableField);
**다중 정렬 (ThenBy / ThenByDescending):**
복잡한 정렬 규칙을 적용할 때는 `ThenBy`와 `ThenByDescending`을 사용합니다.
```csharp
class Employee
{
public string Department { get; set; }
public string Name { get; set; }
public int Salary { get; set; }
}
List<Employee> employees = new List<Employee>
{
new Employee { Department = "개발", Name = "김철수", Salary = 5000 },
new Employee { Department = "개발", Name = "이영희", Salary = 6000 },
new Employee { Department = "영업", Name = "박민수", Salary = 4500 },
new Employee { Department = "영업", Name = "최지혜", Salary = 5500 },
new Employee { Department = "개발", Name = "정다은", Salary = 5000 }
};
// 부서별로 먼저 정렬하고, 같은 부서 내에서는 연봉순으로 정렬
var sorted = employees
.OrderBy(e => e.Department)
.ThenByDescending(e => e.Salary)
.ThenBy(e => e.Name); // 연봉이 같으면 이름순
Console.WriteLine("부서별 > 연봉순 > 이름순:");
foreach (var emp in sorted)
{
Console.WriteLine($"{emp.Department} - {emp.Name}: {emp.Salary:C0}");
}
// 출력:
// 개발 - 이영희: ₩6,000
// 개발 - 김철수: ₩5,000
// 개발 - 정다은: ₩5,000
// 영업 - 최지혜: ₩5,500
// 영업 - 박민수: ₩4,500
안정 정렬의 중요성:
LINQ의 OrderBy는 안정 정렬(Stable Sort)을 보장합니다. 즉, 키 값이 같은 요소들의 상대적 순서가 보존됩니다.
var data = new[]
{
new { Name = "Alice", Score = 85 },
new { Name = "Bob", Score = 90 },
new { Name = "Charlie", Score = 85 },
new { Name = "David", Score = 90 }
};
// Score로 정렬하되, 같은 점수 내에서는 원래 순서 유지
var sorted = data.OrderBy(x => x.Score);
foreach (var item in sorted)
{
Console.WriteLine($"{item.Name}: {item.Score}");
}
// 출력:
// Alice: 85
// Charlie: 85 // Alice와 Charlie의 원래 순서 유지
// Bob: 90
// David: 90 // Bob과 David의 원래 순서 유지
14.4 집계 연산자¶
집계 연산자(Aggregation Operators)는 컬렉션의 데이터를 하나의 값으로 요약하는 연산자들입니다. SQL의 집계 함수(Aggregate Functions)와 동일한 역할을 하며, 통계 분석과 데이터 요약에 필수적입니다. 이러한 연산자들은 즉시 실행(Immediate Execution)되어 결과를 반환합니다.
14.4.1 Count, Sum, Average¶
집계 연산자(Aggregation Operators)는 컬렉션 전체를 하나의 값으로 축약(Reduce)하는 연산자들입니다. 이들은 함수형 프로그래밍의 reduce 또는 fold 패턴을 구현하며, 통계 분석과 데이터 요약에 필수적입니다. 중요한 점은 이들이 즉시 실행(Immediate Execution) 연산자라는 것입니다. 즉, 호출 즉시 전체 컬렉션을 순회하여 결과를 반환합니다.
즉시 실행의 의미:
Where, Select, OrderBy와 달리, 집계 연산자는 지연 실행되지 않습니다. 메서드 호출 시점에 즉시 모든 요소를 평가하고 최종 값을 반환합니다. 이는 집계 연산의 본질상 전체 데이터를 봐야만 결과를 계산할 수 있기 때문입니다.
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var query = numbers.Where(n => n > 2); // 지연 실행: 아직 실행 안 됨
int count = query.Count(); // 즉시 실행: 여기서 Where와 Count가 함께 실행됨
Count - 요소 개수 세기:
Count는 시퀀스의 요소 개수를 반환합니다. 단순해 보이지만, 내부적으로는 최적화가 이루어집니다.
// 시그니처
public static int Count<TSource>(this IEnumerable<TSource> source)
public static int Count<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
Count의 내부 최적화:
Count는 입력 타입에 따라 다르게 동작합니다:
ICollection<T>를 구현한 경우:Count속성 직접 반환 (O(1))- 그 외의 경우: 전체 열거하여 개수 세기 (O(n))
List<int> list = new List<int> { 1, 2, 3, 4, 5 };
int count1 = list.Count(); // O(1) - List.Count 속성 사용
IEnumerable<int> sequence = list.Where(n => n > 2);
int count2 = sequence.Count(); // O(n) - 전체 열거 필요
// 최적화 비교
var largeList = Enumerable.Range(1, 10000000).ToList();
// list.Count() → 즉시 반환 (Count 속성)
// list.Where(...).Count() → 전체 필터링 후 계산
기본 예제:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 전체 개수
int totalCount = numbers.Count();
Console.WriteLine($"전체 개수: {totalCount}");
// 출력: 전체 개수: 10
// 조건을 만족하는 요소 개수 (술어 포함 오버로드)
int evenCount = numbers.Count(n => n % 2 == 0);
Console.WriteLine($"짝수 개수: {evenCount}");
// 출력: 짝수 개수: 5
int greaterThanFive = numbers.Count(n => n > 5);
Console.WriteLine($"5보다 큰 수 개수: {greaterThanFive}");
// 출력: 5보다 큰 수 개수: 5
// Any와 Count의 차이
bool hasEven = numbers.Any(n => n % 2 == 0); // 하나라도 있으면 즉시 true 반환 (효율적)
int evenCount2 = numbers.Count(n => n % 2 == 0); // 모든 요소 검사 후 개수 반환
LongCount - 큰 컬렉션:
Count는 int를 반환하므로 약 21억 개가 한계입니다. 더 큰 컬렉션에는 LongCount를 사용합니다.
// int 범위: -2,147,483,648 ~ 2,147,483,647 (약 21억)
// long 범위: -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 (약 900경)
long hugeCount = Enumerable.Range(1, int.MaxValue / 2).LongCount();
Console.WriteLine($"큰 컬렉션 개수: {hugeCount:N0}");
// 출력: 큰 컬렉션 개수: 1,073,741,823
Sum - 합계 계산:
Sum은 숫자 시퀀스의 합계를 계산합니다. 여러 숫자 타입에 대한 오버로드가 제공됩니다.
// 시그니처 (int 버전, 다른 숫자 타입도 유사)
public static int Sum(this IEnumerable<int> source)
public static int Sum<TSource>(this IEnumerable<TSource> source, Func<TSource, int> selector)
Sum의 오버플로 주의:
Sum은 오버플로를 검사하지 않으므로, 큰 수의 합계 시 주의가 필요합니다.
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 전체 합계
int sum = numbers.Sum();
Console.WriteLine($"전체 합계: {sum}");
// 출력: 전체 합계: 55
// 조건부 합계
int evenSum = numbers.Where(n => n % 2 == 0).Sum();
Console.WriteLine($"짝수 합계: {evenSum}");
// 출력: 짝수 합계: 30
// 오버플로 예제
var largeNumbers = Enumerable.Repeat(int.MaxValue / 2, 10);
try
{
int overflow = largeNumbers.Sum(); // 오버플로 발생!
Console.WriteLine($"합계: {overflow}");
}
catch (OverflowException)
{
Console.WriteLine("오버플로 발생!");
}
// 해결: long 사용
long safeSum = largeNumbers.Select(n => (long)n).Sum();
Console.WriteLine($"안전한 합계: {safeSum:N0}");
Average - 평균 계산:
Average는 숫자 시퀀스의 산술 평균을 계산합니다. 반환 타입은 항상 double 또는 decimal입니다.
// 시그니처
public static double Average(this IEnumerable<int> source)
public static double Average<TSource>(this IEnumerable<TSource> source, Func<TSource, int> selector)
Average의 주의사항:
- 빈 시퀀스에 대해
Average를 호출하면InvalidOperationException발생 - 반환 타입이
double이므로 부동소수점 정밀도 문제 가능
// 전체 평균
double average = numbers.Average();
Console.WriteLine($"전체 평균: {average}");
// 출력: 전체 평균: 5.5
// 조건부 평균
double oddAverage = numbers.Where(n => n % 2 != 0).Average();
Console.WriteLine($"홀수 평균: {oddAverage}");
// 출력: 홀수 평균: 5
// 빈 시퀀스 처리
var empty = new List<int>();
try
{
double emptyAvg = empty.Average(); // 예외!
}
catch (InvalidOperationException)
{
Console.WriteLine("빈 컬렉션의 평균을 계산할 수 없습니다.");
}
// 안전한 패턴
double safeAverage = empty.Any() ? empty.Average() : 0;
// 또는 DefaultIfEmpty 사용
double safeAverage2 = empty.DefaultIfEmpty().Average(); // 0 반환
객체 컬렉션에서의 집계:
실무에서는 객체의 특정 속성을 집계하는 경우가 많습니다.
class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public string Category { get; set; }
}
List<Product> products = new List<Product>
{
new Product { Name = "노트북", Price = 1500000, Quantity = 3, Category = "전자제품" },
new Product { Name = "마우스", Price = 35000, Quantity = 10, Category = "전자제품" },
new Product { Name = "키보드", Price = 120000, Quantity = 5, Category = "전자제품" },
new Product { Name = "책상", Price = 300000, Quantity = 2, Category = "가구" }
};
// 상품 종류 개수
int productCount = products.Count();
Console.WriteLine($"총 상품 종류: {productCount}");
// 출력: 총 상품 종류: 4
// 카테고리별 개수
int electronicsCount = products.Count(p => p.Category == "전자제품");
Console.WriteLine($"전자제품: {electronicsCount}개");
// 출력: 전자제품: 3개
// 총 재고 수량
int totalQuantity = products.Sum(p => p.Quantity);
Console.WriteLine($"총 재고: {totalQuantity}개");
// 출력: 총 재고: 20개
// 평균 가격
decimal avgPrice = products.Average(p => p.Price);
Console.WriteLine($"평균 가격: {avgPrice:C0}");
// 출력: 평균 가격: ₩488,750
// 총 재고 가치 (가격 × 수량의 합)
decimal totalValue = products.Sum(p => p.Price * p.Quantity);
Console.WriteLine($"총 재고 가치: {totalValue:C0}");
// 출력: 총 재고 가치: ₩5,450,000
// 전자제품 평균 가격
decimal electronicsAvgPrice = products
.Where(p => p.Category == "전자제품")
.Average(p => p.Price);
Console.WriteLine($"전자제품 평균 가격: {electronicsAvgPrice:C0}");
// 출력: 전자제품 평균 가격: ₩551,667
실무 패턴 - 통계 한 번에 계산:
여러 통계를 계산할 때 매번 컬렉션을 순회하는 것은 비효율적입니다.
// 비효율적: 4번 순회
int count = products.Count();
int sumQuantity = products.Sum(p => p.Quantity);
decimal avgPrice = products.Average(p => p.Price);
decimal maxPrice = products.Max(p => p.Price);
// 효율적: 1번 순회로 모든 통계 계산 (Aggregate 활용)
var stats = products.Aggregate(
new { Count = 0, TotalQty = 0, SumPrice = 0m, MaxPrice = 0m },
(acc, p) => new
{
Count = acc.Count + 1,
TotalQty = acc.TotalQty + p.Quantity,
SumPrice = acc.SumPrice + p.Price,
MaxPrice = Math.Max(acc.MaxPrice, p.Price)
},
acc => new
{
acc.Count,
acc.TotalQty,
AveragePrice = acc.Count > 0 ? acc.SumPrice / acc.Count : 0,
acc.MaxPrice
}
);
Console.WriteLine($"제품 수: {stats.Count}, 총 재고: {stats.TotalQty}");
Console.WriteLine($"평균 가격: {stats.AveragePrice:C0}, 최고가: {stats.MaxPrice:C0}");
성능 고려사항:
- Where().Count() vs Count(predicate): 후자가 약간 더 효율적
// 덜 효율적
int count1 = numbers.Where(n => n > 5).Count(); // 중간 시퀀스 생성
// 더 효율적
int count2 = numbers.Count(n => n > 5); // 직접 카운트
- Any() vs Count() > 0: 존재 여부만 확인한다면
Any()사용
// 비효율적: 모든 요소 세기
if (numbers.Count(n => n > 5) > 0) { }
// 효율적: 하나만 찾으면 즉시 반환
if (numbers.Any(n => n > 5)) { }
Average - 평균 계산:
// 전체 평균
double average = numbers.Average();
Console.WriteLine($"전체 평균: {average}");
// 출력: 전체 평균: 5.5
// 조건을 만족하는 요소의 평균
double oddAverage = numbers.Where(n => n % 2 != 0).Average();
Console.WriteLine($"홀수 평균: {oddAverage}");
// 출력: 홀수 평균: 5
객체 컬렉션에서의 집계:
class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
}
List<Product> products = new List<Product>
{
new Product { Name = "노트북", Price = 1500000, Quantity = 3 },
new Product { Name = "마우스", Price = 35000, Quantity = 10 },
new Product { Name = "키보드", Price = 120000, Quantity = 5 }
};
// 상품 개수
int productCount = products.Count();
Console.WriteLine($"총 상품 종류: {productCount}");
// 출력: 총 상품 종류: 3
// 총 재고 수량
int totalQuantity = products.Sum(p => p.Quantity);
Console.WriteLine($"총 재고: {totalQuantity}개");
// 출력: 총 재고: 18개
// 평균 가격
decimal avgPrice = products.Average(p => p.Price);
Console.WriteLine($"평균 가격: {avgPrice:C0}");
// 출력: 평균 가격: ₩551,667
// 총 재고 가치 (가격 × 수량의 합)
decimal totalValue = products.Sum(p => p.Price * p.Quantity);
Console.WriteLine($"총 재고 가치: {totalValue:C0}");
// 출력: 총 재고 가치: ₩5,450,000
LongCount - 큰 컬렉션:
매우 큰 컬렉션(int 범위 초과)의 경우 LongCount를 사용합니다.
// int 범위: -2,147,483,648 ~ 2,147,483,647
// long 범위: -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
long hugeCount = Enumerable.Range(1, int.MaxValue / 2).LongCount();
Console.WriteLine($"큰 컬렉션 개수: {hugeCount:N0}");
// 출력: 큰 컬렉션 개수: 1,073,741,823
14.4.2 Min, Max¶
Min과 Max 연산자는 컬렉션에서 최소값과 최대값을 찾습니다. 이들은 선형 탐색(Linear Search)을 수행하여 O(n) 시간 복잡도를 가지며, 비교 가능한(IComparable<T>) 요소에 대해 작동합니다. 수학적으로는 전순서(Total Order)를 가진 집합에서의 최소/최대 원소를 찾는 연산입니다.
알고리즘과 성능:
Min과 Max는 단순한 선형 탐색 알고리즘을 사용합니다. 첫 번째 요소를 현재 최소/최대값으로 설정하고, 나머지 요소들을 순회하며 비교하여 갱신합니다. 이는 최적의 알고리즘이며(모든 요소를 확인해야 하므로), 어떤 정렬이나 추가 자료구조도 필요하지 않습니다.
// 시그니처
public static TSource Min<TSource>(this IEnumerable<TSource> source)
public static TResult Min<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
public static TSource Max<TSource>(this IEnumerable<TSource> source)
public static TResult Max<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
성능 특성:
- 시간 복잡도: O(n) - 모든 요소를 한 번씩 검사
- 공간 복잡도: O(1) - 현재 최소/최대값만 유지
- 즉시 실행: 호출 즉시 전체 컬렉션 순회
- 빈 시퀀스:
InvalidOperationException발생
기본 예제:
List<int> numbers = new List<int> { 5, 2, 8, 1, 9, 3, 7 };
// 최소값
int min = numbers.Min();
Console.WriteLine($"최소값: {min}");
// 출력: 최소값: 1
// 최대값
int max = numbers.Max();
Console.WriteLine($"최대값: {max}");
// 출력: 최대값: 9
// 범위 계산
int range = max - min;
Console.WriteLine($"범위 (Range): {range}");
// 출력: 범위 (Range): 8
빈 시퀀스 처리:
빈 컬렉션에 대해 Min/Max를 호출하면 예외가 발생합니다. 안전하게 처리해야 합니다.
var empty = new List<int>();
try
{
int min = empty.Min(); // InvalidOperationException!
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"오류: {ex.Message}");
}
// 안전한 패턴
int safeMin = empty.Any() ? empty.Min() : 0;
// 또는 DefaultIfEmpty 사용
int safeMin2 = empty.DefaultIfEmpty().Min(); // 기본값(0) 반환
// Nullable 타입 사용 (가장 권장)
int? nullableMin = empty.Cast<int?>().Min(); // null 반환
if (nullableMin.HasValue)
{
Console.WriteLine($"최소값: {nullableMin.Value}");
}
else
{
Console.WriteLine("값이 없습니다.");
}
객체 속성의 Min, Max:
실무에서는 객체의 특정 속성을 기준으로 최소/최대를 찾는 경우가 많습니다.
class Student
{
public string Name { get; set; }
public int Score { get; set; }
public int Age { get; set; }
}
List<Student> students = new List<Student>
{
new Student { Name = "김철수", Score = 85, Age = 20 },
new Student { Name = "이영희", Score = 92, Age = 22 },
new Student { Name = "박민수", Score = 78, Age = 19 },
new Student { Name = "최지혜", Score = 95, Age = 21 }
};
// 최고 점수 (값만)
int maxScore = students.Max(s => s.Score);
Console.WriteLine($"최고 점수: {maxScore}");
// 출력: 최고 점수: 95
// 최저 점수
int minScore = students.Min(s => s.Score);
Console.WriteLine($"최저 점수: {minScore}");
// 출력: 최저 점수: 78
// 점수 차이
Console.WriteLine($"점수 차이: {maxScore - minScore}점");
// 출력: 점수 차이: 17점
최소/최대값을 가진 객체 찾기:
Min/Max는 값만 반환하므로, 해당 값을 가진 객체를 찾으려면 추가 작업이 필요합니다.
// 방법 1: Min/Max 후 Where로 찾기
int maxScore = students.Max(s => s.Score);
var topStudent = students.First(s => s.Score == maxScore);
Console.WriteLine($"최우수 학생: {topStudent.Name} ({topStudent.Score}점)");
// 출력: 최우수 학생: 최지혜 (95점)
// 방법 2: OrderBy로 직접 찾기 (더 효율적)
var topStudent2 = students.OrderByDescending(s => s.Score).First();
Console.WriteLine($"최우수 학생: {topStudent2.Name} ({topStudent2.Score}점)");
// 방법 3: Aggregate로 한 번에 찾기 (가장 효율적)
var topStudent3 = students.Aggregate((max, current) =>
current.Score > max.Score ? current : max);
Console.WriteLine($"최우수 학생: {topStudent3.Name} ({topStudent3.Score}점)");
// 방법 4: MaxBy (C# 9.0+, .NET 6+)
// var topStudent4 = students.MaxBy(s => s.Score);
다양한 타입에서의 Min/Max:
// 문자열 비교 (사전순)
string[] names = { "Alice", "Charlie", "Bob", "David" };
string minName = names.Min(); // "Alice"
string maxName = names.Max(); // "David"
// 날짜 비교
class Event
{
public string Name { get; set; }
public DateTime Date { get; set; }
public int Attendees { get; set; }
}
List<Event> events = new List<Event>
{
new Event { Name = "회의", Date = new DateTime(2024, 11, 15), Attendees = 10 },
new Event { Name = "세미나", Date = new DateTime(2024, 11, 10), Attendees = 50 },
new Event { Name = "워크샵", Date = new DateTime(2024, 11, 20), Attendees = 30 }
};
// 가장 빠른 날짜
DateTime earliest = events.Min(e => e.Date);
Console.WriteLine($"가장 빠른 일정: {earliest:yyyy-MM-dd}");
// 출력: 가장 빠른 일정: 2024-11-10
// 가장 늦은 날짜
DateTime latest = events.Max(e => e.Date);
Console.WriteLine($"가장 늦은 일정: {latest:yyyy-MM-dd}");
// 출력: 가장 늦은 일정: 2024-11-20
// 가장 많은 참석자 수
int maxAttendees = events.Max(e => e.Attendees);
Console.WriteLine($"최대 참석자: {maxAttendees}명");
// 출력: 최대 참석자: 50명
// 가장 많은 참석자가 있는 이벤트
var popularEvent = events.First(e => e.Attendees == maxAttendees);
Console.WriteLine($"인기 이벤트: {popularEvent.Name}");
// 출력: 인기 이벤트: 세미나
성능 최적화 - 한 번의 순회로 Min과 Max 모두 찾기:
Min과 Max를 별도로 호출하면 2번 순회합니다. Aggregate를 사용하면 1번 순회로 가능합니다.
// 비효율적: 2번 순회
int min = numbers.Min(); // 첫 번째 순회
int max = numbers.Max(); // 두 번째 순회
// 효율적: 1번 순회
var minMax = numbers.Aggregate(
new { Min = int.MaxValue, Max = int.MinValue },
(acc, n) => new
{
Min = Math.Min(acc.Min, n),
Max = Math.Max(acc.Max, n)
}
);
Console.WriteLine($"최소: {minMax.Min}, 최대: {minMax.Max}");
사용자 정의 비교:
IComparer<T>를 제공하여 비교 로직을 커스터마이즈할 수 있습니다 (일부 오버로드에서).
// 절대값 기준 최소/최대
var numbers = new[] { -5, 3, -8, 2, 7 };
int minByAbs = numbers.OrderBy(n => Math.Abs(n)).First(); // 2
int maxByAbs = numbers.OrderByDescending(n => Math.Abs(n)).First(); // -8
14.4.3 Aggregate¶
Aggregate 연산자는 LINQ의 가장 강력하고 범용적인 집계 연산자입니다. 함수형 프로그래밍의 reduce (JavaScript, Python) 또는 fold (Haskell, Scala) 연산과 동일한 개념으로, 임의의 사용자 정의 집계 로직을 구현할 수 있습니다. Count, Sum, Average, Min, Max 등은 모두 Aggregate의 특수한 경우로 볼 수 있습니다.
함수형 프로그래밍의 Fold/Reduce 패턴:
Aggregate는 1960년대 Lisp에서 유래한 고전적인 패턴입니다. 아이디어는 시퀀스를 하나의 값으로 "접는(fold)" 것입니다. 누산기(Accumulator)를 유지하면서 각 요소에 함수를 적용하여 누산기를 갱신하고, 최종적으로 누산기 값을 반환합니다.
시그니처와 오버로드:
// 오버로드 1: 시드 없음 (첫 요소를 시드로 사용)
public static TSource Aggregate<TSource>(
this IEnumerable<TSource> source,
Func<TSource, TSource, TSource> func)
// 오버로드 2: 시드 제공
public static TAccumulate Aggregate<TSource, TAccumulate>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func)
// 오버로드 3: 시드 + 결과 변환
public static TResult Aggregate<TSource, TAccumulate, TResult>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func,
Func<TAccumulate, TResult> resultSelector)
내부 동작 메커니즘:
// Aggregate의 개념적 구현
public static TAccumulate Aggregate<TSource, TAccumulate>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func)
{
TAccumulate accumulator = seed;
foreach (TSource element in source)
{
accumulator = func(accumulator, element);
}
return accumulator;
}
기본 예제 - 다양한 집계 연산:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
// 오버로드 1: 시드 없음 - 첫 요소가 초기값
int sum = numbers.Aggregate((acc, n) => acc + n);
Console.WriteLine($"합계 (Aggregate): {sum}");
// 출력: 합계 (Aggregate): 15
// 동작 과정:
// 1단계: 1 + 2 = 3
// 2단계: 3 + 3 = 6
// 3단계: 6 + 4 = 10
// 4단계: 10 + 5 = 15
초기값(Seed) 사용:
오버로드 2는 초기값을 명시적으로 제공합니다. 이는 빈 시퀀스 처리나 타입 변환에 유용합니다.
// 초기값 100부터 시작
int sumWithSeed = numbers.Aggregate(100, (acc, n) => acc + n);
Console.WriteLine($"초기값 100 + 합계: {sumWithSeed}");
// 출력: 초기값 100 + 합계: 115
// 곱셈
int product = numbers.Aggregate(1, (acc, n) => acc * n);
Console.WriteLine($"곱셈: {product}");
// 출력: 곱셈: 120 (1 × 2 × 3 × 4 × 5)
문자열 결합:
string[] words = { "LINQ", "는", "강력하다" };
// 단어 결합
string sentence = words.Aggregate((acc, word) => acc + " " + word);
Console.WriteLine(sentence);
// 출력: LINQ 는 강력하다
// 초기값과 함께
string prefixed = words.Aggregate("C#의 ", (acc, word) => acc + word + " ");
Console.WriteLine(prefixed);
// 출력: C#의 LINQ 는 강력하다
복잡한 계산:
class Transaction
{
public string Type { get; set; } // "입금" 또는 "출금"
public decimal Amount { get; set; }
}
List<Transaction> transactions = new List<Transaction>
{
new Transaction { Type = "입금", Amount = 100000 },
new Transaction { Type = "출금", Amount = 30000 },
new Transaction { Type = "입금", Amount = 50000 },
new Transaction { Type = "출금", Amount = 20000 }
};
// 최종 잔액 계산
decimal balance = transactions.Aggregate(
0m, // 초기 잔액
(acc, t) => t.Type == "입금" ? acc + t.Amount : acc - t.Amount
);
Console.WriteLine($"최종 잔액: {balance:C0}");
// 출력: 최종 잔액: ₩100,000
// 동작 과정:
// 초기: 0
// 1. 입금 100000: 0 + 100000 = 100000
// 2. 출금 30000: 100000 - 30000 = 70000
// 3. 입금 50000: 70000 + 50000 = 120000
// 4. 출금 20000: 120000 - 20000 = 100000
결과 변환 (Result Selector):
List<int> nums = new List<int> { 2, 3, 4 };
// 곱셈 후 문자열로 변환
string result = nums.Aggregate(
1, // 초기값
(acc, n) => acc * n, // 집계 함수
finalResult => $"결과: {finalResult}" // 결과 변환
);
Console.WriteLine(result);
// 출력: 결과: 24
실용적인 예제 - 통계 계산:
List<int> scores = new List<int> { 85, 92, 78, 95, 88, 73, 91, 67 };
// 여러 통계를 한 번에 계산
var stats = scores.Aggregate(
new { Count = 0, Sum = 0, Min = int.MaxValue, Max = int.MinValue },
(acc, score) => new
{
Count = acc.Count + 1,
Sum = acc.Sum + score,
Min = Math.Min(acc.Min, score),
Max = Math.Max(acc.Max, score)
},
acc => new
{
acc.Count,
acc.Sum,
acc.Min,
acc.Max,
Average = (double)acc.Sum / acc.Count
}
);
Console.WriteLine($"개수: {stats.Count}");
Console.WriteLine($"합계: {stats.Sum}");
Console.WriteLine($"최소: {stats.Min}");
Console.WriteLine($"최대: {stats.Max}");
Console.WriteLine($"평균: {stats.Average:F2}");
// 출력:
// 개수: 8
// 합계: 669
// 최소: 67
// 최대: 95
// 평균: 83.63
Aggregate의 강력한 응용:
Aggregate는 단순한 집계를 넘어 복잡한 비즈니스 로직을 구현할 수 있습니다.
// 팩토리얼 계산
var factorial = Enumerable.Range(1, 5).Aggregate((acc, n) => acc * n);
Console.WriteLine($"5! = {factorial}"); // 출력: 5! = 120
// 문자열 리스트를 CSV로 변환
var items = new[] { "사과", "바나나", "오렌지" };
var csv = items.Aggregate((acc, item) => $"{acc},{item}");
Console.WriteLine(csv); // 출력: 사과,바나나,오렌지
// 중첩된 컬렉션 평탄화
var nestedLists = new List<List<int>>
{
new List<int> { 1, 2 },
new List<int> { 3, 4, 5 },
new List<int> { 6 }
};
var flattened = nestedLists.Aggregate(
new List<int>(),
(acc, list) => { acc.AddRange(list); return acc; }
);
Console.WriteLine("평탄화: " + string.Join(", ", flattened));
// 출력: 평탄화: 1, 2, 3, 4, 5, 6
다른 집계 연산자와의 관계:
모든 집계 연산자는 Aggregate로 구현할 수 있습니다:
var numbers = new[] { 1, 2, 3, 4, 5 };
// Count를 Aggregate로
int count = numbers.Aggregate(0, (acc, n) => acc + 1);
// Sum을 Aggregate로
int sum = numbers.Aggregate(0, (acc, n) => acc + n);
// Average를 Aggregate로
var avgResult = numbers.Aggregate(
new { Sum = 0, Count = 0 },
(acc, n) => new { Sum = acc.Sum + n, Count = acc.Count + 1 },
acc => (double)acc.Sum / acc.Count
);
// Min을 Aggregate로
int min = numbers.Aggregate((acc, n) => acc < n ? acc : n);
// Max를 Aggregate로
int max = numbers.Aggregate((acc, n) => acc > n ? acc : n);
성능 고려사항:
Aggregate는 강력하지만, 특수 목적 연산자(Sum, Count 등)가 있다면 그것을 사용하는 것이 더 명확하고 최적화되어 있습니다.
// 권장: 명확하고 최적화됨
int sum1 = numbers.Sum();
// 가능하지만 불필요하게 복잡
int sum2 = numbers.Aggregate(0, (acc, n) => acc + n);
주의사항:
- 빈 시퀀스: 시드 없는 오버로드는 빈 시퀀스에 대해 예외 발생
var empty = new List<int>();
// int result = empty.Aggregate((acc, n) => acc + n); // 예외!
// 안전한 패턴: 시드 제공
int safeResult = empty.Aggregate(0, (acc, n) => acc + n); // 0 반환
- 부작용 피하기: 집계 함수는 순수 함수여야 합니다
// 나쁜 예: 외부 상태 변경
int externalCounter = 0;
var bad = numbers.Aggregate(0, (acc, n) =>
{
externalCounter++; // 부작용! 피해야 함
return acc + n;
});
// 좋은 예: 순수 함수
var good = numbers.Aggregate(0, (acc, n) => acc + n);
- 가변 객체 주의: 누산기가 가변 객체인 경우 조심
// 위험: 같은 리스트 인스턴스를 계속 수정
var dangerous = nestedLists.Aggregate(
new List<int>(), // 이 리스트가 계속 변경됨
(acc, list) => { acc.AddRange(list); return acc; }
);
// 안전: 불변 패턴 사용 (비효율적이지만 안전)
var safe = nestedLists.Aggregate(
Enumerable.Empty<int>(),
(acc, list) => acc.Concat(list)
);
Aggregate vs SelectMany:
평탄화 작업에는 SelectMany가 더 적합합니다:
// Aggregate로 평탄화 (복잡)
var flattened1 = nestedLists.Aggregate(
new List<int>(),
(acc, list) => { acc.AddRange(list); return acc; }
);
// SelectMany로 평탄화 (권장)
var flattened2 = nestedLists.SelectMany(list => list);
실무 팁:
- 간단한 집계는 특수 연산자(
Sum,Count등) 사용 - 복잡하거나 사용자 정의 집계는
Aggregate사용 - 여러 통계를 한 번에 계산할 때
Aggregate활용 - 코드 가독성을 위해 복잡한 람다는 별도 메서드로 추출
// 복잡한 람다 추출
(sum, variance) CalculateStatistics(IEnumerable<double> numbers)
{
var stats = numbers.Aggregate(
new { Sum = 0.0, SumOfSquares = 0.0, Count = 0 },
(acc, n) => new
{
Sum = acc.Sum + n,
SumOfSquares = acc.SumOfSquares + n * n,
Count = acc.Count + 1
}
);
double mean = stats.Sum / stats.Count;
double variance = (stats.SumOfSquares / stats.Count) - (mean * mean);
return (stats.Sum, variance);
}
마무리¶
이 장에서는 LINQ(Language Integrated Query)의 기초를 체계적으로 학습했습니다. LINQ는 단순히 편리한 문법이 아니라, 2007년 C# 3.0에서 도입된 이후 .NET 생태계의 데이터 처리 패러다임을 근본적으로 변화시킨 혁명적인 기능입니다. SQL의 선언적 쿼리 스타일과 함수형 프로그래밍의 강력함을 C# 언어에 통합함으로써, 메모리 상의 컬렉션부터 데이터베이스, XML에 이르기까지 모든 데이터 소스를 일관된 방식으로 다룰 수 있게 되었습니다.
학습 내용 종합:
이 장을 통해 독자 여러분은 LINQ의 핵심 개념과 실전 활용법을 다음과 같이 학습했습니다:
✅ LINQ의 역사적·이론적 배경: Language Integrated Query가 탄생하게 된 역사적 맥락과 철학적 기반을 이해했습니다. 1970년대 Edgar F. Codd의 관계형 모델에서 시작하여, 함수형 프로그래밍 언어들(Lisp, Haskell, ML)의 리스트 처리 개념, 그리고 2000년대 모나드와 표현식 트리 이론이 결합되어 LINQ가 탄생하게 된 과정을 살펴보았습니다. Erik Meijer의 통찰력 있는 설계는 범주론(Category Theory)의 개념을 활용하여 다양한 데이터 소스를 추상화하는 통합된 쿼리 모델을 만들어냈습니다.
✅ LINQ의 아키텍처와 공급자 모델: LINQ가 3계층 아키텍처(쿼리 표현 계층, 표준 쿼리 연산자 계층, 실행 계층)로 설계되어 있음을 이해했습니다. 공급자 모델(Provider Model)을 통해 LINQ to Objects, LINQ to SQL, LINQ to XML, PLINQ 등이 동일한 쿼리 구문을 사용하면서도 각 데이터 소스에 최적화된 방식으로 실행되는 메커니즘을 학습했습니다.
✅ 지연 실행(Deferred Execution)의 이해: LINQ의 가장 독특하고 강력한 특성인 지연 실행 개념을 완전히 이해했습니다. Haskell의 게으른 평가(Lazy Evaluation)에서 영감을 받은 이 메커니즘은 쿼리 정의와 실행을 분리하여, 불필요한 계산을 피하고 쿼리 조합(Query Composition)을 가능하게 합니다. 이를 통해 성능을 최적화하고 메모리를 효율적으로 사용할 수 있습니다.
✅ 쿼리 구문과 메서드 구문의 완벽한 이해: LINQ를 표현하는 두 가지 방식의 차이점과 각각의 장단점을 깊이 있게 학습했습니다. 쿼리 구문은 SQL 친화적 접근으로 복잡한 쿼리의 가독성을 높이며, 메서드 구문은 함수형 프로그래밍 스타일로 모든 LINQ 연산자를 유연하게 사용할 수 있습니다. 컴파일러가 쿼리 구문을 메서드 호출로 변환하는 쿼리 컴프리헨션(Query Comprehension) 과정을 이해함으로써, 두 방식이 완전히 동등함을 확인했습니다.
✅ 기본 LINQ 연산자의 숙달: 데이터 처리의 핵심 연산자들을 체계적으로 학습했습니다: - Where (필터링): 함수형 프로그래밍의 filter 함수에 해당하며, 조건을 만족하는 요소만 선택합니다. 인덱스를 활용한 필터링, 복잡한 조건 결합 등 다양한 활용 패턴을 익혔습니다. - Select (투영): map 함수에 해당하며, 각 요소를 다른 형태로 변환합니다. 익명 타입을 활용한 투영, 인덱스를 포함한 변환 등을 학습했습니다. - OrderBy / OrderByDescending (정렬): 안정 정렬(Stable Sort) 알고리즘을 사용하며, ThenBy를 통한 다중 기준 정렬을 지원합니다. IntroSort 알고리즘의 내부 동작을 이해했습니다.
✅ 집계 연산자를 통한 데이터 요약: 데이터를 단일 값으로 축약하는 연산자들을 학습했습니다: - Count, Sum, Average: 기본 통계 정보 계산 - Min, Max: 최소값과 최대값 추출 - Aggregate: 함수형 프로그래밍의 reduce/fold에 해당하는 범용 집계 연산자로, 사용자 정의 집계 로직을 구현할 수 있습니다.
명령형에서 선언적 프로그래밍으로의 패러다임 전환:
LINQ를 학습하면서 가장 중요한 통찰은 프로그래밍 스타일의 근본적인 변화입니다. 전통적인 명령형 프로그래밍(Imperative Programming)에서는 "어떻게(How)" 할 것인지를 단계별로 명시했습니다:
// 명령형: 구현 세부사항에 집중
List<int> result = new List<int>();
foreach (var item in collection)
{
if (item > 10)
{
result.Add(item * 2);
}
}
LINQ의 선언적 프로그래밍(Declarative Programming)에서는 "무엇을(What)" 원하는지만 명시합니다:
이러한 패러다임 전환은 다음과 같은 이점을 제공합니다:
- 의도의 명확성: 코드가 "무엇을 하려는지"가 즉시 드러나 가독성이 향상됩니다.
- 오류 감소: 반복문, 인덱스, 임시 변수 등의 보일러플레이트 코드가 사라져 실수 가능성이 줄어듭니다.
- 최적화 가능성: 구현 세부사항을 라이브러리에 위임하여, 컴파일러나 런타임이 최적화할 여지를 제공합니다.
- 조합 가능성: 작은 쿼리들을 조합하여 복잡한 데이터 처리 파이프라인을 구축할 수 있습니다.
실무 활용 가이드라인:
LINQ를 실무에서 효과적으로 활용하기 위한 핵심 지침들:
- 지연 실행의 이해와 활용:
- 대부분의 LINQ 연산자는 지연 실행되므로, 쿼리 정의 시점이 아닌 열거 시점에 실행됩니다.
ToList(),ToArray(),Count(),First()등은 즉시 실행됩니다.- 쿼리를 여러 번 열거하면 매번 다시 실행되므로, 필요시
ToList()로 결과를 캐싱하세요. -
데이터베이스 쿼리에서는 지연 실행을 활용하여 불필요한 데이터 전송을 방지할 수 있습니다.
-
메서드 체이닝을 통한 파이프라인 구축:
- LINQ 연산자들을 체이닝하여 데이터 변환 파이프라인을 만드세요.
- 각 단계를 명확히 분리하여 디버깅과 유지보수를 용이하게 하세요.
-
Unix 파이프라인이나 함수형 언어의 함수 합성 개념과 유사합니다.
-
성능 고려사항:
- 복잡한 LINQ 쿼리는 가독성을 높이지만, 때로는 성능 비용이 있습니다.
- 핫 패스(Hot Path)에서는 프로파일링하여 필요시 전통적인 반복문으로 최적화하세요.
Where().Count()보다는Count(predicate)를 사용하여 불필요한 중간 시퀀스 생성을 피하세요.-
데이터베이스 쿼리에서는 LINQ가 생성하는 SQL을 확인하여 N+1 문제 등을 방지하세요.
-
가독성과 유지보수성:
- LINQ는 코드의 의도를 명확하게 표현하므로, 코드 리뷰와 유지보수가 용이합니다.
- 복잡한 쿼리는 여러 단계로 나누고 중간 결과에 의미 있는 변수명을 사용하세요.
-
팀 내 코딩 스타일(쿼리 구문 vs 메서드 구문)을 일관되게 유지하세요.
-
타입 안정성 활용:
- LINQ의 강력한 타입 시스템을 활용하여 컴파일 타임에 오류를 검출하세요.
- 익명 타입을 활용하되, API 경계를 넘어갈 때는 명시적 타입을 사용하세요.
var키워드로 타입 추론을 활용하여 코드를 간결하게 유지하세요.
LINQ와 함수형 프로그래밍의 연결:
LINQ를 학습하면서 함수형 프로그래밍의 핵심 개념들을 간접적으로 접하게 됩니다:
- 불변성(Immutability): LINQ 쿼리는 원본 컬렉션을 변경하지 않고 새로운 시퀀스를 반환합니다.
- 순수 함수(Pure Functions): LINQ 람다 식은 부작용(Side Effects)이 없는 순수 함수여야 합니다.
- 고차 함수(Higher-order Functions): Where, Select 등은 함수를 인자로 받는 고차 함수입니다.
- 합성 가능성(Composability): LINQ 연산자들은 자유롭게 조합할 수 있습니다.
이러한 개념들은 16장 "함수형 프로그래밍 개념"에서 더 깊이 다루게 됩니다.
다음 단계로 나아가기:
LINQ 기초를 완전히 이해했다면, 다음 학습 경로를 따라가세요:
15장. 고급 LINQ**에서 다룰 내용: - **조인 연산자 (Join, GroupJoin): 여러 데이터 소스를 결합하는 관계형 연산 - 그룹화 (GroupBy): 키를 기준으로 데이터를 그룹화하고 집계 - 집합 연산자 (Distinct, Union, Intersect, Except): 수학적 집합 연산 - 양화 연산자 (Any, All): 조건 검사 - 요소 연산자 (First, Single, ElementAt): 특정 요소 추출 - 생성 연산자 (Range, Repeat, Empty): 시퀀스 생성 - 지연 실행과 즉시 실행의 심화 이해: 성능 최적화 전략
실무 프로젝트 적용: - 20장 "할 일 관리 앱"에서 LINQ를 활용한 검색 및 필터링 구현 - 22장 "REST API 클라이언트"에서 JSON 데이터 처리에 LINQ 활용
실습 과제 (심화):
LINQ 실력을 한 단계 높이기 위한 실전 연습 문제들:
- 복합 필터링과 정렬:
- 정수 리스트에서 3의 배수이면서 10보다 큰 수를 선택
- 제곱 값으로 변환한 후 내림차순 정렬
-
상위 5개만 선택하여 출력
-
객체 변환과 집계:
- 학생 리스트(이름, 점수 목록)에서 각 학생의 평균 점수 계산
- 평균 80점 이상인 학생의 이름을 가나다순으로 정렬
-
이름과 평균 점수를 포함하는 익명 타입으로 투영
-
단어 빈도 분석:
- 텍스트 문자열을 단어로 분할
- 각 단어의 출현 빈도를 계산 (Dictionary 활용)
-
빈도순으로 정렬하여 상위 10개 단어 출력
-
고급 Aggregate 활용:
- 숫자 리스트의 표준 편차(Standard Deviation) 계산
- Aggregate를 사용하여 평균, 분산, 표준편차를 한 번의 순회로 계산
-
익명 타입을 활용하여 중간 결과 누적
-
실전 데이터 처리:
- CSV 형식의 문자열 배열을 파싱하여 객체 리스트로 변환
- 특정 조건으로 필터링하고 그룹화
- 각 그룹별 통계 정보(개수, 합계, 평균) 계산
학습 리소스:
- 공식 문서: Microsoft Docs - LINQ
- 권장 도서: "C# in Depth" by Jon Skeet - LINQ 심화
- 온라인 학습: LINQPad - LINQ 쿼리 실습 도구
- 실전 예제: 101 LINQ Samples - Microsoft 공식 샘플
마지막 조언:
LINQ는 현대 C# 프로그래밍의 핵심 기술입니다. 단순히 문법을 암기하는 것이 아니라, 선언적 프로그래밍 스타일과 함수형 사고방식을 체득하는 것이 중요합니다. 이 장에서 배운 기초를 바탕으로 실제 프로젝트에 LINQ를 적극 활용해보세요. 처음에는 어색할 수 있지만, 익숙해지면 더 이상 LINQ 없이는 코딩할 수 없을 정도로 강력하고 우아한 도구입니다.
"Code is read more often than it is written."라는 Guido van Rossum의 말처럼, LINQ를 통해 작성한 코드는 다른 개발자들(그리고 미래의 자신)이 읽고 이해하기 훨씬 쉽습니다. 이것이 바로 LINQ의 진정한 가치입니다.