본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
31장 : 함수형 파이프라인

제네릭 filter·map

문자열 전용 반복문에서 선택과 변환의 공통 구조를 찾고 조건·변환 함수와 와일드카드를 적용해 입력 불변, 처리 순서, 즉시 결과 계약이 분명한 유틸리티를 만듭니다.

컬렉션 처리에서 필터는 원소를 유지할지 판단하고, map은 각 원소를 다른 값으로 바꿉니다. String 목록용 메서드를 먼저 만들 수 있지만 반복문 구조는 원소 타입과 무관합니다. 구체 타입을 제네릭 타입 매개변수로 바꾸고 변화하는 판단과 변환을 함수 매개변수로 받으면 하나의 알고리즘으로 여러 도메인을 처리할 수 있습니다.

일반화할 때는 문법만 치환하지 않습니다. 원본 목록을 수정하는지, 입력 처리 순서를 보존하는지, null을 허용하는지, 결과를 즉시하게 만드는지 계약을 정합니다. 예제의 유틸리티는 원본을 읽기만 하고 새 불변 스냅샷을 반환하며, 조건식과 매퍼는 원본 순서대로 정확히 한 번 호출합니다.

타입별 알고리즘 중복

다음 코드는 String 길이 필터와 Integer 제곱 map을 별도 구현합니다. 둘 다 순회와 결과 추가 구조를 갖지만 타입과 조건이 메서드 본문에 박혀 있습니다. 새로운 상품 필터나 순서 매핑을 추가할 때 반복문까지 다시 작성합니다.

bad/ConcreteCollectionUtilities.java
import java.util.ArrayList;
import java.util.List;

public final class ConcreteCollectionUtilities {
    static List<String> longStrings(List<String> source) {
        List<String> result = new ArrayList<>();
        for (String value : source) {
            if (value.length() >= 5) {
                result.add(value);
            }
        }
        return result;
    }

    static List<Integer> squares(List<Integer> source) {
        List<Integer> result = new ArrayList<>();
        for (Integer value : source) {
            result.add(value * value);
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println("compile-only concrete collection counterexample");
    }
}

필터의 타입은 T -> boolean, map의 타입은 T -> R입니다. JDK 표준 인터페이스로 각각 Predicate<? super T>Function<? super T, ? extends R>를 받을 수 있습니다. 입력 쪽 superT의 상위 타입을 처리하는 기존 함수를 재사용하게 하고 산출물 쪽 extendsR의 하위 타입을 생산하는 매퍼를 허용합니다.

알고리즘의 제네릭 매개변수는 원본과 함수 사이 타입 관계를 컴파일러에 제공합니다. 원시 PredicateFunction을 쓰면 구현은 짧아도 형변환 실패가 실행 시점으로 밀립니다. 호출 예에서 타입 추론이 충분하면 명시적 타입 인수를 쓰지 않아도 됩니다.

Predicate·Function 분리

아래 filter()map()은 원본 순서를 보존하고 결과를 복사해 반환합니다. filter()는 원소 참조를 선택할 뿐 깊은 copy하지 않습니다. 원소 자체가 가변이면 결과 목록을 불변으로 만들어도 원소 상태 변화는 보입니다.

src/GenericFilterMap.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public final class GenericFilterMap {
    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);
    }

    record Product(String name, int price) {
    }

    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("Keyboard", 120),
                new Product("Cable", 15),
                new Product("Book", 35));
        List<Product> affordable = filter(products, product -> product.price() <= 50);
        List<String> names = map(affordable, Product::name);
        List<Integer> lengths = map(names, String::length);
        System.out.println(affordable);
        System.out.println(names);
        System.out.println(lengths);
    }
}

매퍼가 null을 반환하면 List.copyOf()가 결과 확정 시 NullPointerException을 던집니다. 이 동작을 우연에 맡기지 않고 “null 원소와 null 결과를 허용하지 않는다”는 계약으로 명시하거나, null 허용 컬렉션이 필요하면 다른 반환 방식을 선택합니다. null을 제외하는 작업과 실제 변환 작업을 한 매퍼에 숨기지 않습니다.

함수에서 예외가 발생하면 현재 구현은 즉시 중단하고 부분 결과를 외부에 반환하지 않습니다. 이것은 빠른 실패 즉시 알고리즘입니다. 모든 원소 오류를 모아야 한다면 매퍼 결과를 Result로 바꾸거나 별도 수집 API를 만듭니다.

호출 계약 검증

함수형 유틸리티의 결과 값만 맞아도 내부 의미가 모두 맞는 것은 아닙니다. 조건식을 두 번 호출하면 부수 효과가 없는 경우 결과는 같지만 비용이 두 배이고 비결정 함수에서는 결과가 달라질 수 있습니다. 처리 순서를 보존해야 UI 목록이나 감사 결과가 예측 가능합니다.

다음 검증 프로그램은 호출 추적과 원본 목록을 함께 출력합니다. 필터와 map이 원본 순서대로 각 원소를 한 번씩 처리하고 원본을 변경하지 않는지 확인합니다.

src/FilterMapContractProbe.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public final class FilterMapContractProbe {
    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 List.copyOf(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 List.copyOf(result);
    }

    public static void main(String[] args) {
        List<Integer> source = new ArrayList<>(List.of(3, 1, 4, 2));
        List<String> trace = new ArrayList<>();
        List<Integer> selected = filter(source, value -> {
            trace.add("test:" + value);
            return value % 2 == 0;
        });
        List<String> mapped = map(selected, value -> {
            trace.add("map:" + value);
            return "N" + value;
        });
        System.out.println("source=" + source);
        System.out.println("selected=" + selected);
        System.out.println("mapped=" + mapped);
        System.out.println("trace=" + trace);
    }
}

추적을 이용한 예제는 함수를 상태 유지하게 만들었지만 관찰 목적입니다. 운영 필터가 외부 목록을 변경한다면 병렬화, 재실행, 순서 변경에 취약합니다. 판단과 변환은 가능하면 같은 입력에 같은 결과를 내고 외부 상태를 바꾸지 않게 합니다.

즉시 결과와 원본 스냅샷

이 유틸리티는 호출 즉시 모든 원소를 처리합니다. 결과 목록은 그 시점의 선택과 변환 스냅샷입니다. 원본이 이후 변경돼도 결과 구성원은 바뀌지 않습니다. 지연 파이프라인은 최종 연산 전까지 함수를 실행하지 않으며 원본 변경을 언제 관찰할지 다른 규칙이 필요합니다.

큰 목록에서 필터 결과와 map 결과를 각각 만들면 중간 컬렉션 할당이 생깁니다. 먼저 명료한 두 알고리즘의 의미를 익힌 뒤 한 번의 반복문으로 융합하는 방법을 선택할 수 있습니다. 최적화 때문에 필터와 map의 역할 이름까지 지우면 오류 정책과 호출 순서를 설명하기 어려워집니다.

연습 문제

원소와 0부터 시작하는 인덱스를 받는 IndexedMapper<T, R>를 정의하고 mapIndexed()를 작성합니다. 원본 순서를 보존하고 매퍼 결과가 null이면 인덱스가 포함된 오류를 던집니다. 문자열 목록을 "index:value" 형태로 변환해 실행합니다.

해답 보기
src/IndexedMapSolution.java
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class IndexedMapSolution {
    @FunctionalInterface
    interface IndexedMapper<T, R> {
        R map(int index, T value);
    }

    static <T, R> List<R> mapIndexed(
            List<? extends T> source,
            IndexedMapper<? super T, ? extends R> mapper) {
        List<R> result = new ArrayList<>(source.size());
        for (int index = 0; index < source.size(); index += 1) {
            R mapped = mapper.map(index, source.get(index));
            result.add(Objects.requireNonNull(mapped, "null result at index " + index));
        }
        return List.copyOf(result);
    }

    public static void main(String[] args) {
        List<String> result = mapIndexed(
                List.of("java", "lambda", "stream"),
                (index, value) -> index + ":" + value);
        System.out.println(result);
        try {
            mapIndexed(List.of("ok", "bad"), (index, value) -> index == 1 ? null : value);
        } catch (NullPointerException exception) {
            System.out.println(exception.getMessage());
        }
    }
}

해답은 인덱스 생성 책임을 알고리즘에 두고 매퍼가 원본 접근 방식을 알지 않게 합니다. 연결 목록처럼 인덱스 접근이 비싼 원본까지 지원하려면 Iterable 기반 반복문과 별도 카운터가 더 일반적입니다.