1. 함수형 인터페이스 깊게 이해하기
함수형 인터페이스는 정확히 하나의 추상 메서드(즉, 구현되지 않은 메서드)만 가지는 인터페이스입니다. 이 덕분에 Java는 “아하, 여기에 람다를 넣을 수 있겠네!” 혹은 메서드 참조를 사용할 수 있음을 이해합니다.
혼동을 줄이기 위해 이러한 인터페이스에는 보통 @FunctionalInterface 애너테이션을 붙입니다. 필수는 아니지만, 붙여두고 실수로 두 번째 추상 메서드를 작성하면 컴파일러가 즉시 오류를 알려줍니다.
예:
@FunctionalInterface
interface MyAction {
void run();
}
MyAction action = () -> System.out.println("람다에서 인사!");
action.run(); // 출력: 람다에서 인사!
왜 필요한가?
- 익명 클래스를 생성하는 대신 람다식과 메서드 참조를 사용할 수 있습니다(보일러플레이트 코드가 줄어듭니다!).
- 컴파일러에게 이 인터페이스가 함수형 프로그래밍을 위한 것임을 알려줍니다.
흥미로운 사실: 표준 Java 라이브러리에는 이미 수십 개의 이런 인터페이스가 있으니 새로 만들 필요가 없습니다!
2. 표준 함수형 인터페이스 살펴보기
java.util.function 패키지에는 수십 개의 함수형 인터페이스가 있습니다. 그중 가장 흔히 쓰이는 네 가지를 살펴봅시다(가장 “방문율”이 높은 인터페이스들입니다).
| 인터페이스 | 입력 | 반환 | 주 용도 |
|---|---|---|---|
|
|
|
조건 검사(필터링) |
|
|
|
객체에 대해 동작 수행 |
|
없음 | |
객체 제공/생성 |
|
|
|
T를 R로 변환 |
Predicate<T>
설명: 타입 T의 객체를 받아 true 또는 false를 반환하는 함수입니다. 대표 예시는 리스트 필터링입니다. 핵심 메서드는 test입니다.
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("Java")); // false
System.out.println(isLong.test("Functional")); // true
Consumer<T>
설명: 타입 T의 객체를 받아 어떤 동작을 수행하고, 아무것도 반환하지 않습니다. 핵심 메서드는 accept입니다.
Consumer<String> printer = s -> System.out.println("출력: " + s);
printer.accept("Hello, world!"); // 출력: Hello, world!
Supplier<T>
설명: 아무것도 받지 않고 타입 T의 객체를 반환합니다. 값을 “생성하는” 공급자로 생각할 수 있습니다. 핵심 메서드는 get입니다.
Supplier<Double> randomSupplier = () -> Math.random();
System.out.println(randomSupplier.get()); // 예: 0.1234567
Function<T, R>
설명: 타입 T의 객체를 받아 타입 R의 객체를 반환합니다. 대표 예시는 데이터 변환입니다. 핵심 메서드는 apply입니다.
Function<String, Integer> stringToLength = s -> s.length();
System.out.println(stringToLength.apply("Java")); // 4
요약: UnaryOperator, BinaryOperator, BiFunction
- UnaryOperator<T> — Function<T, T>와 동일: 입력과 반환 타입이 동일합니다.
- BinaryOperator<T> — BiFunction<T, T, T>와 동일: T 두 개를 받아 T 하나를 반환합니다.
- BiFunction<T, U, R> — 서로 다른 두 타입을 받아 세 번째 타입을 반환합니다.
UnaryOperator<Integer> square = x -> x * x;
BinaryOperator<Integer> sum = (a, b) -> a + b;
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
3. 사용 예시
이 인터페이스들이 실제 과제에서, 특히 컬렉션과 Stream API에서 어떻게 쓰이는지 살펴봅시다.
컬렉션 및 Stream API 메서드에 전달
예제 1: Predicate와 필터링
List<String> words = List.of("java", "stream", "lambda", "code");
List<String> longWords = words.stream()
.filter(word -> word.length() > 4) // Predicate<String>
.toList();
System.out.println(longWords); // [stream, lambda]
예제 2: Consumer와 forEach
words.forEach(word -> System.out.println("단어: " + word)); // Consumer<String>
예제 3: Function과 map
List<Integer> lengths = words.stream()
.map(word -> word.length()) // Function<String, Integer>
.toList();
System.out.println(lengths); // [4, 6, 6, 4]
예제 4: Supplier와 값 생성
Supplier<String> greetingSupplier = () -> "안녕, Java!";
System.out.println(greetingSupplier.get()); // 안녕, Java!
익명 클래스와 비교
예전에는 이렇게 작성했습니다:
Predicate<String> isShort = new Predicate<String>() {
@Override
public boolean test(String s) {
return s.length() < 5;
}
};
람다를 쓰면 훨씬 간결합니다:
Predicate<String> isShort = s -> s.length() < 5;
4. 실습: 각 인터페이스에 대한 람다 작성
간단한 애플리케이션 — 사용자 목록 — 을 만들어 봅시다. 각 사용자는 User 클래스로 표현합니다:
public class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public String toString() {
return name + " (" + age + ")";
}
}
사용자 목록을 생성합니다:
List<User> users = List.of(
new User("Anna", 23),
new User("Boris", 17),
new User("Vika", 31),
new User("Gosha", 15)
);
Predicate: 성인 필터링
Predicate<User> isAdult = user -> user.getAge() >= 18;
List<User> adults = users.stream()
.filter(isAdult)
.toList();
System.out.println("성인: " + adults); // 성인: [Anna (23), Vika (31)]
Consumer: 사용자 출력
Consumer<User> printUser = user -> System.out.println("사용자: " + user);
adults.forEach(printUser);
Supplier: 사용자 생성
Supplier<User> randomUserSupplier = () -> {
String[] names = {"Dima", "Katya", "Lyosha"};
int randomAge = 10 + (int)(Math.random() * 30);
String randomName = names[(int)(Math.random() * names.length)];
return new User(randomName, randomAge);
};
User randomUser = randomUserSupplier.get();
System.out.println("무작위 사용자: " + randomUser);
Function: 사용자 이름 가져오기
Function<User, String> getName = user -> user.getName();
List<String> names = users.stream()
.map(getName)
.toList();
System.out.println("이름: " + names); // 이름: [Anna, Boris, Vika, Gosha]
5. 유용한 팁
Stream API에서의 사용: filter, map, forEach 등
모든 것을 모아 변환 체인을 작성해 봅시다:
users.stream()
.filter(user -> user.getAge() >= 18) // Predicate<User>
.map(user -> user.getName().toUpperCase()) // Function<User, String>
.forEach(name -> System.out.println("성인: " + name)); // Consumer<String>
결과:
성인: ANNA
성인: VIKA
치트시트: 어디에 무엇을 넣는가
| 사용 위치 | 필요한 인터페이스 | 사용 예 |
|---|---|---|
| filter (Stream) | |
|
| map (Stream) | |
|
| forEach (Stream, List) | |
|
| generate (Stream) | |
|
왜 함수형 인터페이스를 알아야 할까?
- Java의 모든 람다식의 기반을 이룹니다.
- 범용적이고 재사용 가능하며 간결한 코드를 작성할 수 있습니다.
- 컬렉션, 스트림, 비동기 작업을 더 쉽게 다룰 수 있습니다.
어떤 인터페이스를 언제 사용할까?
- Predicate — 조건을 검사할 때(필터링, 검색).
- Consumer — 객체에 대해 어떤 동작을 수행할 때(출력, 기록, 전송).
- Supplier — 객체를 제공하거나 생성할 때(팩터리, 제너레이터).
- Function — 한 타입의 객체를 다른 타입으로 변환할 때.
6. 함수형 인터페이스 작업 시 흔한 실수
오류 1: 인터페이스를 잘못 선택. 초보자는 Predicate와 Function을 혼동해, Function에서 boolean을 반환하려 하는 경우가 있습니다. 기억하세요: Predicate는 항상 boolean을 반환하고, Function은 그 외 임의의 타입을 반환합니다.
오류 2: 표준 인터페이스를 사용하지 않음. 종종 boolean check(T t) 메서드를 가진 “Checker” 같은 자체 인터페이스를 만듭니다. 하지만 Predicate 같은 표준 인터페이스를 사용하는 편이 좋습니다. 어디서나 지원되며 다른 개발자에게 코드가 더 명확해집니다.
오류 3: 람다가 지나치게 복잡함. 람다가 10줄짜리 미니 소설이 되어가면, 별도 메서드나 클래스로 빼는 것이 좋습니다. 람다는 간결함과 가독성을 위한 것입니다.
오류 4: @FunctionalInterface 애너테이션을 잊음. 직접 함수형 인터페이스를 정의할 때는 이 애너테이션을 잊지 마세요. 두 번째 추상 메서드를 실수로 추가하는 등의 오류를 막아줍니다.
오류 5: 람다 내부에서 가변 상태를 사용. 람다가 외부 변수나 컬렉션을 변경하면, 특히 멀티스레드 환경에서 예기치 않은 버그로 이어질 수 있습니다. 부작용은 가급적 피하세요.
GO TO FULL VERSION