CodeGym /행동 /JAVA 25 SELF /groupingBy 및 partitioningBy 메서드 (Collectors)

groupingBy 및 partitioningBy 메서드 (Collectors)

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

1. 소개

현실의 대부분 상황에서 데이터를 그룹화해야 합니다. 예를 들어 학생을 학년별로 나누거나, 상품을 카테고리별로 묶거나, 성인을 미성년자와 구분하는 등입니다.

Stream API 없이 이런 작업은 수동으로 처리했습니다. 컬렉션을 순회하고, 조건을 확인하고, 해당 Map의 올바른 목록에 추가하는 방식이죠. 다음은 전형적인 “올드스쿨” 코드입니다:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
    String dept = employee.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}

작동은 하지만 마치 국수를 병마다 손으로 분류하는 듯 보입니다. 만약 여러 기준으로 그룹화해야 한다면? 게다가 그룹별 합계까지 계산해야 한다면? 코드는 급격히 커지고 읽기 어려워집니다.

Stream API와 전용 컬렉터(groupingBy, partitioningBy)를 사용하면 이 모든 것을 한두 줄로 작성할 수 있고 — 진짜 Java 마법사처럼 보일 수 있습니다.

2. 컬렉터 groupingBy: 기준에 따른 그룹화

핵심 아이디어

groupingBy는 요소 스트림을 Map으로 바꾸는 컬렉터입니다. 이때 키는 분류 함수의 결과이고 값은 그 기준에 해당하는 요소 목록입니다.

시그니처:

Collectors.groupingBy(Function<T, K>)
  • T — 스트림의 요소 타입,
  • K — 함수가 반환하는 키(그룹)의 타입.

간단한 예: 부서별 직원 그룹화

다음과 같은 클래스가 있다고 합시다:

public class Employee {
    private final String name;
    private final String department;
    // ... 생성자와 getter
    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }
    public String getName() { return name; }
    public String getDepartment() { return department; }
}

그리고 직원 컬렉션:

List<Employee> employees = List.of(
    new Employee("알리사", "IT"),
    new Employee("밥", "HR"),
    new Employee("클라라", "IT"),
    new Employee("데니스", "Finance"),
    new Employee("에바", "HR")
);

부서별로 그룹화:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

무엇을 얻었나?

  • 키: 부서 이름(String).
  • 값: 해당 부서의 직원 목록(List<Employee>).

결과 출력:

byDepartment.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::getName).toList());
});

출력:

IT: [알리사, 클라라]
HR: [밥, 에바]
Finance: [데니스]

내부 동작 원리

먼저 스트림의 각 요소에 대해 키(예: getDepartment())를 계산합니다. 해당 키가 이미 Map에 있으면 그 리스트에 요소를 추가하고, 없으면 새 리스트를 만듭니다.

비유

편지를 폴더별로 분류한다고 생각해 보세요. 각 편지를 보고 해당 이름의 폴더가 있으면 거기에 넣고, 없으면 새 폴더를 만들어 넣습니다.

3. 컬렉터 partitioningBy: 두 그룹으로 분할

때로는 “값에 따라 여러 그룹으로 묶는” 것보다 논리 조건(true/false)으로 컬렉션을 두 부분으로 나누는 것이 필요합니다. 예를 들어 특정 임계값 이상의 급여를 받는 직원과 그렇지 않은 직원, 학생을 “합격/불합격”으로 나누는 경우입니다.

이를 위한 전용 컬렉터가 partitioningBy입니다.

시그니처:

Collectors.partitioningBy(Predicate<T>)

Predicate는 불리언 값을 반환하는 함수입니다.

예: 급여 수준으로 직원 분할

다음이 있다고 합시다:

public class Employee {
    private final String name;
    private final int salary;
    // ... 생성자와 getter
    public Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String getName() { return name; }
    public int getSalary() { return salary; }
}

그리고 목록:

List<Employee> employees = List.of(
    new Employee("알리사", 120_000),
    new Employee("밥", 80_000),
    new Employee("클라라", 150_000),
    new Employee("데니스", 95_000)
);

‘고소득’과 ‘기타’로 나눕니다:

Map<Boolean, List<Employee>> partitioned = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
  • true — 급여가 100_000보다 큰 직원.
  • false — 나머지.

출력:

System.out.println("고소득: " +
    partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("기타: " +
    partitioned.get(false).stream().map(Employee::getName).toList());

결과:

고소득: [알리사, 클라라]
기타: [밥, 데니스]

partitioningBy와 groupingBy는 언제 사용할까?

  • 그룹이 둘 이상이면 — groupingBy를 사용하세요.
  • 불리언 기준의 두 그룹만 있다면 — partitioningBy가 더 빠르고 명확합니다.

4. 중첩 그룹화: 여러 기준으로 그룹화

하나의 기준만이 아니라 그룹화를 “중첩”하고 싶을 때가 있습니다. 예를 들어 직원을 먼저 부서로, 부서 안에서는 직책으로 그룹화하는 경우입니다.

예:

우리의 Employee 클래스에 position 필드가 더 있다고 합시다:

public class Employee {
    private final String name;
    private final String department;
    private final String position;
    // ... 생성자와 getter
}

중첩 그룹화:

Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)));
  • 바깥쪽 키 — 부서.
  • 안쪽 키 — 직책.
  • 값 — 직원 목록.

IT 부서에서 직책이 "Developer"인 직원에 어떻게 접근할까요?

List<Employee> itDevs = byDeptAndPosition
    .getOrDefault("IT", Map.of())
    .getOrDefault("Developer", List.of());

시각적 스키마

Map<Department, Map<Position, List<Employee>>>
└─ "IT"
    ├─ "Developer" -> [알리사, 클라라]
    └─ "QA"        -> [보리스]
└─ "HR"
    └─ "Recruiter" -> [데니스]

5. 그룹화 실전 예제

예제 1: 문자열을 길이로 그룹화

List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");

Map<Integer, List<String>> byLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));

출력:

3: [cat, dog, bee, ant]
8: [elephant, dolphin]

예제 2: 숫자를 짝/홀로 그룹화

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

Map<String, List<Integer>> byParity = numbers.stream()
    .collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));

System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}

예제 3: 문자열에 partitioningBy 사용

"A"로 시작하는 문자열과 나머지로 나눠 봅시다:

List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");

Map<Boolean, List<String>> byA = names.stream()
    .collect(Collectors.partitioningBy(s -> s.startsWith("A")));

System.out.println("A-names: " + byA.get(true));  // [Alice, Anna]
System.out.println("Other: " + byA.get(false));   // [Bob, Charlie]

5. 유용한 팁

그룹화 결과 활용하기

그룹화 후에는 단순히 Map을 얻는 것에서 끝나지 않고 종종 후처리가 필요합니다:

  • 모든 그룹을 순회하며 정보를 출력.
  • 요소 수가 최대인 그룹 찾기.
  • 각 그룹에 대해 합계나 평균 계산 — 자세한 내용은 다음 강의에서 다룹니다.

예: 각 부서의 직원 수 출력

byDepartment.forEach((dept, emps) ->
    System.out.println(dept + ": " + emps.size() + " 명"));

수동 구현과의 비교

복습 차원에서: 부서별 그룹화를 “옛 방식”으로 하면 다음과 같습니다:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
    String dept = e.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}

Stream API를 사용하면 다음과 같습니다:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

결론: 코드가 짧아지고, 오류가 줄며, 가독성이 좋아집니다.

요령

List가 아니라 Set으로 그룹화해야 한다면 — 두 번째 인수로 Collectors.toSet()을 사용하세요:

Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())

바로 집계할 수도 있습니다. 예를 들어 각 부서의 직원 수를 얻기:

Collectors.groupingBy(Employee::getDepartment, Collectors.counting())

하지만 이것은 — 다음 강의에서!

partitioningBy 이후에는 항상 두 키(true와 false)가 존재합니다. 한 그룹이 비어 있어도 마찬가지입니다.

중첩 그룹화를 사용하면 구조가 “트리” 형태가 됩니다. 즉, Map 안에 Map이 들어가고, 그 안에 다시 Map이 올 수 있습니다.

6. 그룹화와 partitioning에서 흔한 실수

오류 № 1: 잘못된 결과 타입. 초보자는 groupingBy의 결과가 단순한 List<T>라고 기대하는 경우가 많습니다. 실제로는 Map<K, List<T>>입니다. 그래서 리스트 메서드를 호출하려다 컴파일 오류가 납니다. 기억하세요: 그룹화의 결과는 항상 Map입니다!

오류 № 2: 존재하지 않는 그룹 접근 시 NullPointerException. 존재하지 않는 키로 리스트를 가져오려 하면 null을 얻게 됩니다. getOrDefault(key, List.of())를 사용하거나 containsKey로 키 존재 여부를 확인하세요.

오류 № 3: 여러 그룹이 필요한데 partitioningBy 사용. partitioningBy는 오직 두 그룹(true/false)에만 적합합니다. 그룹이 더 많다면 groupingBy를 사용하세요.

오류 № 4: 스트림 내부에서 컬렉션 수정. 스트림 내부에서 원본 컬렉션이나 Map을 수정하려 하지 마세요 — 예기치 않은 오류를 유발합니다. 모든 처리를 Stream API와 컬렉터로 수행하세요.

오류 № 5: 중첩 그룹화의 구조를 간과. 중첩된 groupingBy의 결과는 Map 안의 Map입니다. 데이터를 올바르게 추출(getOrDefault 등)하지 않으면 ClassCastException이 발생할 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 31, 레슨 2
잠금
기업 "혁신적인 솔루션"의 팀 구성 🏢
기업 "혁신적인 솔루션"의 팀 구성 🏢
1
과제
JAVA 25 SELF, 레벨 31, 레슨 2
잠금
이국적인 과일 분류 🍎🍌
이국적인 과일 분류 🍎🍌
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION