본문 바로가기

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

다인자 함수와 기본형 특화 인터페이스를 비교하고 박싱 비용을 줄인 통계 파이프라인을 만듭니다.

두 입력이 필요한 함수에는 BiFunction, BiConsumer, BiPredicate, BinaryOperator가 있습니다.

세 입력 이상은 JDK가 일반 표준 인터페이스를 제공하지 않으므로 작은 사용자 정의 TriFunction을 정의하거나 여러 값을 record 하나로 묶습니다.

인자 수만 보고 선택하지 말고 세 값이 하나의 명령이나 문맥을 이루는지 먼저 판단합니다.

제네릭 함수에 int를 전달하면 Integer 박싱이 발생합니다.

대부분의 업무 코드에서는 가독성과 객체 비용의 균형이 제네릭 타입 쪽에 있을 수 있습니다.

그러나 대량 숫자 스트림과 성능 핵심 반복문에서는 IntPredicate, IntUnaryOperator, IntBinaryOperator, ToIntFunction 같은 기본형 특화가 할당과 언박싱을 줄입니다.

추측이 아니라 프로파일러와 벤치마크로 병목을 확인합니다.


박싱 비용

다음 코드는 모든 int를 Integer로 박싱한 목록에 담고 Function 호출마다 언박싱과 박싱이 생길 수 있습니다.

JIT가 일부 할당을 제거할 수도 있지만 제네릭 인수는 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를 반환합니다.

IntUnaryOperator는 int에서 int, IntBinaryOperator는 두 int에서 int입니다.

이름 앞의 To는 결과 기본형, 이름 앞의 Int는 입력 기본형을 뜻하는 경우가 많습니다.

BiFunction<T, U, R>은 두 입력 타입이 다를 수 있습니다.

BinaryOperator<T>는 두 입력과 결과가 모두 T로 같습니다.

축약에서 두 입력과 결과가 같은 타입이면 BinaryOperator가 그 형태를 드러냅니다. 결합 법칙과 필요한 항등원은 사용하는 축약 연산의 계약이며 이 인터페이스 타입 자체가 강제하지는 않습니다.

단순히 매개변수가 둘이라는 이유로 연산자를 선택하지 않습니다.


다인자·기본형 계약

아래 예제는 BiFunction으로 가격과 할인 정책을 결합하고 ToIntFunction으로 record에서 기본형 가격을 추출합니다.

IntPredicate와 IntUnaryOperator는 박싱되지 않은 정수 단계에서 동작합니다.

BiConsumer는 상품과 최종 가격을 받고, 바깥의 audit 목록에 문자열을 추가합니다.

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

discounted는 Integer 제네릭 경계를 사용해 결과에서 언박싱합니다.

금액 도메인은 오버플로와 반올림 규칙이 중요하므로 실제 서비스에서는 최소 화폐 단위로 나타낸 long이나 BigDecimal 값 객체를 검토합니다.

기본형 특화는 빠른 계산 도구이지 금액 모델의 정확성을 자동으로 보장하지 않습니다.

BiConsumer의 두 입력이 자주 함께 이동한다면 PricedItem record로 묶어 Consumer<PricedItem>을 쓰는 편이 확장에 유리합니다.

매개변수가 늘어날 때마다 TriConsumer, QuadConsumer를 추가하면 호출 순서 실수가 커집니다.

예시 Book의 80은 10% 할인 계산에서 72가 되고 배송비 5를 더해 77이므로 예산 100 안입니다. Desk의 220은 198·203으로 바뀌어 기록하지 않습니다. 예제의 할인 계산은 int 나눗셈이므로 가격 99라면 99×10/100은 9, 할인 뒤 90, 배송 뒤 95입니다. 금액의 반올림 계약을 별도로 정해야 하는 이유를 이 중간 계산에서 볼 수 있습니다.


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 하나를 받는 설계가 더 안전합니다.

예제의 fee(10, 2, false)는 3000+10×20+2×500=4200입니다. quote(12, 3, true)는 기본값 4740을 두 배로 만든 9480을 포함하고, summary(5, 1, false)는 수수료 3600을 담은 문자열 5km/1kg=3600을 반환합니다. andThen은 새 배송 입력 세 개를 다시 받는 함수가 아니라 이미 계산한 Quote를 다음 Function에 넘깁니다.


기본형 인터페이스 선택

ToIntFunction은 null 결과를 표현하지 못합니다.

값 부재가 정상이라면 OptionalInt나 별도 Result를 사용해야 합니다.

Integer를 쓴 뒤 null을 센티널로 활용하는 것은 언박싱 지점에서 NullPointerException을 만들 수 있습니다.

기본형 반환은 정상 반환 시 값 하나를 준다는 형태를 표현하지만, 센티널 사용이나 예외까지 막지는 않습니다. 부재를 결과로 표현하려면 Function<T, OptionalInt>처럼 다른 반환 계약이 필요합니다.

IntStream은 map, 필터, 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));
    }
}

보정 후 통과한 값으로 만드는 통계

다섯 원시 값에 +2를 적용하고 0..100 통과한 값 12·22·32를 누산해 count=3/sum=66을 만듭니다. 변환·선택·통계 상태를 실제 값으로 추적할 수 있습니다.

보정 후 통과한 값으로 만드는 통계

보정 후 통과한 값으로 만드는 통계

보정 · 선택 · 통계

보정 후 통과한 값으로 만드는 통계

각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다.

보정 다음에 선택하고 통과한 값으로만 통계를 만든다원 다섯 값에 각각2를 더한 뒤 0..100 범위의 12·22·32를 모아 count3·sum66·min12·max32를 얻는다. 통과값이 없으면 count0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6노드5관계와 경계조건을 유지한다.원시 int 값-5·10·20·150·30각 값에 +2-3·12·22·152·320..100 선택12·22·32통과값이 있는가?Statisticscount=3·sum=66min=12·max=32count=0이면Optional.empty예아니요
보정 다음에 선택하고 통과한 값으로만 통계를 만든다원 다섯 값에 각각2를 더한 뒤 0..100 범위의 12·22·32를 모아 count3·sum66·min12·max32를 얻는다. 통과값이 없으면 count0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6노드5관계와 경계조건을 유지한다.원시 int 값-5·10·20150·30각 값에 +2-3·12·22152·320..100 선택12·22·32통과값이있는가?Statisticscount=3·sum=66min=12·max=32count=0이면Optional.empty예아니요

[-5, 10, 20, 150, 30]

보정 후 선택값
[12, 22, 32]
반환
count=3, sum=66, minimum=12, maximum=32

[-9, 500]

보정 후 선택값
없음
반환
Optional.empty

통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다.

12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.

이 예의 조건

  • for의 교육용 중간값이며 새 배치 API 실행이 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다.
9통과한 값이 있는가?
원 도표의 설명과 주석
구분내용
설명원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다.
전체 읽기 정보보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 · 선택 · 통계 보정 후 통과한 값으로 만드는 통계 각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다. [-5, 10, 20, 150, 30] 보정 후 선택값 [12, 22, 32] 반환 count=3, sum=66, minimum=12, maximum=32 [-9, 500] 보정 후 선택값 없음 반환 Optional.empty 통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다. 12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
보정

-5,10,20,150,30 각각에 2를 더합니다.

  • 보정값: -3,12,22,152,32
  • 계산 타입: int
  • long 합계가 이전 int 보정 overflow를 막지 않습니다.
범위 선택

0..100에 해당하는 값만 통과합니다.

  • 통과: 12,22,32
  • 제외: -3,152
  • 보정 뒤에 선택하므로 통계 입력이 바뀝니다.
통계 반환

통과 값으로 Statistics를 만듭니다.

  • count,sum: 3,66
  • min,max: 12,32 / 값이 없는 경로 Optional.empty
  • count 0인 별도 입력에서는 정상 통계를 만들지 않습니다.

보정 후 통과한 값으로 만드는 통계

다섯 원시 값에 +2를 적용하고 0..100 통과한 값 12·22·32를 누산해 count=3/sum=66을 만듭니다. 변환·선택·통계 상태를 실제 값으로 추적할 수 있습니다.

보정 후 통과한 값으로 만드는 통계

보정 후 통과한 값으로 만드는 통계

보정 · 선택 · 통계

보정 후 통과한 값으로 만드는 통계

각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다.

보정 다음에 선택하고 통과한 값으로만 통계를 만든다원 다섯 값에 각각2를 더한 뒤 0..100 범위의 12·22·32를 모아 count3·sum66·min12·max32를 얻는다. 통과값이 없으면 count0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6노드5관계와 경계조건을 유지한다.원시 int 값-5·10·20·150·30각 값에 +2-3·12·22·152·320..100 선택12·22·32통과값이 있는가?Statisticscount=3·sum=66min=12·max=32count=0이면Optional.empty예아니요
보정 다음에 선택하고 통과한 값으로만 통계를 만든다원 다섯 값에 각각2를 더한 뒤 0..100 범위의 12·22·32를 모아 count3·sum66·min12·max32를 얻는다. 통과값이 없으면 count0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6노드5관계와 경계조건을 유지한다.원시 int 값-5·10·20150·30각 값에 +2-3·12·22152·320..100 선택12·22·32통과값이있는가?Statisticscount=3·sum=66min=12·max=32count=0이면Optional.empty예아니요

[-5, 10, 20, 150, 30]

보정 후 선택값
[12, 22, 32]
반환
count=3, sum=66, minimum=12, maximum=32

[-9, 500]

보정 후 선택값
없음
반환
Optional.empty

통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다.

12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.

이 예의 조건

  • for의 교육용 중간값이며 새 배치 API 실행이 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다.
9통과한 값이 있는가?
원 도표의 설명과 주석
구분내용
설명원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다.
전체 읽기 정보보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 · 선택 · 통계 보정 후 통과한 값으로 만드는 통계 각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다. [-5, 10, 20, 150, 30] 보정 후 선택값 [12, 22, 32] 반환 count=3, sum=66, minimum=12, maximum=32 [-9, 500] 보정 후 선택값 없음 반환 Optional.empty 통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다. 12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
보정

-5,10,20,150,30 각각에 2를 더합니다.

  • 보정값: -3,12,22,152,32
  • 계산 타입: int
  • long 합계가 이전 int 보정 overflow를 막지 않습니다.
범위 선택

0..100에 해당하는 값만 통과합니다.

  • 통과: 12,22,32
  • 제외: -3,152
  • 보정 뒤에 선택하므로 통계 입력이 바뀝니다.
통계 반환

통과 값으로 Statistics를 만듭니다.

  • count,sum: 3,66
  • min,max: 12,32 / 값이 없는 경로 Optional.empty
  • count 0인 별도 입력에서는 정상 통계를 만들지 않습니다.

병렬 집계가 필요하다면 결합 가능한 누산기와 항등원을 설계하거나 IntSummaryStatistics 수집기를 사용합니다.

독립 확인: 예제의 보정 함수와 범위 검사에 {-2, 98, 99}를 넣습니다. 보정값과 선택값, count·sum·minimum·maximum을 구하세요. {-9, 500}처럼 통과값이 없는 경우 왜 초기 최소·최대를 반환하지 않나요? 합계 long이 보정 value+2의 int 오버플로까지 막나요?

풀이 확인

보정값은 0·100·101이고 통과값은 0·100입니다. count=2, sum=100, minimum=0, maximum=100입니다. 통과값이 없으면 실제 관측 최소·최대가 없어 Optional.empty를 반환하며 초기 센티널은 통계값이 아닙니다. long은 선택값을 더하는 합계의 타입입니다. 앞의 calibration은 여전히 int 연산이므로 임의의 큰 입력의 보정 오버플로까지 막지는 않습니다.