본문 바로가기
Java-Spring

"모던 자바 인 액션" 궁금했던 것들을 질문으로 정리해봤습니다 (2탄)

by 감사쟁이야 2025. 11. 24.

 

스트림을 사용하다 보면 어느 순간부터

“음… 이건 그냥 쓰긴 쓰는데, 정확히는 모르겠네?”

싶은 부분들이 생기기 시작합니다.

 

책을 읽다가 햇갈렸던 부분이 생겼습니다.

  • 무한 스트림은 어떻게 만들지? iterate와 generate는 뭐가 다르지?
  • flatMap은 왜 꼭 필요한 걸까?
  • 컬렉션이 null일 때 스트림은 어떻게 안전하게 만들지?
  • groupingBy랑 partitioningBy는 언제 무엇을 써야 할까?
  • reduce와 collect는 둘 다 ‘결과를 만든다’는데, 정확한 차이가 뭘까?

 

"헷갈렸던 부분들을 직접 코드로 실험해보고 정리한 내용"을 담았습니다.

 

 

CHAPTER 05 스트림 활용

🤔 무한 스트림 생성하는 방법

Q. 무한 스트림을 생성하는 방법에는 iterate 와 generate가 있는데요.
이 둘의 차이점은 무엇일까요?

 

스트림에서는 iterate()와 generate()를 사용해 무한 스트림을 만들 수 있습니다.

둘의 차이는 다음과 같습니다.

  • Stream.iterate(초기값, 조건, 연산자)
    • 초기값과 연산자를 기반으로 다음 값을 계산
    • 내부 상태를 불변 객체(immutable) 로 취급하며 안전함
  • Stream.generate(Supplier)
    • Supplier가 제공하는 값으로 스트림 생성
    • 내부 상태를 직접 변경할 수 있어 부작용(side effect) 발생 가능

책의 예제처럼 피보나치 수열을 만드는 과정을 각각의 메서드를 활용해봅시다.

Supplier<Integer> fib = new Supplier<>() {
    private int previous = 0;
    private int current = 1;

    @Override
    public Integer get() {
        int oldPrevious = this.previous;
        int nextValue = this.previous + this.current;
        this.previous = this.current;
        this.current = nextValue;
        return oldPrevious;
    }
};

@Test
void test() {
    List<Integer> iterateList = Stream.iterate(new int[]{0, 1},
                    t -> t[0] <= 514229,
                    t -> new int[]{t[1], t[0] + t[1]})
            .map(t -> t[0])
            .sorted()
            .collect(Collectors.toList());

    List<Integer> generateList = Stream.generate(fib)
            .takeWhile(t -> t <= 514229)
            .sorted()
            .collect(Collectors.toList());

    assertThat(iterateList.equals(generateList)).isTrue();
}

// iterate()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229]

// generate()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229]

두 방식 모두 동일한 피보나치 수열을 잘 만들어냅니다.

 

Q. 병렬 스트림으로 처리한다면 어떤 차이가 날까요?
@Test
void parallelTest() {
    List<Integer> iterateList = Stream.iterate(new int[]{0, 1},
                    t -> t[0] <= 514229,
                    t -> new int[]{t[1], t[0] + t[1]})
            .parallel()
            .map(t -> t[0])
            .sorted()
            .collect(Collectors.toList());

    List<Integer> generateList = Stream.generate(fib)
            .parallel()
            .takeWhile(t -> t <= 514229)
            .sorted()
            .collect(Collectors.toList());

    assertThat(iterateList.equals(generateList)).isTrue();
}

// 정상적인 피보나치 수열 출력
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229]

// iterate()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229]

// generate() 실행할 때마다 다르게 나온다.
// [0, 1, 1, 1, 2, 3, 5, 8, 8, 13, 21, 21, 34, 55, 89, 144, 233, 233, 377, 610, 987, 1597, 2584, 2584, 4181, 6765, 10946, 17711, 28657, 28657, 46368, 75025, 75025, 121393, 196418, 196418, 317811, 514229, 514229]

결과 차이

  • iterate()
    • 불변 객체를 기반으로 값을 생성 → 병렬에서도 안전
  • generate()
    • 외부 상태(previous, current)를 변경
    • 병렬 실행 시 값이 뒤섞여 부작용 발생
    • 실행할 때마다 결과가 달라짐

스트림 병렬 처리에서는 반드시 상태를 변경하지 않는 람다, 즉 순수 함수(pure function)를 사용해야 합니다.

 

🤔 flatMap 이란?

Q. flatMap은 어떤 상황에서 사용하는 것일까요?
어떻게 동작하는 것일까요?

 

flatMap()은 중첩된 스트림을 하나의 스트림으로 평평하게 만드는 작업(flattening)을 합니다.

 

예시: 문자열을 문자 스트림으로 분리

Stream<String> words = Stream.of("Hello", "World");

List<String> result = words
        .map(word -> word.split(""))
        .flatMap(Arrays::stream)
        .distinct()
        .collect(Collectors.toList());

 

  1. Hello가 split("")에 의해 ["H", "e", "l", "l", "o"] 로 분리되고, World가 split("")에 의해 ["W", "o", "r", "l", "d"]로 분리된다.
  2. Arrays.stream(T[] array)를 사용해 ["H", "e", "l", "l", "o"] 와 ["W", "o", "r", "l", "d"]를 각각 Stream으로 만든다.
  3. flatMap()을 사용해 여러 개의 Stream을 1개의 Stream으로 평평하게 합치고, Stream의 소스는 ["H", "e", "l", "l", "o", W", "o", "r", "l", "d"] 가 된다.
  4. distinct()에 의해 중복된 소스(l, o)가 제거된다.
  5. 중복이 제거된 ["H", "e", "l", "o", "W", "r", "d"]가 collect(toList())에 의해 수집된다.

 

예시 : 2차원 배열

 int[][] sample = new int[][]{
                {1, 2},
                {3, 4},
                {5, 6},
                {7, 8},
                {9, 10}
        };
IntStream intStream = Arrays.stream(sample)
                            .flatMapToInt(array -> Arrays.stream(array));

intStream.forEach(System.out::println);

결과

1
2
3
4
5
6
7
8
9
10

 

🤔 null에 안전한 스트림 생성 방법

null에 안전한 스트림은 어떻게 생성할까요?

 

컬렉션이 null일 때 바로 stream()을 호출하면 NPE가 발생합니다.

 

안전한 방식 1

public Stream collectionAsStream(Collection collection) {
    return collection != null ? collection.stream() : Stream.empty();
}

안전한 방식 2 (Optional 활용)

public Stream collectionAsStream(Collection collection) {
    return Optional.ofNullable(collection)
            .map(Collection::stream)
            .orElseGet(Stream::empty);
}

 

🤔 스트림 내에서 특정 null 값을 빼고 싶다면?

List<String> filtered = Optional.ofNullable(list)
        .orElseGet(Collections::emptyList)
        .stream()
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

 

 

CHAPTER 6 스트림으로 데이터 수집

🤔 groupingBy vs partitioningBy 차이

groupingBy — Function 기준으로 그룹화

Map<Dish.Type, List<Dish>> caloricDishesByType =
        menu.stream()
            .collect(groupingBy(Dish::getType));

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를

groupbingBy 메서드로 전달했습니다.

 

이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부릅니다.

 

다수준 그룹화도 가능합니다.

groupingBy(Dish::getType,
        groupingBy(dish -> {
            if (dish.getCalories() <= 400) return DIET;
            else if (dish.getCalories() <= 700) return NORMAL;
            else return FAT;
        })
)

 

partitioningBy — Predicate 기준으로 참/거짓 분류

Map<Boolean, List<Dish>> partitioned =
        menu.stream()
            .collect(partitioningBy(Dish::isVegetarian));
  • key가 무조건 true / false
  • 오직 두 그룹으로만 분할됨
  • Predicate만 전달할 수 있음

“두 그룹”으로 나누는 상황에는 partitioningBy가 groupingBy보다 성능이 더 좋습니다.

 

🤔 reduce vs collect 차이

둘 다 최종 연산이지만 목적과 동작 방식이 다릅니다.

 

reduce

  • 주로 값을 하나로 축소할 때 사용
  • 초기값 + BinaryOperator 필요
Optional<Integer> product = numbers.stream()
        .reduce((a, b) -> a * b);

 

collect

  • 스트림을 컬렉션, 맵, DTO 등으로 수집
  • 스트림의 요소를 수집하여 다양한 컨테이너에 넣거나 결과를 변환하는 데 사용됨
  • Collector를 사용하며, 병렬 스트림에서 reduce보다 더 안전하고 효율적
List<Integer> squared = numbers.stream()
        .map(x -> x * x)
        .collect(Collectors.toList());

collect는 병렬 스트림에서 효과적으로 동작하는 반면

reduce는 병렬 처리에 대한 효율성이 떨어질 수 있습니다.

 

일반적으로 데이터를 수집하거나 변환할 때는 collect를 사용하고,

최종 결과를 계산할 때는 reduce를 사용하는 것이 일반적인 패턴입니다.

 

 

마무리

이번 글에서는 무한 스트림, flatMap, null-safe 처리,

groupingBy·partitioningBy, reduce·collect 등

실무에서 자주 등장하지만 헷갈리기 쉬운 요소들을 다시 보았습니다.

 

스트림은 알아갈수록 더 강력하고, 동시에 더 조심스럽게 다뤄야 하는 도구라 생각합니다.

 

앞으로도 책을 읽으며 떠오른 질문들을 하나씩 풀어내고,

실험한 내용을 차곡차곡 쌓아갈 예정이니 다음 글도 기대해주세요.

 

읽어주셔서 감사합니다. ☺️