1. 소개
이제 진짜 재밌고 실무에서 자주 쓰는 주제로 넘어가자: LINQ의 집합 연산 — Union, Intersect, Except. 이걸로 컬렉션을 마치 집합론 하듯이 다룰 수 있어 (아니면 개발자 책상 위에 스티커 두 묶음 세는 거랑 비슷하다고 생각해도 돼 — 거의 똑같지). 혹시 집합론을 한 번도 안 들어봤거나 까먹었다면 좀 무섭게 들릴 수도 있는데, 사실 엄청 쉬운 개념이야: 두 개의 과일 자루가 있다고 생각해봐. 집합론은 두 자루에 어떤 과일이 같이 있고, 어떤 건 한 쪽에만 있고, 합치면 뭐가 나오는지 알아내는 방법이야. 스티커 놀이랑 똑같아: 다 합치거나, 공통된 것만 찾거나, 한 쪽에서만 있는 걸 빼거나. 그게 다야.
실습: 이 메서드들은 두 개의 다른 쿼리 결과를 합치거나, 공통된 요소를 찾거나, 컬렉션 간의 차이를 알아낼 때 진짜 편해. 예를 들면:
- 판매 목록이랑 구매 목록에 있는 모든 상품의 전체 리스트 만들기.
- 두 리스트에 모두 있는 상품 찾기 — 공통 항목.
- 아니면 창고에만 있고 한 번도 팔린 적 없는 상품 찾기 — 누가 창고에서 썩고 있네!
실제 문제: 집합 연산은 어디서나 써: 구독자 필터링, 여러 그룹에 속한 유저 찾기, 유니크하거나 겹치는 주문 찾기, 서로 다른 쿼리 결과 비교 등등 진짜 많아.
2. Union 연산 — 컬렉션 합치기
진짜 간단하니까 바로 실습 들어가자. 예를 들어, 두 개의 상품 리스트가 있다고 해보자:
List<string> warehouseProducts = new List<string> { "우유", "빵", "치즈", "계란" };
List<string> recentlySold = new List<string> { "빵", "치즈", "살라미", "차" };
Union이 뭐 하는 거야?
Union은 적어도 하나의 컬렉션에 있는 유니크한 요소들을 반환해.
즉, 합집합이야.
var allProducts = warehouseProducts.Union(recentlySold);
foreach (var product in allProducts)
{
Console.WriteLine(product);
}
// 출력: 우유, 빵, 치즈, 계란, 살라미, 차
이걸 시각적으로 보면 이렇게 돼:
| 창고 | 판매됨 | Union (합집합) |
|---|---|---|
| 우유 | 빵 | 우유 |
| 빵 | 치즈 | 빵 |
| 치즈 | 살라미 | 치즈 |
| 계란 | 차 | 계란 |
| 살라미 | ||
| 차 |
집합론 용어로 Union은 "or" 연산이야: 어디든 있으면 다 줘. 차 종류가 다른 두 상자가 있다고 생각해봐 — 모든 맛을 다 마셔보고 싶으면, 중복된 차가 있어도 한 번만 마시게 돼.
Union은 자동으로 중복을 제거해 (요소 타입의 Equals랑 GetHashCode 구현에 따라). 만약 네가 직접 만든 클래스(예: Product)로 합집합을 쓴다면, 이 메서드들이 제대로 구현되어 있는지 꼭 확인해야 해. 아니면 Union이 이상하게 동작해서 똑같은 요소가 다르게 취급될 수도 있어!
꼭 기억해: 결과 컬렉션의 순서는 첫 번째 컬렉션의 순서를 유지하고, 새로운 요소는 두 번째 컬렉션에서 처음 나오는 순서대로 뒤에 붙어.
객체 예시
우리 "상점" 앱 계속 해보자. Product 리스트 두 개가 있어:
public class Product
{
public string Name { get; set; }
public string Category { get; set; }
// Union/Intersect/Except 제대로 쓰려면 Equals랑 GetHashCode 잘 구현해야 해!
public override bool Equals(object? obj) =>
obj is Product other && Name == other.Name && Category == other.Category;
public override int GetHashCode() => HashCode.Combine(Name, Category);
}
List<Product> stock = new()
{
new Product { Name = "우유", Category = "유제품" },
new Product { Name = "빵", Category = "빵류" },
};
List<Product> sold = new()
{
new Product { Name = "빵", Category = "빵류" },
new Product { Name = "살라미", Category = "소시지" },
};
var all = stock.Union(sold);
// 주의: 요소가 "겉보기엔" 똑같아도,
// 논리적으로 같으면 중복되지 않아.
foreach (var p in all)
Console.WriteLine($"{p.Name} ({p.Category})");
자주 하는 실수: Equals/GetHashCode를 오버라이드 안 하면, Union이 값이 똑같은 인스턴스도 다르게 봐!
3. Intersect 연산 — 컬렉션 교집합
Intersect는 모든 원본 컬렉션에 있는 요소만 반환해. 집합론에서 "and" 연산이야.
예시
아까 썼던 리스트 다시 써보자:
var commonProducts = warehouseProducts.Intersect(recentlySold);
foreach (var product in commonProducts)
{
Console.WriteLine(product);
}
// 출력: 빵, 치즈
시각화:
| 창고 | 판매됨 | Intersect (공통) |
|---|---|---|
| 빵 | 빵 | 빵 |
| 치즈 | 치즈 | 치즈 |
어디에 쓸까?
- 오늘 창고에서 팔린 상품이 뭔지 알고 싶을 때.
- 면접에서 "두 리스트의 교집합을 어떻게 찾을래?"라고 물어보면 — LINQ면 한 줄이면 끝!
- 비즈니스 앱에서 여러 조건을 동시에 만족하는 필터링이 필요할 때 자주 써.
특징과 자주 하는 실수
컬렉션에 요소가 여러 번 있어도 결과엔 한 번만 나와.
복잡한 객체(예: Product 클래스)는 Equals/GetHashCode 구현이 또 중요해.
객체 예시
var commonObjects = stock.Intersect(sold);
foreach (var p in commonObjects)
Console.WriteLine($"{p.Name} ({p.Category})");
// 출력: 빵 (빵류) 만 나옴
4. Except 연산 — 컬렉션 차집합
Except는 첫 번째 컬렉션에만 있고 두 번째엔 없는 요소만 반환해.
예시
var productsOnlyInStock = warehouseProducts.Except(recentlySold);
foreach (var product in productsOnlyInStock)
{
Console.WriteLine(product);
}
// 출력: 우유, 계란
즉, 창고에만 있는데 한 번도 팔린 적 없는 상품이야.
시각화:
| 창고 | 판매됨 | Except (창고만) |
|---|---|---|
| 우유 | 우유 | |
| 계란 | 계란 | |
| 빵 | 빵 | (생략) |
| 치즈 | 치즈 | (생략) |
비유
이미 보낸 문서는 다 빼고, 네 손에만 남아있는 문서만 남기는 거랑 똑같아.
객체 예시
var unsold = stock.Except(sold);
foreach (var p in unsold)
Console.WriteLine($"{p.Name} ({p.Category})");
// 출력: 우유 (유제품)
! 헷갈릴 수 있는 포인트
Except는 순서에 민감해: A.Except(B)랑 B.Except(A)는 완전 달라! 첫 번째 컬렉션이 "빼는 대상", 두 번째가 "뺄 것"이야.
5. LINQ로 여러 집합 연산 조합하기
한 번의 연산으론 부족할 때도 있어. 예를 들어, 창고에만 있거나 판매된 것에만 있는 상품(둘 다엔 없는 것), 즉 "대칭 차집합"을 구하고 싶을 때.
대칭 차집합 ("XOR" 집합):
var onlyInOne = warehouseProducts.Except(recentlySold)
.Union(recentlySold.Except(warehouseProducts));
foreach (var product in onlyInOne)
{
Console.WriteLine(product);
}
// 출력: 우유, 계란, 살라미, 차
복잡한 로직은 LINQ 메서드 조합이 편해:
// "입고 예정" 리스트에만 있고, 창고나 판매된 곳엔 없는 상품 찾기
List<string> expected = new() { "커피", "차", "우유" };
var onlyExpected = expected.Except(warehouseProducts.Union(recentlySold));
foreach (var product in onlyExpected)
Console.WriteLine(product);
// 출력: 커피
6. 커스텀 타입과 IEqualityComparer 사용하기
가끔 모든 필드로 비교할 필요 없이, 예를 들어 상품 이름만 중요하고 카테고리는 신경 안 쓸 때가 있어. 이럴 땐 LINQ 메서드에 IEqualityComparer<T> 파라미터를 넘겨서 비교 방식을 직접 정할 수 있어.
커스텀 비교자 예시:
class ProductNameComparer : IEqualityComparer<Product>
{
public bool Equals(Product? x, Product? y) => x?.Name == y?.Name;
public int GetHashCode(Product obj) => obj.Name.GetHashCode();
}
var comp = new ProductNameComparer();
var uniqueByName = stock.Union(sold, comp);
foreach (var p in uniqueByName)
Console.WriteLine(p.Name); // 우유, 빵, 살라미
이건 모델 전체의 Equals/GetHashCode를 바꾸고 싶지 않고, 한 번만 특정 규칙으로 비교하고 싶을 때 특히 좋아.
7. 시각적 도식과 표
정리해보자 (리스트 A와 B 기준):
| 연산 | 결과 |
|---|---|
|
A 또는 B에 있는 모든 것 (유니크한 요소). |
|
둘 다(A와 B)에 있는 모든 것. |
|
A에만 있고 B엔 없는 모든 것. |
도식 (Venn Diagram, 그림을 그릴 수 있다면):
[A] [B]
oooooooooo
oooooooo oooo
ooooo oo ooo
ooo ooo oo
oo o ooo
ooo ooo ooo
- Union: 두 원 안에 있는 모든 것.
- Intersect: 교집합(가운데)만.
- Except: A 원에만 있고 B랑 겹치지 않는 부분만.
8. Union, Intersect, Except 쓸 때 자주 하는 실수
실수 1: Equals랑 GetHashCode 없는 객체로 사용.
클래스에 이 메서드들이 오버라이드 안 되어 있으면, Union, Intersect, Except가 제대로 동작 안 해: 내용이 같은 객체도 다르게 취급돼. 그래서 결과가 엉뚱하게 나올 수 있어.
실수 2: IEqualityComparer 없이 일부 필드만 비교하려고 함.
예를 들어, 상품 이름만 비교하고 싶을 때 그냥 Intersect 쓰면 원하는 대로 안 돼. IEqualityComparer를 명시적으로 넘기지 않으면 결과가 기대랑 달라.
실수 3: 요소 순서에 대한 잘못된 기대.
많은 사람들이 결과 컬렉션이 합치거나 교집합한 순서를 유지한다고 생각하는데, 메서드마다 다 달라: Union은 첫 번째 컬렉션 순서를 유지하지만, Intersect랑 Except는 순서가 예측 불가야. 순서에 의존하지 않는 게 좋아.
실수 4: 큰 컬렉션에서 성능 무시.
데이터가 많으면 메서드가 느릴 수 있어. 미리 집계하거나 필터링하거나, HashSet 같은 해시 구조를 써서 연산을 빠르게 하는 걸 고려해.
GO TO FULL VERSION