자바 함수형 마스터 노트 시리즈 3편. java.util.function 패키지의 12개 인터페이스 한 흐름 정리. Supplier/Consumer/Predicate/Function 4대 기본형, Bi 변형 4종, Unary·BinaryOperator의 동일 타입 단축형, Runnable·Callable의 차이까지. 각 인터페이스의 default 메서드(andThen·compose·and·or·negate)와 실전 예시.
이 글은 자바 함수형 마스터 노트 시리즈의 세 번째 편입니다. 2편(람다)에서 람다 문법을 익혔다면, 이번엔 그 람다를 받을 그릇 — java.util.function 패키지 12개 인터페이스.
매번 Comparator 같은 자기 인터페이스 만들 필요 없이, 자바가 표준 12개를 미리 준비. 4편 Stream API의 모든 메서드가 이 12개를 받습니다. 이름이 비슷해 헷갈리지만 시그니처 패턴으로 묶으면 한 번에 정리됩니다.
처음 함수형 인터페이스가 어렵게 느껴지는 이유
처음 강의를 들을 때 함수형 인터페이스 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 이름이 너무 비슷비슷합니다. Supplier·Consumer·Function·Predicate·UnaryOperator·BinaryOperator… 한 번에 들어오면 머리가 멍해집니다. 둘째, "왜 이게 다 따로 있어야 하나"가 안 보여요. 일반 인터페이스 하나로 통합 안 되나? 같은 의문이 자꾸 듭니다.
해결법은 한 가지예요. 각 인터페이스를 **"입력·출력 패턴 한 줄"**로 줄이는 것. Supplier = () → T, Consumer = T → void, Predicate = T → boolean, Function = T → R. 이 4가지만 외우면 나머지 8개는 변형(Bi, Unary, Binary, Runnable, Callable)일 뿐입니다.
4대 기본 인터페이스 — 패턴으로 묶기
| 인터페이스 | 시그니처 | 한 줄 비유 |
|---|---|---|
| Supplier<T> | () → T |
값 공급자 — 자판기 |
| Consumer<T> | T → void |
값 소비자 — 우체통 |
| Predicate<T> | T → boolean |
검증기 — 출입 검사대 |
| Function<T,R> | T → R |
변환기 — 환전소 |
입력 0/1 + 출력 void/boolean/R 조합이 4가지 기본형. 외울 땐 이 표를 머리에 그려두면 끝.
Supplier<T> — () → T
값 공급. 입력 X, 출력 T.
Supplier<String> greeter = () -> "Hello";
String msg = greeter.get(); // "Hello"
Supplier<List<String>> listFactory = ArrayList::new;
List<String> list = listFactory.get();
사용처 — 지연 초기화, 팩토리 패턴, 기본값 제공.
String value = optional.orElseGet(() -> computeExpensive()); // 필요할 때만 호출
여기서 시험 함정이 하나 있어요. orElse(default) vs orElseGet(supplier). orElse는 default를 항상 평가, orElseGet은 필요할 때만. 비싼 기본값엔 orElseGet이 맞습니다.
Consumer<T> — T → void
값 소비. 입력 T, 출력 X.
Consumer<String> printer = System.out::println;
printer.accept("Hello"); // Hello
list.forEach(printer); // 모든 요소 출력
andThen() — 체이닝
Consumer<String> log = s -> System.out.println("LOG: " + s);
Consumer<String> save = s -> db.save(s);
Consumer<String> chained = log.andThen(save);
chained.accept("data");
// 출력: LOG: data
// 그 후 db.save("data") 실행
같은 입력으로 순차 실행. 둘 다 같은 인자를 받음.
Predicate<T> — T → boolean
조건 검증. 입력 T, 출력 boolean.
Predicate<Integer> isPositive = n -> n > 0;
boolean result = isPositive.test(5); // true
list.stream().filter(isPositive).count();
and() / or() / negate() — 조합
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> positiveAndEven = isPositive.and(isEven);
Predicate<Integer> positiveOrEven = isPositive.or(isEven);
Predicate<Integer> notPositive = isPositive.negate();
positiveAndEven.test(4); // true
positiveAndEven.test(3); // false
notPositive.test(-1); // true
여기서 정말 중요한 시험 함정 — Predicate 조합으로 동적 쿼리 빌더 가능. 사용자 입력에 따라 Predicate를 동적으로 합쳐 filter에 넘기는 패턴.
Function<T,R> — T → R
값 변환. 입력 T, 출력 R.
Function<String, Integer> length = String::length;
int len = length.apply("Hello"); // 5
Function<Integer, String> doubler = n -> "Result: " + (n * 2);
String s = doubler.apply(5); // "Result: 10"
andThen() / compose() — 합성
Function<Integer, Integer> plus1 = x -> x + 1;
Function<Integer, Integer> times2 = x -> x * 2;
// andThen: 먼저 자기 → 다음 적용
Function<Integer, Integer> a = plus1.andThen(times2);
a.apply(3); // (3+1)*2 = 8
// compose: 먼저 인자 → 자기 적용
Function<Integer, Integer> b = plus1.compose(times2);
b.apply(3); // (3*2)+1 = 7
여기서 시험 함정이 하나 있어요. andThen vs compose 헷갈림. f.andThen(g) = g(f(x)), f.compose(g) = f(g(x)). andThen = "그 다음" / compose = "먼저".
Identity
Function<String, String> id = Function.identity();
id.apply("Hello"); // "Hello"
// 사용 — Map의 키·값 그대로 받기
Map<Person, Person> map = list.stream()
.collect(Collectors.toMap(Function.identity(), Function.identity()));
Bi 변형 4종 — 인자 2개
기본 4개의 2-인자 버전.
| 인터페이스 | 시그니처 |
|---|---|
| BiConsumer<T,U> | (T, U) → void |
| BiPredicate<T,U> | (T, U) → boolean |
| BiFunction<T,U,R> | (T, U) → R |
BiConsumer
BiConsumer<String, Integer> logger = (k, v) -> System.out.println(k + "=" + v);
map.forEach(logger);
BiPredicate
BiPredicate<String, Integer> isLongerThan = (s, n) -> s.length() > n;
isLongerThan.test("Hello", 3); // true
BiFunction
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
int sum = add.apply(3, 4); // 7
BiFunction<String, String, Person> personFactory = Person::new;
여기서 시험 함정이 하나 있어요. BiSupplier는 없음. Supplier는 입력이 0개라 "Bi"가 의미 X. () → T 1종뿐.
Unary·Binary Operator — 동일 타입 단축형
특수 케이스 — 입력과 출력 타입이 같음.
| 인터페이스 | 시그니처 | 동치 |
|---|---|---|
| UnaryOperator<T> | T → T |
Function<T, T> |
| BinaryOperator<T> | (T, T) → T |
BiFunction<T, T, T> |
UnaryOperator
UnaryOperator<String> upper = String::toUpperCase;
String s = upper.apply("hello"); // "HELLO"
list.replaceAll(upper); // 모든 요소 대문자로
BinaryOperator
BinaryOperator<Integer> max = Integer::max;
int m = max.apply(3, 7); // 7
list.stream().reduce(0, BinaryOperator.maxBy(...));
여기서 정말 중요한 시험 함정 — reduce()가 BinaryOperator 받음. 누적 연산은 항상 같은 타입이라 BinaryOperator 시그니처. Stream 4편에서 다시.
Runnable — () → void
가장 단순한 함수형 인터페이스. 자바 1.0부터 있던 것을 8에서 함수형 인터페이스로 재해석.
Runnable r = () -> System.out.println("Running");
r.run();
new Thread(r).start(); // 별도 스레드 실행
ExecutorService.submit(r);
여기서 시험 함정이 하나 있어요. Runnable은 java.lang.Runnable이지만 함수형 인터페이스. java.util.function에 없음. Consumer<Void>가 아닌 Runnable 사용이 표준.
Callable<T> — () → T (with checked exception)
Runnable과 비슷하지만 반환값 + checked exception 던질 수 있음.
Callable<String> task = () -> {
Thread.sleep(1000); // checked exception 가능
return "Done";
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(task);
String result = future.get(); // "Done"
| 구분 | Runnable | Callable<T> |
|---|---|---|
| 반환 | void | T |
| Checked Exception | X | O |
| 시그니처 | run() |
call() |
여기서 정말 중요한 시험 함정 — Supplier는 Checked Exception X, Callable은 O. 시그니처가 같아 보여도 다릅니다. 파일 I/O 같은 IOException 던지는 작업엔 Callable.
표준 12 함수형 인터페이스 한눈에
| # | 인터페이스 | 시그니처 | 메서드 |
|---|---|---|---|
| 1 | Supplier<T> | () → T |
get() |
| 2 | Consumer<T> | T → void |
accept(t) |
| 3 | BiConsumer<T,U> | (T,U) → void |
accept(t,u) |
| 4 | Predicate<T> | T → boolean |
test(t) |
| 5 | BiPredicate<T,U> | (T,U) → boolean |
test(t,u) |
| 6 | Function<T,R> | T → R |
apply(t) |
| 7 | BiFunction<T,U,R> | (T,U) → R |
apply(t,u) |
| 8 | UnaryOperator<T> | T → T |
apply(t) |
| 9 | BinaryOperator<T> | (T,T) → T |
apply(t,t) |
| 10 | Runnable | () → void |
run() |
| 11 | Callable<T> | () → T (throws) |
call() |
| 12 | (원시) IntPredicate, IntFunction 등 | 원시 타입 변형 | 같음 |
원시 타입 변형 — 박싱 비용 회피
// 박싱 비용 발생
Predicate<Integer> p = n -> n > 5;
// 원시 변형 — 박싱 X
IntPredicate ip = n -> n > 5;
ip.test(10); // 빠름
대표:
IntPredicate,LongPredicate,DoublePredicateIntFunction<R>,IntToLongFunction,IntToDoubleFunctionIntConsumer,IntSupplier,IntUnaryOperator,IntBinaryOperator
여기서 시험 함정이 하나 있어요. IntStream 같은 원시 스트림은 원시 함수형 인터페이스 받음. Stream<Integer>와 IntStream이 다른 이유. 4편에서 다시.
default 메서드 정리
| 인터페이스 | default 메서드 |
|---|---|
| Consumer | andThen(after) |
| Predicate | and(other), or(other), negate() |
| Function | andThen(after), compose(before) |
| BiConsumer | andThen(after) |
| BiFunction | andThen(after) |
| Predicate (static) | Predicate.not(p), Predicate.isEqual(target) |
실전 — Predicate 조합 빌더 패턴
public class CriteriaFactory {
public static Predicate<Person> ageOver(int age) {
return p -> p.getAge() > age;
}
public static Predicate<Person> nameStartsWith(String prefix) {
return p -> p.getName().startsWith(prefix);
}
public static Predicate<Person> livesIn(String city) {
return p -> p.getCity().equals(city);
}
}
// 사용
Predicate<Person> filter = CriteriaFactory.ageOver(20)
.and(CriteriaFactory.nameStartsWith("A"))
.and(CriteriaFactory.livesIn("Seoul"));
list.stream().filter(filter).collect(toList());
여기서 정말 중요한 시험 함정 — Predicate 빌더 패턴 = 동적 쿼리·필터의 표준. JPA Specification, MyBatis 동적 쿼리, 검색 필터 모두 이 패턴 응용.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
java.util.function= 표준 함수형 인터페이스 패키지- 4 기본 — Supplier / Consumer / Predicate / Function
- Supplier
() → T(자판기) - Consumer
T → void(우체통) - Predicate
T → boolean(검사대) - Function
T → R(환전소) - Bi 변형 4종 — BiConsumer / BiPredicate / BiFunction
- BiSupplier 없음 — 입력 0개라 의미 X
- UnaryOperator =
T → T(Function 단축) - BinaryOperator =
(T,T) → T(BiFunction 단축) - reduce()가 BinaryOperator 받음
- Consumer
andThen()= 같은 입력 순차 실행 - Predicate
and / or / negate= 조건 조합 - Function
andThen / compose= 함수 합성 - andThen = "그 다음" / compose = "먼저"
Function.identity()= 그대로 반환 (Map 변환에 유용)- Runnable
() → void(java.lang.Runnable) - Callable<T>
() → T (throws)— checked exception O - Supplier vs Callable — Callable만 checked exception
- 원시 타입 변형 — IntPredicate·IntFunction 등 (박싱 회피)
- IntStream이 원시 함수형 인터페이스 받음
- Predicate 빌더 패턴 = 동적 쿼리·필터 표준
orElsevsorElseGet— orElse는 항상 평가, orElseGet은 lazy- 비싼 기본값엔 orElseGet
시리즈 다른 편
- 1편 — JVM·OOP·컬렉션 기초
- 2편 — 람다 표현식
- 3편 — 함수형 인터페이스 (현재 글)
- 4편 — Stream API
- 5편 — Modern Java (9~17)
- 6편 — Java 21 가상 스레드
공식 문서: Oracle java.util.function Package 에서 더 깊이.
다음 글(4편)에서는 Stream API — 6대 특성, 중간/최종 연산 전체, Collectors, Optional, 병렬 스트림까지 풀어 갑니다.