[Java] 63. 스트림 API1 - 기본

2025. 7. 4. 23:53·Java/Modern Java(8~)

 

스트림 API1 - 기본


#Java/adv3


스트림 API 시작

  • 우리가 만든 MyStreamV3를 사용할 때를 떠올려보면 데이터들이 흘러가면서 필터되고, 매핑된다.
  • 자바도 스트림 API라는 이름으로 스트림 관련 기능들을 제공한다. (I/O 스트림이 아니다.)
    • 자바가 제공하는 스트림 API는 더 정교하고, 더 많은 기능을 제공한다.
List<String> names = List.of("Apple", "Banana", "Berry", "Tomato");

// "B"로 시작하는 이름만 필터 후 대문자로 바꿔서 리스트로 수집
Stream<String> stream = names.stream();
List<String> result = stream
        .filter(name -> name.startsWith("B"))
        .map(s -> s.toUpperCase())
        .toList();

names.stream()
        .filter(name -> name.startsWith("B"))
        .map(s -> s.toUpperCase())
        .forEach(s -> System.out.println(s));
        
names.stream()
        .filter(name -> name.startsWith("B"))
        .map(String::toUpperCase) // 임의 객체의 인스턴스 메서드 참조(매개변수 참조)
        .forEach(System.out::println); // 특정 객체의 인스턴스 메서드 참조
  • 스트림 생성
    • List의 stream() 메서드를 사용하면 자바가 제공하는 스트림을 생성할 수 있다.
  • 중간 연산: filter, map
    • 스트림에서 요소를 걸러내거나(필터링), 다른 형태로 변환(매핑)하는 기능이다.
  • 최종 연산: toList()
    • 중간 연산에서 정의한 연산을 기반으로 최종 결과를 List로 만들어 반환한다.
  • 내부 반복(Internal Iteration) - forEach
    • 스트림에 대해 forEach()를 호출하면, 스트림에 담긴 요소들을 내부적으로 반복해가며 람다 표현식(또는 메서드 참조)에 지정한 동작을 수행한다. 개발자가 직접 for/while 문을 작성하지 않아도 된다.
  • 메서드 참조(Method Reference)
    • 람다 표현식에서 단순히 특정 메서드를 호출만 하는 경우에, 더 짧고 직관적으로 표현할 수 있는 문법
  • 스트림의 내부 반복을 통해, "어떻게 반복할지(for 루프, while 루프 등) 직접 신경 쓰기보다는, 결과가 어떻게 변환되어야 하는지"에만 집중할 수 있다. 이런 특징을 선언형 프로그래밍(Declarative Programming) 스타일이라 한다.

스트림 API란?

  • 스트림(Stream)은 자바 8부터 추가된 기능으로, 데이터의 흐름을 추상화해서 다루는 도구이다.
  • 컬렉션(Collection) 또는 배열 등의 요소들을 연산 파이프라인을 통해 연속적인 형태로 처리할 수 있게 해준다.
    • 스트림이 여러 단계를 거쳐 변환되고 처리되는 모습이 마치 물이 여러 파이프(관)를 타고 이동하면서 정수 시설이나 필터를 거치는 과정과 유사하다. 각 파이프 구간(중간 연산)마다 데이터를 가공하고 마지막 종착지(종료 연산)까지 흐르는 개념이 파이프라인과 비슷하다.
  • 스트림의 특징
  1. 데이터 소스를 변경하지 않음(Immutable)
    • 스트림에서 제공하는 연산들은 원본 컬렉션(예: List, Set)을 변경하지 않고 결과만 새로 생성한다.
  2. 일회성(1회 소비)
    • 한 번 사용(소비)된 스트림은 다시 사용할 수 없으며, 필요하다면 새로 스트림을 생성해야 한다.
    • stream.forEach(System.out::println); // 1. 최초 실행
    • stream.forEach(System.out::println); // 2. 스트림 중복 실행 불가
      • java.lang.IllegalStateException이 발생한다.
  3. 파이프라인(Pipeline) 구성
    • 중간 연산(map, filter 등)들이 이어지다가, 최종 연산(forEach, collect, reduce 등)을 만나면 연산이 수행되고 종료된다.
  4. 지연 연산(Lazy Operation)
    • 중간 연산은 필요할 때까지 실제로 동작하지 않고, 최종 연산이 실행될 때 한 번에 처리된다.
  5. 병렬 처리(Parallel) 용이
    • 스트림으로부터 병렬 스트림(Parallel Stream)을 쉽게 만들 수 있어서, 멀티코어 환경에서 병렬 연산을 비교적 단순한 코드로 작성할 수 있다.

파이프라인 구성: 우리가 만든 MyStreamV3와 자바 스트림 API의 차이점

  • 1-6의 숫자 중 짝수를 구하고, 구한 짝수에 10을 곱해서 출력하는 예제를 보자.
private static void ex1(List<Integer> data) {
    System.out.println("== MyStreamV3 시작 ==");
    List<Integer> result = MyStreamV3.of(data)
            .filter(i -> {
                boolean isEven = i % 2 == 0;
                System.out.println("filter() 실행: " + i + "(" + isEven + ")");
                return isEven;
            })
            .map(i -> {
                int mapped = i * 10;
                System.out.println("map() 실행: " + i + " -> " + mapped);
                return mapped;
            })
            .toList();
    System.out.println("result = " + result);
    System.out.println("== MyStreamV3 종료 ==");
}

private static void ex2(List<Integer> data) {
    System.out.println("== Stream API 시작 ==");
    List<Integer> result = data.stream()
            .filter(i -> {
                boolean isEven = i % 2 == 0;
                System.out.println("filter() 실행: " + i + "(" + isEven + ")");
                return isEven;
            })
            .map(i -> {
                int mapped = i * 10;
                System.out.println("map() 실행: " + i + " -> " + mapped);
                return mapped;
            })
            .toList();
    System.out.println("result = " + result);
    System.out.println("== Stream API 종료 ==");
}

어떤 차이가 존재할까? 실행 결과를 확인해보자.

== MyStreamV3 시작 ==
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = [20, 40, 60]
== MyStreamV3 종료 ==

== Stream API 시작 ==
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
filter() 실행: 3(false)
filter() 실행: 4(true)
map() 실행: 4 -> 40
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 6 -> 60
result = [20, 40, 60]
== Stream API 종료 ==
  • MyStreamV3는 모든 원소에 대해 filter를 완료하고, 그 결과로 나온 모든 원소에 대해 map()을 적용한다.
    • filter(1, 2, 3, 4, 5, 6) map(2, 4, 6) result
  • 자바 Stream API는 원소 filter를 진행하다가 통과한 원소가 존재하면, 그 원소를 바로 map()에 적용한다.
    1. data(1) -> filter(1) → false → 통과 못함, skip
    2. data(2) -> filter(2) → true → 통과, 바로 map(2) -> 20 실행 -> list(20)
    3. data(3) -> filter(3) → false
    4. data(4) -> filter(4) → true → 바로 map(4) -> 40 -> list(20,40)
    5. data(5) -> filter(5) → false
    6. data(6) -> filter(6) → true → 바로 map(6) -> 60 -> list(20,40,60)
  • MyStreamV3는 일괄 처리(Batch Processing) 방식이다.
    • 반죽을 모두 만들어 두고, 쌓아둔 반죽을 한꺼번에 굽고, 구워진 쿠키들을 한 번에 몰아서 포장한다.
    • 각 단계마다 결과물을 모아두고, 전체가 끝난 뒤에야 다음 단계로 넘긴다.
  • 자바 Stream API는 파이프라인 방식이다.
    • 차체 뼈대를 조립하고 나면, 바로 다음 공정으로 넘기고, 프레임이 오면 곧바로 엔진을 달아주고… 모든 공정이 끝난 차는 즉시 공장에서 출하한다.
    • 파이프라인 처리 방식은 하나의 작업이 처리되면 바로 다음 단계로 넘긴다.
    • 하나의 제품(자동차)이 여러 공정을 흐르듯이 쭉 통과하고, 끝난 차량은 바로 출하되는데, 이러한 처리를 파이프라인 처리라고 한다.
  • 핵심은 자바 스트림은 중간 단계에서 데이터를 모아서 한 방에 처리하지 않고, 한 요소가 중간 연산을 통과하면 곧바로 다음 중간 연산으로 "이어지는" 파이프라인 형태를 가진다는 점이다.

지연 연산: 자바 스트림은 toList()와 같은 최종 연산을 수행할 때만 작동한다.

private static void ex1(List<Integer> data) {
    System.out.println("== MyStreamV3 시작 ==");
    MyStreamV3.of(data)
        .filter(i -> {
            boolean isEven = i % 2 == 0;
            System.out.println("filter() 실행: " + i + "(" + isEven + ")");
            return isEven;
        })
        .map(i -> {
            int mapped = i * 10;
            System.out.println("map() 실행: " + i + " -> " + mapped);
            return mapped;
        });
    System.out.println("== MyStreamV3 종료 ==");
}

private static void ex2(List<Integer> data) {
    System.out.println("== Stream API 시작 ==");
    data.stream()
        .filter(i -> {
            boolean isEven = i % 2 == 0;
            System.out.println("filter() 실행: " + i + "(" + isEven + ")");
            return isEven;
        })
        .map(i -> {
            int mapped = i * 10;
            System.out.println("map() 실행: " + i + " -> " + mapped);
            return mapped;
        });
    System.out.println("== Stream API 종료 ==");
}
  • MyStreamV3 에서는 최종 연산(toList(), forEach())을 호출하지 않았는데도, filter() 와 map() 이 바로바로 실행되어 모든 로그가 찍힌다.
    • 즉시(Eager) 연산을 사용하고 있다. filter, map 같은 중간 연산이 호출될 때마다 바로 연산을 수행하고 저장한다.
  • 반면에 자바 스트림은 최종 연산(toList(), forEach() 등)이 호출되지 않으면 아무 일도 하지 않아 filter(), map() 부분의 로그가 찍히지 않았다.
    • 연산을 최대한 미루다가, 연산을 반드시 수행해야 하는 최종 연산을 만나면 본인이 가지고 있던 중간 연산들을 수행한다. 이러한 방식을 지연(Lazy) 연산이라 한다.
  • 스트림 API에서 중간 연산들은 "이런 일을 할 것이다"라는 파이프라인 설정을 해놓기만 하고, 정작 실제 연산은 최종 연산이 호출되기 전까지 전혀 진행되지 않는다. 람다를 내부에 저장만 해두고 실행하지는 않는 것이다. toList(), forEach() 요런게 호출되야 그때서야 항목들을 꺼내서 저장해둔 람다를 실행한다.

지연 연산과 최적화: 자바 스트림을 왜 이렇게 파이프라인 방식과 지연 연산 방식을 사용하도록 설계하였을까?

데이터 리스트 중에 짝수를 찾고, 찾은 짝수에 10을 곱하자. 이렇게 계산한 짝수 중에서 첫 번째 항목 하나만 찾는다고 가정해보자.

private static void ex1(List<Integer> data) {
    System.out.println("== MyStreamV3 시작 ==");
    Integer result = MyStreamV3.of(data)
            .filter(i -> {
                boolean isEven = i % 2 == 0;
                System.out.println("filter() 실행: " + i + "(" + isEven + ")");
                return isEven;
            })
            .map(i -> {
                int mapped = i * 10;
                System.out.println("map() 실행: " + i + " -> " + mapped);
                return mapped;
            })
            .getFirst();
    System.out.println("result = " + result);
    System.out.println("== MyStreamV3 종료 ==");
}

private static void ex2(List<Integer> data) {
    System.out.println("== Stream API 시작 ==");
    Integer result = data.stream()
            .filter(i -> {
                boolean isEven = i % 2 == 0;
                System.out.println("filter() 실행: " + i + "(" + isEven + ")");
                return isEven;
            })
            .map(i -> {
                int mapped = i * 10;
                System.out.println("map() 실행: " + i + " -> " + mapped);
                return mapped;
            })
            .findFirst().get();
    System.out.println("result = " + result);
    System.out.println("== Stream API 종료 ==");
}
  • 자바 스트림 API는 첫 번째 최종 연산을 구할수 있는 findFirst() 라는 기능을 지원한다.
    • (이 메서드는 Optional 객체를 반환한다)
== MyStreamV3 시작 ==
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = 20
== MyStreamV3 종료 ==
== Stream API 시작 ==
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
result = 20
== Stream API 종료 ==
  • MyStreamV3는 모든 요소(1~6)에 대해 필터 → 모두 통과한 요소에 대해 map을 끝까지 수행한 후 결과 목록 중 첫 번째 원소(20)을 꺼냈다. 총 9번의 연산을 수행했다.
  • 자바 스트림 API는 findFirst() 라는 최종 연산을 만나면, 조건을 만족하는 요소(2 -> 20)를 찾은 순간 연산을 멈추고 곧바로 결과를 반환해버린다. 총 3번의 연산만 수행했다.
    • filter(1) → false (버림)
    • filter(2) → true → map(2) -> 20 → findFirst()는 결과를 찾았으므로 종료
    • 따라서 이후의 요소(3, 4, 5, 6)에 대해서는 더 이상 filter, map 을 호출하지 않는다.
  • 이를 "단축 평가"(short-circuit)라고 하며, 조건을 만족하는 결과를 찾으면 더 이상 연산을 진행하지 않는 방식이다.
    • 지연 연산과 파이프라인 방식이 있기 때문에 가능한 최적화 중 하나이다.

지연 연산 정리: 지연 연산을 통해 얻을 수 있는 장점

  1. 불필요한 연산의 생략(단축, Short-Circuiting): 스트림이 실제로 데이터를 처리하기 직전에, "이후 연산 중에 어차피 건너뛰어도 되는 부분"을 알아내 불필요한 연산을 피할 수 있게 한다.
  2. 메모리 사용 효율: 중간 연산 결과를 매 단계마다 별도의 자료구조에 저장하지 않고, 최종 연산 때까지 필요할 때만 가져와서 처리한다.
  3. 파이프라인 최적화: 요소 하나하나 연산을 묶어서 실행할 수 있고, 중간 단계를 저장하지 않아 내부적으로 효율적으로 동작한다.

지연 연산과 파이프라인 구조를 이해하면, 스트림 API를 사용해 더 효율적이고 간결한 코드를 작성할 수 있게 된다.


Ref) 김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

'Java > Modern Java(8~)' 카테고리의 다른 글

[Java] 65. 스트림 API3 - 컬렉터  (0) 2025.07.04
[Java] 64. 스트림 API2 - 기능  (0) 2025.07.04
[Java] 62. 메서드 참조  (0) 2025.07.04
[Java] 61. 람다 vs 익명 클래스  (0) 2025.07.04
[Java] 60. 람다 활용  (0) 2025.07.04
'Java/Modern Java(8~)' 카테고리의 다른 글
  • [Java] 65. 스트림 API3 - 컬렉터
  • [Java] 64. 스트림 API2 - 기능
  • [Java] 62. 메서드 참조
  • [Java] 61. 람다 vs 익명 클래스
lumana
lumana
배움을 나누는 공간 https://github.com/bebeis
  • lumana
    Brute force Study
    lumana
  • 전체
    오늘
    어제
    • 분류 전체보기 (465)
      • 개발 일지 (28)
        • Performance (0)
        • TroubleShooting (1)
        • Refactoring (0)
        • Code Style, Convetion (0)
        • Architecture (1)
      • Software Engineering (36)
        • Test (8)
        • 이론 (18)
        • Clean Code (10)
      • Java (72)
        • Basic (5)
        • Core (21)
        • Collection (7)
        • 멀티스레드&동시성 (13)
        • IO, Network (8)
        • Reflection, Annotation (3)
        • Modern Java(8~) (13)
        • JVM (2)
      • Spring (53)
        • Framework (12)
        • MVC (23)
        • Transaction (3)
        • AOP (11)
        • Boot (0)
        • AI (0)
      • DB Access (16)
        • Jdbc (1)
        • JdbcTemplate (0)
        • JPA (14)
        • Spring Data JPA (0)
        • QueryDSL (0)
      • Computer Science (130)
        • Data Structure (27)
        • OS (14)
        • Database (10)
        • Network (21)
        • 컴퓨터구조 (6)
        • 시스템 프로그래밍 (23)
        • Algorithm (29)
      • HTTP (8)
      • Infra (1)
        • Docker (1)
      • 프로그래밍언어론 (15)
      • Programming Language(Sub) (77)
        • Kotlin (1)
        • Python (25)
        • C++ (51)
        • JavaScript (0)
      • FE (11)
        • HTML (1)
        • CSS (9)
        • React (0)
        • Application (1)
      • Unix_Linux (0)
        • Common (0)
      • PS (13)
        • BOJ (7)
        • Tip (3)
        • 프로그래머스 (0)
        • CodeForce (0)
      • Book Review (4)
      • Math (3)
        • Linear Algebra (3)
      • AI (7)
        • DL (0)
        • ML (0)
        • DA (0)
        • Concepts (7)
      • 프리코스 (4)
      • Project Review (6)
      • LegacyPosts (11)
      • 모니터 (0)
      • Diary (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
lumana
[Java] 63. 스트림 API1 - 기본
상단으로

티스토리툴바