표준 함수형 인터페이스와 함수 조합
java.util.function의 Function·Consumer·Supplier·Predicate·Operator를 받는 값과 돌려주는 값으로 구분하고, andThen·compose·and 같은 조합 메서드와 Supplier의 지연 실행을 예제로 익힙니다.
앞 글들에서는 함수형 인터페이스를 직접 선언하기도 하고, Predicate나 Function처럼 JDK가 준비해 둔 인터페이스를 쓰기도 했습니다. 코드를 쓰다 보면 '값 하나를 받아 다른 값을 돌려준다', '값 하나를 받아 참인지 거짓인지 판단한다' 같은 모양이 계속 되풀이되는데, 그때마다 인터페이스를 새로 만들면 모양은 같고 이름만 다른 타입이 늘어납니다. 게다가 JDK의 컬렉션 메서드나 다른 라이브러리는 그렇게 만든 타입을 받아 주지 않습니다. 그래서 JDK는 Java 8부터 자주 쓰는 함수 모양 43가지를 java.util.function 패키지에 표준 함수형 인터페이스로 모아 두었습니다.
이 글에서는 그 가운데 뼈대가 되는 여섯 가지, Function, Consumer, Supplier, Predicate, UnaryOperator, BinaryOperator를 다룹니다. 먼저 각 인터페이스가 무엇을 받아 무엇을 돌려주는지 정리하고, 함께 들어 있는 andThen, compose, and 같은 조합 메서드가 어떤 순서로 실행되는지 출력으로 확인합니다. 그다음 Supplier로 계산 시점을 미루는 방법과, 표준 타입만으로는 정보가 모자랄 때 보완하는 방법을 살펴봅니다. 인수가 둘인 함수와 int 같은 기본형을 위한 인터페이스는 다음 글에서 이어 갑니다.
받는 값과 돌려주는 값으로 구분하기
표준 함수형 인터페이스는 이름을 외우기보다 '무엇을 받아서 무엇을 돌려주는가'로 읽는 편이 쉽습니다. 아래 여섯 개가 기본 모양이고, 패키지의 나머지 인터페이스는 대부분 이 모양에서 인수 개수나 기본형 타입을 바꾼 변형입니다.
| 인터페이스 | 추상 메서드 | 받는 값 → 돌려주는 값 | 하는 일 |
|---|---|---|---|
Function<T, R> | R apply(T t) | T → R | 값을 다른 값으로 바꾸기 |
Consumer<T> | void accept(T t) | T → 없음 | 값을 받아 출력·저장 같은 작업 하기 |
Supplier<T> | T get() | 없음 → T | 요청받을 때 값을 만들어 주기 |
Predicate<T> | boolean test(T t) | T → boolean | 조건 판단하기 |
UnaryOperator<T> | T apply(T t) | T → T | 같은 타입 안에서 값 바꾸기 |
BinaryOperator<T> | T apply(T t1, T t2) | T, T → T | 같은 타입의 두 값을 하나로 합치기 |
마지막 두 줄은 따로 만든 모양이 아닙니다. UnaryOperator<T>는 Function<T, T>를 상속하고, BinaryOperator<T>는 인수가 둘인 BiFunction<T, T, T>를 상속한 인터페이스여서 추상 메서드도 물려받은 apply 하나뿐입니다. 입력과 결과의 타입이 같다는 사실을 이름으로 드러내려고 둔 특수한 경우라고 보면 됩니다. 반대로 Consumer는 결과가 없습니다. 돌려주는 값이 없으니 하는 일은 출력, 저장, 목록에 추가하기처럼 바깥 상태를 바꾸는 부수 효과(side effect)일 수밖에 없고, JDK 문서도 Consumer는 부수 효과로 동작하는 인터페이스라고 설명합니다.
이 모양들이 쓸모 있는 이유는 JDK가 이미 이 타입들을 매개변수로 받기 때문입니다. 다음 코드는 스트림 없이 컬렉션과 Objects의 메서드만으로 다섯 가지 인터페이스를 모두 사용합니다. 주석은 각 람다나 메서드 참조가 어떤 인터페이스의 구현이 되는지 적은 것입니다.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class StandardShapesInJdk {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of(" kim ", "lee", " ", "park "));
names.replaceAll(String::trim); // UnaryOperator<String>
names.removeIf(String::isEmpty); // Predicate<String>
names.forEach(name -> System.out.println("hello " + name)); // Consumer<String>
Map<String, Integer> lengths = new HashMap<>();
lengths.computeIfAbsent("lambda", String::length); // Function<String, Integer>
System.out.println(lengths);
String nickname = null;
System.out.println(Objects.requireNonNullElseGet(nickname, () -> "guest")); // Supplier<String>
}
}replaceAll과 removeIf는 목록을 바꾸기만 하고 아무것도 찍지 않습니다. 출력의 처음 세 줄은 forEach가, 넷째 줄은 맵을 출력한 println이, 마지막 줄은 requireNonNullElseGet의 결과를 찍은 것입니다.
hello kim
hello lee
hello park
{lambda=6}
guestreplaceAll은 각 원소를 같은 타입의 새 값으로 바꾸므로 UnaryOperator<E>를 받습니다. 그래서 " kim "은 "kim"이 되고, 공백뿐인 " "은 빈 문자열이 됩니다. removeIf는 Predicate<? super E>를 받아 true가 나온 원소를 지우므로 빈 문자열이 빠지고, forEach는 Consumer<? super T>를 받아 남은 세 이름을 출력합니다. computeIfAbsent는 키가 없을 때만 Function으로 값을 계산해 넣으므로 "lambda"의 길이 6이 저장되고, requireNonNullElseGet은 첫 인수가 null일 때만 Supplier를 호출해 "guest"를 돌려줍니다.
뒤에서 배울 스트림과 Optional도 같은 인터페이스들을 매개변수로 받습니다. 그래서 함수를 표준 타입으로 만들어 두면 이런 API에 바로 넘길 수 있고, 시그니처만 봐도 다른 개발자가 어떤 함수인지 짐작할 수 있습니다.
같은 모양의 인터페이스를 따로 만들면 생기는 일
다음 예제는 '값 하나를 받아 값 하나를 돌려준다'는 같은 모양을 Parser, Formatter, Mapper라는 세 인터페이스로 따로 선언했습니다. 문자열 " 7 "의 공백을 지우고, 정수로 바꾸고, 앞에 #을 붙이는 세 단계입니다.
public final class DuplicateFunctionalTypes {
@FunctionalInterface
interface Parser<T, R> {
R parse(T value);
}
@FunctionalInterface
interface Formatter<T, R> {
R format(T value);
}
@FunctionalInterface
interface Mapper<T, R> {
R map(T value);
}
public static void main(String[] args) {
Parser<String, Integer> parser = Integer::parseInt;
Formatter<Integer, String> formatter = value -> "#" + value;
Mapper<String, String> mapper = String::trim;
System.out.println(formatter.format(parser.parse(mapper.map(" 7 "))));
}
}세 인터페이스는 이름만 다를 뿐, 모두 값 하나를 받아 값 하나를 돌려주는 메서드 하나를 가집니다. 실행하면 공백을 지운 "7"이 정수 7이 되고, 그 앞에 #이 붙습니다.
#7결과는 맞지만 읽기가 불편합니다. 메서드 이름이 parse, format, map으로 제각각이고, 마지막 줄은 가장 안쪽 괄호부터 거꾸로 읽어야 실행 순서가 보입니다. 단계를 이어 붙이는 andThen 같은 메서드도 없어서, 필요하면 인터페이스마다 직접 만들어야 합니다.
더 큰 문제는 이 객체들을 표준 타입이 필요한 곳에 넘길 수 없다는 것입니다. 모양이 똑같으니 Mapper를 Function 변수에 그대로 담을 수 있을 것 같지만, 컴파일러는 거부합니다.
import java.util.function.Function;
public final class NominalTypes {
@FunctionalInterface
interface Mapper<T, R> {
R map(T value);
}
public static void main(String[] args) {
Mapper<String, String> mapper = String::trim;
Function<String, String> function = mapper;
System.out.println(function.apply(" 7 "));
}
}NominalTypes.java:11: error: incompatible types: Mapper<String,String> cannot be converted to Function<String,String>
Function<String, String> function = mapper;
^
1 errorJava는 함수형 인터페이스를 모양이 아니라 이름으로 구별합니다. 메서드 참조 String::trim은 앞 글에서 본 대로 쓰인 자리의 대상 타입에 따라 Mapper도 될 수 있고 Function도 될 수 있습니다. 하지만 일단 Mapper 객체로 만들어진 뒤에는 그냥 Mapper일 뿐이고, Mapper와 Function 사이에는 상속 관계가 없으므로 대입할 수 없습니다. 꼭 연결해야 한다면 mapper::map처럼 메서드 참조를 써서 mapper.map을 호출하는 새 Function을 만들어야 합니다.
같은 일을 처음부터 Function으로 쓰면 이런 문제가 사라집니다. 아래 코드의 뒷부분은 mapper::map으로 만든 Function이 표준 조합 메서드에 그대로 참여하는 모습도 보여 줍니다.
import java.util.function.Function;
public final class StandardFunctionChain {
@FunctionalInterface
interface Mapper<T, R> {
R map(T value);
}
public static void main(String[] args) {
Function<String, String> trim = String::trim;
Function<String, Integer> parse = Integer::parseInt;
Function<Integer, String> format = value -> "#" + value;
System.out.println(trim.andThen(parse).andThen(format).apply(" 7 "));
Mapper<String, String> mapper = String::trim;
Function<String, String> bridged = mapper::map;
System.out.println(bridged.andThen(parse).apply(" 8 "));
}
}mapper::map은 이미 있는 Mapper 객체의 map 메서드를 가리키는 메서드 참조입니다. 모양이 같으므로 Function의 구현으로 쓸 수 있고, 그러면 andThen 같은 표준 조합 메서드에도 참여할 수 있습니다.
#7
8andThen은 앞 함수의 결과를 다음 함수에 넘기므로, 코드를 왼쪽에서 오른쪽으로 읽는 순서가 곧 실행 순서입니다. 첫 줄은 공백 제거, 정수 변환, # 붙이기를 차례로 거친 결과이고, 둘째 줄은 bridged가 공백을 지운 "8"을 parse가 정수 8로 바꾼 결과입니다.
직접 인터페이스를 만드는 편이 나은 경우
그렇다고 언제나 표준 타입이 답은 아닙니다. 표준 타입으로 바꾸면 꼭 필요한 정보가 사라지는 경우가 있는데, 대표적인 것이 다음 세 가지입니다.
첫째, 검사 예외(checked exception)를 던져야 할 때입니다. java.util.function의 추상 메서드는 어떤 검사 예외도 선언하지 않으므로, 파일을 읽는 Files.readString처럼 IOException을 던지는 코드를 Function의 람다 본문에 그대로 넣을 수 없습니다.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;
public final class CheckedLambda {
public static void main(String[] args) {
Function<Path, String> read = path -> Files.readString(path);
}
}CheckedLambda.java:7: error: unreported exception IOException; must be caught or declared to be thrown
Function<Path, String> read = path -> Files.readString(path);
^
1 error이럴 때는 String load(Path path) throws IOException처럼 예외를 선언한 인터페이스를 직접 만들거나, 람다 안에서 예외를 잡아 처리합니다. 자세한 방법은 람다에서 검사 예외를 다루는 방법에서 다뤘습니다.
둘째, 타입만으로 의미가 드러나지 않을 때입니다. Consumer<Order>라는 타입만 봐서는 주문을 감사 로그에 남기는지, 저장하는지, 메시지로 발행하는지 알 수 없습니다. onOrderSaved(Consumer<Order> listener)처럼 메서드 이름과 매개변수 이름으로 충분히 설명된다면 표준 타입을 써도 됩니다. 하지만 규칙 ID나 설명문, 재시도 정책처럼 함수와 함께 다녀야 하는 정보가 있다면 도메인 이름을 가진 인터페이스를 만들거나, 함수와 그 정보를 레코드 하나로 묶습니다. 이 글 뒷부분의 Rule 레코드가 두 번째 방법의 예입니다.
셋째, 결과를 알려 줘야 할 때입니다. Consumer는 값을 돌려주지 않으므로, 호출한 쪽이 알 수 있는 것은 정상적으로 끝났는지 예외가 났는지뿐입니다. 저장된 주문 번호나 처리 상태를 돌려받아야 한다면 Function<Order, SaveResult>처럼 결과 타입이 있는 함수가 맞습니다. 반환값을 버리기 편하다는 이유로 Consumer를 고르면, 나중에 결과가 필요해졌을 때 시그니처를 모두 바꿔야 합니다.
조합 메서드는 어떤 순서로 실행될까
표준 인터페이스에는 추상 메서드 말고도 함수를 이어 붙이는 default 메서드가 들어 있습니다. Function의 andThen과 compose, Predicate의 and, or, negate, Consumer의 andThen이 대표적입니다. 이름만 보고 쓰면 실행 순서나 건너뛰는 호출을 착각하기 쉬우므로 출력으로 직접 확인해 보겠습니다. 두 조건식에는 호출될 때마다 한 줄씩 출력하는 코드를 넣었습니다.
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
public final class CompositionOrder {
public static void main(String[] args) {
Function<Integer, Integer> plusOne = x -> x + 1;
Function<Integer, Integer> timesTwo = x -> x * 2;
System.out.println(plusOne.andThen(timesTwo).apply(5));
System.out.println(plusOne.compose(timesTwo).apply(5));
Predicate<String> notNull = text -> {
System.out.println(" notNull 검사");
return text != null;
};
Predicate<String> notBlank = text -> {
System.out.println(" notBlank 검사");
return !text.isBlank();
};
Predicate<String> present = notNull.and(notBlank);
System.out.println(present.test(null));
System.out.println(present.test("java"));
System.out.println(present.negate().test("java"));
Consumer<String> save = text -> {
throw new IllegalStateException("저장 실패: " + text);
};
Consumer<String> notify = text -> System.out.println("알림: " + text);
try {
save.andThen(notify).accept("order-1");
} catch (IllegalStateException exception) {
System.out.println(exception.getMessage());
}
}
}들여 쓴 줄은 조건식이 실제로 호출될 때만 찍힙니다. 그래서 test의 결과 줄 앞에 들여 쓴 줄이 몇 개 있는지 보면 어느 조건이 건너뛰어졌는지 알 수 있습니다.
12
11
notNull 검사
false
notNull 검사
notBlank 검사
true
notNull 검사
notBlank 검사
false
저장 실패: order-1처음 두 줄은 andThen과 compose의 차이입니다. plusOne.andThen(timesTwo)는 자기 자신인 plusOne을 먼저 실행하고 그 결과를 timesTwo에 넘기므로 (5 + 1) × 2 = 12입니다. plusOne.compose(timesTwo)는 반대로 인수로 받은 timesTwo를 먼저 실행하므로 5 × 2 + 1 = 11입니다. 수학의 합성 함수 f∘g가 g를 먼저 적용하는 것과 같은 방향이라고 기억하면 됩니다. 즉 f.compose(g)와 g.andThen(f)는 같은 함수입니다.
다음 두 줄은 null을 검사한 결과입니다. and는 &&처럼 단락 평가(short-circuit evaluation)를 합니다. 앞의 notNull이 false를 내면 전체 결과가 이미 false로 정해지므로 notBlank는 호출하지 않습니다. 그래서 notBlank 검사가 출력되지 않았고, null에 isBlank()를 호출하다 생길 NullPointerException도 일어나지 않았습니다. 순서를 바꿔 notBlank.and(notNull)로 쓰면 첫 조건에서 바로 NullPointerException이 나므로, null을 거르는 조건이나 값싼 조건을 앞에 둡니다. or도 같은 방식이어서 앞 조건이 true이면 뒤 조건을 평가하지 않습니다.
"java"는 두 조건을 모두 통과해야 하므로 두 검사가 모두 출력되고 true가 나옵니다. negate()는 결과만 뒤집은 새 Predicate를 만들 뿐이라 두 검사는 똑같이 실행되고, 마지막에 true가 false로 바뀝니다.
마지막 줄은 Consumer의 andThen입니다. 합쳐진 소비자는 save를 실행한 뒤 notify를 실행하는데, save가 예외를 던지면 notify는 실행되지 않고 예외가 호출한 쪽으로 그대로 전달됩니다. 그래서 알림: order-1은 출력되지 않고 catch에서 잡은 메시지만 나왔습니다. 저장에 실패했는데 알림이 나가는 일은 막아 주지만, 앞 작업이 실패해도 뒤 작업을 꼭 실행해야 한다면 andThen이 아니라 try-finally로 직접 써야 한다는 뜻이기도 합니다.
정적 메서드도 몇 가지 알아 두면 편합니다. Function.identity()는 받은 값을 그대로 돌려주는 함수입니다. Predicate.not(String::isBlank)처럼 쓰면 메서드 참조의 결과를 뒤집을 수 있는데, 이 메서드는 Java 11에 추가되었습니다. Predicate.isEqual(target)은 Objects.equals로 target과 같은지 비교하는 조건식을 만들어 줍니다.
여러 인터페이스를 한 흐름으로 엮기
이제 여러 인터페이스를 함께 써 보겠습니다. 다음 프로그램은 센서에서 문자열로 들어온 판독값을 처리합니다. 공백을 지우고 정수로 바꾼 뒤(Function), 2를 더해 보정하고(UnaryOperator), 0 이상 100 이하인 값만(Predicate) 저장합니다(Consumer). 저장할 때는 판독값마다 추적 ID를 붙이는데, 이 ID는 Supplier가 만듭니다. 함수마다 한 가지 일만 맡기 때문에 전체 처리 순서는 main의 반복문만 읽어도 알 수 있습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
public final class StandardFunctionToolkit {
record Reading(String traceId, int value) {
}
public static void main(String[] args) {
Function<String, String> normalize = String::trim;
Function<String, Integer> parse = Integer::parseInt;
Function<String, Integer> normalizedNumber = normalize.andThen(parse);
UnaryOperator<Integer> calibrate = value -> value + 2;
Predicate<Integer> accepted = value -> value >= 0 && value <= 100;
AtomicInteger sequence = new AtomicInteger();
Supplier<String> traceId = () -> "trace-" + sequence.incrementAndGet();
List<Reading> sink = new ArrayList<>();
Consumer<Integer> store = value -> sink.add(new Reading(traceId.get(), value));
for (String raw : List.of(" 10 ", " 120 ", " -3 ", " 40 ")) {
int value = calibrate.apply(normalizedNumber.apply(raw));
if (accepted.test(value)) {
store.accept(value);
}
}
System.out.println(sink);
System.out.println("generatedIds=" + sequence.get());
}
}traceId.get()은 store 안에서만 호출되므로, 범위를 벗어나 저장되지 않은 판독값에는 ID가 만들어지지 않습니다. 마지막 줄은 그렇게 실제로 만들어진 ID의 개수입니다.
[Reading[traceId=trace-1, value=12], Reading[traceId=trace-2, value=42]]
generatedIds=2네 입력이 각 단계에서 어떻게 바뀌는지 표로 따라가 보겠습니다.
| 입력 | 공백 제거와 정수 변환 | 보정(+2) | 범위 안인가 | 저장 |
|---|---|---|---|---|
" 10 " | 10 | 12 | 예 | trace-1, 12 |
" 120 " | 120 | 122 | 아니요 | 없음 |
" -3 " | -3 | -1 | 아니요 | 없음 |
" 40 " | 40 | 42 | 예 | trace-2, 42 |
눈여겨볼 곳은 traceId.get()을 부르는 위치입니다. 이 호출은 store 람다 안에 있으므로, 람다를 만들 때가 아니라 store.accept(value)가 실행될 때마다 일어납니다. 범위를 통과한 12와 42에서만 accept가 호출되니 ID도 두 번만 만들어졌고, 그래서 generatedIds=2입니다. 만약 반복문 앞에서 ID를 한 번만 꺼내 두고 store가 그 값을 쓰게 바꾸면 결과가 달라집니다.
String id = traceId.get();
Consumer<Integer> store = value -> sink.add(new Reading(id, value));[Reading[traceId=trace-1, value=12], Reading[traceId=trace-1, value=42]]
generatedIds=1이번에는 get()이 한 번만 호출되어 두 판독값이 같은 ID를 나눠 갖습니다. 이처럼 Supplier는 값을 만드는 방법만 들고 있다가 get()이 호출되는 순간에 계산합니다. 그래서 get()을 어디에서 부르느냐가 계산이 언제, 몇 번 일어나는지를 정합니다. 다만 호출할 때마다 새 값이 나온 것은 이 람다가 카운터를 하나씩 올리도록 작성됐기 때문입니다. JDK 문서도 Supplier가 호출될 때마다 새롭거나 다른 결과를 돌려줘야 한다는 요구는 없다고 밝혀 둡니다.
카운터로 int 지역 변수 대신 AtomicInteger를 쓴 이유도 람다의 규칙에 있습니다. 람다가 사용하는 바깥 지역 변수는 값이 한 번만 정해지는 사실상 final(effectively final) 변수여야 하므로, 람다 안에서 지역 변수 count를 count++로 바꿀 수 없습니다. sequence 변수는 처음 만든 AtomicInteger 객체를 계속 가리키고 그 객체 안의 값만 바뀌므로 이 규칙에 걸리지 않습니다. 이 규칙은 익명 클래스와 람다의 차이에서 자세히 다뤘습니다.
한 가지 더 짚어 두겠습니다. calibrate는 UnaryOperator<Integer>, accepted는 Predicate<Integer>라서 정수가 int와 Integer 사이를 오가며 박싱과 언박싱이 일어납니다. 이 변환을 없애는 IntUnaryOperator, IntPredicate 같은 기본형 전용 인터페이스는 다음 글에서 다룹니다.
Supplier의 결과를 한 번만 계산하기
앞 예제와 반대로, 계산을 미루되 한 번만 하고 싶을 때가 있습니다. 설정 파일을 읽거나 큰 표를 만드는 일처럼 비싼 계산은 정말 필요해질 때까지 미루고, 한 번 계산한 뒤에는 그 결과를 다시 쓰는 편이 좋습니다. 이렇게 처음 계산한 결과를 저장해 두고 이후 호출에서 재사용하는 기법을 메모이제이션(memoization)이라고 합니다. 여기서는 Supplier를 감싸서 첫 get()에서만 원래 Supplier를 부르고, 그 뒤로는 저장한 값을 돌려주는 래퍼를 만들어 보겠습니다.
람다로는 호출 사이의 상태를 기억할 수 없다
이 래퍼는 '이미 계산했는가'와 '계산한 값'을 get() 호출 사이에 기억해야 합니다. 먼저 람다로 써 보면 다음과 같습니다.
import java.util.function.Supplier;
public final class BrokenLambdaMemo {
static <T> Supplier<T> memoize(Supplier<? extends T> source) {
boolean initialized = false;
T value = null;
return () -> {
if (!initialized) {
value = source.get();
initialized = true;
}
return value;
};
}
public static void main(String[] args) {
System.out.println(memoize(() -> "x").get());
}
}BrokenLambdaMemo.java:8: error: local variables referenced from a lambda expression must be final or effectively final
if (!initialized) {
^
BrokenLambdaMemo.java:9: error: local variables referenced from a lambda expression must be final or effectively final
value = source.get();
^
BrokenLambdaMemo.java:10: error: local variables referenced from a lambda expression must be final or effectively final
initialized = true;
^
BrokenLambdaMemo.java:12: error: local variables referenced from a lambda expression must be final or effectively final
return value;
^
4 errors람다 본문은 바깥 메서드의 지역 변수를 쓸 수 있지만, 그 변수는 사실상 final이어야 합니다. initialized와 value는 람다 안에서 다시 대입되므로 사실상 final이 아니고, 그래서 대입하는 9행과 10행뿐 아니라 읽기만 하는 8행과 12행까지 모두 오류가 납니다. 변수를 람다 본문 안으로 옮겨도 소용없습니다. 본문의 지역 변수는 get()을 호출할 때마다 새로 만들어졌다가 사라지기 때문입니다. 람다는 필드를 선언할 수도 없으니, 호출 사이에 바뀌는 상태를 담을 자리가 없습니다.
익명 클래스는 이 점이 다릅니다. 익명 클래스도 바깥 지역 변수는 사실상 final이어야 한다는 같은 규칙을 따르지만, 자기 필드를 선언할 수 있습니다. 필드는 객체가 살아 있는 동안 유지되므로 호출 사이의 상태를 담기에 알맞습니다.
익명 클래스로 만든 메모이제이션 래퍼
다음 memoizeSuccessful은 Supplier를 구현한 익명 클래스 객체를 돌려줍니다. 원래 Supplier가 예외를 던지거나 null을 돌려주면 아무것도 저장하지 않아서, 다음 호출이 다시 시도할 수 있게 했습니다. main의 source는 첫 호출에서 일부러 실패하고, 두 번째 호출부터는 value-2처럼 호출 번호가 붙은 값을 돌려줍니다.
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
public final class LazySupplierMemoizer {
static <T> Supplier<T> memoizeSuccessful(Supplier<? extends T> source) {
Objects.requireNonNull(source);
return new Supplier<>() {
private boolean initialized;
private T value;
@Override
public synchronized T get() {
if (!initialized) {
T created = Objects.requireNonNull(source.get(), "supplier returned null");
value = created;
initialized = true;
}
return value;
}
};
}
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
Supplier<String> source = () -> {
int call = calls.incrementAndGet();
if (call == 1) {
throw new IllegalStateException("temporary failure");
}
return "value-" + call;
};
Supplier<String> cached = memoizeSuccessful(source);
try {
cached.get();
} catch (IllegalStateException exception) {
System.out.println(exception.getMessage());
}
System.out.println(cached.get());
System.out.println(cached.get());
System.out.println("sourceCalls=" + calls.get());
}
}main은 첫 get()을 try로 감싸 실패 메시지를 찍고, 이어서 get()을 두 번 더 부릅니다. 마지막 줄의 sourceCalls는 source가 실제로 몇 번 호출됐는지를 셉니다.
temporary failure
value-2
value-2
sourceCalls=2new Supplier<>() { ... }는 Supplier를 구현한 이름 없는 클래스를 선언하면서 그 객체를 바로 만듭니다. 익명 클래스에 다이아몬드 <>를 쓸 수 있게 된 것은 Java 9부터입니다. 객체 안의 initialized와 value 필드가 '계산했는가'와 '계산한 값'을 기억합니다. get()은 initialized가 false일 때만 source.get()을 부르고, 결과를 Objects.requireNonNull로 검사한 다음에야 두 필드를 바꿉니다. 그래서 source.get()이 예외를 던지거나 requireNonNull이 null을 거절해 NullPointerException이 나면, 필드를 바꾸는 줄에 닿기 전에 메서드를 빠져나가 상태가 그대로 남습니다.
출력을 호출 순서대로 따라가 보겠습니다. 첫 cached.get()에서는 initialized가 false이므로 source를 부르는데, 첫 호출이라 IllegalStateException이 나고 main의 catch가 temporary failure를 출력합니다. 이때 initialized는 여전히 false입니다. 두 번째 get()은 source를 다시 부르고, 두 번째 호출이므로 value-2를 받아 저장한 뒤 initialized를 true로 바꿉니다. 세 번째 get()은 initialized가 true이므로 source를 부르지 않고 저장한 value-2를 돌려줍니다. 그래서 source는 모두 두 번 호출되었습니다.
성공한 값만 저장하는 세 번의 호출
미초기화 get에서 source 예외/null은 캐시를 채우지 않고 성공한 값만 저장하여 이후 get은 source 없이 반환합니다. 호출 수와 initialized의 실제 전후가 핵심입니다.
성공한 값만 저장하는 세 번의 호출
성공 값의 캐시
성공한 값만 저장하는 세 번의 호출
첫 실패는 초기화하지 않습니다. 두 번째 호출의 value-2를 저장한 뒤 세 번째 호출은 원본 source를 다시 부르지 않습니다.
첫 번째 호출
- 원본 source 호출
- 1회째: 예외 발생
- 초기화 상태와 결과
- initialized=false 유지; temporary failure
두 번째 호출
- 원본 source 호출
- 2회째: value-2 생성
- 초기화 상태와 결과
- initialized=true; value-2 반환
세 번째 호출
- 원본 source 호출
- 호출하지 않음
- 초기화 상태와 결과
- 저장된 value-2 반환
원문 main의 연속 호출을 따라간 표입니다. null 거절은 이 래퍼의 선택이며, 별도 initialized 플래그는 캐시된 null과 미계산 상태도 구분할 수 있습니다.
source는 총 두 번 호출됩니다. synchronized get과 null 거절은 이 래퍼의 정책입니다.
이 예의 조건
- 성공한 non-null 값만 캐시에 저장한다.
- source 예외와 null 실패는 독립적인 미초기화 경로이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | initialized가 true면 저장한 값을 바로 반환하고 false면 source를 호출한다. source 예외와 null 거절은 초기화를 완료하지 않고 실패한다. non-null 성공이면 value를 저장하고 initialized=true로 바꾼다. 화살표는 원 synchronized get 내부의 제어 흐름이며 실제 다중 스레드 경쟁 시험을 나타내지 않는다. 모바일도 같은 7개 노드와 8개 관계, 성공/두 실패 분기를 유지한다. |
| 6 | non-null 성공한 값? |
| 구분 | 내용 |
|---|---|
| 설명 | initialized가 true면 저장한 값을 바로 반환하고 false면 source를 호출한다. source 예외와 null 거절은 초기화를 완료하지 않고 실패한다. non-null 성공이면 value를 저장하고 initialized=true로 바꾼다. 화살표는 원 synchronized get 내부의 제어 흐름이며 실제 다중 스레드 경쟁 시험을 나타내지 않는다. 모바일도 같은 7개 노드와 8개 관계, 성공/두 실패 분기를 유지한다. |
| 전체 읽기 정보 | 성공한 값만 저장하는 세 번의 호출 성공한 값만 저장하는 세 번의 호출 성공한 값만 저장하는 세 번의 호출 성공 값의 캐시 성공한 값만 저장하는 세 번의 호출 첫 실패는 초기화하지 않습니다. 두 번째 호출의 value-2를 저장한 뒤 세 번째 호출은 원본 source를 다시 부르지 않습니다. 첫 번째 호출 원본 source 호출 1회째: 예외 발생 초기화 상태와 결과 initialized=false 유지; temporary failure 두 번째 호출 원본 source 호출 2회째: value-2 생성 초기화 상태와 결과 initialized=true; value-2 반환 세 번째 호출 원본 source 호출 호출하지 않음 초기화 상태와 결과 저장된 value-2 반환 원문 main의 연속 호출을 따라간 표입니다. null 거절은 이 래퍼의 선택이며, 별도 initialized 플래그는 캐시된 null과 미계산 상태도 구분할 수 있습니다. source는 총 두 번 호출됩니다. synchronized get과 null 거절은 이 래퍼의 정책입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 호출 준비 initialized=false라 source를 호출합니다. |
|
|
| 실패 유지 source 예외 또는 null 거절은 초기화를 끝내지 못합니다. |
|
|
| 성공 저장 다음 non-null 결과를 저장합니다. |
|
|
| 재사용 이후 get은 source를 생략합니다. |
|
|
성공한 값만 저장하는 세 번의 호출
미초기화 get에서 source 예외/null은 캐시를 채우지 않고 성공한 값만 저장하여 이후 get은 source 없이 반환합니다. 호출 수와 initialized의 실제 전후가 핵심입니다.
성공한 값만 저장하는 세 번의 호출
성공 값의 캐시
성공한 값만 저장하는 세 번의 호출
첫 실패는 초기화하지 않습니다. 두 번째 호출의 value-2를 저장한 뒤 세 번째 호출은 원본 source를 다시 부르지 않습니다.
첫 번째 호출
- 원본 source 호출
- 1회째: 예외 발생
- 초기화 상태와 결과
- initialized=false 유지; temporary failure
두 번째 호출
- 원본 source 호출
- 2회째: value-2 생성
- 초기화 상태와 결과
- initialized=true; value-2 반환
세 번째 호출
- 원본 source 호출
- 호출하지 않음
- 초기화 상태와 결과
- 저장된 value-2 반환
원문 main의 연속 호출을 따라간 표입니다. null 거절은 이 래퍼의 선택이며, 별도 initialized 플래그는 캐시된 null과 미계산 상태도 구분할 수 있습니다.
source는 총 두 번 호출됩니다. synchronized get과 null 거절은 이 래퍼의 정책입니다.
이 예의 조건
- 성공한 non-null 값만 캐시에 저장한다.
- source 예외와 null 실패는 독립적인 미초기화 경로이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | initialized가 true면 저장한 값을 바로 반환하고 false면 source를 호출한다. source 예외와 null 거절은 초기화를 완료하지 않고 실패한다. non-null 성공이면 value를 저장하고 initialized=true로 바꾼다. 화살표는 원 synchronized get 내부의 제어 흐름이며 실제 다중 스레드 경쟁 시험을 나타내지 않는다. 모바일도 같은 7개 노드와 8개 관계, 성공/두 실패 분기를 유지한다. |
| 6 | non-null 성공한 값? |
| 구분 | 내용 |
|---|---|
| 설명 | initialized가 true면 저장한 값을 바로 반환하고 false면 source를 호출한다. source 예외와 null 거절은 초기화를 완료하지 않고 실패한다. non-null 성공이면 value를 저장하고 initialized=true로 바꾼다. 화살표는 원 synchronized get 내부의 제어 흐름이며 실제 다중 스레드 경쟁 시험을 나타내지 않는다. 모바일도 같은 7개 노드와 8개 관계, 성공/두 실패 분기를 유지한다. |
| 전체 읽기 정보 | 성공한 값만 저장하는 세 번의 호출 성공한 값만 저장하는 세 번의 호출 성공한 값만 저장하는 세 번의 호출 성공 값의 캐시 성공한 값만 저장하는 세 번의 호출 첫 실패는 초기화하지 않습니다. 두 번째 호출의 value-2를 저장한 뒤 세 번째 호출은 원본 source를 다시 부르지 않습니다. 첫 번째 호출 원본 source 호출 1회째: 예외 발생 초기화 상태와 결과 initialized=false 유지; temporary failure 두 번째 호출 원본 source 호출 2회째: value-2 생성 초기화 상태와 결과 initialized=true; value-2 반환 세 번째 호출 원본 source 호출 호출하지 않음 초기화 상태와 결과 저장된 value-2 반환 원문 main의 연속 호출을 따라간 표입니다. null 거절은 이 래퍼의 선택이며, 별도 initialized 플래그는 캐시된 null과 미계산 상태도 구분할 수 있습니다. source는 총 두 번 호출됩니다. synchronized get과 null 거절은 이 래퍼의 정책입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 호출 준비 initialized=false라 source를 호출합니다. |
|
|
| 실패 유지 source 예외 또는 null 거절은 초기화를 끝내지 못합니다. |
|
|
| 성공 저장 다음 non-null 결과를 저장합니다. |
|
|
| 재사용 이후 get은 source를 생략합니다. |
|
|
get()에 붙은 synchronized는 여러 스레드가 같은 래퍼를 동시에 부를 때를 위한 것입니다. 인스턴스 메서드의 synchronized는 this, 즉 이 익명 클래스 객체를 잠그므로 한 번에 한 스레드만 get() 안에 들어갈 수 있습니다. 그래서 두 스레드가 동시에 source를 부르거나, 한 스레드가 쓰다 만 필드를 다른 스레드가 읽는 일이 생기지 않습니다. 이 부분도 람다로는 그대로 옮길 수 없습니다. 람다 본문의 this는 람다 자신이 아니라 람다를 둘러싼 바깥 객체를 가리키고, 이 예제처럼 static 메서드 안이라면 this 자체가 없기 때문입니다.
initialized 플래그를 따로 둔 것은 '아직 계산하지 않음'과 '계산 결과가 null임'을 구별하기 위해서입니다. 이 래퍼는 null을 거절하므로 value != null로도 판단할 수 있지만, null도 정상 결과로 저장하도록 바꾸려면 플래그가 있어야 합니다.
실패를 저장하지 않고 다시 시도하는 것은 이 래퍼가 고른 정책일 뿐입니다. 외부 서비스가 잠시 응답하지 않은 경우라면 다시 시도하는 편이 맞습니다. 하지만 설정 파일의 문법 오류처럼 몇 번을 다시 해도 똑같이 실패할 일이라면, 첫 실패를 저장해 두고 같은 예외를 다시 던지는 편이 낫습니다. 어느 쪽이 맞는지는 무엇을 계산하느냐에 달려 있습니다.
더 깊이: 매번 잠그는 비용과 재진입
synchronized get()은 초기화가 끝난 뒤에도 호출할 때마다 잠금을 얻습니다. 대부분의 코드에서는 문제가 되지 않지만, 아주 자주 호출되는 경로라면 volatile 필드로 초기화 여부를 먼저 확인하고 초기화되지 않았을 때만 잠그는 이중 검사 잠금(double-checked locking)을 쓰기도 합니다. JDK 25에는 이런 지연 초기화를 돕는 StableValue API가 프리뷰(preview) 기능으로 들어 있으며, 프리뷰 기능 실행과 StableValue에서 다룹니다.
이 래퍼는 재진입도 막지 않습니다. source가 실행 도중 같은 래퍼의 get()을 다시 부르면, synchronized는 같은 스레드가 이미 가진 잠금을 다시 얻는 것을 허용하므로 안쪽 호출이 그대로 들어옵니다. 그때 initialized는 아직 false라서 source가 또 호출되고, 이 과정이 끝없이 되풀이되다가 StackOverflowError로 끝납니다. 계산 중에 자기 자신을 다시 부를 수 있는 구조라면 '계산 중' 상태를 따로 두어 이런 호출을 거절해야 합니다.
어떤 규칙이 실패했는지 알려 주기
Predicate를 and로 이어 붙이면 여러 조건을 boolean 하나로 줄일 수 있습니다. 결과가 참인지 거짓인지만 필요하다면 이것으로 충분합니다. 하지만 회원 가입 화면은 '비밀번호가 너무 짧습니다'처럼 어떤 규칙이 실패했는지 사용자에게 알려 줘야 합니다. idRequired.and(passwordMin8)이 false를 돌려주는 순간 어느 쪽이 실패했는지는 사라지고, Predicate 객체에는 이름도 없어서 나중에 알아낼 방법도 없습니다.
이럴 때는 Predicate를 버리지 말고, 규칙 이름과 함께 레코드 하나로 묶습니다.
record Rule<T>(String name, Predicate<T> predicate) {
}규칙 목록을 순서대로 돌면서 predicate().test(value)가 false인 규칙의 이름을 모으면 됩니다. 조건 자체는 여전히 표준 Predicate이므로 람다, 메서드 참조, and, negate를 그대로 쓸 수 있고, 이름만 바깥에서 덧붙인 셈입니다. 앞에서 말한 '함수와 추가 정보를 레코드로 묶는 방법'이 바로 이것입니다.
실패를 모으는 방식은 두 가지를 생각할 수 있습니다. 첫 실패에서 멈추는 빠른 실패(fail-fast)는 and의 단락 평가와 비슷하고, 뒤에 있는 비싼 검사를 아낄 수 있습니다. 끝까지 검사하는 전체 수집은 사용자에게 모든 문제를 한꺼번에 보여 줄 수 있습니다. 전체 수집에서는 앞 규칙이 실패해도 뒤 규칙이 실행되므로, 규칙 하나하나가 null 같은 입력을 스스로 걸러야 하고 부수 효과가 없어야 합니다. 그래야 규칙의 순서나 모드를 바꿔도 결과를 예측할 수 있습니다. 두 모드를 함께 제공하는 구현은 연습 문제 3에서 직접 만들어 봅니다.
UnaryOperator와 BinaryOperator: 타입이 그대로인 연산
UnaryOperator<T>는 받은 값과 같은 타입의 값을 돌려주는 함수입니다. 문자열 정규화처럼 같은 타입 안에서 값을 다듬는 단계나, 불변 객체의 일부만 바꾼 새 객체를 만드는 단계에 어울립니다. BinaryOperator<T>는 같은 타입의 두 값을 하나로 합치는 함수로, 합계나 최댓값처럼 여러 값을 하나로 줄이는 데 씁니다. 다음 예제는 단어의 대소문자와 공백을 정리한 뒤 단어별로 몇 번 나왔는지 셉니다. 출력 순서가 일정하도록 키를 정렬해 보관하는 TreeMap을 썼습니다.
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.BinaryOperator;
import java.util.function.UnaryOperator;
public final class OperatorExamples {
public static void main(String[] args) {
UnaryOperator<String> normalize = text -> text.trim().toLowerCase();
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<String> shorter = BinaryOperator.minBy(Comparator.comparingInt(String::length));
Map<String, Integer> counts = new TreeMap<>();
for (String word : List.of(" Java ", "lambda", "JAVA", " Lambda", "java")) {
counts.merge(normalize.apply(word), 1, add);
}
System.out.println(counts);
System.out.println(shorter.apply("lambda", "java"));
}
}TreeMap은 키를 정렬해 보관하므로 첫 줄의 단어는 알파벳 순으로 나옵니다. 둘째 줄은 shorter가 두 단어 가운데 하나를 고른 결과입니다.
{java=3, lambda=2}
javanormalize가 " Java ", "JAVA", "java"를 모두 "java"로 바꾸므로 java는 3번, lambda는 2번으로 세어집니다. Map.merge(key, 1, add)는 키가 없으면 1을 넣고, 이미 있으면 기존 값과 1을 add로 합친 값을 넣습니다. merge의 세 번째 매개변수 타입은 BiFunction<? super V, ? super V, ? extends V>인데, BinaryOperator<Integer>는 BiFunction<Integer, Integer, Integer>의 하위 타입이므로 그대로 넘길 수 있습니다. 둘째 줄의 BinaryOperator.minBy는 비교자(Comparator)를 받아 둘 중 작은 쪽을 고르는 연산자를 만들어 주므로, 길이가 더 짧은 "java"가 나왔습니다.
UnaryOperator에는 흔한 함정이 하나 있습니다. andThen과 compose는 Function에서 물려받은 메서드라서, UnaryOperator 둘을 이어도 결과 타입은 Function입니다.
import java.util.function.UnaryOperator;
public final class OperatorAndThen {
public static void main(String[] args) {
UnaryOperator<String> trim = String::trim;
UnaryOperator<String> upper = String::toUpperCase;
UnaryOperator<String> cleanUp = trim.andThen(upper);
System.out.println(cleanUp.apply(" java "));
}
}OperatorAndThen.java:7: error: incompatible types: no instance(s) of type variable(s) V exist so that Function<String,V> conforms to UnaryOperator<String>
UnaryOperator<String> cleanUp = trim.andThen(upper);
^
where V,R,T are type-variables:
V extends Object declared in method <V>andThen(Function<? super R,? extends V>)
R extends Object declared in interface Function
T extends Object declared in interface Function
1 error오류 메시지는 길지만 요점은 첫 줄에 있습니다. andThen이 돌려주는 Function<String, V>는 V를 무엇으로 정하든 UnaryOperator<String>이 될 수 없다는 뜻입니다. 변수 타입을 Function<String, String>으로 선언하거나, text -> upper.apply(trim.apply(text))처럼 람다로 직접 이어 쓰면 해결됩니다.
마지막으로, BinaryOperator라는 타입은 연산의 성질까지 보장하지 않습니다. 나중에 배울 스트림의 reduce처럼 값을 여러 묶음으로 나눠 합칠 수 있는 연산은 연산자가 결합 법칙을 만족한다고 가정합니다. 덧셈은 (1 + 2) + 3과 1 + (2 + 3)이 같지만, 뺄셈은 (10 - 3) - 2가 5이고 10 - (3 - 2)는 9입니다. 이런 연산자를 넘기면 값을 묶는 방식에 따라 결과가 달라질 수 있는데, 이 문제는 병렬 스트림과 결합 법칙에서 자세히 다룹니다.
정리
java.util.function의 표준 인터페이스는 받는 값과 돌려주는 값으로 읽습니다.Function은T를R로 바꾸고,Consumer는 받기만 하고,Supplier는 주기만 하고,Predicate는boolean을 돌려줍니다.UnaryOperator와BinaryOperator는 입력과 결과의 타입이 같은 특수한 경우입니다.- 함수형 인터페이스는 모양이 아니라 이름으로 구별되므로, 모양이 같은 인터페이스를 따로 만들면 표준 API와 조합 메서드를 쓸 수 없습니다. 검사 예외나 함께 다녀야 할 정보가 있을 때만 직접 만들거나 레코드로 감쌉니다.
f.andThen(g)는 f를,f.compose(g)는 g를 먼저 실행합니다.Predicate의and와or는 단락 평가를 하고,Consumer의andThen은 앞 작업이 예외를 던지면 뒤 작업을 실행하지 않습니다.Supplier의 계산은get()을 부르는 순간 일어납니다. 결과를 한 번만 계산하려면 상태를 필드에 담아야 하므로, 람다가 아니라 익명 클래스 같은 클래스로 래퍼를 만듭니다.
연습 문제
문제 1: 합성 순서 예측하기
다음 두 함수가 있습니다. 둘 다 정수를 받아 정수를 돌려주므로 어느 순서로든 이어 붙일 수 있습니다.
Function<Integer, Integer> square = x -> x * x;
Function<Integer, Integer> minusOne = x -> x - 1;square.andThen(minusOne).apply(4)와 square.compose(minusOne).apply(4)의 결과를 실행하기 전에 예측하세요. 그리고 두 번째와 같은 함수를 compose 없이 andThen만으로 만들어 보세요.
해설 보기
첫 번째는 15, 두 번째는 9입니다. andThen은 자기 자신을 먼저 실행하므로 4를 제곱한 16에서 1을 빼서 15가 됩니다. compose는 인수로 받은 함수를 먼저 실행하므로 4에서 1을 뺀 3을 제곱해 9가 됩니다.
f.compose(g)는 g.andThen(f)와 같으므로, 두 번째 함수는 minusOne.andThen(square)로 만들 수 있습니다. 이 함수에 4를 넣어도 결과는 9입니다.
문제 2: 성공한 값만 저장하는 래퍼 따라가기
본문의 LazySupplierMemoizer에서 source가 첫 호출에 예외를 던지는 대신 null을 돌려주게 바꿉니다. 두 번째 호출부터는 그대로 "value-" + call을 돌려주고, main의 catch는 NullPointerException을 잡도록 고칩니다. 출력 네 줄과, 세 번의 get()을 거치는 동안 initialized가 어떻게 바뀌는지 적으세요.
해설 보기
supplier returned null
value-2
value-2
sourceCalls=2첫 get()에서 source가 null을 돌려주면 Objects.requireNonNull이 supplier returned null 메시지를 담은 NullPointerException을 던집니다. 필드를 바꾸는 줄에 닿기 전이므로 initialized는 false 그대로이고, catch가 메시지를 출력합니다. 두 번째 get()은 source를 다시 불러 value-2를 저장하고 initialized를 true로 바꿉니다. 세 번째 get()은 source를 부르지 않고 저장한 값을 돌려주므로 source는 모두 두 번 호출됩니다.
예외와 null은 실패한 원인만 다를 뿐, 이 래퍼에서는 둘 다 '저장하지 않고 다음 호출에서 다시 시도'하는 같은 경로를 탑니다.
문제 3: 가입 규칙을 두 가지 방식으로 검사하기
회원 가입 입력 Signup(id, password, email)에 세 규칙을 적용합니다. ID는 비어 있지 않아야 하고(id-required), 비밀번호는 8자 이상이어야 하며(password-min-8), 이메일은 @example.com으로 끝나야 합니다(company-email). 규칙은 본문의 Rule<T> 레코드로 표현하고, 첫 실패에서 멈추는 빠른 실패와 끝까지 검사하는 전체 수집을 한 메서드에서 고를 수 있게 만드세요. 결과에는 실패한 규칙의 이름뿐 아니라 실제로 평가한 규칙의 이름도 담습니다. 마지막으로 Signup("", "short", "[email protected]")을 두 모드로 검사해 보세요.
해설 보기
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public final class PredicateRuleSetSolution {
record Signup(String id, String password, String email) {
}
record Rule<T>(String name, Predicate<T> predicate) {
}
record Validation(List<String> evaluated, List<String> failed) {
Validation {
evaluated = List.copyOf(evaluated);
failed = List.copyOf(failed);
}
}
static <T> Validation validate(List<Rule<T>> rules, T value, boolean failFast) {
List<String> evaluated = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (Rule<T> rule : rules) {
evaluated.add(rule.name());
if (!rule.predicate().test(value)) {
failed.add(rule.name());
if (failFast) {
break;
}
}
}
return new Validation(evaluated, failed);
}
public static void main(String[] args) {
List<Rule<Signup>> rules = List.of(
new Rule<>("id-required", signup -> signup.id() != null && !signup.id().isBlank()),
new Rule<>("password-min-8", signup -> signup.password() != null
&& signup.password().length() >= 8),
new Rule<>("company-email", signup -> signup.email() != null
&& signup.email().endsWith("@example.com")));
Signup invalid = new Signup("", "short", "[email protected]");
System.out.println(validate(rules, invalid, true));
System.out.println(validate(rules, invalid, false));
}
}실행 결과:
Validation[evaluated=[id-required], failed=[id-required]]
Validation[evaluated=[id-required, password-min-8, company-email], failed=[id-required, password-min-8, company-email]]같은 가입 입력에서 달라지는 규칙 평가 범위
동일한 invalid 입력에서 failFast는 첫 id 실패 후 멈추고 전체 평가 모드는 세 실패를 모읍니다. 평가한 규칙과 누적 실패 목록을 나란히 비교합니다.
같은 가입 입력에서 달라지는 규칙 평가 범위
규칙 평가의 범위
같은 가입 입력에서 달라지는 규칙 평가 범위
같은 invalid 입력이라도 빠른 실패는 첫 규칙에서 멈추고, 전체 수집은 세 규칙을 모두 평가합니다.
빠른 실패
- 평가한 규칙
- id-required
- 실패로 수집한 규칙 수
- 1개
전체 수집
- 평가한 규칙
- id-required, password-min-8, company-email
- 실패로 수집한 규칙 수
- 3개
풀이의 invalid 입력은 세 규칙을 모두 어깁니다. 이 결과는 해당 목록과 조건식에서 도출한 것이며, 조건식이 예외를 던지는 경우까지 위반 목록으로 바꾸지는 않습니다.
이 입력은 세 규칙 모두 실패합니다. 평가한 규칙과 실패한 규칙을 따로 보관하는 계약을 유지합니다.
이 예의 조건
- 원 invalid의 id="", password="short", email="[email protected]"은 세 규칙 모두 false입니다. 두 validate 호출의 모드를 같은 평가 단계마다 비교합니다.
- id가 정상인 독립 문제의 evaluated 2대3과 구분합니다. 던진 예외를 failed로 변환하는 catch가 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 invalid는 id·password·email 세 조건 모두 false다. failFast=true는 첫 id 실패 뒤 중단하며 false는 세 조건을 모두 평가한다. evaluated는 검사한 규칙이고 failed는 false였던 규칙이다. 이 그림의 1대3은 원 invalid 조건에 한정하고 별도 id가 정상인 독립 문제의 2대3과 구분한다. 던진 예외를 위반으로 변환하는 catch는 없다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 invalid는 id·password·email 세 조건 모두 false다. failFast=true는 첫 id 실패 뒤 중단하며 false는 세 조건을 모두 평가한다. evaluated는 검사한 규칙이고 failed는 false였던 규칙이다. 이 그림의 1대3은 원 invalid 조건에 한정하고 별도 id가 정상인 독립 문제의 2대3과 구분한다. 던진 예외를 위반으로 변환하는 catch는 없다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 전체 읽기 정보 | 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 규칙 평가의 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 invalid 입력이라도 빠른 실패는 첫 규칙에서 멈추고, 전체 수집은 세 규칙을 모두 평가합니다. 빠른 실패 평가한 규칙 id-required 실패로 수집한 규칙 수 1개 전체 수집 평가한 규칙 id-required, password-min-8, company-email 실패로 수집한 규칙 수 3개 풀이의 invalid 입력은 세 규칙을 모두 어깁니다. 이 결과는 해당 목록과 조건식에서 도출한 것이며, 조건식이 예외를 던지는 경우까지 위반 목록으로 바꾸지는 않습니다. 이 입력은 세 규칙 모두 실패합니다. 평가한 규칙과 실패한 규칙을 따로 보관하는 계약을 유지합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 모드의 첫 규칙 평가 failFast true와 false 모두 id-required를 먼저 평가해 실패를 기록합니다. |
|
|
| 남은 규칙 실행 또는 생략 true는 첫 실패에서 break하고 false는 password와 email 규칙도 평가합니다. |
|
|
| 각 Validation 반환 두 모드가 실제 평가 목록과 false 목록을 수정할 수 없도록 복사하여 반환합니다. |
|
|
같은 가입 입력에서 달라지는 규칙 평가 범위
동일한 invalid 입력에서 failFast는 첫 id 실패 후 멈추고 전체 평가 모드는 세 실패를 모읍니다. 평가한 규칙과 누적 실패 목록을 나란히 비교합니다.
같은 가입 입력에서 달라지는 규칙 평가 범위
규칙 평가의 범위
같은 가입 입력에서 달라지는 규칙 평가 범위
같은 invalid 입력이라도 빠른 실패는 첫 규칙에서 멈추고, 전체 수집은 세 규칙을 모두 평가합니다.
빠른 실패
- 평가한 규칙
- id-required
- 실패로 수집한 규칙 수
- 1개
전체 수집
- 평가한 규칙
- id-required, password-min-8, company-email
- 실패로 수집한 규칙 수
- 3개
풀이의 invalid 입력은 세 규칙을 모두 어깁니다. 이 결과는 해당 목록과 조건식에서 도출한 것이며, 조건식이 예외를 던지는 경우까지 위반 목록으로 바꾸지는 않습니다.
이 입력은 세 규칙 모두 실패합니다. 평가한 규칙과 실패한 규칙을 따로 보관하는 계약을 유지합니다.
이 예의 조건
- 원 invalid의 id="", password="short", email="[email protected]"은 세 규칙 모두 false입니다. 두 validate 호출의 모드를 같은 평가 단계마다 비교합니다.
- id가 정상인 독립 문제의 evaluated 2대3과 구분합니다. 던진 예외를 failed로 변환하는 catch가 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 invalid는 id·password·email 세 조건 모두 false다. failFast=true는 첫 id 실패 뒤 중단하며 false는 세 조건을 모두 평가한다. evaluated는 검사한 규칙이고 failed는 false였던 규칙이다. 이 그림의 1대3은 원 invalid 조건에 한정하고 별도 id가 정상인 독립 문제의 2대3과 구분한다. 던진 예외를 위반으로 변환하는 catch는 없다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 invalid는 id·password·email 세 조건 모두 false다. failFast=true는 첫 id 실패 뒤 중단하며 false는 세 조건을 모두 평가한다. evaluated는 검사한 규칙이고 failed는 false였던 규칙이다. 이 그림의 1대3은 원 invalid 조건에 한정하고 별도 id가 정상인 독립 문제의 2대3과 구분한다. 던진 예외를 위반으로 변환하는 catch는 없다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 전체 읽기 정보 | 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 규칙 평가의 범위 같은 가입 입력에서 달라지는 규칙 평가 범위 같은 invalid 입력이라도 빠른 실패는 첫 규칙에서 멈추고, 전체 수집은 세 규칙을 모두 평가합니다. 빠른 실패 평가한 규칙 id-required 실패로 수집한 규칙 수 1개 전체 수집 평가한 규칙 id-required, password-min-8, company-email 실패로 수집한 규칙 수 3개 풀이의 invalid 입력은 세 규칙을 모두 어깁니다. 이 결과는 해당 목록과 조건식에서 도출한 것이며, 조건식이 예외를 던지는 경우까지 위반 목록으로 바꾸지는 않습니다. 이 입력은 세 규칙 모두 실패합니다. 평가한 규칙과 실패한 규칙을 따로 보관하는 계약을 유지합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 모드의 첫 규칙 평가 failFast true와 false 모두 id-required를 먼저 평가해 실패를 기록합니다. |
|
|
| 남은 규칙 실행 또는 생략 true는 첫 실패에서 break하고 false는 password와 email 규칙도 평가합니다. |
|
|
| 각 Validation 반환 두 모드가 실제 평가 목록과 false 목록을 수정할 수 없도록 복사하여 반환합니다. |
|
|
validate는 규칙을 평가하기 직전에 이름을 evaluated에 넣고, test가 false를 돌려주면 failed에도 넣습니다. failFast가 true면 첫 실패에서 break로 반복문을 빠져나옵니다. 입력 Signup("", "short", "[email protected]")은 세 규칙을 모두 어기므로, 빠른 실패는 첫 규칙 id-required에서 멈춰 두 목록에 이름이 하나씩만 남습니다. 전체 수집은 세 규칙을 모두 평가하므로 두 목록에 세 이름이 모두 들어갑니다. Validation의 컴팩트 생성자(compact constructor)는 List.copyOf로 두 목록을 수정할 수 없는 복사본으로 바꿔, 돌려준 결과를 바깥에서 고칠 수 없게 합니다.
각 규칙이 null을 먼저 확인하는 것은 전체 수집 모드 때문입니다. 앞 규칙이 실패해도 뒤 규칙이 실행되므로, 예를 들어 비밀번호 규칙이 null 검사 없이 length()를 부르면 비밀번호가 없는 입력에서 NullPointerException이 납니다. 이 구현은 조건식이 던진 예외를 failed로 바꾸지 않고 validate 밖으로 그대로 내보낸다는 점도 기억해 두세요. 화면에 여러 언어로 메시지를 보여 주거나 어느 입력 칸의 문제인지 알려 줘야 한다면, 이름 대신 오류 코드와 메시지 인수를 담도록 Rule을 확장하면 됩니다.
문제 4: ID만 올바른 입력 따라가기
문제 3의 해답에서 입력만 Signup("U-1", "short", "[email protected]")으로 바꿉니다. 두 모드에서 evaluated와 failed가 각각 어떻게 되는지 적으세요.
해설 보기
Validation[evaluated=[id-required, password-min-8], failed=[password-min-8]]
Validation[evaluated=[id-required, password-min-8, company-email], failed=[password-min-8, company-email]]빠른 실패에서는 id-required가 통과하므로 다음 규칙으로 넘어가고, password-min-8에서 처음 실패해 멈춥니다. 그래서 평가한 규칙은 두 개, 실패한 규칙은 하나이고, 이메일 규칙은 평가하지도 않습니다. 전체 수집은 세 규칙을 모두 평가하므로 실패 목록에 비밀번호와 이메일 규칙이 함께 들어갑니다. 같은 입력이라도 모드에 따라 평가한 규칙이 달라지기 때문에, 결과에 evaluated를 따로 담아 두면 어떤 규칙이 검사되지 않았는지까지 알 수 있습니다.
다음 글
BiFunction과 기본형 함수형 인터페이스에서는 인수가 둘인 BiFunction, BiConsumer, BiPredicate와, 박싱 없이 int·long·double을 다루는 IntPredicate, ToIntFunction 같은 인터페이스를 살펴봅니다. 이 글의 판독값 예제에서 일어난 박싱을 없애는 방법도 함께 봅니다.