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

안동민 개발노트

본문 시작
35장 : 함수형 프로그래밍

일급 함수와 함수 합성

Function과 Predicate를 값으로 전달하고 compose·andThen 순서 오류를 재현하며 Study Log 규칙 파이프라인과 지연 대체 경로를 설계합니다.

Java에서 람다는 함수형 인터페이스의 인스턴스로 표현됩니다. 함수를 변수에 저장하고 인수로 전달하고 반환할 수 있으므로 동작을 값처럼 조합할 수 있습니다. 다른 함수를 받거나 반환하는 함수는 고차 함수입니다. 선언형 파이프라인은 작은 규칙의 관계를 앞에 두지만, 타입과 실행 순서가 숨겨질 정도로 연쇄가 길어지면 오히려 이름 있는 메서드가 낫습니다.

compose 적용 순서

compose는 인수로 받은 함수를 먼저 적용하고 수신 함수를 나중에 적용합니다. 아래 작성자는 5에 3을 더한 뒤 2를 곱해 16을 기대했지만 addThree.compose(doubleValue)는 5를 두 배로 만든 뒤 3을 더합니다. 출력은 wrong-result=13입니다.

lab/WrongFunctionCompositionOrderBug.java
import java.util.function.Function;

public final class WrongFunctionCompositionOrderBug {
    public static void main(String[] args) {
        Function<Integer, Integer> addThree = value -> value + 3;
        Function<Integer, Integer> doubleValue = value -> value * 2;

        Function<Integer, Integer> misunderstood = addThree.compose(doubleValue);
        System.out.println("wrong-result=" + misunderstood.apply(5));
    }
}

원칙은 first.andThen(second)를 읽는 방향으로 사용하거나 중간 함수에 도메인 이름을 붙이고 대표 입력으로 순서를 확인하는 것입니다. first.compose(before)first(before(x))입니다.

함수 값에 따른 정책 교체

학습 점수 계산의 기본 구조는 같고 주제별 보너스만 다르다면 IntUnaryOperator를 정책으로 받습니다. 호출자는 람다, 메서드 참조, 미리 만든 상수 함수 가운데 하나를 선택합니다.

src/FirstClassScorePolicy.java
import java.util.Map;
import java.util.function.IntUnaryOperator;

public final class FirstClassScorePolicy {
    static int score(int minutes, IntUnaryOperator bonusPolicy) {
        int base = minutes * 2;
        return base + bonusPolicy.applyAsInt(minutes);
    }

    static IntUnaryOperator cappedBonus(int pointsPerTenMinutes, int maximum) {
        return minutes -> Math.min((minutes / 10) * pointsPerTenMinutes, maximum);
    }

    public static void main(String[] args) {
        Map<String, IntUnaryOperator> policies =
                Map.of(
                        "java", cappedBonus(3, 15),
                        "database", cappedBonus(2, 10));
        System.out.println("java=" + score(50, policies.get("java")));
        System.out.println("database=" + score(50, policies.get("database")));
    }
}

cappedBonus는 함수를 반환하는 고차 함수이며 매개변수를 클로저로 캡처합니다. 캡처한 값이 가변하면 호출 시점마다 결과가 달라질 수 있으므로 불변 구성만 캡처합니다.

Predicate·Function 조합

명령형 버전은 반복문, if, add를 직접 작성합니다. 선언형 버전은 대상 조건과 변환에 먼저 이름을 붙인 뒤 파이프라인에서 조합합니다. 조건을 여러 곳에서 재사용하거나 이름이 업무 용어와 맞을 때 가독성이 좋아집니다.

src/DeclarativeStudyRulePipeline.java
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public final class DeclarativeStudyRulePipeline {
    private record Session(String topic, int minutes, boolean completed) {}

    private record Summary(String topic, int score) {}

    public static void main(String[] args) {
        Predicate<Session> completed = Session::completed;
        Predicate<Session> substantial = session -> session.minutes() >= 30;
        Function<Session, Summary> summarize =
                session -> new Summary(session.topic(), session.minutes() * 2);

        List<Session> sessions =
                List.of(
                        new Session("lambda", 35, true),
                        new Session("stream", 20, true),
                        new Session("optional", 45, false));

        List<Summary> result =
                sessions.stream().filter(completed.and(substantial)).map(summarize).toList();
        System.out.println(result);
    }
}

Predicate의 순서가 기능에 영향을 줄 수 있습니다. 첫 조건이 false이면 뒤 조건은 단락 평가로 호출되지 않습니다. 뒤 조건의 비용이 크거나 예외 가능성이 있다면 안전한 조건을 먼저 둡니다.

andThen·compose 실행 순서 추적

문자열 입력의 양끝 공백을 제거하고 정수로 변환해 제곱한 뒤 레이블로 바꾸는 파이프라인은 타입이 String → String → Integer → Integer → String으로 이어집니다. andThen을 쓰면 읽는 방향과 실행 방향이 같습니다.

src/TypedFunctionComposition.java
import java.util.function.Function;

public final class TypedFunctionComposition {
    public static void main(String[] args) {
        Function<String, String> trim = String::trim;
        Function<String, Integer> parse = Integer::parseInt;
        Function<Integer, Integer> square = value -> value * value;
        Function<Integer, String> label = value -> "score=" + value;

        Function<String, String> pipeline = trim.andThen(parse).andThen(square).andThen(label);

        System.out.println(pipeline.apply(" 12 "));
        System.out.println(parse.compose(trim).apply(" 7 "));
    }
}

지연 평가의 행동 전달과 실행

Supplier<T>는 값이 아니라 값을 만드는 동작을 나타냅니다. 캐시 적중 시 저장소 대체 경로를 호출하지 않는 구조처럼 계산을 피하는 데 가치가 있을 때 씁니다. 계산 결과 저장은 Supplier 자체 기능이 아니므로 get()을 여러 번 호출하면 본문도 여러 번 실행될 수 있습니다.

app/LazyStudySummaryFallback.java
import java.util.Map;
import java.util.function.Supplier;

public final class LazyStudySummaryFallback {
    private static String resolve(
            String key, Map<String, String> cache, Supplier<String> fallback) {
        String cached = cache.get(key);
        return cached != null ? cached : fallback.get();
    }

    public static void main(String[] args) {
        int[] loads = {0};
        Supplier<String> repository =
                () -> {
                    loads[0]++;
                    return "loaded-summary";
                };

        String cached = resolve("java", Map.of("java", "cached-summary"), repository);
        String missing = resolve("spring", Map.of(), repository);
        System.out.println("cached=" + cached);
        System.out.println("missing=" + missing);
        System.out.println("loads=" + loads[0]);
    }
}

이 예제의 가변 배열은 부수 효과 횟수를 관찰하기 위한 점검 장치입니다. 계산 함수 안에서 공유하지 않습니다. 운영 저장소 I/O는 시간 제한과 예외를 가진 별도 포트로 표현합니다.

함수 합성을 억지로 적용하지 않는 기준

  • 두세 단계의 타입 안전한 변환이 반복된다면 함수 합성이 유용합니다.
  • 자원 획득, 트랜잭션, 여러 예외 복구는 명령형 블록이 생명 주기를 더 잘 보여 줍니다.
  • Function<Object, Object>처럼 타입 정보를 잃는 제네릭 연쇄는 피합니다.
  • 스택 추적과 중단점이 중요한 복잡한 단계는 이름 있는 메서드로 추출합니다.
  • 람다가 여러 줄의 제어 흐름과 가변 지역 컨테이너를 가진다면 별도 클래스나 메서드가 더 읽기 쉽습니다.
  • 지연 평가는 비용 회피가 있을 때 사용하며 단순 상수를 무조건 Supplier로 감싸지 않습니다.
작은 함수를 많이 만들수록 항상 재사용성이 좋아지나요?

아닙니다. 도메인 의미 없이 문장 한 줄을 잘게 나누면 독자가 파일을 오가야 합니다. 독립적인 규칙과 안정적인 이름이 있고 여러 조합에서 쓰일 때 추출합니다. 함께 바뀌는 세부 단계는 하나의 응집된 함수로 유지할 수 있습니다.

연습 문제

Predicate를 조합하고 주제를 정규화하는 Function을 적용해 완료됐고 30분 이상인 세션의 소문자 제목만 반환하세요.

해설 보기
exercise/ComposedSessionSelectionSolution.java
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public final class ComposedSessionSelectionSolution {
    private record Session(String topic, int minutes, boolean completed) {}

    public static void main(String[] args) {
        Predicate<Session> completed = Session::completed;
        Predicate<Session> longEnough = session -> session.minutes() >= 30;
        Function<Session, String> topic = Session::topic;
        Function<String, String> normalize = value -> value.trim().toLowerCase();
        Function<Session, String> normalizedTopic = topic.andThen(normalize);

        List<String> selected =
                List.of(
                                new Session(" Java ", 40, true),
                                new Session(" Spring ", 20, true),
                                new Session(" Database ", 50, false))
                        .stream()
                        .filter(completed.and(longEnough))
                        .map(normalizedTopic)
                        .toList();

        System.out.println(selected);
    }
}

종료 기준은 [java] 하나입니다. 정규화 순서를 바꿔도 결과가 같은지, 로캘에 민감한 대소문자 변환이 필요한 도메인인지도 추가로 결정합니다.