[Java 개념노트 59] Stream 중간 연산과 최종 연산 자세히 보기

[Java 개념노트 59] Stream 중간 연산과 최종 연산 자세히 보기

안녕하세요. Java 개념노트 시리즈 쉰아홉 번째 글입니다.

지난 글에서는 컬렉션 데이터를 더 선언적으로 처리하는 Stream API의 기본 개념을 정리했습니다. 이번 글에서는 Stream에서 자주 사용하는 중간 연산최종 연산을 조금 더 자세히 알아보겠습니다.

Stream을 처음 배울 때 가장 헷갈리는 부분은 “어떤 메서드는 계속 이어 붙일 수 있고, 어떤 메서드는 마지막에만 써야 하는가?”입니다. 이 차이를 이해하려면 중간 연산최종 연산을 구분해야 합니다.


1. Stream 연산은 두 종류로 나뉜다

Stream 연산은 크게 중간 연산최종 연산으로 나뉩니다.

구분 설명 예시
중간 연산 Stream을 다시 Stream으로 반환합니다. filter(), map(), sorted(), distinct()
최종 연산 Stream 처리를 끝내고 결과를 만듭니다. collect(), count(), forEach(), reduce()

쉽게 말하면
중간 연산은 데이터를 가공하는 과정이고, 최종 연산은 가공된 결과를 실제로 꺼내는 마지막 작업입니다.

2. Stream 기본 흐름 다시 보기

Stream은 보통 다음 순서로 작성합니다.

컬렉션 준비
   ↓
stream()으로 Stream 생성
   ↓
중간 연산
   ↓
최종 연산

예를 들어 다음 코드를 보겠습니다.

subjects.stream()
        .filter(subject -> subject.length() >= 4)
        .map(subject -> subject.toUpperCase())
        .forEach(System.out::println);
코드 구분 역할
stream() Stream 생성 컬렉션에서 Stream을 만듭니다.
filter() 중간 연산 조건에 맞는 값만 통과시킵니다.
map() 중간 연산 값을 다른 형태로 변환합니다.
forEach() 최종 연산 값을 하나씩 처리하고 Stream을 끝냅니다.

3. 중간 연산은 여러 개 이어 붙일 수 있다

중간 연산은 결과로 다시 Stream을 반환합니다. 그래서 여러 개를 이어 붙일 수 있습니다.

subjects.stream()
        .filter(subject -> subject.length() >= 4)
        .distinct()
        .sorted()
        .map(String::toUpperCase)
        .forEach(System.out::println);

위 코드에서 filter(), distinct(), sorted(), map()은 모두 중간 연산입니다. 마지막의 forEach()는 최종 연산입니다.

핵심 포인트
중간 연산은 Stream을 계속 이어가는 연산이고, 최종 연산은 Stream을 끝내는 연산입니다.

4. 최종 연산은 마지막에 한 번 사용한다

최종 연산은 Stream 처리를 끝냅니다. 최종 연산이 실행되면 그 Stream은 더 이상 사용할 수 없습니다.

Stream<String> stream = subjects.stream();

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

// stream.count(); // 이미 사용한 Stream이라 오류 발생 가능

Stream은 한 번 흘러가는 데이터 흐름이라고 생각하면 됩니다. 한 번 끝까지 흘려보냈다면 다시 같은 Stream을 사용할 수 없습니다.

다시 사용하려면 새 Stream을 만들어야 합니다.

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

long count = subjects.stream().count();

5. 중간 연산은 최종 연산이 있어야 실행된다

Stream의 중간 연산은 최종 연산이 없으면 실제로 실행되지 않습니다. 이것을 지연 실행이라고 합니다.

subjects.stream()
        .filter(subject -> {
            System.out.println("filter 실행: " + subject);
            return subject.length() >= 4;
        });

위 코드는 최종 연산이 없기 때문에 filter() 안의 출력문이 실행되지 않습니다.

최종 연산을 붙이면 그때 실행됩니다.

subjects.stream()
        .filter(subject -> {
            System.out.println("filter 실행: " + subject);
            return subject.length() >= 4;
        })
        .forEach(System.out::println);

주의
Stream 중간 연산은 최종 연산이 있을 때 실제로 동작합니다. 중간 연산만 작성하고 결과가 나오지 않는다면 최종 연산이 있는지 확인해야 합니다.

6. filter() 자세히 보기

filter()는 조건에 맞는 값만 통과시키는 중간 연산입니다. 조건 결과가 true인 값만 다음 단계로 넘어갑니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("SQL");
        subjects.add("JPA");

        subjects.stream()
                .filter(subject -> subject.length() >= 4)
                .forEach(System.out::println);
    }
}

실행 결과는 다음과 같습니다.

Java
Spring

JavaSpring은 길이가 4 이상이므로 통과하고, SQLJPA는 제외됩니다.

7. filter()는 Predicate와 연결된다

filter() 안에는 조건식을 넣습니다. 이 조건식은 Predicate 역할을 합니다.

filter(value -> 조건식)

예를 들어 점수가 60점 이상인 학생만 고르는 코드는 다음과 같습니다.

students.stream()
        .filter(student -> student.getScore() >= 60)
        .forEach(student -> System.out.println(student.getName()));

조건이 true인 학생만 다음 단계로 넘어갑니다.

8. map() 자세히 보기

map()은 값을 다른 값으로 변환하는 중간 연산입니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("SQL");

        subjects.stream()
                .map(subject -> subject.length())
                .forEach(System.out::println);
    }
}

실행 결과는 다음과 같습니다.

4
6
3

문자열이 각각 문자열 길이 숫자로 변환되었습니다.

Java    → 4
Spring  → 6
SQL     → 3

9. map()은 Function과 연결된다

map()은 값을 받아 다른 값으로 바꾸는 연산입니다. 그래서 함수형 인터페이스 Function과 연결됩니다.

map(value -> 변환결과)

상품 객체에서 상품명만 꺼내고 싶다면 다음처럼 작성할 수 있습니다.

products.stream()
        .map(product -> product.getName())
        .forEach(System.out::println);

객체 전체가 아니라 필요한 값만 추출할 수 있습니다.

10. map()과 메서드 참조

람다식이 단순히 기존 메서드를 호출하는 형태라면 메서드 참조를 사용할 수 있습니다.

람다식

subjects.stream()
        .map(subject -> subject.length())
        .forEach(System.out::println);

메서드 참조

subjects.stream()
        .map(String::length)
        .forEach(System.out::println);

String::length는 전달된 문자열의 length() 메서드를 호출합니다.

11. sorted() 자세히 보기

sorted()는 Stream의 값을 정렬하는 중간 연산입니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Spring");
        subjects.add("Java");
        subjects.add("SQL");

        subjects.stream()
                .sorted()
                .forEach(System.out::println);
    }
}

실행 결과는 다음과 같습니다.

Java
SQL
Spring

문자열은 기본적으로 사전식 순서에 가깝게 정렬됩니다.

12. sorted()에 직접 정렬 기준 넣기

정렬 기준을 직접 넣고 싶다면 람다식을 사용할 수 있습니다.

subjects.stream()
        .sorted((a, b) -> a.length() - b.length())
        .forEach(System.out::println);

실행 결과는 다음과 같습니다.

SQL
Java
Spring

문자열 길이가 짧은 순서대로 정렬되었습니다.

반대로 긴 순서대로 정렬하고 싶다면 비교 순서를 바꿀 수 있습니다.

subjects.stream()
        .sorted((a, b) -> b.length() - a.length())
        .forEach(System.out::println);

13. distinct() 자세히 보기

distinct()는 중복된 값을 제거하는 중간 연산입니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("Java");
        subjects.add("SQL");
        subjects.add("Spring");

        subjects.stream()
                .distinct()
                .forEach(System.out::println);
    }
}

실행 결과는 다음과 같습니다.

Java
Spring
SQL

중복된 JavaSpring은 한 번씩만 통과했습니다.

14. 객체에서 distinct()를 사용할 때

직접 만든 객체에서 distinct()로 중복을 제거하려면 equals()hashCode()가 중요합니다.

예를 들어 회원 id가 같으면 같은 회원으로 판단하고 싶다면 equals()hashCode()를 오버라이딩해야 합니다. 이 부분은 HashSet에서 객체 중복을 판단할 때와 비슷합니다.

기억하기
객체의 중복 제거 기준을 직접 정하고 싶다면 equals()hashCode()를 함께 확인해야 합니다.

15. limit() 자세히 보기

limit()은 앞에서부터 원하는 개수만 가져오는 중간 연산입니다.

ArrayList<String> subjects = new ArrayList<>();

subjects.add("Java");
subjects.add("Spring");
subjects.add("SQL");
subjects.add("JPA");

subjects.stream()
        .limit(2)
        .forEach(System.out::println);

실행 결과는 다음과 같습니다.

Java
Spring

앞에서부터 2개만 가져왔습니다.

16. skip() 자세히 보기

skip()은 앞의 일부 값을 건너뛰는 중간 연산입니다.

ArrayList<String> subjects = new ArrayList<>();

subjects.add("Java");
subjects.add("Spring");
subjects.add("SQL");
subjects.add("JPA");

subjects.stream()
        .skip(2)
        .forEach(System.out::println);

실행 결과는 다음과 같습니다.

SQL
JPA

앞의 2개인 Java, Spring을 건너뛰고 나머지 값만 출력했습니다.

17. limit()과 skip() 함께 사용하기

limit()skip()을 함께 사용하면 일부 구간만 가져올 수 있습니다.

subjects.stream()
        .skip(1)
        .limit(2)
        .forEach(System.out::println);

데이터가 [Java, Spring, SQL, JPA]라면 실행 결과는 다음과 같습니다.

Spring
SQL

먼저 Java 하나를 건너뛰고, 그다음 2개인 Spring, SQL만 가져왔습니다.

18. peek() 맛보기

peek()는 Stream 처리 중간에 값을 확인할 때 사용할 수 있는 중간 연산입니다. 주로 디버깅이나 흐름 확인용으로 사용합니다.

subjects.stream()
        .peek(subject -> System.out.println("처리 전: " + subject))
        .filter(subject -> subject.length() >= 4)
        .peek(subject -> System.out.println("필터 통과: " + subject))
        .forEach(System.out::println);

peek()는 값을 바꾸기보다는 중간 상태를 확인하는 용도에 가깝습니다. 실제 변환은 map()을 사용하는 것이 좋습니다.

19. collect() 자세히 보기

collect()는 Stream 결과를 다시 컬렉션으로 모으는 최종 연산입니다. 가장 자주 사용하는 형태는 Collectors.toList()입니다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("SQL");
        subjects.add("JPA");

        List<String> result = subjects.stream()
                                      .filter(subject -> subject.length() >= 4)
                                      .collect(Collectors.toList());

        System.out.println(result);
    }
}

실행 결과는 다음과 같습니다.

[Java, Spring]

조건에 맞는 값을 새 List로 모았습니다.

20. count() 자세히 보기

count()는 Stream의 요소 개수를 세는 최종 연산입니다. 반환 타입은 long입니다.

long count = subjects.stream()
                     .filter(subject -> subject.length() >= 4)
                     .count();

System.out.println(count);

실행 결과는 다음과 같습니다.

2

길이가 4 이상인 값이 2개이기 때문에 2가 출력됩니다.

21. forEach() 자세히 보기

forEach()는 Stream의 값을 하나씩 소비하는 최종 연산입니다. 주로 출력이나 간단한 처리에 사용합니다.

subjects.stream()
        .forEach(subject -> System.out.println(subject));

메서드 참조를 사용하면 다음처럼 쓸 수 있습니다.

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

다만 forEach()는 값을 모아서 반환하지 않습니다. 결과 목록이 필요하다면 collect()를 사용해야 합니다.

22. reduce() 자세히 보기

reduce()는 Stream의 값을 하나로 누적하는 최종 연산입니다. 합계, 곱셈, 문자열 연결 같은 작업에 사용할 수 있습니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> numbers = new ArrayList<>();

        numbers.add(10);
        numbers.add(20);
        numbers.add(30);

        int sum = numbers.stream()
                         .reduce(0, (a, b) -> a + b);

        System.out.println(sum);
    }
}

실행 결과는 다음과 같습니다.

60

초기값 0에서 시작해서 값을 하나씩 더했습니다.

0 + 10 = 10
10 + 20 = 30
30 + 30 = 60

23. anyMatch(), allMatch(), noneMatch()

조건 만족 여부를 확인할 때는 anyMatch(), allMatch(), noneMatch()를 사용할 수 있습니다.

메서드 의미
anyMatch() 하나라도 조건을 만족하면 true
allMatch() 모두 조건을 만족하면 true
noneMatch() 모두 조건을 만족하지 않으면 true
ArrayList<Integer> scores = new ArrayList<>();

scores.add(90);
scores.add(80);
scores.add(70);

boolean hasPerfect = scores.stream()
                           .anyMatch(score -> score == 100);

boolean allPass = scores.stream()
                        .allMatch(score -> score >= 60);

boolean noFail = scores.stream()
                       .noneMatch(score -> score < 60);

System.out.println(hasPerfect);
System.out.println(allPass);
System.out.println(noFail);

실행 결과는 다음과 같습니다.

false
true
true

24. findFirst()와 findAny()

조건에 맞는 값을 하나 찾고 싶을 때는 findFirst()findAny()를 사용할 수 있습니다.

메서드 의미
findFirst() 조건에 맞는 첫 번째 값을 찾습니다.
findAny() 조건에 맞는 값 중 아무거나 하나 찾습니다.
import java.util.ArrayList;
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("SQL");

        Optional<String> result = subjects.stream()
                                         .filter(subject -> subject.length() >= 4)
                                         .findFirst();

        System.out.println(result.orElse("없음"));
    }
}

실행 결과는 다음과 같습니다.

Java

findFirst()의 결과는 Optional로 반환됩니다. 값이 없을 수도 있기 때문입니다. Optional은 다음 글들에서 더 자세히 다루겠습니다.

25. min()과 max()

Stream에서 최솟값과 최댓값을 찾을 때는 min(), max()를 사용할 수 있습니다.

import java.util.ArrayList;
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> scores = new ArrayList<>();

        scores.add(90);
        scores.add(80);
        scores.add(70);

        Optional<Integer> minScore = scores.stream()
                                           .min((a, b) -> a - b);

        Optional<Integer> maxScore = scores.stream()
                                           .max((a, b) -> a - b);

        System.out.println(minScore.orElse(0));
        System.out.println(maxScore.orElse(0));
    }
}

실행 결과는 다음과 같습니다.

70
90

min()max()도 값이 없을 가능성이 있으므로 Optional을 반환합니다.

26. 중간 연산과 최종 연산 전체 예제

이번에는 상품 목록에서 가격이 40000원 이상인 상품만 골라 이름을 정렬하고 List로 모아보겠습니다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        ArrayList<Product> products = new ArrayList<>();

        products.add(new Product("키보드", 50000));
        products.add(new Product("마우스", 30000));
        products.add(new Product("모니터", 200000));
        products.add(new Product("USB", 10000));
        products.add(new Product("키보드", 50000));

        List<String> result = products.stream()
                                      .filter(product -> product.getPrice() >= 40000)
                                      .map(product -> product.getName())
                                      .distinct()
                                      .sorted()
                                      .collect(Collectors.toList());

        long count = products.stream()
                             .filter(product -> product.getPrice() >= 40000)
                             .count();

        System.out.println("결과 목록: " + result);
        System.out.println("조건에 맞는 상품 수: " + count);
    }
}

class Product {
    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

실행 결과는 다음과 같습니다.

결과 목록: [모니터, 키보드]
조건에 맞는 상품 수: 3

처리 흐름은 다음과 같습니다.

상품 목록
   ↓
가격이 40000원 이상인 상품만 필터링
   ↓
상품명만 추출
   ↓
중복 상품명 제거
   ↓
상품명 정렬
   ↓
List로 수집

27. 중간 연산과 최종 연산 사용 시 자주 하는 실수

Stream 중간 연산과 최종 연산을 처음 사용할 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
중간 연산만 작성하고 끝냄 최종 연산이 없으면 실행되지 않음 forEach(), collect(), count() 같은 최종 연산 사용
Stream을 재사용하려고 함 한 번 최종 연산 후 Stream은 재사용 불가 필요할 때마다 stream()을 다시 호출
map()filter()를 혼동 조건 검사와 값 변환을 헷갈림 filter()는 고르기, map()은 바꾸기로 기억
forEach()로 결과를 모으려고 함 forEach는 반환값이 없음 결과 컬렉션이 필요하면 collect() 사용
peek()로 값을 바꾸려고 함 peek은 중간 확인용에 가까움 값 변환은 map() 사용
findFirst() 결과를 바로 String으로 생각 결과는 Optional 타입 orElse() 등으로 값이 없을 때 처리

28. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        ArrayList<Student> students = new ArrayList<>();

        students.add(new Student("자바", 90));
        students.add(new Student("스프링", 85));
        students.add(new Student("SQL", 55));
        students.add(new Student("JPA", 70));
        students.add(new Student("자바", 90));

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

        long failCount = students.stream()
                                 .filter(student -> student.getScore() < 60)
                                 .count();

        boolean allPass = students.stream()
                                  .allMatch(student -> student.getScore() >= 60);

        Optional<Student> topStudent = students.stream()
                                               .max((a, b) -> a.getScore() - b.getScore());

        System.out.println("합격자 이름: " + passNames);
        System.out.println("불합격자 수: " + failCount);
        System.out.println("전원 합격 여부: " + allPass);

        if (topStudent.isPresent()) {
            System.out.println("최고 점수 학생: " + topStudent.get().getName());
        }
    }
}

class Student {
    private String name;
    private int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }
}

실행 결과는 다음과 같습니다.

합격자 이름: [JPA, 스프링, 자바]
불합격자 수: 1
전원 합격 여부: false
최고 점수 학생: 자바

29. 이번 글 정리

이번 글에서는 Stream의 중간 연산과 최종 연산을 자세히 정리했습니다. 핵심 내용은 다음과 같습니다.

  • Stream 연산은 중간 연산과 최종 연산으로 나뉜다.
  • 중간 연산은 Stream을 다시 Stream으로 반환한다.
  • 최종 연산은 Stream 처리를 끝내고 결과를 만든다.
  • 중간 연산은 최종 연산이 있어야 실제로 실행된다.
  • filter()는 조건에 맞는 값만 통과시킨다.
  • filter()는 Predicate와 연결된다.
  • map()은 값을 다른 값으로 변환한다.
  • map()은 Function과 연결된다.
  • sorted()는 값을 정렬한다.
  • distinct()는 중복을 제거한다.
  • limit()은 앞에서부터 원하는 개수만 가져온다.
  • skip()은 앞의 일부 값을 건너뛴다.
  • peek()는 중간 흐름 확인용으로 사용할 수 있다.
  • collect()는 결과를 컬렉션으로 모은다.
  • count()는 개수를 센다.
  • forEach()는 값을 하나씩 처리한다.
  • reduce()는 값을 하나로 누적한다.
  • anyMatch(), allMatch(), noneMatch()는 조건 만족 여부를 확인한다.
  • findFirst(), min(), max()는 Optional 결과를 반환할 수 있다.
  • Stream은 한 번 최종 연산을 수행하면 재사용할 수 없다.

한 줄 요약
Stream 중간 연산은 데이터를 고르고 바꾸고 정렬하는 과정이고, 최종 연산은 그 결과를 실제 값이나 컬렉션으로 꺼내는 마지막 작업입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 60] Optional 클래스 이해하기라는 주제로 값이 있을 수도 없을 수도 있는 상황을 더 안전하게 다루는 방법을 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈