CodeGym /행동 /JAVA 25 SELF /고급 집계: 중첩 그룹화

고급 집계: 중첩 그룹화

JAVA 25 SELF
레벨 31, 레슨 3
사용 가능

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"
             ├── "매니저"   → [표트르]
             └── "리크루터"   → [스베틀라나]

표: 조합별로 어떤 결과가 나오는가

컬렉터 결과
groupingBy(Employee::getDepartment)
Map<String, List<Employee>>
groupingBy(Employee::getDepartment, averagingInt(...))
Map<String, Double>
groupingBy(Employee::getDepartment, groupingBy(...))
Map<String, Map<String, List<Employee>>>
groupingBy(..., mapping(..., toList()))
Map<..., List<...>>
groupingBy(..., groupingBy(..., mapping(..., toList())))
Map<..., Map<..., List<...>>>

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 내부에서 요소를 변경하려는 시도.
그룹화 과정에서 원본 컬렉션이나 객체를 변경하지 마세요 — 찾기 어려운 버그의 원인이 될 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 31, 레슨 3
잠금
회사 조직 구조 구성 📊
회사 조직 구조 구성 📊
1
과제
JAVA 25 SELF, 레벨 31, 레슨 3
잠금
명문 대학 우등생 보고서 ✨
명문 대학 우등생 보고서 ✨
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION