Java Stream API
Language/Java 2026. 9. 7. 23:02

Java Stream API

@Beemo9
목차

요즘 AI 에이전트를 활용해 바이브코딩을 하다 보면 유독 자주 마주치는 코드가 있다.

바로 Stream API를 활용한 코드다.

 

Stream API가 일반적인 반복문에 비해 눈에 띄는 성능 차이를 만들어내는 것도 아니고,

평소에도 크게 필요성을 느끼지 못했던 나는 그동안 Stream API를 적극적으로 사용하지 않았다.

 

문제는 AI가 작성해주는 코드에서 Stream API를 활용한 로직을 자주 마주치게 되면서부터였다.

코드 리뷰를 하다가 Stream API가 복잡하게 체이닝되어 있으면, 그때마다 AI에게 "이 코드가 정확히 어떤 로직으로 동작하는지"를 다시 물어보고, 설명을 읽은 뒤 대략적으로 이해하고 넘어가는 일이 반복됐다.

 

처음에는 크게 불편하지 않았다.

하지만 AI가 작성한 코드를 리뷰하는 일이 많아질수록 같은 과정이 반복되면서 생각보다 많은 시간이 소요됐다.

 

결국 문제는 Stream API 자체가 아니라, 내가 그 코드를 직접 읽고 로직을 해석할 만큼 Stream API에 익숙하지 않다는 것이었다.

그래서 이번 기회에 더 이상 AI에게 설명을 맡기지 않고, Stream API를 직접 뜯어보기로 했다.

 

이번 글에서는 Stream API가 정확히 무엇인지부터 시작해, 각 연산이 어떤 방식으로 동작하는지, 그리고 일반적인 반복문과 비교했을 때 어떤 차이가 있는지 하나씩 정리해보려고 한다.

 


Stream API란?

Stream API는 컬렉션·배열 등의 데이터 소스를 선언적으로 처리하는 파이프라인 추상화로, Java 8부터 도입되었다.

기존의 Collections가 데이터의 저장/관리에 집중한 것이라면,

Stream API의 경우 어떻게 계산/가공할 것인가를 선언적 방식으로 기술하는데 목적을 둔다.

 

구조를 자세히 보기전에 우선 Stream API의 주요 기능들에 대해 알아보고자 한다.

 

Stream API는 간단히 스트림 생성 -> 중간 연산 -> 최종 연산으로 3단계로 구성된다.

스트림의 핵심은 선언적인데, 여기서는 단순 for문을 대체하는 정도로 알아두면 쉬울 것 같다.

 


Stream API Example.

간단히 예로 보면 아래와 같이 기존 for문을 Stream 방식으로 변경할 수 있다.

public class Main {

    public static void main(String[] args) throws IOException {

        int[] age = new int[]{20, 23, 30, 40, 18, 28, 27, 10, 67, 42, 25};

        int count = 0;
        // for
        for(int i=0; i<age.length; i++) {
            if(age[i] < 30) {
                count++;
            }
        }

	// Stream API
        long countWithStream = Arrays.stream(age)
            .filter(n -> n < 30)
            .count();

        System.out.println(count);  //output: 7
        System.out.println(countWithStream);    //output: 7
        
    }
}

나이 정보가 담겨있는 배열에서 30세 미만인 데이터의 개수를 출력할 때,

for문과 다르게 filter, count라는 메서드 체이닝만 선언하면 손쉽게 값을 구할 수 있다.

예제와 같이 3줄 정도의 간단한 로직에 대해서는 가독성 차이도 크게 없어, 성향 차이에 따라 골라 사용하면 될 것 같다.

 


Stream 파이프라인

위에서 보았듯, 스트림 연산은 3가지 단계로 구성된다.

각각의 단계에서 어떤 것들을 할 수 있는지 코드로 알아본다.

Stream 소스 생성

// 1. 컬렉션에서 생성
List<Integer> list = new ArrayList<>();
Stream<Integer> listStream = list.stream();

// 2. 배열에서 생성
int[] arr = {1, 2, 3};
IntStream arrayStream = Arrays.stream(arr);

// 3. 범위 생성
IntStream range1 = IntStream.range(1, 10); //1 ~ 9
IntStream range2 = IntStream.rangeClosed(1, 10); //1 ~ 10

// 4. 팩토리 메서드
Stream<String> factoryStream = Stream.of("KIM", "LIM");

Stream<Integer> it = Stream.iterate(0, n -> n + 1); //무한스트림: 0 ~ n
Stream<Double> ge = Stream.generate(Math::random);  //무한스트림: 난수 무한 생성

// 5. 빈 스트림
Stream<Integer> em = Stream.empty();

// 6. 스트림 결합
Stream<Integer> conn = Stream.concat(it, em);

 


중간 연산

예시 데이터

record User(String name, int age, String dept, List<String> skills) {}

List<User> users = List.of(
    new User("김철수", 28, "개발", List.of("Java", "Spring")),
    new User("이영희", 34, "개발", List.of("Java", "React")),
    new User("박민수", 22, "영업", List.of("Excel")),
    new User("최지은", 41, "개발", List.of("Spring", "Docker"))
);

1. Filter - 조건에 맞는 데이터

List<User> devs = users.stream()
        .filter(u -> u.dept().equals("개발"))
        .toList();  //최종 연산: 중간 연산의 결과를 리스트로 변환
// [김철수, 이영희, 최지은]

2. map - 각 요소를 다른 값으로 변환

List<String> names = users.stream()
        .map(User::name)
        .toList();
// [김철수, 이영희, 박민수, 최지은]

// Entity → DTO 변환. 실무에서 가장 많이 쓰는 형태
List<UserDto> dtos = users.stream()
        .map(UserDto::from)
        .toList();

3. mapToXXX(mapToInt / mapToLong / mapToDouble)

언박싱과 숫자 전용 연산 sum(), average(), max(), min(), summaryStatistics()을 사용하기 위해서 사용한다.

※반대로 박싱을 위해서는 boxed()를 사용한다.

IntSummaryStatistics stats = users.stream()
        .mapToInt(User::age)
        .summaryStatistics();	//개수, 합, 평균, 최대, 최소를 한 번에 구하기

stats.getSum();      // 125
stats.getAverage();  // 31.25
stats.getMax();      // 41

 

4. flatMap() - 중첩 구조를 한번에 펼치기

// map은 1:1, flatMap은 1:N 구조다.

// map을 쓰면 리스트의 리스트가 된다
users.stream().map(User::skills);   // Stream<List<String>>

// flatMap은 안쪽 리스트를 풀어서 이어붙인다
List<String> allSkills = users.stream()
        .flatMap(u -> u.skills().stream())
        .distinct()
        .toList();
// [Java, Spring, React, Excel]

 

5. distinct - 중복 제거

// 중복은 equals(), hashCode() 기준으로 판단한다.
List<String> depts = users.stream()
        .map(User::dept)
        .distinct()
        .toList();
// [개발, 영업]
// ※record는 equals가 자동 생성되지만, 일반 클래스는 직접 재정의하지 않으면 중복이 안 걸러진다.

6. sorted - 정렬

// 나이 오름차순
users.stream().sorted(Comparator.comparingInt(User::age)).toList();

// 내림차순
users.stream().sorted(Comparator.comparingInt(User::age).reversed()).toList();

// 부서 → 나이 순 (다중 정렬)
users.stream()
     .sorted(Comparator.comparing(User::dept)
                       .thenComparingInt(User::age))
     .toList();

7. limit / skip - 개수 자르기

// 나이 많은 순 상위 2명
users.stream()
     .sorted(Comparator.comparingInt(User::age).reversed())
     .limit(2)
     .toList();
// [최지은, 이영희]

// 페이징 (2페이지, 페이지당 2건)
users.stream().skip(2).limit(2).toList();

// ※무한 스트림을 끊을 때도 사용한다.

 

8. peek - 중간 상태 확인

users.stream()
     .filter(u -> u.age() >= 30)
     .peek(u -> log.debug("필터 통과: {}", u.name()))  //값을 바꾸진 않는다.
     .map(User::name)
     .toList();

 


최종 연산

예시 데이터

record User(String name, int age, String dept, List<String> skills) {}

List<User> users = List.of(
    new User("김철수", 28, "개발", List.of("Java", "Spring")),
    new User("이영희", 34, "개발", List.of("Java", "React")),
    new User("박민수", 22, "영업", List.of("Excel")),
    new User("최지은", 41, "개발", List.of("Spring", "Docker"))
);

1. toList() - 리스트로 변환

// Java 16+ 도입, 최종 List는 불변
List<String> names = users.stream()
        .map(User::name)
        .toList();
// [김철수, 이영희, 박민수, 최지은]

2. collect(Collectors.*) - 원하는 형태로 변환

Collectors에 따른 많은 사용법이 있다.

===== 1. toMap — Map으로 =====
Map<String, Integer> ageByName = users.stream()
        .collect(Collectors.toMap(User::name, User::age));
// {김철수=28, 이영희=34, 박민수=22, 최지은=41}

===== 2. groupingBy — 그룹으로 묶기 =====
Map<String, List<User>> byDept = users.stream()
        .collect(Collectors.groupingBy(User::dept));
// {개발=[김철수, 이영희, 최지은], 영업=[박민수]}

// 부서별 인원수
Map<String, Long> countByDept = users.stream()
        .collect(Collectors.groupingBy(User::dept, Collectors.counting()));
// {개발=3, 영업=1}

// 부서별 평균 나이
Map<String, Double> avgAgeByDept = users.stream()
        .collect(Collectors.groupingBy(User::dept,
                 Collectors.averagingInt(User::age)));
// {개발=34.33, 영업=22.0}

// 부서별 이름 목록 (User 통째로 말고 이름만)
Map<String, List<String>> namesByDept = users.stream()
        .collect(Collectors.groupingBy(User::dept,
                 Collectors.mapping(User::name, Collectors.toList())));
// {개발=[김철수, 이영희, 최지은], 영업=[박민수]}

// 부서별 보유 스킬 (flatMapping - Java 9+)
Map<String, Set<String>> skillsByDept = users.stream()
        .collect(Collectors.groupingBy(User::dept,
                 Collectors.flatMapping(u -> u.skills().stream(), Collectors.toSet())));
// {개발=[Java, Spring, React, Docker], 영업=[Excel]}

===== 3. partitioningBy — true/false 두 덩어리로 =====
// ※조건이 하나뿐일 때 쓴다.
// 키가 항상 true/false 두 개로 고정되어, 해당자가 없어도 빈 리스트가 들어있다.
Map<Boolean, List<User>> byJava = users.stream()
        .collect(Collectors.partitioningBy(u -> u.skills().contains("Java")));

byJava.get(true);    // [김철수, 이영희]
byJava.get(false);   // [박민수, 최지은]

===== joining — 문자열 합치기 =====
String result = users.stream()
        .filter(u -> u.dept().equals("개발"))
        .map(User::name)
        .collect(Collectors.joining(", ", "[", "]"));
// [김철수, 이영희, 최지은]

3. forEach - 요소 하나씩 처리

users.stream()
     .filter(u -> u.age() >= 30)
     .forEach(u -> log.info("{} ({}세) - {}", u.name(), u.age(), u.skills()));
     
// 반환값은 없다.

4. anyMatch / allMatch / noneMatch - 조건 검사

// boolean을 반환하며, Short-circuit(단축검사)이 적용된다.
users.stream().anyMatch(u -> u.skills().contains("Docker"));  // true  - 하나라도 있나
users.stream().allMatch(u -> u.age() >= 20);                  // true  - 전부 그런가
users.stream().noneMatch(u -> u.dept().equals("인사"));        // true  - 하나도 없나

// ※빈 스트림에서 allMatch는 항상 true
// ※빈 스트림에서 anyMatch는 항상 false

5. findFirst / findAny - 하나만 조회

// 값이 없을수도 있기 때문에 Optional을 반환한다.
Optional<User> first = users.stream()
        .filter(u -> u.dept().equals("개발"))
        .findFirst();   // 김철수

String name = first.map(User::name).orElse("없음");

// 없으면 예외로 처리
User target = users.stream()
        .filter(u -> u.skills().contains("Kotlin"))
        .findFirst()
        .orElseThrow(() -> new BusinessLogicException(ErrorCode.USER_NOT_FOUND));
        
// ※ 순차(일반) 스트림에서는 두 메서드가 사실상 동일한 결과를 반환하지만,
// 병렬(Parallel) 스트림에서는 결과가 다를 수 있다.

6. count - 개수 조회

// 항상 long Type을 반환한다.
long devCount = users.stream()
        .filter(u -> u.dept().equals("개발"))
        .count();   // 3

// 전체 스킬 개수 (중복 포함)
long skillCount = users.stream()
        .flatMap(u -> u.skills().stream())
        .count();   // 7

7. min / max - 최소 / 최대값 조회

// Comparator가 필요하며, Optional을 반환한다.
Optional<User> oldest = users.stream()
        .max(Comparator.comparingInt(User::age));
// 최지은(41)

String youngestName = users.stream()
        .min(Comparator.comparingInt(User::age))
        .map(User::name)
        .orElse("없음");   // 박민수

8. sum / average / summaryStatistics - 숫자 집계

// mapToXXX를 거쳐야 사용할 수 있다.
int totalAge = users.stream().mapToInt(User::age).sum();                // 125
double avgAge = users.stream().mapToInt(User::age).average().orElse(0); // 31.25

// 한 번의 순회로 전부
IntSummaryStatistics stats = users.stream()
        .mapToInt(User::age)
        .summaryStatistics();
// count=4, sum=125, min=22, average=31.25, max=41

9. reduce - 7, 8로 안되는 규칙 커스텀

// 모든 나이를 곱하기
int mulAge = users.stream()
            .map(User::age)
            .reduce(1, (a, b) -> a * b);
            
// ※reduce(identity, accumulator) 초기값과 함수식

 


Stream API 특징

특성 집합 - 자세한 설명은 아래 참고

앞서 for를 대체하는 정도라고 설명을 했는데,

이러한 Stream API를 잘 사용하기 위해서 알아야 할 특징이 몇 개 있다.

1. 지연 처리 (Lazy Evaluation)

최종 연산을 처리하기 전까진 아무런 연산도 하지 않는다.

코드로 보면 아래와 같다.

System.out.println("=== 연산 시작 ===");

users.stream()
     .filter(u -> {
         System.out.println("filter 실행: " + u.name());
         return u.dept().equals("개발");
     })
     .map(u -> {
         System.out.println("map 실행: " + u.name());
         return u.name();
     });
// 최종연산 없음

/**
출력:
=== 연산 시작 ===
*/

 

이러한 특성으로 중간 연산을 변수와 같이 가지고만 있다가 이후에 실행하는 것도 가능하다.

Stream<String> pipeline = users.stream()
        .filter(u -> u.dept().equals("개발"))
        .map(User::name);

System.out.println("아직 아무것도 실행 안 됨");

=== 중간 로직 ===

List<String> result = pipeline.toList();   // 이후에 최종 연산 실행
// [김철수, 이영희, 최지은]

그러나 위와 같이 사용하는 것은 안티패턴이다.

Stream은 일회성으로 한번 최종연산이 종료되면 그 Stream은 재사용할 수 없다.

만약 재사용하게 되면 java.lang.IllegalStateException 런타임 예외가 발생한다.

그렇기 때문에 Stream의 선언과 동시에 최종 연산까지 진행하지 않으면 언제 이 Stream을 사용했는지 보기 힘들어 안티패턴이 된다.

 

또한 Stream은 이 지연처리를 활용하여 두가지 개념이 추가된다.

 

❇️ 수직 처리

Stream은 Collection의 요소 1개가 파이프라인(연산) 끝까지 통과한다. 그 뒤 다음 요소가 진행된다.

아래와 같은 예시 코드가 있다고 가정해보자.

import java.util.List;

public class VerticalProcessing {
    public static void main(String[] args) {

        List<String> names = List.of("김철수", "이영희", "박민수");

        List<String> result = names.stream()
                .peek(n -> System.out.println("1) filter 단계: " + n))
                .filter(n -> !n.startsWith("박"))
                .peek(n -> System.out.println("2) map 단계:    " + n))
                .map(String::toUpperCase)
                .toList();

        System.out.println("결과: " + result);
    }
}

첫번째 중간 연산을 Collection의 모든 요소가 진행하고, 그 다음 남은 요소들에 대해 두번째 중간 연산을 진행하는 것으로 오해할 수 있는데, 수직 처리를 가장 쉽게 확인하는 방법은 중간 연산 사이에 peek()를 통해 출력을 찍어 디버깅해보는 것이다.

위의 결과는 아래와 같다.

1) filter 단계: 김철수
2) map 단계:    김철수      ← 김철수가 끝까지 갔다
1) filter 단계: 이영희
2) map 단계:    이영희      ← 그 다음 이영희
1) filter 단계: 박민수      ← 탈락. 2단계로 안 감
결과: [김철수, 이영희, 박민수]

"김철수" 요소가 모든 중간연산을 끝낸 후 다음 요소로 넘어가는 것을 확인할 수 있다.

 

지연 처리는 위 특성으로 인해 정렬 유무에 따라 최종 연산의 결과가 바뀔 수 있다.

// 1. 원본 순서 유지: "banana" (길이 6)를 가장 먼저 만남
String result1 = words.stream()
        .filter(w -> w.length() >= 5)
        .findFirst()
        .orElse("None");

// 2. 정렬 후 찾기: 알파벳순 정렬("apple", "banana", "cherry", "dragonfruit") 실행 후 첫 번째 "apple" 반환
String result2 = words.stream()
        .sorted()
        .filter(w -> w.length() >= 5)
        .findFirst()
        .orElse("None");

System.out.println("원본 순서 결과: " + result1); // 출력: banana
System.out.println("정렬 후 결과: " + result2);   // 출력: apple

 

❇️ 단축 평가 (Short-circuit)

Stream은 최종 연산에서 답이 정해지는 순간 뒤에 남은 요소는 아예 평가하지 않고 멈춘다.

아래와 같은 최종 연산들에서 동작한다.

findFirst(), anyMatch(), allMatch(), noneMatch(), limit()

List<String> names = List.of("김철수", "이영희", "박민수", "최지은");

String found = names.stream()
        .peek(n -> System.out.println("검사: " + n))
        .filter(n -> n.startsWith("박"))
        .findFirst()
        .orElse("없음");

System.out.println("결과: " + found);

/**
=== 출력 ===
검사: 김철수
검사: 이영희
검사: 박민수   <- 중간 연산을 다 통과하므로 여기서 중단
결과: 박민수
*/

지연 처리 덕분에 Stream은 "필요한 연산만 최소한으로 실행(Short-circuit)"할 수 있다.

따라서 데이터의 양을 먼저 줄이는 연산을 앞에 두는 것이 핵심이다.

// 10,000개 데이터 전체를 변환한 뒤 10개만 남김 (10,000번 변환)
list.stream()
    .map(this::heavyTransformation) 
    .filter(x -> x.isValid())
    .collect(Collectors.toList());

위 코드의 성능을 높이는 방법은 filter()를 map()보다 앞에 두어 map() 연산 대상을 줄이는 것이다.

 


2. 원본 데이터는 변경되지 않는다.

Stream은 소스가 되는 데이터의 구조를 바꾸지 않고 새 결과를 만들어낸다.

이 불변성은 얕은(shallow)수준으로 원소 객체 내부 상태는 얼마든지 바뀔 수 있다.

List<User> users = repository.findAll();

// 구조는 안 바뀜 — users.size()는 그대로
List<String> names = users.stream()
        .map(User::getName)
        .toList();

// ❌ 하지만 원소 내부 상태는 그대로 노출되어 있다
users.stream()
     .filter(u -> u.getAge() > 30)
     .forEach(u -> u.setGrade("VIP"));  // 원본 User 객체가 변경됨

 

주의할 점으로는 JPA 환경에서는 위 코드가 트랜잭션 내부에 있다면 영속성 컨텍스트의 dirty checking으로 인해 의도치 않은 UPDATE 쿼리가 나갈 수 있다.

또한 toList()나 collect()의 경우 새 컬렉션을 만들어 할당하므로 메모리 측면에서 트레이드오프가 있다. ※원소가 많을수록 트레이드오프는 커진다.

 


 

3. 선언적

Stream동작을 담당하는 로직의 제어권은 개발자가 아닌 라이브러리가 소유한다. ※반대로 for문은 명령형

이는 선언적 프로그래밍의 특징을 그대로 가져간다.

 

❔선언적 프로그래밍

어떻게(How) 수행할 것인가가 아니라 무엇을(What) 달성할 것인가에 집중하여 코드를 작성하는 방식을 의미한다.

for문의 경우 아래와 같이 제어 흐름 전반을 코드를 통해 개발자가 직접 다룬다.

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

for (int i = 0; i < numbers.length; i++) {
    if (numbers.get(i) % 2 == 0) {
        int doubled = numbers.get(i) * 2;
        sum += doubled;
    }
}

인덱스 증가, 조건문 등의 데이터를 처리하는 순서와 방법을 직접 명시한다.

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

// 무엇을 할 것인지만 파이프라인으로 연결
int sum = numbers.stream()
                 .filter(n -> n % 2 == 0) // 짝수만 걸러내라 (What)
                 .mapToInt(n -> n * 2)    // 2를 곱해라 (What)
                 .sum();                  // 다 더해라 (What)

반면 Stream의 경우 조건문을 매개변수로 전달한 것 이외에는 어떻게 처리할지 알 수 없다. 내부 동작은 라이브러리에게 위임했기 때문이다.

 

이러한 선언적 특성으로 인해 얻을 수 있는 이점은 가독성과 앞서 봤던 지연 처리, 단축 평가, 병렬화(지연 처리의 파이프라인 구조를 를 Collection을 분할하여 여러 스레드에 나누어 동작)같은 최적화를 런타임이 알아서 적용할 수 있다는 점이다.

Beemo9
@Beemo9
개발 기술 블로그, Dev 포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!
image