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

안동민 개발노트

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

표준 함수형 인터페이스

JDK 표준 함수형 인터페이스를 입력과 출력, 부수 효과, 지연 생성, 동일 타입 연산으로 분류하고 함수 조합·단락 평가·메모이제이션의 실제 의미를 검증합니다.

표준 함수형 인터페이스는 이름을 외우기보다 데이터 흐름으로 읽습니다. Function은 입력을 다른 값으로 바꿉니다. Consumer는 값을 받아 부수 효과를 수행하고 반환하지 않습니다. Supplier는 입력 없이 값을 제공합니다. Predicateboolean 결과를 만듭니다. UnaryOperatorBinaryOperator는 입력과 결과 타입이 같은 Function, BiFunction의 특수 형태입니다.

표준 타입을 선택하면 Stream, Optional, 컬렉션의 기본 메서드와 자연스럽게 연결되고 팀원이 함수 시그니처를 바로 예상할 수 있습니다. 하지만 검사 예외, 규칙 ID, 설명, 실행 문맥처럼 추가 계약이 필요하면 사용자 정의 인터페이스나 래퍼 레코드를 사용합니다. 표준화는 정보 손실을 감수하는 축약이 되어서는 안 됩니다.

중복 사용자 인터페이스

다음 예제는 입력 하나와 결과 하나라는 같은 구조를 Parser, Formatter, Mapper로 반복합니다. 도메인 의미가 정말 다르면 이름이 도움이 되지만 조합 API를 각각 다시 작성해야 합니다. 단순 변환만 필요하다면 Function<T, R> 하나로 충분합니다.

bad/DuplicateFunctionalTypes.java
public final class DuplicateFunctionalTypes {
    @FunctionalInterface
    interface Parser<T, R> {
        R parse(T value);
    }

    @FunctionalInterface
    interface Formatter<T, R> {
        R format(T value);
    }

    @FunctionalInterface
    interface Mapper<T, R> {
        R map(T value);
    }

    public static void main(String[] args) {
        Parser<String, Integer> parser = Integer::parseInt;
        Formatter<Integer, String> formatter = value -> "#" + value;
        Mapper<String, String> mapper = String::trim;
        System.out.println(formatter.format(parser.parse(mapper.map(" 7 "))));
    }
}

반대로 Consumer<Order>라는 타입만 보고 감사인지 저장인지 메시지 발행인지 알 수 없는 경우가 있습니다. 매개변수 이름과 메서드 이름으로 충분히 설명되면 표준 타입을 유지할 수 있습니다. 실행 보장과 재시도 정책까지 달라지면 명령 추상화를 별도로 만듭니다.

표준 인터페이스의 기본 메서드도 의미가 있습니다. Functioncompose는 인수 함수를 먼저, andThen은 현재 함수를 먼저 실행합니다. Predicateandor는 단락 평가합니다. ConsumerandThen은 첫 소비자가 예외를 던지면 뒤 소비자를 실행하지 않습니다.

표준 인터페이스 조합

아래 프로그램은 원시 입력을 정규화하고 파싱한 뒤 연산자로 조정합니다. Predicate가 통과한 값만 Consumer에 전달하며 Supplier가 추적 ID를 늦게 만듭니다. 각 함수 값은 한 책임만 가져 실행 순서를 main에서 읽을 수 있습니다.

src/StandardFunctionToolkit.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;

public final class StandardFunctionToolkit {
    record Reading(String traceId, int value) {
    }

    public static void main(String[] args) {
        Function<String, String> normalize = String::trim;
        Function<String, Integer> parse = Integer::parseInt;
        Function<String, Integer> normalizedNumber = normalize.andThen(parse);
        UnaryOperator<Integer> calibrate = value -> value + 2;
        Predicate<Integer> accepted = value -> value >= 0 && value <= 100;
        AtomicInteger sequence = new AtomicInteger();
        Supplier<String> traceId = () -> "trace-" + sequence.incrementAndGet();
        List<Reading> sink = new ArrayList<>();
        Consumer<Integer> store = value -> sink.add(new Reading(traceId.get(), value));

        for (String raw : List.of(" 10 ", " 120 ", " -3 ", " 40 ")) {
            int value = calibrate.apply(normalizedNumber.apply(raw));
            if (accepted.test(value)) {
                store.accept(value);
            }
        }
        System.out.println(sink);
        System.out.println("generatedIds=" + sequence.get());
    }
}

Supplier를 반복문 밖에서 String id = traceId.get()으로 평가했다면 모든 판독값이 같은 ID를 공유합니다. Supplier 자체를 전달하면 승인된 값마다 새 ID를 만듭니다. 지연은 비용을 피하는 도구이면서 관찰 시점을 정하는 계약입니다.

Consumer는 완료를 알리는 값이 없습니다. 저장 성공과 실패를 호출자가 구별해야 한다면 Consumer보다 결과를 반환하는 Function이나 예외를 선언한 사용자 정의 명령이 적합합니다. 반환을 버리기 위해 Consumer를 선택하지 않습니다.

지연 실행과 메모이제이션

일반 Supplierget마다 다시 계산합니다. 메모이제이션 래퍼는 첫 성공을 캐시해 이후 호출이 같은 값을 보게 합니다. 동시 스레드에서 정확히 한 번 초기화가 필요하면 게시 규칙이 있어야 합니다. 다음 구현은 synchronized 블록으로 단순하게 보장하고, 실패는 캐시하지 않아 다음 호출이 재시도하게 합니다.

src/LazySupplierMemoizer.java
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public final class LazySupplierMemoizer {
    static <T> Supplier<T> memoizeSuccessful(Supplier<? extends T> source) {
        Objects.requireNonNull(source);
        return new Supplier<>() {
            private boolean initialized;
            private T value;

            @Override
            public synchronized T get() {
                if (!initialized) {
                    T created = Objects.requireNonNull(source.get(), "supplier returned null");
                    value = created;
                    initialized = true;
                }
                return value;
            }
        };
    }

    public static void main(String[] args) {
        AtomicInteger calls = new AtomicInteger();
        Supplier<String> source = () -> {
            int call = calls.incrementAndGet();
            if (call == 1) {
                throw new IllegalStateException("temporary failure");
            }
            return "value-" + call;
        };
        Supplier<String> cached = memoizeSuccessful(source);
        try {
            cached.get();
        } catch (IllegalStateException exception) {
            System.out.println(exception.getMessage());
        }
        System.out.println(cached.get());
        System.out.println(cached.get());
        System.out.println("sourceCalls=" + calls.get());
    }
}

실패를 캐시할지 재시도할지는 유스케이스 정책입니다. 구성 파싱처럼 같은 입력으로 영원히 실패한다면 실패 캐시가 낫습니다. 외부 서비스 일시 장애라면 재시도가 필요할 수 있습니다. 메모이저가 null을 허용하면 초기화 플래그와 값만으로 미계산 상태를 구분할 수 없으므로 예제는 null을 거절합니다.

Predicate 평가와 진단

predicateA.and(predicateB)boolean 결과만 필요할 때 간결합니다. 어떤 규칙이 실패했는지 사용자에게 보여 줘야 한다면 Predicate만으로 정보가 부족합니다. Rule(name, predicate) 목록을 순회해 실패 이름을 모을 수 있습니다. 모든 규칙 평가가 안전하고 부수 효과가 없어야 순서 변경과 전체 수집이 예측 가능합니다.

Operator는 동일 타입을 유지합니다. 문자열 normalize 체인이나 불변 값 갱신에 UnaryOperator가 맞고, 같은 타입 둘을 합치는 축약에는 BinaryOperator가 맞습니다. 결합법칙이 필요한 병렬 reduce에서 비결합 연산자를 사용하면 순서에 따라 결과가 달라질 수 있습니다.

연습 문제

회원 가입 입력에 빈 값 ID, 짧은 비밀번호, 허용하지 않은 이메일 도메인 규칙을 적용합니다. Rule<T>은 이름과 Predicate를 가집니다. 빠른 실패와 전체 수집 두 실행 모드를 모두 제공하고 실제로 평가한 규칙 이름을 함께 반환합니다.

해답 보기
src/PredicateRuleSetSolution.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public final class PredicateRuleSetSolution {
    record Signup(String id, String password, String email) {
    }

    record Rule<T>(String name, Predicate<T> predicate) {
    }

    record Validation(List<String> evaluated, List<String> failed) {
        Validation {
            evaluated = List.copyOf(evaluated);
            failed = List.copyOf(failed);
        }
    }

    static <T> Validation validate(List<Rule<T>> rules, T value, boolean failFast) {
        List<String> evaluated = new ArrayList<>();
        List<String> failed = new ArrayList<>();
        for (Rule<T> rule : rules) {
            evaluated.add(rule.name());
            if (!rule.predicate().test(value)) {
                failed.add(rule.name());
                if (failFast) {
                    break;
                }
            }
        }
        return new Validation(evaluated, failed);
    }

    public static void main(String[] args) {
        List<Rule<Signup>> rules = List.of(
                new Rule<>("id-required", signup -> signup.id() != null && !signup.id().isBlank()),
                new Rule<>("password-min-8", signup -> signup.password() != null
                        && signup.password().length() >= 8),
                new Rule<>("company-email", signup -> signup.email() != null
                        && signup.email().endsWith("@example.com")));
        Signup invalid = new Signup("", "short", "user@other.net");
        System.out.println(validate(rules, invalid, true));
        System.out.println(validate(rules, invalid, false));
    }
}

해답은 Predicate를 그대로 재사용하면서 규칙 이름을 별도 메타데이터로 보존합니다. 메시지 다국어화나 필드 위치가 필요하면 이름 대신 오류 코드와 메시지 인수를 반환하는 규칙 계약으로 확장합니다.