본문 바로가기

일급 함수와 함수 합성

함수를 변수에 담고 인수로 넘기고 반환값으로 돌려받는 방법과, Predicate·Function을 조합할 때 순서가 결과를 바꾸는 이유, Supplier로 계산을 필요할 때까지 미루는 방법을 배웁니다.

앞 글에서는 값을 고치지 않고 새 값을 만들어 주고받았습니다. 함수형 스타일은 여기서 한 걸음 더 나아가 동작도 값처럼 다룹니다. 점수를 매기는 규칙이나 게시글을 고르는 조건을 변수에 담아 두었다가 메서드에 넘기고, 필요하면 메서드가 새 규칙을 만들어 돌려주기도 합니다. 이처럼 다른 값과 똑같이 변수에 담고, 인수로 넘기고, 반환할 수 있는 함수를 일급 함수(first-class function)라고 합니다.

Java에는 함수만을 위한 별도의 타입이 없습니다. 대신 람다와 함수형 인터페이스 장에서 본 것처럼, 람다와 메서드 참조가 Function이나 Predicate 같은 함수형 인터페이스의 객체가 되어 함수 노릇을 합니다. 함수를 인수로 받거나 함수를 돌려주는 메서드는 고차 함수(higher-order function)라고 부릅니다. andThen과 compose의 기본 동작은 표준 함수형 인터페이스와 함수 조합에서 익혔으니, 이 글에서는 이런 도구를 설계에 쓰는 방법과 그때 자주 저지르는 실수에 집중합니다.

분류마다 다른 보너스 규칙을 함수로 넘기기

게시글 점수는 조회 수의 두 배에 보너스를 더해 구하는데, 보너스 규칙만 게시판 분류마다 다르다고 해 봅시다. score 메서드 안에서 if나 switch로 분류별 규칙을 모두 처리하면, 분류가 늘어날 때마다 score를 고쳐야 합니다. 대신 보너스 규칙을 IntUnaryOperator로 받으면 score는 공통 계산만 맡고, 어떤 규칙을 쓸지는 호출하는 쪽이 정합니다. IntUnaryOperator는 int 하나를 받아 int를 돌려주는 표준 함수형 인터페이스이고, 추상 메서드의 이름은 applyAsInt입니다.

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(50)=" + score(50, policies.get("java")));
        System.out.println("java(80)=" + score(80, policies.get("java")));
        System.out.println("database(50)=" + score(50, policies.get("database")));
        System.out.println("none(50)=" + score(50, viewCount -> 0));
    }
}
실행 결과
java(50)=115
java(80)=175
database(50)=110
none(50)=100

이 코드에는 함수를 값으로 쓰는 세 가지 모습이 모두 들어 있습니다. cappedBonus는 함수를 반환하고, 그렇게 받은 함수는 Map에 값으로 저장되며, score는 그 함수를 인수로 받습니다. 마지막 줄처럼 미리 만들어 둔 함수 대신 람다 viewCount -> 0을 그 자리에서 넘길 수도 있습니다.

cappedBonus(3, 15)를 호출하는 시점에는 아직 조회 수가 없습니다. 이 호출은 '10회마다 3점, 최대 15점'이라는 규칙을 담은 함수를 만들어 돌려줄 뿐이고, 실제 계산은 score가 그 함수에 조회 수를 넣을 때 일어납니다. 조회 수가 50이면 정수 나눗셈 50 / 10이 5이고 Math.min(5 * 3, 15)가 15이므로 점수는 100 + 15 = 115입니다. 80이면 8 * 3이 24이지만 상한 15에 걸려 160 + 15 = 175가 됩니다. database 규칙은 Math.min(5 * 2, 10)으로 10을 더해 110이고, 보너스가 없는 람다를 넘긴 마지막 줄은 기본 점수 100 그대로입니다.

돌려받은 람다는 cappedBonus가 끝난 뒤에도 매개변수 pointsPerTenViewCount와 maximum의 값을 기억합니다. 람다가 바깥 변수의 값을 붙잡아 두는 것을 캡처(capture)라고 하고, 이렇게 주변 값을 붙잡은 함수를 클로저(closure)라고도 부릅니다. cappedBonus를 두 번 부르면 서로 다른 값을 붙잡은 함수 두 개가 따로 만들어지므로 java 규칙과 database 규칙이 섞이지 않습니다.

Java의 람다가 붙잡는 지역 변수와 매개변수는 사실상 final(effectively final), 즉 한 번 정해진 뒤 다시 대입되지 않는 변수여야 합니다. 여기서처럼 int 값을 붙잡으면 함수의 동작이 나중에 바뀔 일이 없습니다. 반면 가변 객체의 참조를 붙잡으면 그 객체가 바뀔 때 같은 함수가 다른 결과를 내게 되는데, 이 문제는 상태를 가진 람다와 재사용 문제에서 자세히 다뤘습니다.

조건에 이름을 붙이고 and·negate로 조합하기

조건도 함수입니다. Predicate<T>는 값을 받아 boolean을 돌려주는 함수형 인터페이스이고, and, or, negate 메서드로 다른 조건과 묶어 새 조건을 만들 수 있습니다. 아래 코드는 '공개됨'과 '조회 30회 이상'이라는 두 조건에 각각 이름을 붙이고, 둘을 and로 묶은 eligible을 두 곳에서 씁니다. 한 번은 집계 대상을 골라 요약을 만들 때이고, 한 번은 negate()로 뒤집어 제외된 게시글을 찾을 때입니다.

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;
        Predicate<PostMetric> eligible = published.and(substantial);
        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(eligible).map(summarize).toList();
        List<String> excluded =
                posts.stream().filter(eligible.negate()).map(PostMetric::category).toList();
        System.out.println("result=" + result);
        System.out.println("excluded=" + excluded);
    }
}
실행 결과
result=[Summary[category=lambda, score=70]]
excluded=[stream, optional]

세 입력을 차례로 보겠습니다. lambda는 공개이고 35회라 두 조건을 모두 통과해 점수 70인 요약이 됩니다. stream은 공개이지만 20회라 두 번째 조건에서 떨어지고, optional은 미공개라서 첫 번째 조건에서 바로 떨어집니다. 그래서 제외 목록에는 stream과 optional이 남습니다.

조건에 이름을 붙여 두면 기준이 바뀌어도 고칠 곳이 한 곳뿐입니다. 같은 일을 반복문으로 쓰면 post.published() && post.viewCount() >= 30 같은 식을 두 번 적어야 하고, 제외 목록 쪽에서는 그 식을 직접 뒤집어야 합니다. 이때 !post.published() && post.viewCount() < 30처럼 &&를 ||로 바꾸는 것을 잊기 쉬운데, 이 잘못된 식으로는 위 입력의 제외 목록이 빈 목록이 됩니다. negate()는 조건 전체를 그대로 뒤집으므로 이런 실수가 끼어들 틈이 없습니다.

조건을 묶는 순서가 중요한 이유

and로 묶은 조건은 && 연산자처럼 단락 평가(short-circuit evaluation)를 합니다. Predicate.and의 공식 문서에 따르면 앞 조건이 false이면 뒤 조건은 평가하지 않습니다. 위 예제에서도 optional은 미공개이므로 조회 수 조건이 아예 호출되지 않습니다. 결과만 보면 순서가 상관없어 보이지만, 뒤 조건이 예외를 던질 수 있다면 순서가 프로그램의 안전을 좌우합니다.

lab/PredicateOrderMatters.java
import java.util.Objects;
import java.util.function.Predicate;

public final class PredicateOrderMatters {
    public static void main(String[] args) {
        Predicate<String> present = Objects::nonNull;
        Predicate<String> notBlank = text -> !text.isBlank();

        System.out.println("safe=" + present.and(notBlank).test(null));
        try {
            System.out.println("unsafe=" + notBlank.and(present).test(null));
        } catch (NullPointerException e) {
            System.out.println("unsafe=" + e.getClass().getSimpleName());
        }
    }
}
실행 결과
safe=false
unsafe=NullPointerException

present.and(notBlank)는 null을 받으면 present에서 false가 나오므로 notBlank를 부르지 않습니다. 순서를 바꾼 notBlank.and(present)는 null에 대고 isBlank()부터 호출하므로 NullPointerException이 납니다. 그래서 뒤 조건이 안전하게 실행될 수 있게 해 주는 조건, 그리고 비용이 싼 조건을 앞에 둡니다. 뒤 조건에 로그 출력 같은 부수 효과가 있다면, 순서만 바꿔도 결과는 같은데 실행 기록이 달라진다는 점도 알아 두면 좋습니다.

compose와 andThen: 같은 두 함수, 다른 순서

Function 두 개를 이어 붙일 때는 순서를 헷갈리기 쉽습니다. f.andThen(g)는 f를 먼저 적용하고 그 결과를 g에 넘기지만, f.compose(g)는 인수로 받은 g를 먼저 적용합니다. 아래 코드는 '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));
    }
}
실행 결과
wrong-result=13

입력 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의 결과가 달라 순서가 의미를 바꾼다.

addThree.compose(doubleValue)는 인수인 doubleValue를 먼저 적용하므로 5가 10이 되고, 그다음 addThree가 3을 더해 13을 만듭니다. 의도한 계산은 5에 3을 더해 8을 만들고 두 배 해서 16을 얻는 것이었습니다. 도표가 나란히 보여 주듯 addThree.andThen(doubleValue)로 썼다면 5가 8을 거쳐 16이 되었을 것입니다. 같은 두 함수를 써도 적용 순서가 다르면 다른 함수가 됩니다.

의도대로 고치는 방법은 두 가지입니다. 아래 코드는 두 방법을 나란히 실행합니다.

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

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

        Function<Integer, Integer> addThenDouble = addThree.andThen(doubleValue);
        Function<Integer, Integer> sameWithCompose = doubleValue.compose(addThree);
        System.out.println("andThen=" + addThenDouble.apply(5));
        System.out.println("compose=" + sameWithCompose.apply(5));
    }
}
실행 결과
andThen=16
compose=16

f.compose(g)와 g.andThen(f)는 같은 함수이므로 두 줄 모두 16입니다. 그래도 andThen 쪽이 왼쪽에서 오른쪽으로 읽는 순서와 실행 순서가 같아서 읽기 쉽습니다. 합성 순서가 헷갈린다면 andThen으로 통일하는 편이 낫습니다. 그리고 대표 입력 하나를 넣어 중간값을 직접 확인해 보면 순서 실수를 바로 찾을 수 있습니다.

타입이 맞아야 이어 붙일 수 있다

Function<T, R>의 T는 입력 타입이고 R은 결과 타입입니다. andThen으로 이으려면 앞 함수의 결과를 뒤 함수가 입력으로 받을 수 있어야 합니다. 아래 파이프라인은 앞뒤 공백 제거, 정수 변환, 제곱, 레이블 붙이기의 네 단계로 이루어집니다. 타입은 String → String → Integer → Integer → String으로 한 칸씩 이어집니다.

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 "));
    }
}
실행 결과
score=144
7

첫 줄은 " 12 "가 "12"로 다듬어진 뒤 정수 12가 되고, 제곱해 144가 된 다음, 문자열 score=144로 바뀐 결과입니다. 둘째 줄의 parse.compose(trim)은 trim을 먼저 적용하므로 trim.andThen(parse)와 같은 함수이고, " 7 "을 정수 7로 바꿉니다.

타입이 맞지 않게 이으면 실행하기도 전에 컴파일러가 막아 줍니다. 예를 들어 Function<String, Integer> broken = trim.andThen(square);는 문자열을 내는 함수 뒤에 정수를 받는 함수를 붙이려는 것이라서 컴파일되지 않고, javac는 그 이유로 Function<Integer,Integer> cannot be converted to Function<? super String,? extends V>를 보여 줍니다. 하지만 앞 절의 addThree와 doubleValue처럼 입력과 결과 타입이 같은 함수끼리는 어떤 순서로 이어도 컴파일됩니다. 그런 순서 실수는 컴파일러가 잡아 주지 못하므로 실행 결과로 확인해야 합니다.

중간 단계에서 예외가 나면

합성한 함수의 한 단계에서 예외가 나면 뒤 단계는 실행되지 않고, 예외가 합성 함수를 호출한 쪽으로 그대로 전달됩니다. 위 코드에서 첫 번째 apply의 인수를 "x"로 바꿔 실행하면 parse 단계에서 NumberFormatException이 발생하고, square와 label은 실행되지 않습니다. 다음은 JDK 21에서 실행한 결과이며, java.base 안의 줄 번호는 JDK 버전에 따라 다를 수 있습니다.

실행 결과
Exception in thread "main" java.lang.NumberFormatException: For input string: "x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at java.base/java.util.function.Function.lambda$andThen$1(Function.java:88)
	at java.base/java.util.function.Function.lambda$andThen$1(Function.java:88)
	at java.base/java.util.function.Function.lambda$andThen$1(Function.java:88)
	at TypedFunctionComposition.main(TypedFunctionComposition.java:12)

이 스택 트레이스는 긴 합성 함수를 디버깅하기 어려운 이유도 보여 줍니다. Integer.parseInt에서 실패했다는 것은 알 수 있지만, 그 위로는 Function.lambda$andThen$1이 세 번 찍혀 있을 뿐 파이프라인의 몇 번째 단계였는지는 나오지 않습니다. 우리 코드의 줄 번호도 함수를 정의한 줄이 아니라 apply를 호출한 12번째 줄을 가리킵니다. 단계마다 실패할 수 있거나 단계가 길어진다면 각 단계를 이름 있는 메서드로 빼서 메서드 참조로 연결하는 편이 낫습니다. 그러면 스택 트레이스에 그 메서드의 이름이 찍히고 중단점을 걸기도 쉬워집니다.

Supplier로 계산을 필요할 때까지 미루기

함수를 넘긴다는 것은 그 동작을 언제 실행할지를 받는 쪽에 맡긴다는 뜻이기도 합니다. Supplier<T>는 인수 없이 값을 하나 만들어 돌려주는 함수형 인터페이스입니다. 값을 직접 넘기면 메서드를 호출하기 전에 그 값부터 계산해야 하지만, Supplier를 넘기면 받는 쪽이 정말 필요할 때만 get()을 불러 계산하게 할 수 있습니다. 아래 resolve는 캐시에 요약이 있으면 그것을 쓰고, 없을 때만 저장소에서 불러옵니다. 저장소를 흉내 내는 람다는 불릴 때마다 loads를 하나씩 올립니다.

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";
                };
        Map<String, String> cache = Map.of("java", "cached-summary");

        System.out.println("java=" + resolve("java", cache, repository));
        System.out.println("loads=" + loads[0]);
        System.out.println("spring=" + resolve("spring", cache, repository));
        System.out.println("spring=" + resolve("spring", cache, repository));
        System.out.println("loads=" + loads[0]);
    }
}
실행 결과
java=cached-summary
loads=0
spring=loaded-summary
spring=loaded-summary
loads=2

java는 캐시에 있으므로 fallback.get()이 호출되지 않고, 그래서 둘째 줄의 loads가 0입니다. 만약 resolve가 Supplier<String> 대신 String을 받았다면 호출하는 쪽이 저장소 조회를 먼저 끝내고 그 결과를 넘겨야 하므로, 캐시에 값이 있어도 조회가 일어났을 것입니다. 인수가 언제 계산되는지는 즉시 평가와 지연 평가에서 자세히 다뤘습니다.

spring은 캐시에 없어서 두 번 모두 저장소를 불렀고, 마지막 loads는 2입니다. Supplier는 계산을 미룰 뿐 결과를 기억하지 않으므로 get()을 부를 때마다 람다 본문이 다시 실행됩니다. 게다가 이 resolve는 불러온 값을 캐시에 넣지도 않습니다. Map.of로 만든 캐시는 애초에 수정할 수 없는 맵입니다. 한 번 계산한 결과를 다시 쓰려면 결과를 저장하는 코드를 따로 써야 하며, 그 방법은 표준 함수형 인터페이스와 함수 조합에서 다뤘습니다.

호출 횟수를 int[] 배열로 센 것은 람다 안에서 지역 변수에 다시 대입할 수 없어서 쓴 우회 방법입니다. 이 예제는 스레드 하나에서만 실행되므로 괜찮지만, 여러 스레드가 함께 부른다면 AtomicInteger 같은 스레드 안전한 카운터가 필요합니다. 또 실제 저장소 조회는 오래 걸리거나 실패할 수 있는데, Supplier.get()은 검사 예외를 던지도록 선언되어 있지 않습니다. IOException 같은 검사 예외를 다뤄야 한다면 Supplier에 억지로 맞추기보다, 실패를 표현할 수 있는 전용 인터페이스를 만드는 편이 낫습니다.

함수 조합보다 평범한 메서드가 나을 때

작은 함수를 이어 붙이는 방식은 두세 단계의 변환이 여러 곳에서 반복될 때 가장 효과가 큽니다. 단계마다 이름이 있고 타입이 한 칸씩 이어지니, 데이터가 어떤 순서로 바뀌는지 한눈에 보입니다. 반대로 다음과 같은 경우에는 평범한 메서드나 반복문이 더 읽기 쉽습니다.

  • 자원을 열고 닫거나 트랜잭션처럼 순서와 정리가 중요한 작업: try-with-resources나 try-finally 블록이 자원의 수명을 더 분명히 보여 줍니다.
  • 단계마다 실패를 다르게 처리해야 하는 작업: 합성 함수는 예외를 그대로 전달할 뿐이고, 앞에서 본 것처럼 스택 트레이스에도 단계 이름이 남지 않습니다.
  • Function<Object, Object>처럼 타입 정보를 잃는 연쇄: 단계 사이의 타입 실수를 컴파일러가 잡아 주지 못합니다.
  • 람다 본문이 여러 줄의 분기와 가변 지역 변수를 품게 되는 경우: 이름 있는 메서드로 빼면 이름이 의도를 설명하고, 중단점을 걸기도 쉽습니다.
  • 이미 값이 있거나 계산이 싼 경우의 Supplier: 상수 하나를 () -> "default"로 감싸도 아낄 계산이 없습니다.
작은 함수를 많이 만들수록 재사용하기 좋아지나요?

반드시 그렇지는 않습니다. 업무상 의미가 없는 한 줄짜리 계산까지 잘게 나누면, 읽는 사람이 여러 정의를 오가며 조각을 맞춰야 합니다. 함수로 따로 꺼낼 만한 것은 독립된 규칙이고, 안정된 이름이 있으며, 여러 조합에서 실제로 쓰이는 것입니다. 늘 함께 바뀌는 세부 단계라면 하나의 함수 안에 두는 편이 읽고 고치기 쉽습니다.

정리

  • Java의 함수는 람다와 메서드 참조로 만든 함수형 인터페이스 객체입니다. 변수에 담고, 인수로 넘기고, 반환할 수 있으므로 일급 함수처럼 쓸 수 있습니다.
  • 함수를 돌려주는 고차 함수는 매개변수를 캡처해 규칙을 만들어 둡니다. 실제 계산은 그 함수를 호출할 때 일어납니다.
  • 이름 붙인 조건을 and, or, negate로 조합하면 같은 기준을 여러 곳에서 쓰면서도 고칠 곳은 한 곳으로 모을 수 있습니다. and와 or는 단락 평가를 하므로, 뒤 조건을 안전하게 만드는 조건을 앞에 둡니다.
  • f.andThen(g)는 f를, f.compose(g)는 g를 먼저 적용합니다. 타입이 맞지 않는 순서는 컴파일러가 막지만, 타입이 같은 함수끼리의 순서 실수는 대표 입력으로 확인해야 합니다.
  • Supplier를 넘기면 받는 쪽이 필요할 때만 계산합니다. 결과를 기억하지는 않으므로 get()을 부를 때마다 다시 계산합니다.

35장에서 다룬 내용은 하나로 이어집니다. 순수 함수는 같은 입력에 같은 결과를 내고, 불변 값은 그 입력이 중간에 바뀌지 않게 하며, 일급 함수는 규칙 자체를 그런 값처럼 주고받게 해 줍니다. 세 가지를 함께 쓰면 계산하는 부분은 값을 받아 새 값을 돌려주는 함수들의 조합이 되고, 부수 효과는 그 바깥에 모입니다.

연습 문제

  1. 앞의 addThree와 doubleValue에 입력 2를 넣습니다. addThree.compose(doubleValue)와 addThree.andThen(doubleValue) 각각의 중간값과 최종값을 실행하기 전에 적어 보세요.
  2. 두 Predicate를 조합하고 분류 이름을 정규화하는 Function을 이어 붙여, 공개됐고 조회 수가 30회 이상인 게시글의 분류 이름만 소문자로 돌려주세요. 입력은 순서대로 " Java "(40회, 공개), " Spring "(20회, 공개), " Database "(50회, 미공개)이고, 정규화는 앞뒤 공백 제거와 소문자 변환입니다.
  3. 최소 조회 수를 매개변수로 받아 Predicate<PostMetric>을 돌려주는 고차 함수 atLeast(int minimum)을 만드세요. 본문의 세 게시글(lambda 35회 공개, stream 20회 공개, optional 45회 미공개)에 published.and(atLeast(minimum))을 적용해, 기준이 10, 30, 40일 때 고른 분류를 출력합니다.
1번 해설 보기

compose는 인수로 받은 doubleValue를 먼저 적용하므로 2가 4가 되고, 이어서 3을 더해 7입니다. andThen은 addThree를 먼저 적용하므로 2가 5가 되고, 이어서 두 배 해서 10입니다. 두 함수의 규칙은 그대로인데 적용 순서만 바뀌어 결과가 달라졌습니다. System.out.println(addThree.compose(doubleValue).apply(2));처럼 두 식을 출력해 보면 7과 10이 나옵니다.

2번 해설 보기
exercise/ComposedPostMetricSelectionSolution.java
import java.util.List;
import java.util.Locale;
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(Locale.ROOT);
        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]

필터가 먼저 실행되므로 " Spring "은 조회 수 조건에서, " Database "는 공개 조건에서 떨어지고, 정규화 함수는 남은 " Java " 하나에만 적용됩니다. category.andThen(normalize)는 게시글에서 분류 이름을 꺼낸 뒤 다듬는 순서이고, 타입은 PostMetric → String → String으로 이어집니다. 이 정규화에서는 공백 제거와 소문자 변환의 순서를 바꿔도 결과가 같습니다. 공백 제거는 대소문자를 건드리지 않고, 소문자 변환은 공백을 건드리지 않기 때문입니다. 순서에 따라 결과가 달라졌던 addThree와 doubleValue의 경우와 비교해 보세요.

toLowerCase에 Locale.ROOT를 넘긴 것은 실행 환경의 기본 로캘에 따라 결과가 달라지지 않게 하려는 것입니다. String.toLowerCase()의 공식 문서는 기본 로캘이 터키어이면 "TITLE".toLowerCase()가 점 없는 ı가 들어간 tıtle을 돌려준다고 경고합니다. 분류 이름처럼 언어와 상관없이 비교할 식별자라면 Locale.ROOT를 쓰는 편이 안전합니다.

3번 해설 보기
exercise/MinimumViewCountSolution.java
import java.util.List;
import java.util.function.Predicate;

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

    static Predicate<PostMetric> atLeast(int minimum) {
        return post -> post.viewCount() >= minimum;
    }

    public static void main(String[] args) {
        Predicate<PostMetric> published = PostMetric::published;
        List<PostMetric> posts =
                List.of(
                        new PostMetric("lambda", 35, true),
                        new PostMetric("stream", 20, true),
                        new PostMetric("optional", 45, false));

        for (int minimum : new int[] {10, 30, 40}) {
            List<String> selected =
                    posts.stream()
                            .filter(published.and(atLeast(minimum)))
                            .map(PostMetric::category)
                            .toList();
            System.out.println("atLeast(" + minimum + ")=" + selected);
        }
    }
}
실행 결과
atLeast(10)=[lambda, stream]
atLeast(30)=[lambda]
atLeast(40)=[]

atLeast는 조회 수를 받지 않고 기준값만 받아, 그 기준을 캡처한 조건을 돌려줍니다. 반복문이 돌 때마다 기준이 다른 조건이 새로 만들어지고, published와 묶여 필터에 쓰입니다. 기준 10에서는 공개된 두 게시글이 모두 남고, 30에서는 20회인 stream이 빠집니다. 40에서는 남는 것이 없는데, 45회인 optional은 기준을 넘지만 미공개라서 published에서 먼저 떨어지기 때문입니다.

다음 글

이것으로 35장의 함수형 프로그래밍을 마칩니다. 다음 36장 'Java 25 안정 기능'의 첫 글인 record 심화와 불변 스냅샷에서는 불변 값과 컬렉션 스냅샷에서 쓴 record와 List.copyOf를 더 깊이 다룹니다.