728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

Collectors

스트림이 중간 연산을 거쳐 최종 연산으로써 데이터를 처리할 때, 그 결과물이 필요한 경우가 많다. 대표적으로 "리스트나 맵 같은 자료구조에 담고싶다"거나 "통계 데이터를 내고 싶다"는 식의 요구가 있을 때, 이 최종 연산에 Collectors를 활용한다.

 

collect연산(예: stream.collect(...))은 반환값을 만들어내는 최종 연산이다. collect(Collector<? super T, A, R> collector)형태를 주로 사용하고, Collectors 클래스 안에 준비된 여러 메서드를 통해서 다양한 수집 방식을 적용할 수 있다.

 

Collectors의 주요 기능

List 수집

  • Collectors.toList()
  • Collectors.toUnmodifiableList() : 불변 리스트

Set 수집

  • Collectors.toSet()
  • Collectors.toCollection(HashSet::new) : 특정 Set 타입으로 모으려면 toCollection 사용

Map 수집

  • Collectors.toMap(keyMapper, valueMapper)
  • Collectors.toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)
  • 중복 키가 발생할 경우, mergeFunction 으로 해결할 수 있고, mapSupplier로 맵 타입을 지정할 수 있다.

그룹화

  • Collectors.groupingBy(classifier)
  • Collectors.groupingBy(classifier, downstreamCollector)
  • 특정 기준 함수(classifier)에 따라 그룹별로 스트림 요소를 묶는다. 각 그룹에 대해 추가로 적용할 다운스트림 컬렉터를 지정할 수 있다.

분할

  • Collectors.partitioningBy(predicate)
  • Collectors.partitioningBy(predicate, downstreamCollector)
  • predicate 결과가 truefalse로 나뉘어, 2개 그룹으로 분할한다.

통계

  • Collectors.counting()
  • Collectors.summingInt()
  • Collectors.averagingInt()
  • Collectors.summarizingInt()
  • 요소의 개수, 합계, 평균, 최소, 최대값을 구하거나, IntSummaryStatistics 같은 통계 객체로도 모을 수 있다.

리듀싱

  • Collectors.reducing()
  • 스트림의 reduce()와 유사하게, Collector 환경에서 요소를 하나로 합치는 연산을 할 수 있다.

문자열 연결

  • Collectors.joining(delimiter, prefix, suffix)
  • 문자열 스트림을 하나로 합쳐서 연결한다. 구분자(delimiter), 접두사(prefix), 접미사(suffix)등을 붙일 수 있다.

매핑

  • Collectors.mapping(mapper, downstream)
  • 각 요소를 다른 값으로 변환(mapper)한뒤 다운스트림 컬렉터로 넘긴다.

 

기본 수집 - 예시 코드

package stream.collectors;

import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;

import static java.util.stream.Collectors.*;

public class Collectors1Basic {
    public static void main(String[] args) {
        // 기본 기능
        List<String> list = Stream.of("Java", "Spring", "JPA")
                .collect(toList());
        System.out.println("list = " + list);
        list.set(0, "Python");
        System.out.println("list = " + list);

        // 수정 불가능 리스트
        List<Integer> unmodifiableList = Stream.of(1, 2, 3)
                        .toList();
        System.out.println("unmodifiableList = " + unmodifiableList);

        // Set
        Set<Integer> set = Stream.of(3, 1, 2, 2, 3, 3, 3)
                .collect(toSet());
        System.out.println("set = " + set);
        // Set 타입 지정 
        TreeSet<Integer> treeSet = Stream.of(3, 4, 5, 2, 1)
                .collect(toCollection(TreeSet::new));
        System.out.println("treeSet = " + treeSet);
    }
}

실행 결과

list = [Java, Spring, JPA]
list = [Python, Spring, JPA]
unmodifiableList = [1, 2, 3]
set = [1, 2, 3]
treeSet = [1, 2, 3, 4, 5]
  • Collectors.toList()는 수정 가능한 ArrayList로 수집한다.
  • Collectors.toUnmodifiableList()는 자바 10부터 제공하는 불변 리스트를 만들어서 수정할 수 없다.
  • Collectors.toSet()은 중복을 제거한 채로 Set에 수집한다.
  • Collectors.toCollection(TreeSet::new)처럼 toCollection()을 사용하면 원하는 컬렉션 구현체를 직접 지정할 수 있다. 

참고로, Collectors.toUnmodifiableList() 대신 자바 16부터는 Stream.toList()를 바로 호출할 수 있다. 얘는 마찬가지로 불변 리스트를 제공한다. 

 

또한, Collectors를 사용할 때는 static import를 추천한다.

 

 

Map 수집

package stream.collectors;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toMap;

public class Collectors2Map {
    public static void main(String[] args) {
        Map<String, Integer> map1 = Stream.of("Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length
                        )
                );
        System.out.println("map1 = " + map1);

        // 키 중복 예외 발생 코드
        /*Map<String, Integer> map2 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length
                        )
                );
        System.out.println("map2 = " + map2);*/

        // 키 중복 대안 (병합)
        Map<String, Integer> map3 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length,
                                (oldValue, newValue) -> oldValue
                        )
                );
        System.out.println("map3 = " + map3);

        // Map 타입 지정
        Map<String, Integer> map4 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length,
                                (oldValue, newValue) -> oldValue,
                                LinkedHashMap::new
                        )
                );
        System.out.println("map4 = " + map4);
    }
}

실행 결과

map1 = {Apple=5, Tomato=6, Banana=6}
map3 = {Apple=5, Tomato=6, Banana=6}
map4 = {Apple=5, Banana=6, Tomato=6}
  • Collectors.toMap(keyMapper, valueMapper): 각 요소에 대한 키, 값을 지정해서 Map을 만든다.
  • 키가 중복될 경우 IllegalStateException이 발생한다. 
  • 그래서, 중복의 경우 세번째 인자로 mergeFunction을 넘기는데 위와 같이 사용한다. (oldValue, newValue) -> oldValue. 이렇게 하면 이전에 같은 키로 저장된 값과 이번에 같은 키의 값 중 이전 값으로 처리한다는 의미가 된다. 두 값을 합칠 수도 있다. 이렇게. (oldValue, newValue) -> oldValue + newValue.
  • 마지막 인자로 LinkedHashMap::new 같은 걸 넘기면, 결과를 LinkedHashMap으로 얻을 수 있다.

 

그룹과 분할 수집

package stream.collectors;

import java.util.List;
import java.util.Map;

import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.partitioningBy;

public class Collectors3Group {
    public static void main(String[] args) {
        // 첫 글자 알파벳을 기준으로 그룹화
        List<String> names = List.of("Apple", "Avocado", "Banana", "Blueberry", "Cherry");

        Map<String, List<String>> grouped = names.stream()
                .collect(groupingBy(
                                name -> name.substring(0, 1)
                        )
                );
        System.out.println("grouped = " + grouped);

        // 짝수인지 여부로 분할
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
        Map<Boolean, List<Integer>> partitioned = numbers.stream()
                .collect(partitioningBy(n -> n % 2 == 0));
        System.out.println("partitioned = " + partitioned);
    }
}

실행 결과

grouped = {A=[Apple, Avocado], B=[Banana, Blueberry], C=[Cherry]}
partitioned = {false=[1, 3, 5], true=[2, 4, 6]}
  • Collectors.groupingBy(...)는 특정 기준(여기서는 첫 글자)에 따라 스트림 요소를 여러 그룹으로 묶는다. 결과는 Map<기준, List<요소>> 형태이다.
  • Collectors.partitioningBy(...)는 단순하게 truefalse 두 그룹으로 나눈다. 예제에서는 짝수(true), 홀수(false)로 분할됐다.

 

최솟값 최댓값 수집

package stream.collectors;

import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.stream.Collectors.maxBy;

public class Collectors4MinMax {
    public static void main(String[] args) {
        // 이렇게 사용할 때는 downstream 을 사용할 때 용이
        Integer max1 = Stream.of(1, 2, 3)
                .collect(maxBy(Integer::compareTo))
                .get();
        System.out.println("max1 = " + max1);

        // 일반적으로는 그냥 max()를 사용하면 더 편리
        Integer max2 = Stream.of(1, 2, 3)
                .max(Integer::compareTo).get();
        System.out.println("max2 = " + max2);

        // 기본형 특화 스트림은 매우 간단
        int max3 = IntStream.of(1, 2, 3).max().getAsInt();
        System.out.println("max3 = " + max3);
    }
}

실행 결과

max1 = 3
max2 = 3
max3 = 3
  • Collectors.maxBy(...)이나 Collectors.minBy(...)를 통해 최소, 최댓값을 구할 수 있다. 다만 스트림 자체가 제공하는 max(), min() 메서드를 사용하면 더 간단하다. Collectors가 제공하는 이 기능들은 다운 스트림 컬렉터에서 유용하게 사용할 수 있다.
  • 기본형 특화 스트림(IntStream 등)을 쓰면, .max().getAsInt()처럼 바로 기본형으로 결과를 얻을 수 있다.

 

통계 수집

package stream.collectors;

import java.util.IntSummaryStatistics;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.stream.Collectors.*;

public class Collectors4Summing {
    public static void main(String[] args) {
        // 이것도 다운스트림 컬렉터에서 유용하게 사용
        Long counting = Stream.of(1, 2, 3)
                .collect(counting());
        System.out.println("counting = " + counting);

        // 일반적으로는 count() 그냥 쓰면 편리
        long count = Stream.of(1, 2, 3)
                .count();
        System.out.println("count = " + count);

        System.out.println("-------------------------------");

        // 평균 구하기 (averagingInt)
        Double average1 = Stream.of(1, 2, 3)
                .collect(averagingInt(i -> i));
        System.out.println("average1 = " + average1);

        // 기본형 특화 스트림으로 변형
        double average2 = Stream.of(1, 2, 3)
                .mapToInt(i -> i)
                .average().getAsDouble();
        System.out.println("average2 = " + average2);

        // 처음부터 기본형 특화 스트림 사용
        double average3 = IntStream.of(1, 2, 3)
                .average()
                .getAsDouble();
        System.out.println("average3 = " + average3);

        System.out.println("-------------------------------");

        // 통계
        IntSummaryStatistics stats = Stream.of("Apple", "Banana", "Tomato")
                .collect(summarizingInt(String::length));
        System.out.println("stats.getCount() = " + stats.getCount());
        System.out.println("stats.getSum() = " + stats.getSum());
        System.out.println("stats.getAverage() = " + stats.getAverage());
        System.out.println("stats.getMin() = " + stats.getMin());
        System.out.println("stats.getMax() = " + stats.getMax());
    }
}

실행 결과

counting = 3
count = 3
-------------------------------
average1 = 2.0
average2 = 2.0
average3 = 2.0
-------------------------------
stats.getCount() = 3
stats.getSum() = 17
stats.getAverage() = 5.666666666666667
stats.getMin() = 5
stats.getMax() = 6
  • Collectors.counting()은 요소 개수를 구한다.
  • Collectors.averagingInt()는 요소들의 평균을 구한다.
  • Collectors.summarizingInt()는 합계, 최솟값, 최댓값, 평균 등 다양한 통계 정보를 담은 IntSummaryStatistics 객체를 얻는다.
  • 자주 쓰이는 통계 메서드로 Collectors.summingInt(), Collectors.maxBy(), Collectors.minBy(), Collectors.counting() 등이 있다.
  • Collectors의 일부 기능은 스트림에서 직접 제공하는 기능과 중복된다. Collectors의 기능들은 뒤에서 설명할 다운 스트림 컬렉터에서 유용하게 사용할 수 있다.

 

리듀싱 수집

package stream.collectors;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static java.util.stream.Collectors.*;
import static java.util.stream.Collectors.reducing;

public class Collectors5Reducing {
    public static void main(String[] args) {
        List<String> names = List.of("a", "b", "c", "d");

        // 컬렉션의 reducing은 주로 다운스트림에 사용
        String joined1 = names.stream()
                .collect(reducing(
                        (s1, s2) -> s1 + ", " + s2)
                )
                .get();
        System.out.println("joined1 = " + joined1);

        Optional<String> reduce = names.stream()
                .reduce((s1, s2) -> s1 + ", " + s2);
        System.out.println("reduce.get() = " + reduce.get());

        String joined2 = names.stream()
                .collect(joining(", ", "[", "]"));
        System.out.println("joined2 = " + joined2);
    }
}

실행 결과

joined1 = a, b, c, d
reduce.get() = a, b, c, d
joined2 = [a, b, c, d]
  • Collectors.reducing(...)은 최종적으로 하나의 값으로 요소들을 합치는 방식을 지정한다. 여기서는 문자열들을 ',' 로 이어붙였다.
  • 스트림 자체의 reduce(...)와 유사한 기능이다.
  • 문자열을 이어 붙일 때는 Collectors.joining()이나 String.join()을 쓰는게 더 간편하다.

 

다운 스트림 컬렉터

다운 스트림 컬렉터가 필요한 이유

  • groupingBy(...)를 사용하면 일단 요소가 그룹별로 묶이지만, 그룹 내 요소를 구체적으로 어떻게 처리할지는 기본적으로 Collectors.toList()만 적용된다.
  • 그런데, 실무에서는 "그룹별 총합, 평균, 최대/최소값, 매핑된 결과, 통계"등을 바로 얻고 싶을 때가 많다.
  • 예를 들어, "학년별로 학생들을 그룹화한 뒤, 각 학년 그룹에서 평균 점수를 구하고 싶다"는 상황에서는 단순히 List<Student>로 끝나는 게 아니라, 그룹 내 학생들의 점수를 합산하고 평균을 내는 동작이 더 필요하다.
  • 이처럼, 그룹화된 이후 각 그룹 내부에서 추가적인 연산 또는 결과물(예: 평균, 합계, 최댓값, 최솟값, 통계, 다른 타입으로 변환 등)을 정의하는 역할을 하는 것이 바로 다운 스트림 컬렉터이다.
  • 이때, 다운 스트림 컬렉터를 활용하면, "그룹 내부"를 다시 한번 모으거나 집계하여 원하는 결과를 얻을 수 있다.
    • 예: groupingBy(분류함수, counting()) → 그룹별 개수
    • 예: groupingBy(분류함수, summingInt(Student::getScore)) → 그룹별 점수 합계
    • 예: groupingBy(분류함수, mapping(Student::getName, toList())) → 그룹별 학생 이름 리스트

 

그림 예시

  • 각 학년별로 그룹화한 뒤, 그룹화한 학년별 점수의 합을 구하는 방법

 

다운 스트림 컬렉터의 종류

Collector 사용 메서드 예시 설명 예시 반환 타입
counting() Collectors.counting() 그룹 내(혹은 스트림 내) 요소들의 개수를 센다. Long
summingInt() 등 Collectors.summingInt(...),
Collectors.summingLong(...)
그룹 내 요소들의 특정 정수형 속성을 모두 합산한다. Integer, Long 등
averagingInt() 등 Collectors.averagingInt(...),
Collectors.averagingDouble(...)
그룹 내 요소들의 특정 속성 평균값을 구한다. Double
minBy(), maxBy() Collectors.minBy(Comparator),
Collectors.maxBy(Comparator)
그룹 내 최소, 최댓값을 구한다. Optional<T>
summarizingInt() 등 Collectors.summarizingInt(...),
Collectors.summarizingLong(...)
개수, 합계, 평균, 최소, 최댓값을 동시에 구할 수 있는 SummaryStatistics 객체를 반환한다. IntSummaryStatistics 등
mapping() Collectors.mapping(변환함수, 다운스트림) 각 요소를 다른 값으로 변환한 뒤, 변환된 값들을 다시 다른 Collector로 수집할 수 있게 한다. 다운스트림 반환 타입에 따라 달라짐
collectingAndThen() Collectors.collectingAndThen(다른 컬렉터, 변환 함수) 다운 스트림 컬렉터의 결과를 최종적으로 한번 더 가공(후처리)할 수 있다. 후처리 후의 타입
reducing() Collectors.reducing(초기값, 변환 함수, 누적 함수),
Collectors.reducing(누적 함수)
스트림의 recuce()와 유사하게, 그룹 내 요소들을 하나로 합치는 로직을 정의할 수 있다. 누적 로직에 따라 달라짐
toList(), toSet() Collectors.toList(),
Collectors.toSet()
그룹 내(혹은 스트림 내) 요소를 리스트나 집합으로 수집한다. toCollection(...)으로 구현체 지정 가능 List<T>, Set<T>

 

 

다운 스트림 컬렉터 예제 1

package stream.collectors;

public class Student {
    private String name;
    private int grade;
    private int score;

    public Student(String name, int grade, int score) {
        this.name = name;
        this.grade = grade;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getGrade() {
        return grade;
    }

    public int getScore() {
        return score;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", grade=" + grade +
                ", score=" + score +
                '}';
    }
}

 

 

package stream.collectors;

import java.util.List;
import java.util.Map;

import static java.util.stream.Collectors.*;

public class DownStreamMain1 {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Kim", 1, 85),
                new Student("Park", 1, 70),
                new Student("Lee", 2, 70),
                new Student("Han", 2, 90),
                new Student("Hoon", 3, 90),
                new Student("Ha", 3, 89)
        );

        // 1단계: Grade 별로 그룹화
        Map<Integer, List<Student>> collect1_1 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                toList()
                        )
                );
        System.out.println("collect1_1 = " + collect1_1);

        // 1단계: 다운 스트림에서 toList() 생략 가능
        Map<Integer, List<Student>> collect1_2 = students.stream()
                .collect(groupingBy(Student::getGrade));
        System.out.println("collect1_2 = " + collect1_2);

        // 2단계: 학년별로 학생들의 이름을 출력
        Map<Integer, List<String>> collect2 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                mapping( // 다운스트림 1: 학생 -> 이름 변환
                                        Student::getName,
                                        toList() // 다운스트림 2: 변환된 값을 List 로 수집
                                )
                        )
                );
        System.out.println("collect2 = " + collect2);

        // 3단계: 학년별로 학생들의 수를 출력
        Map<Integer, Long> collect3 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                counting()
                        )
                );
        System.out.println("collect3 = " + collect3);

        // 4단계: 학년별로 학생들의 평균 성적 출력
        Map<Integer, Double> collect4 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                averagingDouble(Student::getScore)
                        )
                );
        System.out.println("collect4 = " + collect4);
    }
}

실행 결과

collect1_1 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect1_2 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect2 = {1=[Kim, Park], 2=[Lee, Han], 3=[Hoon, Ha]}
collect3 = {1=2, 2=2, 3=2}
collect4 = {1=77.5, 2=80.0, 3=89.5}

 

 

다운 스트림 컬렉터 예제 2

package stream.collectors;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static java.util.stream.Collectors.*;

public class DownStreamMain2 {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Kim", 1, 85),
                new Student("Park", 1, 70),
                new Student("Lee", 2, 70),
                new Student("Han", 2, 90),
                new Student("Hoon", 3, 90),
                new Student("Ha", 3, 89)
        );

        // 1단계: 학년별로 학생들을 그룹화해라.
        Map<Integer, List<Student>> collect1 = students.stream()
                .collect(groupingBy(Student::getGrade));
        System.out.println("collect1 = " + collect1);

        // 2단계: 학년별로 가장 점수가 높은 학생을 구해라. reducing 사용
        Map<Integer, Optional<Student>> collect2 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                reducing((s1, s2) -> s1.getScore() > s2.getScore() ? s1 : s2)
                        )
                );
        System.out.println("collect2 = " + collect2);

        // 3단계: 학년별로 가장 점수가 높은 학생을 구해라. maxBy 사용
        Map<Integer, Optional<Student>> collect3 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                maxBy(Comparator.comparing(Student::getScore))
                        )
                );
        System.out.println("collect3 = " + collect3);

        // 4단계: 학년별로 가장 점수가 높은 학생의 이름을 구해라 (collectingAndThen + maxBy)
        Map<Integer, String> collect4 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                collectingAndThen(
                                        maxBy(Comparator.comparing(Student::getScore)),
                                        sO -> sO.map(Student::getName).orElse(null)
                                )
                        )
                );
        System.out.println("collect4 = " + collect4);
    }
}

실행 결과

collect1 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect2 = {1=Optional[Student{name='Kim', grade=1, score=85}], 2=Optional[Student{name='Han', grade=2, score=90}], 3=Optional[Student{name='Hoon', grade=3, score=90}]}
collect3 = {1=Optional[Student{name='Kim', grade=1, score=85}], 2=Optional[Student{name='Han', grade=2, score=90}], 3=Optional[Student{name='Hoon', grade=3, score=90}]}
collect4 = {1=Kim, 2=Han, 3=Hoon}

 

 

정리

다운 스트림 컬렉터를이해하면, groupingBy(...)partitioningBy(...)로 그룹화, 분할을 한 뒤 내부 요소를 어떻게 가공하고 수집할지 자유롭게 설계할 수 있다. 

728x90
반응형
LIST

+ Recent posts