filter·map 조합
단계별 즉시 컬렉션과 한 번의 융합 반복문을 비교하고, 명령형·선언형 표현에서 실행 순서·중간 값·오류 위치·가변 누산기가 어떻게 달라지는지 분석합니다.
필터 결과를 map에 넘기면 “조건을 통과한 원소를 다른 표현으로 바꾼다”는 파이프라인을 만듭니다.
두 메서드를 이어 쓰는 코드는 단계 이름이 보이고 중간 결과를 조사하기 쉽습니다.
대신 필터 목록과 map 목록을 각각 할당합니다.
한 반복문에서 조건과 변환을 연속 수행하면 할당을 줄이지만 두 단계 의미를 본문 안에 다시 섞을 수 있습니다.
명령형과 선언형은 선악 구도가 아닙니다. 명령형 반복문은 제어 흐름과 변경 위치를 직접 보여 주고, 선언형 파이프라인은 무엇을 선택하고 변환하는지 연산 이름으로 보여 줍니다. 실패 처리, 단락 평가, 성능 요구에 따라 더 설명 가능한 형태를 고릅니다.
공유 가변 결과
다음 처리기는 필드 목록에 결과를 계속 추가합니다. 같은 인스턴스로 두 요청을 처리하면 두 번째 결과에 첫 번째 값이 남습니다. 반환한 가변 목록을 호출자가 수정하면 처리기 내부 상태까지 바뀝니다. 반복문 자체보다 누산기 소유권이 문제입니다.
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();
System.out.println(processor.process(List.of(1, -1)));
System.out.println(processor.process(List.of(2)));
}
}누산기는 한 호출 안에서 만들고 결과를 스냅샷으로 반환합니다. 파이프라인 객체 자체가 재사용 가능하다면 연산 메타데이터는 불변으로 보관하고 실행 상태는 최종 호출마다 새로 만듭니다. 이 구분은 여러 스레드가 같은 파이프라인 정의를 공유할 때도 중요합니다.
필터와 map 함수가 가변 외부 상태를 캡처하면 지역 결과를 사용해도 호출 간 영향이 남습니다.
순수 함수를 강제할 수는 없지만 API 문서에서 비간섭과 무상태 동작을 요구할 수 있습니다.
즉시 파이프라인
다음 예제는 제네릭 필터와 map을 연결하고 각 단계 결과를 record에 담습니다.
디버깅과 교육에서는 어느 원소가 탈락하고 어떤 값으로 변환됐는지 쉽게 봅니다.
각 단계는 호출 즉시 전체 원본을 처리합니다.
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());
}
}단계 결과 record는 운영 API에 항상 필요한 것은 아닙니다.
교육이나 진단 모드에서 유용하며, 큰 데이터에서는 중간 값을 보관하는 비용이 큽니다.
추적 필요와 실행 비용을 별도 옵션으로 두는 방법도 있습니다.
필터 뒤 map 순서는 map 뒤 필터와 일반적으로 같지 않습니다.
매퍼가 타입을 바꾸면 조건식 자체가 다른 도메인을 보게 되고, 예외나 부수 효과 발생 횟수도 달라집니다.
연산 재정렬은 함수가 순수하더라도 의미 동등성을 증명한 뒤 수행합니다.
연산과 호출 추적
아래 프로그램은 같은 결과를 외부 반복문과 Stream 파이프라인으로 만듭니다.
추적을 남겨 두 형태의 호출 순서를 비교합니다.
Stream은 각 원소에 필터와 map을 이어 적용하는 수직 실행을 하므로 첫 원소 필터, 첫 원소 map, 다음 원소 필터 순으로 진행됩니다.
즉시 두-목록 구현은 모든 필터 뒤 모든 map이 실행됩니다.
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);
}
}예제의 두 추적은 연산 접두사만 다르고 원소별 순서가 같습니다.
한 반복문과 Stream 파이프라인 모두 필터를 통과한 원소만 map합니다.
반면 앞선 즉시 filter() 후 map() 두 호출은 전체 필터 단계와 전체 map 단계로 나뉩니다.
결과 동등성만으로 평가 시점 동등성을 가정하지 않습니다.
단일 순회 최적화
필터와 map을 한 반복문에 넣으면 중간 목록 하나를 줄입니다.
매퍼는 조건식이 true인 원소에만 호출되어야 하고, 원본 처리 순서와 빠른 실패 예외 의미를 유지해야 합니다.
결과 용량를 원본 크기로 미리 잡으면 일부만 선택될 때 여유 메모리가 생기므로 데이터 특성에 따라 결정합니다.
병렬 실행까지 염두에 두면 공유 ArrayList에 직접 add하는 구현은 안전하지 않습니다.
각 파티션에 독립 누산기를 만들고 결합하는 축약 계약이 필요합니다.
단일 스레드 최적화를 병렬 안전하다고 확대 해석하지 않습니다.
연습 문제
원본을 한 번만 순회해 조건식 통과 수, 매퍼 호출 수, 결과 목록을 반환합니다.
조건식이 false인 원소에는 매퍼를 호출하지 않아야 합니다.
결과와 카운터는 불변 record로 제공합니다.
해답 보기
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);
}
}해답은 Iterable을 받아 임의 접근 가정을 없애고, accepted와 mapped가 같은지 결과로 확인할 수 있게 합니다.
매퍼가 예외를 던지면 record는 반환되지 않으며 이 빠른 실패 정책도 계약에 포함합니다.