본문 바로가기

익명 클래스와 람다의 차이

같은 함수형 인터페이스를 익명 클래스와 람다로 구현해 보고, this가 가리키는 대상과 이름 범위, 지역 변수 캡처, 객체 동일성이 어떻게 다른지 실제 컴파일 오류와 실행 결과로 확인합니다.

앞 글에서는 상품을 고르는 규칙을 이름 있는 클래스와 람다로 만들어 같은 select에 넘겼습니다. 익명 클래스로 동작 전달하기에서 본 것처럼, 추상 메서드가 하나뿐인 인터페이스라면 익명 클래스로도 같은 일을 할 수 있습니다. 그래서 람다를 '짧게 줄인 익명 클래스'로 이해하기 쉽지만, 둘은 만들어지는 방식부터 다릅니다.

익명 클래스 식은 이름 없는 클래스를 하나 선언하고 그 객체를 만듭니다. 클래스를 선언하므로 본문은 새로운 이름 범위가 되고, 본문 안의 this는 그 익명 객체를 가리킵니다. 반면 람다식은 함수형 인터페이스의 추상 메서드 하나를 구현하는 코드일 뿐이고, 그 본문은 람다를 둘러싼 코드의 일부로 해석됩니다. 이 차이가 this, 이름, 지역 변수 캡처, 객체의 동일성 네 곳에서 드러납니다. 이 글에서는 각각을 실제 컴파일 오류와 실행 결과로 확인하고, 마지막으로 콜백을 등록하고 해제하는 API를 만들어 봅니다.

익명 클래스를 람다로 바꾸면 사라지는 것

다음 프로그램은 작은 동작 세 개를 모두 익명 클래스로 만듭니다. 글자 수가 5 이상인지 판단하는 Predicate<String>, 글자 수로 순서를 정하는 Comparator<String>, 메시지를 출력하는 Runnable입니다.

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();
    }
}

세 익명 클래스는 각각 test, compare, run 메서드 하나만 구현합니다. 구현할 추상 메서드가 하나뿐인 인터페이스라는 점이, 뒤에서 이 코드를 람다로 바꿀 수 있는 조건이 됩니다.

[lambda]
ready

스트림은 32장에서 다루므로, 여기서는 filter가 Predicate로 원소를 고르고 sorted가 Comparator로 정렬한다는 것만 알면 됩니다. 글자 수가 4인 java는 걸러지고 lambda만 남아 첫 줄이 출력되고, 이어서 notice.run()이 ready를 출력합니다. 그런데 이 코드에서 실제 동작을 담은 부분은 value.length() >= 5, Integer.compare(...), System.out.println("ready") 세 곳뿐입니다. 나머지는 new Predicate<>(), @Override, 메서드 선언처럼 컴파일러가 이미 아는 내용을 되풀이한 것입니다.

같은 동작을 람다로 쓰면 다음과 같습니다. 세 변수 선언을 이것으로 바꿔도 출력은 똑같습니다.

Predicate<String> longText = value -> value.length() >= 5;
Comparator<String> byLength = (left, right) -> Integer.compare(left.length(), right.length());
Runnable notice = () -> System.out.println("ready");

람다에서 사라진 정보는 모두 대입받는 쪽의 타입에서 나옵니다. 람다가 쓰인 자리가 기대하는 함수형 인터페이스를 대상 타입(target type)이라고 합니다. 대상 타입이 Predicate<String>이면 구현할 메서드는 boolean test(String)이므로, 컴파일러는 value가 String이고 화살표 오른쪽 값이 boolean이어야 한다는 것을 압니다. 매개변수가 두 개인 Comparator는 (left, right)처럼 괄호로 묶고, 매개변수가 없는 Runnable은 빈 괄호 ()를 씁니다.

반대로 대상 타입이 없으면 람다는 타입을 가질 수 없습니다. var는 오른쪽 식의 타입으로 변수 타입을 정하는데, 람다는 스스로 타입을 정하지 못하므로 둘을 함께 쓰면 컴파일 오류가 납니다.

var callback = value -> value.length();
VarLambda.java:3: error: cannot infer type for local variable callback
        var callback = value -> value.length();
            ^
  (lambda expression needs an explicit target-type)
1 error

오류 메시지 그대로 람다에는 명시적인 대상 타입이 필요합니다. 같은 value -> value.length()라도 Function<String, Integer> 자리에 쓰면 그 인터페이스의 객체가 되고, 직접 만든 다른 함수형 인터페이스 자리에 쓰면 또 그 인터페이스의 객체가 됩니다. 대상 타입이 람다의 의미를 정하는 규칙은 람다의 대상 타입과 오버로딩에서 자세히 다룹니다.

구현 방식을 모르는 호출자

다음 예제는 같은 Formatter를 이름 있는 클래스, 익명 클래스, 람다로 구현해 같은 render에 넘깁니다. 세 구현은 생김새가 다르지만 render 입장에서는 모두 Formatter일 뿐입니다.

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));
    }
}

render는 값마다 format을 한 번씩 부르고 그 결과를 toList()로 모읍니다. 그래서 출력의 각 줄은 원소가 두 개인 목록입니다.

[[item:2], [item:4]]
[ITEM=2, ITEM=4]
[item#4, item#8]

render는 넘겨받은 formatter가 어떤 방식으로 만들어졌는지 모른 채 format을 부를 뿐입니다. 세 줄의 출력이 다른 것은 세 구현의 본문이 다르기 때문입니다. 첫 줄은 BracketFormatter가 대괄호로 감싼 결과이고, 둘째 줄은 익명 클래스가 이름을 대문자로 바꾼 결과, 셋째 줄은 람다가 값을 두 배로 만든 결과입니다.

lambda 변수에 람다를 대입하는 줄에서는 본문이 실행되지 않는다는 점도 눈여겨보세요. 그 줄은 Formatter 객체를 마련할 뿐이고, 본문은 render 안의 map이 원소마다 format을 호출할 때 실행됩니다. render 안의 value -> formatter.format("item", value)도 람다인데, 이 람다는 render의 매개변수 formatter를 본문에서 사용합니다. 람다가 바깥의 지역 변수나 매개변수를 이렇게 가져다 쓰는 것을 캡처(capture)라고 하며, 뒤에서 규칙을 자세히 봅니다.

this: 람다는 바깥 객체를, 익명 클래스는 자기 자신을 가리킨다

첫 번째 차이는 this입니다. 다음 예제의 두 메서드는 모두 IntSupplier를 만들어 돌려줍니다. IntSupplier는 매개변수 없이 int를 돌려주는 getAsInt 하나를 가진 표준 함수형 인터페이스입니다. lambda 메서드는 람다로, anonymous 메서드는 익명 클래스로 만들고, 익명 클래스에는 바깥 클래스의 필드와 이름이 같은 base 필드를 일부러 두었습니다.

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에서 같은 offset 3으로 만들어집니다. 그러니 두 결과의 차이는 본문의 this가 무엇을 가리키느냐에서만 나옵니다.

lambda=13
anonymous=113
같은 owner를 사용해도 this가 가리키는 객체는 다르다

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

this의 참조 대상

같은 owner를 사용해도 this가 가리키는 객체는 다르다

람다의 this는 바깥 owner를 가리키고, 익명 클래스의 this는 새 익명 객체를 가리킵니다.

같은 owner와 서로 다른 this의 참조 관계람다는 둘러싼 owner의 this를 사용한다. 익명 객체의 this.base는100이고 Outer.this로 같은 owner의10을 읽는다. offset3은 둘 다 캡처한 값이며 람다는10+3, 익명은100+10+3을 계산한다. 화살표는 참조·입력 관계이며 실행 순서 사슬이 아니다. 모바일도 같은 4노드4관계를 유지한다.람다this는 owner바깥 ownerbase=10익명 객체this.base=100캡처한 offset=313과 113 계산Outer.this
같은 owner와 서로 다른 this의 참조 관계람다는 둘러싼 owner의 this를 사용한다. 익명 객체의 this.base는100이고 Outer.this로 같은 owner의10을 읽는다. offset3은 둘 다 캡처한 값이며 람다는10+3, 익명은100+10+3을 계산한다. 화살표는 참조·입력 관계이며 실행 순서 사슬이 아니다. 모바일도 같은 4노드4관계를 유지한다.람다this는 owner바깥 ownerbase=10익명 객체this.base=100캡처한 offset=313과 113 계산Outer.this

람다

읽는 값
바깥 owner의 base + offset
원문 상수의 계산
10 + 3 = 13

익명 클래스

읽는 값
익명 객체의 base + 바깥 owner의 base + offset
원문 상수의 계산
100 + 10 + 3 = 113

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

offset=3은 두 구현이 캡처한 값입니다. 바깥 owner의 base=10과 익명 객체의 base=100을 구분합니다.

owner의 base는 10이고 offset은 3입니다. 람다 본문의 this는 람다를 만든 메서드를 호출받은 객체, 즉 owner이므로 this.base는 10이고 결과는 13입니다. 익명 클래스 본문의 this는 익명 객체 자신이라서 this.base는 익명 객체의 필드 100입니다. 바깥 객체의 필드는 LambdaCaptureSemantics.this.base처럼 바깥 클래스 이름을 붙여야 읽을 수 있으므로, 결과는 100 + 10 + 3 = 113이 됩니다.

람다가 바깥의 this를 그대로 쓴다는 것은, 바깥에 this가 없는 static 메서드 안에서는 람다에서도 this를 쓸 수 없다는 뜻입니다. static인 main 안에서 () -> System.out.println(this.name)을 만들면 다음 오류가 납니다.

StaticThis.java:5: error: non-static variable this cannot be referenced from a static context
        Runnable lambda = () -> System.out.println(this.name);
                                                   ^
1 error

람다가 자기 자신을 가리킬 수 없다는 것도 같은 규칙에서 나옵니다. 익명 클래스는 this로 자기 자신을 넘길 수 있어서, 이벤트를 한 번 받은 뒤 리스너 목록에서 자신을 지우는 코드도 쓸 수 있습니다. 람다 본문의 this는 바깥 객체이고, 람다를 담을 지역 변수를 본문에서 부르려 해도 그 변수는 아직 초기화되기 전입니다.

SelfRef.java:3: error: variable again might not have been initialized
        Runnable again = () -> again.run();
                               ^
1 error

JLS 15.27.2는 람다 본문의 이름과 this가 둘러싼 문맥에서와 같은 뜻이라고 정하면서, 람다가 자기 자신을 가리켜야 한다면 익명 클래스나 메서드 참조를 쓰라고 안내합니다.

이름: 람다 본문은 새 범위를 만들지 않는다

두 번째 차이는 이름을 찾는 범위입니다. 익명 클래스 본문은 클래스 선언이므로 새 이름 범위가 됩니다. 그래서 익명 클래스의 메서드 안에서는 바깥 메서드의 지역 변수와 같은 이름의 변수를 다시 선언할 수 있고, 안쪽 이름이 바깥 이름을 가립니다.

src/ScopeComparison.java
public final class ScopeComparison {
    public static void main(String[] args) {
        String message = "outer";
        Runnable anonymous = new Runnable() {
            @Override
            public void run() {
                String message = "inner";
                System.out.println("anonymous sees " + message);
            }
        };
        anonymous.run();
        System.out.println("main sees " + message);
    }
}

run은 main의 message와 같은 이름의 지역 변수를 새로 선언하고, 바깥 message에는 손대지 않습니다. 두 줄은 각자 어느 message를 읽었는지 보여 줍니다.

anonymous sees inner
main sees outer

run 안의 message는 새로 선언한 지역 변수이므로 inner가 출력되고, main의 message는 그대로 outer입니다. 같은 본문을 람다로 옮겨 anonymous.run(); 다음 줄에 추가하면 컴파일되지 않습니다.

Runnable lambda = () -> {
    String message = "inner";
    System.out.println("lambda sees " + message);
};
ScopeComparison.java:13: error: variable message is already defined in method main(String[])
            String message = "inner";
                   ^
1 error

람다 본문은 main 안에 있는 블록 하나처럼 취급되기 때문입니다. 메서드 안의 중첩 블록에서 바깥 지역 변수와 같은 이름을 다시 선언할 수 없는 것과 같은 규칙입니다. 람다 매개변수도 마찬가지여서, main에 지역 변수 value가 있는데 value -> value + 1을 쓰면 variable value is already defined in method main(String[]) 오류가 납니다.

이름을 찾는 방향도 다릅니다. 익명 클래스는 자신이 구현한 인터페이스의 멤버를 물려받으므로, 본문에서 그 인터페이스의 default 메서드를 이름만으로 부를 수 있습니다. 람다 본문은 인터페이스의 멤버를 물려받지 않고 바깥 클래스에서 이름을 찾습니다. 다음 코드에서 Greeter의 default 메서드 polite를 익명 클래스는 부를 수 있지만, 람다는 바깥 클래스 InheritedNames에서 polite를 찾다가 실패합니다.

bad/InheritedNames.java
public final class InheritedNames {
    interface Greeter {
        String greet(String name);

        default String polite(String text) {
            return text + "-nim";
        }
    }

    public static void main(String[] args) {
        Greeter anonymous = new Greeter() {
            @Override
            public String greet(String name) {
                return polite(name);
            }
        };
        Greeter lambda = name -> polite(name);
        System.out.println(anonymous.greet("Mina") + " " + lambda.greet("Mina"));
    }
}
InheritedNames.java:17: error: cannot find symbol
        Greeter lambda = name -> polite(name);
                                 ^
  symbol:   method polite(String)
  location: class InheritedNames
1 error

익명 클래스 쪽에는 오류가 없고, 람다 쪽 오류의 location: class InheritedNames가 컴파일러가 이름을 찾은 곳을 알려 줍니다. Oracle 자바 튜토리얼은 이 성질을 '람다식은 어휘적으로 범위가 정해진다(lexically scoped)'고 설명합니다. 상위 타입에서 이름을 물려받지도, 새로운 범위를 만들지도 않고, 람다 안의 선언은 둘러싼 환경에서와 똑같이 해석된다는 뜻입니다. 람다의 this가 바깥 객체를 가리키는 것도 같은 원리입니다.

캡처: 사실상 final인 지역 변수만 읽을 수 있다

세 번째는 지역 변수 캡처입니다. 람다와 익명 클래스는 모두 바깥 메서드의 지역 변수를 읽을 수 있지만, 그 변수가 사실상 final(effectively final)이어야 합니다. 사실상 final이란 final을 붙이지 않았어도 초기화한 뒤 다시 대입하지 않는 변수를 말합니다. 이 규칙은 익명 클래스와 람다에 똑같이 적용되고, 오류 메시지만 조금 다릅니다.

bad/CaptureReassign.java
import java.util.function.IntSupplier;

public final class CaptureReassign {
    public static void main(String[] args) {
        int count = 0;
        IntSupplier lambda = () -> count + 1;
        IntSupplier anonymous = new IntSupplier() {
            @Override
            public int getAsInt() {
                return count + 1;
            }
        };
        count = 5;
        System.out.println(lambda.getAsInt() + anonymous.getAsInt());
    }
}
CaptureReassign.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        IntSupplier lambda = () -> count + 1;
                                   ^
CaptureReassign.java:10: error: local variables referenced from an inner class must be final or effectively final
                return count + 1;
                       ^
2 errors

오류는 캡처한 줄에서 나지만 원인은 13번째 줄의 count = 5;입니다. 이 대입 때문에 count가 사실상 final이 아니게 되었고, 대입을 지우면 두 오류가 함께 사라집니다.

규칙이 이렇게 정해진 이유는 캡처가 변수 자체가 아니라 변수의 값을 복사해 가는 것이기 때문입니다. 앞의 LambdaCaptureSemantics에서 lambda(int offset)이 돌려준 람다는 lambda 메서드가 끝난 뒤에 실행됐습니다. 메서드가 끝나면 그 지역 변수도 사라지므로, 람다는 만들어질 때 offset의 값 3을 복사해 가지고 있어야 합니다.

그런데 원래 변수를 나중에 다시 대입할 수 있다면 복사본과 변수의 값이 어긋나고, 람다가 어느 값을 보는지 헷갈리게 됩니다. 자바는 다시 대입하는 것 자체를 막아 이 문제를 없앴습니다. 여러 스레드가 지역 변수를 공유하게 되는 문제를 포함한 자세한 이유는 지역 클래스와 변수 캡처에서 다뤘습니다.

주의할 점은 다시 대입하지 못하는 것이 변수일 뿐, 변수가 가리키는 객체는 아니라는 것입니다.

src/CaptureReference.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntSupplier;

public final class CaptureReference {
    public static void main(String[] args) {
        List<Integer> values = new ArrayList<>();
        IntSupplier size = () -> values.size();
        System.out.println("before add: " + size.getAsInt());
        values.add(7);
        System.out.println("after add: " + size.getAsInt());
    }
}

size는 목록을 만든 직후에 정의되지만, 크기를 읽는 것은 getAsInt()를 부르는 순간입니다. 같은 람다를 add 전후로 한 번씩 불러 두 시점의 결과를 비교합니다.

before add: 0
after add: 1

values는 처음 만든 목록을 계속 가리키고 다시 대입되지 않으므로 사실상 final입니다. 람다가 캡처한 것은 이 목록을 가리키는 참조값이고, getAsInt()를 호출할 때마다 그 목록의 현재 크기를 읽습니다. 그래서 add 뒤에는 1이 나옵니다. 사실상 final은 변수에 다른 값을 넣지 못한다는 뜻이지 목록의 내용을 얼린다는 뜻이 아닙니다.

이 점을 이용해 int[] 배열이나 AtomicInteger를 캡처하고 그 안의 값을 바꾸면 람다 안에서도 카운터를 늘릴 수 있습니다. 앞 글의 재시도 예제가 호출 횟수를 셀 때 쓴 방법입니다. 하지만 이렇게 하면 람다의 결과가 입력이 아니라 바깥 상태에 따라 달라지고, 람다가 여러 스레드에서 호출되면 int[] 카운터는 증가가 유실될 수 있습니다. 합계나 개수를 구하는 것이 목적이라면 반복문을 쓰거나 스트림의 sum, count 같은 집계를 쓰는 편이 분명합니다. 상태를 가진 람다의 문제는 상태를 가진 람다와 재사용 문제에서 다시 다룹니다.

동일성: 람다 객체의 클래스와 참조에 기대지 않는다

네 번째 차이는 객체가 만들어지는 방식입니다. 익명 클래스는 컴파일할 때 ClassNames$1.class 같은 클래스 파일이 따로 생기고, new 식이 실행될 때마다 그 클래스의 새 객체가 만들어집니다. 람다는 클래스 파일이 생기지 않고, 프로그램이 실행되는 중에 JVM이 함수형 인터페이스를 구현한 객체를 마련합니다. 다음 프로그램으로 두 객체의 클래스 이름을 출력해 보겠습니다.

src/ClassNames.java
public final class ClassNames {
    public static void main(String[] args) {
        Runnable anonymous = new Runnable() {
            @Override
            public void run() {
            }
        };
        Runnable lambda = () -> { };
        System.out.println(anonymous.getClass().getName());
        System.out.println(lambda.getClass().getName());
        System.out.println(lambda);
    }
}

JDK 21에서 실행한 결과입니다. 0x 뒤의 숫자와 @ 뒤의 해시 값은 실행할 때마다, 또 환경마다 달라질 수 있습니다.

ClassNames$1
ClassNames$$Lambda/0x00007f3610000c30
ClassNames$$Lambda/0x00007f3610000c30@677327b6

컴파일한 디렉터리를 보면 ClassNames.class 옆에 ClassNames$1.class는 있지만 람다를 위한 클래스 파일은 없습니다. 익명 클래스의 이름은 바깥 클래스 이름 뒤에 $1을 붙인 것이고, 람다 객체의 클래스 이름에는 $$Lambda와 주소처럼 보이는 숫자가 붙습니다. 람다의 toString()도 이 클래스 이름에 해시 값을 붙인 것이라서, 로그에 남겨도 무슨 동작인지 알 수 없습니다. 로그에 정책 이름이 필요하다면 record NamedRule(String name, Predicate<Product> rule)처럼 이름과 람다를 함께 담는 편이 낫습니다.

람다를 평가할 때마다 새 객체가 생기는지도 정해져 있지 않습니다. JLS 15.27.4는 람다식을 평가할 때마다 새 객체를 만들 필요가 없고, 서로 다른 람다식이 같은 클래스의 객체를 만들 수도 있다고 정해 둡니다. 익명 클래스로 동작 전달하기에서 본 것처럼 JDK 21에서는 아무것도 캡처하지 않는 람다를 반복해서 평가하면 같은 객체가 나오지만, 이것은 명세가 아니라 구현이 정한 동작입니다.

이 차이가 실제 버그로 이어지는 곳이 콜백 해제입니다. 다음 프로그램은 리스너 목록에 람다를 등록한 뒤, 글자까지 똑같은 람다를 다시 만들어 remove에 넘깁니다.

src/ListenerRemoval.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public final class ListenerRemoval {
    public static void main(String[] args) {
        List<Consumer<String>> listeners = new ArrayList<>();

        listeners.add(event -> System.out.println("log: " + event));
        Consumer<String> sameText = event -> System.out.println("log: " + event);
        System.out.println("remove same text: " + listeners.remove(sameText));

        Consumer<String> saved = event -> System.out.println("audit: " + event);
        listeners.add(saved);
        System.out.println("remove saved: " + listeners.remove(saved));

        System.out.println("listeners=" + listeners.size());
    }
}

첫 remove에는 등록한 람다와 글자만 같은 다른 람다를, 두 번째 remove에는 등록할 때 쓴 바로 그 참조를 넘깁니다. List.remove(Object)는 원소를 찾아 지웠을 때만 true를 돌려줍니다.

remove same text: false
remove saved: true
listeners=1

List.remove는 equals로 같은 원소를 찾는데, 람다 객체는 equals를 재정의하지 않으므로 같은 객체일 때만 같다고 판단합니다. 글자가 같아도 두 람다식은 소스의 서로 다른 위치에 있는 별개의 식이고, JDK 21에서는 서로 다른 객체가 만들어져 첫 번째 remove가 실패합니다. 명세는 두 식이 같은 객체를 만들 가능성도 막지 않으므로 어느 쪽 결과에도 기대면 안 됩니다. 등록한 리스너를 나중에 지워야 한다면 saved처럼 처음 만든 객체를 변수에 보관해 두고 그 참조로 지워야 합니다. 마지막 줄의 1은 지우지 못한 첫 번째 람다가 목록에 그대로 남아 있다는 뜻입니다.

더 깊이: 람다는 어떻게 객체가 되는가

익명 클래스는 컴파일러가 보통 클래스와 똑같이 별도의 클래스 파일로 만듭니다. 람다는 다릅니다. javac는 람다 본문을 바깥 클래스 안의 private 메서드로 옮기고, 람다를 평가하는 자리에는 invokedynamic 명령을 둡니다.

이 명령이 처음 실행될 때 JDK의 LambdaMetafactory가 함수형 인터페이스를 구현하는 클래스를 실행 중에 만들어 연결하고, 그 뒤로는 연결된 결과를 다시 씁니다. 출력에서 본 ClassNames$$Lambda/0x...가 이렇게 만들어진 숨은 클래스(hidden class)의 이름입니다. 클래스를 언제, 몇 개 만들지가 컴파일 시점에 고정되지 않기 때문에, 명세도 람다 객체의 클래스와 동일성을 약속하지 않고 구현에 맡겨 둔 것입니다.

같은 이유로 람다를 직렬화해 저장하는 것도 피해야 합니다. Oracle 자바 튜토리얼은 대상 타입과 캡처한 값이 직렬화 가능하면 람다도 직렬화할 수 있지만, 내부 클래스와 마찬가지로 람다의 직렬화는 강하게 권하지 않는다고 설명합니다. 직렬화된 람다는 본문이 옮겨진 메서드의 이름처럼 컴파일러가 정한 세부 사항을 담고 있어서, 코드를 고쳐 다시 컴파일하면 예전에 저장한 데이터를 읽지 못할 수 있습니다.

콜백을 오래 보관할 때 생기는 문제

람다가 캡처한 객체를 붙잡고 있다는 사실은 콜백을 오래 보관할 때 문제가 됩니다. 이벤트를 발생시키는 객체가 리스너 목록을 가지고 있으면, 그 목록에 든 람다와 람다가 캡처한 객체도 함께 살아 있습니다. 람다가 바깥 객체의 필드나 메서드를 쓰면 그 바깥 객체까지 붙잡습니다. 화면이 닫히거나 요청 처리가 끝났는데 콜백이 목록에 남아 있으면, 가비지 컬렉터는 그 객체들을 회수하지 못하고 이벤트가 올 때마다 이미 끝난 화면의 코드가 실행됩니다.

이 문제는 문법이 아니라 API 설계로 풀어야 합니다. 앞에서 본 것처럼 람다를 다시 만들어 해제하는 방법은 믿을 수 없으므로, 등록하는 메서드가 해제에 쓸 손잡이(handle) 객체를 돌려주게 만드는 것이 흔한 해법입니다. 호출한 쪽은 그 손잡이를 보관했다가 이벤트가 더 필요 없을 때 닫습니다. 손잡이가 AutoCloseable을 구현하면 try-with-resources로 구독 기간을 코드 블록에 맞출 수도 있습니다. 연습 문제 2에서 이런 API를 직접 만들어 봅니다.

익명 클래스와 람다 한눈에 비교하기

비교 항목익명 클래스람다
구현할 수 있는 타입클래스 상속이나 인터페이스 하나 구현, 추상 메서드 수 제한 없음함수형 인터페이스만
필드와 상태가질 수 있음가질 수 없음
본문의 this익명 객체 자신람다 바깥의 this
이름 범위새 범위, 바깥 이름을 가릴 수 있고 상위 타입 멤버를 물려받음둘러싼 범위와 같음
지역 변수 캡처사실상 final인 변수만사실상 final인 변수만
객체와 클래스클래스 파일이 생기고 new마다 새 객체실행 중에 클래스를 마련하고, 새 객체인지는 정해지지 않음

함수형 인터페이스의 동작 하나만 넘기면 된다면 람다가 짧고, this가 바깥과 같아 헷갈릴 일도 적습니다. 상태 필드가 필요하거나, 메서드가 여럿이거나, 본문에서 자기 자신을 가리켜야 한다면 익명 클래스나 이름 있는 클래스를 씁니다.

정리

  • 익명 클래스는 클래스를 선언하고 객체를 만드는 식이고, 람다는 함수형 인터페이스의 메서드 하나를 구현하는 코드입니다. 람다의 타입은 대상 타입이 정하므로 var와 함께 쓸 수 없습니다.
  • 람다 본문의 this와 이름은 둘러싼 문맥에서와 같은 뜻입니다. 람다는 바깥 지역 변수와 같은 이름을 다시 선언할 수 없고, 인터페이스의 멤버를 물려받지도 않습니다.
  • 람다와 익명 클래스는 사실상 final인 지역 변수만 캡처합니다. 다시 대입할 수 없는 것은 변수이고, 변수가 가리키는 객체의 내용은 바뀔 수 있습니다.
  • 람다 객체의 클래스 이름, toString(), 평가할 때마다 새 객체가 생기는지는 명세가 정하지 않습니다. 해제가 필요한 콜백은 처음 만든 참조나 등록할 때 받은 손잡이로 지웁니다.

연습 문제

1. 람다 안에서 합계 구하기

다음 코드는 컴파일되지 않습니다. 어떤 오류가 날지 예상한 다음, total=6을 출력하도록 두 가지 방법으로 고치세요.

bad/SumInLambda.java
import java.util.List;

public final class SumInLambda {
    public static void main(String[] args) {
        int total = 0;
        List.of(1, 2, 3).forEach(number -> total += number);
        System.out.println("total=" + total);
    }
}
풀이 보기

total += number는 total에 다시 대입하는 식이므로 total은 사실상 final이 아니고, 람다는 그런 변수를 캡처할 수 없습니다.

SumInLambda.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        List.of(1, 2, 3).forEach(number -> total += number);
                                           ^
1 error

가장 간단한 해결은 람다 대신 향상된 for 문을 쓰는 것입니다. 반복문의 본문은 람다가 아니라 main 안의 코드이므로 total을 얼마든지 다시 대입할 수 있습니다. 두 번째 방법은 바깥 변수를 바꾸지 않고 합계를 값으로 돌려받는 것입니다. List.of(1, 2, 3).stream().mapToInt(Integer::intValue).sum()은 원소를 int로 바꿔 더한 결과를 돌려주므로, int total = ...;로 받으면 됩니다. 두 방법 모두 total=6을 출력합니다.

AtomicInteger total을 만들고 람다 안에서 total.addAndGet(number)를 호출해도 컴파일되고 결과도 6이 나옵니다. 하지만 이 방법은 람다에 바깥 상태를 바꾸는 부수 효과를 넣는 것이고, 합계를 구하는 목적이라면 앞의 두 방법이 의도를 더 분명하게 보여 줍니다.

2. 해제 손잡이를 돌려주는 이벤트 원본

문자열 이벤트를 발생시키는 EventSource를 만듭니다. subscribe(Consumer<String>)은 리스너를 등록하고, 해제할 때 쓸 Subscription을 돌려줍니다. 요구 사항은 다음과 같습니다.

  • close()는 여러 번 호출해도 안전해야 합니다. 두 번째 호출부터는 아무 일도 하지 않습니다.
  • 해제한 뒤에 시작하는 게시(publish)에서는 그 리스너를 호출하지 않습니다. 이미 진행 중인 게시를 취소하거나 끝날 때까지 기다릴 필요는 없습니다.
  • 호출하는 쪽은 람다 객체를 직접 비교하지 않아도 됩니다.

다 만들었다면 이어서 생각해 보세요. 한 게시의 호출 목록에 L1과 L2가 등록 순서대로 들어 있고, L1이 실행되면서 L2의 Subscription.close()를 호출합니다. 이번 게시에서 L2는 호출될까요? 다음 게시에서는 어떨까요?

풀이 보기
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());
    }
}

실행 결과:

one|
closed=true
listeners=0

subscribe는 리스너를 번호(id)와 함께 LinkedHashMap에 넣고, 그 번호를 기억하는 Subscription을 익명 클래스로 만들어 돌려줍니다. 여기서 익명 클래스를 쓴 것은 closed라는 상태 필드가 필요하고 구현할 메서드도 close와 closed 두 개이기 때문입니다. 람다로는 둘 다 할 수 없습니다. close는 아직 닫히지 않았을 때만 EventSource.this.remove(id)로 등록을 지우므로 두 번 호출해도 안전합니다. 또 해제를 람다 객체가 아니라 번호로 하므로, 앞에서 본 동일성 문제가 생기지 않습니다.

publish는 synchronized 블록 안에서 등록된 리스너를 배열로 복사한 뒤, 잠금을 푼 상태에서 복사본의 리스너를 차례로 호출합니다. 복사본을 쓰면 리스너가 호출되는 도중에 close로 등록 목록을 바꾸더라도, 순회하는 것은 복사본이라 ConcurrentModificationException이 나지 않습니다. 또 잠금을 쥔 채 리스너 코드를 실행하지 않으므로, 리스너가 오래 걸려도 다른 스레드의 subscribe와 close가 막히지 않습니다.

main을 따라가면, 첫 publish("one")에서는 리스너가 등록돼 있어 one|이 쌓입니다. 그다음 close()를 두 번 부르는데, 두 번째 호출은 이미 닫힌 상태라 아무 일도 하지 않습니다. 해제 뒤의 publish("two")는 새로 만든 복사본에 리스너가 없으므로 아무것도 쌓지 않습니다. 그래서 received에는 one|만 남고, closed=true와 등록된 리스너 수 0이 출력됩니다.

컴파일하면 Note: CallbackLifecycleSolution.java uses unchecked or unsafe operations.라는 안내가 함께 나옵니다. 자바는 Consumer<String>[] 같은 제네릭 배열을 직접 만들 수 없어서, Consumer[]::new로 만든 배열을 Consumer<String>[] 변수에 담았기 때문입니다. 이 맵에는 Consumer<String>만 들어가므로 이 코드에서 실제 문제는 생기지 않습니다. 안내가 신경 쓰인다면 List.copyOf(listeners.values())로 복사본을 배열 대신 목록으로 만들어도 됩니다.

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

게시 snapshot에는 L1/L2가 복사되어 있고 L2 구독 해제는 Map만 바꿔 기존 배열의 호출 가능성을 남깁니다. 제거 전후의 새 snapshot과 기존 snapshot을 구분하며 상태를 확인합니다.

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

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

구독 해제의 경계

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

close는 저장된 리스너를 제거합니다. 이미 만들어진 게시 스냅샷에 남은 참조까지 제거하지는 않습니다.

등록 저장소와 복사한 호출 배열은 서로 다른 객체다publish는 동기화 영역에서 Map값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는L2가 없지만 이미 복사한 기존 배열은L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6노드5관계이다.Map:제거 전L1·L2 참조기존 게시 배열L1·L2 복사close(L2)Map에서 제거같은 Map:제거 뒤L1 참조만 남음새 게시 배열L1만 복사기존 배열 호출L2도 호출 가능같은 Map제거기존 참조
등록 저장소와 복사한 호출 배열은 서로 다른 객체다publish는 동기화 영역에서 Map값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는L2가 없지만 이미 복사한 기존 배열은L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6노드5관계이다.Map:제거 전L1·L2 참조기존 게시 배열L1·L2 복사close(L2)Map에서 제거같은 Map:제거 뒤L1 참조만 남음새 게시 배열L1만 복사기존 배열 호출L2도 호출 가능같은 Map제거기존 참조

close가 제거한 뒤 새 publish

목록에 리스너가 있는가
새 스냅샷에서는 제외
호출에 미치는 영향
그 publish는 해당 리스너를 호출하지 않음

close 전에 이미 복사한 publish

목록에 리스너가 있는가
기존 배열에 참조가 남음
호출에 미치는 영향
close가 반환해도 그 배열의 호출은 진행할 수 있음

같은 구독을 다시 close

목록에 리스너가 있는가
추가 제거 없음
호출에 미치는 영향
이미 시작한 콜백 완료를 기다리지는 않음

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

닫기 이후 새 스냅샷과 닫기 이전의 기존 배열을 구분합니다. close는 콜백 완료를 기다리지 않습니다.

이 예의 조건

  • 제거는 기존 게시 snapshot의 참조를 지우지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2publish는 동기화 영역에서 Map 값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는 L2가 없지만 이미 복사한 기존 배열은 L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차 main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6개 노드와 5개 관계이다.
원 도표의 설명과 주석
구분내용
설명publish는 동기화 영역에서 Map 값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는 L2가 없지만 이미 복사한 기존 배열은 L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차 main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6개 노드와 5개 관계이다.
전체 읽기 정보해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 CallbackLifecycleSolution에서 제거 시점과 publish의 복사 시점을 구분합니다. 구독 해제의 경계 해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 close는 저장된 리스너를 제거합니다. 이미 만들어진 게시 스냅샷에 남은 참조까지 제거하지는 않습니다. close가 제거한 뒤 새 publish 목록에 리스너가 있는가 새 스냅샷에서는 제외 호출에 미치는 영향 그 publish는 해당 리스너를 호출하지 않음 close 전에 이미 복사한 publish 목록에 리스너가 있는가 기존 배열에 참조가 남음 호출에 미치는 영향 close가 반환해도 그 배열의 호출은 진행할 수 있음 같은 구독을 다시 close 목록에 리스너가 있는가 추가 제거 없음 호출에 미치는 영향 이미 시작한 콜백 완료를 기다리지는 않음 main은 publish(one) → close 두 번 → publish(two)의 순차 호출만 합니다. 진행 중 게시와 해제가 겹치는 경우를 실행한 예제는 아닙니다. 닫기 이후 새 스냅샷과 닫기 이전의 기존 배열을 구분합니다. close는 콜백 완료를 기다리지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
게시 snapshot

Map의 L1/L2를 배열에 복사합니다.

  • Map: L1,L2
  • 기존 배열: L1,L2
  • 게시 대상 참조가 독립 배열에 남는다.
L2 구독 해제

close(L2)가 Map에서 L2를 제거합니다.

  • Map: L1
  • 기존 배열: L1,L2 유지
  • 등록 해제는 진행 중인 snapshot을 수정하지 않는다.
다음 게시 snapshot

새 배열은 L1만 복사합니다.

  • 새 배열: L1
  • 기존 배열: L1,L2
  • 제거 이후 게시만 새 등록 상태를 사용한다.
기존 게시 진행

기존 배열에 있던 L2 호출은 여전히 가능합니다.

  • 기존 게시: L2 호출 가능
  • 이후 게시: L2 제외
  • close 반환을 모든 진행 중인 callback 종료로 읽지 않는다.

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

게시 snapshot에는 L1/L2가 복사되어 있고 L2 구독 해제는 Map만 바꿔 기존 배열의 호출 가능성을 남깁니다. 제거 전후의 새 snapshot과 기존 snapshot을 구분하며 상태를 확인합니다.

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

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

구독 해제의 경계

해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다

close는 저장된 리스너를 제거합니다. 이미 만들어진 게시 스냅샷에 남은 참조까지 제거하지는 않습니다.

등록 저장소와 복사한 호출 배열은 서로 다른 객체다publish는 동기화 영역에서 Map값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는L2가 없지만 이미 복사한 기존 배열은L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6노드5관계이다.Map:제거 전L1·L2 참조기존 게시 배열L1·L2 복사close(L2)Map에서 제거같은 Map:제거 뒤L1 참조만 남음새 게시 배열L1만 복사기존 배열 호출L2도 호출 가능같은 Map제거기존 참조
등록 저장소와 복사한 호출 배열은 서로 다른 객체다publish는 동기화 영역에서 Map값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는L2가 없지만 이미 복사한 기존 배열은L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6노드5관계이다.Map:제거 전L1·L2 참조기존 게시 배열L1·L2 복사close(L2)Map에서 제거같은 Map:제거 뒤L1 참조만 남음새 게시 배열L1만 복사기존 배열 호출L2도 호출 가능같은 Map제거기존 참조

close가 제거한 뒤 새 publish

목록에 리스너가 있는가
새 스냅샷에서는 제외
호출에 미치는 영향
그 publish는 해당 리스너를 호출하지 않음

close 전에 이미 복사한 publish

목록에 리스너가 있는가
기존 배열에 참조가 남음
호출에 미치는 영향
close가 반환해도 그 배열의 호출은 진행할 수 있음

같은 구독을 다시 close

목록에 리스너가 있는가
추가 제거 없음
호출에 미치는 영향
이미 시작한 콜백 완료를 기다리지는 않음

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

닫기 이후 새 스냅샷과 닫기 이전의 기존 배열을 구분합니다. close는 콜백 완료를 기다리지 않습니다.

이 예의 조건

  • 제거는 기존 게시 snapshot의 참조를 지우지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2publish는 동기화 영역에서 Map 값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는 L2가 없지만 이미 복사한 기존 배열은 L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차 main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6개 노드와 5개 관계이다.
원 도표의 설명과 주석
구분내용
설명publish는 동기화 영역에서 Map 값을 새 배열로 복사하고 바깥에서 호출한다. close(L2)는 Map에서 제거하므로 제거 이후 새 배열에는 L2가 없지만 이미 복사한 기존 배열은 L2를 유지한다. 제거가 그 배열을 수정하거나 완료를 기다리지 않는다. 화살표는 복사·제거·호출의 대상 관계로, 원 순차 main과 진행 중 변형을 구분한다. 제거 전후 Map 두 노드는 같은 객체의 서로 다른 시점 상태이다. 모바일도 같은 6개 노드와 5개 관계이다.
전체 읽기 정보해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 CallbackLifecycleSolution에서 제거 시점과 publish의 복사 시점을 구분합니다. 구독 해제의 경계 해제는 이후 스냅샷에서 제외할 뿐 기존 호출 목록을 취소하지 않는다 close는 저장된 리스너를 제거합니다. 이미 만들어진 게시 스냅샷에 남은 참조까지 제거하지는 않습니다. close가 제거한 뒤 새 publish 목록에 리스너가 있는가 새 스냅샷에서는 제외 호출에 미치는 영향 그 publish는 해당 리스너를 호출하지 않음 close 전에 이미 복사한 publish 목록에 리스너가 있는가 기존 배열에 참조가 남음 호출에 미치는 영향 close가 반환해도 그 배열의 호출은 진행할 수 있음 같은 구독을 다시 close 목록에 리스너가 있는가 추가 제거 없음 호출에 미치는 영향 이미 시작한 콜백 완료를 기다리지는 않음 main은 publish(one) → close 두 번 → publish(two)의 순차 호출만 합니다. 진행 중 게시와 해제가 겹치는 경우를 실행한 예제는 아닙니다. 닫기 이후 새 스냅샷과 닫기 이전의 기존 배열을 구분합니다. close는 콜백 완료를 기다리지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
게시 snapshot

Map의 L1/L2를 배열에 복사합니다.

  • Map: L1,L2
  • 기존 배열: L1,L2
  • 게시 대상 참조가 독립 배열에 남는다.
L2 구독 해제

close(L2)가 Map에서 L2를 제거합니다.

  • Map: L1
  • 기존 배열: L1,L2 유지
  • 등록 해제는 진행 중인 snapshot을 수정하지 않는다.
다음 게시 snapshot

새 배열은 L1만 복사합니다.

  • 새 배열: L1
  • 기존 배열: L1,L2
  • 제거 이후 게시만 새 등록 상태를 사용한다.
기존 게시 진행

기존 배열에 있던 L2 호출은 여전히 가능합니다.

  • 기존 게시: L2 호출 가능
  • 이후 게시: L2 제외
  • close 반환을 모든 진행 중인 callback 종료로 읽지 않는다.

이어지는 질문의 답은 도표와 같습니다. L2는 이번 게시에서 호출됩니다. 이번 게시의 복사본 배열에는 L2의 참조가 이미 들어 있고, close는 맵에서만 L2를 지울 뿐 이미 만든 배열을 고치지 않기 때문입니다. 다음 게시는 새 복사본을 만들고 거기에는 L2가 없으므로 호출되지 않습니다. 요구 사항이 '해제한 뒤에 시작하는 게시'로 범위를 한정한 이유가 이것입니다.

같은 구독을 다시 닫아도 이미 닫힌 상태라 맵도 배열도 바뀌지 않습니다. 이 예제의 main은 게시와 해제를 차례로 호출할 뿐이므로, 여러 스레드에서 게시와 해제가 겹치는 상황까지 시험한 것은 아닙니다.

마지막으로, 리스너는 publish를 호출한 스레드에서 실행되고 리스너가 던진 예외는 따로 잡지 않습니다. 그래서 한 리스너가 예외를 던지면 그 게시에서 뒤에 남은 리스너는 호출되지 않습니다. 실제 이벤트 시스템이라면 리스너마다 예외를 잡아 기록하고 다음 리스너로 넘어갈지도 정해야 합니다.

다음 글

람다 문법과 실행 시점에서는 지금까지 써 온 람다식의 문법을 정확히 정리합니다. 매개변수를 쓰는 여러 방법, 식 본문과 블록 본문의 차이, 람다 본문이 실제로 실행되는 시점, 그리고 검사 예외를 던지는 메서드를 람다로 넘길 때 생기는 문제를 살펴봅니다.