1. 왜 함수형 인터페이스가 등장했는가?
1.1 배경 및 목적
- 배경: Java 8 이전에는 메서드(동작) 하나를 파라미터로 넘기려면 거추장스러운 **익명 내부 클래스(Anonymous Inner Class)**를 써야 했다. (보일러플레이트 코드 발생)
- 목적: "함수를 변수처럼 다루자(동작 파라미터화)"는 함수형 프로그래밍의 니즈를 충족하고, 코드를 간결하게 만들기 위해 **람다(Lambda)**가 도입되었다.
- 정의: 람다식을 담을 수 있는 그릇, 그것이 바로 **함수형 인터페이스(Functional Interface)**다.
Code: Before & After
// [Before: Java 8 이전] 익명 내부 클래스 사용
// 코드가 뚱뚱하다. (핵심 로직보다 껍데기가 더 큼)
Collections.sort(list, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2; // 핵심 로직
}
});
// [After: Java 8 이후] 람다식 사용
// 껍데기를 버리고 핵심 로직만 남긴다.
Collections.sort(list, (o1, o2) -> o1 - o2);
2. 함수형 인터페이스의 정의와 조건
2.1 절대 규칙 (SAM: Single Abstract Method)
- 정의: "구현해야 할 추상 메서드가 오직 1개인 인터페이스"
- 이 조건이 충족되어야만 람다식(() -> {})으로 변환이 가능하다.
- @FunctionalInterface: 컴파일러에게 "추상 메서드 1개인지 검사해줘"라고 요청하는 어노테이션. (필수는 아니지만 실수를 방지하기 위해 권장)
2.2 예외 규칙 (추상 메서드가 아닌 것들)
추상 메서드 개수 카운팅에서 제외되는 3가지 요소 (이것들이 있어도 함수형 인터페이스다!)
- default 메서드: 하위 호환성을 위해 구현 코드를 제공하는 메서드. (오버라이딩 선택)
- static 메서드: 인터페이스 관련 유틸리티 도구 메서드.
- Object 클래스의 메서드: equals, toString 등 모든 객체가 기본적으로 상속받는 메서드.
- 예시: Comparator 인터페이스는 메서드가 많지만, 추상 메서드는 compare 하나뿐이라 함수형 인터페이스다.
@FunctionalInterface
public interface MyCustomFunction {
// 1. 추상 메서드 (오직 1개여야 함 -> 람다의 대상)
void run();
// 2. default/static/Object 메서드는 개수 제한 없음
default void printHello() { System.out.println("Hello"); }
static void printWorld() { System.out.println("World"); }
String toString();
}
3. 자바가 미리 만든 "표준 4대장" (java.util.function)
매번 인터페이스를 정의하기 귀찮으니, 자바에서 가장 많이 쓰는 패턴 4가지를 미리 만들어 두었다.
| 종류 | 영어 (Interface) | 메서드 | 입출력 흐름 | 역할 및 비유 |
| 공급자 | Supplier | get() | None -> Data | 자판기. 입력 없이 데이터를 만들어 냄. (Lazy Evaluation의 핵심) |
| 소비자 | Consumer | accept() | Data -> Void | 블랙홀. 데이터를 받아서 쓰고(출력, 저장) 끝냄. |
| 함수 | Function | apply() | Data A -> Data B | 믹서기. 값을 받아서 다른 값으로 변환함. |
| 조건 | Predicate | test() | Data -> Boolean | 검문소. 데이터를 받아 참/거짓을 판별함. |
// 1. Supplier (공급자): 주는 놈
// 용도: Lazy Evaluation (필요할 때 값을 생성)
Supplier<String> supplier = () -> "새로운 데이터 생성";
System.out.println(supplier.get());
// 2. Consumer (소비자): 받는 놈
// 용도: 출력, DB 저장, 이메일 발송 (리턴 없음)
Consumer<String> consumer = (str) -> System.out.println("출력: " + str);
consumer.accept("Hello");
// 3. Function (함수): 바꾸는 놈
// 용도: 매핑 (String -> Integer 변환 등)
Function<String, Integer> function = (str) -> str.length();
Integer len = function.apply("Hello"); // 5
// 4. Predicate (서술어): 검사하는 놈
// 용도: 필터링 조건 (boolean 반환)
Predicate<Integer> predicate = (num) -> num > 10;
boolean result = predicate.test(15); // true
참고: Comparator는 "정렬"이라는 특수 목적용이라 4대장에 끼지 않지만, 역시 함수형 인터페이스다.
4. 실무 활용 패턴 (Optional & Stream)
이 4대장은 Optional과 Stream을 사용할 때 부품으로 사용된다.
4.1 Optional에서의 활용
- orElseGet(() -> new ...) (Supplier): 값이 없을 때만 실행해서 가져와라. (Lazy)
- ifPresent(x -> ...) (Consumer): 값이 있으면 처리해라.
- map(x -> ...) (Function): 값을 꺼내서 변환해라.
- filter(x -> ...) (Predicate): 조건에 안 맞으면 빈 상자로 만들어라.
Optional<User> optionalUser = repository.findByName("chulsoo");
// 1. orElseGet (Supplier 사용)
// "값이 없으면 이거 실행해서 가져와"
User user = optionalUser.orElseGet(() -> new User("default"));
// 2. ifPresent (Consumer 사용)
// "값이 있으면 출력(소비)해"
optionalUser.ifPresent(u -> System.out.println(u.getName()));
// 3. map (Function 사용)
// "User 객체를 이메일 String으로 바꿔줘"
Optional<String> email = optionalUser.map(u -> u.getEmail());
// 4. filter (Predicate 사용)
// "나이가 20살 넘는지 검사해"
Optional<User> adult = optionalUser.filter(u -> u.getAge() >= 20);
4.2 Stream에서의 활용 (핵심!)
스트림 메서드들은 파라미터로 특정 함수형 인터페이스를 강제한다.
- filter(Predicate): x -> x > 10 (참인 것만 통과)
- map(Function): x -> x.getName() (객체 -> 이름으로 변환)
- forEach(Consumer): x -> System.out.println(x) (출력하고 종료)
- generate(Supplier): () -> Math.random() (무한으로 데이터 생성)
5. Stream API의 핵심 특징과 오개념 정리
5.1 Stream vs Collection
- Collection: 데이터의 **저장(공간)**이 목적. (DVD)
- Stream: 데이터의 **흐름과 계산(시간)**이 목적. (Netflix 스트리밍)
5.2 중간 연산 vs 최종 연산
- 중간 연산 (peek, map, filter):
- 작업 지시서만 작성한다. (Lazy Evaluation)
- 스트림을 리턴한다. (Chaining 가능)
- peek은 왜 쓰는가? -> 스트림을 끊지 않고 내부 상태를 확인(로깅)하기 위해.
- 최종 연산 (forEach, collect):
- 지시서를 실행(발동)시킨다.
- 스트림을 닫아버린다. (재사용 불가)
- forEach는 왜 Consumer인가? -> 데이터를 받아서 처리하고 아무것도 리턴하지 않기(Void) 때문에.
peek 와 foreach 차이!!
// [peek]: 중간 연산 (CCTV)
// 데이터를 쓱 훑어보고(로그 찍고) 다음 단계로 넘김.
list.stream()
.peek(x -> System.out.println("검사 중: " + x))
.map(x -> x * 2)
.collect(Collectors.toList());
// [forEach]: 최종 연산 (소각장)
// 데이터를 받아서 처리(출력)하고 스트림을 종료시킴. 리턴이 void.
list.stream()
.map(x -> x * 2)
.forEach(x -> System.out.println("최종 출력: " + x));
5.3 Map은 왜 바로 stream()이 안 되는가?
- 족보 문제: Map은 Collection 인터페이스를 상속받지 않는다.
- 구조 문제: Key를 줄지 Value를 줄지 모호하다.
- 해결: entrySet()을 호출하여 Set(Collection의 자식)으로 변환한 뒤 stream을 연다. (Collection View 개념)
map으로 stream 사용법
Map<String, Integer> map = new HashMap<>();
// map.stream() -> [Error!] 불가능
// entrySet()을 통해 Set(Collection)으로 'View'를 변환 후 사용
map.entrySet().stream()
.filter(entry -> entry.getValue() > 10) // Predicate
.map(entry -> entry.getKey()) // Function
.forEach(System.out::println); // Consumer
6. 결론: 포함 관계 정리
- 함수형 인터페이스 (Engine): 가장 기초가 되는 개념. 함수를 다루는 규격.
- 람다 (Cable): 함수형 인터페이스를 구현하는 간결한 문법.
- 스트림 (Machine): 함수형 인터페이스와 람다를 활용해 데이터를 우아하게 처리하는 응용 기술.
"결국 스트림을 잘 쓴다는 것은, 적재적소에 **4대장(Supplier, Consumer, Function, Predicate)**을 람다로 잘 끼워 넣는다는 뜻이다."
'자바 > 자바' 카테고리의 다른 글
| (자바) Apache Kafka 핵심 개념 (RabbitMQ <->Redis (Pub/Sub) <->Apache Kafka 차이 ) (0) | 2025.12.15 |
|---|---|
| [Java] I/O 스트림과 안전한 자원 관리: Try-with-Resources 완벽 분석 (Try-Catch-Finally 비교) (0) | 2025.12.04 |
| (자바) JVM 메모리 관리와 GC, 객체 동작 핵심 정리 *널널한 개발자 (0) | 2025.09.09 |
| (자바)JVM, 클래스 로딩, 런타임 메모리 구조 정리 *널널한 개발자 (0) | 2025.09.09 |
| (자바) 자바 상속, 다형성, 추상화 *널널한 개발자 (0) | 2025.09.08 |