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

안동민 개발노트

본문 시작
30장 : 람다와 함수형 인터페이스

다인자·기본형 인터페이스

인자 수가 둘 이상인 표준 계약, 사용자 정의 삼인자 함수, 박싱이 발생하는 제네릭 숫자 경로와 기본형 특화 인터페이스를 비교하고 측정 가능한 통계 파이프라인을 만듭니다.

두 입력이 필요한 함수에는 BiFunction, BiConsumer, BiPredicate, BinaryOperator가 있습니다. 세 입력 이상은 JDK가 일반 표준 인터페이스를 제공하지 않으므로 작은 사용자 정의 TriFunction을 정의하거나 여러 값을 record 하나로 묶습니다. 인자 수만 보고 선택하지 말고 세 값이 하나의 명령이나 문맥을 이루는지 먼저 판단합니다.

제네릭 함수에 int를 전달하면 Integer 박싱이 발생합니다. 대부분의 업무 코드에서는 가독성과 객체 비용의 균형이 제네릭 타입 쪽에 있을 수 있습니다. 그러나 대량 숫자 스트림과 성능 핵심 반복문에서는 IntPredicate, IntUnaryOperator, IntBinaryOperator, ToIntFunction 같은 기본형 특화가 할당과 언박싱을 줄입니다. 추측이 아니라 프로파일러와 벤치마크로 병목을 확인합니다.

박싱 비용

다음 코드는 모든 intInteger로 박싱한 목록에 담고 Function 호출마다 언박싱과 박싱이 생길 수 있습니다. JIT가 일부 할당을 제거할 수도 있지만 API 자체는 null 허용 Integer도 허용하고 기본형 의미를 숨깁니다. 데이터 크기와 실행 빈도가 크다면 기본형 전용 경로가 더 적합합니다.

bad/BoxedNumericPipeline.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

public final class BoxedNumericPipeline {
    static List<Integer> apply(List<Integer> values, Function<Integer, Integer> operator) {
        List<Integer> result = new ArrayList<>();
        for (Integer value : values) {
            result.add(operator.apply(value));
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println(apply(List.of(1, 2, 3), value -> value * 2));
    }
}

기본형 특화 이름은 함수 시그니처를 압축합니다. IntFunction<R>int를 받아 R을 반환하고, ToIntFunction<T>T를 받아 int를 반환합니다. IntUnaryOperatorint에서 int, IntBinaryOperator는 두 int에서 int입니다. 이름 앞의 To는 결과 기본형, 이름 앞의 Int는 입력 기본형을 뜻하는 경우가 많습니다.

BiFunction<T, U, R>은 두 입력 타입이 다를 수 있습니다. BinaryOperator<T>는 두 입력과 결과가 모두 T로 같습니다. 축약에서 동일성과 결합 법칙이 필요하면 BinaryOperator가 의미를 잘 드러냅니다. 단순히 매개변수가 둘이라는 이유로 연산자를 선택하지 않습니다.

다인자·기본형 계약

아래 예제는 BiFunction으로 가격과 할인 정책을 결합하고 ToIntFunction으로 record에서 기본형 가격을 추출합니다. IntPredicateIntUnaryOperator는 박싱되지 않은 정수 단계에서 동작합니다. BiConsumer는 결과와 감사 수집 대상이라는 두 값을 받아 부수 효과를 수행합니다.

src/BiAndPrimitiveInterfaces.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.IntPredicate;
import java.util.function.IntUnaryOperator;
import java.util.function.ToIntFunction;

public final class BiAndPrimitiveInterfaces {
    record Item(String name, int price) {
    }

    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> discounted = (price, percent) ->
                price - price * percent / 100;
        ToIntFunction<Item> priceOf = Item::price;
        IntPredicate withinBudget = price -> price <= 100;
        IntUnaryOperator addShipping = price -> price + 5;
        List<String> audit = new ArrayList<>();
        BiConsumer<Item, Integer> record = (item, finalPrice) ->
                audit.add(item.name() + "=" + finalPrice);

        for (Item item : List.of(new Item("Book", 80), new Item("Desk", 220))) {
            int sale = discounted.apply(priceOf.applyAsInt(item), 10);
            int finalPrice = addShipping.applyAsInt(sale);
            if (withinBudget.test(finalPrice)) {
                record.accept(item, finalPrice);
            }
        }
        System.out.println(audit);
    }
}

discountedInteger 제네릭 경계를 사용해 결과에서 언박싱합니다. 금액 도메인은 오버플로와 반올림 규칙이 중요하므로 실제 서비스에서는 long 마이너 버전 단위이나 BigDecimal 값 객체가 필요할 수 있습니다. 기본형 특화는 빠른 계산 도구이지 금액 모델의 정확성을 자동으로 보장하지 않습니다.

BiConsumer의 두 입력이 자주 함께 이동한다면 PricedItem record로 묶어 Consumer<PricedItem>을 쓰는 편이 확장에 유리합니다. 매개변수가 늘어날 때마다 TriConsumer, QuadConsumer를 추가하면 호출 순서 실수가 커집니다.

TriFunction의 적용 범위

JDK에 TriFunction이 없으므로 다음처럼 직접 정의할 수 있습니다. andThen은 세 입력 함수 결과를 다음 Function에 전달합니다. 세 값이 배송 견적의 독립 입력이라는 계약이 분명할 때는 적합하지만, 값이 계속 늘면 요청 record가 더 읽기 좋습니다.

src/TriFunctionComposition.java
import java.util.Objects;
import java.util.function.Function;

public final class TriFunctionComposition {
    @FunctionalInterface
    interface TriFunction<A, B, C, R> {
        R apply(A first, B second, C third);

        default <V> TriFunction<A, B, C, V> andThen(Function<? super R, ? extends V> next) {
            Objects.requireNonNull(next);
            return (first, second, third) -> next.apply(apply(first, second, third));
        }
    }

    record Quote(int distanceKm, int weightKg, boolean express, int fee) {
    }

    public static void main(String[] args) {
        TriFunction<Integer, Integer, Boolean, Integer> fee = (distance, weight, express) -> {
            int base = 3000 + distance * 20 + weight * 500;
            return express ? base * 2 : base;
        };
        TriFunction<Integer, Integer, Boolean, Quote> quote = (distance, weight, express) ->
                new Quote(distance, weight, express, fee.apply(distance, weight, express));
        TriFunction<Integer, Integer, Boolean, String> summary = quote.andThen(value ->
                value.distanceKm() + "km/" + value.weightKg() + "kg=" + value.fee());
        System.out.println(fee.apply(10, 2, false));
        System.out.println(quote.apply(12, 3, true));
        System.out.println(summary.apply(5, 1, false));
    }
}

예제의 quote는 입력 세 개와 계산한 수수료를 한 번에 record로 묶어 관계를 보존합니다. 그다음 andThen은 완성된 Quote만 받아 표시 문자열로 바꿉니다. 그래도 타입이 같거나 비슷한 여러 인자는 위치 교환 실수를 컴파일 단계에서 잡기 어렵습니다. 입력이 여러 함수 사이를 이동한다면 ShippingRequest record 하나를 받는 설계가 더 안전합니다.

기본형 인터페이스 선택

ToIntFunctionnull 결과를 표현하지 못합니다. 값 부재가 정상이라면 OptionalInt나 별도 Result를 사용해야 합니다. Integer를 쓴 뒤 null을 센티널로 활용하는 것은 언박싱 지점에서 NullPointerException을 만들 수 있습니다. 기본형 특화는 데이터가 반드시 존재한다는 계약도 함께 표현합니다.

IntStreammap, 필터, sum, summaryStatistics를 기본형으로 수행합니다. 객체 스트림에서 map(Item::price)를 쓰면 Stream<Integer>가 되지만 mapToInt(Item::price)IntStream입니다. 계산을 마친 뒤 박싱된 컬렉션이 실제로 필요할 때만 boxed()합니다.

연습 문제

원시 센서 int 배열에 보정 함수와 유효 범위 조건식을 적용한 뒤 개수, 합계, 최소, 최대를 계산합니다. 중간 Integer 목록을 만들지 않고 오버플로 가능성을 줄이기 위해 합계는 long으로 누적합니다. 값이 하나도 통과하지 않는 경우도 표현합니다.

해답 보기
src/PrimitiveStatisticsSolution.java
import java.util.Optional;
import java.util.function.IntPredicate;
import java.util.function.IntUnaryOperator;

public final class PrimitiveStatisticsSolution {
    record Statistics(int count, long sum, int minimum, int maximum) {
    }

    static Optional<Statistics> summarize(
            int[] rawValues,
            IntUnaryOperator calibration,
            IntPredicate accepted) {
        int count = 0;
        long sum = 0;
        int minimum = Integer.MAX_VALUE;
        int maximum = Integer.MIN_VALUE;
        for (int raw : rawValues) {
            int value = calibration.applyAsInt(raw);
            if (!accepted.test(value)) {
                continue;
            }
            count += 1;
            sum += value;
            minimum = Math.min(minimum, value);
            maximum = Math.max(maximum, value);
        }
        return count == 0
                ? Optional.empty()
                : Optional.of(new Statistics(count, sum, minimum, maximum));
    }

    public static void main(String[] args) {
        int[] raw = { -5, 10, 20, 150, 30 };
        IntUnaryOperator calibrate = value -> value + 2;
        IntPredicate accepted = value -> value >= 0 && value <= 100;
        System.out.println(summarize(raw, calibrate, accepted));
        System.out.println(summarize(new int[] { -9, 500 }, calibrate, accepted));
    }
}

해답은 count가 0일 때 minmax 센티널을 외부에 노출하지 않고 Optional.empty를 반환합니다. 병렬 집계가 필요하다면 결합 가능한 누산기와 동일성을 설계하거나 IntSummaryStatistics 수집기를 사용합니다.