filter와 map을 연결하고 한 반복문으로 합치기
filter와 map을 단계별로 이어 실행하는 방법과 한 반복문으로 합치는 방법을 비교하고, 결과는 같아도 함수가 호출되는 순서와 예외가 나는 지점이 어떻게 달라지는지 확인합니다.
앞 글에서 만든 filter의 결과를 map에 넘기면 '조건을 통과한 원소를 다른 표현으로 바꾼다'는 처리가 됩니다. 이처럼 한 단계의 출력이 다음 단계의 입력이 되도록 연산을 이어 붙인 것을 파이프라인(pipeline)이라고 부릅니다. 두 메서드를 이어 쓰면 단계마다 이름이 보이고 중간 결과를 들여다보기도 쉽지만, 단계마다 목록을 하나씩 새로 만듭니다. 반대로 한 반복문 안에서 조건 검사와 변환을 연달아 하면 중간 목록은 사라지는 대신 두 단계의 의미가 반복문 본문 안에 다시 섞입니다.
이 글에서는 두 방식을 같은 입력으로 실행해 결과뿐 아니라 함수가 불리는 순서와 예외가 나는 지점까지 비교합니다. 어느 한쪽이 언제나 낫다는 결론을 내리려는 것은 아닙니다. 할 일을 순서대로 적는 명령형(imperative) 반복문은 제어 흐름과 값이 바뀌는 위치를 직접 보여 주고, 원하는 결과를 연산 이름으로 적는 선언형(declarative) 파이프라인은 무엇을 고르고 무엇으로 바꾸는지를 한눈에 보여 줍니다. 실패를 어떻게 처리해야 하는지, 중간에 멈춰야 하는지, 성능이 얼마나 중요한지에 따라 더 잘 읽히는 쪽을 고르면 됩니다.
결과 목록을 필드에 두면 생기는 일
두 방식을 비교하기 전에 결과를 어디에 모을지부터 정해야 합니다. 다음 처리기는 양수만 골라 두 배로 만든 문자열을 결과 목록에 모으는데, 그 목록을 메서드 안이 아니라 필드에 두었습니다. 같은 처리기로 요청 두 개를 처리한 다음, 호출한 쪽에서 돌려받은 목록을 비워 보겠습니다.
import java.util.ArrayList;
import java.util.List;
public final class SharedMutablePipelineResult {
private final List<String> output = new ArrayList<>();
List<String> process(List<Integer> values) {
for (Integer value : values) {
if (value > 0) {
output.add("N" + (value * 2));
}
}
return output;
}
public static void main(String[] args) {
SharedMutablePipelineResult processor = new SharedMutablePipelineResult();
List<String> first = processor.process(List.of(1, -1));
System.out.println("first=" + first);
List<String> second = processor.process(List.of(2));
System.out.println("second=" + second);
System.out.println("first=" + first);
second.clear();
System.out.println("third=" + processor.process(List.of(3)));
}
}first=[N2]
second=[N2, N4]
first=[N2, N4]
third=[N6]첫 줄은 기대한 대로입니다. 그런데 List.of(2)만 넘긴 두 번째 호출의 결과에 첫 요청의 N2가 남아 있습니다. 필드 목록은 호출이 끝나도 비워지지 않기 때문입니다. 셋째 줄은 더 곤란합니다. 이미 받아 둔 first까지 [N2, N4]로 바뀌었는데, process가 매번 같은 목록 객체를 돌려주므로 first와 second가 사실은 한 목록을 가리키고 있었던 것입니다. 마지막 줄에서는 호출한 쪽이 second.clear()로 처리기 내부의 목록을 비워 버렸고, 그래서 세 번째 결과에는 N6만 남았습니다. 처리기의 동작이 이전 호출과 호출한 쪽의 행동에 따라 달라지는 셈입니다.
문제는 반복문이 아니라 결과를 모으는 목록, 즉 누산기(accumulator)를 누가 갖고 있느냐에 있습니다. 누산기를 호출마다 메서드 안에서 새로 만들고, 돌려줄 때는 수정할 수 없는 사본을 주면 두 문제가 함께 사라집니다.
List<String> process(List<Integer> values) {
List<String> output = new ArrayList<>(); // 호출마다 새 누산기
for (Integer value : values) {
if (value > 0) {
output.add("N" + (value * 2));
}
}
return List.copyOf(output); // 수정할 수 없는 사본을 돌려준다
}이렇게 고치면 같은 두 요청의 결과는 [N2]와 [N4]로 서로 섞이지 않습니다. 같은 생각을 파이프라인 객체로 넓히면, 어떤 함수를 어떤 순서로 적용할지 같은 정의는 불변으로 보관하고, 실행할 때 필요한 누산기 같은 상태는 실행할 때마다 새로 만들어야 합니다. 이 구분은 여러 스레드가 같은 파이프라인 정의를 함께 쓸 때도 중요하며, 다음 글에서 만드는 작은 스트림도 이 규칙을 따릅니다. 다만 누산기를 지역 변수로 옮겨도, 조건 함수나 변환 함수가 바깥의 가변 상태를 캡처하고 있다면 호출 사이에 영향이 남습니다. 자바가 함수의 순수성을 강제할 방법은 없으므로 JDK의 Stream 문서도 이런 함수가 원본을 건드리지 않고 상태를 갖지 않기를 요구하는 방식으로 이 문제를 다룹니다. 자세한 내용은 상태를 가진 람다와 재사용 문제에서 봅니다.
단계별로 실행하는 파이프라인
다음 예제는 제네릭 filter와 map을 이어서 실행하고, 두 단계의 결과를 모두 레코드에 담아 돌려줍니다. 어느 원소가 걸러지고 어떤 값으로 바뀌었는지 한 번에 볼 수 있어서 디버깅하거나 처리 과정을 설명할 때 편리합니다. 각 단계는 호출되는 동안 자기 입력을 끝까지 순회합니다. filter는 원본을, map은 filter가 고른 목록을 처리하고, 함수가 예외를 던지면 그 단계에서 멈춥니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class EagerFilterMapPipeline {
record PipelineResult<T, R>(List<T> filtered, List<R> mapped) {
PipelineResult {
filtered = List.copyOf(filtered);
mapped = List.copyOf(mapped);
}
}
static <T> List<T> filter(List<T> source, Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return result;
}
static <T, R> List<R> map(List<T> source, Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>();
for (T value : source) {
result.add(mapper.apply(value));
}
return result;
}
static <T, R> PipelineResult<T, R> run(
List<T> source,
Predicate<? super T> predicate,
Function<? super T, ? extends R> mapper) {
List<T> filtered = filter(source, predicate);
List<R> mapped = map(filtered, mapper);
return new PipelineResult<>(filtered, mapped);
}
record Order(String id, int total) {
}
public static void main(String[] args) {
PipelineResult<Order, String> result = run(
List.of(new Order("A", 40), new Order("B", 150), new Order("C", 90)),
order -> order.total() >= 80,
order -> order.id() + "=" + order.total());
System.out.println(result.filtered());
System.out.println(result.mapped());
}
}[Order[id=B, total=150], Order[id=C, total=90]]
[B=150, C=90]합계가 80 이상인 주문만 남기므로 40인 A가 빠지고 B와 C가 원래 순서대로 남습니다. 둘째 줄은 그 두 주문만 "id=합계" 모양의 문자열로 바꾼 결과입니다. 이 예제의 filter와 map은 앞 글과 달리 ArrayList를 그대로 돌려주는데, 레코드의 컴팩트 생성자(compact constructor)가 두 목록을 List.copyOf로 복사하기 때문에 복사를 맨 끝에서 한 번만 하도록 한 것입니다.
단계별 결과를 레코드로 돌려주는 것은 교육용이나 진단용으로는 좋지만, 실제 서비스의 API에 언제나 필요한 것은 아닙니다. 데이터가 크면 중간 결과를 모두 붙잡아 두는 것만으로도 메모리가 많이 듭니다. 그래서 필요할 때만 중간 결과를 남기도록 추적 기능을 별도의 선택 사항으로 두기도 합니다.
단계의 순서를 바꾸면 결과도 바뀐다
filter 뒤에 map을 하는 것과 map 뒤에 filter를 하는 것은 일반적으로 같지 않습니다. 정수 [1, 2, 3, 4]에 '짝수만 남기기'와 '1 더하기'를 서로 다른 순서로 적용해 보면 바로 드러납니다.
| 순서 | 첫 단계 뒤 | 둘째 단계 뒤 | 변환 함수 호출 수 |
|---|---|---|---|
| 짝수만 남기기 → 1 더하기 | [2, 4] | [3, 5] | 2 |
| 1 더하기 → 짝수만 남기기 | [2, 3, 4, 5] | [2, 4] | 4 |
결과가 다를 뿐 아니라 변환 함수가 불리는 횟수도 다릅니다. 변환 함수가 타입을 바꾸면 차이는 더 커집니다. 위의 주문 예제에서 map을 먼저 하면 조건 함수는 Order가 아니라 "B=150" 같은 문자열을 검사해야 하므로 아예 다른 조건을 새로 써야 합니다. 그러니 함수가 순수하더라도 단계의 순서는 두 순서가 같은 결과를 낸다는 것을 확인한 뒤에만 바꿔야 합니다.
한 반복문으로 합치면 호출 순서가 바뀐다
이제 filter와 map을 한 반복문으로 합쳐 보겠습니다. 아래 프로그램은 같은 처리를 두 가지로 작성합니다. imperative는 반복문 하나에서 원소마다 조건을 검사하고 통과하면 바로 변환하는 명령형 코드이고, declarative는 다음 장에서 배울 Stream API로 같은 일을 적은 선언형 코드입니다. 두 쪽 모두 함수가 불릴 때마다 기록을 남기게 해서 호출 순서를 비교합니다.
import java.util.ArrayList;
import java.util.List;
public final class ImperativeDeclarativeTrace {
static List<String> imperative(List<Integer> source, List<String> trace) {
List<String> result = new ArrayList<>();
for (Integer value : source) {
trace.add("loop-test:" + value);
if (value % 2 == 0) {
trace.add("loop-map:" + value);
result.add("N" + (value * 10));
}
}
return List.copyOf(result);
}
static List<String> declarative(List<Integer> source, List<String> trace) {
return source.stream()
.filter(value -> {
trace.add("stream-test:" + value);
return value % 2 == 0;
})
.map(value -> {
trace.add("stream-map:" + value);
return "N" + (value * 10);
})
.toList();
}
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3, 4);
List<String> imperativeTrace = new ArrayList<>();
List<String> streamTrace = new ArrayList<>();
System.out.println(imperative(source, imperativeTrace));
System.out.println(declarative(source, streamTrace));
System.out.println(imperativeTrace);
System.out.println(streamTrace);
}
}[N20, N40]
[N20, N40]
[loop-test:1, loop-test:2, loop-map:2, loop-test:3, loop-test:4, loop-map:4]
[stream-test:1, stream-test:2, stream-map:2, stream-test:3, stream-test:4, stream-map:4]두 결과는 [N20, N40]으로 같고, 앞 글의 단계별 filter와 map으로 처리해도 같은 결과가 나옵니다. 차이는 기록에 있습니다. 단계별 처리에서는 test가 네 번 모두 끝난 뒤에 map이 두 번 불렸지만, 합친 반복문에서는 2가 조건을 통과하자마자 map:2가 불리고 그다음에야 3을 검사합니다. 넷째 줄을 보면 Stream도 같은 순서로 동작했습니다. 이 파이프라인에서 Stream은 filter를 모든 원소에 먼저 적용한 뒤 map으로 넘어가지 않고, 원소 하나를 파이프라인 끝까지 통과시킨 다음 다음 원소로 넘어갔습니다. 그래서 filter의 결과를 담아 둘 중간 목록이 필요 없습니다.
두 단계 순회와 융합 순회의 호출 순서
즉시 filter/map 두 순회와 융합 순회는 같은 [N20,N40]을 만들지만 test/map 호출이 섞이는 순서가 다릅니다. 원 코드 trace의 누적을 비교하여 평가 시점을 설명할 수 있습니다.
두 단계 순회와 융합 순회의 호출 순서
순회 방식과 호출 순서
두 단계 순회와 융합 순회의 호출 순서
결과는 같아도 test와 map의 순서는 다릅니다. 두 단계 순회는 모든 test가 끝난 뒤 map을 시작합니다.
필터 전체 뒤 매핑
- 호출 순서
- test:1, test:2, test:3, test:4, map:2, map:4
- 결과
- [N20, N40]
한 반복문에서 선택·매핑
- 호출 순서
- test:1, test:2, map:2, test:3, test:4, map:4
- 결과
- [N20, N40]
같은 입력 [1, 2, 3, 4]에 짝수 선택과 N+(값×10) 변환을 적용한 소스 비교입니다. JDK Stream의 부수 효과 순서를 보장하거나 실행 로그를 제시한 표가 아닙니다.
동일 입력 [1, 2, 3, 4]에서 짝수를 선택하고 10을 곱합니다. 서로 다른 콜백 순서로 같은 [N20, N40]을 만듭니다.
이 예의 조건
- 별도 비교 조건 [1,2,3,4]이며 Stream 전체 로그 보장으로 확대하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 짝수 선택과 N+(값×10) 변환을 [1,2,3,4]에 적용하는 별도 비교 조건이다. 왼쪽 즉시 두 단계는 test 네 번 뒤 map 두 번이고 오른쪽 융합은 2와 4가 통과한 즉시 map을 호출한다. 각 영역의 화살표는 그 반복문의 호출·중간값 순서이며 두 영역 사이 호출을 연결하지 않는다. 같은 최종값만으로 예외·부수 효과 순서가 같아지지 않으며 모든 Stream 실행의 로그 보장으로 확대하지 않는다. 모바일도 같은 두 영역과 12개 노드, 10개 관계를 유지한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 짝수 선택과 N+(값×10) 변환을 [1,2,3,4]에 적용하는 별도 비교 조건이다. 왼쪽 즉시 두 단계는 test 네 번 뒤 map 두 번이고 오른쪽 융합은 2와 4가 통과한 즉시 map을 호출한다. 각 영역의 화살표는 그 반복문의 호출·중간값 순서이며 두 영역 사이 호출을 연결하지 않는다. 같은 최종값만으로 예외·부수 효과 순서가 같아지지 않으며 모든 Stream 실행의 로그 보장으로 확대하지 않는다. 모바일도 같은 두 영역과 12개 노드, 10개 관계를 유지한다. |
| 전체 읽기 정보 | 두 단계 순회와 융합 순회의 호출 순서 두 단계 순회와 융합 순회의 호출 순서 두 단계 순회와 융합 순회의 호출 순서 순회 방식과 호출 순서 두 단계 순회와 융합 순회의 호출 순서 결과는 같아도 test와 map의 순서는 다릅니다. 두 단계 순회는 모든 test가 끝난 뒤 map을 시작합니다. 필터 전체 뒤 매핑 호출 순서 test:1, test:2, test:3, test:4, map:2, map:4 결과 [N20, N40] 한 반복문에서 선택·매핑 호출 순서 test:1, test:2, map:2, test:3, test:4, map:4 결과 [N20, N40] 같은 입력 [1, 2, 3, 4]에 짝수 선택과 N+(값×10) 변환을 적용한 소스 비교입니다. JDK Stream의 부수 효과 순서를 보장하거나 실행 로그를 제시한 표가 아닙니다. 동일 입력 [1, 2, 3, 4]에서 짝수를 선택하고 10을 곱합니다. 서로 다른 콜백 순서로 같은 [N20, N40]을 만듭니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 선택 호출 시작 두 반복문은 1을 test하고 2를 test합니다. |
|
|
| 뒤 입력 평가 3은 제외되고 4는 통과합니다. |
|
|
| 최종값 비교 두 단계는 map 2, map 4 뒤 같은 목록을 반환합니다. |
|
|
두 단계 순회와 융합 순회의 호출 순서
즉시 filter/map 두 순회와 융합 순회는 같은 [N20,N40]을 만들지만 test/map 호출이 섞이는 순서가 다릅니다. 원 코드 trace의 누적을 비교하여 평가 시점을 설명할 수 있습니다.
두 단계 순회와 융합 순회의 호출 순서
순회 방식과 호출 순서
두 단계 순회와 융합 순회의 호출 순서
결과는 같아도 test와 map의 순서는 다릅니다. 두 단계 순회는 모든 test가 끝난 뒤 map을 시작합니다.
필터 전체 뒤 매핑
- 호출 순서
- test:1, test:2, test:3, test:4, map:2, map:4
- 결과
- [N20, N40]
한 반복문에서 선택·매핑
- 호출 순서
- test:1, test:2, map:2, test:3, test:4, map:4
- 결과
- [N20, N40]
같은 입력 [1, 2, 3, 4]에 짝수 선택과 N+(값×10) 변환을 적용한 소스 비교입니다. JDK Stream의 부수 효과 순서를 보장하거나 실행 로그를 제시한 표가 아닙니다.
동일 입력 [1, 2, 3, 4]에서 짝수를 선택하고 10을 곱합니다. 서로 다른 콜백 순서로 같은 [N20, N40]을 만듭니다.
이 예의 조건
- 별도 비교 조건 [1,2,3,4]이며 Stream 전체 로그 보장으로 확대하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 짝수 선택과 N+(값×10) 변환을 [1,2,3,4]에 적용하는 별도 비교 조건이다. 왼쪽 즉시 두 단계는 test 네 번 뒤 map 두 번이고 오른쪽 융합은 2와 4가 통과한 즉시 map을 호출한다. 각 영역의 화살표는 그 반복문의 호출·중간값 순서이며 두 영역 사이 호출을 연결하지 않는다. 같은 최종값만으로 예외·부수 효과 순서가 같아지지 않으며 모든 Stream 실행의 로그 보장으로 확대하지 않는다. 모바일도 같은 두 영역과 12개 노드, 10개 관계를 유지한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 짝수 선택과 N+(값×10) 변환을 [1,2,3,4]에 적용하는 별도 비교 조건이다. 왼쪽 즉시 두 단계는 test 네 번 뒤 map 두 번이고 오른쪽 융합은 2와 4가 통과한 즉시 map을 호출한다. 각 영역의 화살표는 그 반복문의 호출·중간값 순서이며 두 영역 사이 호출을 연결하지 않는다. 같은 최종값만으로 예외·부수 효과 순서가 같아지지 않으며 모든 Stream 실행의 로그 보장으로 확대하지 않는다. 모바일도 같은 두 영역과 12개 노드, 10개 관계를 유지한다. |
| 전체 읽기 정보 | 두 단계 순회와 융합 순회의 호출 순서 두 단계 순회와 융합 순회의 호출 순서 두 단계 순회와 융합 순회의 호출 순서 순회 방식과 호출 순서 두 단계 순회와 융합 순회의 호출 순서 결과는 같아도 test와 map의 순서는 다릅니다. 두 단계 순회는 모든 test가 끝난 뒤 map을 시작합니다. 필터 전체 뒤 매핑 호출 순서 test:1, test:2, test:3, test:4, map:2, map:4 결과 [N20, N40] 한 반복문에서 선택·매핑 호출 순서 test:1, test:2, map:2, test:3, test:4, map:4 결과 [N20, N40] 같은 입력 [1, 2, 3, 4]에 짝수 선택과 N+(값×10) 변환을 적용한 소스 비교입니다. JDK Stream의 부수 효과 순서를 보장하거나 실행 로그를 제시한 표가 아닙니다. 동일 입력 [1, 2, 3, 4]에서 짝수를 선택하고 10을 곱합니다. 서로 다른 콜백 순서로 같은 [N20, N40]을 만듭니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 선택 호출 시작 두 반복문은 1을 test하고 2를 test합니다. |
|
|
| 뒤 입력 평가 3은 제외되고 4는 통과합니다. |
|
|
| 최종값 비교 두 단계는 map 2, map 4 뒤 같은 목록을 반환합니다. |
|
|
여기서 얻을 교훈은 최종 결과가 같다고 해서 함수가 평가되는 순서까지 같지는 않다는 것입니다. 함수에 부수 효과가 있거나 예외를 던질 수 있다면 그 순서 차이가 그대로 드러납니다. 다만 이 기록은 이 입력과 이 파이프라인에서 실제로 일어난 일일 뿐입니다. 부수 효과에 업무 로직을 맡기거나, 다른 최종 연산이나 병렬 실행에서도 같은 기록이 나오리라고 기대하면 안 됩니다. 그런 예외적인 경우는 이 글 끝의 '더 깊이'에서 확인합니다.
합친 반복문에서 예외가 나는 지점
filter와 map을 합칠 때 지켜야 할 것은 변환 함수를 조건 함수가 true를 돌려준 원소에만 부르는 것입니다. 원소를 입력 순서대로 처리한다는 점과, 함수가 예외를 던지면 그 자리에서 멈춘다는 점도 그대로 유지할 수 있습니다. 하지만 앞에서 본 것처럼 조건 함수와 변환 함수가 섞여서 불리므로, 두 함수가 모두 예외를 던질 수 있다면 어느 예외가 먼저 나는지가 달라집니다.
다음 프로그램은 조건 함수가 3에서 P3 예외를, 변환 함수가 2에서 M2 예외를 던지게 만든 뒤, 앞 글의 단계별 filter와 map, 그리고 둘을 합친 filterMap에 같은 입력을 넣습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class FusedFailureOrder {
static <T> List<T> filter(List<? extends T> source, Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return List.copyOf(result);
}
static <T, R> List<R> map(
List<? extends T> source,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
for (T value : source) {
result.add(mapper.apply(value));
}
return List.copyOf(result);
}
static <T, R> List<R> filterMap(
List<? extends T> source,
Predicate<? super T> predicate,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(mapper.apply(value));
}
}
return List.copyOf(result);
}
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3, 4);
List<String> trace = new ArrayList<>();
Predicate<Integer> isEven = value -> {
trace.add("test:" + value);
if (value == 3) {
throw new IllegalStateException("P3");
}
return value % 2 == 0;
};
Function<Integer, String> label = value -> {
trace.add("map:" + value);
if (value == 2) {
throw new IllegalStateException("M2");
}
return "N" + (value * 10);
};
try {
map(filter(source, isEven), label);
} catch (IllegalStateException exception) {
System.out.println("two passes: " + exception.getMessage() + " " + trace);
}
trace.clear();
try {
filterMap(source, isEven, label);
} catch (IllegalStateException exception) {
System.out.println("one pass: " + exception.getMessage() + " " + trace);
}
}
}two passes: P3 [test:1, test:2, test:3]
one pass: M2 [test:1, test:2, map:2]단계별 처리는 filter가 모든 원소를 먼저 검사하므로 3을 검사하다가 P3로 멈추고, map은 시작조차 하지 않습니다. 합친 반복문은 2가 조건을 통과하자마자 변환 함수를 부르므로 map:2에서 M2로 멈추고, 3은 검사하지 않습니다. 두 방식 모두 일부만 채운 결과를 돌려주지는 않지만, 호출한 쪽이 받는 예외의 종류가 다릅니다. 두 함수가 순수하고 예외 없이 끝나는 입력에서는 두 방식의 결과가 같았다는 사실만으로, 실패하는 입력에서도 같게 동작하리라고 판단할 수는 없다는 뜻입니다.
filterMap의 결과 목록에 용량을 미리 잡지 않은 데도 이유가 있습니다. new ArrayList<>(source.size())처럼 원본 크기만큼 잡아 두면, 원소가 조금만 통과할 때는 쓰지 않는 공간이 생깁니다. 통과하는 비율을 미리 알 수 있다면 그에 맞게 잡으면 되고, 모른다면 기본 용량으로 시작해도 충분합니다.
병렬 실행까지 생각한다면 이 반복문을 그대로 쓸 수는 없습니다. 여러 스레드가 한 ArrayList에 동시에 add하면 원소가 사라지거나 예외가 나는 등 결과를 믿을 수 없게 됩니다. 병렬 처리는 입력을 나눈 조각마다 따로 누산기를 만들고 마지막에 합치는 방식이어야 하며, 이 방법은 병렬 스트림과 Fork/Join에서 다룹니다. 한 스레드에서 맞게 동작하는 최적화가 병렬에서도 안전하다고 넘겨짚지 않아야 합니다.
더 깊이: Stream이 언제나 원소 단위로 처리하지는 않는다
앞의 기록에서 Stream이 합친 반복문처럼 원소 하나씩 처리하는 것을 보았지만, 이것은 Stream이 지키겠다고 약속한 순서가 아닙니다. 다음 프로그램은 같은 종류의 기록을 두 가지 상황에서 다시 남깁니다. 첫 번째는 filter와 map 사이에 정렬 sorted()를 끼운 경우이고, 두 번째는 map 뒤에 원소 개수를 세는 count()로 끝낸 경우입니다.
import java.util.ArrayList;
import java.util.List;
public final class StreamOrderCaveats {
public static void main(String[] args) {
List<Integer> source = List.of(4, 1, 3, 2);
List<String> trace = new ArrayList<>();
List<String> sorted = source.stream()
.filter(value -> {
trace.add("test:" + value);
return value % 2 == 0;
})
.sorted()
.map(value -> {
trace.add("map:" + value);
return "N" + (value * 10);
})
.toList();
System.out.println(sorted + " " + trace);
trace.clear();
long count = source.stream()
.map(value -> {
trace.add("map:" + value);
return value * 10;
})
.count();
System.out.println("count=" + count + " " + trace);
}
}[N20, N40] [test:4, test:1, test:3, test:2, map:2, map:4]
count=4 []첫 줄에서는 test 네 번이 모두 끝난 뒤에 map이 시작됩니다. 정렬은 마지막 원소까지 받아 봐야 첫 번째로 내보낼 원소를 정할 수 있으므로, sorted() 앞쪽은 단계별 처리처럼 동작할 수밖에 없습니다. 둘째 줄은 더 극단적입니다. 개수는 4로 맞게 나왔지만 변환 함수는 한 번도 불리지 않았습니다. 원본 목록의 크기를 이미 알고 map은 원소 수를 바꾸지 않으므로, 파이프라인을 실행하지 않고도 개수를 알 수 있기 때문입니다.
Stream.count()의 Javadoc은 이렇게 원본에서 바로 개수를 계산할 수 있으면 파이프라인을 실행하지 않을 수 있고, 그 경우 중간 연산이 평가되지 않는다고 밝힙니다. Stream 클래스 설명도 결과에 영향이 없다고 판단되면 연산이나 단계 전체를 생략할 수 있으므로 함수의 부수 효과에 기대지 말라고 적고 있습니다. 위의 결과는 JDK 21에서 실행한 것이며, 이런 최적화는 구현에 따라 달라질 수 있습니다. 함수 호출 기록은 이해를 돕는 도구로만 쓰고, 프로그램의 결과가 그 기록에 기대게 만들지 않아야 합니다.
정리
- 결과를 모으는 누산기는 호출마다 새로 만들고, 돌려줄 때는 수정할 수 없는 사본을 줍니다. 필드에 두면 이전 호출의 결과가 남고, 돌려준 목록을 통해 내부 상태가 바뀝니다.
- 단계별 파이프라인은 단계마다 중간 목록을 만들고, 한 단계가 모든 원소를 처리한 뒤 다음 단계가 시작됩니다. 중간 결과를 보기 쉽지만 메모리를 더 씁니다.
- filter와 map을 한 반복문으로 합치면 중간 목록이 없어지고, 조건 검사와 변환이 원소마다 번갈아 일어납니다. Stream의 filter와 map도 보통 이렇게 원소 단위로 처리합니다.
- 결과가 같아도 호출 순서가 다르면 먼저 나는 예외와 부수 효과가 달라집니다. 단계의 순서를 바꾸면 결과 자체가 달라질 수도 있습니다.
연습 문제
- 원본을 한 번만 순회하면서 검사한 원소 수, 조건을 통과한 원소 수, 변환 함수 호출 수, 결과 목록을 한꺼번에 돌려주는
collect를 작성하세요. 조건 함수가false인 원소에는 변환 함수를 부르지 않아야 하고, 결과와 세 카운터는 불변 레코드로 돌려줍니다. 입력List.of(1, 2, 3, 4, 5), 홀수 조건,"odd:" + value변환으로 실행해 보세요. - 1번의
collect에[0, 1, 2, 3], 조건value >= 2, 변환"v" + value를 넣으면 세 카운터와 결과 목록은 무엇일까요? 변환 함수가 3에서 예외를 던지도록 바꾸면 앞에서 만든v2가 담긴 레코드를 받을 수 있을까요? - 정수
[1, 2, 3, 4]에 '짝수만 남기기 → 10 곱하기'와 '10 곱하기 → 짝수만 남기기'를 각각 적용하면 결과는 무엇이고, 변환 함수는 각각 몇 번 불릴까요?
힌트
- 카운터 세 개를 지역 변수로 두고, 조건을 통과하지 못한 원소는
continue로 건너뛰면 변환 함수 호출이 자연스럽게 빠집니다. 순회만 하면 되므로 매개변수는List보다 넓은Iterable로 받을 수 있습니다. - 레코드는 반복문이 끝난 뒤 마지막 줄에서 만들어집니다.
- 10을 곱한 정수가 홀수일 수 있는지 생각해 보세요.
해설
- 카운터는 메서드 안의 지역 변수이므로 호출마다 0에서 시작합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class SinglePassCollectSolution {
record Collected<R>(int tested, int accepted, int mapped, List<R> values) {
Collected {
values = List.copyOf(values);
}
}
static <T, R> Collected<R> collect(
Iterable<? extends T> source,
Predicate<? super T> predicate,
Function<? super T, ? extends R> mapper) {
List<R> values = new ArrayList<>();
int tested = 0;
int accepted = 0;
int mapped = 0;
for (T value : source) {
tested += 1;
if (!predicate.test(value)) {
continue;
}
accepted += 1;
values.add(mapper.apply(value));
mapped += 1;
}
return new Collected<>(tested, accepted, mapped, values);
}
public static void main(String[] args) {
Collected<String> result = collect(
List.of(1, 2, 3, 4, 5),
value -> value % 2 == 1,
value -> "odd:" + value);
System.out.println(result);
}
}Collected[tested=5, accepted=3, mapped=3, values=[odd:1, odd:3, odd:5]]다섯 원소를 모두 검사했고 홀수 세 개만 통과했으며, 변환 함수도 그 세 개에만 불렸습니다. accepted와 mapped가 같다는 것이 '통과한 원소에만, 한 번씩 변환했다'는 것을 결과로 보여 줍니다. 매개변수를 Iterable<? extends T>로 받았기 때문에 위치로 원소를 찾을 필요가 없고, List가 아닌 Set 같은 컬렉션도 넘길 수 있습니다.
- 예외가 없으면
Collected[tested=4, accepted=2, mapped=2, values=[v2, v3]]입니다. 0과 1은 조건을 통과하지 못하므로 변환 함수가 불리지 않습니다. 변환 함수가 3에서 예외를 던지면 그 예외가 반복문을 빠져나가므로 마지막 줄의new Collected<>(...)까지 가지 못하고, 레코드는 아예 만들어지지 않습니다. 호출 기록을 남겨 보면map:2와map:3이 불렸다는 것은 알 수 있지만,v2만 담긴 레코드를 돌려받는 방법은 없습니다. 실패하면 아무 결과도 주지 않는 이런 방식도collect가 사용자에게 알려야 할 동작의 일부입니다. - '짝수만 남기기 → 10 곱하기'는
[2, 4]를 거쳐[20, 40]이 되고, 변환 함수는 2번 불립니다. '10 곱하기 → 짝수만 남기기'는[10, 20, 30, 40]을 만든 뒤 짝수를 고르는데, 10을 곱한 정수는 모두 짝수이므로 결과가[10, 20, 30, 40]그대로이고 변환 함수는 4번 불립니다. 단계 하나의 순서만 바꿨는데 조건이 사실상 아무것도 거르지 못하게 된 것입니다.
다음 글
TinyStream으로 스트림 직접 만들기에서는 filter와 map을 메서드 체이닝으로 이어 쓸 수 있는 작은 스트림을 직접 만듭니다. 연산마다 새 객체를 돌려주는 불변 설계와, 결과가 필요해질 때까지 계산을 미루는 지연 평가를 차례로 구현합니다.