안동민 개발노트

본문 시작

filter·map 조합

단계별 즉시 컬렉션과 한 번의 융합 반복문을 비교하고, 명령형·선언형 표현에서 실행 순서·중간 값·오류 위치·가변 누산기가 어떻게 달라지는지 분석합니다.

필터 결과를 map에 넘기면 “조건을 통과한 원소를 다른 표현으로 바꾼다”는 파이프라인을 만듭니다.

두 메서드를 이어 쓰는 코드는 단계 이름이 보이고 중간 결과를 조사하기 쉽습니다.

대신 필터 목록과 map 목록을 각각 할당합니다.

한 반복문에서 조건과 변환을 연속 수행하면 할당을 줄이지만 두 단계 의미를 본문 안에 다시 섞을 수 있습니다.

명령형과 선언형은 선악 구도가 아닙니다.

명령형 반복문은 제어 흐름과 변경 위치를 직접 보여 주고, 선언형 파이프라인은 무엇을 선택하고 변환하는지 연산 이름으로 보여 줍니다.

실패 처리, 단락 평가, 성능 요구에 따라 더 설명 가능한 형태를 고릅니다.


공유 가변 결과

다음 처리기는 필드 목록에 결과를 계속 추가합니다.

같은 인스턴스로 두 요청을 처리하면 두 번째 결과에 첫 번째 값이 남습니다.

반환한 가변 목록을 호출자가 수정하면 처리기 내부 상태까지 바뀝니다.

반복문 자체보다 누산기 소유권이 문제입니다.

bad/SharedMutablePipelineResult.java
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에 담습니다.

디버깅과 교육에서는 어느 원소가 탈락하고 어떤 값으로 변환됐는지 쉽게 봅니다.

각 단계는 호출 중 자신의 입력을 순회합니다. filter는 원본을, map은 선택된 목록을 처리하며 콜백 예외가 발생하면 해당 단계에서 중단합니다.

src/EagerFilterMapPipeline.java
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 파이프라인으로 만듭니다.

추적을 남겨 두 형태의 호출 순서를 비교합니다.

선택과 변환을 원소별로 이어 수행하면 중간 필터 목록을 피할 수 있습니다.

src/ImperativeDeclarativeTrace.java
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);
    }
}
두 단계 순회와 융합 순회의 호출 순서

두 단계 순회와 융합 순회의 호출 순서

두 단계 순회와 융합 순회의 호출 순서
반복문 구현호출 순서결과
필터 전체 뒤 매핑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]
필터 전체 뒤 매핑
호출 순서: 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의 부수 효과 순서를 보장하거나 실행 로그를 제시한 표가 아닙니다.

부수 효과를 업무 로직으로 삼거나 다른 최종 연산·병렬 실행에서도 같은 추적을 기대하지 않습니다.


단일 순회 최적화

필터와 map을 한 반복문에 넣으면 중간 목록 하나를 줄입니다.

매퍼는 조건식이 true인 원소에만 호출되어야 합니다. 원소 순서와 콜백 예외 시 중단 정책은 유지할 수 있지만, 필터 전체 뒤 매핑하던 순서를 융합하면 어느 콜백과 예외가 먼저 실행되는지는 달라질 수 있습니다.

결과 용량을 원본 크기로 미리 잡으면 일부만 선택될 때 여유 메모리가 생기므로 데이터 특성에 따라 결정합니다.

병렬 실행까지 염두에 두면 공유 ArrayList에 직접 add하는 구현은 안전하지 않습니다.

각 파티션에 독립 누산기를 만들고 결합하는 축약 계약이 필요합니다.

단일 스레드 최적화를 병렬 안전하다고 확대 해석하지 않습니다.


연습 문제

원본을 한 번만 순회해 조건식 통과 수, 매퍼 호출 수, 결과 목록을 반환합니다.

조건식이 false인 원소에는 매퍼를 호출하지 않아야 합니다.

결과와 카운터는 불변 record로 제공합니다.

해답 보기
src/SinglePassCollectSolution.java
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는 반환되지 않으며 이 빠른 실패 정책도 계약에 포함합니다.