본문 바로가기

함수형 인터페이스 계약

함수형 인터페이스의 단일 추상 메서드 규칙과 상속·기본·정적 메서드의 영향을 구분하고 타입 안전한 변환 계약을 조합합니다.

람다의 대상은 하나의 추상 행동 계약을 나타내는 함수형 인터페이스입니다. Object의 public 메서드 제외와 상속된 메서드의 재정의 관계를 반영해 판정하며, sealed로 선언된 인터페이스는 대상이 아닙니다.

이를 SAM, 즉 단일 추상 메서드 계약이라고 부릅니다.

메서드 이름은 자유롭고 매개변수와 return 타입이 함수 시그니처를 만듭니다.

@FunctionalInterface는 필수가 아니지만 향후 누군가 두 번째 추상 메서드를 추가할 때 컴파일 오류로 계약 파괴를 알려 줍니다.

단일 책임이라는 설계 원칙과 SAM은 같은 말이 아닙니다.

추상 메서드가 하나여도 입력과 출력에 Object만 쓰고 여러 모드 플래그를 받으면 의미가 넓을 수 있습니다.

반대로 기본 메서드가 여러 개 있어도 핵심 추상 행동 하나를 안전하게 조합하도록 돕는다면 좋은 함수형 API가 될 수 있습니다.


Object 함수의 타입 손실

다음 인터페이스는 SAM이므로 람다를 받을 수 있지만 Object -> Object 계약이 실제 의미를 숨깁니다.

호출자가 형변환을 반복하고 잘못된 타입 조합도 컴파일됩니다.

모드 문자열까지 더해지면 하나의 함수가 파싱, 서식화, 검증 역할을 모두 떠맡습니다.

bad/RawObjectAction.java
import java.util.List;

public final class RawObjectAction {
    @FunctionalInterface
    interface Action {
        Object apply(Object value);
    }

    public static void main(String[] args) {
        Action length = value -> ((String) value).length();
        Action doubleNumber = value -> ((Integer) value) * 2;
        List<Action> actions = List.of(length, doubleNumber);
        Object first = actions.get(0).apply("lambda");
        Object second = actions.get(1).apply(21);
        System.out.println(first + " " + second);
    }
}

제네릭 매개변수 T와 결과 R을 분리하면 컴파일러가 함수 연결을 검사합니다.

Transformer<String, Integer>는 String을 받아 Integer를 돌려준다는 계약입니다.

Transformer<Integer, Money>를 다음 단계로 연결할 수 있지만 Transformer<Path, User>를 잘못 연결하면 컴파일되지 않습니다.

기본형을 제네릭 타입 인수로 쓸 수 없으므로 Integer 같은 래퍼가 필요합니다.

대량 숫자 처리에서 박싱 비용이 중요하면 IntFunction, ToIntFunction 같은 기본형 특화를 선택합니다.

먼저 의미 있는 제네릭 계약을 세우고 측정된 성능 핵심 경로에서 특화를 검토합니다.


추상 메서드 판정

인터페이스가 boolean equals(Object)를 다시 선언해도 Object의 public 메서드와 일치하므로 SAM 계산에서 제외됩니다.

default 메서드와 static 메서드는 추상 메서드가 아닙니다.

함수형 인터페이스 명세는 Object의 public 인스턴스 메서드와 일치하는 선언을 제외합니다. Object.clone은 protected이므로 인터페이스에 public clone()을 추가하면 같은 제외 규칙을 적용할 수 없습니다. 예제의 Converter에 convert 외의 추상 clone을 추가하는 경우와 static 도우미를 추가하는 경우를 구별하세요.

아래 예제의 멤버들을 코드 뒤 표에서 분류합니다.

src/FunctionalInterfaceRules.java
import java.util.Objects;

public final class FunctionalInterfaceRules {
    @FunctionalInterface
    interface Converter<T, R> {
        R convert(T value);

        @Override
        boolean equals(Object other);

        default <V> Converter<T, V> andThen(Converter<? super R, ? extends V> next) {
            Objects.requireNonNull(next);
            return value -> next.convert(convert(value));
        }

        static <T> Converter<T, T> identity() {
            return value -> value;
        }
    }

    record UserId(long value) {
    }

    public static void main(String[] args) {
        Converter<String, String> trim = String::trim;
        Converter<String, Long> parse = Long::parseLong;
        Converter<Long, UserId> wrap = UserId::new;
        Converter<String, UserId> userId = trim.andThen(parse).andThen(wrap);

        System.out.println(userId.convert(" 42 "));
        System.out.println(Converter.<String>identity().convert("same"));
    }
}
선언된 메서드 수와 람다가 구현할 추상 행동 수를 구분한다

FunctionalInterfaceRules.Converter의 네 멤버를 분류합니다.

하나의 추상 행동

선언된 메서드 수와 람다가 구현할 추상 행동 수를 구분한다

Converter에는 네 멤버가 있지만 람다가 구현하는 행동은 convert 하나입니다.

원 Converter 멤버의 포함과 제외 관계실선은 핵심 추상 convert가 함수 계약을 정한다는 관계다. 점선은 Object public equals, default andThen, static identity가 추상 행동을 추가하지 않는다는 분류이며 호출 순서가 아니다. 선언 멤버4개와 추상 행동1개를 구분한다. 상세 멤버표는 그대로 유지하며 모바일도 같은 3노드2관계이다.convert(T)추상 행동equals(Object)andThen:defaultidentity:static함수 계약R convert(T)행동 1개추가 0개
원 Converter 멤버의 포함과 제외 관계실선은 핵심 추상 convert가 함수 계약을 정한다는 관계다. 점선은 Object public equals, default andThen, static identity가 추상 행동을 추가하지 않는다는 분류이며 호출 순서가 아니다. 선언 멤버4개와 추상 행동1개를 구분한다. 상세 멤버표는 그대로 유지하며 모바일도 같은 3노드2관계이다.convert(T)추상 행동equals(Object)andThen:defaultidentity:static함수 계약R convert(T)행동 1개추가 0개
선언된 메서드 수와 람다가 구현할 추상 행동 수를 구분한다
멤버선언의 역할SAM 판정
convert(T)변환의 추상 메서드람다가 구현하는 행동
equals(Object)Object의 public 메서드와 같은 시그니처계산에서 제외
andThen(next)본문이 있는 default 조합 메서드추상 행동을 추가하지 않음
identity()static 팩터리추상 행동을 추가하지 않음
convert(T)
선언의 역할
변환의 추상 메서드
SAM 판정
람다가 구현하는 행동
equals(Object)
선언의 역할
Object의 public 메서드와 같은 시그니처
SAM 판정
계산에서 제외
andThen(next)
선언의 역할
본문이 있는 default 조합 메서드
SAM 판정
추상 행동을 추가하지 않음
identity()
선언의 역할
static 팩터리
SAM 판정
추상 행동을 추가하지 않음

equals(Object)는 Object의 public 메서드와 같은 시그니처여서 제외합니다. default·static 메서드는 추상 행동을 추가하지 않습니다.

andThen의 와일드카드는 현재 결과를 소비할 수 있는 더 넓은 입력과, 원하는 결과 하위 타입을 허용합니다.

제네릭 변성은 뒤 문서에서 더 깊게 다룹니다.

여기서는 기본 메서드가 핵심부 함수 시그니처를 바꾸지 않으면서 함수 조합을 제공한다는 점을 확인합니다.

인터페이스가 부모 둘에서 동일 시그니처의 추상 메서드를 상속하면 하나의 함수 시그니처로 합쳐질 수 있습니다.

반대로 이름이나 매개변수가 다른 추상 메서드가 둘이면 함수형이 아닙니다.

반환 타입은 대체 가능해야 합니다.

복잡한 상속 계산에 기대기보다 공개 함수형 인터페이스는 작은 자체 선언으로 유지하는 편이 설명하기 쉽습니다.


타입 매개변수의 역할

T, R, U는 관례지만 API가 복잡하면 INPUT, OUTPUT처럼 의미 있는 이름도 가능합니다.

같은 T를 매개변수와 return에 사용하면 입력과 출력 타입이 반드시 같다는 제약을 겁니다.

변환기인데 UnaryOperator<T>를 잘못 선택하면 String을 Integer로 바꾸는 함수를 표현할 수 없습니다.

다음 예제는 제네릭 Transformer<I, O>와 mapAll()을 사용합니다.

함수형 인터페이스와 알고리즘 양쪽의 타입 매개변수가 독립적으로 추론됩니다.

결과는 새 목록이며 원본을 수정하지 않습니다.

src/GenericTransformer.java
import java.util.ArrayList;
import java.util.List;

public final class GenericTransformer {
    @FunctionalInterface
    interface Transformer<I, O> {
        O transform(I input);
    }

    static <I, O> List<O> mapAll(List<I> inputs, Transformer<? super I, ? extends O> transformer) {
        List<O> outputs = new ArrayList<>(inputs.size());
        for (I input : inputs) {
            outputs.add(transformer.transform(input));
        }
        return List.copyOf(outputs);
    }

    sealed interface Token permits Word, NumberToken {
    }

    record Word(String value) implements Token {
    }

    record NumberToken(int value) implements Token {
    }

    public static void main(String[] args) {
        Transformer<CharSequence, Word> word = value -> new Word(value.toString().trim());
        List<Token> words = mapAll(List.of(" java ", " lambda "), word);
        List<Token> numbers = mapAll(List.of("3", "7"), value -> new NumberToken(Integer.parseInt(value)));
        System.out.println(words);
        System.out.println(numbers);
    }
}

mapAll()이 Transformer<I, O>만 받도록 써도 기본 사례는 동작합니다.

? super I와 ? extends O는 호출자가 이미 가진 넓은 소비자와 구체 결과 함수를 재사용할 수 있게 합니다.

와일드카드를 무조건 추가하면 시그니처 읽기가 어려워질 수 있으므로 실제 변성 요구가 있는 API 경계에 사용합니다.


@FunctionalInterface 계약

애노테이션 없이도 SAM이면 람다 대상이 됩니다.

그래도 공개 API에 애노테이션을 붙이면 “두 번째 추상 행동을 추가하지 말라”는 의도를 컴파일러와 독자에게 전달합니다.

새 기능은 기본 메서드, 별도 협력자, 새로운 인터페이스 가운데 의미에 맞는 위치로 갑니다.

함수형 인터페이스의 이름은 실행 의미를 드러내야 합니다.

Processor와 Handler만 반복하면 매개변수가 무엇을 소비하고 실패를 어떻게 전달하는지 알기 어렵습니다.

표준 Function, Predicate로 충분하면 표준 타입을 사용하고, 검사 예외나 도메인 메타데이터처럼 다른 계약이 필요할 때 사용자 정의 인터페이스를 만듭니다.


연습 문제

CheckedMapper<I, O>에 O map(I input)과 기본 메서드 andThen을 정의합니다.

합성 경계에서 중간 결과의 null을 거절하고 다음 단계의 런타임 예외에 단계 이름을 붙입니다. 초기 입력 검증과 이전 단계의 실패가 어디까지 포장되는지도 구분합니다.

String 정규화, 정수 변환, 양수 검증을 연결해 실행합니다.

해답 보기
src/ValidatedMapperSolution.java
import java.util.Objects;

public final class ValidatedMapperSolution {
    @FunctionalInterface
    interface CheckedMapper<I, O> {
        O map(I input);

        default <N> CheckedMapper<I, N> andThen(
                String stage,
                CheckedMapper<? super O, ? extends N> next) {
            Objects.requireNonNull(stage);
            Objects.requireNonNull(next);
            return input -> {
                O intermediate = Objects.requireNonNull(map(input), "null before " + stage);
                try {
                    return Objects.requireNonNull(next.map(intermediate), "null from " + stage);
                } catch (RuntimeException exception) {
                    throw new PipelineException(stage, exception);
                }
            };
        }
    }

    static final class PipelineException extends RuntimeException {
        PipelineException(String stage, Throwable cause) {
            super("pipeline failed at " + stage + ": " + cause.getMessage(), cause);
        }
    }

    public static void main(String[] args) {
        CheckedMapper<String, String> normalize = value -> value.trim();
        CheckedMapper<String, Integer> parse = Integer::parseInt;
        CheckedMapper<Integer, Integer> positive = value -> {
            if (value <= 0) {
                throw new IllegalArgumentException("value is not positive");
            }
            return value;
        };
        CheckedMapper<String, Integer> pipeline = normalize
                .andThen("parse", parse)
                .andThen("positive", positive);

        System.out.println(pipeline.map(" 12 "));
        try {
            pipeline.map(" -3 ");
        } catch (PipelineException exception) {
            System.out.println(exception.getMessage());
        }
    }
}

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

현재 stage의 try에는 다음 mapper 호출과 null from 검사가 포함되어 RuntimeException을 현재 단계 이름으로 감쌉니다. 이전 mapper 호출과 null before 검사는 try 밖에 있으므로 실패를 그대로 전달합니다.

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

ValidatedMapperSolution.andThen의 검사 위치를 구분합니다.

try의 범위

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

이전 결과를 구하는 코드는 try 밖에 있습니다. 다음 매퍼의 RuntimeException과 null 결과만 현재 단계 이름으로 감쌉니다.

이전 평가와 다음 평가 사이의 try 경계현재 단계의 try 영역에는 next.map와 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5노드4관계와 영역 소속을 유지한다.현재 단계의 try이전 map(input)null before 검사다음 mapnull from 검사정상 결과앞 단계 실패그대로 전달PipelineException현재 stage 이름실패정상RuntimeException
이전 평가와 다음 평가 사이의 try 경계현재 단계의 try 영역에는 next.map와 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5노드4관계와 영역 소속을 유지한다.현재 단계의 try이전 map(input)null before 검사다음 mapnull from 검사정상 결과앞 단계 실패그대로 전달PipelineException현재 stage 이름실패정상RuntimeException

이전 map(input)의 실패

try 안인가
아니오
원문의 전달 방식
기존 예외를 그대로 전달

이전 결과가 null

try 안인가
아니오
원문의 전달 방식
null before 단계 이름의 null 검사 예외

next.map의 실행 실패

try 안인가
예
원문의 전달 방식
RuntimeException을 현재 단계의 PipelineException으로 포장

next 결과가 null

try 안인가
예
원문의 전달 방식
null from 단계 이름의 예외를 포장

main은 정상 12와 positive 단계의 -3만 호출합니다. 초기 null이나 앞 단계 실패를 넣은 main은 없습니다.

초기 null이나 이전 단계 실패는 이 단계의 포장 경계 밖입니다. Error를 포함한 모든 Throwable을 감싸는 구현도 아닙니다.

이 예의 조건

  • 현재 stage의 catch는 try 안의 RuntimeException만 포장합니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2현재 단계의 try 영역에는 next.map과 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5개 노드와 4개 관계와 영역 소속을 유지한다.
원 도표의 설명과 주석
구분내용
설명현재 단계의 try 영역에는 next.map과 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5개 노드와 4개 관계와 영역 소속을 유지한다.
전체 읽기 정보현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 ValidatedMapperSolution.andThen의 검사 위치를 구분합니다. try의 범위 현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 이전 결과를 구하는 코드는 try 밖에 있습니다. 다음 매퍼의 RuntimeException과 null 결과만 현재 단계 이름으로 감쌉니다. 이전 map(input)의 실패 try 안인가 아니오 원문의 전달 방식 기존 예외를 그대로 전달 이전 결과가 null try 안인가 아니오 원문의 전달 방식 null before 단계 이름의 null 검사 예외 next.map의 실행 실패 try 안인가 예 원문의 전달 방식 RuntimeException을 현재 단계의 PipelineException으로 포장 next 결과가 null try 안인가 예 원문의 전달 방식 null from 단계 이름의 예외를 포장 main은 정상 12와 positive 단계의 -3만 호출합니다. 초기 null이나 앞 단계 실패를 넣은 main은 없습니다. 초기 null이나 이전 단계 실패는 이 단계의 포장 경계 밖입니다. Error를 포함한 모든 Throwable을 감싸는 구현도 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
앞선 평가

이전 map(input)과 null before 검사를 try 밖에서 실행합니다.

  • 앞 실패: 그대로 전달
  • 현재 stage 포장: 없음
  • 앞 단계 실패를 현재 stage 이름으로 다시 포장하지 않습니다.
현재 try

next.map과 null from 검사를 try 안에서 실행합니다.

  • 정상: 현재 결과
  • RuntimeException: catch 진입
  • try 경계가 현재 stage의 귀속 범위를 정합니다.
반환과 포장

정상 결과를 반환하거나 PipelineException을 만듭니다.

  • 포장: 현재 stage 이름
  • Error: 전체 포장 계약 아님
  • 같은 실패라도 발생 위치에 따라 전달 형태가 다릅니다.

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

현재 stage의 try에는 다음 mapper 호출과 null from 검사가 포함되어 RuntimeException을 현재 단계 이름으로 감쌉니다. 이전 mapper 호출과 null before 검사는 try 밖에 있으므로 실패를 그대로 전달합니다.

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

ValidatedMapperSolution.andThen의 검사 위치를 구분합니다.

try의 범위

현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다

이전 결과를 구하는 코드는 try 밖에 있습니다. 다음 매퍼의 RuntimeException과 null 결과만 현재 단계 이름으로 감쌉니다.

이전 평가와 다음 평가 사이의 try 경계현재 단계의 try 영역에는 next.map와 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5노드4관계와 영역 소속을 유지한다.현재 단계의 try이전 map(input)null before 검사다음 mapnull from 검사정상 결과앞 단계 실패그대로 전달PipelineException현재 stage 이름실패정상RuntimeException
이전 평가와 다음 평가 사이의 try 경계현재 단계의 try 영역에는 next.map와 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5노드4관계와 영역 소속을 유지한다.현재 단계의 try이전 map(input)null before 검사다음 mapnull from 검사정상 결과앞 단계 실패그대로 전달PipelineException현재 stage 이름실패정상RuntimeException

이전 map(input)의 실패

try 안인가
아니오
원문의 전달 방식
기존 예외를 그대로 전달

이전 결과가 null

try 안인가
아니오
원문의 전달 방식
null before 단계 이름의 null 검사 예외

next.map의 실행 실패

try 안인가
예
원문의 전달 방식
RuntimeException을 현재 단계의 PipelineException으로 포장

next 결과가 null

try 안인가
예
원문의 전달 방식
null from 단계 이름의 예외를 포장

main은 정상 12와 positive 단계의 -3만 호출합니다. 초기 null이나 앞 단계 실패를 넣은 main은 없습니다.

초기 null이나 이전 단계 실패는 이 단계의 포장 경계 밖입니다. Error를 포함한 모든 Throwable을 감싸는 구현도 아닙니다.

이 예의 조건

  • 현재 stage의 catch는 try 안의 RuntimeException만 포장합니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2현재 단계의 try 영역에는 next.map과 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5개 노드와 4개 관계와 영역 소속을 유지한다.
원 도표의 설명과 주석
구분내용
설명현재 단계의 try 영역에는 next.map과 null from 검사가 있다. 이전 map(input)과 null before는 밖에 있어 그 실패를 현재 stage로 다시 포장하지 않는다. try 안의 RuntimeException만 catch하여 PipelineException을 만들고 정상 결과는 반환한다. Error 전체를 포장하는 계약은 아니다. 모바일도 같은 5개 노드와 4개 관계와 영역 소속을 유지한다.
전체 읽기 정보현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 ValidatedMapperSolution.andThen의 검사 위치를 구분합니다. try의 범위 현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다 이전 결과를 구하는 코드는 try 밖에 있습니다. 다음 매퍼의 RuntimeException과 null 결과만 현재 단계 이름으로 감쌉니다. 이전 map(input)의 실패 try 안인가 아니오 원문의 전달 방식 기존 예외를 그대로 전달 이전 결과가 null try 안인가 아니오 원문의 전달 방식 null before 단계 이름의 null 검사 예외 next.map의 실행 실패 try 안인가 예 원문의 전달 방식 RuntimeException을 현재 단계의 PipelineException으로 포장 next 결과가 null try 안인가 예 원문의 전달 방식 null from 단계 이름의 예외를 포장 main은 정상 12와 positive 단계의 -3만 호출합니다. 초기 null이나 앞 단계 실패를 넣은 main은 없습니다. 초기 null이나 이전 단계 실패는 이 단계의 포장 경계 밖입니다. Error를 포함한 모든 Throwable을 감싸는 구현도 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
앞선 평가

이전 map(input)과 null before 검사를 try 밖에서 실행합니다.

  • 앞 실패: 그대로 전달
  • 현재 stage 포장: 없음
  • 앞 단계 실패를 현재 stage 이름으로 다시 포장하지 않습니다.
현재 try

next.map과 null from 검사를 try 안에서 실행합니다.

  • 정상: 현재 결과
  • RuntimeException: catch 진입
  • try 경계가 현재 stage의 귀속 범위를 정합니다.
반환과 포장

정상 결과를 반환하거나 PipelineException을 만듭니다.

  • 포장: 현재 stage 이름
  • Error: 전체 포장 계약 아님
  • 같은 실패라도 발생 위치에 따라 전달 형태가 다릅니다.

초기 입력을 반드시 거절하는 계약이 필요하면 첫 매퍼 호출 전에 검사를 추가해야 합니다. 현재 코드가 모든 입력과 실패를 한 가지 진단 형식으로 처리한다고 가정하지 않습니다.

예시 "many"는 normalize에 성공한 뒤 parse의 next.map에서 NumberFormatException이 납니다. 안쪽 andThen("parse", parse)의 try 안이라 parse 단계의 PipelineException이 됩니다. 바깥 positive 조합에서 이 실패는 이전 map(input)을 구하는 try 밖 위치에서 들어오므로 positive 이름으로 다시 포장하지 않습니다.

독립 확인: 예제의 pipeline에 " 0 "과 null을 각각 넣습니다. positive 포장과 최초 입력의 실패 위치를 구별해 반환·예외를 설명하세요.

풀이 확인

" 0 "은 normalize·parse에 성공하고 positive가 IllegalArgumentException을 던져 positive 단계의 PipelineException이 됩니다. null은 첫 normalize의 trim에서 NullPointerException이 나며 두 합성의 이전 map(input) 평가 위치에 있으므로 단계 이름으로 포장되지 않습니다. 이전 결과 null 검사도 현재 try 밖이고, 다음 결과 null 검사는 try 안이라는 위치 차이를 예제 코드에서 확인하세요.

모든 예외를 같은 타입으로 감싸는 정책이 적절한지는 사용처에 따라 다르며, 이미 도메인 타입인 예외를 보존할 매퍼가 필요할 수도 있습니다.