1. 중첩 groupingBy: 문법과 동작 원리
현실에서는 데이터를 하나의 기준으로만 그룹화하면 충분하지 않은 경우가 많습니다. 예를 들어, 회사의 직원 목록이 있다면 각 부서별 인원 수만이 아니라 각 부서의 각 직무별 인원 수도 알고 싶어집니다. 또는 학생 데이터베이스가 있다면 — 각 전공의 각 학년(코스)별 학생 수가 궁금할 수 있습니다.
중첩 그룹화는 이런 “지도 안의 지도” — “부서 → 직무 → 직원 목록”, 또는 “학년 → 전공 → 학생 목록” 구조를 만들 수 있게 해줍니다.
Stream API가 없다면 이런 과제는 여러 중첩 루프와 수동으로 Map 안에 Map을 구성해서 해결해야 했을 것입니다. 스트림과 컬렉터를 사용하면 한두 줄로 끝납니다.
중첩 그룹화란 Collectors.groupingBy 메서드의 두 번째 인수로 또 다른 컬렉터(예: 또 하나의 groupingBy)를 전달하는 것입니다. 그 결과 각 키의 값이 또 다른 맵인 구조가 생성됩니다.
일반 패턴
Map<Klyuch1, Map<Klyuch2, List<T>>> result =
stream.collect(Collectors.groupingBy(
obekt -> klyuch1,
Collectors.groupingBy(obekt -> klyuch2)
));
예시: 부서와 직무로 직원 그룹화
다음과 같은 클래스가 있다고 합시다:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... 생성자, 게터, toString
}
직원 목록:
List<Employee> employees = List.of(
new Employee("이반", "IT", "개발자", 120_000),
new Employee("마리야", "IT", "테스터", 90_000),
new Employee("표트르", "HR", "매니저", 80_000),
new Employee("올가", "IT", "개발자", 130_000),
new Employee("스베틀라나", "HR", "리크루터", 70_000)
);
부서와 직무로 그룹화:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
무엇을 얻었나요?
키는 부서, 값은 또 다른 맵(키는 직무, 값은 직원 목록)인 구조의 맵입니다.
구조 시각화
IT:
개발자: [이반, 올가]
테스터: [마리야]
HR:
매니저: [표트르]
리크루터: [스베틀라나]
2. 그룹화와 집계 결합: groupingBy + 집계기
중첩 그룹화는 리스트를 모으는 데서 끝나지 않습니다! 각 하위 그룹 내부에서 바로 집계를 수행할 수 있습니다.
예시: 부서별 최대 급여
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
여기서는 각 부서에 대해 최대 급여를 받는 직원을 얻습니다(해당 부서가 비어 있을 수 있으므로 결과는 Optional로 감쌉니다).
중첩 그룹화 + 집계
각 부서의 각 직무별 최대 급여를 알고 싶다고 가정해 봅시다:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
이게 의미하는 바는?
- 각 부서마다 직무 맵이 있습니다.
- 각 직무에는 최대 급여를 받는 직원이 들어갑니다(아무도 없으면 비어 있는 Optional).
3. 변환을 포함한 그룹화: mapping을 groupingBy 내부에서 사용
가끔은 객체를 그냥 그룹화하는 대신, 그룹 내에서 특정 필드만 뽑아내고 싶을 때가 있습니다.
예시: 부서별 직원 이름
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
결과:
IT: [이반, 마리야, 올가]
HR: [표트르, 스베틀라나]
중첩 mapping
mapping을 중첩 groupingBy와 결합할 수 있습니다:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
결과:
IT:
개발자: [이반, 올가]
테스터: [마리야]
HR:
매니저: [표트르]
리크루터: [스베틀라나]
4. 그룹화 + 수치 데이터 집계
종종 단순 그룹화가 아니라, 그룹별 합계, 평균, 개수 등을 계산해야 합니다.
예시: 부서별 평균 급여
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
중첩 집계
각 부서의 각 직무별 평균 급여:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + 집계
불리언 조건으로 컬렉션을 두 그룹으로 나누고, 각 그룹 내부에서 집계를 하는 것이 유용할 때가 있습니다.
예시: 각 부서에서 급여가 100_000을 초과하는 직원 수
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
결과:
각 부서마다 true/false → 직원 수 형태의 맵을 얻습니다.
6. 실전 과제: 중첩 그룹화 적용
과제 1. 학년(코스)과 전공별 학생
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... 게터, 생성자
}
List<Student> students = ... // 이미 있다고 가정
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
과제 2. 코스별 평균 성적
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
과제 3. 그룹별로 이름만
Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(
Student::getSpeciality,
Collectors.mapping(Student::getName, Collectors.toList())
)
));
7. 유용한 팁
중첩된 Map에서 데이터를 읽고 꺼내는 방법
처음에는 중첩 맵을 다루는 것이 낯설 수 있습니다. 기본 예시는 다음과 같습니다:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("부서: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" 직무: " + pos + " -> " + emps);
}
}
중첩 그룹화 도식
Map<부서, Map<직무, List<Employee>>>
│
├── "IT"
│ ├── "개발자" → [이반, 올가]
│ └── "테스터" → [마리야]
└── "HR"
├── "매니저" → [표트르]
└── "리크루터" → [스베틀라나]
표: 조합별로 어떤 결과가 나오는가
| 컬렉터 | 결과 |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. 중첩 그룹화에서 흔한 실수
오류 №1: 중첩 Map 구조를 잘못 이해함.
중첩 그룹화 후에는 각 맵의 값에 정확히 무엇이 들어있는지 헷갈리기 쉽습니다. 항상 결과의 시그니처를 확인하세요 — IDE가 타입을 알려줍니다. 확신이 없다면 System.out.println(grouped)로 결과를 출력하거나 디버거를 사용하세요.
오류 №2: 데이터를 꺼낼 때 NullPointerException.
키가 없으면(예: 해당 부서에 특정 직무의 직원이 없는 경우) Map.get(key)는 null을 반환합니다. containsKey로 키 존재를 확인하거나, Java 8부터는 Map.getOrDefault를 사용할 수 있고, Java 9부터는 Map.ofNullable과 Optional의 메서드를 사용할 수 있습니다.
오류 №3: 지나치게 깊은 중첩.
그룹화가 너무 깊어지면(3–4단계) 데이터 구조를 재고하거나 문제를 더 작은 단계로 나누는 것이 좋습니다.
오류 №4: 잘못된 레벨에서의 집계.
집계 컬렉터(averagingInt, counting)를 필요한 groupingBy 내부가 아니라 바깥에 두어 예상치 못한 결과를 얻는 경우가 있습니다. 괄호를 항상 주의 깊게 배치하세요!
오류 №5: collect 내부에서 요소를 변경하려는 시도.
그룹화 과정에서 원본 컬렉션이나 객체를 변경하지 마세요 — 찾기 어려운 버그의 원인이 될 수 있습니다.
GO TO FULL VERSION