코드 한 줄의 기록

Java Stream Collectors 완전 정복: groupingBy와 partitioningBy로 데이터 그룹화하기 본문

JAVA

Java Stream Collectors 완전 정복: groupingBy와 partitioningBy로 데이터 그룹화하기

CodeByJin 2025. 11. 6. 08:52
반응형

Stream API를 공부하다 보면 중간 연산까지는 그럭저럭 이해가 되는데, 정작 최종 연산인 collect()에서 막히는 경우가 많다. 특히 Collectors 클래스의 다양한 메서드들은 처음 봤을 때 복잡해 보이지만, 제대로 익혀두면 데이터를 다루는 강력한 도구가 된다. 이번 글에서는 실무에서 자주 사용하는 Collectors의 주요 기능들, 특히 그룹화(groupingBy)와 분할(partitioningBy)을 중심으로 정리해보려 한다.

Collectors란 무엇인가

Collectors는 Stream의 요소들을 수집(collect)하는 다양한 방법을 제공하는 유틸리티 클래스다. Stream API에서 중간 연산을 거친 데이터를 최종적으로 원하는 형태로 변환할 때 사용한다.

 

collect() 메서드는 Stream의 최종 연산이며, 매개변수로 Collector를 필요로 한다. Collector는 인터페이스이고, Collectors 클래스는 이 인터페이스를 구현한 다양한 컬렉터들을 static 메서드로 제공한다.

 

기본적인 구조를 이해하면 이렇다.
- collect(): Stream의 최종 연산 메서드
- Collector: 어떻게 수집할지 정의한 인터페이스
- Collectors: 미리 구현된 다양한 Collector를 제공하는 클래스

기본 수집 메서드

toList()와 toSet()

가장 기본적인 수집 메서드로, Stream의 요소들을 List나 Set으로 변환한다.

List<String> nameList = students.stream()
    .map(Student::getName)
    .collect(Collectors.toList());

Set<String> nameSet = students.stream()
    .map(Student::getName)
    .collect(Collectors.toSet());

toList()는 순서를 유지하며 중복을 허용하고, toSet()은 중복을 자동으로 제거한다는 차이가 있다. 특정 Collection 구현체를 원한다면 toCollection()을 사용할 수 있다.

LinkedList<String> linkedList = students.stream()
    .map(Student::getName)
    .collect(Collectors.toCollection(LinkedList::new));

toMap()

Stream의 요소들을 Map으로 변환할 때 사용한다. keyMapper와 valueMapper 함수를 인자로 받아서 각각 키와 값을 생성한다.

Map<Long, String> idToName = students.stream()
    .collect(Collectors.toMap(Student::getId, Student::getName));

여기서 주의할 점은 중복 키가 발생하면 IllegalStateException이 발생한다는 것이다. 중복 키 문제를 해결하려면 세 번째 인자로 병합 함수(merge function)를 제공해야 한다.

// 중복 키 발생 시 기존 값 유지
Map<String, Student> nameToStudent = students.stream()
    .collect(Collectors.toMap(
        Student::getName,
        Function.identity(),
        (existing, replacement) -> existing
    ));

병합 함수는 BinaryOperator<T> 타입으로, 중복 키가 발생했을 때 어떤 값을 선택할지 결정한다. (existing, replacement) -> existing은 기존 값을 유지하고, (existing, replacement) -> replacement는 새로운 값으로 교체한다.

통계 및 요약 연산

counting(), summingInt(), averagingInt()

데이터의 개수, 합계, 평균 등을 구할 때 사용하는 메서드들이다.

// 학생 수 세기
Long studentCount = students.stream()
    .collect(Collectors.counting());

// 전체 점수 합계
Integer totalScore = students.stream()
    .collect(Collectors.summingInt(Student::getScore));

// 평균 점수
Double averageScore = students.stream()
    .collect(Collectors.averagingInt(Student::getScore));

counting()은 Stream의 count() 메서드와 동일한 기능을 하지만, 나중에 살펴볼 groupingBy()와 함께 사용할 때 진가를 발휘한다.

 

summarizingInt()

개수, 합계, 평균, 최솟값, 최댓값을 한 번에 계산하고 싶을 때는 summarizingInt()를 사용한다.

IntSummaryStatistics statistics = students.stream()
    .collect(Collectors.summarizingInt(Student::getScore));

System.out.println("개수: " + statistics.getCount());
System.out.println("합계: " + statistics.getSum());
System.out.println("평균: " + statistics.getAverage());
System.out.println("최솟값: " + statistics.getMin());
System.out.println("최댓값: " + statistics.getMax());

여러 번 Stream을 돌리는 것보다 한 번에 처리하는 것이 효율적이기 때문에 실무에서 유용하게 사용된다.

 

joining()

문자열 Stream의 요소들을 하나의 문자열로 연결할 때 사용한다. 내부적으로 StringBuilder를 이용해서 효율적으로 처리한다.

// 단순 연결
String names = students.stream()
    .map(Student::getName)
    .collect(Collectors.joining());

// 구분자 사용
String namesWithComma = students.stream()
    .map(Student::getName)
    .collect(Collectors.joining(", "));

// 접두사와 접미사 사용
String namesList = students.stream()
    .map(Student::getName)
    .collect(Collectors.joining(", ", "[", "]"));

delimiter(구분자), prefix(접두사), suffix(접미사)를 자유롭게 설정할 수 있어 문자열 조합 작업을 간편하게 처리할 수 있다.

분할(Partitioning) - partitioningBy()

분할은 Stream의 요소를 두 개의 그룹으로 나누는 작업이다. Predicate를 조건으로 받아서 true와 false로 나누기 때문에 결과는 Map<Boolean, List<T>> 형태로 반환된다.

 

partitioningBy()는 두 가지 형태로 오버로딩되어 있다. 첫 번째는 단순히 조건으로 분할만 하고, 두 번째는 분할 후 추가적인 downstream 연산을 수행한다.

 

기본 사용 예제

학생들을 성별로 분할하는 예제를 보자.

// 남학생, 여학생으로 분할
Map<Boolean, List<Student>> studentsBySex = students.stream()
    .collect(Collectors.partitioningBy(Student::isMale));

List<Student> maleStudents = studentsBySex.get(true);
List<Student> femaleStudents = studentsBySex.get(false);

짝수와 홀수로 분할하는 간단한 예제도 살펴보자.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Map<Boolean, List<Integer>> evenOddMap = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

System.out.println("짝수: " + evenOddMap.get(true));
System.out.println("홀수: " + evenOddMap.get(false));

downstream 컬렉터와 함께 사용

분할 후 각 그룹에 대해 추가 연산을 수행할 수 있다.

// 성별 학생 수 구하기
Map<Boolean, Long> studentCountBySex = students.stream()
    .collect(Collectors.partitioningBy(Student::isMale, Collectors.counting()));

System.out.println("남학생 수: " + studentCountBySex.get(true));
System.out.println("여학생 수: " + studentCountBySex.get(false));

// 성별 1등 학생 구하기
Map<Boolean, Optional<Student>> topStudentBySex = students.stream()
    .collect(Collectors.partitioningBy(
        Student::isMale,
        Collectors.maxBy(Comparator.comparingInt(Student::getScore))
    ));

System.out.println("남학생 1등: " + topStudentBySex.get(true));
System.out.println("여학생 1등: " + topStudentBySex.get(false));

다중 분할

partitioningBy()를 중첩해서 사용하면 다중 분할도 가능하다.

// 성별로 나눈 후, 합격/불합격으로 다시 분할
Map<Boolean, Map<Boolean, List<Student>>> failedStudentsBySex = students.stream()
    .collect(Collectors.partitioningBy(
        Student::isMale,
        Collectors.partitioningBy(s -> s.getScore() < 150)
    ));

List<Student> failedMaleStudents = failedStudentsBySex.get(true).get(true);
List<Student> failedFemaleStudents = failedStudentsBySex.get(false).get(true);

결과는 Map<Boolean, Map<Boolean, List<Student>>> 형태가 되며, get()을 두 번 호출해서 원하는 그룹의 데이터를 가져올 수 있다.

 

partitioningBy() 사용 시점: Stream을 정확히 두 개의 그룹으로 나눠야 할 때 partitioningBy()를 사용하면 groupingBy()보다 더 빠르고 의미도 명확하다.

그룹화(Grouping) - groupingBy()

그룹화는 Stream의 요소를 특정 기준으로 여러 그룹으로 나누는 작업이다. Function을 분류 기준으로 받아서 Map<K, List<T>> 형태로 반환한다.

 

groupingBy()는 세 가지 형태로 오버로딩되어 있다. 분류 함수만 받는 기본 형태, downstream 컬렉터를 추가로 받는 형태, 그리고 Map 구현체까지 지정할 수 있는 형태다.

 

기본 사용 예제

학생들을 반별로 그룹화하는 예제를 보자.

// 반별 그룹화
Map<Integer, List<Student>> studentsByClass = students.stream()
    .collect(Collectors.groupingBy(Student::getClassNumber));

// 1반 학생들
List<Student> class1Students = studentsByClass.get(1);

기본적으로 결과는 List<T>에 담긴다. 필요에 따라 toSet()이나 toCollection()을 사용할 수도 있다.

// Set으로 그룹화
Map<Integer, Set<Student>> studentsByClassSet = students.stream()
    .collect(Collectors.groupingBy(Student::getClassNumber, Collectors.toSet()));

downstream 컬렉터와 함께 사용

그룹화 후 각 그룹에 대해 추가 연산을 수행할 수 있다.

// 반별 학생 수
Map<Integer, Long> studentCountByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.counting()
    ));

// 반별 평균 점수
Map<Integer, Double> averageScoreByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.averagingInt(Student::getScore)
    ));

// 반별 총점
Map<Integer, Integer> totalScoreByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.summingInt(Student::getScore)
    ));

// 반별 최고 점수 학생
Map<Integer, Optional<Student>> topStudentByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.maxBy(Comparator.comparingInt(Student::getScore))
    ));

다단계 그룹화

groupingBy()를 중첩해서 사용하면 다단계 그룹화가 가능하다.

// 학년별로 그룹화한 후, 다시 반별로 그룹화
Map<Integer, Map<Integer, List<Student>>> studentsByGradeAndClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.groupingBy(Student::getClassNumber)
    ));

// 1학년 3반 학생들
List<Student> grade1Class3 = studentsByGradeAndClass.get(1).get(3);

결과는 Map 안에 Map이 들어있는 중첩 구조가 된다.

더 복잡한 예제로, 학년별로 그룹화한 후 반별로 나누고, 각 학생의 성적을 등급으로 변환하는 작업을 해보자.

enum Level { HIGH, MEDIUM, LOW }

Map<Integer, Map<Integer, Set<Level>>> studentLevelsByGradeAndClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.groupingBy(
            Student::getClassNumber,
            Collectors.mapping(
                student -> {
                    if (student.getScore() >= 200) return Level.HIGH;
                    else if (student.getScore() >= 100) return Level.MEDIUM;
                    else return Level.LOW;
                },
                Collectors.toSet()
            )
        )
    ));

이 예제에서는 mapping()을 사용해서 학생 객체를 Level로 변환한 후 Set으로 수집한다. 이렇게 하면 각 반에 어떤 등급의 학생들이 있는지 한눈에 파악할 수 있다.

 

TreeMap으로 정렬된 결과 얻기

기본적으로 groupingBy()는 HashMap을 사용하지만, 세 번째 파라미터로 Map 구현체를 지정할 수 있다.

// 이름순으로 정렬된 그룹화 결과
Map<String, List<Student>> sortedStudentsByName = students.stream()
    .collect(Collectors.groupingBy(
        Student::getName,
        TreeMap::new,
        Collectors.toList()
    ));

reducing()과 mapping()

reducing()

reducing()은 Stream의 reduce() 연산과 동일한 기능을 하지만, groupingBy()나 partitioningBy()의 downstream으로 사용될 때 유용하다.

// 전체 점수 합계
Integer totalScore = students.stream()
    .collect(Collectors.reducing(
        0,
        Student::getScore,
        Integer::sum
    ));

// 반별 점수 합계
Map<Integer, Integer> totalScoreByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.reducing(0, Student::getScore, Integer::sum)
    ));

reducing()은 세 가지 파라미터를 받는다: 초기값, 매핑 함수, 그리고 BinaryOperator다.

 

mapping()

mapping()은 downstream 컬렉터를 적용하기 전에 요소를 다른 형태로 변환할 때 사용한다.

// 반별 학생 이름 목록
Map<Integer, List<String>> namesByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.mapping(Student::getName, Collectors.toList())
    ));

collectingAndThen()

collectingAndThen()은 수집 작업을 완료한 후, 그 결과에 대해 추가 변환 함수를 적용할 때 사용한다.

// List로 수집한 후 불변 리스트로 변환
List<String> immutableList = students.stream()
    .map(Student::getName)
    .collect(Collectors.collectingAndThen(
        Collectors.toList(),
        Collections::unmodifiableList
    ));

// 반별 학생 수를 int로 변환
Map<Integer, Integer> studentCountByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.collectingAndThen(Collectors.counting(), Long::intValue)
    ));

첫 번째 파라미터로 컬렉터를, 두 번째 파라미터로 변환 함수를 받는다.

실무 활용 팁

복잡한 그룹화는 단계별로 나눠서 생각하기

처음부터 복잡한 중첩 구조를 만들려고 하지 말고, 먼저 1단계 그룹화를 구현한 후 점진적으로 downstream을 추가하는 방식이 이해하기 쉽다.

// 1단계: 학년별 그룹화
Map<Integer, List<Student>> byGrade = students.stream()
    .collect(Collectors.groupingBy(Student::getGrade));

// 2단계: 학년별, 반별 그룹화
Map<Integer, Map<Integer, List<Student>>> byGradeAndClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.groupingBy(Student::getClassNumber)
    ));

Optional 처리

maxBy(), minBy() 같은 연산은 Optional을 반환하는데, 이를 처리하는 방법을 알아두면 좋다.

// Optional을 그대로 사용
Map<Integer, Optional<Student>> topByClass = students.stream()
    .collect(Collectors.groupingBy(
        Student::getClassNumber,
        Collectors.maxBy(Comparator.comparingInt(Student::getScore))
    ));

Optional<Student> topStudent = topByClass.get(1);
topStudent.ifPresent(s -> System.out.println(s.getName()));

성능 고려사항

partitioningBy()는 정확히 2개 그룹으로 나눌 때 groupingBy()보다 빠르다. 조건이 boolean으로 명확하게 나뉜다면 partitioningBy()를 사용하는 것이 좋다.

// 합격/불합격 분할 - partitioningBy가 더 적합
Map<Boolean, List<Student>> passedStudents = students.stream()
    .collect(Collectors.partitioningBy(s -> s.getScore() >= 60));

// 3단계 이상 등급 - groupingBy가 필요
Map<String, List<Student>> gradeStudents = students.stream()
    .collect(Collectors.groupingBy(s -> {
        if (s.getScore() >= 90) return "A";
        else if (s.getScore() >= 80) return "B";
        else return "C";
    }));

코드 가독성

복잡한 람다식은 별도 메서드로 분리하면 가독성이 좋아진다.

// 개선 전
Map<String, List<Student>> graded = students.stream()
    .collect(Collectors.groupingBy(s -> {
        if (s.getScore() >= 90) return "A";
        else if (s.getScore() >= 80) return "B";
        else return "C";
    }));

// 개선 후
private String getGrade(Student student) {
    if (student.getScore() >= 90) return "A";
    else if (student.getScore() >= 80) return "B";
    else return "C";
}

Map<String, List<Student>> graded = students.stream()
    .collect(Collectors.groupingBy(this::getGrade));

Collectors의 다양한 메서드들은 처음에는 복잡해 보이지만, 기본 개념을 이해하고 나면 데이터를 다루는 강력한 도구가 된다. 특히 groupingBy()와 partitioningBy()는 실무에서 데이터를 분류하고 통계를 내는 작업에 매우 유용하다.

 

핵심은 이렇게 정리할 수 있다.
- toList(), toSet(), toMap(): 기본적인 컬렉션 변환
- counting(), summingInt(), averagingInt(): 통계 연산
- partitioningBy(): 2개 그룹으로 분할 (Predicate 사용)
- groupingBy(): N개 그룹으로 분류 (Function 사용)
- reducing(), mapping(): 추가 변환 작업
- collectingAndThen(): 수집 후 변환

 

실제 프로젝트에서 이런 메서드들을 활용하다 보면, 반복문과 조건문으로 복잡하게 작성하던 코드를 훨씬 간결하고 읽기 쉽게 만들 수 있다는 것을 체감하게 된다. 처음에는 익숙하지 않더라도 하나씩 사용해보면서 익혀나가면, 어느새 Stream API를 자유자재로 다루고 있는 자신을 발견하게 될 것이다.

 

앞으로 데이터 처리 작업을 할 때 이 글에서 정리한 내용들이 도움이 되길 바란다. 같이 공부하는 분들에게도 유용한 참고 자료가 되었으면 좋겠다.

 

 

Java Optional로 NPE를 잡아보자 - 실무 활용 가이드

개발을 하다 보면 정말 자주 마주치는 에러가 있다. 바로 NullPointerException, 즉 NPE다. 처음에는 이게 뭐 하는 에러인가 싶겠지만, 경력이 쌓이면서 "아, 또 이거네?"라고 한숨이 나오는 그런 에러다.

byteandbit.tistory.com

 

반응형