* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
(자바 버전 여행 ③)
자바 8은 2014년에 나왔지만, 자바를 통틀어 가장 큰 사건을 하나만 꼽으라면 나는 주저 없이 람다를 든다. 이 편부터 다섯 편은 자바 8이 바꿔놓은 것들을 다루는데, 람다가 그 시작이자 뿌리다. 스트림도 Optional도 결국 람다 위에서 돈다.
익명 클래스의 장황함
버튼에 클릭 동작을 붙이거나 리스트를 정렬할 때, 자바 8 이전에는 익명 클래스를 썼다. 하는 일은 한 줄인데 껍데기가 다섯 줄이다.
// Java 7 — 이름 길이로 정렬
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
정작 중요한 로직은 a.length() - b.length() 딱 한 줄인데, 그걸 감싸는 new Comparator..., @Override, 메서드 시그니처가 화면을 채운다. 눈이 진짜 코드를 찾아 헤맨다.
람다로 줄이면
람다는 이 껍데기를 걷어내고 입력 → 동작만 남긴다.
// Java 8 — 같은 동작
Collections.sort(names, (a, b) -> a.length() - b.length());
(a, b) -> ... 가 람다다. 화살표 왼쪽은 매개변수, 오른쪽은 할 일. 타입도 컴파일러가 추론하니 안 적어도 된다. 다섯 줄이 한 줄이 됐는데, 오히려 무슨 일을 하는지 더 잘 보인다.
함수형 인터페이스 — 람다가 앉는 자리
람다가 아무 데나 되는 건 아니다. 추상 메서드가 딱 하나인 인터페이스, 즉 함수형 인터페이스 자리에만 들어간다. Comparator가 그랬고, Runnable도 그렇다. 자바 8은 자주 쓰는 모양을 java.util.function에 미리 만들어 뒀다.
| 인터페이스 | 모양 | 쓰임 |
|---|---|---|
| Supplier<T> | () → T | 값을 공급 |
| Consumer<T> | T → void | 값을 소비 |
| Function<T,R> | T → R | 변환 |
| Predicate<T> | T → boolean | 참·거짓 판정 |
Predicate<String> isEmpty = s -> s.isEmpty();
Function<String, Integer> length = s -> s.length();
isEmpty.test(""); // true
length.apply("java"); // 4
직접 만들 때는 @FunctionalInterface를 붙이면, 실수로 추상 메서드를 두 개 만들었을 때 컴파일러가 잡아준다.
메서드 참조 — 람다를 더 줄인다
람다가 그저 기존 메서드를 부르기만 한다면, 메서드 참조로 한 번 더 줄일 수 있다. x -> x.toUpperCase() 는 String::toUpperCase 로 쓴다.
names.forEach(n -> System.out.println(n)); // 람다
names.forEach(System.out::println); // 메서드 참조
직접 해보기 — 익명 클래스를 람다로 줄이기
같은 정렬을 익명 클래스와 람다로 각각 짜 보고, 결과가 같은지 확인하자.
- 문자열 리스트를 익명
Comparator로 길이순 정렬한다. - 그걸 람다 한 줄로 바꾼다.
Comparator.comparingInt+ 메서드 참조로 한 번 더 줄인다.
var names = new ArrayList<>(List.of("java", "go", "python", "c"));
// 익명 클래스
names.sort(new Comparator<String>() {
public int compare(String a, String b) { return a.length() - b.length(); }
});
System.out.println(names);
// 람다 → 메서드 참조 (결과는 동일)
names.sort((a, b) -> a.length() - b.length());
names.sort(Comparator.comparingInt(String::length));
System.out.println(names);
실행해 보면 이렇게 나온다.
[c, go, java, python]
[c, go, java, python]
여기서 조금씩 바꿔 보자.
- 정렬 기준을 가나다순(
Comparator.naturalOrder())으로 바꿔 본다. .reversed()를 붙여 내림차순으로 정렬해 본다.
한 걸음 더 — Predicate<String> isShort = s -> s.length() < 3;을 만들어 names.removeIf(isShort)에 넘겨 보자.
✅ 확인 — 익명 클래스와 람다가 같은 결과를 낸다는 걸 확인하고, 함수형 인터페이스를 한 줄로 설명할 수 있으면 통과다.
실무 냄새 나는 예 — 전략 패턴이 한 줄로
정렬 말고 실무 코드에서 람다가 사건인 이유를 보자. 할인 정책처럼 ‘동작이 여러 갈래’인 코드는 전통적으로 전략 패턴 — 인터페이스 하나에 구현 클래스 N개 — 이었다.
// Before — 정책마다 클래스 파일 하나씩
class VipDiscount implements DiscountPolicy {
public int apply(int price) { return price * 80 / 100; }
}
class NewYearDiscount implements DiscountPolicy { ... } // 파일 또 하나
// After — 정책이 값이 된다. 파일 0개 추가
Map<String, UnaryOperator<Integer>> policies = Map.of(
"VIP", price -> price * 80 / 100,
"NEWYEAR", price -> price - 1000,
"NONE", price -> price
);
int paid = policies.get(grade).apply(price);
클래스 세 개가 Map 한 덩이로 접혔다. ‘동작을 값처럼’ 넘긴다는 말의 실무적 의미가 이것이다 — 콜백, 전략, 템플릿 메서드처럼 클래스가 사실상 함수 하나를 감싸던 자리가 전부 람다의 자리다.
람다는 바깥 변수를 붙잡는다 — 클로저처럼
람다는 바깥의 지역변수를 참조할 수 있다. 단 그 변수는 ‘사실상 final(effectively final)‘이어야 한다 — 캡처한 뒤 값을 바꾸면 컴파일 에러가 난다. 익명 클래스 시절부터의 규칙이 람다에도 그대로 적용된다. this도 다르다: 익명 클래스의 this는 자기 자신을, 람다의 this는 바깥 인스턴스를 가리킨다.
자주 묻는 질문 (FAQ)
람다는 아무 인터페이스에나 쓸 수 있나요?
추상 메서드가 하나뿐인 함수형 인터페이스 자리에만 됩니다. Runnable·Comparator·Function 등이 그 예입니다.
람다와 익명 클래스는 완전히 같나요?
대부분 같지만 this가 가리키는 대상이 다릅니다. 익명 클래스의 this는 자기 자신, 람다의 this는 바깥 인스턴스입니다.

답글 남기기