본문으로 건너뛰기

안동민 개발노트

본문 시작

filter·map 조합

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

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

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

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

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

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

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

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

명령형과 선언형 표현은 제어 위치가 다르다

둘 다 같은 결과를 만들 수 있지만 누가 순회·분기·축적을 지시하는지가 다릅니다.

  1. 명령형 loop

    호출자가 iteration·if·add 제어

  2. 선언형 pipeline

    무엇을 선택·변환할지 전달

  3. 숨은 계약

    순서·null·exception 정책


공유 가변 결과

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

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

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

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

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 문서에서 비간섭과 무상태 동작을 요구할 수 있습니다.

Accumulator는 한 실행에만 소유되고 완료 뒤 결과로 닫힌다

공유 field에 결과를 쌓으면 호출 간 값이 섞이고 병렬 실행에서 race가 생깁니다.

  1. reusable operation

    predicate·mapper

  2. execution

    호출 한 번

  3. local accumulator

    새 ArrayList<R>

  4. iteration

    선택 값만 add

  5. finished snapshot

    List.copyOf


즉시 파이프라인

다음 예제는 제네릭 필터와 map을 연결하고 각 단계 결과를 record에 담습니다.

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

각 단계는 호출 즉시 전체 원본을 처리합니다.

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 뒤 필터와 일반적으로 같지 않습니다.

매퍼가 타입을 바꾸면 조건식 자체가 다른 도메인을 보게 되고, 예외나 부수 효과 발생 횟수도 달라집니다.

연산 재정렬은 함수가 순수하더라도 의미 동등성을 증명한 뒤 수행합니다.

원소별 vertical execution은 한 값을 판단·변환한 뒤 다음으로 간다

전체 filter List를 먼저 만든 뒤 map하면 중간 저장소가 필요하지만 융합 loop는 현재 원소만 유지합니다.

  1. next T

    source 순서

  2. predicate

    유지 여부

  3. mapper

    true일 때만

  4. accumulate R

    결과 추가


연산과 호출 추적

아래 프로그램은 같은 결과를 외부 반복문과 Stream 파이프라인으로 만듭니다.

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

Stream은 각 원소에 필터와 map을 이어 적용하는 수직 실행을 하므로 첫 원소 필터, 첫 원소 map, 다음 원소 필터 순으로 진행됩니다.

즉시 두-목록 구현은 모든 필터 뒤 모든 map이 실행됩니다.

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);
    }
}

예제의 두 추적은 연산 접두사만 다르고 원소별 순서가 같습니다.

한 반복문과 Stream 파이프라인 모두 필터를 통과한 원소만 map합니다.

반면 앞선 즉시 filter()map() 두 호출은 전체 필터 단계와 전체 map 단계로 나뉩니다.

결과 동등성만으로 평가 시점 동등성을 가정하지 않습니다.


단일 순회 최적화

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

매퍼는 조건식이 true인 원소에만 호출되어야 하고, 원본 처리 순서와 빠른 실패 예외 의미를 유지해야 합니다.

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

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

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

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

Single-pass fuse는 관찰 가능한 계약을 보존할 때만 안전하다

중간 List를 없애면서 함수 순서나 호출 횟수, 예외 시점을 바꾸면 같은 결과 값이어도 같은 프로그램이 아닙니다.

보존 조건fuse 전fuse 후
원소 순서source 순서동일
predicate 횟수원소당 1회동일
mapper 조건선택 원소만동일
예외 시점단계별원소 처리 중
중간 결과 노출외부 비공개없음

연습 문제

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

조건식이 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을 받아 임의 접근 가정을 없애고, acceptedmapped가 같은지 결과로 확인할 수 있게 합니다.

매퍼가 예외를 던지면 record는 반환되지 않으며 이 빠른 실패 정책도 계약에 포함합니다.