Java Stream API: filter, map, reduce와 collect 활용

이 글의 핵심

for 루프로 쓰던 필터링·변환·집계를 Stream 파이프라인으로 바꾸면 의도가 드러나는 코드가 됩니다. 중간 연산과 최종 연산의 역할을 구분하고, Collectors.toMap에서 키가 중복되면 예외가 나는 함정과 병렬 스트림을 순차 스트림과 시간 측정으로 비교해 보는 방법까지 짚어 실무 데이터 처리에 적용하게 합니다.

들어가며

Stream API는 Java 8에서 도입된 함수형 스타일로 컬렉션을 처리하는 도구입니다. 비유(컨베이어 벨트): 리스트·배열의 요소가 컨베이어 벨트 위를 지나가며 filter로 걸러지고 map으로 바뀌고, 마지막에 collect 등으로 최종 연산에서 꺼내 담습니다. 한 번 소비한 스트림은 다시 쓸 수 없다는 점도, 벨트 끝까지 간 물건을 같은 줄로 두 번 보낼 수 없다고 이해하시면 됩니다.

이 비유에서 가장 중요한 부분은 벨트가 최종 연산을 만나기 전까지는 움직이지 않는다는 점입니다. filter나 map을 호출하면 실제로 요소를 처리하는 것이 아니라 “이런 단계를 거칠 것”이라는 파이프라인만 조립됩니다. collect, count, forEach 같은 최종 연산이 호출되는 순간 요소가 하나씩 모든 단계를 차례로 통과합니다. 이를 지연 평가(lazy evaluation)라고 하며, 이 덕분에 Stream.iterate로 만든 무한 스트림도 limit과 함께 쓸 수 있고, findFirst나 anyMatch는 답을 찾는 즉시 나머지 요소를 처리하지 않고 멈춥니다.


Stream을 만드는 방법

다양한 생성 방법

import java.util.*;
import java.util.stream.*;
// 컬렉션에서
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream1 = numbers.stream();
// 배열에서
String[] arr = {"a", "b", "c"};
Stream<String> stream2 = Arrays.stream(arr);
// 직접 생성
Stream<String> stream3 = Stream.of("a", "b", "c");
// 빈 스트림
Stream<String> empty = Stream.empty();
// 무한 스트림
Stream<Integer> infinite = Stream.iterate(0, n -> n + 1);
Stream<Double> random = Stream.generate(Math::random);
// 범위
IntStream range = IntStream.range(1, 10);  // 1~9
IntStream rangeClosed = IntStream.rangeClosed(1, 10);  // 1~10

Stream<Integer>와 IntStream의 차이는 성능에서 중요합니다. Stream<Integer>는 요소마다 Integer 객체(박싱된 값)를 다루므로, 큰 데이터에서는 객체 할당과 언박싱 비용이 쌓입니다. IntStream, LongStream, DoubleStream은 기본형을 그대로 다루고 sum(), average(), max() 같은 숫자 전용 메서드도 제공합니다. 객체 스트림에서 숫자를 집계할 때 mapToInt로 갈아타는 이유가 이것입니다.

무한 스트림은 반드시 limit이나 takeWhile(Java 9) 같은 단락(short-circuit) 연산과 함께 써야 합니다. Stream.iterate(0, n -> n + 1).collect(Collectors.toList())는 끝나지 않고 결국 OutOfMemoryError로 죽습니다. Java 9부터는 Stream.iterate(0, n -> n < 10, n -> n + 1)처럼 종료 조건을 받는 오버로드도 있어 for 루프와 같은 형태로 쓸 수 있습니다.


중간 연산 (Intermediate Operations)

filter - 필터링

조건에 맞는 요소만 선택하는 중간 연산입니다:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// filter: 조건을 만족하는 요소만 통과
// n -> n % 2 == 0: 람다 표현식 (짝수 판별)
// collect: 최종 연산, Stream을 List로 변환
List<Integer> evens = numbers.stream()
    .filter(n -> n % 2 == 0)  // 짝수만 필터링
    .collect(Collectors.toList());
System.out.println(evens);  // [2, 4, 6, 8, 10]
// 여러 filter 체이닝 가능
// 각 filter는 순차적으로 적용됨
List<Integer> filtered = numbers.stream()
    .filter(n -> n > 3)   // 3보다 큰 수 [4, 5, 6, 7, 8, 9, 10]
    .filter(n -> n < 8)   // 8보다 작은 수 [4, 5, 6, 7]
    .collect(Collectors.toList());
System.out.println(filtered);  // [4, 5, 6, 7]
// 복잡한 조건
List<Integer> complex = numbers.stream()
    .filter(n -> n % 2 == 0 && n > 5)  // 5보다 큰 짝수
    .collect(Collectors.toList());
System.out.println(complex);  // [6, 8, 10]

filter의 동작 원리:

  1. Stream의 각 요소에 대해 조건 함수(Predicate) 실행
  2. 조건이 true인 요소만 다음 단계로 전달
  3. 조건이 false인 요소는 제거됨
  4. 원본 컬렉션은 변경되지 않음 (불변성)

“여러 filter를 체이닝하면 리스트를 여러 번 도는 것 아닌가”라는 걱정은 하지 않아도 됩니다. 앞에서 말한 지연 평가 때문에 요소 하나가 두 filter를 연달아 통과한 뒤 다음 요소로 넘어가므로, 전체 순회는 한 번입니다. 중간 리스트([4, 5, 6, ...])가 실제로 만들어지지도 않습니다. 따라서 조건을 한 filter에 &&로 합칠지 여러 filter로 나눌지는 성능이 아니라 가독성으로 결정하면 됩니다.

원본이 변경되지 않는다는 말은 “스트림 연산이 원본을 수정하지 않는다”는 뜻이지, 람다 안에서 원본을 수정해도 된다는 뜻은 아닙니다. list.stream().forEach(x -> list.add(x))처럼 순회 중에 원본 컬렉션을 수정하면 ConcurrentModificationException이 나거나 결과가 정의되지 않습니다. Stream API 문서가 요구하는 “비간섭(non-interference)” 조건입니다.

map - 변환

// 2배로
List<Integer> doubled = numbers.stream()
    .map(n -> n * 2)
    .collect(Collectors.toList());
// 문자열로
List<String> strings = numbers.stream()
    .map(n -> "숫자: " + n)
    .collect(Collectors.toList());
// 객체 변환
class User {
    String name;
    int age;
    User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
List<User> users = Arrays.asList(
    new User("홍길동", 25),
    new User("김철수", 30)
);
List<String> names = users.stream()
    .map(u -> u.name)
    .collect(Collectors.toList());

flatMap - 평탄화

List<List<Integer>> nested = Arrays.asList(
    Arrays.asList(1, 2),
    Arrays.asList(3, 4),
    Arrays.asList(5, 6)
);
List<Integer> flattened = nested.stream()
    .flatMap(list -> list.stream())
    .collect(Collectors.toList());
System.out.println(flattened);  // [1, 2, 3, 4, 5, 6]

map과 flatMap의 차이는 반환 타입으로 보면 명확합니다. 여기서 map(list -> list.stream())을 쓰면 결과가 Stream<Stream<Integer>>가 되어, collect 결과도 List<Stream<Integer>>처럼 쓸모없는 형태가 됩니다. flatMap은 각 요소가 만든 스트림을 풀어서 하나의 스트림으로 이어 붙입니다. 주문 목록에서 모든 주문 항목을 꺼내거나(orders.stream().flatMap(o -> o.getItems().stream())), 문장 목록을 단어 목록으로 쪼갤 때 자주 씁니다. list -> list.stream()은 메서드 참조 List::stream으로 줄여 쓸 수 있습니다.

distinct, sorted, limit, skip

List<Integer> numbers = Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6, 5);
// 중복 제거
List<Integer> unique = numbers.stream()
    .distinct()
    .collect(Collectors.toList());
// 정렬
List<Integer> sorted = numbers.stream()
    .sorted()
    .collect(Collectors.toList());
// 역순 정렬
List<Integer> reversed = numbers.stream()
    .sorted(Comparator.reverseOrder())
    .collect(Collectors.toList());
// 상위 3개
List<Integer> top3 = numbers.stream()
    .sorted(Comparator.reverseOrder())
    .limit(3)
    .collect(Collectors.toList());
// 처음 2개 건너뛰기
List<Integer> skipped = numbers.stream()
    .skip(2)
    .collect(Collectors.toList());

distinct와 sorted는 다른 중간 연산과 성격이 다릅니다. filter나 map은 요소 하나만 보고 바로 다음 단계로 넘길 수 있지만(무상태 연산), sorted는 모든 요소를 받아야 정렬할 수 있고 distinct는 지금까지 본 값을 기억해야 합니다(상태 유지 연산). 그래서 sorted 앞까지는 요소가 흘러가다가 sorted에서 전부 쌓인 뒤에야 다음 단계가 시작됩니다. 무한 스트림에 sorted를 걸면 영원히 끝나지 않는 이유이고, 큰 데이터에서는 메모리를 한 번에 많이 쓰는 지점이 됩니다.

sorted().limit(3) 조합도 알아 둘 만합니다. 상위 3개만 필요해도 전체를 정렬하므로 O(n log n)입니다. 데이터가 매우 크고 k가 작다면 크기 k의 PriorityQueue를 쓰는 편이 효율적입니다. distinct는 equals와 hashCode로 중복을 판단하므로, 직접 만든 클래스에 이 두 메서드를 재정의하지 않으면 필드 값이 같은 객체도 모두 다른 요소로 취급됩니다.


최종 연산 (Terminal Operations)

reduce - 집계

Stream의 요소들을 하나의 값으로 줄이는(reduce) 연산입니다:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// reduce: 누적 연산
// 첫 번째 인자(0): 초기값 (identity)
// 두 번째 인자: 누적 함수 (accumulator)
//   a: 누적값, b: 현재 요소
int sum = numbers.stream()
    .reduce(0, (a, b) -> a + b);
// 동작 과정:
// 1. a=0 (초기값), b=1 → 0+1=1
// 2. a=1 (이전 결과), b=2 → 1+2=3
// 3. a=3, b=3 → 3+3=6
// 4. a=6, b=4 → 6+4=10
// 5. a=10, b=5 → 10+5=15
System.out.println(sum);  // 15
// 곱셈: 초기값을 1로 설정
int product = numbers.stream()
    .reduce(1, (a, b) -> a * b);
// 1 * 1 * 2 * 3 * 4 * 5 = 120
System.out.println(product);  // 120
// 최댓값: 초기값 없이 사용 (Optional 반환)
Optional<Integer> max = numbers.stream()
    .reduce((a, b) -> a > b ? a : b);
// 동작: 두 값을 비교해서 큰 값을 누적
// 1과 2 비교 → 2
// 2와 3 비교 → 3
// 3과 4 비교 → 4
// 4와 5 비교 → 5
max.ifPresent(System.out::println);  // 5
// 실전 예시: 문자열 연결
List<String> words = Arrays.asList("Hello", "World", "Java");
String sentence = words.stream()
    .reduce("", (a, b) -> a + " " + b);
System.out.println(sentence.trim());  // Hello World Java

reduce의 세 가지 형태:

  1. reduce(identity, accumulator): 초기값 있음, 결과 타입 확정
  2. reduce(accumulator): 초기값 없음, Optional 반환
  3. reduce(identity, accumulator, combiner): 병렬 스트림에서 부분 결과를 합치거나, 결과 타입이 요소 타입과 다를 때

언제 사용하나: 합계, 곱셈, 최대/최소값처럼 요소를 하나의 값으로 접는 계산에 씁니다. 다만 실무에서는 reduce를 직접 쓸 일이 생각보다 적습니다. 합계는 mapToInt(...).sum(), 최댓값은 max(Comparator.naturalOrder()), 개수는 count()처럼 전용 메서드가 더 읽기 쉽고 박싱도 피할 수 있기 때문입니다.

위의 문자열 연결 예제는 reduce를 쓰면 안 되는 대표적인 경우입니다. 문자열은 불변이라 a + " " + b가 실행될 때마다 새 문자열을 만들고 이전 내용을 전부 복사하므로, 요소가 n개면 O(n²) 복사가 일어납니다. 결과 앞에 공백이 붙어 trim()이 필요한 것도 초기값 ""와 구분자를 억지로 맞춘 흔적입니다. 문자열 결합은 내부적으로 StringBuilder를 쓰는 Collectors.joining(" ")이 정답입니다.

reduce의 초기값(identity)은 “어떤 값과 합쳐도 그 값을 그대로 돌려주는 값”이어야 합니다. 덧셈의 0, 곱셈의 1이 그렇습니다. 순차 스트림에서는 reduce(10, (a, b) -> a + b)처럼 틀린 초기값을 줘도 결과가 10만큼 커질 뿐이지만, 병렬 스트림에서는 데이터를 여러 조각으로 나눠 조각마다 초기값에서 시작하므로 조각 수만큼 10이 더해져 실행할 때마다 결과가 달라집니다. 누적 함수도 결합법칙을 만족해야 하므로 뺄셈 같은 연산은 병렬에서 틀린 값을 냅니다.

collect - 수집

// List로
List<Integer> list = numbers.stream()
    .collect(Collectors.toList());
// Set으로
Set<Integer> set = numbers.stream()
    .collect(Collectors.toSet());
// Map으로
Map<String, Integer> map = users.stream()
    .collect(Collectors.toMap(
        u -> u.name,
        u -> u.age
    ));
// 그룹화
Map<Integer, List<User>> byAge = users.stream()
    .collect(Collectors.groupingBy(u -> u.age));
// 문자열 결합
String joined = numbers.stream()
    .map(String::valueOf)
    .collect(Collectors.joining(", "));
System.out.println(joined);  // "1, 2, 3, 4, 5"

Collectors.toList()가 돌려주는 리스트의 구현체는 명세상 정해져 있지 않습니다(현재 구현은 ArrayList). Java 16부터는 stream.toList()를 바로 쓸 수 있는데, 이 메서드는 수정 불가능한 리스트를 돌려준다는 차이가 있습니다. Collectors.toList()에서 .toList()로 바꾼 뒤 결과에 add를 하던 코드가 UnsupportedOperationException으로 깨지는 것이 마이그레이션 때 흔히 겪는 문제입니다. 결과를 수정해야 한다면 Collectors.toCollection(ArrayList::new)로 명시하는 편이 의도가 분명합니다.

toMap은 키 중복 외에도 값이 null이면 NullPointerException을 던집니다. 내부적으로 HashMap.merge를 쓰는데 merge가 null 값을 허용하지 않기 때문입니다. HashMap 자체는 null 값을 허용하므로 “왜 여기서 NPE가 나지?” 하고 한참 헤매게 되는 함정입니다. groupingBy는 기본적으로 HashMap<K, List<V>>를 만들어 키 순서가 보장되지 않으므로, 나이순으로 출력하고 싶다면 groupingBy(u -> u.age, TreeMap::new, Collectors.toList())처럼 맵 구현을 지정합니다. 그룹별 개수만 필요하면 두 번째 인자로 Collectors.counting()을 넘기면 됩니다.

forEach, count, anyMatch

// forEach
numbers.stream()
    .forEach(System.out::println);
// count
long count = numbers.stream()
    .filter(n -> n > 3)
    .count();
// anyMatch, allMatch, noneMatch
boolean hasEven = numbers.stream()
    .anyMatch(n -> n % 2 == 0);
boolean allPositive = numbers.stream()
    .allMatch(n -> n > 0);
boolean noNegative = numbers.stream()
    .noneMatch(n -> n < 0);

병렬 스트림은 언제 쓰나

List<Integer> numbers = IntStream.rangeClosed(1, 1000000)
    .boxed()
    .collect(Collectors.toList());
// 순차 스트림
long start = System.currentTimeMillis();
int sum1 = numbers.stream()
    .mapToInt(Integer::intValue)
    .sum();
long time1 = System.currentTimeMillis() - start;
// 병렬 스트림
start = System.currentTimeMillis();
int sum2 = numbers.parallelStream()
    .mapToInt(Integer::intValue)
    .sum();
long time2 = System.currentTimeMillis() - start;
System.out.println("순차: " + time1 + "ms");
System.out.println("병렬: " + time2 + "ms");

이 측정 코드는 “병렬이 빠르다”를 증명하기에 적합하지 않다는 점을 먼저 짚어 두겠습니다. 첫째, 먼저 실행되는 순차 스트림은 JIT 컴파일이 되기 전의 느린 코드로 실행되고, 뒤의 병렬 스트림은 이미 워밍업된 상태에서 시작하므로 순서만 바꿔도 결과가 달라집니다. 둘째, currentTimeMillis는 해상도가 낮아 수 ms 단위 작업에서는 오차가 결과보다 클 수 있습니다. 제대로 비교하려면 JMH(Java Microbenchmark Harness)를 써야 합니다.

측정을 제대로 해 보면, 100만 개의 정수를 더하는 정도의 가벼운 작업은 병렬 스트림이 순차보다 느리거나 비슷한 경우가 많습니다. 작업을 쪼개고 스레드에 나눠 주고 결과를 합치는 비용이 덧셈 자체보다 크기 때문입니다. 병렬 스트림이 이득을 보는 조건은 (1) 요소당 연산이 무겁고, (2) 데이터가 크며, (3) ArrayList나 배열, IntStream.range처럼 균등하게 쪼개기 쉬운 소스이고(LinkedList나 Stream.iterate는 분할이 비효율적), (4) 연산이 공유 상태를 건드리지 않을 때입니다.

병렬 스트림은 기본적으로 JVM 전체가 공유하는 ForkJoinPool.commonPool()을 씁니다. 웹 서버에서 요청 처리 중에 parallelStream() 안에서 DB 조회나 HTTP 호출 같은 블로킹 작업을 하면, 공용 풀의 스레드(보통 CPU 코어 수 - 1개)가 모두 대기 상태에 묶여 같은 풀을 쓰는 다른 요청의 병렬 스트림까지 멈춥니다. 제가 운영 환경에서 병렬 스트림을 거의 쓰지 않는 이유가 이것으로, I/O가 섞인 병렬 작업은 전용 ExecutorService나 CompletableFuture로 스레드 수를 직접 통제하는 편이 안전합니다. 또 parallelStream().forEach(list::add)처럼 스레드 안전하지 않은 컬렉션에 결과를 넣으면 요소가 사라지거나 ArrayIndexOutOfBoundsException이 납니다. 결과는 반드시 collect로 모아야 합니다.


사용자 데이터를 스트림으로 처리하기

예제: 사용자 데이터 처리

class User {
    String name;
    int age;
    boolean active;
    
    User(String name, int age, boolean active) {
        this.name = name;
        this.age = age;
        this.active = active;
    }
}
List<User> users = Arrays.asList(
    new User("홍길동", 25, true),
    new User("김철수", 17, false),
    new User("이영희", 30, true),
    new User("박민수", 22, true)
);
// 활성 성인 사용자 이름
List<String> activeAdults = users.stream()
    .filter(u -> u.active)
    .filter(u -> u.age >= 18)
    .map(u -> u.name)
    .collect(Collectors.toList());
System.out.println(activeAdults);  // [홍길동, 이영희, 박민수]
// 평균 나이
double avgAge = users.stream()
    .filter(u -> u.active)
    .mapToInt(u -> u.age)
    .average()
    .orElse(0);
System.out.println("평균 나이: " + avgAge);

average()는 double이 아니라 OptionalDouble을 반환합니다. 필터를 통과한 사용자가 한 명도 없으면 평균을 정의할 수 없기 때문입니다. 여기서는 orElse(0)으로 0을 기본값으로 썼는데, “활성 사용자가 없음”과 “평균 나이가 0”이 화면에서 같은 값으로 보이게 되므로 실제 서비스에서는 빈 경우를 따로 처리하는 편이 낫습니다.

두 파이프라인이 모두 filter(u -> u.active)로 시작하는데, 스트림은 재사용할 수 없으므로 중간 결과를 변수에 담아 두 번 쓰는 것은 불가능합니다. 이미 최종 연산을 수행한 스트림에 다시 연산을 걸면 IllegalStateException: stream has already been operated upon or closed가 납니다. 같은 필터 결과를 여러 번 쓴다면 한 번 List로 모은 뒤 그 리스트에서 새 스트림을 여는 것이 일반적입니다. 이름 목록과 평균을 한 번의 순회로 동시에 구하고 싶다면 Java 12의 Collectors.teeing을 쓸 수 있습니다.


Stream API 요약

핵심 요약

  1. Stream 생성: stream(), of(), range()
  2. 중간 연산: filter, map, flatMap, distinct, sorted
  3. 최종 연산: reduce, collect, forEach, count
  4. 병렬 처리: parallelStream()
  5. Collectors: toList, toSet, toMap, groupingBy

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Collectors.toMap을 쓸 때 키가 중복되면 어떻게 되나요?

A. 기본 toMap(keyMapper, valueMapper)는 같은 키가 두 번 나오면 IllegalStateException을 던집니다. 이름처럼 중복될 수 있는 값을 키로 쓴다면 세 번째 인자로 (a, b) -> a처럼 충돌 시 어떤 값을 남길지 정하는 병합 함수를 넘겨야 합니다. 같은 키의 값을 모두 모으고 싶다면 toMap 대신 groupingBy를 쓰는 편이 의도에 맞습니다.