Language/Java

[Java] Stream Collector, Downstream Collector

kkang._.h00n 2025. 12. 26. 04:00

개요

Stream은 중간 연산을 거친 데이터들을 collect 최종 연산을 통해 다양한 컬렉션으로 반환할 수 있다. 

 

 

구조

public interface Stream<T> extends BaseStream<T, Stream<T>> {
	..
	
    //Collector 인터페이스를 통해 최종 연산 반환 타입 지정
    <R, A> R collect(Collector<? super T, A, R> collector);
    
    ..
}

collect 연산은 Collector 인터페이스을 매개변수로 받아서 알맞은 컬렉션 형태로 반환한다.

 

자바에서 Collectors 클래스를 통해 Collector 인터페이스의 대부분 기능을 구현해 놓았다. Collectors 클래스에서 어떤 기능들로 어떤 타입들을 반환하는지 알아보자.

 

 

List 반환

//수정 가능
List<String> list = Stream.of("Java", "Spring", "JPA")
		.collect(Collectors.toList());
        
//수정 불가능
List<Integer> unmodifiableList = Stream.of(1, 2, 3)
		.collect(Collectors.toUnmodifiableList());

toList() 메서드와 toUnmodifiableList() 메서드로 리스트 반환이 가능하다. 둘은 수정 가능 여부에 차이가 있다. 

 

//Java 17부터 더 간략하게 가능        
List<Integer> unmodifiableList2 = Stream.of(1, 2, 3)
		.toList();

Java 17부터는 아예 toList() 최종 연산을 지원한다. 해당 최종 연산을 통해 반환되는 결과는 수정이 불가능하니 이 점 주의하자.

 

 

Set 반환

//수정 가능
Set<Integer> set = Stream.of(1, 2, 2, 3, 3, 3)
		.collect(Collectors.toSet());

//수정 불가능
Set<Integer> unmodifiableSet = Stream.of(1, 2, 2, 3, 3, 3)
		.collect(Collectors.toUnmodifiableSet());

동일하게 Collectors 클래스에서 제공하는 메서드로 간단하게 Set 타입으로 반환이 가능하다.

 

//타입 지정
TreeSet<Integer> treeSet = Stream.of(3, 4, 5, 2, 1)
		.collect(Collectors.toCollection(TreeSet::new));

Treeset, HashSet 등의 타입을 지정할 수도 있다.

 

 

Map 반환

//수정 가능
Map<String, Integer> map = Stream.of("Apple", "Banana", "Tomato")
		.collect(Collectors.toMap(
				// key
				name -> name,
				// value
				name -> name.length()
		));

//수정 불가능
Map<String, Integer> unModifiedableMap = Stream.of("Apple", "Banana", "Tomato")
		.collect(Collectors.toUnmodifiableMap(
				name -> name, // keyMapper
				name -> name.length() // valueMapper
		));

Map 타입에 대한 메서드도 지원한다.

 

//키 중복 대안
Map<String, Integer> alternativeKeyMap = Stream.of("Apple", "Apple", "Tomato")
		.collect(Collectors.toMap(
				name -> name,
				name -> name.length(),
                
				// 중복될 경우 -> 기존 값 + 새 값
				(oldVal, newVal) -> oldVal + newVal 
		));

Map에 key는 유니크하다. key가 중복될 경우, 그에 대한 대체도 지정할 수 있다.

 

//Map의 타입 지정
Map<String, Integer> typeMap = Stream.of("Apple", "Apple", "Tomato")
		.collect(Collectors.toMap(
				name -> name,
				name -> name.length(),
				(oldVal, newVal) -> oldVal + newVal,
                
				// 타입 지정
				LinkedHashMap::new
		));

Map에 타입도 지정할 수 있다.

 

 

그룹핑과 파티셔닝

그룹핑

List<String> names = List.of("Apple", "Avocado", "Banana", "Blueberry", "Cherry");

// 람다식을 기준으로 그룹화
Map<String, List<String>> grouped = names.stream()
		.collect(Collectors.groupingBy(name -> name.substring(0, 1)));

groupingBy() 매개변수로 람다식을 전달하여, 해당 람다식 수행 후 결과값이 동일한 데이터들을 그룹화 한 Map을 반환할 수 있다.

 

파티셔닝

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

// 조건을 기준으로 요소 분할
Map<Boolean, List<Integer>> partitioned = numbers.stream()
		.collect(Collectors.partitioningBy(n -> n % 2 == 0));

partitioningBy() 매개변수로 Boolean 람다식을 전달하여, 해당 조건에 조합하는지 안하는지에 따라 이분화 한 Map을 반환할 수 있다.

 

 

Downstream Collector (다운 스트림 컬렉터)

위에서 그룹핑 혹은 파티셔닝을 통해 Map 타입을 반환받았다.

grouping() 메서드와 partitioningBy() 메서드를 살펴보면, 반환 타입인 Map의 value는 List 타입으로 그룹화 된 결과를 나타낸다. 

그룹화 된 결과 컬렉션에 대해 추가로 어떻게 처리할 지 정의하는 것을 다운 스트림 컬렉터라고 한다.

 

너무 막연하니 예시로 살펴보자.

 

데이터 셋

@Getter
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;
    }

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

=================

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)
);

Student 객체가 있고, Student List를 정의하였다.

 

//Grade 별로 그룹핑
Map<Integer, List<Student>> grouped = students.stream()
        .collect(Collectors.groupingBy(
                Student::getGrade 
        ));
System.out.println("grouped = " + grouped);

학생들에 대해 Grade 별로 그룹핑 할 수 있다.

 

grouped = {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}]
           }

출력 결과를 보면, Grade 별로 학생들이 그룹핑 된 것을 볼 수 있다.

 

그룹화 된 데이터 변환

//Grade 별로 그룹핑
Map<Integer, List<Student>> groupedMapping = students.stream()
        .collect(Collectors.groupingBy(
                Student::getGrade,
                
                //그룹화 된 리스트를 변환
                //name으로 변환
                Collectors.mapping(
                	Student::getName,
                	Collectors.toList())
        ));
System.out.println("groupedMapping = " + groupedMapping);

collect의 매개변수로 그룹화 된 컬렉션을 어떻게 처리할지 Collector를 통해 정의하였다.

각 객체의 이름으로 리스트를 구성하도록 정의하였다.

 

groupedMapping = {1=[Kim, Park], 2=[Lee, Han], 3=[Hoon, Ha]}

그룹핑 된 데이터가 name으로 변환되었다.

 

그룹화 된 데이터 통계

Map<Integer, List<Student>> groupedCount = students.stream()
        .collect(Collectors.groupingBy(
                Student::getGrade,
                
                //그룹화 별 갯수
                Collectors.counting()
        ));


Map<Integer, List<Student>> groupedAverage = students.stream()
        .collect(Collectors.groupingBy(
                Student::getGrade,
                
                //그룹화 별 점수 평균
                Collectors.averagingInt(Student::getScore)
        ));        
        

System.out.println("groupedCount = " + groupedCount);
System.out.println("groupedAverage = " + groupedAverage);

그룹화 된 컬렉션에 대한 갯수나 특정 필드의 평균을 구하는 등, 통계를 구하는데도 아주 유연하다.

 

groupedCount = {1=2, 2=2, 3=2}
groupedAverage = {1=77.5, 2=80.0, 3=89.5}

 

결과는 다음과 같이 출력되었다.

 

그룹화 된 데이터에 대한 후처리

Map<Integer, List<Student>> groupedAndThen = students.stream()
        .collect(Collectors.groupingBy(
                Student::getGrade,
                
                //그룹화 된 리스트에 대한 후처리
                Collectors.collectingAndThen(
                        //점수가 가장 높은 학생에 대해
                        Collectors.maxBy(Comparator.comparingInt(Student::getScore)),
                        //해당 학생의 이름을 반환
                        sOpt -> sOpt.get().getName()
                )
        ));
System.out.println("groupedAndThen = " + groupedAndThen);

 

그룹핑 된 리스트에 대해 추가적인 복잡한 후처리 로직도 작성 가능하다.

위 코드에서는 각 그룹 별 점수가 가장 높은 학생의 이름을 출력하도록 후처리 로직을 작성하였다.

 

groupedAndThen = {1=Kim, 2=Han, 3=Hoon}

결과를 통해 각 그룹 별 점ㅅ가 가장 높은 학생의 이름만 저장되었다.

 

 

정리

Stream의 Collector부터 다운 스트림 컬렉터까지 알아보았다. Collector와 다운 스트림을 이용하면 특정 데이터들에 대한 처리나 복잡한 통계같은 것도 유연하게 처리가 가능할 것 같다.

Stream Collector는 위에서 설명한 것 외에도 다양한 API를 제공해주기 때문에, 모든 기능에 대해서 외우기보다는 각각의 상황에 맞게 찾아서 사용해보자.