개요
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를 제공해주기 때문에, 모든 기능에 대해서 외우기보다는 각각의 상황에 맞게 찾아서 사용해보자.
'Language > Java' 카테고리의 다른 글
| [Java] ReentrantLock을 통한 스레드 동기화 (0) | 2026.01.02 |
|---|---|
| [Java] 자바 스레드 생명 주기와 메모리 가시성 (0) | 2025.12.30 |
| [Java] Stream API와 지연 연산 (0) | 2025.12.25 |
| [Java] 메서드 참조 (0) | 2025.12.24 |
| [Java] 람다와 함수형 프로그래밍 (0) | 2025.12.23 |