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

안동민 개발노트

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

임의 객체 메서드 참조

임의 객체의 인스턴스 메서드 참조에서 첫 매개변수가 수신 객체가 되는 규칙을 단항·이항 메서드로 확장하고 비교·변환·축약·빈 값 경계에 안전하게 적용합니다.

String::length는 특정 String을 캡처하지 않습니다. ToIntFunction<String>으로 볼 때 applyAsIntString 매개변수가 수신 객체가 되고 receiver.length()를 호출합니다. 이를 특정 타입의 임의 객체에 대한 인스턴스 메서드 참조라고 합니다. 여기서 임의란 실행 시점에 어떤 수신 객체가 들어올지 함수 호출자가 정한다는 뜻입니다.

인스턴스 메서드가 명시 매개변수를 가지면 수신 객체가 함수의 첫 매개변수가 되고 원래 메서드 매개변수가 뒤에 이어집니다. String::substringBiFunction<String, Integer, String>으로 받으면 apply의 첫 String은 수신 객체, 두 번째 IntegerbeginIndex입니다. String::compareToIgnoreCase는 두 String을 받아 int를 돌려주는 ToIntBiFunction<String, String>에 맞습니다.

문자열 기반 동적 호출

다음 반례는 메서드 이름을 String으로 받아 리플렉션 호출합니다. 이름 변경이 컴파일 시간 리팩터링에 연결되지 않고 시그니처와 반환 형변환이 실행 시점까지 미뤄집니다. 알려진 동작을 전달하려면 타입 지정 메서드 참조가 더 안전합니다.

bad/StringNamedInstanceOperation.java
import java.lang.reflect.Method;

public final class StringNamedInstanceOperation {
    static Object invoke(Object receiver, String methodName, Class<?>[] types, Object[] arguments)
            throws Exception {
        Method method = receiver.getClass().getMethod(methodName, types);
        return method.invoke(receiver, arguments);
    }

    public static void main(String[] args) throws Exception {
        Object result = invoke("lambda", "substring", new Class<?>[] { int.class }, new Object[] { 2 });
        System.out.println((String) result);
    }
}

리플렉션은 플러그인이나 직렬화처럼 실행 시점 메타데이터가 실제 요구일 때 사용합니다. 컴파일 시 알려진 String 메서드를 이름 문자열로 바꾸면 유연성이 아니라 타입 정보 손실입니다. 메서드 참조는 오버로드와 접근 수준을 컴파일러가 대상 함수 시그니처에 맞춰 검증합니다.

비결합 참조는 수신 객체를 캡처하지 않으므로 하나의 함수 값을 여러 인스턴스에 적용할 수 있습니다. 결합된 참조는 특정 정책 객체나 로케일 포매터 상태를 고정해야 할 때 적합합니다.

수신자와 인수 순서

아래 예제는 매개변수가 0개, 1개, 2개인 인스턴스 메서드 참조를 대응 함수형 타입에 대입합니다. 사용자 정의 TriFunction은 수신 객체 뒤에 두 메서드 매개변수가 이어지는 substring(begin, end)을 표현합니다.

src/UnboundInstanceReferences.java
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.ToIntBiFunction;
import java.util.function.ToIntFunction;

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

    public static void main(String[] args) {
        ToIntFunction<String> length = String::length;
        Function<String, String> trim = String::trim;
        BiFunction<String, Integer, String> suffix = String::substring;
        TriFunction<String, Integer, Integer, String> slice = String::substring;
        ToIntBiFunction<String, String> compareIgnoringCase = String::compareToIgnoreCase;

        System.out.println(length.applyAsInt("lambda"));
        System.out.println(trim.apply(" java "));
        System.out.println(suffix.apply("stream", 3));
        System.out.println(slice.apply("functional", 1, 5));
        System.out.println(compareIgnoringCase.applyAsInt("JAVA", "java"));
    }
}

String::substring에는 오버로드가 둘 있지만 BiFunction 함수 시그니처는 인수 하나짜리, TriFunction 함수 시그니처는 인수 둘짜리 인스턴스 메서드를 각각 선택합니다. 대상이 모호하면 타입 지정 변수로 분리합니다. 강제 형변환이 반복된다면 API 오버로드 구조를 다시 봅니다.

수신 객체 타입은 첫 매개변수와 호환돼야 합니다. CharSequence::length는 다양한 구현을 받을 수 있어 ToIntFunction<CharSequence>에 적합합니다. 더 구체적인 String 전용 메서드가 필요하면 String 대상을 사용합니다.

비교·컬렉션 참조

메서드 참조는 Comparator 팩터리와 map, reduce의 기존 메서드를 연결하는 데 유용합니다. Comparator.comparing(User::name)에서 User::nameFunction<User, String>입니다. thenComparingInt(User::score)ToIntFunction<User>입니다. String::concatBinaryOperator<String>처럼 사용할 수 있지만 결합 과정의 할당과 구분자 의미를 확인해야 합니다.

src/MethodReferenceApplications.java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.BinaryOperator;
import java.util.function.Function;

public final class MethodReferenceApplications {
    record User(String name, int score) {
    }

    public static void main(String[] args) {
        List<User> users = List.of(
                new User("Mina", 80),
                new User("Jin", 95),
                new User("Mina", 90));
        Comparator<User> order = Comparator
                .comparing(User::name)
                .thenComparingInt(User::score)
                .reversed();
        Function<User, String> name = User::name;
        BinaryOperator<Integer> maximum = Integer::max;

        List<User> sorted = users.stream().sorted(order).toList();
        List<String> names = sorted.stream().map(name).distinct().toList();
        int best = users.stream().map(User::score).reduce(0, maximum);
        Map<String, Integer> bestByName = users.stream().collect(
                java.util.stream.Collectors.toMap(
                        User::name,
                        User::score,
                        Integer::max));
        System.out.println(sorted);
        System.out.println(names);
        System.out.println("best=" + best);
        System.out.println(bestByName);
    }
}

reversed()는 전체 비교자 체인을 뒤집습니다. 이름은 오름차순으로 두고 점수만 내림차순인 것이 목적이라면 각 구성 요소 비교자를 따로 구성해야 합니다. 메서드 참조가 간결해도 sort 정책의 방향을 문장과 테스트로 확인합니다.

reduce(0, Integer::max)의 동일성 0은 음수 점수가 가능하면 잘못된 결과를 만들 수 있습니다. 동일성 없는 reduceOptional을 반환합니다. 함수 참조 문법이 축약 대수를 자동으로 올바르게 만들지 않습니다.

null 정책

비결합 인스턴스 참조에 null 수신 객체가 전달되면 인스턴스 메서드 호출에서 NullPointerException이 발생합니다. 원본에 null이 올 수 있다면 필터로 제거하거나 null을 명시적으로 매핑한 래퍼 함수를 만듭니다. ComparatornullsFirstnullsLast로 정책을 표현할 수 있습니다.

참조가 가리키는 메서드가 null을 반환할 수도 있습니다. 다음 단계가 인스턴스 참조라면 그 값이 수신 객체가 되어 실패합니다. 파이프라인 경계에서 null 허용을 Optional이나 Result로 바꾸거나 null 계약을 거절합니다. 메서드 참조 자체에는 null-안전 연산자가 없습니다.

연습 문제

null 허용 문자열 목록에서 null과 빈 값을 제거하고 trim, 소문자화, 길이 정렬을 수행합니다. 핵심 변환은 메서드 참조를 쓰되 null 정책은 PredicateComparator 래퍼로 명시합니다. 원본 목록은 변경하지 않습니다.

해답 보기
src/NullSafeReferenceSolution.java
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;

public final class NullSafeReferenceSolution {
    static List<String> normalize(List<String> source) {
        Predicate<String> present = Objects::nonNull;
        Predicate<String> nonBlank = value -> !value.isBlank();
        Function<String, String> trim = String::trim;
        Function<String, String> lower = String::toLowerCase;
        Comparator<String> byLengthThenText = Comparator
                .comparingInt(String::length)
                .thenComparing(Comparator.naturalOrder());

        return source.stream()
                .filter(present)
                .map(trim)
                .filter(nonBlank)
                .map(lower)
                .sorted(byLengthThenText)
                .toList();
    }

    public static void main(String[] args) {
        List<String> source = java.util.Arrays.asList(" Java ", null, " ", "LAMBDA", "go", " java");
        System.out.println(normalize(source));
        System.out.println(source);
    }
}

해답은 null을 수신 객체로 사용하는 첫 인스턴스 참조 전에 제거합니다. trim 후 빈 값이 될 수 있으므로 빈 값 검사는 그다음에 둡니다. 소문자 변환 로케일 정책이 중요하다면 value -> value.toLowerCase(Locale.ROOT)처럼 매개변수가 있는 람다나 결합 포매터를 사용합니다.