[자바] 3. 람다 : 익명 클래스와 작별하는 법 (Java 8 람다 표현식)

자바 버전 여행 3편 - 익명 클래스와 작별하는 법

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(자바 버전 여행 ③)

자바 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);          // 메서드 참조

직접 해보기 — 익명 클래스를 람다로 줄이기

같은 정렬을 익명 클래스와 람다로 각각 짜 보고, 결과가 같은지 확인하자.

  1. 문자열 리스트를 익명 Comparator로 길이순 정렬한다.
  2. 그걸 람다 한 줄로 바꾼다.
  3. 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는 바깥 인스턴스입니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다