
람다는 혼자 떠다닐 수 없습니다. 들어갈 자리가 먼저 있어야 합니다.
1편에서 다섯 줄짜리 익명 클래스가 한 줄 람다로 줄어드는 것을 봤습니다. 그리고 마지막에 조건 하나가 붙었죠. 추상 메서드가 딱 하나인 인터페이스여야 한다는 것.
오늘은 그 인터페이스를 직접 만들어 봅니다. 그리고 만들다 보면 곧 알게 됩니다. 대부분은 만들 필요조차 없다는 것을요.
람다에는 타입이 없습니다
먼저 짚고 갈 것이 있습니다. 이 코드는 컴파일되지 않습니다.
var f = (a, b) -> a + b; // 오류
이상하죠. 람다식만 보면 무슨 일을 하는지 뻔한데 왜 안 될까요.
자바는 모든 값에 타입이 있어야 하는 언어입니다. 10은 int, "안녕"은 String입니다. 그런데 (a, b) -> a + b는 그 자체로는 타입이 없습니다. a가 정수인지 실수인지, 결과를 무엇으로 돌려주는지 적혀 있지 않으니까요.
그래서 자바는 람다가 들어갈 자리를 보고 타입을 알아냅니다.
Calculator add = (a, b) -> a + b;
왼쪽에 Calculator가 있으니 "아, calculate(int, int)를 구현하는 거구나" 하고 판단합니다. 이 자리 역할을 하는 것이 함수형 인터페이스입니다.
쉽게 — 람다는 열쇠고, 함수형 인터페이스는 자물쇠입니다. 열쇠만 들고 있으면 아무 일도 못 합니다. 어느 자물쇠에 꽂을지가 정해져야 비로소 의미가 생깁니다.
직접 만들어 보기
조건은 하나뿐입니다. 추상 메서드가 정확히 하나.
interface Greeter {
String greet(String name); // 추상 메서드 1개
}
이제 이 자리에 람다를 끼울 수 있습니다.
Greeter formal = name -> name + "님, 안녕하십니까";
Greeter casual = name -> name + "아, 안녕!";
System.out.println(formal.greet("길동")); // 길동님, 안녕하십니까
System.out.println(casual.greet("길동")); // 길동아, 안녕!
같은 자리에 다른 동작을 끼워 넣었습니다. 인사하는 방식 자체를 값처럼 주고받은 셈입니다. 1편에서 말한 "메서드를 값처럼 넘긴다"가 이런 모습입니다.
@FunctionalInterface — 실수를 미리 막습니다
여기서 사고가 하나 납니다. 몇 달 뒤 누군가 이 인터페이스에 메서드를 하나 더 붙였다고 해봅시다.
interface Greeter {
String greet(String name);
void reset(); // 누가 추가함
}
이 순간 Greeter는 함수형 인터페이스가 아니게 되고, 이 인터페이스를 쓰던 모든 람다가 한꺼번에 깨집니다. 정작 오류는 인터페이스가 아니라 람다를 쓴 파일들에서 납니다. 원인을 찾기 성가신 유형이죠.
이걸 막는 표시가 @FunctionalInterface입니다.
@FunctionalInterface
interface Greeter {
String greet(String name);
}
이렇게 붙여 두면 추상 메서드를 두 개 이상 만드는 순간 인터페이스 쪽에서 바로 오류가 납니다. 사고가 퍼지기 전에 진원지에서 잡히는 겁니다.
붙이지 않아도 람다는 동작합니다. 하지만 "이건 람다용 자리"라는 의도를 코드에 남기는 것이라 붙이는 편이 좋습니다. JAVA2에서 배운 @Override와 성격이 같습니다. 없어도 되지만 있으면 실수를 잡아 주죠.
하나만 세면 됩니다 — 추상 메서드
주의할 점이 있습니다. "메서드가 하나"가 아니라 "추상 메서드가 하나" 입니다. 아래는 메서드가 셋이지만 여전히 함수형 인터페이스입니다.
@FunctionalInterface
interface Greeter {
String greet(String name); // 추상 1개 — 이것만 셈
default String greetAll(String a, String b) { // default는 몸통이 있음
return greet(a) + " / " + greet(b);
}
static Greeter simple() { // static도 제외
return name -> "안녕, " + name;
}
}
default와 static 메서드는 이미 몸통이 있어서 람다가 구현할 대상이 아닙니다. 그래서 세지 않습니다. 자바가 기존 인터페이스에 기능을 덧붙이면서도 람다를 유지할 수 있었던 것이 이 규칙 덕분입니다.
그런데 대부분은 만들 필요가 없습니다
여기까지 만들어 놓고 보면 허무한 사실을 만납니다. Greeter 같은 것을 매번 만들 필요가 없다는 것. 자바가 자주 쓰는 모양을 미리 준비해 뒀거든요.

그림 1. 무엇을 받고 무엇을 주는지, 그 방향만 보면 구분됩니다.
| 이름 | 받는 것 | 주는 것 | 메서드 |
|---|---|---|---|
Supplier | 없음 | 값 | get() |
Consumer | 값 | 없음 | accept() |
Function | 값 | 바꾼 값 | apply() |
Predicate | 값 | true/false | test() |
코드로 보면 이렇습니다.
import java.util.function.*;
Supplier<String> now = () -> "지금은 밤입니다";
Consumer<String> print = s -> System.out.println(s);
Function<Integer, Integer> twice = n -> n * 2;
Predicate<Integer> isEven = n -> n % 2 == 0;
print.accept(now.get()); // 지금은 밤입니다
System.out.println(twice.apply(7)); // 14
System.out.println(isEven.test(7)); // false
이름이 어려워 보여도 뜻은 그대로입니다. Supplier는 공급자라 달라는 대로 주고, Consumer는 소비자라 받아서 쓰고 끝냅니다. Function은 변환기, Predicate는 판단기입니다.
값이 두 개 필요하면 BiFunction처럼 앞에 Bi가 붙은 형제들이 있고, 받은 것과 같은 타입으로 돌려줄 때 쓰는 UnaryOperator도 있습니다. 이름의 규칙만 알아 두면 나머지는 필요할 때 찾아 쓰면 됩니다.
Predicate가 특히 자주 보입니다
넷 중에서도 실제로 가장 자주 마주치는 것은 Predicate입니다. 거르는 일이 프로그래밍에서 워낙 흔하기 때문입니다.
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
nums.removeIf(n -> n % 2 != 0); // 홀수를 제거
System.out.println(nums); // [2, 4, 6]
removeIf가 받는 것이 바로 Predicate입니다. 조건을 값으로 넘겨 컬렉션이 알아서 걸러 내게 하는 것이죠. 다음 편에서 다룰 스트림도 이 방식을 그대로 씁니다.
직접 실행해 보기
네 가지를 한 번에 확인해 봅니다. 각 줄이 무엇을 받고 무엇을 주는지 보면서 돌려 보세요.
import java.util.function.*;
import java.util.*;
public class Main {
@FunctionalInterface
interface Greeter {
String greet(String name);
}
public static void main(String[] args) {
// 직접 만든 자리
Greeter formal = name -> name + "님, 안녕하십니까";
Greeter casual = name -> name + "아, 안녕!";
System.out.println(formal.greet("길동"));
System.out.println(casual.greet("길동"));
System.out.println();
// 자바가 준비해 둔 넷
Supplier<String> supplier = () -> "공급자가 만든 값";
Consumer<String> consumer = s -> System.out.println("소비자가 받음: " + s);
Function<Integer, Integer> function = n -> n * 2;
Predicate<Integer> predicate = n -> n % 2 == 0;
consumer.accept(supplier.get());
System.out.println("변환기 7 -> " + function.apply(7));
System.out.println("판단기 7은 짝수? " + predicate.test(7));
System.out.println();
// Predicate를 조건으로 넘기기
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
nums.removeIf(n -> n % 2 != 0);
System.out.println("홀수 제거: " + nums);
}
}📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.
Greeter를 하나 더 만들어 다른 인사말을 넣어 보거나, predicate의 조건을 n > 3으로 바꿔 보면 감이 더 옵니다.
단골 함정
첫째, 추상 메서드를 두 개 만드는 것. 가장 흔합니다. @FunctionalInterface를 붙여 두면 인터페이스 파일에서 바로 걸립니다.
둘째, 제네릭 타입을 빠뜨리는 것. Function twice = n -> n * 2; 처럼 쓰면 타입 정보가 없어 컴파일 오류나 형변환 문제가 생깁니다. JAVA3 3편에서 본 제네릭 그대로, Function<Integer, Integer>처럼 받을 타입과 줄 타입을 적어야 합니다.
셋째, Consumer에 값을 돌려주게 하는 것. Consumer는 반환형이 void입니다. s -> s.length()처럼 값을 돌려주는 람다는 여기 들어갈 수 없습니다. 돌려주고 싶다면 Function을 써야 합니다.
한 장 정리
| 항목 | 내용 |
|---|---|
| 함수형 인터페이스 | 추상 메서드가 정확히 하나인 인터페이스 |
| 왜 필요한가 | 람다에는 타입이 없어 들어갈 자리가 필요함 |
@FunctionalInterface | 추상 메서드가 늘면 인터페이스에서 바로 오류 |
default·static | 몸통이 있어 개수에서 제외 |
| Supplier / Consumer | 주기만 / 받기만 |
| Function / Predicate | 바꿔서 주기 / 참·거짓 판단 |
이것만 기억하면 됩니다. 함수형 인터페이스는 람다가 들어갈 자리입니다. 조건은 추상 메서드 하나뿐이고, @FunctionalInterface를 붙이면 나중에 누가 실수로 늘리는 것을 막아 줍니다. 그리고 자주 쓰는 자리는 이미 java.util.function에 만들어져 있으니, 먼저 찾아보고 없을 때 만드는 순서가 좋습니다.
미니 퀴즈
개념을 제대로 잡았는지 직접 풀어 봅니다. 정답은 눌러야 나옵니다.
미니 퀴즈
포켓코딩(JAVA4) 2편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 스트림 — 컬렉션을 흐름으로 다루기.