표준 함수형 인터페이스
JDK 표준 함수형 인터페이스를 입력과 출력, 부수 효과, 지연 생성, 동일 타입 연산으로 분류하고 함수 조합·단락 평가·메모이제이션의 실제 의미를 검증합니다.
표준 함수형 인터페이스는 이름을 외우기보다 데이터 흐름으로 읽습니다.
Function은 입력을 다른 값으로 바꿉니다.
Consumer는 값을 받아 부수 효과를 수행하고 반환하지 않습니다.
Supplier는 입력 없이 값을 제공합니다.
Predicate는 boolean 결과를 만듭니다.
UnaryOperator와 BinaryOperator는 입력과 결과 타입이 같은 Function, BiFunction의 특수 형태입니다.
표준 타입을 선택하면 Stream, Optional, 컬렉션의 기본 메서드와 자연스럽게 연결되고 팀원이 함수 시그니처를 바로 예상할 수 있습니다.
하지만 검사 예외, 규칙 ID, 설명, 실행 문맥처럼 추가 계약이 필요하면 사용자 정의 인터페이스나 래퍼 레코드를 사용합니다.
표준화는 정보 손실을 감수하는 축약이 되어서는 안 됩니다.
중복 사용자 인터페이스
다음 예제는 입력 하나와 결과 하나라는 같은 구조를 Parser, Formatter, Mapper로 반복합니다.
도메인 의미가 정말 다르면 이름이 도움이 되지만 조합 API를 각각 다시 작성해야 합니다.
단순 변환만 필요하다면 Function<T, R> 하나로 충분합니다.
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 "))));
}
}반대로 Consumer<Order>라는 타입만 보고 감사인지 저장인지 메시지 발행인지 알 수 없는 경우가 있습니다.
매개변수 이름과 메서드 이름으로 충분히 설명되면 표준 타입을 유지할 수 있습니다.
실행 보장과 재시도 정책까지 달라지면 명령 추상화를 별도로 만듭니다.
표준 인터페이스의 기본 메서드도 의미가 있습니다.
Function의 compose는 인수 함수를 먼저, andThen은 현재 함수를 먼저 실행합니다.
Predicate의 and와 or는 단락 평가합니다.
Consumer의 andThen은 첫 소비자가 예외를 던지면 뒤 소비자를 실행하지 않습니다.
표준 인터페이스 조합
아래 프로그램은 원시 입력을 정규화하고 파싱한 뒤 연산자로 조정합니다.
Predicate가 통과한 값만 Consumer에 전달하며 Supplier가 추적 ID를 늦게 만듭니다.
각 함수 값은 한 책임만 가져 실행 순서를 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());
}
}Supplier를 반복문 밖에서 String id = traceId.get()으로 평가했다면 모든 판독값이 같은 ID를 공유합니다.
이 예제는 승인된 값마다 store 안에서 traceId.get()을 호출하므로 ID가 하나씩 증가합니다. 이 동작은 해당 람다의 구현이며 모든 Supplier의 공통 보장은 아닙니다.
지연은 비용을 피하는 도구이면서 관찰 시점을 정하는 계약입니다.
Consumer는 완료를 알리는 값이 없습니다.
호출자는 Consumer의 정상 반환과 예외를 구별할 수 있지만, 저장 결과를 값이나 상태 코드로 전달해야 한다면 Function이나 별도 결과형을 가진 명령이 적합합니다.
반환을 버리기 위해 Consumer를 선택하지 않습니다.
지연 실행과 메모이제이션
Supplier는 get()으로 결과를 제공한다는 계약이며 매번 재계산하거나 새 객체를 반환한다고 보장하지 않습니다. 아래 source는 호출될 때마다 본문을 실행합니다.
메모이제이션 래퍼는 첫 성공을 캐시해 이후 호출이 같은 값을 보게 합니다.
여러 스레드가 같은 캐시를 사용하려면 초기화와 값 게시를 동기화해야 합니다.
다음 구현은 synchronized get()으로 호출을 직렬화하고 첫 non-null 성공 값을 저장합니다. 예외와 null 실패는 캐시하지 않아 다음 호출이 다시 시도합니다. source가 같은 캐시를 재귀적으로 호출하는 재진입까지 막는 상태는 두지 않았습니다.
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());
}
}실패를 캐시할지 재시도할지는 유스케이스 정책입니다.
구성 파싱처럼 같은 입력으로 영원히 실패한다면 실패 캐시가 낫습니다.
외부 서비스 일시 장애라면 재시도가 필요할 수 있습니다.
성공한 값만 저장하는 세 번의 호출
| cached.get 호출 | 원본 source 호출 | 초기화 상태와 결과 |
|---|---|---|
| 첫 번째 호출 | 1회째: 예외 발생 | initialized=false 유지; temporary failure |
| 두 번째 호출 | 2회째: value-2 생성 | initialized=true; value-2 반환 |
| 세 번째 호출 | 호출하지 않음 | 저장된 value-2 반환 |
- 첫 번째 호출
- 원본 source 호출: 1회째: 예외 발생초기화 상태와 결과: initialized=false 유지; temporary failure
- 두 번째 호출
- 원본 source 호출: 2회째: value-2 생성초기화 상태와 결과: initialized=true; value-2 반환
- 세 번째 호출
- 원본 source 호출: 호출하지 않음초기화 상태와 결과: 저장된 value-2 반환
원문 main의 연속 호출을 따라간 표입니다. null 거절은 이 래퍼의 선택이며, 별도 initialized 플래그는 캐시된 null과 미계산 상태도 구분할 수 있습니다.
Predicate 평가와 진단
predicateA.and(predicateB)는 boolean 결과만 필요할 때 간결합니다.
어떤 규칙이 실패했는지 사용자에게 보여 줘야 한다면 Predicate만으로 정보가 부족합니다.
Rule(name, predicate) 목록을 순회해 실패 이름을 모을 수 있습니다.
모든 규칙 평가가 안전하고 부수 효과가 없어야 순서 변경과 전체 수집이 예측 가능합니다.
Operator는 동일 타입을 유지합니다.
문자열 normalize 체인이나 불변 값 갱신에 UnaryOperator가 맞고, 같은 타입 둘을 합치는 축약에는 BinaryOperator가 맞습니다.
결합법칙이 필요한 병렬 reduce에서 비결합 연산자를 사용하면 순서에 따라 결과가 달라질 수 있습니다.
연습 문제
회원 가입 입력에 빈 값 ID, 짧은 비밀번호, 허용하지 않은 이메일 도메인 규칙을 적용합니다.
Rule<T>은 이름과 Predicate를 가집니다.
빠른 실패와 전체 수집 두 실행 모드를 모두 제공하고 실제로 평가한 규칙 이름을 함께 반환합니다.
해답 보기
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", "user@other.net");
System.out.println(validate(rules, invalid, true));
System.out.println(validate(rules, invalid, false));
}
}같은 가입 입력에서 달라지는 규칙 평가 범위
| 실행 모드 | 평가한 규칙 | 실패로 수집한 규칙 수 |
|---|---|---|
| 빠른 실패 | id-required | 1개 |
| 전체 수집 | id-required, password-min-8, company-email | 3개 |
- 빠른 실패
- 평가한 규칙: id-required실패로 수집한 규칙 수: 1개
- 전체 수집
- 평가한 규칙: id-required, password-min-8, company-email실패로 수집한 규칙 수: 3개
풀이의 invalid 입력은 세 규칙을 모두 어깁니다. 이 결과는 해당 목록과 조건식에서 도출한 것이며, 조건식이 예외를 던지는 경우까지 위반 목록으로 바꾸지는 않습니다.
해답은 Predicate를 그대로 재사용하면서 규칙 이름을 별도 메타데이터로 보존합니다.
메시지 다국어화나 필드 위치가 필요하면 이름 대신 오류 코드와 메시지 인수를 반환하는 규칙 계약으로 확장합니다.