본문 바로가기
JAVA

[자바] 스트림 API - 활용

by 감자b 2025. 4. 6.

중간 연산 - FlatMap

map은 각 요소를 단순히 하나의 값으로 변환 

flatMap은 각 요소를 또 다른 컬렉션이나 스트림으로 변환한 뒤, 그 결과를 하나의 스트림으로 평탄화

 

FlatMap 동작 과정

아래와 같이 중첩 리스트 구조를 가진 컬렉션이 있다고 하자. [[1, 2], [3, 4], [5, 6]]

public class Test {
    public static void main(String[] args) {
        List<List<Integer>> list = List.of(
                List.of(1, 2),
                List.of(3, 4),
                List.of(5, 6)
        );
        System.out.println("list = " + list);
    }
}

 

Map의 경우

// map
List<Stream<Integer>> mapResult = list.stream()
		.map(inner -> inner.stream())
		.toList();
System.out.println("mapResult = " + mapResult);

 

1. list.stream()을 통해 Stream<List<Integer>> 형태로 변환

2. map(inner -> inner.stream())을 통해 내부의 list가 각각 Stream으로 변환. 따라서 Stream<Stream<Integer>>가 된다.

3. toList()를 통해 스트림을 리스트로 변환한다. 따라서 List<Stream<Integer>> 형태로 변환된다.

따라서 최종 반환은 List<Stream<Integer>>가 된다.

 

FlatMap의 경우

// flatMap
List<Integer> flatMapResult = list.stream()
		.flatMap(inner -> inner.stream())
		.toList();
System.out.println("flatMapResult = " + flatMapResult);

 

1. list.stream()을 통해 Stream<List<Integer>> 형태로 변환

2. flatMap(inner -> inner.stream())을 통해 내부의 list가 각각 Stream으로 변환한 후, 각 스트림들의 내부의 값을 꺼내어서 외부의 스트림에 포함한다. 따라서 Stream<Integer>가 된다.

3. toList()를 통해 스트림을 리스트로 변환한다. 따라서 List<Integer> 형태로 변환된다.

따라서 최종 반환은 List<Integer>가 된다.

 

즉 flatMap은 각 요소를 스트림으로 변환한 후 해당 값을 꺼내어서 외부 스트림에 포함하여 중첩 구조를 일차원으로 펼치는 작업을 수행한다.


컬렉터

스트림의 collect()는 스트림 요소들을 가공해서 하나의 결과로 수집할 때 사용하는 최종 연산이다.

<R, A> R collect(Collector<? super T, A, R> collector);

 

여기서 Collector는 스트림의 요소를 어떻게 수집할지 정의하는 인터페이스이고, java.util.stream.Collectors 클래스는 정적 메서드를 사용해 다양한 수집 방식을 적용할 수 있도록 지원하는 유틸리티 클래스이다.

Collectors에서 자주 쓰이는 메서드와 사용 방법에 대해 하나씩 알아보도록 하겠다.

 

컬렉션 수집 List<T> toList() 요소를 List로 수집
Set<T> toSet() 요소르 Set으로 수집
Collection<T> toCollection(Supplier) 지정한 컬렉션 타입으로 수집
Map 수집 Map<K, V> toMap(keyMapper, valueMapper) 요소를 키-값 형태로 수집 (키 중복 시 예외)
Map<K, V> toMap(keyMapper, valueMapper, mergeFunction) 위와 같지만 키 중복 시 병합 함수로 처리
그룹화 Map<K, List<T>> groupingBy(classifier) 특정 기준 함수(classifier)에 따라 그룹핑
Map<K, D> groupingBy(classifier, downstream) 그룹핑 + 각 그룹에 추가로 적용할 다운 스트림 컬렉터 지정 가능
분할 Map<Boolean, List<T>> partitioningBy(predicate) predicate 조건에 맞는 그룹, 그렇지 않은 그룹 2개로 분할
Map<Boolean, D> partitioningBy(predicate, downstream) 두 그룹으로 분할 + 추가 적용할 다운 스트림 컬렉터 지정 가능
통계 Long counting() 요소 개수 카운트
Integer summingInt(ToIntFunction) int 속성 합계
Double avergagingDouble(ToDoubleFunction) double 속성 평균
IntSummaryStatistics summarizingInt(ToIntFunction) 합계, 평균, 최소, 최대를 포함한 통계 객체
리듀싱 T reducing(identity, accumulator) 누적 연산
연결 String joining(delimiter, prefix, suffix) 문자열 합치기
매핑 D mapping(mapper, downstream)
각 요소를 다른 값으로 변환한 뒤 추가 적용할 다운스트림 컬렉터 지정 가능

 

1. Map<K, V> toMap(keyMapper, valueMapper, mergeFunction)

public class Test {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "apricot");

        Map<Character, String> result = words.stream()
                .collect(Collectors.toMap(
                        word -> word.charAt(0),      // keyMapper: 첫 글자
                        word -> word,                // valueMapper: 단어 그대로
                        (v1, v2) -> v1 + ", " + v2   // mergeFunction: 키 중복 시 기존 값 + 새 값
                ));
        System.out.println(result); // {a=apple, apricot, b=banana}
    }
}

 

2. Map<K, List<T>> groupingBy(classifier)

public class Test {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "apricot");
        Map<Character, List<String>> grouped = words.stream()
                .collect(Collectors.groupingBy(word -> word.charAt(0))); // 첫글자가 같은 단어끼리 그룹화
        System.out.println(grouped); // {a=[apple, apricot], b=[banana]}
    }
}

 

3. T reducing(identity, accumulator)

public class Test {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5);
        int sum = nums.stream()
                .collect(Collectors.reducing(0, (a, b) -> a + b));
        System.out.println(sum); // 15
    }
}

 

4. String joining(delimiter, prefix, suffix)

public class Test {
    public static void main(String[] args) {
        List<String> fruits = List.of("apple", "banana", "cherry");
        String result = fruits.stream()
                .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(result); // [apple, banana, cherry]
    }
}

 

여기서 3번의 경우를 보면 스트림에서 직접 제공하는 reduce()와 중복되고 이를 사용하면 더 간단하게 작성할 수 있다.

public class Test {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5);
        int sum = nums.stream()
                .reduce(0, (a, b) -> a + b);
        System.out.println(sum); // 15
    }
}

 

그렇다면 Collectors에 이런 기능이 존재하는 이유는 무엇일까?
그 이유는 Collectors.reducing()이 단독으로도 사용 가능하지만, 다른 Collector와 조합이 가능하다는 큰 장점이 있기 때문이다.

예를 들어 groupingBy나 partitioningBy와 함께 사용하면, 그룹화된 항목들에 대해 누적, 요약, 변환 등의 작업을 유연하게 처리할 수 있다.

이처럼 컬렉터 안에 또 다른 컬렉터가 들어가는 구조에서, 내부에 전달되어 개별 항목들을 처리하는 역할을 하는 컬렉터를 다운스트림 컬렉터(Downstream Collector)라고 한다.


다운 스트림 컬렉터

그룹화된 항목들에 대해 추가적인 연산을 처리하는 컬렉터를 다운스트림 컬렉터라고 한다.

위에서 자주 사용하는 Collectors의 메서드 중에서 downstream을 인자로 받는 메서드를 살펴보도록 하겠다.

 

1. Map<K, D> groupingBy(classifier, downstream)

public class Test {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Anna", "Brian", "Amanda", "Kei");
        Map<Character, Long> result = names.stream()
                .collect(Collectors.groupingBy(
                        name -> name.charAt(0), // classifier: 첫 글자 기준 그룹핑
                        Collectors.counting()   // downstream: 각 그룹의 개수 세기
                ));
        System.out.println(result); // {A=3, B=2, K=1}
    }
}

1. 먼저 첫 글자를 기준으로 그룹화를 진행한다. 

이 때 타입은 Map<Character, List<String>>, 결과는 {A=[Alice, Anna, Amanda], B=[Bob, Brian], K=[Kei]}가 된다.

2. 컬렉터 안에 또 다른 컬렉터인 Collectors.counting()이 다운 스트림 컬렉터으로 동작한다.

다운 스트림 컬렉터는 그룹화된 각 그룹들에 대해 추가 연산을 진행한다.

여기서 각 그룹 요소의 개수는 A그룹은 3명, B그룹은 2명, K그룹은 1명이므로 {A=3, B=2, K=1}의 결과가 도출되며 반환 타입은 Map<Character, Long>이 된다.

 

2. Map<Boolean, D> partitioningBy(predicate, downstream)

public class Test {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
        Map<Boolean, Integer> result = numbers.stream()
                .collect(Collectors.partitioningBy(
                        n -> n % 2 == 0,    // predicate: 짝수/홀수 구분
                        Collectors.reducing(0, (a, b) -> a + b)
                ));
        System.out.println(result); // {false=9, true=12}
    }
}

1. 먼저 조건에 따라 분할을 진행한다.

짝수, 홀수에 따라 구분이 되므로 타입은 Map<Boolean, List<Integer>>가 되고, 결과는 false=[1, 3, 5], true=[2, 4, 6]}이 된다.

2. 컬렉터 안에 또 다른 컬렉터인 Collectors.reducing()을 통해 두 그룹에 누적합을 구한다.

false 그룹은 1 + 3 + 5 = 9, true 그룹은 2 + 4 + 6 = 12가 되면서 결과가 위와 같이 도출되며 반환 타입은 Map<Boolean, Integer>가 된다.  

 

3. D mapping(mapper, downstream)

public class Test {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "cherry");
        Set<Integer> lengths = words.stream()
                .collect(Collectors.mapping(
                        String::length,   // mapper: 문자열 → 길이
                        Collectors.toSet()
                ));
        System.out.println(lengths); // [5, 6]
    }
}

1. 먼저 각 단어를 문자열에서 문자열의 길이로 변환한다. 따라서 [5, 6, 6]으로 변환된다.

2. 그리고 다운 스트림 컬렉터인 Collectors.toSet()을 통해 Set으로 변환한다. 따라서 중복된 6 하나는 제거되어 [5, 6]의 결과가 도출된다.

 

추가로 다운 스트림 컬렉터를 생략하면 Collectors.toList()가 디폴트 값으로 적용된다.

 

이렇게 3가지 예시를 보았다면 다운 스트림 컬렉터가 무엇인지 이해가 될 것이다.

마지막으로 다운 스트림 컬렉터의 종류를 정리하고 마무리하도록 하겠다.

메서드명 반환 타입 설명
counting() Long 그룹 내 요소의 개수
summingInt(), summingLong() Integer, Long 그룹 내 요소를 합산
averagingInt(), averagingLong() Double 그룹 내 요소의 평균
minBy(), maxBy() Optional<T> 그룹 내 최소, 최대값 반환
summarizingInt(), summarizingLong() IntSummaryStatistics, LongSummaryStatistics 그룹 내 통계 정보를 포함한 객체 반환
mapping() downstream 반환 타입
요소를 변환 후, downstream으로 수집
collectingAndThen() 후처리 함수 타입 downstream 처리 후, 최종 결과를 한 번 더 후처리
reducing() mapper 반환 타입 그룹 내 요소를 mapper라는 누적 연산을 적용
toList(), toSet() List<T>, Set<T> 그룹 내 요소를 List, Set으로 수집

 

'JAVA' 카테고리의 다른 글

[자바] 스트림 API - 병렬 스트림  (0) 2025.04.08
[자바] ForkJoinPool  (0) 2025.04.07
[자바] 스트림 API  (0) 2025.04.05
[자바] 람다식과 함수형 인터페이스  (0) 2025.04.03
[자바] 디폴트 메서드  (0) 2025.04.03