스트림 파이프라인: 원본, 중간 연산, 최종 연산
스트림을 원본·중간 연산·최종 연산으로 나눠 읽는 법과 원소가 파이프라인을 하나씩 지나가는 실행 순서, 스트림을 한 번만 쓸 수 있는 이유를 실행 결과로 확인합니다.
게시글 목록에서 공개된 글만 골라 제목을 소문자로 바꾸고 정렬하는 코드를 반복문으로 쓰면 할 일이 많습니다. 결과를 담을 빈 ArrayList를 만들고, for로 원소를 하나씩 꺼내 if로 거른 다음, add로 담고, 마지막에 정렬합니다. 그런데 이 가운데 '무엇을 남기고 어떻게 바꿀지'를 말하는 부분은 if 조건과 변환식 몇 줄뿐이고, 나머지는 '어떻게 순회할지'를 적은 코드입니다. 스트림(Stream)은 이 둘을 떼어 놓습니다. 순회는 라이브러리에 맡기고, 개발자는 거르기와 바꾸기를 filter, map 같은 연산의 연쇄로만 적습니다.
TinyStream으로 스트림 직접 만들기에서 작은 스트림을 직접 만들어 보았다면 이 글의 연산 이름은 익숙할 것입니다. 이번에는 JDK가 제공하는 Stream이 파이프라인을 어떻게 구성하고, 원소를 어떤 순서로 처리하며, 왜 한 번만 쓸 수 있는지를 실행 결과로 확인합니다. 예제는 Java 16에 추가된 toList()를 쓰므로 Java 16 이상에서 실행됩니다.
반복문과 스트림으로 같은 결과 만들기
먼저 같은 일을 두 방식으로 해 보겠습니다. 아래 코드는 1부터 6까지의 정수에서 짝수만 골라 제곱한 목록을 반복문과 스트림으로 각각 만들고, 두 결과가 같은지 비교합니다.
import java.util.ArrayList;
import java.util.List;
public final class LoopAndStreamEquivalent {
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3, 4, 5, 6);
List<Integer> loop = new ArrayList<>();
for (int value : source) {
if (value % 2 == 0) loop.add(value * value);
}
List<Integer> stream =
source.stream()
.filter(value -> value % 2 == 0)
.map(value -> value * value)
.toList();
System.out.println("same=" + loop.equals(stream) + ", result=" + stream);
}
}loop.equals(stream)은 두 목록의 원소와 순서가 모두 같을 때만 true입니다. 그래서 same=true가 나온다면 두 방식이 같은 값을 같은 순서로 만들었다는 뜻입니다.
same=true, result=[4, 16, 36]반복문 쪽은 결과를 담을 loop 목록을 직접 만들고, 원소를 하나씩 꺼내 조건을 검사한 뒤 add를 호출합니다. 반복을 사용자 코드가 바깥에서 몰고 가므로 이런 방식을 외부 반복(external iteration)이라고 합니다. 스트림 쪽에는 반복문도, 결과 목록을 만드는 코드도 없습니다. filter에 '짝수인가'라는 조건을, map에 '제곱한다'는 변환을 넘기면 원소를 꺼내 함수에 넘기는 일은 라이브러리가 합니다. 이렇게 반복이 라이브러리 안에서 일어나는 방식을 내부 반복(internal iteration)이라고 합니다.
내부 반복의 이점은 코드가 짧아지는 데서 끝나지 않습니다. 반복을 라이브러리가 쥐고 있으므로 여러 단계를 원소 하나씩 한 번에 흘려보낼 수도 있고, 답이 나오면 남은 원소를 읽지 않고 멈출 수도 있으며, 여러 스레드로 나눠 실행할 수도 있습니다. 이 글과 다음 글에서 앞의 두 가지를 실행 결과로 확인합니다. 다만 내부 반복이 우리가 넘긴 함수의 부수 효과까지 안전하게 만들어 주지는 않는데, 이 점은 뒤에서 따로 다룹니다.
toList()는 Java 16에 추가된 최종 연산입니다. 그 전에는 같은 일을 collect(Collectors.toList())로 했기 때문에 오래된 코드에서 지금도 자주 보입니다. 둘은 결과 목록을 고칠 수 있는지가 다릅니다. toList()가 돌려준 목록에 add를 호출하면 UnsupportedOperationException이 발생하는 반면, Collectors.toList()는 목록의 구체 타입이나 수정 가능 여부를 명세로 약속하지 않습니다. JDK 21 구현은 ArrayList를 돌려주지만 거기에 기대지 않는 것이 좋습니다. Collector는 Collectors로 List·Map 만들고 그룹으로 묶기에서 자세히 다룹니다.
파이프라인의 세 부분
스트림 코드는 세 부분으로 나눠 읽습니다. 원소를 공급하는 원본(source), 원소를 거르거나 바꾸는 중간 연산(intermediate operation), 실행을 시작해 결과를 만드는 최종 연산(terminal operation)입니다. 원본 하나에 중간 연산 0개 이상과 최종 연산 하나를 이은 전체를 스트림 파이프라인(stream pipeline)이라고 부릅니다.
| 부분 | 하는 일 | 예 | 반환 타입 |
|---|---|---|---|
| 원본 | 원소를 공급한다 | list.stream(), Arrays.stream(array), Stream.of(a, b) | Stream |
| 중간 연산 | 원소를 거르거나 바꾸거나 순서를 바꾼다 | filter, map, sorted, distinct, limit | Stream |
| 최종 연산 | 실행을 시작하고 결과를 만든다 | toList, count, forEach, reduce, findFirst | Stream이 아닌 값, 또는 void |
어떤 메서드가 중간 연산인지 최종 연산인지 헷갈리면 반환 타입을 보면 됩니다. Stream을 돌려주면 뒤에 연산을 더 이을 수 있는 중간 연산입니다. List, long, Optional처럼 다른 타입을 돌려주거나 아무것도 돌려주지 않으면 최종 연산이고, 그 뒤로는 연산을 더 이을 수 없습니다.
다음 코드는 게시글 통계 목록에서 공개된 글 가운데 조회 수가 30 이상인 글의 제목을 다듬어 정렬합니다.
import java.util.List;
public final class PostMetricPipeline {
private record PostMetric(String title, int viewCount, boolean published) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric(" Stream ", 45, true),
new PostMetric("Optional", 20, false),
new PostMetric("Collector", 60, true));
List<String> report =
posts.stream()
.filter(PostMetric::published)
.filter(post -> post.viewCount() >= 30)
.map(post -> post.title().strip().toLowerCase())
.sorted()
.toList();
System.out.println(report);
}
}map의 strip()은 제목 앞뒤의 공백을 지우고 toLowerCase()는 소문자로 바꿉니다. sorted()는 그 뒤에 오므로, 정렬은 다듬은 제목을 기준으로 이루어집니다.
[collector, stream]원본은 posts.stream(), 중간 연산은 두 번의 filter와 map, sorted이고, 최종 연산은 toList()입니다. 세 원소가 각 단계를 지나며 어떻게 되는지 표로 따라가 보겠습니다.
| 원소 | 공개 여부 검사 | 조회 수 30 이상 검사 | map 결과 |
|---|---|---|---|
" Stream ", 45, 공개 | 통과 | 통과 | "stream" |
"Optional", 20, 비공개 | 탈락 | 검사하지 않음 | 없음 |
"Collector", 60, 공개 | 통과 | 통과 | "collector" |
두 번째 글은 첫 filter에서 탈락하므로 조회 수 검사도, 제목 변환도 받지 않습니다. 살아남은 "stream"과 "collector"를 sorted()가 사전순으로 정렬하므로 collector가 앞에 옵니다. 이때 정렬되는 것은 결과 목록일 뿐이고, posts의 순서와 내용은 그대로입니다. 스트림은 원본을 바꾸지 않고 새 결과를 만듭니다.
toList()가 돌려준 목록은 수정할 수 없어서 add나 set을 호출하면 UnsupportedOperationException이 발생합니다. 다만 고칠 수 없는 것은 목록의 구조이지 원소 객체가 아닙니다. 원소가 가변 객체라면 그 객체의 상태는 여전히 바꿀 수 있습니다. 이 예제의 원소는 불변인 String이라 그런 걱정이 없습니다.
원소는 하나씩 파이프라인을 지나간다
filter와 map을 연결하고 한 반복문으로 합치기에서 본 단계별 즉시 처리는 먼저 모든 원소에 filter를 적용해 임시 목록을 만들고, 그 목록 전체에 다시 map을 적용했습니다. 스트림도 그렇게 동작할까요? filter와 map 안에 출력문을 넣어 함수가 호출되는 순서를 찍어 보겠습니다.
import java.util.List;
public final class ElementPipelineTrace {
public static void main(String[] args) {
List<Integer> result =
List.of(1, 2, 3, 4).stream()
.filter(
value -> {
System.out.println("filter " + value);
return value % 2 == 0;
})
.map(
value -> {
System.out.println("map " + value);
return value * 10;
})
.toList();
System.out.println("result=" + result);
}
}출력문은 함수가 호출되는 바로 그 순간 찍히므로, 줄의 순서가 곧 함수가 호출된 순서입니다. 단계별 즉시 처리라면 filter 네 줄이 모두 나온 뒤에 map 줄이 나와야 합니다.
filter 1
filter 2
map 2
filter 3
filter 4
map 4
result=[20, 40]filter가 네 번 연달아 호출된 뒤에 map이 호출되는 것이 아니라, 두 함수의 호출이 번갈아 나타납니다. 1은 filter에서 탈락하므로 map까지 가지 않습니다. 2는 filter를 통과하자마자 map으로 넘어가 20이 되고 결과 목록에 담기며, 그다음에야 원본에서 3을 꺼냅니다. 즉 순차 스트림은 원소 하나를 파이프라인 끝까지 보낸 뒤에 다음 원소를 꺼냅니다. 그래서 filter의 결과를 모아 둘 임시 목록이 필요 없습니다.
아래 그림은 이 실행에서 각 원소가 어느 경로로 가는지를 보여 줍니다. 그림이 나타내는 것은 어떤 원소가 결과에 들어가는지라는 값의 경로이고, 함수가 호출되는 시각이나 횟수는 아닙니다.
짝수 원소가 map 결과에 포함되는 경로
원소별 filter 통과와 10배 값 변환 뒤 [20,40]이 됩니다. 구체 값의 변환을 보여 주되 중간 List의 생성이나 로그 호출 순서·횟수를 보장하지 않는 원 조건을 유지합니다.
원소 1과 3은 짝수 조건에서 제외되고, 2와 4는 열 배로 변환되어 결과 20과 40에 포함됩니다. 값의 관계를 나타내며 로그 호출 시각이나 횟수를 표시하지 않습니다.
원소가 통과하는 값의 경로
짝수 원소가 map 결과에 포함되는 경로
짝수인 2·4만 10배로 변환됩니다. 화살표는 값이 결과에 포함되는 경로를 설명합니다.
이 예의 조건
- 값 경로이며 중간 List 생성·정확한 로그 호출 수 보장이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원소 1·3은 조건에서 제외되고 2·4는 10배로 바뀌어 toList 결과 20·40에 들어간다. 결정 노드의 짝수와 홀수 분기, 변환, 결과는 기존 5개 노드와 4개 간선과 같다. 화살표는 값의 경로이며 중간 List 생성이나 정확한 로그 호출 수를 보장하지 않는다. 모바일 240에서도 동일한 분기를 각 포트로 분리한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원소 1·3은 조건에서 제외되고 2·4는 10배로 바뀌어 toList 결과 20·40에 들어간다. 결정 노드의 짝수와 홀수 분기, 변환, 결과는 기존 5개 노드와 4개 간선과 같다. 화살표는 값의 경로이며 중간 List 생성이나 정확한 로그 호출 수를 보장하지 않는다. 모바일 240에서도 동일한 분기를 각 포트로 분리한다. |
| 전체 읽기 정보 | 짝수 원소가 map 결과에 포함되는 경로 짝수 원소가 map 결과에 포함되는 경로 원소 1과 3은 짝수 조건에서 제외되고, 2와 4는 열 배로 변환되어 결과 20과 40에 포함됩니다. 값의 관계를 나타내며 로그 호출 시각이나 횟수를 표시하지 않습니다. 원소가 통과하는 값의 경로 짝수 원소가 map 결과에 포함되는 경로 짝수인 2·4만 10배로 변환됩니다. 화살표는 값이 결과에 포함되는 경로를 설명합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 조건 선별 1,2,3,4에서 짝수 조건을 적용합니다. |
|
|
| 통과한 값 변환 2와 4에 10배를 적용합니다. |
|
|
| 종단 수집 toList가 결과를 모읍니다. |
|
|
짝수 원소가 map 결과에 포함되는 경로
원소별 filter 통과와 10배 값 변환 뒤 [20,40]이 됩니다. 구체 값의 변환을 보여 주되 중간 List의 생성이나 로그 호출 순서·횟수를 보장하지 않는 원 조건을 유지합니다.
원소 1과 3은 짝수 조건에서 제외되고, 2와 4는 열 배로 변환되어 결과 20과 40에 포함됩니다. 값의 관계를 나타내며 로그 호출 시각이나 횟수를 표시하지 않습니다.
원소가 통과하는 값의 경로
짝수 원소가 map 결과에 포함되는 경로
짝수인 2·4만 10배로 변환됩니다. 화살표는 값이 결과에 포함되는 경로를 설명합니다.
이 예의 조건
- 값 경로이며 중간 List 생성·정확한 로그 호출 수 보장이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원소 1·3은 조건에서 제외되고 2·4는 10배로 바뀌어 toList 결과 20·40에 들어간다. 결정 노드의 짝수와 홀수 분기, 변환, 결과는 기존 5개 노드와 4개 간선과 같다. 화살표는 값의 경로이며 중간 List 생성이나 정확한 로그 호출 수를 보장하지 않는다. 모바일 240에서도 동일한 분기를 각 포트로 분리한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원소 1·3은 조건에서 제외되고 2·4는 10배로 바뀌어 toList 결과 20·40에 들어간다. 결정 노드의 짝수와 홀수 분기, 변환, 결과는 기존 5개 노드와 4개 간선과 같다. 화살표는 값의 경로이며 중간 List 생성이나 정확한 로그 호출 수를 보장하지 않는다. 모바일 240에서도 동일한 분기를 각 포트로 분리한다. |
| 전체 읽기 정보 | 짝수 원소가 map 결과에 포함되는 경로 짝수 원소가 map 결과에 포함되는 경로 원소 1과 3은 짝수 조건에서 제외되고, 2와 4는 열 배로 변환되어 결과 20과 40에 포함됩니다. 값의 관계를 나타내며 로그 호출 시각이나 횟수를 표시하지 않습니다. 원소가 통과하는 값의 경로 짝수 원소가 map 결과에 포함되는 경로 짝수인 2·4만 10배로 변환됩니다. 화살표는 값이 결과에 포함되는 경로를 설명합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 조건 선별 1,2,3,4에서 짝수 조건을 적용합니다. |
|
|
| 통과한 값 변환 2와 4에 10배를 적용합니다. |
|
|
| 종단 수집 toList가 결과를 모읍니다. |
|
|
여기서 한 가지를 분명히 해 두겠습니다. 위 출력은 JDK의 순차 스트림이 실제로 동작하는 방식을 보여 주지만, Stream 명세가 이 호출 순서와 횟수를 약속하지는 않습니다. java.util.stream 패키지 문서는 계산 결과에 영향이 없다고 판단되면 구현이 연산이나 단계 전체를 생략할 수 있고, 병렬 실행에서는 함수가 각 원소에 적용되는 순서도 정해져 있지 않다고 설명합니다. 그래서 이런 출력문은 동작을 배우거나 디버깅할 때만 쓰고, 프로그램의 결과가 출력문이나 로그의 호출 횟수에 기대게 만들지 않습니다. 생략이 실제로 일어나는 예는 다음 글에서 확인합니다.
sorted는 모든 원소가 모일 때까지 기다린다
filter와 map은 원소 하나만 보고 일을 끝냅니다. 2가 짝수인지 판단하는 데 다른 원소는 필요 없기 때문입니다. 이런 연산을 무상태(stateless) 연산이라고 합니다. 반면 sorted는 다릅니다. 가장 작은 원소가 무엇인지는 원소를 모두 본 다음에야 알 수 있습니다. 이처럼 앞서 지나간 원소를 기억해야 하는 연산을 상태 보유(stateful) 연산이라고 합니다. 두 연산 사이에 sorted를 끼우면 실행 순서가 어떻게 바뀌는지 보겠습니다.
import java.util.List;
public final class SortedBarrierTrace {
public static void main(String[] args) {
List<Integer> result =
List.of(3, 1, 4, 2).stream()
.filter(
value -> {
System.out.println("filter " + value);
return value > 1;
})
.sorted()
.map(
value -> {
System.out.println("map " + value);
return value * 10;
})
.toList();
System.out.println("result=" + result);
}
}앞 예제와 달리 원본은 3, 1, 4, 2로 섞여 있고, filter는 1만 거릅니다. map 줄이 언제부터 나오는지, 어떤 순서로 나오는지를 보세요.
filter 3
filter 1
filter 4
filter 2
map 2
map 3
map 4
result=[20, 30, 40]이번에는 네 원소 모두에 filter가 먼저 호출되고, 그 뒤에 map이 2, 3, 4 순서로 호출됩니다. sorted가 통과한 원소 3, 4, 2를 내부 버퍼에 모아 두었다가, 원본이 바닥난 뒤에 정렬해서 작은 것부터 다음 단계로 내보냈기 때문입니다. map이 받은 순서가 원본 순서인 3, 4, 2가 아니라 정렬된 2, 3, 4라는 점이 이를 보여 줍니다.
그래서 상태 보유 연산은 원소 수에 비례하는 메모리를 쓸 수 있고, 원소를 하나씩 흘려보내는 흐름을 그 지점에서 한 번 끊습니다. distinct도 이미 지나간 원소를 기억해야 하므로 상태 보유 연산입니다. 연산별 분류와 limit, skip처럼 위치를 세는 연산은 filter·map·flatMap과 상태 보유 중간 연산에서 자세히 다룹니다.
스트림은 한 번만 쓸 수 있다
스트림은 데이터를 담아 두는 컬렉션이 아니라, 원본에서 원소를 한 번 꺼내 흘려보내는 일회용 통로입니다. 그래서 최종 연산을 한 번 실행한 스트림은 다시 쓸 수 없습니다. 아래 코드는 같은 스트림 객체에 count()를 호출한 뒤 toList()를 또 호출합니다.
import java.util.List;
import java.util.stream.Stream;
public final class StreamReuseFailure {
public static void main(String[] args) {
Stream<String> titles = List.of("stream", "collector", "optional").stream();
System.out.println("count=" + titles.count());
System.out.println(titles.toList());
}
}JDK 21에서 실행한 결과입니다. 스택 트레이스에 찍히는 JDK 내부 줄 번호는 버전마다 다를 수 있습니다.
count=3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.evaluateToArrayNode(AbstractPipeline.java:246)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:616)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:622)
at java.base/java.util.stream.ReferencePipeline.toList(ReferencePipeline.java:627)
at StreamReuseFailure.main(StreamReuseFailure.java:8)첫 줄의 count=3은 정상적으로 출력됩니다. 그런데 count()가 실행되면서 titles는 이미 소비된 상태가 되었고, 두 번째 최종 연산인 toList()가 이를 확인하고 IllegalStateException을 던졌습니다. 메시지의 'operated upon or closed'는 이미 연산에 쓰였거나 닫힌 스트림이라는 뜻입니다. 원본인 List는 아무 일 없이 남아 있으므로, 원본에서 스트림을 새로 만들면 다시 처리할 수 있습니다.
중간 연산을 호출하는 것도 스트림을 쓰는 일입니다. 그래서 스트림 하나에서 파이프라인을 두 갈래로 나누려고 하면, 두 번째 중간 연산을 호출하는 순간 같은 예외가 발생합니다.
import java.util.List;
import java.util.stream.Stream;
public final class StreamBranchFailure {
public static void main(String[] args) {
Stream<String> words = List.of("a", "bb", "ccc").stream();
Stream<String> longWords = words.filter(word -> word.length() > 1);
try {
words.map(String::toUpperCase);
} catch (IllegalStateException e) {
System.out.println("두 번째 연결: " + e.getMessage());
}
System.out.println("첫 번째 연결: " + longWords.toList());
}
}try는 같은 words에 두 번째 중간 연산을 연결하는 줄만 감쌉니다. 먼저 연결한 longWords의 최종 연산은 그 뒤에 따로 실행합니다.
두 번째 연결: stream has already been operated upon or closed
첫 번째 연결: [bb, ccc]words에 filter를 호출한 순간 words는 다음 단계와 연결되었고, 같은 words에 map을 또 연결하려 하자 예외가 났습니다. 최종 연산을 호출하기도 전인데 예외가 났다는 점에 주목하세요. 먼저 연결한 longWords 쪽은 아무 문제 없이 최종 연산까지 실행됩니다. 즉 스트림 객체 하나에는 연산을 하나만 이을 수 있고, 파이프라인은 갈라지지 않는 한 줄입니다.
같은 데이터로 결과가 두 개 필요하면 결과마다 원본에서 스트림을 새로 만듭니다. 같은 파이프라인 앞부분을 여러 번 써야 한다면, 스트림 대신 스트림을 만드는 방법을 Supplier에 담아 두고 필요할 때마다 새 스트림을 받습니다.
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;
public final class FreshStreamPerUse {
public static void main(String[] args) {
List<String> titles = List.of("stream", "collector", "optional");
Supplier<Stream<String>> longTitles =
() -> titles.stream().filter(title -> title.length() > 6);
System.out.println("count=" + longTitles.get().count());
System.out.println(longTitles.get().toList());
}
}두 줄은 같은 조건으로 거른 결과를 하나는 개수로, 하나는 목록으로 보여 줍니다. 같은 스트림 객체에 최종 연산을 두 번 호출했다면 두 번째 줄에서 앞 예제와 같은 예외가 났을 것입니다.
count=2
[collector, optional]longTitles.get()은 호출될 때마다 titles.stream()부터 다시 실행해 새 스트림을 돌려주므로, 두 최종 연산은 서로 다른 스트림 객체에서 실행됩니다. 대신 원본을 두 번 순회하는 셈입니다. 원본이 크거나 파일처럼 다시 읽기 비싸다면 결과를 toList()로 한 번 모아 두고 그 목록을 재사용하는 편이 낫습니다. 반대로 Stream 객체를 필드에 저장해 두고 여러 곳에서 꺼내 쓰는 설계는 두 번째 사용에서 예외가 나므로 피합니다.
최종 연산으로 스트림을 소비하는 것과 close()로 스트림을 닫는 것은 다른 일이라는 점도 기억해 두세요. 최종 연산은 스트림을 닫지 않으므로, 파일처럼 자원에 연결된 스트림은 따로 닫아야 합니다. 이 내용은 다음 글에서 다룹니다.
결과의 순서는 무엇이 정하나
List나 배열에서 만든 스트림에는 원본이 정한 순서가 있습니다. 이를 만남 순서(encounter order)라고 합니다. filter와 map은 만남 순서를 그대로 유지하고, toList()는 만남 순서가 있으면 그 순서대로 목록을 만듭니다. 앞의 예제들이 sorted() 없이도 원본 순서대로 결과를 낸 것은 이 때문입니다. sorted()는 만남 순서를 정렬 순서로 바꿉니다.
모든 원본에 만남 순서가 있는 것은 아닙니다. HashSet처럼 원소의 순서를 정하지 않는 컬렉션에서 만든 스트림에는 만남 순서가 없고, 실행할 때 나오는 순서는 그 구현에서 우연히 그렇게 된 것일 뿐입니다. 결과 순서가 기능상 필요하다면 순서가 있는 원본을 쓰거나 sorted로 순서를 명시해야 합니다.
결과의 순서와 함수가 호출되는 시간 순서는 따로 생각해야 합니다. 병렬 스트림에서도 toList()의 결과는 만남 순서를 지키지만, 각 원소에 map 함수가 호출되는 순서는 정해져 있지 않습니다. forEach는 병렬 스트림에서 만남 순서를 지킨다는 약속이 없으므로, 순서대로 처리해야 한다면 병렬 처리의 이점을 일부 포기하는 대신 순서를 지키는 forEachOrdered를 씁니다. 병렬 스트림의 순서 문제는 작업 훔치기와 병렬 스트림에서 실험합니다.
파이프라인 안에서 바깥 상태를 바꾸지 않기
스트림을 반복문처럼 생각하면 map 안에서 결과를 바깥 목록에 쌓는 코드를 쓰기 쉽습니다. 다음 코드는 제목을 대문자로 바꾸면서 그 값을 upper 목록에도 모으고, 동시에 6글자보다 긴 제목이 있는지 검사합니다. anyMatch는 조건을 만족하는 원소가 하나라도 있는지 검사하는 최종 연산입니다.
import java.util.ArrayList;
import java.util.List;
public final class SideEffectList {
public static void main(String[] args) {
List<String> titles = List.of("stream", "collector", "optional");
List<String> upper = new ArrayList<>();
boolean hasLong =
titles.stream()
.map(
title -> {
String converted = title.toUpperCase();
upper.add(converted); // 결과를 바깥 목록에 따로 쌓는다
return converted;
})
.anyMatch(title -> title.length() > 6);
System.out.println("hasLong=" + hasLong + ", upper=" + upper);
}
}map 안의 upper.add는 원소가 실제로 map을 지날 때만 실행됩니다. 출력에서 upper에 담긴 제목 수를 원본의 제목 수와 비교해 보세요.
hasLong=true, upper=[STREAM, COLLECTOR]upper에 세 제목이 모두 들어 있으리라 기대했다면 결과가 다릅니다. anyMatch는 두 번째 원소 COLLECTOR에서 이미 답을 얻었으므로 세 번째 원소를 원본에서 꺼내지 않았고, 그래서 optional은 변환되지도, 목록에 담기지도 않았습니다. 바깥 목록의 내용이 최종 연산이 원소를 얼마나 요구했는지에 따라 달라진 것입니다. 같은 이유로 구현이 결과에 필요 없는 단계를 생략하면 바깥 목록이 아예 비어 있을 수도 있습니다. 병렬 스트림이라면 여러 스레드가 ArrayList에 동시에 add하다가 원소가 사라질 수도 있습니다.
결과를 최종 연산의 반환값으로만 얻도록 바꾸면 이런 문제가 없습니다.
import java.util.List;
public final class SideEffectFree {
public static void main(String[] args) {
List<String> titles = List.of("stream", "collector", "optional");
List<String> upper = titles.stream().map(String::toUpperCase).toList();
boolean hasLong = upper.stream().anyMatch(title -> title.length() > 6);
System.out.println("hasLong=" + hasLong + ", upper=" + upper);
}
}이번에는 변환한 목록을 먼저 완성한 뒤, 그 목록에서 새 스트림을 만들어 검사합니다. 앞의 출력과 upper 부분을 비교해 보세요.
hasLong=true, upper=[STREAM, COLLECTOR, OPTIONAL]변환한 목록은 toList()의 반환값으로 받고, 검사는 그 목록에서 새 스트림을 만들어 따로 합니다. 이제 각 람다는 받은 원소로 값을 계산해 돌려주기만 하므로, 몇 번 호출되든 어떤 순서로 호출되든 결과가 같습니다. 스트림 연산에 넘기는 함수는 이처럼 바깥 상태를 바꾸지 않는 것이 원칙입니다. 여러 원소를 모으는 상태가 꼭 필요하다면 collect와 Collector로 누적 방법을 명시하는데, 이는 Collectors로 List·Map 만들고 그룹으로 묶기에서 다룹니다.
반복문이 더 나은 경우
스트림이 언제나 더 나은 표현은 아닙니다. 다음과 같은 코드는 반복문으로 쓰는 편이 대개 더 읽기 쉽습니다.
- 조건에 따라
break나return으로 중간에 빠져나가는 흐름이 핵심인 코드 - 검사 예외를 던지는 메서드를 호출하는 코드.
map이 받는Function같은 표준 함수형 인터페이스는 검사 예외를 선언하지 않으므로, 람다 안에서 검사 예외를 그대로 던질 수 없습니다. - 서로 영향을 주는 누적 변수가 여러 개인 코드
- 인덱스로 이웃 원소를 보거나 컬렉션을 제자리에서 고쳐야 하는 코드
이런 코드를 억지로 스트림에 넣으면 바깥 상태를 바꾸는 람다가 생기고, 앞 절의 문제가 그대로 돌아옵니다. 성능도 추측으로 판단하지 않습니다. 원소가 아주 많은 반복에서는 박싱과 람다 호출 비용이 드러날 수 있지만, 실제로 그런지는 프로파일러나 벤치마크로 측정한 뒤에 결정합니다.
반대로 거르기, 바꾸기, 정렬, 그룹 짓기처럼 데이터 사이의 관계를 단계별로 적는 일이라면 스트림이 잘 맞습니다. 이때 람다가 길어지면 조건과 변환을 이름 있는 메서드로 빼서 파이프라인이 한 문장처럼 읽히게 만듭니다.
import java.util.Comparator;
import java.util.List;
public final class BoardReportPipeline {
private record Entry(String category, String title, int viewCount) {}
private record Row(String label, int viewCount) {}
private static boolean isNotice(Entry entry) {
return entry.category().equals("notice");
}
private static Row toRow(Entry entry) {
return new Row(entry.title().toUpperCase(), entry.viewCount());
}
public static void main(String[] args) {
List<Entry> entries =
List.of(
new Entry("notice", "welcome", 50),
new Entry("free", "question", 20),
new Entry("notice", "maintenance", 40));
List<Row> rows =
entries.stream()
.filter(BoardReportPipeline::isNotice)
.map(BoardReportPipeline::toRow)
.sorted(Comparator.comparingInt(Row::viewCount).reversed())
.toList();
rows.forEach(System.out::println);
}
}rows.forEach(System.out::println)은 행마다 레코드의 toString()을 한 줄씩 찍습니다. free 분류의 글은 첫 단계에서 빠지므로 출력에 없습니다.
Row[label=WELCOME, viewCount=50]
Row[label=MAINTENANCE, viewCount=40]파이프라인만 읽어도 '공지만 골라서, 행으로 바꾸고, 조회 수가 많은 순서로 정렬한다'는 뜻이 드러납니다. 조건과 변환의 세부는 isNotice와 toRow라는 이름 뒤에 들어가 있고, 정렬 기준도 비교 람다를 직접 쓰는 대신 Comparator.comparingInt(Row::viewCount).reversed()로 적었습니다. 출력은 최종 연산이 돌려준 rows를 받은 다음에 하므로, 파이프라인 안의 함수는 값을 계산하는 일만 합니다.
더 깊이: JDK가 파이프라인을 실행하는 방식
OpenJDK 구현에서 filter나 map을 호출하면 계산은 하지 않고, 이전 단계에 연결된 새 단계 객체(AbstractPipeline의 하위 클래스)만 만듭니다. 최종 연산이 호출되면 마지막 단계부터 첫 단계 쪽으로 거슬러 가며 단계마다 원소를 받아 처리하는 객체(Sink)를 엮어 하나의 사슬을 만들고, 원본의 Spliterator가 원소를 하나씩 이 사슬의 맨 앞에 밀어 넣습니다. 원소 하나가 filter의 처리기에서 map의 처리기로, 다시 결과를 모으는 처리기로 곧장 전달되기 때문에 첫 번째 추적처럼 호출이 번갈아 나타납니다. sorted의 처리기는 받은 원소를 버퍼에 쌓기만 하다가, 원본이 끝났다는 신호를 받은 뒤에 정렬해서 다음 처리기로 내보냅니다. 두 번째 추적에서 filter가 모두 끝난 뒤에야 map이 시작된 이유입니다.
재사용 검사도 이 단계 객체에 붙어 있습니다. 각 단계는 다음 단계에 이미 연결되었거나 소비되었는지를 나타내는 플래그를 가지고 있어서, 플래그가 켜진 단계에 연산을 또 이으려 하면 IllegalStateException을 던집니다. 다만 Stream 문서는 일부 연산이 새 스트림 대신 자기 자신을 돌려줄 수 있어서 모든 재사용을 감지하지는 못할 수 있다고 적고 있습니다. 실제로 sequential()은 같은 스트림 객체를 돌려주며, 이미 소비된 스트림에 호출해도 예외가 나지 않습니다. 그러니 예외는 실수를 잡아 주는 안전장치일 뿐이고, 예외가 나지 않았다고 재사용해도 된다는 뜻은 아닙니다.
더 깊이: 중간 연산을 직접 정의하는 Stream.gather
오랫동안 중간 연산의 종류는 Stream 인터페이스에 정해져 있어서, 원소를 세 개씩 묶는 것처럼 앞 원소를 기억해야 하는 새 연산이 필요하면 결과를 모은 뒤 따로 처리해야 했습니다. Java 22와 23의 프리뷰를 거쳐 Java 24에서 정식 기능이 된 스트림 게더러(Stream Gatherers, JEP 485)는 Stream.gather라는 중간 연산을 추가해, 원소를 받아 0개 이상의 원소를 내보내는 단계를 직접 정의할 수 있게 했습니다. java.util.stream.Gatherers에는 고정 크기로 묶는 windowFixed, 한 칸씩 밀며 묶는 windowSliding, 누적값을 차례로 내보내는 scan 같은 기본 게더러가 들어 있습니다. Java 24 이상이 필요한 API이므로 이 글의 예제에서는 쓰지 않았습니다.
정리
- 스트림 파이프라인은 원본, 0개 이상의 중간 연산, 하나의 최종 연산으로 이루어집니다.
Stream을 돌려주면 중간 연산이고, 다른 값을 돌려주거나 아무것도 돌려주지 않으면 최종 연산입니다. - 순차 스트림은 원소 하나를 파이프라인 끝까지 보낸 뒤 다음 원소를 꺼냅니다. 그래서
filter와map사이에 임시 목록이 생기지 않습니다. sorted처럼 모든 원소를 봐야 하는 상태 보유 연산은 원소를 버퍼에 모은 뒤에야 다음 단계로 내보냅니다.- 스트림 객체에는 연산을 한 번만 이을 수 있습니다. 같은 데이터를 다시 처리하려면 원본에서 새 스트림을 만듭니다.
- 결과는 최종 연산의 반환값으로 받고, 람다 안에서 바깥 상태를 바꾸지 않습니다. 함수가 몇 번, 어떤 순서로 호출되는지는 명세가 약속하지 않습니다.
연습 문제
ElementPipelineTrace의 원본을List.of(4, 1, 2, 3, 2)로 바꾸면 결과 목록은 무엇일까요? 중복된 2는 어떻게 되나요?toList()앞에sorted()를 추가하면 결과가 어떻게 바뀌나요?PostMetricPipeline의 원본에new PostMetric(null, 50, false)를 추가하면 프로그램이 정상적으로 끝날까요? 대신new PostMetric(null, 50, true)를 추가하면 어떻게 될까요?Post(String title, boolean published)목록에서 공개된 글만 남기고, 제목 양끝의 공백을 제거한 뒤 길이가 짧은 순서로, 길이가 같으면 사전순으로 정렬한 제목 목록을 만드세요. 원본 목록은 바꾸지 않아야 합니다.
1번 풀이
결과는 [40, 20, 20]입니다. filter는 원소마다 짝수인지만 따지므로 4, 2, 2가 원본 순서대로 통과하고, map이 각각 10배로 바꿉니다. filter는 조건으로 원소를 고를 뿐 중복을 없애지 않으므로 두 2가 모두 남습니다. 중복을 없애려면 distinct()가 따로 필요합니다. sorted()를 추가하면 결과는 [20, 20, 40]이 되고, 정렬은 순서만 바꿀 뿐 원소 수는 바꾸지 않으므로 20은 여전히 두 개입니다.
2번 풀이
비공개인 (null, 50, false)는 첫 filter에서 탈락하므로 title().strip()이 호출되지 않고, 결과는 그대로 [collector, stream]입니다. 공개인 (null, 50, true)는 두 filter를 모두 통과해 map에 도달하고, null에 strip()을 호출하는 순간 NullPointerException이 발생합니다. 예외가 나면 toList()는 결과를 돌려주지 못합니다.
원소가 하나씩 파이프라인을 지나가므로, 어떤 원소가 어느 단계까지 가는지 따라가 보면 실패 위치를 예측할 수 있습니다. 첫 번째 경우에 null 제목이 문제를 일으키지 않은 것은 공개 여부 검사에서 우연히 걸러졌기 때문입니다. 제목이 null일 수 있다면 filter(post -> post.title() != null)처럼 따로 검사해야 합니다.
3번 정답과 풀이
filter가 공개 여부를 검사하고, map이 공백을 제거하고, sorted가 두 단계 정렬 기준을 적용하고, toList가 결과 목록을 만듭니다.
import java.util.Comparator;
import java.util.List;
public final class PublishedPostTitlesSolution {
private record Post(String title, boolean published) {}
public static void main(String[] args) {
List<Post> source =
List.of(
new Post(" Welcome ", true),
new Post("Draft", false),
new Post(" Notice", true));
List<String> titles =
source.stream()
.filter(Post::published)
.map(post -> post.title().strip())
.sorted(
Comparator.comparingInt(String::length)
.thenComparing(Comparator.naturalOrder()))
.toList();
System.out.println(titles);
System.out.println("source-size=" + source.size());
}
}실행 결과:
[Notice, Welcome]
source-size=3Draft는 비공개라 걸러지고, 공백을 제거한 Welcome(7글자)과 Notice(6글자)가 길이순으로 정렬되어 Notice가 먼저 옵니다. 정렬 기준은 Comparator.comparingInt(String::length)로 길이를 먼저 비교하고, 길이가 같을 때만 thenComparing(Comparator.naturalOrder())로 사전순 비교를 합니다. (a, b) -> a.length() - b.length()처럼 뺄셈으로 비교자를 만들 수도 있지만, 값의 범위가 넓으면 뺄셈이 오버플로해 잘못된 순서를 낼 수 있으므로 비교할 키를 지정하는 comparingInt를 쓰는 습관이 안전합니다. 마지막 줄의 source-size=3은 원본 목록이 그대로임을 보여 줍니다.
다음 글
지연 연산과 스트림 만들기에서는 최종 연산을 호출하기 전까지는 아무 계산도 일어나지 않는다는 점과, 최종 연산이 필요한 만큼만 원소를 꺼내는 단락 평가를 실행 결과로 확인합니다. 컬렉션, 배열, 값, 무한 원본에서 스트림을 만드는 방법도 함께 정리합니다.