본문 바로가기

일급 함수와 함수 합성

Function과 Predicate를 조합하고 compose·andThen 순서를 구분해 게시판 통계 파이프라인을 설계합니다.

Java에서 람다는 함수형 인터페이스의 인스턴스로 표현됩니다.

함수를 변수에 저장하고 인수로 전달하고 반환할 수 있으므로 동작을 값처럼 조합할 수 있습니다.

다른 함수를 받거나 반환하는 함수는 고차 함수입니다.

선언형 파이프라인은 작은 규칙의 관계를 앞에 두지만, 타입과 실행 순서가 숨겨질 정도로 연쇄가 길어지면 오히려 이름 있는 메서드가 낫습니다.


compose 적용 순서

compose는 인수로 받은 함수를 먼저 적용하고 수신 함수를 나중에 적용합니다.

아래 예제는 3을 더한 뒤 두 배로 만드는 의도와 compose에 전달한 순서가 어긋난 경우입니다.

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

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

같은 5를 compose는 10→13, andThen은 8→16으로 변환합니다. 두 독립 실행의 현재 함수와 중간값을 나란히 보여 주면 합성 순서의 인과가 드러납니다.

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

addThree와 doubleValue에 compose와 andThen을 적용한 함수식을 비교합니다.

함수 이름의 나열과 적용 순서

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

compose는 인수 함수를 먼저 적용해 13을 만들고, andThen은 수신 함수를 먼저 적용해 16을 만듭니다.

같은 입력에서 함수 적용 순서가 갈라진다두영역은 같은입력5를 독립적으로계산한다. compose는doubleValue먼저5에서10, addThree나중10에서13이다. andThen은addThree먼저5에서8,doubleValue나중8에서16이다. 화살표는값이 다음함수로전달되는순서이며 원main실제호출은compose이고andThen은같은입력 API규칙 비교다.addThree.compose(doubleValue)addThree.andThen(doubleValue)입력값5먼저 두 배5 × 2 = 10나중에 3 더하기10 + 3 = 13입력값5먼저 3 더하기5 + 3 = 8나중에 두 배8 × 2 = 16
같은 입력에서 함수 적용 순서가 갈라진다두영역은 같은입력5를 독립적으로계산한다. compose는doubleValue먼저5에서10, addThree나중10에서13이다. andThen은addThree먼저5에서8,doubleValue나중8에서16이다. 화살표는값이 다음함수로전달되는순서이며 원main실제호출은compose이고andThen은같은입력 API규칙 비교다.addThree.compose(doubleValue)addThree.andThen(doubleValue)입력값5먼저 두 배5 × 2 = 10나중에 3 더하기10 + 3 = 13입력값5먼저 3 더하기5 + 3 = 8나중에 두 배8 × 2 = 16

addThree.compose(doubleValue)

적용 순서
두 배 → 3 더하기
입력 5의 계산
5 → 10 → 13

addThree.andThen(doubleValue)

적용 순서
3 더하기 → 두 배
입력 5의 계산
5 → 8 → 16

main은 compose만 호출합니다. andThen 행은 API 규칙에 따른 비교 계산이며 추가 실행 관측은 아닙니다.

이 예의 조건

  • compose와 andThen은 독립적으로 입력 5에서 시작한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2두 영역은 같은 입력 5를 독립적으로 계산한다. compose는 doubleValue를 먼저 적용해 5에서 10으로, addThree를 나중에 적용해 10에서 13으로 바꾼다. andThen은 addThree를 먼저 적용해 5에서 8로, doubleValue를 나중에 적용해 8에서 16으로 바꾼다. 화살표는 값이 다음 함수로 전달되는 순서이며 원 main의 실제 호출은 compose이고 andThen은 같은 입력에 대한 API 규칙 비교다.
원 도표의 설명과 주석
구분내용
설명두 영역은 같은 입력 5를 독립적으로 계산한다. compose는 doubleValue를 먼저 적용해 5에서 10으로, addThree를 나중에 적용해 10에서 13으로 바꾼다. andThen은 addThree를 먼저 적용해 5에서 8로, doubleValue를 나중에 적용해 8에서 16으로 바꾼다. 화살표는 값이 다음 함수로 전달되는 순서이며 원 main의 실제 호출은 compose이고 andThen은 같은 입력에 대한 API 규칙 비교다.
전체 읽기 정보입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 addThree와 doubleValue에 compose와 andThen을 적용한 함수식을 비교합니다. 함수 이름의 나열과 적용 순서 입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 compose는 인수 함수를 먼저 적용해 13을 만들고, andThen은 수신 함수를 먼저 적용해 16을 만듭니다. addThree.compose(doubleValue) 적용 순서 두 배 → 3 더하기 입력 5의 계산 5 → 10 → 13 addThree.andThen(doubleValue) 적용 순서 3 더하기 → 두 배 입력 5의 계산 5 → 8 → 16 main은 compose만 호출합니다. andThen 행은 API 규칙에 따른 비교 계산이며 추가 실행 관측은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 입력

5를 두 합성 정의에 전달합니다.

  • 입력: 5
  • compose 첫 함수: doubleValue
  • andThen 첫 함수: addThree
  • 합성 API가 첫 실행 함수를 다르게 선택한다.
첫 계산

현재 함수의 결과를 다음 입력으로 넘깁니다.

  • compose 중간값: 10
  • andThen 중간값: 8
  • 같은 입력이라도 함수 순서가 중간값을 바꾼다.
둘째 계산

두 번째 함수가 최종값을 만듭니다.

  • compose 결과: 13
  • andThen 결과: 16
  • 10+3과 8×2의 결과가 달라 순서가 의미를 바꾼다.

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

같은 5를 compose는 10→13, andThen은 8→16으로 변환합니다. 두 독립 실행의 현재 함수와 중간값을 나란히 보여 주면 합성 순서의 인과가 드러납니다.

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

addThree와 doubleValue에 compose와 andThen을 적용한 함수식을 비교합니다.

함수 이름의 나열과 적용 순서

입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다

compose는 인수 함수를 먼저 적용해 13을 만들고, andThen은 수신 함수를 먼저 적용해 16을 만듭니다.

같은 입력에서 함수 적용 순서가 갈라진다두영역은 같은입력5를 독립적으로계산한다. compose는doubleValue먼저5에서10, addThree나중10에서13이다. andThen은addThree먼저5에서8,doubleValue나중8에서16이다. 화살표는값이 다음함수로전달되는순서이며 원main실제호출은compose이고andThen은같은입력 API규칙 비교다.addThree.compose(doubleValue)addThree.andThen(doubleValue)입력값5먼저 두 배5 × 2 = 10나중에 3 더하기10 + 3 = 13입력값5먼저 3 더하기5 + 3 = 8나중에 두 배8 × 2 = 16
같은 입력에서 함수 적용 순서가 갈라진다두영역은 같은입력5를 독립적으로계산한다. compose는doubleValue먼저5에서10, addThree나중10에서13이다. andThen은addThree먼저5에서8,doubleValue나중8에서16이다. 화살표는값이 다음함수로전달되는순서이며 원main실제호출은compose이고andThen은같은입력 API규칙 비교다.addThree.compose(doubleValue)addThree.andThen(doubleValue)입력값5먼저 두 배5 × 2 = 10나중에 3 더하기10 + 3 = 13입력값5먼저 3 더하기5 + 3 = 8나중에 두 배8 × 2 = 16

addThree.compose(doubleValue)

적용 순서
두 배 → 3 더하기
입력 5의 계산
5 → 10 → 13

addThree.andThen(doubleValue)

적용 순서
3 더하기 → 두 배
입력 5의 계산
5 → 8 → 16

main은 compose만 호출합니다. andThen 행은 API 규칙에 따른 비교 계산이며 추가 실행 관측은 아닙니다.

이 예의 조건

  • compose와 andThen은 독립적으로 입력 5에서 시작한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2두 영역은 같은 입력 5를 독립적으로 계산한다. compose는 doubleValue를 먼저 적용해 5에서 10으로, addThree를 나중에 적용해 10에서 13으로 바꾼다. andThen은 addThree를 먼저 적용해 5에서 8로, doubleValue를 나중에 적용해 8에서 16으로 바꾼다. 화살표는 값이 다음 함수로 전달되는 순서이며 원 main의 실제 호출은 compose이고 andThen은 같은 입력에 대한 API 규칙 비교다.
원 도표의 설명과 주석
구분내용
설명두 영역은 같은 입력 5를 독립적으로 계산한다. compose는 doubleValue를 먼저 적용해 5에서 10으로, addThree를 나중에 적용해 10에서 13으로 바꾼다. andThen은 addThree를 먼저 적용해 5에서 8로, doubleValue를 나중에 적용해 8에서 16으로 바꾼다. 화살표는 값이 다음 함수로 전달되는 순서이며 원 main의 실제 호출은 compose이고 andThen은 같은 입력에 대한 API 규칙 비교다.
전체 읽기 정보입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 addThree와 doubleValue에 compose와 andThen을 적용한 함수식을 비교합니다. 함수 이름의 나열과 적용 순서 입력 5에 같은 두 함수를 합성해도 적용 순서가 다르다 compose는 인수 함수를 먼저 적용해 13을 만들고, andThen은 수신 함수를 먼저 적용해 16을 만듭니다. addThree.compose(doubleValue) 적용 순서 두 배 → 3 더하기 입력 5의 계산 5 → 10 → 13 addThree.andThen(doubleValue) 적용 순서 3 더하기 → 두 배 입력 5의 계산 5 → 8 → 16 main은 compose만 호출합니다. andThen 행은 API 규칙에 따른 비교 계산이며 추가 실행 관측은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 입력

5를 두 합성 정의에 전달합니다.

  • 입력: 5
  • compose 첫 함수: doubleValue
  • andThen 첫 함수: addThree
  • 합성 API가 첫 실행 함수를 다르게 선택한다.
첫 계산

현재 함수의 결과를 다음 입력으로 넘깁니다.

  • compose 중간값: 10
  • andThen 중간값: 8
  • 같은 입력이라도 함수 순서가 중간값을 바꾼다.
둘째 계산

두 번째 함수가 최종값을 만듭니다.

  • compose 결과: 13
  • andThen 결과: 16
  • 10+3과 8×2의 결과가 달라 순서가 의미를 바꾼다.

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


함수 값에 따른 정책 교체

게시글 점수 계산의 기본 구조는 같고 게시판 분류별 보너스만 다르다면 IntUnaryOperator를 정책으로 받습니다.

호출자는 람다, 메서드 참조, 미리 만든 상수 함수 가운데 하나를 선택합니다.

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

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

    static IntUnaryOperator cappedBonus(int pointsPerTenViewCount, int maximum) {
        return viewCount -> Math.min((viewCount / 10) * pointsPerTenViewCount, 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는 함수를 반환하는 고차 함수이며 매개변수를 클로저로 캡처합니다.

호출 cappedBonus(3, 15)는 조회 수를 아직 받지 않고 보너스 규칙을 만듭니다. 반환된 함수에 50을 적용할 때 정수 나눗셈 50 / 10 = 5, 상한 적용 min(5 × 3, 15) = 15를 계산합니다. 기본점수 100에 더해 java=115이고, 다른 정책은 보너스 10이라 database=110입니다. 9회와 10회 사이에서는 정수 나눗셈 값이 0에서 1로 바뀝니다.

이 예제는 기본형 int 구성값을 캡처합니다. 가변 객체 참조를 캡처하는 경우에는 그 객체의 변경이 호출 결과에 영향을 줄 수 있습니다.


Predicate·Function 조합

명령형 버전은 반복문, if, add를 직접 작성합니다.

선언형 버전은 대상 조건과 변환에 먼저 이름을 붙인 뒤 파이프라인에서 조합합니다.

조건을 여러 곳에서 재사용하거나 이름이 업무 용어와 맞을 때 가독성이 좋아집니다.

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

public final class DeclarativeBoardRulePipeline {
    private record PostMetric(String category, int viewCount, boolean published) {}

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

    public static void main(String[] args) {
        Predicate<PostMetric> published = PostMetric::published;
        Predicate<PostMetric> substantial = post -> post.viewCount() >= 30;
        Function<PostMetric, Summary> summarize =
                post -> new Summary(post.category(), post.viewCount() * 2);

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

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

Predicate의 순서가 기능에 영향을 줄 수 있습니다.

첫 조건이 false이면 뒤 조건은 단락 평가로 호출되지 않습니다.

뒤 조건의 비용이 크거나 예외 가능성이 있다면 안전한 조건을 먼저 둡니다.

세 입력을 차례로 읽으면 lambda는 공개이고 35회라 통과해 점수 70이 됩니다. stream은 공개이지만 20회라 두 번째 조건에서 탈락합니다. optional은 미공개이므로 조회 45회 조건은 호출하지 않습니다. Predicate.and 공식 API는 이 단락 평가를 정하므로, 뒤 조건에 부수 효과가 있으면 조건 순서를 바꾸는 일이 실행 이력도 바꿀 수 있습니다.


andThen·compose 실행 순서 추적

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

andThen을 쓰면 읽는 방향과 실행 방향이 같습니다.

Function 공식 API의 T는 함수 입력 타입, R은 결과 타입입니다. 앞 함수의 결과를 뒤 함수의 입력으로 받을 수 있어야 합성할 수 있습니다. 아래 첫 호출은 " 12 "를 "12"로 다듬고 정수 12·정수 144·문자열 "score=144"로 바꿉니다. 둘째 호출은 정수 7입니다. 입력을 "x"로 바꾸면 정수 변환에서 예외가 전달돼 제곱과 레이블 단계에 도달하지 않습니다.

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/LazyBoardSummaryFallback.java
import java.util.Map;
import java.util.function.Supplier;

public final class LazyBoardSummaryFallback {
    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]);
    }
}

이 예제는 단일 main 흐름에서 가변 배열을 캡처해 대체 경로의 호출 수를 셉니다. 배열 증가는 동기화된 계수기가 아닙니다.

resolve는 불러온 값을 캐시에 저장하지 않으므로 같은 캐시 미적중을 다시 요청하면 대체 경로도 다시 호출합니다.

운영 저장소 I/O는 시간 제한과 예외를 가진 별도 포트로 표현합니다.


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

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

아닙니다.

도메인 의미 없이 문장 한 줄을 잘게 나누면 독자가 파일을 오가야 합니다.

독립적인 규칙과 안정적인 이름이 있고 여러 조합에서 쓰일 때 추출합니다.

함께 바뀌는 세부 단계는 하나의 응집된 함수로 유지할 수 있습니다.


연습 문제

두 Predicate를 조합하고 분류 이름을 정규화하는 Function을 적용해 공개됐고 조회 수가 30회 이상인 게시글의 소문자 분류 이름만 반환하세요.

입력은 순서대로 " Java "·40회·공개, " Spring "·20회·공개, " Database "·50회·미공개입니다. 먼저 조건에 남는 원소를 고르고, 그 원소만 공백 제거와 소문자 변환에 넘깁니다. 이 원문은 toLowerCase()의 기본 로캘을 사용하므로, 다른 언어의 식별자를 처리하는 일반 정책까지 정한 것으로 보지 않습니다.

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

public final class ComposedPostMetricSelectionSolution {
    private record PostMetric(String category, int viewCount, boolean published) {}

    public static void main(String[] args) {
        Predicate<PostMetric> published = PostMetric::published;
        Predicate<PostMetric> longEnough = post -> post.viewCount() >= 30;
        Function<PostMetric, String> category = PostMetric::category;
        Function<String, String> normalize = value -> value.trim().toLowerCase();
        Function<PostMetric, String> normalizedCategory = category.andThen(normalize);

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

        System.out.println(selected);
    }
}

종료 기준은 [java] 하나입니다.

정규화 순서를 바꿔도 결과가 같은지, 로캘에 민감한 대소문자 변환이 필요한 도메인인지도 추가로 결정합니다.

합성 순서 확인

앞의 addThree와 doubleValue에 입력 2를 적용합니다. addThree.compose(doubleValue)와 addThree.andThen(doubleValue)의 중간값과 최종값을 각각 적습니다. 두 식이 같은 함수를 사용한다는 이유만으로 결과가 같다고 판단하지 않습니다.

순서 확인 풀이

compose는 2를 먼저 두 배로 만들어 4, 이어 3을 더해 7입니다. andThen은 2에 먼저 3을 더해 5, 이어 두 배로 만들어 10입니다. 각 함수 자체의 규칙은 같아도 합성 순서가 달라 결과가 달라집니다.