익명 클래스와 람다
함수형 인터페이스 구현이라는 공통 원리 위에서 자기 참조 해석, 이름 범위, 지역 변수 캡처, 객체 동일성이 어떻게 다른지 실행 가능한 비교로 확인합니다.
익명 클래스와 람다는 모두 함수형 인터페이스 값으로 전달될 수 있지만 완전히 같은 문법 설탕은 아닙니다.
익명 클래스는 이름 없는 클래스의 인스턴스를 만듭니다.
람다 식은 목표 함수형 타입의 단일 추상 메서드 구현을 표현하며, 별도의 클래스 본문 범위처럼 행동하지 않습니다.
이 차이는 this, 지역 변수 이름, 직렬화, 동일성 관찰에서 나타납니다.
변환할 때는 먼저 익명 클래스가 단 하나의 행동 계약만 구현하는지 확인합니다. 필드와 여러 도우미, 초기화 블록을 가진 익명 객체를 화살표 한 줄로 억지로 옮기면 상태 의미를 숨깁니다. 작은 무상태 콜백에는 람다가 적합하고, 명시적 상태와 생명 주기가 있는 협력자는 이름 있는 클래스가 더 읽기 쉽습니다.
익명 클래스의 문법 잡음
아래 프로그램은 작은 콜백 세 개를 모두 익명 클래스로 작성합니다.
타입 이름, @Override, 메서드 시그니처가 반복되어 실제 정책인 조건식과 출력이 묻힙니다.
그렇다고 모든 구현을 람다로 바꾸는 것이 목표는 아닙니다.
한 추상 메서드에 대응하는 행동만 짧게 표현할 수 있다는 점이 핵심입니다.
import java.util.Comparator;
import java.util.List;
import java.util.function.Predicate;
public final class AnonymousClassNoise {
public static void main(String[] args) {
Predicate<String> longText = new Predicate<>() {
@Override
public boolean test(String value) {
return value.length() >= 5;
}
};
Comparator<String> byLength = new Comparator<>() {
@Override
public int compare(String left, String right) {
return Integer.compare(left.length(), right.length());
}
};
Runnable notice = new Runnable() {
@Override
public void run() {
System.out.println("ready");
}
};
System.out.println(List.of("java", "lambda").stream().filter(longText).sorted(byLength).toList());
notice.run();
}
}람다의 매개변수와 return 타입은 대상 인터페이스의 함수 시그니처에서 추론됩니다.
Predicate<String>이면 value가 String이고 결과가 boolean이어야 합니다.
대상 타입이 없으면 람다 자체만으로 타입을 결정할 수 없습니다.
var callback = value -> value.length()가 허용되지 않는 이유는 어느 함수형 인터페이스인지 정보가 없기 때문입니다.
익명 클래스는 새 범위를 만들기 때문에 둘러싼 메서드의 지역 변수와 같은 이름의 필드를 선언할 수 있습니다. 람다 본문에서는 둘러싼 범위와 이름 공간을 공유하므로 같은 지역 변수 이름을 다시 선언할 수 없습니다. 이런 차이는 짧은 변환에서도 컴파일 결과를 바꿀 수 있습니다.
익명 클래스와 람다
다음 예제는 같은 Formatter를 이름 있는 클래스, 익명 클래스, 람다로 구현합니다.
호출자인 render()는 구현 형태를 알지 못합니다.
함수 값은 메서드 호출 문법으로 실행되며 람다 식 자체가 즉시 실행되는 것은 아닙니다.
import java.util.List;
public final class AnonymousToLambda {
@FunctionalInterface
interface Formatter {
String format(String label, int value);
}
static final class BracketFormatter implements Formatter {
@Override
public String format(String label, int value) {
return "[" + label + ":" + value + "]";
}
}
static List<String> render(List<Integer> values, Formatter formatter) {
return values.stream()
.map(value -> formatter.format("item", value))
.toList();
}
public static void main(String[] args) {
Formatter named = new BracketFormatter();
Formatter anonymous = new Formatter() {
@Override
public String format(String label, int value) {
return label.toUpperCase() + "=" + value;
}
};
Formatter lambda = (label, value) -> label + "#" + (value * 2);
System.out.println(render(List.of(2, 4), named));
System.out.println(render(List.of(2, 4), anonymous));
System.out.println(render(List.of(2, 4), lambda));
}
}람다 인스턴스의 구체 클래스 이름이나 참조 동일성에 의존해서는 안 됩니다.
JVM은 무상태 람다를 재사용할 수도 있고 캡처 값마다 인스턴스를 만들 수도 있습니다.
명세가 보장하는 것은 함수형 인터페이스로서의 동작이지 getClass() 이름이나 == 결과가 아닙니다.
콜백 제거에 같은 객체 참조가 필요한 API라면 등록할 때 받은 핸들을 보관하는 편이 낫습니다.
함수 값의 toString()도 설명 가능한 이름을 제공하지 않습니다.
운영 로그에서 정책 이름이 필요하면 NamedRule(name, predicate)처럼 메타데이터를 함께 감쌉니다.
람다를 map 키로 쓰거나 직렬화해 장기 저장하는 설계는 구현 변화와 JVM 세부에 취약합니다.
this와 변수 캡처
람다 안의 this는 람다를 둘러싼 인스턴스를 가리킵니다.
익명 클래스의 this는 새 익명 객체를 가리킵니다.
바깥 객체를 명시하려면 Outer.this를 씁니다.
아래 실행 결과는 이름과 인스턴스 값이 어떻게 달라지는지 보여 줍니다.
import java.util.function.IntSupplier;
public final class LambdaCaptureSemantics {
private final int base;
LambdaCaptureSemantics(int base) {
this.base = base;
}
IntSupplier lambda(int offset) {
return () -> this.base + offset;
}
IntSupplier anonymous(int offset) {
return new IntSupplier() {
private final int base = 100;
@Override
public int getAsInt() {
return this.base + LambdaCaptureSemantics.this.base + offset;
}
};
}
public static void main(String[] args) {
LambdaCaptureSemantics owner = new LambdaCaptureSemantics(10);
int offset = 3;
IntSupplier lambda = owner.lambda(offset);
IntSupplier anonymous = owner.anonymous(offset);
System.out.println("lambda=" + lambda.getAsInt());
System.out.println("anonymous=" + anonymous.getAsInt());
}
}지역 변수 offset은 사실상 final이어야 합니다.
명시적으로 final을 쓰지 않아도 초기화 뒤 다시 대입하지 않으면 캡처할 수 있습니다.
스택 프레임이 끝난 뒤 콜백이 실행될 수 있으므로 람다는 변수 자체의 가변 저장 공간이 아니라 값을 캡처합니다.
반면 캡처한 객체의 내부 상태는 바뀔 수 있습니다.
List 참조를 재대입하지 않았다는 사실이 그 목록을 불변하게 만들지는 않습니다.
카운터를 갱신하려고 int[]나 AtomicInteger를 캡처하는 우회는 동시성 의미를 추가합니다.
단일 스레드에서만 쓰는지, 여러 호출이 순서를 공유해야 하는지 판단하지 않고 가변 보관 객체를 넣으면 결과가 입력 외 상태에 의존합니다.
누적이 목적이면 축약이나 명시적 상태 유지 클래스가 더 분명할 수 있습니다.
콜백 소유권
이벤트 원본이 리스너를 장기 보관하면 리스너가 캡처한 소유자도 함께 살아 있습니다.
화면이나 요청이 끝난 뒤에도 콜백이 레지스트리에 남으면 메모리 누수와 잘못된 호출이 생깁니다.
등록 API는 제거에 필요한 토큰을 반환하거나 AutoCloseable 구독을 제공할 수 있습니다.
동일한 람다 식을 다시 평가해 remove에 넘긴다고 같은 참조가 보장되지 않습니다.
source.remove(value -> log(value))는 앞서 등록한 콜백을 제거하지 못할 수 있습니다.
처음 만든 인스턴스를 변수에 저장하거나 등록 핸들을 닫습니다.
이것은 문법 문제가 아니라 객체 수명과 동일성을 다루는 API 설계 문제입니다.
연습 문제
문자열 이벤트 원본을 만들고 subscribe(Consumer<String>)가 Subscription을 반환하게 합니다.
close는 멱등해야 하며, 닫힌 리스너는 이후 이벤트를 받지 않습니다.
호출자는 람다 참조 동일성을 직접 비교하지 않아도 됩니다.
해답 보기
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
public final class CallbackLifecycleSolution {
interface Subscription extends AutoCloseable {
@Override
void close();
boolean closed();
}
static final class EventSource {
private final AtomicLong ids = new AtomicLong();
private final Map<Long, Consumer<String>> listeners = new LinkedHashMap<>();
synchronized Subscription subscribe(Consumer<String> listener) {
long id = ids.incrementAndGet();
listeners.put(id, listener);
return new Subscription() {
private boolean closed;
@Override
public synchronized void close() {
if (!closed) {
EventSource.this.remove(id);
closed = true;
}
}
@Override
public synchronized boolean closed() {
return closed;
}
};
}
private synchronized void remove(long id) {
listeners.remove(id);
}
void publish(String event) {
Consumer<String>[] snapshot;
synchronized (this) {
snapshot = listeners.values().toArray(Consumer[]::new);
}
for (Consumer<String> listener : snapshot) {
listener.accept(event);
}
}
synchronized int listenerCount() {
return listeners.size();
}
}
public static void main(String[] args) {
EventSource source = new EventSource();
StringBuilder received = new StringBuilder();
Subscription subscription = source.subscribe(event -> received.append(event).append('|'));
source.publish("one");
subscription.close();
subscription.close();
source.publish("two");
System.out.println(received);
System.out.println("closed=" + subscription.closed());
System.out.println("listeners=" + source.listenerCount());
}
}해답은 숫자 토큰으로 리스너를 관리하고 close를 여러 번 호출해도 같은 결과가 되게 합니다.
게시 전에 스냅샷을 만들어 콜백이 호출 중 다른 리스너를 등록하거나 해제해도 Map 순회가 깨지지 않습니다.
운영 동시성에서는 콜백 실행 스레드와 예외 격리 정책도 정해야 합니다.