1. 소개
고전적인 문제를 상상해봐: 논리적으로 연결된 두 개의 컬렉션이 있어. 예를 들어, 상품 카테고리 리스트와 실제 상품 리스트. 각 카테고리마다 그 카테고리에 속한 모든 상품을 가져와야 해. 또는 회사 부서 리스트와 직원 리스트가 있는데, 각 부서별로 모든 직원을 출력해야 할 때도 있지.
SQL에서는 이걸 "그룹 조인"(GROUP JOIN 또는 더 정확히는 LEFT OUTER JOIN + 그룹핑)이라고 해. LINQ에서는 이걸 위한 특별한 연산자 GroupJoin이 있어. 이건 일반 Join처럼 왼쪽의 각 레코드에 오른쪽의 하나만 붙이는 게 아니라, 키로 그룹핑하는 거랑 중간쯤 되는 느낌이야. GroupJoin은 한 컬렉션의 각 요소를 두 번째 컬렉션에서 연결된 모든 요소 컬렉션 형태로 묶어줘.
비유
일반 Join이 “아빠와 아들”을 성으로 연결해서 쌍을 만드는 거라면, GroupJoin은 각 아빠 밑에 모든 자식 리스트를 붙여서 트리 구조를 만드는 거야.
도식적으로
카테고리 상품
+--------------+ +---------------------+
| Id | 이름 | | 이름 | CatId |
+----+---------+ +-----------+---------+
| 1 | 빵 | ---> | 바톤 | 1 |
| 2 | 음료 | | 소시지 | 3 |
| 3 | 고기 | | 펩시 | 2 |
| | | | 차 | 2 |
+----+---------+ +-----------+---------+
GroupJoin 후:
- 빵 — [바톤]
- 음료 — [펩시, 차]
- 고기 — [소시지]
2. 메서드 시그니처와 기본 개념
확장 메서드
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // "외부" 컬렉션 (예: 카테고리)
IEnumerable<TInner> inner, // "내부" 컬렉션 (예: 상품)
Func<TOuter, TKey> outerKeySelector, // 외부 요소에서 키 추출
Func<TInner, TKey> innerKeySelector, // 내부 요소에서 키 추출
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // 결과 객체/레코드 만드는 팩토리
)
- outer: 순회할 컬렉션, 여기에 요소들이 붙음 (예: 카테고리).
- inner: 붙일 요소를 고르는 컬렉션 (예: 상품).
- outerKeySelector: "왼쪽" 요소에서 키를 반환하는 람다.
- innerKeySelector: "오른쪽" 요소에서 키를 반환하는 람다.
- resultSelector: 각 쌍(왼쪽+오른쪽 그룹)에 대해 결과가 어떻게 생길지 정하는 함수.
3. 실전 예제: 카테고리와 상품
예를 들어, 이런 모델이 있다고 해보자:
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; }
}
예제용 컬렉션:
var categories = new List<Category>
{
new Category { Id = 1, Name = "빵" },
new Category { Id = 2, Name = "음료" },
new Category { Id = 3, Name = "고기" }
};
var products = new List<Product>
{
new Product { Name = "바톤", CategoryId = 1 },
new Product { Name = "펩시", CategoryId = 2 },
new Product { Name = "차", CategoryId = 2 },
new Product { Name = "소시지", CategoryId = 3 }
};
GroupJoin 사용하기 (메서드 문법)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // 카테고리 키
product => product.CategoryId, // 상품 키
(category, prods) => new // 결과 즉석 생성
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // 이 카테고리의 상품 이름 리스트
}
);
결과를 순회하는 방법:
foreach (var group in groupJoin)
{
Console.WriteLine($"카테고리: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
출력:
카테고리: 빵
- 바톤
카테고리: 음료
- 펩시
- 차
카테고리: 고기
- 소시지
4. GroupJoin: 쿼리 문법 (Query Syntax)
LINQ는 SQL과 비슷한 문법도 지원해. group join에는 join ... into ... 키워드를 쓰고, 이 쿼리는 위 예제랑 거의 똑같이 동작해.
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
이건 LEFT OUTER JOIN ... GROUP BY가 들어간 SQL 쿼리랑 거의 똑같아.
시각적 도식: GroupJoin 동작 방식
[카테고리] [상품] 그룹핑 (GroupJoin)
빵 --------> 바톤 => 빵: [바톤]
음료 --------> 펩시 => 음료: [펩시, 차]
음료 --------> 차
고기 --------> 소시지 => 고기: [소시지]
각 카테고리는 자기만의 “주머니”(IEnumerable<Product>)를 받아서, 거기에 해당 카테고리의 모든 상품이 들어가.
5. 특징과 주의할 점
GroupJoin vs. 일반 Join
일반 Join과 GroupJoin의 차이는 결과 개수야. Join은 매치마다 한 쌍씩 반환하지만, GroupJoin은 외부 컬렉션의 각 요소마다 하나씩 반환하고, 그 안에 매치된 모든 요소가 컬렉션으로 들어가.
만약 카테고리에 상품이 하나도 없으면, GroupJoin에서는 그 카테고리도 결과에 나오고, 상품 컬렉션만 비어 있어. 이건 SQL의 LEFT OUTER JOIN (왼쪽 외부 조인)과 똑같은 동작이야.
상품이 없는 카테고리 예시:
categories.Add(new Category { Id = 4, Name = "치즈" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"카테고리: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (상품 없음)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
카테고리: 빵
- 바톤
카테고리: 음료
- 펩시
- 차
카테고리: 고기
- 소시지
카테고리: 치즈
(상품 없음)
이런 시나리오는 비즈니스 앱에서 자주 필요해: 모든 카테고리(혹은 그룹)를 보여줘야 하는데, 어떤 그룹엔 요소가 하나도 없을 수도 있거든.
GroupBy로 구현? 노노!
많은 사람들이 GroupBy 두 번 써서 GroupJoin을 “에뮬레이션”하려고 하는데, 그럴 필요 없어 — GroupJoin이 바로 이걸 위해 만들어졌고, “왼쪽 조인”을 네이티브로 해줘.
6. 실제 데이터로 활용하기
이전 강의에 추가로, 우리 학습용 앱에 “각 카테고리별 상품 리스트” 리포트 출력 기능을 넣어보자. 이런 건 쇼핑몰, CRM, 회계/리포트 시스템에서 자주 필요해.
데모 앱에 코드 추가:
// Category, Product 클래스와 컬렉션이 이미 있다고 가정
Console.WriteLine("카테고리별 상품 리포트:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"카테고리: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (상품 없음)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. 중첩 그룹핑과 집계 함수 활용
GroupJoin은 집계 함수랑 조합해서 더 복잡한 리포트도 만들 수 있어.
예시: 각 카테고리별 상품 개수 세기
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // 집계 함수!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count}개 상품");
}
컬렉션 categories와 products에 이런 데이터가 있다고 해보자:
var categories = new[]
{
new { Id = 1, Name = "과일" },
new { Id = 2, Name = "채소" },
new { Id = 3, Name = "유제품" }
};
var products = new[]
{
new { Id = 1, Name = "사과", CategoryId = 1 },
new { Id = 2, Name = "바나나", CategoryId = 1 },
new { Id = 3, Name = "당근", CategoryId = 2 }
};
콘솔 출력은 이렇게 나와:
카테고리: 과일 — 2개 상품
카테고리: 채소 — 1개 상품
카테고리: 유제품 — 0개 상품
8. GroupJoin과 초보자들이 자주 하는 실수
흔한 실수 — GroupJoin 결과가 일반 Join처럼 플랫 테이블 쌍이라고 기대하는 거야. 여기서 "플랫"이란 각 행이 외부 요소 하나와 매치된 내부 요소 하나로 이루어진 구조(즉, INNER JOIN 후 SQL 테이블처럼)라는 뜻이야.
DB를 좀 만져본 사람들은 GroupJoin이 LEFT JOIN처럼 동작하길 기대하는데, 실제로는 쌍이 아니라 그룹을 반환해. 즉, 외부 컬렉션의 요소 + 관련된 내부 요소 컬렉션 — 즉, 중첩 구조야.
필요하면 중첩 컬렉션을 "펼쳐서" 써야 해 — 예를 들어, SelectMany로 평평한 쌍 시퀀스를 만들 수 있어.
또 다른 흔한 실수 — 매치가 없는 요소의 그룹이 그냥 빈 리스트라는 걸 잊는 거야. 이건 기본 동작이고, 버그가 아니야 — 근데 이걸 몰라서 출력에 "아무것도 안 나와서" 당황할 수 있어.
GroupJoin을 언제 쓰고, 언제 안 써야 할까?
GroupJoin을 써야 할 때:
- 두 데이터셋(예: 부서와 직원, 카테고리와 상품)이 있고, 각 “부모”별로 모든 “자식”을 계층적으로 보여줘야 할 때.
- 복잡한 리포트에서, “자식”이 없어도 모든 메인 요소를 보여줘야 할 때.
- SQL LEFT OUTER JOIN + 키로 그룹핑이 필요할 때.
그냥 컬렉션을 교집합하거나, 매치마다 한 쌍만 필요하면 GroupJoin 말고 일반 Join을 써.
GO TO FULL VERSION