정렬 기준을 넘기는 코드가 어떻게 변해 왔는지 보면 람다의 목적이 보입니다.
// JDK 1.1 ~ 7: 익명 클래스(anonymous class). 6줄 중 실제 로직은 1줄
Collections.sort(orders, new Comparator<Order>() {
@Override
public int compare(Order a, Order b) {
return Long.compare(a.amount(), b.amount());
}
});
// JDK 8: 람다. 로직만 남음
orders.sort((a, b) -> Long.compare(a.amount(), b.amount()));
// JDK 8: 메서드 참조 + Comparator 유틸
orders.sort(Comparator.comparingLong(Order::amount));익명 클래스의 문제는 의식(ceremony) 이 로직을 가린다는 점입니다. 클래스 이름, 메서드 시그니처, @Override, 중괄호 — 전부 컴파일러가 이미 알고 있는 정보입니다. 람다는 "컴파일러가 추론할 수 있는 것은 전부 생략"한 형태입니다.
(int a, int b) -> { return a + b; } // 완전한 형태: 타입 명시, 블록 본문
(a, b) -> { return a + b; } // 타입 추론
(a, b) -> a + b // 표현식 본문: return 과 중괄호 생략
a -> a * 2 // 파라미터 1개: 괄호 생략
() -> 42 // 파라미터 없음
() -> { System.out.println("hi"); } // void 반환 블록
() -> System.out.println("hi") // void 표현식
(var a, var b) -> a + b // JDK 11+: var 사용 (어노테이션 붙일 때 유용)| 규칙 | 설명 |
|---|---|
| 파라미터 타입 | 전부 생략하거나 전부 명시. 섞으면 에러 |
| 괄호 | 파라미터가 정확히 1개이고 타입 생략 시에만 생략 가능 |
| 표현식 본문 | 결과가 반환값. void 인터페이스면 결과 버림 |
| 블록 본문 | return 필수(반환 타입이 있으면), 여러 문장 가능 |
람다는 그 자체로는 타입이 없습니다. 대입되는 자리의 타입(target type, 목표 타입) 이 함수형 인터페이스일 때 그 인터페이스의 인스턴스로 해석됩니다. 같은 () -> 42 가 Supplier<Integer> 도, Callable<Integer> 도, IntSupplier 도 될 수 있습니다. 그래서 var f = () -> 42; 는 컴파일 에러입니다 — 목표 타입이 없으니까요.
추상 메서드가 정확히 하나인 인터페이스입니다. 람다는 그 하나의 추상 메서드를 구현하는 것으로 해석됩니다. 추상 메서드가 둘이면 어느 것을 구현하는지 모호하므로 람다를 쓸 수 없습니다.
@FunctionalInterface // 추상 메서드가 1개가 아니면 컴파일 에러 (실수 방지용, 선택)
public interface FeeCalculator {
long calculate(long amount); // 유일한 추상 메서드 (SAM: Single Abstract Method)
default FeeCalculator plus(long fixed) { // default 메서드는 개수 무관
return amt -> calculate(amt) + fixed;
}
static FeeCalculator none() { return amt -> 0; } // static 메서드도 무관
// boolean equals(Object o); ← Object 의 public 메서드 재선언은 세지 않음
}Runnable, Comparator<T>, Callable<V>, ActionListener 처럼 JDK 8 이전부터 있던 SAM 인터페이스도 자동으로 함수형 인터페이스입니다. 어노테이션은 "의도 선언"일 뿐 필수가 아닙니다.
매번 인터페이스를 만들지 않도록 JDK 가 43개를 제공합니다. 핵심 6개 + 변형만 외우면 됩니다.
| 인터페이스 | 시그니처 | 의미 | 대표 용도 |
|---|---|---|---|
Function<T, R> |
R apply(T t) |
T 를 R 로 변환 | map(), DTO 변환 |
BiFunction<T, U, R> |
R apply(T t, U u) |
두 입력 → 하나 | Map.merge, 합치기 |
Supplier<T> |
T get() |
입력 없이 생산 | 지연 생성, orElseGet, 팩토리 |
Consumer<T> |
void accept(T t) |
소비, 반환 없음 | forEach, 콜백, 로깅 |
BiConsumer<T, U> |
void accept(T t, U u) |
두 개 소비 | Map.forEach |
Predicate<T> |
boolean test(T t) |
조건 판정 | filter, 검증 |
BiPredicate<T, U> |
boolean test(T t, U u) |
두 입력 판정 | |
UnaryOperator<T> |
T apply(T t) |
같은 타입 변환 (Function<T,T>) |
replaceAll, 정규화 |
BinaryOperator<T> |
T apply(T a, T b) |
같은 타입 둘 → 하나 (BiFunction<T,T,T>) |
reduce, 합계·최대 |
기본형 특화(primitive specialization): Function<Integer, Integer> 는 박싱/언박싱 비용이 듭니다. 이를 피하려고 int/long/double 전용 버전이 있습니다.
| 이름 패턴 | 예 | 시그니처 |
|---|---|---|
IntXxx (입력이 int) |
IntFunction<R>, IntPredicate, IntConsumer, IntUnaryOperator |
R apply(int), boolean test(int) ... |
ToIntXxx (출력이 int) |
ToIntFunction<T>, ToIntBiFunction<T,U> |
int applyAsInt(T) |
IntToXxx (int → 다른 기본형) |
IntToLongFunction, IntToDoubleFunction |
long applyAsLong(int) |
XxxSupplier |
IntSupplier, BooleanSupplier |
int getAsInt() |
ObjIntConsumer<T> |
void accept(T, int) |
long, double 도 같은 패턴입니다. 스트림의 mapToInt(ToIntFunction), IntStream.map(IntUnaryOperator) 가 이것들을 받습니다.
이미 있는 메서드를 람다 대신 :: 로 가리킵니다. 람다 본문이 "메서드 하나 호출"일 때만 가능합니다.
| 종류 | 문법 | 동등한 람다 | 예 |
|---|---|---|---|
| 정적 메서드 | Class::staticMethod |
x -> Class.staticMethod(x) |
Integer::parseInt, Math::abs |
| 특정 객체의 인스턴스 메서드 | obj::method |
x -> obj.method(x) |
System.out::println, this::validate |
| 임의 객체의 인스턴스 메서드 | Class::instanceMethod |
x -> x.method() 또는 (x, y) -> x.method(y) |
String::length, String::compareTo |
| 생성자 | Class::new |
x -> new Class(x) |
ArrayList::new, Order::new |
세 번째가 헷갈립니다.
String::length 는 첫 번째 파라미터가 수신 객체(receiver) 가 됩니다: Function<String, Integer> f = String::length → s -> s.length(). String::compareTo 는 BiFunction<String, String, Integer> → (a, b) -> a.compareTo(b). 첫 인자가 this 역할, 나머지가 메서드 인자입니다.
생성자 참조는 목표 타입에 따라 어떤 생성자를 고를지 결정됩니다: Supplier<List<String>> s = ArrayList::new 는 기본 생성자, Function<Integer, List<String>> f = ArrayList::new 는 ArrayList(int capacity) 입니다. 배열도 됩니다: IntFunction<int[]> arr = int[]::new.
람다는 자기 밖의 지역 변수를 쓸 수 있습니다(캡처, capture). 단, 그 변수는 final 이거나 사실상 final(effectively final, 선언 후 한 번도 재대입되지 않음) 이어야 합니다.
int base = 100;
Function<Integer, Integer> add = x -> x + base; // OK: base 는 effectively final
base = 200; // 이 줄을 추가하면 위 람다가 컴파일 에러: "local variables referenced from a lambda expression must be final or effectively final"왜 그럴까요? 스택 프레임 때문입니다.
main() 스택 프레임 힙
┌─────────────────┐ ┌────────────────────────┐
│ int base = 100 │ ── 복사(캡처) ───► │ 람다 객체 { base = 100 } │
└─────────────────┘ └────────────────────────┘
main 이 끝나면 사라짐 나중에, 다른 스레드에서도 실행될 수 있음지역 변수는 스택에 있고, 메서드가 끝나면 사라집니다. 람다 객체는 힙에 있고, 메서드가 끝난 뒤에도(스레드 풀에 넘겨졌다면 다른 스레드에서도) 실행될 수 있습니다. 그래서 람다는 변수를 참조하는 것이 아니라 값을 복사해서 가집니다.
복사본이니까 원본이 나중에 바뀌면 둘이 달라집니다 — 자바 설계자들은 "복사본인데 원본처럼 보이는" 혼란을 막으려고 아예 재대입을 금지했습니다. C++ 이나 JavaScript 처럼 참조 캡처를 허용하면 스레드 안전성 문제도 함께 따라오므로, 불변 캡처는 동시성 관점에서도 안전한 선택입니다.
주의할 점: 제한은 변수 재대입이지 객체 변경이 아닙니다.
List<String> log = new ArrayList<>();
Runnable r = () -> log.add("hit"); // OK: log 변수는 재대입 안 함. 리스트 내용 변경은 자유
int[] counter = {0};
Runnable inc = () -> counter[0]++; // 편법: 배열 요소 변경. 단일 스레드에서만 쓸 것인스턴스 필드나 static 필드는 힙에 있으므로 제한 없이 읽고 쓸 수 있습니다(단, 스레드 안전성은 별개).
this 와 컴파일 방식겉보기엔 같지만 두 가지가 근본적으로 다릅니다.
| 익명 클래스 | 람다 | |
|---|---|---|
this 의 의미 |
익명 클래스 자기 자신 | 람다를 감싸는 객체(enclosing instance) |
| 컴파일 결과 | Outer$1.class 파일이 생성됨 |
클래스 파일 없음. invokedynamic + private static 메서드 |
| 객체 생성 | 매번 new (캡처 없어도) |
캡처 없으면 싱글턴 재사용, 캡처 있으면 생성 |
| 변수 섀도잉 | 바깥 변수와 같은 이름 선언 가능 | 불가 (같은 스코프로 취급) |
| 추상 메서드 여러 개인 인터페이스/추상 클래스 | 가능 | 불가 (SAM 만) |
| 상태(필드) 보유 | 가능 | 불가 |
this 차이는 실제 버그를 만듭니다.
class Button {
String name = "save";
void bind() {
Runnable a = new Runnable() {
String name = "anon";
public void run() { System.out.println(this.name); } // "anon" — 익명 클래스 자신
};
Runnable b = () -> System.out.println(this.name); // "save" — Button 인스턴스
}
}invokedynamic 이란: JDK 7 에서 동적 언어 지원용으로 추가된 바이트코드 명령입니다. 람다는 컴파일 시 (1) 본문을 lambda$bind$0 같은 private static 메서드로 빼내고 (2) 호출 지점에 invokedynamic 을 심습니다.
최초 실행 시 LambdaMetafactory 가 런타임에 함수형 인터페이스 구현 클래스를 동적으로 생성하고, 이후로는 그 결과를 재사용합니다. 클래스 파일이 미리 생성되지 않으므로 jar 크기가 줄고, JVM 이 나중에 더 나은 구현 전략(예: 숨겨진 클래스)으로 바꿀 수 있는 여지가 생깁니다.
캡처가 없는 람다는 매번 같은 인스턴스를 돌려주므로 == 비교가 true 가 나오기도 하지만, 이에 의존하면 안 됩니다.
표준 인터페이스에는 작은 함수를 이어 붙이는 default 메서드가 있습니다.
| 인터페이스 | 메서드 | 의미 |
|---|---|---|
Function |
f.andThen(g) |
x -> g(f(x)) — f 먼저, 그다음 g |
Function |
f.compose(g) |
x -> f(g(x)) — g 먼저, 그다음 f |
Function |
Function.identity() |
x -> x |
Predicate |
p.and(q), p.or(q), p.negate() |
논리 조합 |
Predicate |
Predicate.not(p) (JDK 11) |
메서드 참조를 부정할 때: not(String::isBlank) |
Consumer |
c.andThen(d) |
c 실행 후 d 실행 |
Comparator |
thenComparing, reversed, nullsFirst |
정렬 기준 조합 |
UnaryOperator |
andThen, compose (Function 상속) |
andThen 과 compose 는 방향이 반대입니다. f.andThen(g) 는 "f 하고 나서 g" 로 읽으면 헷갈리지 않습니다. 실무에서는 거의 andThen 만 씁니다.
합성은 파이프라인을 데이터로 만듭니다. List<Function<Order, Order>> 를 reduce(Function.identity(), Function::andThen) 으로 접으면 설정에 따라 단계를 켜고 끄는 처리 체인이 됩니다.
Comparator.comparing 계열은 람다 활용의 교과서입니다.
Comparator<Order> byStatusThenAmountDesc =
Comparator.comparing(Order::status) // 1차: 상태
.thenComparing(Order::amount, Comparator.reverseOrder()) // 2차: 금액 내림차순
.thenComparing(Order::id); // 3차: id| 메서드 | 역할 |
|---|---|
comparing(keyExtractor) |
키 추출 후 자연 순서 |
comparing(keyExtractor, keyComparator) |
키 추출 후 지정 비교자 |
comparingInt/Long/Double |
기본형 키, 박싱 없음 |
thenComparing(...) |
앞 기준이 같을 때 다음 기준 |
reversed() |
지금까지의 순서 뒤집기 |
nullsFirst(c) / nullsLast(c) |
null 안전 |
naturalOrder() / reverseOrder() |
Comparable 기반 |
comparing(Order::amount).reversed() 에서 타입 추론이 실패하면(Object 로 추론) comparing((Order o) -> o.amount()) 처럼 파라미터 타입을 명시하면 됩니다. 메서드 참조가 있는 첫 comparing 은 대부분 추론이 잘 됩니다.