LINQ
LINQ
LINQ(Language Integrated Query)는 C#에 통합된 쿼리 기능으로, 다양한 데이터 소스(메모리 내 컬렉션, 데이터베이스, XML 등)를 일관된 방식으로 쿼리할 수 있게 해주는 강력한 기능입니다. 이를 통해 개발자는 익숙한 C# 구문으로 데이터를 필터링, 정렬, 그룹화하고 변환할 수 있어 코드의 가독성과 생산성이 향상됩니다.
현대 애플리케이션은 끊임없이 데이터를 다룹니다. 이 데이터는 다양한 형태로 존재합니다.
- 메모리 내 객체 컬렉션 (List<T>, Array 등)
- 관계형 데이터베이스 (SQL Server, Oracle, MySQL 등)
- XML 문서
- 웹 서비스 결과
- 파일 시스템 등
과거에는 이러한 각기 다른 데이터 소스에 접근하고 필요한 정보를 추출하기 위해 서로 다른 API와 쿼리 언어(예: SQL, XPath)를 배워야 했습니다. 이는 개발 생산성을 저해하고 코드의 복잡성을 높이는 요인이었습니다.
LINQ는 이러한 문제를 해결하기 위해 등장했습니다. C# 언어 자체에 쿼리 기능을 내장함으로써, 개발자는 익숙한 C# 구문을 사용하여 어떤 종류의 데이터 소스든 거의 동일한 방식으로 질의할 수 있게 되었습니다. 이는 코드의 가독성, 유지보수성, 개발 생산성을 크게 향상시킵니다.
LINQ의 기본
LINQ 쿼리는 주로 쿼리 식(Query Expression) 구문을 사용하여 작성됩니다. 이는 SQL과 유사하여 직관적이며 가독성이 높습니다. 기본적인 LINQ 쿼리는 다음 키워드들을 조합하여 구성됩니다.
from: 쿼리할 데이터 소스(Source)를 지정하고, 각 요소에 대한 범위 변수(Range Variable)를 선언합니다. SQL의FROM과 유사하지만, LINQ에서는 쿼리의 가장 처음에 위치합니다. 하나의 쿼리에 여러 개의from절을 사용할 수도 있습니다.where: 데이터 소스에서 특정 조건을 만족하는 요소만 필터링합니다.where절의 조건식은bool값을 반환해야 합니다. SQL의WHERE와 유사합니다.orderby: 결과를 특정 키(Key)를 기준으로 정렬합니다.ascending(기본값) 또는descending키워드를 사용하여 오름차순 또는 내림차순으로 정렬할 수 있습니다. SQL의ORDER BY와 유사합니다.select: 최종 결과의 형태를 지정합니다. 원본 요소 전체를 선택하거나, 특정 속성만 추출하거나, 새로운 형태의 객체(익명 타입 등)를 만들어 반환할 수 있습니다. SQL의SELECT와 유사합니다.
숫자 리스트에서 짝수만 골라 내림차순으로 정렬하기
using System;
using System.Collections.Generic;
using System.Linq; // LINQ를 사용하기 위해 필수!
public class LinqBasicExample
{
public static void Main(string[] args)
{
// 데이터 소스 (메모리 내 컬렉션)
List<int> numbers = new List<int> { 5, 10, 8, 3, 6, 12, 1, 9 };
// LINQ 쿼리 식 (Query Expression)
var evenNumbersDesc = from num in numbers // 1. 데이터 소스(numbers) 지정 및 범위 변수(num) 선언
where num % 2 == 0 // 2. 짝수만 필터링 (조건)
orderby num descending // 3. 내림차순 정렬
select num; // 4. 결과 형태 지정 (원본 요소 그대로)
Console.WriteLine("짝수 내림차순 정렬:");
// 쿼리 실행 (이 시점에 실제로 쿼리가 동작 - 지연 실행)
foreach (int n in evenNumbersDesc)
{
Console.Write($"{n} ");
}
Console.WriteLine();
// 예시: 객체 리스트 쿼리 및 새로운 형태(익명 타입)로 반환
List<Product> products = new List<Product>
{
new Product { Name = "Laptop", Price = 1200 },
new Product { Name = "Mouse", Price = 25 },
new Product { Name = "Keyboard", Price = 75 },
new Product { Name = "Monitor", Price = 300 }
};
// 가격이 100 이상인 제품의 이름만 선택하여 정렬
var expensiveProductNames = from p in products
where p.Price >= 100
orderby p.Name
select p.Name; // 이름(string)만 선택
Console.WriteLine("\n가격 100 이상 제품 이름 (오름차순):");
foreach(string name in expensiveProductNames)
{
Console.WriteLine(name);
}
// 가격과 이름을 포함하는 익명 타입으로 반환
var productInfo = from p in products
where p.Price < 100
select new { ProductName = p.Name, ProductPrice = p.Price }; // 익명 타입 생성
Console.WriteLine("\n가격 100 미만 제품 정보:");
foreach(var info in productInfo)
{
Console.WriteLine($"Name: {info.ProductName}, Price: ${info.ProductPrice}");
}
}
}
public class Product
{
public string Name { get; set; }
public int Price { get; set; }
}여러 개의 데이터 원본에 질의하기
LINQ는 하나의 쿼리 식 내에서 여러 데이터 원본을 다룰 수 있습니다. 가장 간단한 방법은 여러 개의 from 절을 사용하는 것입니다.
여러 from 절을 사용하면 각 데이터 원본의 요소들을 조합하는 Cross Join(카티전 곱)과 유사한 효과가 나타납니다. 즉, 첫 번째 데이터 원본의 각 요소에 대해 두 번째 데이터 원본의 모든 요소가 차례로 결합됩니다. 보통은 where 절을 함께 사용하여 의미 있는 조합만 필터링합니다.
두 개의 숫자 리스트에서 합이 10이 되는 조합 찾기
using System;
using System.Collections.Generic;
using System.Linq;
public class MultipleFromExample
{
public static void Main(string[] args)
{
List<int> listA = new List<int> { 1, 2, 3 };
List<int> listB = new List<int> { 7, 8, 9 };
// 여러 from 절을 사용하여 조합 생성 후 필터링
var sumIsTenPairs = from a in listA // 첫 번째 데이터 소스
from b in listB // 두 번째 데이터 소스 (a의 각 요소에 대해 b 전체 반복)
where a + b == 10 // 두 요소의 합이 10인 조건
select new { NumA = a, NumB = b }; // 결과 형태 (익명 타입)
Console.WriteLine("두 리스트에서 합이 10이 되는 조합:");
foreach (var pair in sumIsTenPairs)
{
Console.WriteLine($"({pair.NumA}, {pair.NumB})");
}
// 예시: Categories와 Products (간단 버전)
List<Category> categories = new List<Category>
{
new Category { Id = 1, Name = "Electronics" },
new Category { Id = 2, Name = "Food" }
};
List<ProductSimple> products = new List<ProductSimple>
{
new ProductSimple { Name = "Laptop", CategoryId = 1 },
new ProductSimple { Name = "Apple", CategoryId = 2 },
new ProductSimple { Name = "Monitor", CategoryId = 1 }
};
// 여러 from과 where를 사용한 조인 효과 (Join 연산자 사용이 더 효율적)
var productsWithCategoryName = from c in categories
from p in products
where c.Id == p.CategoryId // 연결 조건
select new { ProductName = p.Name, CategoryName = c.Name };
Console.WriteLine("\n제품과 카테고리 이름:");
foreach(var item in productsWithCategoryName)
{
Console.WriteLine($"{item.ProductName} ({item.CategoryName})");
}
}
}
public class Category { public int Id { get; set; } public string Name { get; set; } }
public class ProductSimple { public string Name { get; set; } public int CategoryId { get; set; } }여러 from 절은 데이터 원본 간의 관계를 명시적으로 표현하지 않기 때문에, 관계형 데이터베이스의 테이블처럼 명확한 관계가 있는 데이터를 연결할 때는 join 연산자를 사용하는 것이 더 효율적이고 의미가 명확합니다.
group by로 데이터 분류하기
group by 절은 특정 키(Key) 값을 기준으로 데이터 요소들을 그룹화합니다. SQL의 GROUP BY와 유사한 기능을 수행합니다.
group A by B: ‘A’ 요소들을 ‘B’ 키 값을 기준으로 그룹화합니다.- 결과는
IEnumerable<IGrouping<TKey, TElement>>형태가 됩니다. 각IGrouping객체는Key속성(그룹화 기준 값)과 해당 키에 속하는 요소들의 시퀀스를 가집니다.
학생들을 학년별로 그룹화하기
using System;
using System.Collections.Generic;
using System.Linq;
public class Student
{
public string Name { get; set; }
public int Grade { get; set; }
}
public class GroupByExample
{
public static void Main(string[] args)
{
List<Student> students = new List<Student>
{
new Student { Name = "Alice", Grade = 1 },
new Student { Name = "Bob", Grade = 2 },
new Student { Name = "Charlie", Grade = 1 },
new Student { Name = "David", Grade = 2 },
new Student { Name = "Eve", Grade = 1 }
};
// 학년(Grade)을 기준으로 학생들을 그룹화
var studentsByGrade = from student in students
group student by student.Grade; // student 객체를 Grade 속성 값으로 그룹화
Console.WriteLine("학년별 학생 목록:");
// 그룹 결과 순회 (각 group은 IGrouping<int, Student>)
foreach (var gradeGroup in studentsByGrade)
{
Console.WriteLine($"\n{gradeGroup.Key}학년:"); // Key는 그룹화 기준 값 (학년)
// 각 그룹 내의 학생 순회
foreach (var student in gradeGroup)
{
Console.WriteLine($"- {student.Name}");
}
}
// group by와 select를 함께 사용하여 결과 형태 변형
var gradeCounts = from student in students
group student by student.Grade into gradeGroup // 그룹 결과를 gradeGroup 변수에 저장
orderby gradeGroup.Key // 학년 순으로 정렬
select new
{
Grade = gradeGroup.Key,
Count = gradeGroup.Count() // 각 그룹의 학생 수 계산
};
Console.WriteLine("\n학년별 학생 수:");
foreach (var gc in gradeCounts)
{
Console.WriteLine($"{gc.Grade}학년: {gc.Count}명");
}
}
}group by는 데이터를 요약하거나 분류하는 데 매우 유용합니다.
두 데이터 원본을 연결하는 join
관계형 데이터베이스에서처럼, LINQ에서도 서로 다른 데이터 원본(컬렉션)의 요소들을 공통된 키(Key)를 기준으로 연결(Join)할 수 있습니다. join 절을 사용합니다.
join A in dataSourceB on keyA equals keyB
join: 조인 연산을 시작합니다.A: 두 번째 데이터 원본(dataSourceB)의 범위 변수입니다.in dataSourceB: 조인할 두 번째 데이터 원본입니다.on keyA equals keyB: 조인 조건입니다. 첫 번째 데이터 원본 요소의 키(keyA)와 두 번째 데이터 원본 요소의 키(keyB)가 같은 요소를 연결합니다.equals키워드 사용에 주의하세요 (==아님).
내부 조인 (Inner Join)
내부 조인은 두 데이터 원본 모두에서 일치하는 키를 가진 요소들만 결과에 포함시킵니다. 어느 한쪽에라도 일치하는 키가 없으면 해당 요소는 결과에서 제외됩니다.
카테고리 ID를 기준으로 카테고리 목록과 제품 목록을 조인하기
using System;
using System.Collections.Generic;
using System.Linq;
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
public class InnerJoinExample
{
public static void Main(string[] args)
{
List<Category> categories = new List<Category>
{
new Category { Id = 1, Name = "Electronics" },
new Category { Id = 2, Name = "Food" },
new Category { Id = 3, Name = "Clothing" } // 이 카테고리에 해당하는 제품 없음
};
List<Product> products = new List<Product>
{
new Product { Name = "Laptop", CategoryId = 1 },
new Product { Name = "Apple", CategoryId = 2 },
new Product { Name = "Monitor", CategoryId = 1 },
new Product { Name = "Orange", CategoryId = 2 },
new Product { Name = "Smartphone", CategoryId = 99 } // 존재하지 않는 카테고리 ID
};
// 내부 조인 (Inner Join)
var innerJoinResult = from c in categories
join p in products on c.Id equals p.CategoryId // c.Id와 p.CategoryId가 같은 것만 연결
select new { CategoryName = c.Name, ProductName = p.Name };
Console.WriteLine("내부 조인 결과 (카테고리와 제품 매칭):");
foreach (var item in innerJoinResult)
{
Console.WriteLine($"{item.CategoryName}: {item.ProductName}");
/- 출력:
Electronics: Laptop
Food: Apple
Electronics: Monitor
Food: Orange
*/
// Clothing 카테고리(제품 없음)와 Smartphone(카테고리 없음)은 결과에 포함되지 않음
}
}
}외부 조인 (Outer Join)
외부 조인은 한쪽 데이터 원본의 모든 요소를 결과에 포함시키고, 다른 쪽 데이터 원본에서는 일치하는 키가 있는 경우 해당 요소를, 없는 경우 기본값(보통 null)을 사용하여 연결합니다. 왼쪽 외부 조인(Left Outer Join)이 가장 흔하게 사용됩니다.
LINQ 쿼리 식에서는 join ... into ... 구문과 DefaultIfEmpty() 메소드를 조합하여 외부 조인을 구현합니다.
join ... into tempGroup: 내부 조인과 유사하게 시작하지만, 조인 결과를 임시 그룹(tempGroup)에 저장합니다. 이 그룹은 첫 번째 원본의 각 요소에 대해 두 번째 원본에서 일치하는 모든 요소들을 포함합니다 (일치하는 것이 없으면 빈 그룹).from item in tempGroup.DefaultIfEmpty(): 각 임시 그룹(tempGroup)을 순회합니다. 만약 그룹이 비어 있다면(즉, 첫 번째 원본 요소와 일치하는 요소가 두 번째 원본에 없다면),DefaultIfEmpty()는 해당 타입의 기본값(참조 타입의 경우null)을 포함하는 단일 요소 시퀀스를 반환합니다.item변수에는 일치하는 요소 또는 기본값이 할당됩니다.
모든 부서를 표시하되, 해당 부서에 속한 직원이 있으면 함께 표시하고 없으면 부서 이름만 표시 (왼쪽 외부 조인)
using System;
using System.Collections.Generic;
using System.Linq;
public class Department
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Employee
{
public string Name { get; set; }
public int DepartmentId { get; set; }
}
public class LeftOuterJoinExample
{
public static void Main(string[] args)
{
List<Department> departments = new List<Department>
{
new Department { Id = 1, Name = "HR" },
new Department { Id = 2, Name = "Engineering" },
new Department { Id = 3, Name = "Sales" } // 이 부서에는 직원이 없음
};
List<Employee> employees = new List<Employee>
{
new Employee { Name = "Alice", DepartmentId = 1 },
new Employee { Name = "Bob", DepartmentId = 2 },
new Employee { Name = "Charlie", DepartmentId = 2 },
new Employee { Name = "David", DepartmentId = 99 } // 존재하지 않는 부서 ID
};
// 왼쪽 외부 조인 (Left Outer Join)
var leftOuterJoinResult = from d in departments
join e in employees on d.Id equals e.DepartmentId into empGroup // 부서 ID 기준으로 조인, 결과를 empGroup에
from emp in empGroup.DefaultIfEmpty() // empGroup이 비어있으면 emp는 null
select new
{
DepartmentName = d.Name,
EmployeeName = emp?.Name // emp가 null일 수 있으므로 Null 조건부 연산자(?) 사용
};
Console.WriteLine("왼쪽 외부 조인 결과 (모든 부서와 해당 직원):");
foreach (var item in leftOuterJoinResult)
{
Console.WriteLine($"{item.DepartmentName}: {item.EmployeeName ?? "(No employees)"}"); // ?? (Null 병합 연산자) 사용
}
}
}LINQ의 비밀과 LINQ 표준 연산자
LINQ가 마법처럼 보이는 이면에는 잘 정의된 메커니즘이 있습니다.
LINQ의 비밀 (동작 원리)
확장 메소드 (Extension Methods) 와 표준 쿼리 연산자 (Standard Query Operators - SQOs):
- 우리가 사용하는
from,where,select등의 쿼리 식 구문(Query Expression Syntax)은 사실 컴파일러에 의해 메소드 구문(Method Syntax)으로 변환됩니다. - 이 메소드들은
System.Linq네임스페이스의Enumerable클래스(메모리 내 컬렉션 용)와Queryable클래스(데이터베이스 등 외부 소스 용)에 정의된 확장 메소드들입니다. 이 메소드들을 표준 쿼리 연산자(SQO)라고 부릅니다. - 예를 들어,
from c in customers where c.City == "London" select c.Name쿼리는 컴파일러에 의해customers.Where(c => c.City == "London").Select(c => c.Name)와 같은 메소드 호출 체인으로 번역됩니다. 람다식이 여기서 핵심적인 역할을 합니다.
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 }; // 쿼리 식 구문 var querySyntax = from n in numbers where n > 2 orderby n descending select n * n; // 메소드 구문 (컴파일러가 변환하는 형태) var methodSyntax = numbers.Where(n => n > 2) .OrderByDescending(n => n) .Select(n => n * n); // 두 결과는 동일합니다. Console.WriteLine("Query Syntax: " + string.Join(", ", querySyntax)); // 출력: 25, 16, 9 Console.WriteLine("Method Syntax: " + string.Join(", ", methodSyntax)); // 출력: 25, 16, 9- 우리가 사용하는
지연 실행 (Deferred Execution):
- LINQ 쿼리는 정의되는 시점에 즉시 실행되지 않습니다.
var query = ...라인에서는 쿼리 계획만 세워집니다. - 쿼리의 실제 실행은 결과가 필요한 시점(주로
foreach루프를 통해 열거될 때, 또는.ToList(),.ToArray(),.Count(),.First()등 결과를 즉시 반환하는 메소드가 호출될 때)까지 지연됩니다. - 효율성: 필요 없는 데이터를 미리 메모리에 로드하지 않습니다. 전체 데이터를 처리할 필요가 없는 경우(예:
.First()사용) 성능상 이득입니다. - 항상 최신 데이터: 쿼리가 실행되는 시점의 데이터 소스 상태를 반영합니다. 쿼리 정의 후 데이터 소스가 변경되면, 실행 시 변경된 내용이 반영됩니다.
List<int> data = new List<int> { 1, 2, 3 }; // 쿼리 정의 (아직 실행 안 됨) var query = from d in data where d > 1 select d; Console.WriteLine("쿼리 정의 후 데이터 추가 전"); // 아직 쿼리 실행 안됨 // 데이터 소스 변경 data.Add(4); data.Add(5); Console.WriteLine("쿼리 실행 시점:"); // 여기서 쿼리가 실제로 실행됨 foreach (var item in query) { Console.Write($"{item} "); // 출력: 2 3 4 5 (추가된 4, 5 포함) } Console.WriteLine();- LINQ 쿼리는 정의되는 시점에 즉시 실행되지 않습니다.
LINQ 표준 쿼리 연산자 (Standard Query Operators - SQOs):
LINQ는 Where, Select, OrderBy, GroupBy, Join 외에도 다양한 작업을 위한 약 50여 개의 표준 쿼리 연산자를 제공합니다. 주요 연산자 그룹은 다음과 같습니다.
- 필터링 (Filtering):
Where,OfType - 프로젝션 (Projection):
Select,SelectMany - 정렬 (Ordering):
OrderBy,OrderByDescending,ThenBy,ThenByDescending,Reverse - 그룹화 (Grouping):
GroupBy,ToLookup - 조인 (Joining):
Join,GroupJoin - 연결 (Concatenation):
Concat - 집합 연산 (Set Operations):
Distinct,Union,Intersect,Except - 분할 (Partitioning):
Take,Skip,TakeWhile,SkipWhile - 변환 (Conversion):
ToList,ToArray,ToDictionary,AsEnumerable,Cast - 요소 연산 (Element Operations):
First,FirstOrDefault,Last,LastOrDefault,Single,SingleOrDefault,ElementAt,ElementAtOrDefault,DefaultIfEmpty - 생성 (Generation):
Empty,Range,Repeat - 수량자 (Quantifiers):
Any,All,Contains - 집계 (Aggregation):
Count,LongCount,Sum,Min,Max,Average,Aggregate
이러한 표준 연산자들을 메소드 구문으로 직접 사용하면 쿼리 식 구문보다 더 복잡하거나 세밀한 쿼리 로직을 구현할 수 있습니다.