↩️ 외부 반복자에서 내부 반복자로

자바에서 컬렉션을 다룰 때 우리는 보통 for, while, Iterator를 사용해왔다. 예를 들어 숫자 리스트에서 짝수만 출력한다고 해보자.

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

for (int number : numbers) {
    if (number % 2 == 0) {
        System.out.println(number);
    }
}

이 코드는 굉장히 익숙하다. 리스트에서 값을 하나씩 꺼내고, 조건을 검사하고, 조건에 맞으면 출력한다.

1 꺼냄 → 짝수인지 검사
2 꺼냄 → 짝수인지 검사 → 출력
3 꺼냄 → 짝수인지 검사
4 꺼냄 → 짝수인지 검사 → 출력
5 꺼냄 → 짝수인지 검사

이처럼 개발자가 컬렉션 바깥에서 직접 요소를 꺼내며 반복을 제어하는 방식을 외부 반복자라고 한다.

 

반면 Java 8부터 자주 사용하게 된 Stream은 조금 다른 방식으로 컬렉션을 처리한다.

numbers.stream()
        .filter(number -> number % 2 == 0)
        .forEach(number -> System.out.println(number));

이 코드에는 for, while, Iterator가 보이지 않는다. 그렇다고 반복이 사라진 것이 아니라 반복은 여전히 일어난다. 다만 개발자가 직접 반복을 제어하지 않을 뿐이다. 스트림 내부에서 반복이 일어나고, 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 이것이 바로 내부 반복자 방식이다.

 

💉 스트림을 비유로 이해하기

스트림을 처음 접하면 이런 코드가 낯설게 느껴진다.

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10)
        .toList();

stream()은 무엇이고, filter()는 무엇이며, map()은 무엇이고, 마지막에 toList()는 왜 필요한 걸까? 이걸 다음과 같이 비유해서 이해해보자.

내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList

여기서 중요한 점은 스트림이 원본 컬렉션을 직접 바꾸는 도구가 아니라는 것이다. 스트림은 컬렉션 안의 데이터를 하나씩 흘려보내면서, 중간중간 람다식으로 전달한 처리 규칙을 적용하고, 마지막에 원하는 결과를 꺼내 쓰는 방식이다. 즉, 스트림을 정리하면 다음과 같다.

"스트림은 컬렉션의 원본 데이터를 직접 수정하지 않고, 요소들을 하나씩 흘려보내며 처리한 뒤, 최종 연산을 통해 원하는 결과를 꺼내 쓰는 데이터 처리 방식이다."

 

🎭 컬렉션은 원본 데이터다

먼저 컬렉션이 있다.

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

numbers는 원본 데이터다. 비유하자면 내 몸 안에 있는 데이터라고 볼 수 있다.

내 몸 = 컬렉션
몸 안의 요소들 = 컬렉션 안의 데이터

 

numbers 안에는 1, 2, 3, 4, 5라는 값이 들어 있다. 이제 이 컬렉션을 스트림으로 바꿔보면...

Stream<Integer> stream = numbers.stream();

위와 같이 stream()을 호출했다고 해서 원본 리스트가 바뀌는 것은 아니다. numbers가 사라지는 것도 아니고, 새로운 리스트가 바로 만들어지는 것도 아니다. 단지 컬렉션 안의 요소들을 하나씩 처리할 수 있는 흐름을 만든 것이다.

"numbers.stream(): numbers 안의 요소들을 하나씩 흘려보낼 수 있는 통로를 만든다 == 빨대를 꽂는다(강사님 피셜)"

 

🌊 스트림은 결과물이 아니라 흐름이다

스트림을 처음 배울 때 가장 헷갈리는 부분이 있었는데, 바로 스트림 자체를 결과물처럼 생각하는 것이다. 예를 들어 다음 코드를 보자.

numbers.stream().filter(number -> number % 2 == 0);

이 코드는 짝수만 골라낸 것처럼 보인다. 하지만 이 시점에서 아직 List<Integer> 결과가 만들어진 것은 아니다. 찍어보면 위 코드의 타입은 여전히 Stream<Integer>인 것을 확인할 수 있다. 즉, 결과 리스트가 아니라 스트림이다.

주사기 비유로 마저 설명해보자면 대략 아래와 느낌이다.

  1. 주사기로 데이터를 뽑았는데,
  2. 아직 결과물을 꺼낸 것이 아니라
  3. 처리 규칙이 연결된 주사기 상태로 들고 있는 것이다.

따라서 "filter() 처리했으니까 필터링된 결과가 리스트로 담겼겠네?" 라는 이해는 완전히 잘못된 것이다. filter, map, sorted 같은 중간 연산은 결과물을 바로 꺼내주지 않는다. 대부분 다시 Stream을 반환한다.

numbers.stream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10);

위 코드도 아직 최종 결과가 아니라 numbers에서 요소를 흘려보내는데, 짝수만 통과시키고, 통과한 값을 10배한 값으로 바꾼 처리 규칙이 명시된 스트림일 뿐이다. 따라서 결과물을 실제로 꺼내려면 마지막에 최종 연산이 필요하다.

 

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10)
        .toList();

위와 같이 작성해야 결과가 리스트로 나온다. 정리하면 중간 연산만 연결했다면 여전히 Stream 타입이고 최종 연산을 해야 비로소 실제 결과물을 얻을 수 있다는 것이다.

 

📝 람다식은 스트림에 전달하는 처리 규칙이다

스트림에서는 람다식이 자주 등장한다. 예를 들어 다음 코드를 보자.

.filter(number -> number % 2 == 0)

위 코드에서 람다식은 number -> number % 2 == 0 부분인데, 이 람다식은 "숫자 하나를 받아서 짝수인지 검사하라" 와 같은 의미다.

스트림은 컬렉션의 요소를 하나씩 흘려보내면서 이 람다식을 적용하는 것이다.

  • 1 들어옴 → 1 % 2 == 0 → false → 버림
  • 2 들어옴 → 2 % 2 == 0 → true → 통과
  • 3 들어옴 → 3 % 2 == 0 → false → 버림
  • 4 들어옴 → 4 % 2 == 0 → true → 통과
  • 5 들어옴 → 5 % 2 == 0 → false → 버림

결국 2와 4만 통과하게 되는 것이다.

 

이번에는 map을 보자.

.map(number -> number * 10)

여기서 람다식은 number -> number * 10인데 숫자 하나를 받아서 10배로 바꾸라는 같은 의미를 갖는다. 따라서 앞에서 filter를 통과한 2, 4는 map을 지나면서 각각 20과 40으로 바뀌는 것이다. 즉, 람다식은 스트림에게 전달하는 처리 규칙이다.

 

  • filter()에 전달하는 람다식: 어떤 요소를 통과시킬지 판단하는 규칙
  • map()에 전달하는 람다식: 요소를 어떤 값으로 바꿀지 정하는 규칙
  • forEach()에 전달하는 람다식: 요소를 어떻게 소비할지 정하는 규칙

 

🧩 중간 연산: filter, map, sorted

스트림에는 크게 중간 연산, 최종 연산이 있다. 먼저 중간 연산부터 보자.

중간 연산은 스트림을 받아서 다시 스트림을 반환하는 연산이다. 대표적으로 다음과 같은 것들이 있다.

  • filter()
  • map()
  • sorted()
  • distinct()
  • limit()
  • skip()

 

중간 연산의 특징은 다음과 같다.

  1. 최종 결과를 바로 만들지 않는다.
  2. 대부분 Stream을 다시 반환한다.
  3. 여러 개를 연결해서 사용할 수 있다.
  4. 최종 연산이 호출되기 전까지 실제로 실행되지 않는다.

 

📌 filter(): 조건에 맞는 요소만 통과시킨다

filter는 조건에 맞는 요소만 통과시킨다.

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

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .toList();

System.out.println(result);

/**
 * [2, 4]
 */

filter에 전달된 람다식은 number -> number % 2 == 0로 각 요소에 대해 true 또는 false를 반환하고 true는 통과, false는 제외시킨다. 따라서 filter는 조건문과 비슷한 역할을 한다.

외부 반복자로 작성하면 다음과 같다.

List<Integer> result = new ArrayList<>();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number);
    }
}

스트림으로 작성하면 다음과 같다.

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .toList();

 

⛏️ map(): 요소를 다른 값으로 변환한다

map은 스트림을 지나가는 요소를 다른 값으로 변환한다.

List<String> names = List.of("kim", "lee", "park");

List<String> result = names.stream()
        .map(name -> name.toUpperCase())
        .toList();

System.out.println(result);

/**
 * [KIM, LEE, PARK]
 */

 

원본 리스트는 바뀌지 않는다.

System.out.println(names);  // [kim, lee, park]

즉, map은 원본 데이터를 직접 수정하는 것이 아니다. 스트림을 지나가는 값을 새로운 값으로 변환해서 다음 단계로 넘긴다.

"kim"  → "KIM"
"lee"  → "LEE"
"park" → "PARK"

 

객체 리스트에서도 자주 사용한다.

List<Student> students = List.of(
        new Student("Kim", 80),
        new Student("Lee", 90),
        new Student("Park", 70)
);

List<String> names = students.stream()
        .map(student -> student.getName())
        .toList();
        
/**
 * Student("Kim", 80)  → "Kim"
 * Student("Lee", 90)  → "Lee"
 * Student("Park", 70) → "Park"
 */

위와 같이 Student 객체에서 이름만 뽑아 새로운 리스트를 만든다.

 

⛓️ sorted(): 요소를 정렬한다

sorted는 스트림의 요소를 정렬한다.

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

List<Integer> result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/**
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */

여기도 마찬가지로 원본 리스트가 바뀌지 않는다. sorted()는 원본 컬렉션을 직접 정렬하는 것이 아니라 스트림을 지나가는 요소들을 정렬된 흐름으로 만들어주고, 최종 연산을 통해 새로운 결과를 꺼내는 것이다.

객체를 정렬할 때는 Comparator를 함께 사용할 수 있다.

List<Student> students = List.of(
        new Student("Kim", 80),
        new Student("Lee", 90),
        new Student("Park", 70)
);

List<Student> result = students.stream()
        .sorted(Comparator.comparing(Student::getScore))
        .toList();

이 코드는 학생들을 점수 기준으로 오름차순 정렬한 결과를 새 리스트로 만든다.

 

🎊 최종 연산: forEach, sum, collect, toList

중간 연산만으로는 결과를 꺼낼 수 없다. 스트림에서 결과를 꺼내려면 반드시 최종 연산이 필요하다. 대표적인 최종 연산은 다음과 같다.

  • forEach()
  • count()
  • sum()
  • average()
  • collect()
  • toList()
  • anyMatch()
  • allMatch()
  • findFirst()

최종 연산의 특징은 다음과 같다.

  1. 스트림을 실제로 실행시킨다.
  2. 결과를 반환하거나 소비한다.
  3. 최종 연산 이후 스트림은 다시 사용할 수 없다.

 

🥪 forEach(): 요소를 하나씩 소비한다

forEach는 스트림의 요소를 하나씩 꺼내서 소비한다.

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

numbers.stream()
        .filter(number -> number % 2 == 0)
        .forEach(number -> System.out.println(number));
        
/**
 * 2
 * 4
 */

forEach는 최종 연산이다. 하지만 결과를 리스트로 모아서 반환하지는 않는다. 각 요소를 하나씩 소비하고 끝낸다. 그래서 forEach는 보통 출력, 로그 확인, 특정 동작 수행 등에 사용된다.

 

🗃️ toList(): 결과를 리스트로 모은다

toList는 스트림의 결과를 리스트로 모은다.

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

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10)
        .toList();

System.out.println(result);

// [20, 40]

 

흐름을 풀어보면 다음과 같다.

원본 리스트: [1, 2, 3, 4, 5]

stream()
→ 요소를 하나씩 흘려보낼 준비

filter(number -> number % 2 == 0)
→ 짝수만 통과
→ 2, 4

map(number -> number * 10)
→ 통과한 값을 10배로 변환
→ 20, 40

toList()
→ 결과를 List로 추출
→ [20, 40]

 

🔍 collect(): 원하는 형태로 결과를 수집한다

collect는 스트림의 결과를 원하는 자료구조나 형태로 모을 때 사용한다.

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .collect(Collectors.toList());

 

Java 16 이후에는 단순히 리스트로 모을 때 toList()를 자주 사용할 수 있다.

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .toList();

 

하지만 collect는 더 다양한 수집 작업을 할 수 있다. 예를 들어 이름들을 하나의 문자열로 합칠 수 있다.

List<String> names = List.of("Kim", "Lee", "Park");

String result = names.stream()
        .collect(Collectors.joining(", "));

System.out.println(result);  // Kim, Lee, Park

또는 그룹화도 할 수 있다.

Map<Integer, List<Student>> result = students.stream()
        .collect(Collectors.groupingBy(Student::getGrade));

즉, collect는 스트림의 결과를 원하는 방식으로 수집하는 강력한 최종 연산이다.

 

✚ sum(): 숫자 값을 합산한다

sum은 숫자 스트림에서 합계를 구할 때 사용한다. 일반 Stream<Integer>에서는 바로 sum()을 사용할 수 없다. 보통 mapToInt를 사용해서 IntStream으로 바꾼 뒤 사용한다.

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

int sum = numbers.stream()
        .mapToInt(number -> number)
        .sum();

System.out.println(sum);  // 15

 

객체 리스트에서도 자주 사용한다.

List<Student> students = List.of(
        new Student("Kim", 80),
        new Student("Lee", 90),
        new Student("Park", 70)
);

int totalScore = students.stream()
        .mapToInt(student -> student.getScore())
        .sum();

System.out.println(totalScore);

흐름은 다음과 같다.

Student 객체들이 지나간다.
→ 각 Student에서 score만 뽑는다.
→ int 값들의 흐름이 된다.
→ sum()으로 합산한다.

 

🚫 중간 연산은 최종 연산이 호출되기 전까지 실행되지 않는다

스트림에서 중요한 특징 중 하나는 지연 실행(Lazy Evaluation)이다. 중간 연산은 최종 연산이 호출되기 전까지 실제로 실행되지 않는다. 다음 코드를 보자.

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

Stream<Integer> stream = numbers.stream()
        .filter(number -> {
            System.out.println("filter 실행: " + number);
            return number % 2 == 0;
        });

이 코드를 실행해도 아무것도 출력되지 않는다. 왜냐하면 아직 최종 연산이 없기 때문이다.

 

stream.toList();

이렇게 최종 연산을 호출해야 그때 실제로 실행된다.

filter 실행: 1
filter 실행: 2
filter 실행: 3
filter 실행: 4
filter 실행: 5

 

즉, 스트림의 중간 연산은 바로 실행되는 것이 아니라, 최종 연산이 호출될 때 한 번에 동작한다. 비유하자면 다음과 같다.

filter, map, sorted를 연결하는 것은
주사기에 처리 장치를 연결해두는 것과 비슷하다.

하지만 아직 실제로 뽑아낸 것은 아니다.

toList, collect, forEach 같은 최종 연산을 해야
비로소 데이터가 흐르면서 처리되고 결과가 나온다.

 

😢 스트림은 한 번 사용하면 다시 사용할 수 없다

스트림은 한 번 최종 연산을 수행하면 다시 사용할 수 없다.

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

Stream<Integer> stream = numbers.stream();

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

stream.forEach(System.out::println);  // 예외 발생

두 번째 forEach에서는 예외가 발생한다. 스트림은 일회용이다. 한 번 최종 연산으로 소비되면 다시 사용할 수 없다. 다시 사용하고 싶다면 원본 컬렉션에서 스트림을 새로 만들어야 한다.

numbers.stream().forEach(System.out::println);
numbers.stream().forEach(System.out::println);

 

비유하면 다음과 같다.

한 번 사용한 주사기는 버리고 새로운 주사기를 사용해야 한다.
다시 처리하고 싶다면 원본 컬렉션에서 새로운 스트림을 만들어야 한다는 말이다.

 

🤔 외부 반복자란 무엇인가?

이제 스트림을 외부 반복자와 내부 반복자 관점에서 다시 살펴보자. 그동안 컬렉션을 다룰 때 for, while, Iterator를 사용해왔다. 이 방식은 외부 반복자 방식이다. 외부 반복자는 개발자가 직접 컬렉션의 요소를 하나씩 꺼내며 반복을 제어하는 방식이다.

예를 들어 다음 코드를 보자.

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

List<Integer> result = new ArrayList<>();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number * 10);
    }
}

System.out.println(result);

 

위 코드에서 개발자는 다음 작업을 직접 수행한다.

  1. 새로운 리스트를 만든다.
  2. numbers에서 숫자를 하나씩 꺼낸다.
  3. 짝수인지 검사한다.
  4. 짝수라면 10을 곱한다.
  5. 결과 리스트에 추가한다.
  6. 반복이 끝나면 결과 리스트를 사용한다.

즉, 반복의 흐름을 개발자가 직접 제어한다. Iterator를 사용해도 마찬가지다.

Iterator<Integer> iterator = numbers.iterator();

while (iterator.hasNext()) {
    Integer number = iterator.next();

    if (number % 2 == 0) {
        result.add(number * 10);
    }
}

여기서도 개발자는 직접 다음 요소가 있는지 확인하고, 직접 요소를 꺼낸다.

iterator.hasNext()
iterator.next()

이처럼 컬렉션 바깥에서 개발자가 반복을 직접 제어하는 방식이 외부 반복자다.

 

🤔 내부 반복자란 무엇인가?

내부 반복자는 반복의 제어권을 개발자가 직접 가지지 않고, 스트림 내부에 맡기는 방식이다. 앞의 코드를 스트림으로 바꿔보자.

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

List<Integer> result = numbers.stream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10)
        .toList();

System.out.println(result);

 

이 코드에서 개발자는 직접 요소를 꺼내지 않는다. 대신 스트림에게 다음과 같이 요청한다.

  1. numbers에서 스트림을 만들어줘.
  2. 짝수만 통과시켜줘.
  3. 통과한 값을 10배로 바꿔줘.
  4. 리스트로 모아줘.

반복은 스트림 내부에서 일어난다. 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 즉, 내부 반복자는 반복은 스트림 내부에 맡기고, 개발자는 처리 규칙만 전달하는 방식이라고 이해할 수 있다.

 

외부 반복자와 내부 반복자의 차이점은 집중하는 관점이 다르다.

<외부 반복자>

  • 어떻게 반복할 것인가?
  • 어떻게 요소를 꺼낼 것인가?
  • 언제 결과 리스트에 추가할 것인가?

<내부 반복자>

  • 무엇을 골라낼 것인가?
  • 무엇으로 변환할 것인가?
  • 어떤 결과로 모을 것인가?

 

따라서 스트림은 단순히 반복문을 짧게 쓰는 문법이 아니다. 스트림은 데이터 처리 과정을 더 선언적으로 표현하게 해주는 도구라고 할 수 있다.

 

👍 스트림의 장점

외부 반복자 방식은 반복 로직 때문에 핵심 의도가 묻힐 때가 있다.

List<String> result = new ArrayList<>();

for (Student student : students) {
    if (student.getScore() >= 60) {
        result.add(student.getName());
    }
}

이 코드는 다음 과정을 직접 따라가야 한다.

학생을 하나씩 꺼낸다.
점수가 60점 이상인지 검사한다.
조건에 맞으면 이름을 결과 리스트에 추가한다.

 

반면, 위와 코드를 똑같이 스트림으로 작성하면 의도가 더 직접적으로 드러난다.

List<String> result = students.stream()
        .filter(student -> student.getScore() >= 60)
        .map(student -> student.getName())
        .toList();

이 코드는 다음처럼 읽힌다.

60점 이상인 학생만 골라서
이름만 뽑고
리스트로 만든다.

 

두 번째 장점으로, 스트림은 여러 중간 연산을 연결해서 사용할 수 있다.

List<String> result = students.stream()
        .filter(student -> student.getScore() >= 60)
        .map(student -> student.getName())
        .sorted()
        .toList();

이 코드는 다음처럼 읽을 수 있다.

60점 이상인 학생만 고른다.
학생 객체에서 이름만 뽑는다.
이름을 정렬한다.
리스트로 만든다.

각 단계가 파이프라인처럼 이어지기 때문에 데이터 처리 흐름을 파악하기 좋다.

 

세 번째 장점으로는 스트림은 원본 데이터를 직접 변경하지 않는다는 것이다.

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

List<Integer> result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/*
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */

원본 리스트는 그대로 유지되고, 정렬된 결과가 새롭게 만들어진다. 이 점은 데이터를 안전하게 다루는 데 도움이 된다.

 

그리고 스트림은 내부 반복자 방식이라고 했다. 이 말은 반복의 제어권을 개발자가 직접 가지는 것이 아니라 스트림이 가진다는 말이고, 이 구조 덕분에 경우에 따라 병렬 처리를 적용하기 쉽다.

List<Integer> result = numbers.parallelStream()
        .filter(number -> number % 2 == 0)
        .map(number -> number * 10)
        .toList();

물론 parallelStream()이 항상 좋은 것은 아니다. 데이터 양이 적거나, 처리 비용이 작거나, 순서가 중요하거나, 공유 자원을 변경하는 작업이 있다면 오히려 문제가 될 수 있다. 하지만 구조적으로 보면 내부 반복자는 반복 제어권을 라이브러리가 가지고 있기 때문에, 최적화나 병렬 처리에 유리한 여지가 생긴다.

 

🫣 스트림이 항상 정답은 아니다

스트림이 편리하다고 해서 항상 스트림을 써야 하는 것은 아니다. 단순한 반복은 오히려 for문이 더 읽기 쉬울 수 있다.

for (int i = 0; i < 5; i++) {
    System.out.println(i);
}

이런 코드를 굳이 스트림으로 바꾸면 오히려 어색해질 수 있다.

또한 반복 중간에 복잡한 상태 변경이 필요하거나, break, continue처럼 흐름 제어가 중요한 경우에는 일반 반복문이 더 적합할 수 있다. 예를 들어 특정 학생을 찾으면 즉시 반복을 멈추고 싶다고 해보자.

for (Student student : students) {
    if (student.getName().equals("Kim")) {
        System.out.println("찾았다!");
        break;
    }
}

스트림에도 findFirst, anyMatch 같은 최종 연산이 있지만, 복잡한 흐름 제어가 들어가면 오히려 코드가 읽기 어려워질 수 있다.

 

따라서 기준은 다음과 같이 잡으면 좋다.

  • 단순 반복, 복잡한 흐름 제어가 필요하다 → for문이 나을 수 있다.
  • 필터링, 변환, 정렬, 집계가 중심이다 → 스트림이 잘 어울린다.

 

🖋️ 정리

스트림을 처음 배울 때는 다음 세 가지를 반드시 기억해야 한다.

첫 번째, 스트림은 원본 컬렉션을 직접 변경하지 않는다.

원본 데이터는 그대로 두고,
처리 결과를 새롭게 만들어서 사용한다.

 

두 번째, 중간 연산만으로는 결과가 나오지 않는다.

filter, map, sorted는 중간 처리기다.
이 연산들은 대부분 다시 Stream을 반환한다.
따라서 최종 연산을 해야 결과를 꺼낼 수 있다.

 

세 번째, 스트림은 내부 반복자를 사용한다.

for, while, Iterator는 외부 반복자다.
개발자가 직접 요소를 꺼내며 반복을 제어한다.

Stream은 내부 반복자다.
반복은 스트림 내부에서 일어나고,
개발자는 람다식으로 처리 규칙만 전달한다.

 

결국 스트림은 다음과 같이 이해할 수 있다.

스트림은 컬렉션의 요소를 직접 꺼내던 외부 반복 방식에서 벗어나,
컬렉션 내부에서 요소를 흘려보내며
람다식으로 전달한 처리 규칙을 적용하고,
최종 연산으로 결과를 꺼내는 내부 반복 기반의 데이터 처리 방식이다.

 

처음 비유로 다시 돌아가면 다음과 같다.

내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList

 

단, 스트림은 결과물이 담긴 주사기 자체가 아니다. 스트림은 데이터를 흘려보내며 처리하는 흐름이다. 따라서 중간 처리기만 연결해두면 아직 결과물이 아니라 Stream 타입일 뿐이다. 결과물을 사용하려면 반드시 forEach, sum, collect, toList 같은 최종 연산으로 꺼내야 한다. 이 관점을 잡으면 스트림 코드는 훨씬 자연스럽게 읽힌다.

스트림은 단순히 반복문을 짧게 쓰기 위한 문법이 아니다. 스트림은 컬렉션 데이터를 더 선언적으로, 더 조합하기 쉽게, 그리고 원본 데이터를 직접 변경하지 않는 방식으로 처리하기 위한 도구다.

0개의 댓글