함수형 인터페이스의 조건과 @FunctionalInterface
람다를 대입할 수 있는 인터페이스의 조건과 추상 메서드를 세는 규칙, @FunctionalInterface가 확인해 주는 것, 타입 매개변수로 입력과 출력 타입을 정하는 방법을 배웁니다.
앞의 글들에서 람다를 Predicate, Function, Comparator 같은 타입의 변수에 대입해 왔습니다. 그런데 람다를 아무 인터페이스에나 대입할 수 있는 것은 아닙니다. amount -> amount + " KRW"라는 람다에는 매개변수와 본문만 있고 메서드 이름이 없습니다. 그래서 컴파일러가 이 본문을 인터페이스의 어느 메서드 구현으로 삼을지 스스로 정해야 하는데, 그렇게 할 수 있는 것은 구현할 메서드가 딱 하나일 때뿐입니다.
이런 인터페이스를 함수형 인터페이스(functional interface)라고 부릅니다. 이 글에서는 추상 메서드를 어떻게 세는지, @FunctionalInterface 애노테이션이 무엇을 확인해 주는지, 그리고 함수형 인터페이스를 직접 만들 때 입력과 출력 타입을 어떻게 정하면 좋은지를 차례로 살펴봅니다.
람다가 구현할 메서드는 하나여야 합니다
금액을 문자열로 바꾸는 Formatter 인터페이스를 만들고, 서로 다른 형식 두 가지를 람다로 구현해 보겠습니다. Formatter에는 메서드가 format 하나뿐이고, 두 람다는 그 메서드의 본문만 다르게 채웁니다.
public final class PriceFormat {
interface Formatter {
String format(int amount);
}
public static void main(String[] args) {
Formatter plain = amount -> amount + " KRW";
Formatter thousands = amount -> (amount / 1000) + "K KRW";
System.out.println(plain.format(15000));
System.out.println(thousands.format(15000));
}
}두 람다는 같은 금액 15000을 받습니다. thousands의 amount / 1000은 정수 나눗셈이라서, 1000으로 나누어떨어지지 않는 금액이면 나머지를 버린다는 점도 기억해 두세요.
15000 KRW
15K KRW람다 어디에도 format이라는 이름은 나오지 않습니다. 컴파일러는 대입할 변수의 타입이 Formatter라는 것을 보고, Formatter에서 구현이 없는 메서드, 즉 추상 메서드를 찾아 람다 본문을 그 메서드의 구현으로 삼습니다. 그래서 plain.format(15000)을 호출하면 amount에 15000이 들어가 본문 amount + " KRW"가 실행됩니다. 둘째 줄은 15000을 1000으로 나눈 정수 15에 K KRW를 붙인 결과입니다.
이때 람다가 맞춰야 하는 것은 메서드 이름이 아니라 모양입니다. format은 int 하나를 받아 String을 돌려주므로, 람다도 매개변수 하나를 받아 문자열을 돌려줘야 합니다. 추상 메서드의 매개변수 타입과 반환 타입, 던질 수 있는 예외를 묶은 이 모양을 Java 언어 명세(JLS)에서는 함수 타입(function type)이라고 부릅니다. 매개변수 amount에 타입을 적지 않아도 되는 것도 함수 타입을 보면 int라는 것을 알 수 있기 때문입니다.
이제 Formatter에 통화 단위를 돌려주는 추상 메서드 String unit();을 하나 더 추가하고 다시 컴파일해 보겠습니다.
PriceFormat.java:9: error: incompatible types: Formatter is not a functional interface
Formatter plain = amount -> amount + " KRW";
^
multiple non-overriding abstract methods found in interface Formatter
PriceFormat.java:10: error: incompatible types: Formatter is not a functional interface
Formatter thousands = amount -> (amount / 1000) + "K KRW";
^
multiple non-overriding abstract methods found in interface Formatter
2 errors람다는 본문을 하나만 줄 수 있습니다. 추상 메서드가 두 개면 그 본문이 format의 구현인지 unit의 구현인지 정할 수 없고, 어느 쪽으로 정하든 나머지 하나는 구현 없이 남습니다. 그래서 컴파일러는 람다를 쓴 두 줄 모두에서 'Formatter는 함수형 인터페이스가 아니다'라는 오류를 냅니다. 추상 메서드가 정확히 하나인 인터페이스만 람다의 대상이 될 수 있고, 이것이 함수형 인터페이스의 핵심 조건입니다. 추상 메서드가 하나(single abstract method)라는 뜻에서 SAM 인터페이스라고도 부릅니다.
익명 클래스와 람다가 갈리는 지점도 여기입니다. 익명 클래스는 클래스 본문 안에 메서드를 몇 개든 적을 수 있으므로 추상 메서드가 둘인 Formatter도, 추상 클래스도 구현할 수 있습니다. 반면 람다의 대상은 함수형 인터페이스뿐이어서, 추상 메서드가 하나뿐인 추상 클래스라도 람다로는 만들 수 없습니다.
public final class AbstractTarget {
abstract static class Task {
abstract void run();
}
public static void main(String[] args) {
Task task = () -> System.out.println("run");
}
}AbstractTarget.java:7: error: incompatible types: Task is not a functional interface
Task task = () -> System.out.println("run");
^
1 errorTask는 추상 메서드가 run 하나뿐이지만 인터페이스가 아니므로, 컴파일러는 함수형 인터페이스가 아니라고 거절합니다. 이런 타입은 익명 클래스로 구현하거나, 바꿀 수 있다면 인터페이스로 선언해야 람다를 쓸 수 있습니다.
추상 메서드를 세는 규칙
'추상 메서드가 하나'라는 조건은 생각보다 너그럽습니다. JDK의 Comparator 선언을 줄여서 보면 추상 메서드가 두 개 있습니다.
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
// 이 밖에 reversed, thenComparing 같은 default 메서드와
// comparing, naturalOrder 같은 static 메서드가 있습니다.
}그런데도 (a, b) -> Integer.compare(a.length(), b.length()) 같은 람다를 Comparator<String>에 대입할 수 있습니다. equals(Object)는 모든 클래스의 조상인 Object에 이미 public 메서드로 있기 때문입니다. 람다로 만든 객체를 포함해 이 인터페이스를 구현하는 객체라면 어떤 것이든 Object에서 equals를 물려받으므로, 이 선언은 구현할 일을 늘리지 않습니다. 그래서 개수를 셀 때 빠집니다. Comparator가 이 메서드를 굳이 다시 선언한 것은 Javadoc에 equals의 의미를 설명해 두기 위해서입니다.
정리하면 개수에 들어가는 것은 '구현하는 객체가 새로 채워야 하는 추상 메서드'뿐입니다. 다음 예제의 Converter에는 메서드가 네 개 있지만 이 기준으로 세면 하나입니다.
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"));
}
}main은 메서드 참조 세 개를 andThen으로 이어 문자열을 UserId로 바꾸고, 정적 메서드 identity()가 돌려준 변환기도 한 번 써 봅니다. 첫 줄은 " 42 "가 공백 제거, long 변환, UserId 생성을 차례로 거친 결과입니다.
UserId[value=42]
same네 멤버를 하나씩 분류하면 다음과 같습니다.
FunctionalInterfaceRules.Converter의 네 멤버를 분류합니다.
하나의 추상 행동
선언된 메서드 수와 람다가 구현할 추상 행동 수를 구분한다
Converter에는 네 멤버가 있지만 람다가 구현하는 행동은 convert 하나입니다.
| 멤버 | 선언의 역할 | 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 메서드는 추상 행동을 추가하지 않습니다.
| 멤버 | 종류 | 개수에 들어가는가 | 이유 |
|---|---|---|---|
convert(T) | 추상 메서드 | 들어감 | 람다 본문이 이 메서드의 구현이 됩니다 |
equals(Object) | 추상 메서드 선언 | 빠짐 | Object의 public 메서드와 같아서 구현 객체가 이미 갖고 있습니다 |
andThen(next) | default 메서드 | 빠짐 | 본문이 있으므로 추상 메서드가 아닙니다 |
identity() | static 메서드 | 빠짐 | 인터페이스 이름으로 호출하는 도우미이며 구현 객체와 상관없습니다 |
출력의 첫 줄은 세 변환을 이어 붙인 결과입니다. trim, parse, wrap은 모두 메서드 참조로 만든 Converter이고, 메서드 참조도 람다와 마찬가지로 추상 메서드 convert 하나를 구현합니다. trim.andThen(parse)는 'trim을 먼저 하고 그 결과를 parse에 넘기는' 새 Converter를 만들고, 거기에 다시 andThen(wrap)을 붙였습니다. 그래서 " 42 "는 "42", 42L, UserId[value=42] 순서로 바뀝니다.
andThen의 본문 value -> next.convert(convert(value))에서 안쪽의 convert(value)는 andThen을 호출한 객체 자신의 convert입니다. default 메서드는 이렇게 추상 메서드를 이용해 기능을 쌓기 때문에, 메서드가 여러 개 늘어나도 람다가 구현할 것은 여전히 하나입니다. 맨 앞의 Objects.requireNonNull(next)는 null을 넘긴 실수를 변환을 실행할 때가 아니라 조합하는 순간에 알려 줍니다. JDK의 Function.andThen도 인수가 null이면 바로 NullPointerException을 던집니다.
둘째 줄은 static 메서드 identity()가 만든 Converter, 즉 받은 값을 그대로 돌려주는 변환의 결과입니다. Converter.<String>identity()처럼 메서드 이름 앞에 타입 인수를 직접 적은 이유는, 호출 결과에 바로 .convert(...)를 이어 붙이면 컴파일러가 T를 정할 단서가 없기 때문입니다. 적지 않으면 T는 Object가 됩니다. 이 예제에서는 출력이 같지만 convert의 결과 타입도 Object가 됩니다.
더 깊이: clone, sealed, 두 부모에게서 물려받은 메서드
Object의 메서드라고 모두 빠지는 것은 아닙니다. 빠지는 것은 public 메서드뿐인데 Object.clone()은 protected입니다. 그래서 인터페이스에 Object clone();을 선언하면 public 추상 메서드가 새로 생기고, 다른 추상 메서드와 합쳐 두 개로 셉니다.
@FunctionalInterface
interface Foo {
int m();
Object clone();
}Foo.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
Foo is not a functional interface
multiple non-overriding abstract methods found in interface Foo
1 errorsealed로 선언한 인터페이스는 추상 메서드가 하나뿐이어도 함수형 인터페이스가 아닙니다. sealed 인터페이스는 허용한 클래스, 즉 permits에 적었거나 같은 파일에 선언한 클래스만 구현할 수 있다는 선언인데, 람다는 그 목록에 없는 객체를 새로 만들기 때문입니다. 예를 들어 sealed interface Shape permits Circle로 선언한 인터페이스에 Shape s = () -> 1.0;처럼 람다를 대입하면 Shape is not a functional interface라는 오류가 납니다.
두 부모 인터페이스에서 시그니처가 같은 추상 메서드를 물려받으면 하나로 합쳐집니다. 아래 Z는 X와 Y에서 m(Iterable<String>)을 하나씩 물려받지만 구현할 메서드는 하나뿐이므로 함수형 인터페이스이고, Z z = arg -> 1;이 컴파일됩니다. 이름이나 매개변수 타입이 다르면 추상 메서드가 둘이 되어 함수형 인터페이스가 아닙니다.
interface X { int m(Iterable<String> arg); }
interface Y { int m(Iterable<String> arg); }
interface Z extends X, Y { }시그니처는 같은데 반환 타입이 서로 맞지 않으면, 예를 들어 X의 m은 int를 Y의 m은 long을 돌려주면, 람다를 쓰기 전에 Z의 선언부터 types Y and X are incompatible 오류가 납니다. 이런 규칙은 JLS §9.8에 정리되어 있지만, 직접 만드는 함수형 인터페이스라면 상속을 복잡하게 엮기보다 추상 메서드를 하나 직접 선언하는 편이 읽는 사람에게 훨씬 분명합니다.
@FunctionalInterface가 확인해 주는 것
앞의 PriceFormat에 있던 Formatter에는 애노테이션이 없었지만 람다를 문제없이 받았습니다. 컴파일러는 애노테이션이 있든 없든 조건을 만족하는 인터페이스를 모두 함수형 인터페이스로 취급하기 때문입니다(FunctionalInterface Javadoc). 즉 @FunctionalInterface는 인터페이스를 함수형으로 만들어 주는 장치가 아닙니다. '이 인터페이스는 함수형이어야 한다'는 의도를 적어 두고, 컴파일러에게 그 조건을 검사하게 하는 표시입니다.
이 검사가 쓸모 있는 순간은 누군가 인터페이스를 고칠 때입니다. 다음은 Converter에 기본값을 받는 추상 메서드 convertOrDefault를 추가한 인터페이스 파일입니다. 이 파일 하나만 컴파일해도, 즉 이 인터페이스를 람다로 구현하는 코드가 아직 하나도 없어도 오류가 납니다.
@FunctionalInterface
interface Converter<T, R> {
R convert(T value);
R convertOrDefault(T value, R fallback);
}Converter.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
Converter is not a functional interface
multiple non-overriding abstract methods found in interface Converter
1 error애노테이션이 없다면 이 파일은 아무 오류 없이 컴파일됩니다. 대신 이 인터페이스에 람다나 메서드 참조를 대입한 모든 곳에서 앞의 PriceFormat처럼 오류가 납니다. 인터페이스를 고친 사람과 오류를 보는 사람이 다를 수 있고, 인터페이스가 라이브러리에 있다면 라이브러리는 정상적으로 빌드된 뒤 그것을 가져다 쓰는 코드에서야 문제가 드러납니다. 그래서 람다로 구현하라고 만든 인터페이스, 특히 다른 사람이 쓸 인터페이스에는 애노테이션을 붙이는 것이 좋습니다. JDK의 java.util.function 패키지에 있는 인터페이스 43개에도 모두 @FunctionalInterface가 붙어 있습니다.
인터페이스에 기능을 더하고 싶을 때는 그 기능을 기존 추상 메서드만으로 만들 수 있는지부터 봅니다. andThen처럼 만들 수 있다면 default 메서드로 추가하고, 구현마다 따로 채워야 하는 일이라면 별도의 인터페이스로 나눕니다.
Object 대신 타입 매개변수로 입력과 출력을 정하기
추상 메서드가 하나라는 조건만 맞추면 함수형 인터페이스는 되지만, 그것만으로 쓰기 좋은 인터페이스가 되지는 않습니다. 다음 Action은 무엇이든 받아 무엇이든 돌려주므로 어떤 변환이든 담을 수 있습니다. 대신 컴파일러는 각 Action이 실제로 무엇을 받는지 알 수 없습니다.
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;
System.out.println(length.apply("lambda"));
System.out.println(doubleNumber.apply(length.apply("lambda")));
System.out.println(length.apply(doubleNumber.apply(21)));
}
}세 println은 차례로 length, doubleNumber, length의 결과를 찍습니다. 앞 호출의 결과를 다음 호출의 인수로 넘길 때, 그 값이 실제로 어떤 타입인지 따라가며 읽어 보세요.
6
12
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at RawObjectAction.lambda$main$0(RawObjectAction.java:8)
at RawObjectAction.main(RawObjectAction.java:13)처음 두 줄은 의도대로 나옵니다. 문제는 셋째 줄입니다. doubleNumber가 돌려준 Integer 42를 문자열을 기대하는 length에 넘겼는데, 두 람다 모두 Object를 받으므로 컴파일러는 이 연결을 막을 수 없습니다. 결국 8번 줄 length 람다 안의 (String) value 형변환이 실행 중에 ClassCastException을 던집니다.
스택 트레이스의 lambda$main$0은 컴파일러가 이 람다 본문을 담으려고 만든 메서드의 이름입니다. 이처럼 형변환을 람다 안에 숨기면 타입 실수가 컴파일을 그대로 통과하고, 실행해서 그 줄에 도달해야 드러납니다.
고치는 방법은 입력 타입과 출력 타입을 타입 매개변수로 드러내는 것입니다. 앞에서 본 Converter<T, R>이 그렇게 만든 인터페이스입니다. Converter<String, Long>은 'String을 받아 Long을 돌려준다'는 뜻이므로, 컴파일러가 두 변환을 이을 때마다 앞 단계의 출력 타입과 다음 단계의 입력 타입을 비교할 수 있습니다. FunctionalInterfaceRules의 main 끝에 parse를 빼먹고 trim 뒤에 바로 wrap을 붙이는 줄을 추가하면 컴파일 오류가 납니다.
Converter<String, UserId> broken = trim.andThen(wrap);FunctionalInterfaceRules.java:32: error: method andThen in interface Converter<T,R> cannot be applied to given types;
Converter<String, UserId> broken = trim.andThen(wrap);
^
required: Converter<? super String,? extends V>
found: Converter<Long,UserId>
reason: cannot infer type-variable(s) V
(argument mismatch; Converter<Long,UserId> cannot be converted to Converter<? super String,? extends V>)
where V,R,T are type-variables:
V extends Object declared in method <V>andThen(Converter<? super R,? extends V>)
R extends Object declared in interface Converter
T extends Object declared in interface Converter
1 error메시지가 길지만 핵심은 required와 found 두 줄입니다. andThen은 String이나 그 상위 타입을 받는 Converter를 요구하는데, 넘어온 wrap은 Long을 받는 Converter<Long,UserId>입니다. Action 버전에서는 실행 중 예외로 나타났던 실수가 여기서는 프로그램을 실행하기 전에 잡힙니다.
타입 매개변수를 몇 개 두느냐도 의미가 있습니다. 같은 T를 입력과 출력에 함께 쓰면 '입력과 출력의 타입이 같다'는 제약이 생깁니다. JDK의 UnaryOperator<T>가 그런 예입니다. 문자열을 다듬어 문자열을 돌려주는 String::trim에는 잘 맞지만, String을 Integer로 바꾸는 변환은 담을 수 없습니다. 입력과 출력이 다를 수 있는 변환이라면 Function<T, R>처럼 타입 매개변수를 둘로 나눠야 합니다.
타입 인수로는 int 같은 기본형을 쓸 수 없다는 점도 기억해 둡니다. 그래서 Converter<String, Long>의 결과는 long 값이 아니라 Long 객체로 박싱됩니다. 숫자를 대량으로 처리하는 코드에서 이 비용이 실제로 문제가 될 때는 ToIntFunction, IntFunction 같은 기본형 특화 함수형 인터페이스를 씁니다. 처음에는 의미가 분명한 제네릭 버전으로 작성하고, 측정해서 느린 곳에만 특화 버전을 적용하는 순서가 안전합니다.
함수를 받는 메서드는 와일드카드로 받기
함수형 인터페이스를 매개변수로 받는 메서드를 만들 때도 타입을 어떻게 적느냐에 따라 받을 수 있는 함수의 범위가 달라집니다. 다음 mapAll은 목록의 원소마다 Transformer를 적용해 새 목록을 만듭니다.
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);
}
interface Token {
}
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);
}
}두 번째 호출은 람다를 바로 넘기면서 value의 타입을 적지 않았지만, 컴파일러가 목록의 원소 타입을 보고 String으로 정합니다. 두 결과 모두 List<Token> 변수에 담깁니다.
[Word[value=java], Word[value=lambda]]
[NumberToken[value=3], NumberToken[value=7]]첫 번째 호출은 타입이 정확히 맞지 않습니다. 목록의 원소는 String인데 word는 CharSequence를 받고, 결과를 담을 변수는 List<Token>인데 word는 Word를 돌려줍니다. 그래도 안전합니다. 어떤 CharSequence든 받는 함수라면 String도 받을 수 있고, Word는 Token의 한 종류이기 때문입니다.
Transformer<? super I, ? extends O>가 바로 이 관계를 적은 것입니다. ? super I는 'I이거나 I의 상위 타입을 받는 함수'를, ? extends O는 'O이거나 O의 하위 타입을 돌려주는 함수'를 뜻합니다. 두 번째 호출은 람다를 바로 넘겼으므로 컴파일러가 value를 String으로 정해 줍니다. 결과는 List.copyOf로 만든 수정할 수 없는 새 목록이고, 원래 목록은 바뀌지 않습니다.
와일드카드를 빼고 Transformer<I, O> transformer로 선언하면 첫 번째 호출이 컴파일되지 않습니다.
GenericTransformer.java:29: error: incompatible types: inference variable O has incompatible equality constraints Token,Word
List<Token> words = mapAll(List.of(" java ", " lambda "), word);
^
where O,I are type-variables:
O extends Object declared in method <I,O>mapAll(List<I>,Transformer<I,O>)
I extends Object declared in method <I,O>mapAll(List<I>,Transformer<I,O>)
1 error결과를 List<Token>에 담으려면 O가 Token이어야 하고, word를 그대로 받으려면 O가 Word여야 합니다. 와일드카드가 없으면 O는 정확히 한 타입이어야 하므로 두 조건을 동시에 만족할 수 없습니다. 다만 와일드카드는 시그니처를 읽기 어렵게 만들기도 하므로, 다른 사람이 만든 함수를 넘겨받는 공개 메서드처럼 실제로 유연함이 필요한 곳에 씁니다. 이 규칙은 다음 글에서 자세히 다루고, 와일드카드의 기본은 와일드카드와 PECS에서 다뤘습니다.
표준 인터페이스를 쓸까, 직접 만들까
이 글의 Converter와 Transformer는 사실 JDK의 Function<T, R>과 모양이 같습니다. 실무에서 단순한 변환이라면 Function을 쓰는 편이 낫습니다. Stream.map, Optional.map처럼 Function을 받는 API에 바로 넘길 수 있고, andThen과 compose도 이미 들어 있으며, 읽는 사람도 따로 설명을 찾을 필요가 없습니다. 어떤 표준 인터페이스가 있는지는 표준 함수형 인터페이스와 함수 조합에서 정리합니다.
직접 만드는 편이 나은 때는 표준 인터페이스로는 표현할 수 없는 것이 있을 때입니다. 예를 들어 Function.apply에는 throws 절이 없어서, 람다 문법을 다룬 글에서 본 것처럼 검사 예외를 던지는 변환은 throws를 선언한 인터페이스를 따로 만들어야 합니다. 단계 이름처럼 함께 다닐 정보가 있거나 이름 자체가 도메인의 의미를 전달해야 할 때도 직접 만듭니다. 이때 Processor나 Handler처럼 무엇이든 될 수 있는 이름보다, 무엇을 받아 무엇을 하는지 드러나는 이름을 붙여야 합니다.
정리
- 람다는 메서드 이름 없이 본문만 주므로, 추상 메서드가 정확히 하나인 인터페이스, 즉 함수형 인터페이스에만 대입할 수 있습니다. 추상 클래스는 추상 메서드가 하나여도 람다의 대상이 아닙니다.
- 개수를 셀 때 default 메서드, static 메서드,
Object의 public 메서드와 같은 선언은 빠집니다.Object의 protected 메서드인clone()은 빠지지 않으며,sealed인터페이스는 함수형 인터페이스가 될 수 없습니다. @FunctionalInterface는 없어도 람다를 받을 수 있지만, 붙여 두면 조건이 깨지는 순간 인터페이스 선언에서 바로 오류가 납니다.Object를 주고받는 대신 타입 매개변수로 입력과 출력을 나누면 잘못된 연결이 컴파일 오류가 됩니다. 함수를 받는 메서드에는? super와? extends를 붙여 더 많은 함수를 받을 수 있습니다.
연습 문제
여러 변환 단계를 이어 붙이되, 실패했을 때 어느 단계에서 실패했는지 알 수 있는 매퍼를 만들어 보겠습니다.
- 추상 메서드
O map(I input)하나를 가진 함수형 인터페이스StageMapper<I, O>를 선언하세요. - 단계 이름과 다음 매퍼를 받는 default 메서드
andThen(String stage, StageMapper<...> next)를 추가하세요. 이 메서드가 만든 매퍼는 앞 단계의 결과가null이면 거절하고, 다음 단계가RuntimeException을 던지거나null을 돌려주면 단계 이름을 붙인PipelineException으로 감싸야 합니다. - 공백 제거, 정수 변환, 양수 검사를 연결하고
" 12 "와" -3 "을 넣어 실행하세요.
해답 보기
import java.util.Objects;
public final class ValidatedMapperSolution {
@FunctionalInterface
interface StageMapper<I, O> {
O map(I input);
default <N> StageMapper<I, N> andThen(
String stage,
StageMapper<? 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) {
StageMapper<String, String> normalize = value -> value.trim();
StageMapper<String, Integer> parse = Integer::parseInt;
StageMapper<Integer, Integer> positive = value -> {
if (value <= 0) {
throw new IllegalArgumentException("value is not positive");
}
return value;
};
StageMapper<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());
}
}
}실행 결과:
12
pipeline failed at positive: value is not positiveStageMapper는 추상 메서드가 map 하나뿐이고 andThen은 본문이 있는 default 메서드이므로 함수형 인터페이스입니다. pipeline은 normalize.andThen("parse", parse)가 만든 매퍼에 다시 andThen("positive", positive)를 붙인 것이어서, 두 겹으로 감싼 구조입니다. " 12 "는 "12", 12를 거쳐 양수 검사를 통과하므로 12가 출력됩니다. " -3 "은 정수 변환까지 성공한 뒤 positive가 IllegalArgumentException을 던지고, 바깥쪽 andThen의 try 안에서 일어난 일이므로 positive라는 이름이 붙은 PipelineException이 됩니다.
여기서 눈여겨볼 것은 try 블록이 감싸는 범위입니다. 앞 단계를 실행하는 map(input)과 null before 검사는 try 밖에 있고, 다음 단계를 실행하는 next.map(...)과 null from 검사만 try 안에 있습니다. 그래서 현재 단계의 이름은 다음 단계에서 일어난 실패에만 붙습니다.
현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다
현재 stage의 try에는 다음 mapper 호출과 null from 검사가 포함되어 RuntimeException을 현재 단계 이름으로 감쌉니다. 이전 mapper 호출과 null before 검사는 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을 감싸는 구현도 아닙니다.
이 예의 조건
- 현재 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 밖에서 실행합니다. |
|
|
| 현재 try next.map과 null from 검사를 try 안에서 실행합니다. |
|
|
| 반환과 포장 정상 결과를 반환하거나 PipelineException을 만듭니다. |
|
|
현재 단계의 try 안에 들어온 실패만 단계 이름으로 감싼다
현재 stage의 try에는 다음 mapper 호출과 null from 검사가 포함되어 RuntimeException을 현재 단계 이름으로 감쌉니다. 이전 mapper 호출과 null before 검사는 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을 감싸는 구현도 아닙니다.
이 예의 조건
- 현재 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 밖에서 실행합니다. |
|
|
| 현재 try next.map과 null from 검사를 try 안에서 실행합니다. |
|
|
| 반환과 포장 정상 결과를 반환하거나 PipelineException을 만듭니다. |
|
|
이 구조를 이해했는지 확인하려면 main의 입력을 바꿔 보세요. 실제로 실행하면 다음과 같이 나옵니다.
"many": 안쪽andThen("parse", parse)의try안에서Integer.parseInt가NumberFormatException을 던지므로pipeline failed at parse: For input string: "many"가 됩니다. 이 예외는 바깥쪽 매퍼 입장에서는try밖의map(input)에서 올라온 것이라positive이름으로 한 번 더 감싸지지 않습니다." 0 ": 변환은 성공하고positive에서 실패하므로-3과 같은pipeline failed at positive: value is not positive가 됩니다.null: 첫 단계normalize의value.trim()에서NullPointerException이 납니다. 첫 단계는 두andThen모두에서try밖에 있으므로 단계 이름 없이NullPointerException이 그대로 전달되고, 예제의catch는PipelineException만 잡으므로 프로그램이 이 예외로 끝납니다. 첫 입력도 검사해야 한다면 첫 매퍼를 호출하기 전에 따로 검사를 넣어야 합니다.
catch가 잡는 것은 RuntimeException뿐이라는 점도 기억해 두세요. StackOverflowError 같은 Error는 감싸지 않고 그대로 전달됩니다. 모든 예외를 PipelineException 하나로 감싸는 방식이 늘 맞는 것도 아닙니다. 호출하는 쪽이 이미 도메인 예외를 구분해 처리하고 있다면, 그 예외는 감싸지 않고 그대로 통과시키는 매퍼가 필요할 수도 있습니다.
다음 글
다음 글에서는 mapAll에서 잠깐 본 ? super와 ? extends를 함수 매개변수의 관점에서 자세히 살펴봅니다. 왜 Function<Animal, String>을 Function<Dog, String> 자리에 그대로 쓸 수 없는지, 그리고 컴파일러가 제네릭 메서드의 타입 인수를 어떻게 추론하는지도 함께 다룹니다.