1. 스트림의 핵심 아키텍처: 파이프라인 (Pipeline)
스트림은 데이터 소스(Array, Collection) → 중간 연산(Intermediate Operations) → 최종 연산(Terminal Operations)의 3단계로 구성된 파이프라인입니다.
- Lazy Evaluation (지연 연산): 중간 연산(filter, map 등)은 호출 즉시 실행되지 않습니다. 최종 연산이 호출되기 전까지는 아무런 연산도 수행하지 않으며, 단지 연산의 '설계도'만 그립니다.
- Vertical Pipelining: for문처럼 요소 하나를 끝까지 처리하고 다음 요소로 넘어가는 수직적 구조입니다. 덕분에 수천 개의 연산을 연결해도 효율적으로 작동합니다.
2. 연산의 분류 (CS적 관점)
① 생성 (Source)
메모리 상의 자료구조를 추상화된 데이터 흐름으로 변환합니다.
- List.stream() / Arrays.stream(T[])
- IntStream.range(0, 10) (Primitive Specialized Stream: 박싱 비용 제거)
② 중간 연산 (Intermediate Operations) - Stream<T> 반환
중간 연산은 Stateless와 Stateful로 나뉩니다.
- Stateless (무상태): filter, map. 이전 요소가 무엇이었는지 몰라도 현재 요소만 보고 처리 가능. (병렬화 유리)
- Stateful (상태 유지): sorted, distinct. 전체 요소를 버퍼링해야 하거나 상태를 공유해야 하므로 성능 오버헤드가 발생.
③ 최종 연산 (Terminal Operations) - Non-stream 반환
파이프라인을 닫고 결과를 도출합니다. 이 연산이 호출되는 순간 Consumer가 작동하며 데이터가 흐르기 시작합니다.
- collect(): 결과를 다시 자료구조(List, Map 등)로 구체화.
- forEach(): 부수 효과(Side-effect)를 발생시킴.
- reduce(): 스트림의 요소를 하나로 소모하여 결합 (Accumulator).
3. 내부 반복 (Internal Iteration) vs 외부 반복 (External Iteration)
전공자 입장에서 가장 중요한 차이입니다.
- 외부 반복 (for-each): 개발자가 인덱스나 이터레이터를 직접 제어 (How 중심). 루프 내부 최적화를 자바 런타임에 맡기기 어려움.
- 내부 반복 (Stream): '무엇을 할 것인가'(What)만 정의하고, 실제 요소 순회는 라이브러리 내부에서 처리. 덕분에 데이터 소스에 따라 병렬 처리(ParallelStream) 등의 최적화를 라이브러리 레벨에서 투명하게 적용 가능.
4. 코드 스니펫 (함수형 인터페이스 결합)
스트림은 기본적으로 Predicate, Function, Consumer 같은 Functional Interface를 인자로 받습니다.
Java
List<Integer> result = list.stream() // 1. 스트림 생성 (Source)
.filter(n -> n % 2 == 0) // 2-1. Predicate: 조건 필터링 (Stateless)
.map(n -> n * n) // 2-2. Function: 타입/값 변환 (Stateless)
.sorted() // 2-3. Comparator: 정렬 (Stateful)
.collect(Collectors.toList()); // 3. 최종 연산: 결과 구체화
1. 필터 (Filtering) - Predicate<T> 기반
데이터 스트림에서 조건에 맞는 요소만 통과시키는 Stateless 연산입니다.
- filter(Predicate<T>): 불리언 조건에 맞는 요소만 선택.
- distinct(): Object.equals(Object)를 기준으로 중복 요소 제거 (Stateful).
- limit(long): 스트림의 크기를 제한 (Short-circuiting).
- skip(long): 처음 n개의 요소를 버림.
2. 맵 (Mapping) - Function<T, R> 기반
요소를 다른 형태나 타입으로 변환하는 Stateless 연산입니다.
- map(Function<T, R>): T 타입을 R 타입으로 1:1 매핑.
- mapToInt, mapToLong, mapToDouble: Stream<T>를 기본형 스트림(IntStream 등)으로 변환하여 언박싱 비용 최적화.
- flatMap(Function<T, Stream<R>>): 중첩 구조(예: List<List<T>>)를 단일 스트림(Stream<R>)으로 평탄화.
- boxed(): 기본형 스트림을 래퍼 객체 스트림(Stream<Integer> 등)으로 변환.
3. 정렬 (Sorting) - Comparator<T> 기반
스트림의 요소를 정렬하는 Stateful 연산입니다. (이 단계에서 전체 데이터 버퍼링이 발생하므로 대량 데이터 처리 시 주의가 필요합니다.)
- sorted(): 요소의 Comparable 구현에 따른 자연 순서(Natural Order) 정렬.
- sorted(Comparator<T>): 커스텀 비교 로직 적용.
- Comparator.reverseOrder(): 역순 정렬.
- Comparator.comparing(String::length): 특정 속성 기준 정렬.
4. 구체화 (Collecting & Terminal Operations)
파이프라인을 종료하고 결과를 도출합니다.
| 분류 | 메서드 | 설명 |
| Collection 수집 | collect(Collectors.toList()) | 결과를 List로 반환. |
| collect(Collectors.toSet()) | 결과를 Set으로 반환. | |
| collect(Collectors.toMap(k, v)) | Key-Value 쌍으로 매핑하여 Map 생성. | |
| 통계/계산 | count() | 요소의 개수 반환 (long). |
| sum(), average() | IntStream 등에서 합계 및 평균 계산. | |
| max(), min() | 최대/최소값 반환 (Optional 타입). | |
| 매칭 (Short-circuit) | anyMatch(Predicate) | 하나라도 조건을 만족하는지 여부 (boolean). |
| allMatch(Predicate) | 모든 요소가 만족하는지 여부. | |
| 소모/기타 | forEach(Consumer) | 각 요소에 대해 작업 수행 (보통 출력용). |
| reduce(BinaryOperator) | 모든 요소를 결합하여 하나의 결과 도출 (누적 계산). | |
| toArray(String[]::new) | 스트림을 특정 타입의 배열로 변환. |
💡 전공자라면 꼭 챙겨야 할 포인트: Collectors.joining()
문자열 배열을 다룰 때 가장 강력한 도구 중 하나입니다. split()한 데이터를 특정 구분자로 다시 합칠 때 유용합니다.
Java
String result = list.stream()
.filter(s -> !s.isBlank())
.collect(Collectors.joining(", ")); // "A, B, C" 형태로 결합
파이프라인 구성 시 filter → map → sorted 순서로 배치하는 것이 성능상 유리