스트림 API (Stream API)
한눈에 보기
스트림 API는 데이터 처리를 중간 연산과 최종 연산의 파이프라인으로 선언적으로 기술하는 Java 8의 추상화다. 지연 평가와 단락 연산, Collectors, 병렬 스트림의 함정을 다룬다.
최종 업데이트 2026.09.17
스트림 API(Stream API)는 Java 8에서 도입된 데이터 처리 추상화로, 컬렉션이나 배열 같은 데이터 소스에 필터링과 변환과 집계를 파이프라인으로 이어 붙여 선언적으로 기술할 수 있게 한다. 스트림 파이프라인은 소스, 0개 이상의 중간 연산(intermediate operation), 하나의 최종 연산(terminal operation)으로 구성되며, 최종 연산이 호출되기 전까지는 아무 계산도 하지 않는 지연 평가(lazy evaluation)로 동작한다. 반복 방법이 아니라 하고 싶은 일을 기술한다는 점이 전통적인 for 루프와의 본질적 차이다.
파이프라인 구조와 지연 평가
중간 연산에는 filter, map, sorted, distinct, limit 등이 있고 모두 스트림을 반환하므로 체이닝이 가능하다. 최종 연산에는 collect, forEach, count, reduce, findFirst 등이 있으며 이 호출이 실행의 방아쇠가 된다. 지연 평가 덕분에 스트림은 원소를 하나씩 파이프라인 전체에 통과시키는 방식으로 동작하고, limit이나 findFirst 같은 단락(short-circuit) 연산을 만나면 필요한 만큼만 처리하고 멈춘다.
List<String> names = List.of("kim", "lee", "park", "choi", "jung");
Optional<String> first = names.stream()
.filter(n -> {
System.out.println("filter: " + n);
return n.length() >= 4;
})
.map(String::toUpperCase)
.findFirst();
// filter는 "park"에서 조건을 만족하는 순간 멈춘다
// kim, lee, park 세 번만 출력된다
이 예에서 다섯 원소 전부가 아니라 조건을 만족하는 첫 원소까지만 filter가 실행되는 것이 지연 평가와 단락 평가의 효과다. 전체를 걸러 새 컬렉션을 만든 뒤 첫 원소를 찾는 명령형 코드보다 불필요한 계산이 적다.
collect와 Collectors
최종 연산 중 가장 강력한 것은 collect이며, Collectors 유틸리티가 다양한 수집 전략을 제공한다. toList와 toSet 같은 기본 수집, groupingBy를 이용한 그룹화, joining을 이용한 문자열 연결, counting과 averagingInt 같은 다운스트림 집계를 조합할 수 있다. Java 16부터는 stream.toList()로 불변 리스트를 바로 얻을 수도 있다.
Map<String, List<Member>> byTeam = members.stream()
.collect(Collectors.groupingBy(Member::getTeam));
Map<String, Long> countByTeam = members.stream()
.collect(Collectors.groupingBy(Member::getTeam, Collectors.counting()));
String csv = members.stream()
.map(Member::getName)
.collect(Collectors.joining(", "));
기본형을 다룰 때는 IntStream, LongStream, DoubleStream 같은 원시 스트림을 쓰는 것이 좋다. Stream<Integer>로 처리하면 원소마다 박싱과 언박싱이 일어나므로, mapToInt로 전환해 sum이나 average 같은 특화 연산을 쓰면 힙 할당 없이 계산할 수 있다.
병렬 스트림의 원리와 함정
parallelStream() 또는 parallel()을 호출하면 스트림이 공용 ForkJoinPool에서 분할 정복 방식으로 병렬 처리된다. 데이터 소스를 Spliterator가 쪼개고 각 조각을 워커 스레드가 처리한 뒤 결과를 결합한다. 다만 병렬화가 항상 빠른 것은 아니다. 원소 수가 적거나 원소당 작업이 가벼우면 분할과 결합 오버헤드가 이득을 상쇄하고, ArrayList나 배열처럼 균등 분할이 쉬운 소스와 달리 LinkedList는 분할 효율이 나쁘다. 공용 풀을 블로킹 IO 작업으로 채우면 애플리케이션 전체의 병렬 스트림이 함께 느려지는 것도 흔한 함정이다. 반드시 측정 후에 도입해야 한다.
사용 시 주의할 점
스트림은 일회용이다. 최종 연산이 끝난 스트림을 다시 사용하면 IllegalStateException이 발생하므로 필요하면 새 스트림을 만들어야 한다. 또한 중간 연산의 람다는 상태를 공유하거나 외부 컬렉션을 수정하는 부수 효과(side effect)를 일으키지 않아야 하며, 특히 병렬 스트림에서 외부 리스트에 add 하는 코드는 경쟁 상태를 만든다. 결과 수집은 collect에 맡기는 것이 정답이다. 스트림 안에서는 검사 예외(checked exception) 처리가 번거롭고 디버깅 스택이 깊어지므로, 로직이 복잡하거나 인덱스 기반 제어가 필요하면 평범한 루프가 더 나은 선택일 수 있다.
중간 연산과 최종 연산 비교
| 구분 | 중간 연산 | 최종 연산 |
|---|---|---|
| 반환 타입 | Stream, 체이닝 가능 | 값, 컬렉션, Optional 또는 void |
| 실행 시점 | 즉시 실행되지 않음 (지연) | 호출 시 파이프라인 전체 실행 |
| 대표 예 | filter, map, sorted, distinct, limit | collect, forEach, reduce, count, findFirst |
| 횟수 | 0개 이상 | 정확히 1개 |
면접에서 이렇게 물어본다
- 스트림 파이프라인의 구성 요소와 지연 평가가 무엇인지, 어떤 이점이 있는지 설명해 보라.
- 중간 연산과 최종 연산을 구분하고 각각의 예를 들어 보라.
- 병렬 스트림이 내부적으로 어떻게 동작하며 어떤 경우에 오히려 느려지는가.
- Stream<Integer> 대신 IntStream을 쓰는 이유를 박싱 비용과 연결해 설명해 보라.
- 스트림과 for 루프 중 무엇을 선택할지 판단하는 본인의 기준을 말해 보라.
Java에서 같이 읽기
- 오토 박싱과 오토 언박싱 (Auto Boxing and Unboxing)오토 박싱은 기본형 값을 래퍼 클래스 객체로, 오토 언박싱은 그 반대로 자동 변환하는 기능이다. Integer 캐시, == 비교 함정, NullPointerException 위험과 성능 비용을 JVM 메모리 관점에서 정리한다.
- Call by Value와 Call by Reference자바는 기본형이든 참조형이든 언제나 값에 의한 호출만 사용하며, 참조형은 객체가 아니라 참조값이 복사되어 전달된다. swap 실패 예제와 스택 프레임 관점으로 그 근거를 설명한다.
- 캐스팅 (Casting), 업캐스팅과 다운캐스팅캐스팅은 값이나 참조를 다른 타입으로 취급하는 변환이다. 기본형 확대와 축소 변환, 업캐스팅과 다형성, 다운캐스팅과 instanceof 패턴 매칭, ClassCastException 예방법을 다룬다.
- 자바 컴파일 과정 (Java Compile Process)javac가 소스를 바이트코드로 바꾸고 JVM이 로딩, 링킹, 초기화 후 인터프리터와 JIT로 실행하는 전체 흐름을 정리한다. 클래스 파일 구조와 부모 위임 모델, 계층형 컴파일까지 다룬다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기