안동민 개발노트

본문 시작

익명 클래스와 람다

함수형 인터페이스 구현이라는 공통 원리 위에서 자기 참조 해석, 이름 범위, 지역 변수 캡처, 객체 동일성이 어떻게 다른지 실행 가능한 비교로 확인합니다.

익명 클래스와 람다는 모두 함수형 인터페이스 값으로 전달될 수 있지만 완전히 같은 문법 설탕은 아닙니다.

익명 클래스는 이름 없는 클래스의 인스턴스를 만듭니다.

람다 식은 목표 함수형 타입의 단일 추상 메서드 구현을 표현하며, 별도의 클래스 본문 범위처럼 행동하지 않습니다.

이 차이는 this, 지역 변수 이름, 직렬화, 동일성 관찰에서 나타납니다.

변환할 때는 먼저 익명 클래스가 단 하나의 행동 계약만 구현하는지 확인합니다.

필드와 여러 도우미, 초기화 블록을 가진 익명 객체를 화살표 한 줄로 억지로 옮기면 상태 의미를 숨깁니다.

작은 무상태 콜백에는 람다가 적합하고, 명시적 상태와 생명 주기가 있는 협력자는 이름 있는 클래스가 더 읽기 쉽습니다.


익명 클래스의 문법 잡음

아래 프로그램은 작은 콜백 세 개를 모두 익명 클래스로 작성합니다.

타입 이름, @Override, 메서드 시그니처가 반복되어 실제 정책인 조건식과 출력이 묻힙니다.

그렇다고 모든 구현을 람다로 바꾸는 것이 목표는 아닙니다.

한 추상 메서드에 대응하는 행동만 짧게 표현할 수 있다는 점이 핵심입니다.

bad/AnonymousClassNoise.java
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()는 구현 형태를 알지 못합니다.

함수 값은 메서드 호출 문법으로 실행되며 람다 식 자체가 즉시 실행되는 것은 아닙니다.

src/AnonymousToLambda.java
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를 만들지 않습니다.

익명 클래스의 this는 새 익명 객체를 가리킵니다.

바깥 객체를 명시하려면 Outer.this를 씁니다.

아래 코드에서 각 this가 참조하는 필드를 비교합니다.

src/LambdaCaptureSemantics.java
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());
    }
}
같은 owner를 사용해도 this가 가리키는 객체는 다르다

LambdaCaptureSemantics의 필드 참조와 상수 계산을 비교합니다.

같은 owner를 사용해도 this가 가리키는 객체는 다르다
구현읽는 값원문 상수의 계산
람다바깥 owner의 base + offset10 + 3 = 13
익명 클래스익명 객체의 base + 바깥 owner의 base + offset100 + 10 + 3 = 113
람다
읽는 값: 바깥 owner의 base + offset
원문 상수의 계산: 10 + 3 = 13
익명 클래스
읽는 값: 익명 객체의 base + 바깥 owner의 base + offset
원문 상수의 계산: 100 + 10 + 3 = 113

실행 관측이 아닌 코드 계산입니다.

지역 변수 offset은 사실상 final이어야 합니다.

명시적으로 final을 쓰지 않아도 초기화 뒤 다시 대입하지 않으면 캡처할 수 있습니다.

스택 프레임이 끝난 뒤 콜백이 실행될 수 있으므로 람다는 변수 자체의 가변 저장 공간이 아니라 값을 캡처합니다.

반면 캡처한 객체의 내부 상태는 바뀔 수 있습니다.

List 참조를 재대입하지 않았다는 사실이 그 목록을 불변하게 만들지는 않습니다.

카운터를 갱신하려고 int[]나 AtomicInteger를 캡처하는 우회는 동시성 의미를 추가합니다.

단일 스레드에서만 쓰는지, 여러 호출이 순서를 공유해야 하는지 판단하지 않고 가변 보관 객체를 넣으면 결과가 입력 외 상태에 의존합니다.

누적이 목적이면 축약이나 명시적 상태 유지 클래스가 더 분명할 수 있습니다.


콜백 소유권

이벤트 원본이 리스너를 장기 보관하면 리스너가 캡처한 소유자도 함께 살아 있습니다.

화면이나 요청이 끝난 뒤에도 콜백이 레지스트리에 남으면 메모리 누수와 잘못된 호출이 생깁니다.

등록 API는 제거에 필요한 토큰을 반환하거나 AutoCloseable 구독을 제공할 수 있습니다.

동일한 람다 식을 다시 평가해 remove에 넘긴다고 같은 참조가 보장되지 않습니다.

source.remove(value -> log(value))는 앞서 등록한 콜백을 제거하지 못할 수 있습니다.

처음 만든 인스턴스를 변수에 저장하거나 등록 핸들을 닫습니다.

이것은 문법 문제가 아니라 객체 수명과 동일성을 다루는 API 설계 문제입니다.


연습 문제

문자열 이벤트 원본을 만들고 subscribe(Consumer<String>)가 Subscription을 반환하게 합니다.

close는 멱등해야 하며, 해제 이후 새로 만드는 게시 스냅샷에서 리스너를 제외합니다. 이미 진행 중인 게시까지 취소하거나 완료를 기다리는 계약은 별도로 설계합니다.

호출자는 람다 참조 동일성을 직접 비교하지 않아도 됩니다.

해답 보기
src/CallbackLifecycleSolution.java
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());
    }
}
해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

CallbackLifecycleSolution에서 제거 시점과 publish의 복사 시점을 구분합니다.

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다
스냅샷을 만드는 시점목록에 리스너가 있는가호출에 미치는 영향
close가 제거한 뒤 새 publish새 스냅샷에서는 제외그 publish는 해당 리스너를 호출하지 않음
close 전에 이미 복사한 publish기존 배열에 참조가 남음close가 반환해도 그 배열의 호출은 진행할 수 있음
같은 구독을 다시 close추가 제거 없음이미 시작한 콜백 완료를 기다리지는 않음
close가 제거한 뒤 새 publish
목록에 리스너가 있는가: 새 스냅샷에서는 제외
호출에 미치는 영향: 그 publish는 해당 리스너를 호출하지 않음
close 전에 이미 복사한 publish
목록에 리스너가 있는가: 기존 배열에 참조가 남음
호출에 미치는 영향: close가 반환해도 그 배열의 호출은 진행할 수 있음
같은 구독을 다시 close
목록에 리스너가 있는가: 추가 제거 없음
호출에 미치는 영향: 이미 시작한 콜백 완료를 기다리지는 않음

main은 publish(one) → close 두 번 → publish(two)의 순차 호출만 합니다. 진행 중 게시와 해제가 겹치는 경우를 실행한 예제는 아닙니다.

콜백은 publish를 호출한 스레드에서 실행되고 예외를 따로 잡지 않습니다. 한 리스너가 예외를 던지면 그 게시의 뒤 리스너 호출은 중단됩니다.