본문 바로가기

지역 클래스와 변수 캡처

지역 클래스가 메서드의 지역 변수를 어떻게 캡처하는지, 캡처한 변수를 다시 대입할 수 없는 이유와 바깥 필드·가변 객체를 쓸 때의 차이를 배웁니다.

앞 글의 마지막 예제에서 메서드 안에 선언한 지역 클래스(local class) LineFormatter는 메서드의 매개변수 prefix, title, viewCount를 그대로 읽었습니다. 이처럼 지역 클래스가 자신을 감싼 메서드의 변수를 가져다 쓰는 것을 캡처(capture)라고 합니다. 그 예제에서는 객체를 만들자마자 같은 메서드 안에서 썼으니 별로 이상할 것이 없었습니다.

그런데 지역 클래스의 객체를 메서드 밖으로 돌려주면 이야기가 달라집니다. JVM 메모리 구조에서 본 것처럼 매개변수와 지역 변수는 메서드가 끝나면 스택 프레임과 함께 사라지지만, 객체는 힙에 남아 계속 쓰일 수 있습니다. 그렇다면 메서드가 끝난 뒤에 그 객체는 이미 사라진 변수를 어떻게 읽을까요? 또 캡처한 변수에 나중에 다른 값을 대입하면 객체는 어느 값을 보게 될까요? 이 글에서는 두 질문의 답을 따라가며, 자바가 캡처를 '값의 복사'로 처리하고 그래서 변수의 재대입을 막는다는 것을 확인합니다.

메서드가 끝난 뒤에도 남는 캡처 값

다음 createPrinter는 지역 클래스 LocalPrinter의 객체를 만들어 돌려주는 팩터리 메서드입니다. LocalPrinter는 메서드의 매개변수 prefix, viewCount와 지역 변수 separator를 캡처합니다.

src/CapturedPostPrinter.java
public final class CapturedPostPrinter {
    public static void main(String[] args) {
        Printer printer = createPrinter("daily", 40);
        System.out.println("factory-finished");
        printer.print("local class");
    }

    private static Printer createPrinter(String prefix, int viewCount) {
        String separator = ":";

        class LocalPrinter implements Printer {
            @Override
            public void print(String title) {
                System.out.println(prefix + separator + title + "=" + viewCount);
            }
        }

        return new LocalPrinter();
    }

    private interface Printer {
        void print(String title);
    }
}
factory-finished
daily:local class=40

LocalPrinter라는 이름은 createPrinter 안에서만 쓸 수 있습니다. 그래서 createPrinter는 객체를 인터페이스 타입 Printer로 돌려주고, main도 Printer 변수로 받습니다. 지역 클래스의 객체를 메서드 밖에서 쓰려면 이렇게 바깥에서도 보이는 인터페이스나 부모 클래스를 구현하게 해야 합니다.

출력 순서를 보세요. factory-finished가 먼저 나왔다는 것은 print를 호출하기 전에 createPrinter가 이미 끝났다는 뜻입니다. 그런데도 둘째 줄에는 createPrinter의 매개변수였던 daily와 40, 지역 변수였던 :가 모두 들어 있습니다. 호출할 때 넘긴 것은 title인 "local class"뿐인데 말입니다. print가 사라진 스택 변수를 다시 찾아간 것은 아닙니다. LocalPrinter 객체가 만들어질 때 필요한 값을 자기 안에 복사해 두었기 때문입니다.

팩터리 반환 뒤에도 사용하는 캡처 값

팩터리 생성→반환→factory-finished→나중 print의 호출 순서가 실제 예제입니다. 메서드 종료 뒤 캡처한 값이 사용되는 시간 차이를 장면으로 따라가며 캡처 값의 사용 시점을 확인할 수 있습니다.

팩터리 반환 뒤에도 사용하는 캡처 값

CapturedPostPrinter는 daily와 40으로 지역 Printer를 만들어 반환한다. main이 factory-finished를 출력한 뒤 반환 객체를 호출하면 daily:local class=40이 출력된다.

지역 클래스 · 생성 뒤 호출

팩터리 반환 뒤에도 사용하는 캡처 값

팩터리는 Printer 객체를 반환합니다. 팩터리 종료 후 print에 전달한 제목과 저장된 prefix·views를 함께 사용합니다.

팩터리 반환과 이후 Printer 호출의 순서 팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다. 생성 → 반환 → main의 다음 문장 → 나중 호출 팩터리 호출 createPrinter("daily", 40) 반환된 Printer prefix=daily / viewCount=40 separator=":" main의 다음 문장 factory-finished 출력 나중 print 호출 title="local class" 호출 결과 daily:local class=40 팩터리는 이미 반환 · 받은 참조를 나중에 호출 · 물리 캡처 필드 배치는 가정하지 않음
팩터리 반환과 이후 Printer 호출의 순서 팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다. 생성 → 반환 뒤 호출 팩터리 호출 createPrinter "daily", 40 반환된 Printer prefix=daily viewCount=40 separator=":" main의 다음 문장 factory-finished 출력 나중 print 호출 title="local class" 호출 결과 daily:local class=40 팩터리는 이미 반환했습니다 앞서 받은 참조를 다시 호출함 상자는 논리적 값과 실행 단계 물리 필드 배치를 가정하지 않음
  1. 팩터리 생성·반환

    사용하는 값과 반환
    createPrinter("daily", 40)에서 LocalPrinter를 생성하고 참조를 반환합니다. prefix는 "daily", viewCount는 40입니다.
    실제 출력
    이 단계의 출력 없음
  2. 반환 후 main

    사용하는 값과 반환
    반환된 참조를 printer에 보관한 뒤 다음 문장을 실행합니다.
    실제 출력
    factory-finished
  3. 나중 print 호출

    사용하는 값과 반환
    printer.print("local class")는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다.
    실제 출력
    daily:local class=40

원래 팩터리 호출이 다시 실행되는 것은 아닙니다. separator = ":"도 문장 구성에 사용하지만, 이 자료는 컴파일러가 만든 물리 필드의 이름·배치를 가정하지 않습니다.

이 예의 조건

  • 논리적 캡처값이며 물리 필드 배치를 가정하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와 40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다.
원 도표의 설명과 주석
구분내용
설명팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와 40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다.
전체 읽기 정보팩터리 반환 뒤에도 사용하는 캡처 값 팩터리 반환 뒤에도 사용하는 캡처 값 CapturedPostPrinter는 daily와 40으로 지역 Printer를 만들어 반환한다. main이 factory-finished를 출력한 뒤 반환 객체를 호출하면 daily:local class=40이 출력된다. 지역 클래스 · 생성 뒤 호출 팩터리 반환 뒤에도 사용하는 캡처 값 팩터리는 Printer 객체를 반환합니다. 팩터리 종료 후 print에 전달한 제목과 저장된 prefix·views를 함께 사용합니다. 팩터리 생성·반환 사용하는 값과 반환 createPrinter("daily", 40) 에서 LocalPrinter 를 생성하고 참조를 반환합니다. prefix 는 "daily" , viewCount 는 40 입니다. 실제 출력 이 단계의 출력 없음 반환 후 main 사용하는 값과 반환 반환된 참조를 printer 에 보관한 뒤 다음 문장을 실행합니다. 실제 출력 factory-finished 나중 print 호출 사용하는 값과 반환 printer.print("local class") 는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다. 실제 출력 daily:local class=40 원래 팩터리 호출이 다시 실행되는 것은 아닙니다. separator = ":" 도 문장 구성에 사용하지만, 이 자료는 컴파일러가 만든 물리 필드의 이름·배치를 가정하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
팩터리 호출

daily/40을 받은 Printer를 만듭니다.

  • 입력: createPrinter(daily,40)
  • 보관 의미: prefix daily, viewCount 40, separator :
  • 나중 행동이 사용할 값을 생성 시 정한다.
팩터리 반환

main이 Printer 참조를 받고 다음 문장을 실행합니다.

  • 출력: factory-finished
  • 팩터리 프레임: 종료됨
  • 생성 호출이 끝난 뒤에도 반환 객체를 사용할 수 있다.
나중 행동

print(local class)가 앞선 값을 사용합니다.

  • 현재 제목: local class
  • 출력: daily:local class=40
  • 호출 시 제목과 생성 시 캡처값이 결합한다.

팩터리 반환 뒤에도 사용하는 캡처 값

팩터리 생성→반환→factory-finished→나중 print의 호출 순서가 실제 예제입니다. 메서드 종료 뒤 캡처한 값이 사용되는 시간 차이를 장면으로 따라가며 캡처 값의 사용 시점을 확인할 수 있습니다.

팩터리 반환 뒤에도 사용하는 캡처 값

CapturedPostPrinter는 daily와 40으로 지역 Printer를 만들어 반환한다. main이 factory-finished를 출력한 뒤 반환 객체를 호출하면 daily:local class=40이 출력된다.

지역 클래스 · 생성 뒤 호출

팩터리 반환 뒤에도 사용하는 캡처 값

팩터리는 Printer 객체를 반환합니다. 팩터리 종료 후 print에 전달한 제목과 저장된 prefix·views를 함께 사용합니다.

팩터리 반환과 이후 Printer 호출의 순서 팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다. 생성 → 반환 → main의 다음 문장 → 나중 호출 팩터리 호출 createPrinter("daily", 40) 반환된 Printer prefix=daily / viewCount=40 separator=":" main의 다음 문장 factory-finished 출력 나중 print 호출 title="local class" 호출 결과 daily:local class=40 팩터리는 이미 반환 · 받은 참조를 나중에 호출 · 물리 캡처 필드 배치는 가정하지 않음
팩터리 반환과 이후 Printer 호출의 순서 팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다. 생성 → 반환 뒤 호출 팩터리 호출 createPrinter "daily", 40 반환된 Printer prefix=daily viewCount=40 separator=":" main의 다음 문장 factory-finished 출력 나중 print 호출 title="local class" 호출 결과 daily:local class=40 팩터리는 이미 반환했습니다 앞서 받은 참조를 다시 호출함 상자는 논리적 값과 실행 단계 물리 필드 배치를 가정하지 않음
  1. 팩터리 생성·반환

    사용하는 값과 반환
    createPrinter("daily", 40)에서 LocalPrinter를 생성하고 참조를 반환합니다. prefix는 "daily", viewCount는 40입니다.
    실제 출력
    이 단계의 출력 없음
  2. 반환 후 main

    사용하는 값과 반환
    반환된 참조를 printer에 보관한 뒤 다음 문장을 실행합니다.
    실제 출력
    factory-finished
  3. 나중 print 호출

    사용하는 값과 반환
    printer.print("local class")는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다.
    실제 출력
    daily:local class=40

원래 팩터리 호출이 다시 실행되는 것은 아닙니다. separator = ":"도 문장 구성에 사용하지만, 이 자료는 컴파일러가 만든 물리 필드의 이름·배치를 가정하지 않습니다.

이 예의 조건

  • 논리적 캡처값이며 물리 필드 배치를 가정하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와 40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다.
원 도표의 설명과 주석
구분내용
설명팩터리 호출은 Printer 참조를 반환하고 끝납니다. main이 factory-finished를 출력한 뒤 그 참조를 호출하면 앞서 받은 daily와 40 및 이번 제목 local class를 사용해 결과를 만듭니다. 논리적 값과 실행 순서를 나타내며 물리 캡처 필드 배치를 가정하지 않습니다.
전체 읽기 정보팩터리 반환 뒤에도 사용하는 캡처 값 팩터리 반환 뒤에도 사용하는 캡처 값 CapturedPostPrinter는 daily와 40으로 지역 Printer를 만들어 반환한다. main이 factory-finished를 출력한 뒤 반환 객체를 호출하면 daily:local class=40이 출력된다. 지역 클래스 · 생성 뒤 호출 팩터리 반환 뒤에도 사용하는 캡처 값 팩터리는 Printer 객체를 반환합니다. 팩터리 종료 후 print에 전달한 제목과 저장된 prefix·views를 함께 사용합니다. 팩터리 생성·반환 사용하는 값과 반환 createPrinter("daily", 40) 에서 LocalPrinter 를 생성하고 참조를 반환합니다. prefix 는 "daily" , viewCount 는 40 입니다. 실제 출력 이 단계의 출력 없음 반환 후 main 사용하는 값과 반환 반환된 참조를 printer 에 보관한 뒤 다음 문장을 실행합니다. 실제 출력 factory-finished 나중 print 호출 사용하는 값과 반환 printer.print("local class") 는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다. 실제 출력 daily:local class=40 원래 팩터리 호출이 다시 실행되는 것은 아닙니다. separator = ":" 도 문장 구성에 사용하지만, 이 자료는 컴파일러가 만든 물리 필드의 이름·배치를 가정하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
팩터리 호출

daily/40을 받은 Printer를 만듭니다.

  • 입력: createPrinter(daily,40)
  • 보관 의미: prefix daily, viewCount 40, separator :
  • 나중 행동이 사용할 값을 생성 시 정한다.
팩터리 반환

main이 Printer 참조를 받고 다음 문장을 실행합니다.

  • 출력: factory-finished
  • 팩터리 프레임: 종료됨
  • 생성 호출이 끝난 뒤에도 반환 객체를 사용할 수 있다.
나중 행동

print(local class)가 앞선 값을 사용합니다.

  • 현재 제목: local class
  • 출력: daily:local class=40
  • 호출 시 제목과 생성 시 캡처값이 결합한다.

스택에서 사라지는 변수, 힙에 남는 객체

메모리에서 무슨 일이 일어나는지 시점별로 따라가 보겠습니다. createPrinter의 매개변수와 지역 변수는 그 호출의 스택 프레임에 있고, new LocalPrinter()로 만든 객체는 힙에 있습니다.

시점createPrinter의 스택 프레임힙의 LocalPrinter 객체
new LocalPrinter() 실행prefix는 "daily", viewCount는 40, separator는 ":"만들어지면서 세 값을 자기 안에 복사함
createPrinter 반환프레임이 사라져 세 변수도 없어짐main의 printer가 가리키므로 남아 있음
printer.print("local class")(없음)복사해 둔 세 값과 인수 title로 문장을 만듦

표에서 보듯 객체가 실제로 읽는 것은 처음부터 끝까지 자기 안의 복사본입니다. 메서드가 끝나면 원래 변수는 없어지므로, 객체가 메서드보다 오래 살 수 있으려면 값을 복사해 두는 수밖에 없습니다. 지역 변수의 수명이 객체와 함께 늘어난 것처럼 보이지만, 정확히는 수명이 늘어난 것이 아니라 값이 객체로 옮겨 간 것입니다.

더 깊이: 컴파일러가 만드는 복사본 필드

이 복사는 앞 글에서 본 바깥 참조 필드처럼 javac가 숨은 필드로 구현합니다. 위 예제를 컴파일하면 지역 클래스는 CapturedPostPrinter$1LocalPrinter.class라는 파일이 되는데, javap -p로 들여다보면 소스에 없던 필드 세 개가 보입니다.

javap -p 출력 중 필드 부분
  final java.lang.String val$prefix;
  final java.lang.String val$separator;
  final int val$viewCount;

javac는 캡처한 변수마다 final 필드를 하나씩 만들고, new LocalPrinter()를 호출할 때 세 값을 생성자로 넘겨 이 필드에 저장합니다. print 안의 prefix는 실제로는 val$prefix 필드를 읽는 코드로 컴파일됩니다. 다만 필드 이름과 배치는 컴파일러가 정하는 구현 방식일 뿐이니 코드가 여기에 기대서는 안 됩니다. 언어가 정한 것은 '지역 클래스는 캡처한 변수의 값을 쓴다'는 규칙까지입니다.

캡처한 변수는 다시 대입할 수 없다

값을 복사해 둔다면 곤란한 경우가 하나 생깁니다. 객체를 만든 뒤에 원래 변수에 다른 값을 대입하면 원래 변수와 복사본이 서로 달라집니다. 다음 코드는 Printer가 prefix를 캡처한 뒤 prefix를 바꾸려고 합니다.

lab/CapturedLocalMutationFailure.java
public final class CapturedLocalMutationFailure {
    public static void main(String[] args) {
        String prefix = "board";

        class Printer {
            void print() {
                System.out.println(prefix);
            }
        }

        prefix = "changed";
        new Printer().print();
    }
}
컴파일 오류
lab/CapturedLocalMutationFailure.java:7: error: local variables referenced from an inner class must be final or effectively final
                System.out.println(prefix);
                                   ^
1 error

오류는 대입한 줄이 아니라 지역 클래스가 prefix를 읽는 7번 줄에 표시됩니다. 만약 이 코드가 컴파일된다면 print()는 무엇을 출력해야 할까요? 이 코드에서는 Printer 객체가 prefix = "changed"; 다음에 만들어지므로 changed를 복사하겠지만, 대입이 객체를 만든 뒤에 있었다면 객체는 board를, 변수는 changed를 들고 있게 됩니다. 같은 이름 prefix가 코드의 위치에 따라 다른 값을 뜻하게 되는 것입니다. 자바는 이런 혼란을 아예 만들지 않는 쪽을 택했습니다. 캡처하는 변수는 값이 한 번 정해진 뒤 바뀌지 않아야 한다고 정해 두면, 원래 변수와 복사본은 언제나 같은 값이므로 어느 쪽을 읽든 결과가 같습니다.

오류 메시지는 캡처하는 변수가 final이거나 사실상 final(effectively final)이어야 한다고 말합니다. 사실상 final이란 final을 붙이지는 않았지만 초기화한 뒤 한 번도 다시 대입하지 않은 변수를 말합니다. 앞 글의 LineFormatter가 final을 쓰지 않은 매개변수를 캡처할 수 있었던 것도 그래서입니다. 메시지가 지역 클래스를 'inner class'라고 부르는 것은 중첩 클래스의 종류와 정적 중첩 클래스에서 본 것처럼 언어 명세가 static이 아닌 중첩 클래스를 모두 내부 클래스라고 부르기 때문입니다.

이 규칙은 대입이 어디에 있든 똑같이 적용됩니다. 위 코드에서 prefix = "changed";를 지역 클래스 선언 앞으로 옮겨도, new Printer().print(); 뒤로 옮겨도 같은 오류가 납니다. 컴파일러는 대입이 캡처보다 먼저인지 나중인지 따지지 않고, 그 변수가 사실상 final인지만 봅니다. count++처럼 값을 하나 늘리는 것도 다시 대입하는 것이라서 캡처한 count에는 쓸 수 없습니다. 반대로 String label;처럼 선언만 해 두고 if와 else에서 각각 한 번씩만 대입한 변수는 사실상 final로 인정되어 캡처할 수 있습니다.

Java 7까지는 지역 클래스나 익명 클래스가 캡처할 변수에 반드시 final을 직접 붙여야 했습니다. Java 8부터는 다시 대입하지 않는 변수라면 final을 붙이지 않아도 캡처할 수 있게 되었고, 같은 규칙이 Java 8에 새로 들어온 람다식에도 적용됩니다. 람다식이 캡처한 변수를 다시 대입하면 메시지만 referenced from a lambda expression으로 바뀐 같은 오류가 납니다. 람다의 캡처는 18장의 Runnable을 만드는 세 가지 방법과 람다 캡처와 30장에서 다시 만납니다.

지역 클래스 안에서 바뀌는 값이 필요하다면 지역 변수 대신 지역 클래스 자신의 필드를 쓰면 됩니다. 필드는 객체 안에 하나만 있으니 복사본과 어긋날 일이 없습니다. 실무 코드에서는 int[] count = {0};처럼 길이가 1인 배열을 캡처해 두고 count[0]++로 값을 바꾸는 우회로도 가끔 보입니다. 바뀌는 것은 변수 count가 아니라 그 변수가 가리키는 배열의 칸이라서 컴파일되는데, 그 이유는 뒤에서 참조를 캡처하는 경우를 다룰 때 드러납니다. 다만 바뀌는 상태를 배열 뒤에 숨기는 방식이라 의도가 잘 보이지 않으므로, 가능하면 필드를 쓰는 편이 낫습니다.

더 깊이: 다른 언어는 변수 자체를 캡처한다

JavaScript나 C# 같은 언어의 클로저(closure)는 값이 아니라 변수 자체를 캡처합니다. 그래서 캡처한 뒤에도 변수를 자유롭게 바꿀 수 있고, 바뀐 값이 클로저 안에서도 보입니다. 이를 위해 C# 컴파일러는 캡처한 지역 변수를 힙에 있는 숨은 객체로 옮겨, 메서드와 클로저가 같은 저장 공간을 함께 쓰게 만듭니다.

자바는 이 방식을 택하지 않았습니다. 자바의 지역 변수는 그 메서드를 실행하는 스레드 하나만 쓰므로 여러 스레드가 동시에 건드릴 걱정이 없는데, 변수 자체를 공유하면 이 성질이 깨집니다. 캡처한 객체를 다른 스레드에 넘기는 순간 하나의 지역 변수를 여러 스레드가 읽고 쓰게 되기 때문입니다. JLS의 람다 본문 규칙도 사실상 final 제한이 계속 바뀌는 지역 변수에 접근하는 것을 막고, 그런 캡처가 일으킬 수 있는 동시성 문제를 피하려는 것이라고 이유를 밝힙니다. 바뀌는 상태를 공유해야 한다면 객체의 필드에 담아 공유하라는 것이 자바의 답이고, 그렇게 하면 공유한다는 사실이 코드에 드러납니다.

반복문 안에서 객체마다 다른 값 캡처하기

반복문 안에서 지역 클래스의 객체를 여러 개 만들 때 이 규칙을 자주 만납니다. 다음 코드는 반복마다 번호가 다른 라벨러를 만들어 목록에 넣고, 반복이 모두 끝난 뒤에 차례로 호출합니다.

src/CaptureSnapshotFactory.java
import java.util.ArrayList;
import java.util.List;

public final class CaptureSnapshotFactory {
    public static void main(String[] args) {
        List<Labeler> labelers = new ArrayList<>();
        for (int index = 0; index < 3; index++) {
            int capturedIndex = index;
            class IndexedLabeler implements Labeler {
                public String label(String title) {
                    return capturedIndex + "=" + title;
                }
            }
            labelers.add(new IndexedLabeler());
        }

        for (Labeler labeler : labelers) {
            System.out.println(labeler.label("capture"));
        }
    }

    private interface Labeler {
        String label(String title);
    }
}
0=capture
1=capture
2=capture

label 안에서 capturedIndex 대신 index를 바로 쓰면 앞에서 본 것과 같은 must be final or effectively final 오류가 납니다. index는 index++로 매 반복 다시 대입되므로 사실상 final이 아니기 때문입니다. 그래서 반복문 본문 안에 capturedIndex를 따로 선언하고 그 반복의 index 값을 담았습니다. 본문 안에서 선언한 변수는 반복할 때마다 새로 만들어지는 별개의 변수이고, 어느 반복에서도 다시 대입되지 않으므로 사실상 final입니다.

출력을 보면 세 객체가 각자 0, 1, 2를 기억하고 있습니다. 각 IndexedLabeler는 만들어질 때 그 반복의 capturedIndex 값을 복사해 두었으므로, 반복문이 끝난 뒤에 호출해도 서로 다른 번호를 냅니다. 참고로 for (String title : titles) 같은 향상된 for문의 반복 변수는 반복마다 새로 선언되는 것으로 취급되어, 본문에서 다시 대입하지만 않으면 따로 복사하지 않고 바로 캡처할 수 있습니다.

바깥 객체의 필드는 복사하지 않고 읽는다

지역 변수와 달리 필드는 사실상 final이 아니어도 지역 클래스 안에서 쓸 수 있습니다. 인스턴스 메서드 안에서 선언한 지역 클래스는 앞 글의 내부 클래스처럼 바깥 객체에 연결되기 때문입니다. 다음 예제에서 ProgressReporter는 매개변수 targetCount와 바깥 객체의 필드 publishedCount를 함께 출력합니다.

src/OuterFieldAndLocalCapture.java
public final class OuterFieldAndLocalCapture {
    private int publishedCount = 10;

    public static void main(String[] args) {
        OuterFieldAndLocalCapture tracker = new OuterFieldAndLocalCapture();
        Reporter reporter = tracker.createReporter(100);

        tracker.publishedCount = 40;
        reporter.print();
    }

    private Reporter createReporter(int targetCount) {
        class ProgressReporter implements Reporter {
            public void print() {
                System.out.println("progress=" + publishedCount + "/" + targetCount);
            }
        }
        return new ProgressReporter();
    }

    private interface Reporter {
        void print();
    }
}
progress=40/100

reporter를 만들 때 publishedCount는 10이었지만, 출력에는 그 뒤에 바꾼 40이 나왔습니다. 반면 100은 createReporter(100)을 호출할 때 캡처한 값 그대로입니다. 두 값은 읽는 방식이 다릅니다. targetCount는 지역 변수이므로 객체가 만들어질 때 값이 복사되었고, publishedCount는 복사된 것이 아니라 객체가 기억하는 바깥 객체 tracker를 통해 print()를 호출하는 순간에 읽습니다. 힙에 있는 tracker의 필드는 하나뿐이므로 지금 값인 40이 보입니다.

필드에 사실상 final 규칙이 적용되지 않는 이유도 여기서 나옵니다. 필드는 복사본이 따로 생기지 않으니, 원래 값과 복사본이 어긋나는 문제가 처음부터 없습니다. 지역 클래스가 static 메서드 안에 있다면 연결된 바깥 객체가 없으므로 바깥 클래스의 인스턴스 필드는 쓸 수 없습니다.

반복별 캡처 값과 바깥 객체의 현재 필드

반복별 캡처값과 별도 Reporter의 생성 시 targetCount/호출 시 publishedCount를 추적합니다. 각 독립 프로그램을 유지한 채 생성→바깥변경→나중호출 시점을 분리해 값의 출처를 확인할 수 있습니다.

반복별 캡처 값과 바깥 객체의 현재 필드

CaptureSnapshotFactory의 세 객체는 각 반복의 capturedIndex 0,1,2를 사용한다. 별도 OuterFieldAndLocalCapture 예제는 캡처한 targetCount 100과 현재 publishedCount 40을 함께 출력한다.

캡처와 필드 · 읽는 시점

반복별 캡처 값과 바깥 객체의 현재 필드

반복 캡처 예제와 진행률 예제는 별도 프로그램입니다. 생성 때 고정한 지역 값과 호출 때 읽는 바깥 필드를 구별합니다.

반복별 캡처와 현재 바깥 필드를 따로 추적 위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount100과 호출 때 읽는 같은 tracker의 publishedCount40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다. 반복별 독립 객체 · 루프가 끝난 뒤 호출 반복 0의 지역 값 capturedIndex=0 각 라벨러 객체 label("capture") 0=capture 반복 1의 지역 값 capturedIndex=1 각 라벨러 객체 label("capture") 1=capture 반복 2의 지역 값 capturedIndex=2 각 라벨러 객체 label("capture") 2=capture 별도 진행률 프로그램 · 생성 뒤 바깥 필드만 10→40으로 변경 캡처한 인수 targetCount=100 createReporter 때 고정 같은 tracker의 필드 publishedCount:10→40 print 때 현재40 읽음 같은 Reporter progress=40/100 현재40 + 캡처100 점선은 출력에 사용되는 값 · 반복 캡처와 진행률은 서로 다른 프로그램
반복별 캡처와 현재 바깥 필드를 따로 추적 위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount100과 호출 때 읽는 같은 tracker의 publishedCount40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다. 반복별 독립 객체 반복 0의 지역 값 capturedIndex=0 각 라벨러 객체 label("capture") 0=capture 반복 1의 지역 값 capturedIndex=1 각 라벨러 객체 label("capture") 1=capture 반복 2의 지역 값 capturedIndex=2 각 라벨러 객체 label("capture") 2=capture 별도 진행률 프로그램 캡처한 인수 targetCount=100 createReporter 때 고정 같은 tracker의 필드 publishedCount:10→40 print 때 현재40 읽음 같은 Reporter progress=40/100 현재40 + 캡처100 점선은 출력에 사용되는 값 두 영역은 서로 다른 프로그램
반복별 캡처 값과 바깥 객체의 현재 필드
읽는 값시간 기준원문 값·결과
반복별 capturedIndex반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다.
0=capture
1=capture
2=capture
진행률의 targetCountcreateReporter(100) 호출 때 캡처한 매개변수 값입니다.100
진행률의 publishedCount같은 tracker의 필드입니다. 객체 생성 때의 10이 아니라 reporter.print() 때의 현재 값을 읽습니다.40
반복별 capturedIndex
시간 기준
반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다.
원문 값·결과
0=capture
1=capture
2=capture
진행률의 targetCount
시간 기준
createReporter(100) 호출 때 캡처한 매개변수 값입니다.
원문 값·결과
100
진행률의 publishedCount
시간 기준
같은 tracker의 필드입니다. 객체 생성 때의 10이 아니라 reporter.print() 때의 현재 값을 읽습니다.
원문 값·결과
40

진행률 예제는 두 값을 합쳐 progress=40/100을 출력합니다. 바뀌는 루프 index는 직접 캡처할 수 없지만, 반복마다 초기화하는 capturedIndex는 사실상 final입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.

이 예의 조건

  • 반복 라벨러와 Reporter는 독립 프로그램이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex 0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount 100과 호출 때 읽는 같은 tracker의 publishedCount 40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다.
25print 때 현재 40 읽음
28현재 40 + 캡처 100
원 도표의 설명과 주석
구분내용
설명위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex 0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount 100과 호출 때 읽는 같은 tracker의 publishedCount 40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다.
전체 읽기 정보반복별 캡처 값과 바깥 객체의 현재 필드 반복별 캡처 값과 바깥 객체의 현재 필드 CaptureSnapshotFactory의 세 객체는 각 반복의 capturedIndex 0,1,2를 사용한다. 별도 OuterFieldAndLocalCapture 예제는 캡처한 targetCount 100과 현재 publishedCount 40을 함께 출력한다. 캡처와 필드 · 읽는 시점 반복별 캡처 값과 바깥 객체의 현재 필드 반복 캡처 예제와 진행률 예제는 별도 프로그램입니다. 생성 때 고정한 지역 값과 호출 때 읽는 바깥 필드를 구별합니다. 반복별 캡처 값과 바깥 객체의 현재 필드 읽는 값 시간 기준 원문 값·결과 반복별 capturedIndex 반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler 를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다. 0=capture 1=capture 2=capture 진행률의 targetCount createReporter(100) 호출 때 캡처한 매개변수 값입니다. 100 진행률의 publishedCount 같은 tracker 의 필드입니다. 객체 생성 때의 10 이 아니라 reporter.print() 때의 현재 값을 읽습니다. 40 반복별 capturedIndex 시간 기준 반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler 를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다. 원문 값·결과 0=capture 1=capture 2=capture 진행률의 targetCount 시간 기준 createReporter(100) 호출 때 캡처한 매개변수 값입니다. 원문 값·결과 100 진행률의 publishedCount 시간 기준 같은 tracker 의 필드입니다. 객체 생성 때의 10 이 아니라 reporter.print() 때의 현재 값을 읽습니다. 원문 값·결과 40 진행률 예제는 두 값을 합쳐 progress=40/100 을 출력합니다. 바뀌는 루프 index 는 직접 캡처할 수 없지만, 반복마다 초기화하는 capturedIndex 는 사실상 final입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
생성 시 값

반복마다 index를 캡처하고 Reporter는 목표 100을 받습니다.

  • 라벨러: capturedIndex 0,1,2
  • Reporter 목표: 100
  • tracker 최초: 10
  • 생성 때 확정한 지역값과 이후 읽을 객체 필드를 구분한다.
생성 후 변경

tracker 필드만 10→40으로 바꿉니다.

  • publishedCount: 40
  • targetCount: 100 유지
  • 라벨러 index: 각 0,1,2 유지
  • 같은 바깥 객체의 현재 필드는 나중 호출에 보인다.
나중 호출

각 객체가 자신의 캡처값 또는 현재 필드를 사용합니다.

  • 라벨 결과: 0=capture,1=capture,2=capture
  • Reporter: progress=40/100
  • 생성 시 snapshot과 호출 시 현재 값이 다른 출처로 결합한다.

반복별 캡처 값과 바깥 객체의 현재 필드

반복별 캡처값과 별도 Reporter의 생성 시 targetCount/호출 시 publishedCount를 추적합니다. 각 독립 프로그램을 유지한 채 생성→바깥변경→나중호출 시점을 분리해 값의 출처를 확인할 수 있습니다.

반복별 캡처 값과 바깥 객체의 현재 필드

CaptureSnapshotFactory의 세 객체는 각 반복의 capturedIndex 0,1,2를 사용한다. 별도 OuterFieldAndLocalCapture 예제는 캡처한 targetCount 100과 현재 publishedCount 40을 함께 출력한다.

캡처와 필드 · 읽는 시점

반복별 캡처 값과 바깥 객체의 현재 필드

반복 캡처 예제와 진행률 예제는 별도 프로그램입니다. 생성 때 고정한 지역 값과 호출 때 읽는 바깥 필드를 구별합니다.

반복별 캡처와 현재 바깥 필드를 따로 추적 위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount100과 호출 때 읽는 같은 tracker의 publishedCount40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다. 반복별 독립 객체 · 루프가 끝난 뒤 호출 반복 0의 지역 값 capturedIndex=0 각 라벨러 객체 label("capture") 0=capture 반복 1의 지역 값 capturedIndex=1 각 라벨러 객체 label("capture") 1=capture 반복 2의 지역 값 capturedIndex=2 각 라벨러 객체 label("capture") 2=capture 별도 진행률 프로그램 · 생성 뒤 바깥 필드만 10→40으로 변경 캡처한 인수 targetCount=100 createReporter 때 고정 같은 tracker의 필드 publishedCount:10→40 print 때 현재40 읽음 같은 Reporter progress=40/100 현재40 + 캡처100 점선은 출력에 사용되는 값 · 반복 캡처와 진행률은 서로 다른 프로그램
반복별 캡처와 현재 바깥 필드를 따로 추적 위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount100과 호출 때 읽는 같은 tracker의 publishedCount40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다. 반복별 독립 객체 반복 0의 지역 값 capturedIndex=0 각 라벨러 객체 label("capture") 0=capture 반복 1의 지역 값 capturedIndex=1 각 라벨러 객체 label("capture") 1=capture 반복 2의 지역 값 capturedIndex=2 각 라벨러 객체 label("capture") 2=capture 별도 진행률 프로그램 캡처한 인수 targetCount=100 createReporter 때 고정 같은 tracker의 필드 publishedCount:10→40 print 때 현재40 읽음 같은 Reporter progress=40/100 현재40 + 캡처100 점선은 출력에 사용되는 값 두 영역은 서로 다른 프로그램
반복별 캡처 값과 바깥 객체의 현재 필드
읽는 값시간 기준원문 값·결과
반복별 capturedIndex반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다.
0=capture
1=capture
2=capture
진행률의 targetCountcreateReporter(100) 호출 때 캡처한 매개변수 값입니다.100
진행률의 publishedCount같은 tracker의 필드입니다. 객체 생성 때의 10이 아니라 reporter.print() 때의 현재 값을 읽습니다.40
반복별 capturedIndex
시간 기준
반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다.
원문 값·결과
0=capture
1=capture
2=capture
진행률의 targetCount
시간 기준
createReporter(100) 호출 때 캡처한 매개변수 값입니다.
원문 값·결과
100
진행률의 publishedCount
시간 기준
같은 tracker의 필드입니다. 객체 생성 때의 10이 아니라 reporter.print() 때의 현재 값을 읽습니다.
원문 값·결과
40

진행률 예제는 두 값을 합쳐 progress=40/100을 출력합니다. 바뀌는 루프 index는 직접 캡처할 수 없지만, 반복마다 초기화하는 capturedIndex는 사실상 final입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.

이 예의 조건

  • 반복 라벨러와 Reporter는 독립 프로그램이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex 0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount 100과 호출 때 읽는 같은 tracker의 publishedCount 40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다.
25print 때 현재 40 읽음
28현재 40 + 캡처 100
원 도표의 설명과 주석
구분내용
설명위 영역의 세 라벨러는 반복마다 초기화한 capturedIndex 0,1,2를 각자 사용합니다. 아래 별도 프로그램의 Reporter는 생성 때 캡처한 targetCount 100과 호출 때 읽는 같은 tracker의 publishedCount 40을 함께 사용합니다. 점선은 출력에 사용되는 값입니다.
전체 읽기 정보반복별 캡처 값과 바깥 객체의 현재 필드 반복별 캡처 값과 바깥 객체의 현재 필드 CaptureSnapshotFactory의 세 객체는 각 반복의 capturedIndex 0,1,2를 사용한다. 별도 OuterFieldAndLocalCapture 예제는 캡처한 targetCount 100과 현재 publishedCount 40을 함께 출력한다. 캡처와 필드 · 읽는 시점 반복별 캡처 값과 바깥 객체의 현재 필드 반복 캡처 예제와 진행률 예제는 별도 프로그램입니다. 생성 때 고정한 지역 값과 호출 때 읽는 바깥 필드를 구별합니다. 반복별 캡처 값과 바깥 객체의 현재 필드 읽는 값 시간 기준 원문 값·결과 반복별 capturedIndex 반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler 를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다. 0=capture 1=capture 2=capture 진행률의 targetCount createReporter(100) 호출 때 캡처한 매개변수 값입니다. 100 진행률의 publishedCount 같은 tracker 의 필드입니다. 객체 생성 때의 10 이 아니라 reporter.print() 때의 현재 값을 읽습니다. 40 반복별 capturedIndex 시간 기준 반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler 를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다. 원문 값·결과 0=capture 1=capture 2=capture 진행률의 targetCount 시간 기준 createReporter(100) 호출 때 캡처한 매개변수 값입니다. 원문 값·결과 100 진행률의 publishedCount 시간 기준 같은 tracker 의 필드입니다. 객체 생성 때의 10 이 아니라 reporter.print() 때의 현재 값을 읽습니다. 원문 값·결과 40 진행률 예제는 두 값을 합쳐 progress=40/100 을 출력합니다. 바뀌는 루프 index 는 직접 캡처할 수 없지만, 반복마다 초기화하는 capturedIndex 는 사실상 final입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
생성 시 값

반복마다 index를 캡처하고 Reporter는 목표 100을 받습니다.

  • 라벨러: capturedIndex 0,1,2
  • Reporter 목표: 100
  • tracker 최초: 10
  • 생성 때 확정한 지역값과 이후 읽을 객체 필드를 구분한다.
생성 후 변경

tracker 필드만 10→40으로 바꿉니다.

  • publishedCount: 40
  • targetCount: 100 유지
  • 라벨러 index: 각 0,1,2 유지
  • 같은 바깥 객체의 현재 필드는 나중 호출에 보인다.
나중 호출

각 객체가 자신의 캡처값 또는 현재 필드를 사용합니다.

  • 라벨 결과: 0=capture,1=capture,2=capture
  • Reporter: progress=40/100
  • 생성 시 snapshot과 호출 시 현재 값이 다른 출처로 결합한다.

그런데 출력 한 줄 안에 '객체를 만든 시점의 목표'와 '출력하는 시점의 게시 수'가 섞여 있다는 점은 짚어 볼 만합니다. 목표가 나중에 바뀌는 프로그램이라면 이 출력은 어느 시점의 진행률도 정확히 나타내지 못합니다. 모든 값이 같은 시점의 것이어야 한다면, 출력에 쓸 값을 한 시점에 모두 읽어 바꿀 수 없는 값 객체로 만든 뒤 넘기는 편이 안전합니다.

참조를 캡처하면 객체는 함께 쓴다

캡처한 지역 변수가 객체를 가리키는 참조 변수라면, 복사되는 것은 참조값입니다. 객체 자체가 복사되는 것은 아닙니다. 다음 코드는 태그 목록을 캡처한 뒤 원래 목록에 태그를 하나 더 넣습니다.

src/CapturedListShared.java
import java.util.ArrayList;
import java.util.List;

public final class CapturedListShared {
    public static void main(String[] args) {
        List<String> tags = new ArrayList<>();
        tags.add("java");

        class SizeReader implements Reader {
            public int size() {
                return tags.size();
            }
        }
        Reader reader = new SizeReader();

        tags.add("nested");
        System.out.println("size=" + reader.size());
    }

    private interface Reader {
        int size();
    }
}
size=2

tags에는 다른 목록을 대입하지 않으므로 사실상 final이고, 컴파일도 됩니다. 하지만 SizeReader가 복사해 둔 것은 목록을 가리키는 참조값이어서, SizeReader와 main은 같은 목록 하나를 함께 씁니다. 그래서 객체를 만든 뒤에 넣은 nested도 보이고 크기는 2가 됩니다. final 참조와 객체 변경에서 본 것처럼, 변수를 다시 대입하지 않는 것과 그 변수가 가리키는 객체가 바뀌지 않는 것은 별개의 이야기입니다.

객체를 만든 시점의 목록 내용을 고정하고 싶다면 List.copyOf(tags)로 복사본을 만들어 그 복사본을 캡처합니다. List.copyOf는 원래 목록과 같은 원소를 담은, 수정할 수 없는 목록을 돌려주며 원래 목록이 나중에 바뀌어도 그 변경을 반영하지 않습니다. 단, 이 복사는 목록의 칸만 복사합니다. 원소가 바꿀 수 있는 객체라면 두 목록이 같은 원소 객체를 가리키므로, 원소의 내용이 바뀌면 복사본에서도 바뀐 내용이 보입니다. List.copyOf의 공식 문서도 수정할 수 없는 목록이 원소의 추가, 삭제, 교체만 막을 뿐 원소 자체를 불변으로 만들지는 않는다고 설명합니다. 원래 목록에 null 원소가 있으면 NullPointerException이 난다는 점도 알아 두세요.

다음 예제는 이 두 가지를 함께 보여 줍니다. StringBuilder는 문자열 서식과 StringBuilder에서 본 것처럼 내용을 바꿀 수 있는 문자열 객체입니다.

src/MutableTagCaptureBoundary.java
import java.util.ArrayList;
import java.util.List;

public final class MutableTagCaptureBoundary {
    public static void main(String[] args) {
        StringBuilder tag = new StringBuilder("java");
        List<StringBuilder> source = new ArrayList<>(List.of(tag));
        Formatter formatter = createFormatter(source);
        tag.append("-changed");
        source.add(new StringBuilder("nested"));
        System.out.println(formatter.format());
        System.out.println("source-size=" + source.size());
    }

    private static Formatter createFormatter(List<StringBuilder> tags) {
        List<StringBuilder> snapshot = List.copyOf(tags);
        String fixedText = snapshot.get(0).toString();
        class TagFormatter implements Formatter {
            public String format() {
                return "members=" + snapshot.size()
                        + ", live=" + snapshot.get(0)
                        + ", fixed=" + fixedText;
            }
        }
        return new TagFormatter();
    }

    private interface Formatter {
        String format();
    }
}
members=1, live=java-changed, fixed=java
source-size=2

List.of(tag)는 tag 하나를 담은 목록을 만들고, new ArrayList<>(...)는 그 원소를 담되 원소를 더 넣을 수 있는 목록을 새로 만듭니다. createFormatter는 이 목록의 복사본 snapshot과, 첫 원소를 그 시점에 문자열로 바꾼 fixedText를 캡처합니다. 그 뒤 main은 tag의 내용에 -changed를 덧붙이고 source에 원소를 하나 더 넣습니다.

출력을 세 부분으로 나눠 읽어 보겠습니다. members=1은 source에 나중에 넣은 nested가 snapshot에 들어오지 않았다는 뜻입니다. 목록의 칸은 복사되었기 때문입니다. live=java-changed는 snapshot의 첫 칸이 source의 첫 칸과 같은 StringBuilder 객체를 가리키고 있어서, 그 객체에 덧붙인 내용이 그대로 보인 것입니다. fixed=java는 fixedText가 toString()을 호출한 시점에 만들어진 별도의 String이기 때문에 나중 변경과 상관없이 java로 남은 것입니다. String은 한 번 만들면 내용을 바꿀 수 없으므로, 이렇게 바꿀 수 없는 값으로 만들어 캡처하면 그 시점의 내용을 확실하게 고정할 수 있습니다.

지역 클래스를 쓸지 판단하기

지역 클래스를 쓸 때는 다음을 먼저 정해 두면 앞에서 본 문제를 피할 수 있습니다.

  • 캡처하는 것이 만든 시점에 고정된 값인가, 아니면 다른 코드와 함께 쓰는 바뀌는 객체인가?
  • 객체가 메서드 밖으로 반환되어 메서드보다 오래 사는가?
  • 큰 객체를 캡처해서 그 객체가 필요 이상으로 오래 메모리에 남지 않는가? 인스턴스 메서드 안의 지역 클래스는 바깥 객체까지 붙잡을 수 있다는 점도 기억하세요.
  • 같은 구현을 다른 메서드에서도 쓰게 되지 않는가? 그렇다면 정적 중첩 클래스나 최상위 클래스로 옮길 때입니다.
  • 메서드 하나짜리 인터페이스를 구현할 뿐이라면 람다식이 더 간단하지 않은가?

정리하면 이름 있는 지역 클래스는 한 메서드 안에서만 필요한 작은 구현이 메서드를 여러 개 가지거나 자기 상태를 가질 때 잘 맞습니다. 지역 클래스에는 private이나 public 같은 접근 제어자를 붙일 수 없는데, 어차피 그 메서드 밖에서는 이름조차 보이지 않기 때문입니다.

더 깊이: 지역 레코드는 캡처하지 않는다

Java 16부터는 메서드 안에 레코드, enum, 인터페이스도 선언할 수 있습니다. 이들은 지역 클래스와 달리 언제나 암묵적으로 static입니다. 그래서 바깥 객체에 연결되지 않고 메서드의 지역 변수도 캡처할 수 없습니다. 메서드 안의 레코드 본문에서 그 메서드의 지역 변수 prefix를 읽으려 하면 javac는 non-static variable prefix cannot be referenced from a static context라는 오류를 냅니다. 레코드의 상태는 선언에 적은 구성 요소로만 정해져야 하는데, 몰래 캡처한 값이 생기면 그 원칙이 깨지기 때문입니다. 지역 레코드에 필요한 값은 구성 요소로 받아야 합니다.

정리

  • 지역 클래스는 자신을 감싼 메서드의 매개변수와 지역 변수를 캡처할 수 있습니다. 캡처한 값은 객체가 만들어질 때 객체 안으로 복사되므로, 메서드가 끝난 뒤에도 쓸 수 있습니다.
  • 캡처하는 변수는 final이거나 사실상 final이어야 합니다. 다시 대입할 수 있다면 원래 변수와 복사본이 어긋나기 때문입니다. 대입이 캡처의 앞에 있든 뒤에 있든 똑같이 거부됩니다.
  • 반복 변수처럼 바뀌는 값은 반복문 본문 안의 새 변수에 담아서 캡처합니다.
  • 바깥 객체의 필드는 복사되지 않고 바깥 객체를 통해 호출 시점에 읽히므로, 사실상 final이 아니어도 되고 최신 값이 보입니다.
  • 참조를 캡처하면 객체는 함께 씁니다. 목록의 내용을 고정하려면 List.copyOf 같은 복사본을 캡처하되, 원소가 가변 객체라면 원소의 변경은 막지 못한다는 점에 주의합니다.

연습 문제

  1. 가변 태그 목록을 받는 팩터리 메서드 createFormatter를 만드세요. 이 메서드는 받은 목록의 수정할 수 없는 복사본을 만들고, 지역 클래스 TagFormatter가 그 복사본으로 문장을 만들게 합니다. format("capture")는 capture=java,nested처럼 제목 뒤에 태그를 쉼표로 이어 붙인 문자열을 돌려줍니다. 원래 목록은 ["java", "nested"]로 시작하고, 포매터를 만든 뒤 원래 목록에 "changed"를 추가해도 포매터의 결과가 바뀌지 않아야 합니다.
  2. 다음 프로그램이 무엇을 출력할지 실행하기 전에 예상하세요. 그다음 views = 5; 바로 아래에 limit = 20;을 추가하면 어떻게 되는지 설명하세요.
src/CaptureQuiz.java
public final class CaptureQuiz {
    private int views = 1;

    public static void main(String[] args) {
        CaptureQuiz quiz = new CaptureQuiz();
        Reporter reporter = quiz.create(10);
        quiz.views = 7;
        System.out.println(reporter.report());
    }

    private Reporter create(int limit) {
        String label = "views";
        class ViewReporter implements Reporter {
            public String report() {
                return label + "=" + views + "/" + limit;
            }
        }
        Reporter reporter = new ViewReporter();
        views = 5;
        return reporter;
    }

    private interface Reporter {
        String report();
    }
}
해설 보기

1번은 다음과 같이 작성할 수 있습니다. String.join(",", snapshot)은 목록의 원소를 쉼표로 이어 문자열 하나로 만듭니다.

src/LocalTagSnapshotExercise.java
import java.util.ArrayList;
import java.util.List;

public final class LocalTagSnapshotExercise {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("java", "nested"));
        Formatter formatter = createFormatter(source);
        source.add("changed");

        System.out.println(formatter.format("capture"));
        System.out.println("source=" + source);
    }

    private static Formatter createFormatter(List<String> tags) {
        List<String> snapshot = List.copyOf(tags);
        class TagFormatter implements Formatter {
            public String format(String title) {
                return title + "=" + String.join(",", snapshot);
            }
        }
        return new TagFormatter();
    }

    private interface Formatter {
        String format(String title);
    }
}
capture=java,nested
source=[java, nested, changed]

TagFormatter가 캡처한 것은 tags가 아니라 List.copyOf로 만든 snapshot입니다. snapshot은 포매터를 만들 때의 두 원소만 담고 있고 원래 목록의 이후 변경을 반영하지 않으므로, source에 changed를 넣은 뒤에도 결과는 capture=java,nested입니다. 둘째 줄은 원래 목록에는 실제로 changed가 들어갔다는 것을 보여 줍니다. 이 예제의 원소는 바꿀 수 없는 String이라서, 목록의 구성뿐 아니라 각 태그의 내용까지 포매터를 만든 시점의 것으로 고정됩니다. createFormatter에서 snapshot 대신 tags를 캡처했다면 첫 줄은 capture=java,nested,changed가 되었을 것입니다.

목록 멤버십의 복사와 요소 참조의 한계

List.copyOf 생성 뒤 source에 changed를 추가해 snapshot 멤버십은 유지되고 String 요소 참조는 공유됩니다. 복사 시점과 이후 추가를 분리하면 얕은 복사의 경계가 명확합니다.

목록 멤버십의 복사와 요소 참조의 한계

LocalTagSnapshotExercise는 java,nested의 목록 스냅숏을 캡처한다. 원본에 changed를 추가해도 formatter의 결과는 유지되지만 List.copyOf는 요소 자체를 깊게 복사하지 않는다.

목록 복사 · 원소 참조

목록 멤버십의 복사와 요소 참조의 한계

List.copyOf로 얻은 목록은 source의 나중 추가를 반영하지 않습니다. 원소 객체 자체를 깊게 복사하는 것은 아닙니다.

목록 멤버십 분리와 같은 String 요소 참조 source는 java,nested에서 changed를 나중에 추가한 세 요소 목록이고 snapshot은 추가 전에 source를 인수 tags로 전달하고 List.copyOf(tags)로 받은 두 요소 목록입니다. 점선은 두 목록이 같은 String 요소를 참조함을 나타냅니다. snapshot은 changed를 담지 않습니다. 실제 String은 불변이며 가변 요소의 깊은 복사 보장은 아닙니다. source 나중 추가 뒤 요소3 java, nested, changed snapshot List.copyOf(tags) 추가 전 요소2 유지 같은 String 요소 String 요소 java String 요소 nested 나중 추가한 요소 changed 점선은 목록의 요소 참조 · 멤버십 복사로 이후 추가는 격리 · String 자체는 불변
목록 멤버십 분리와 같은 String 요소 참조 source는 java,nested에서 changed를 나중에 추가한 세 요소 목록이고 snapshot은 추가 전에 source를 인수 tags로 전달하고 List.copyOf(tags)로 받은 두 요소 목록입니다. 점선은 두 목록이 같은 String 요소를 참조함을 나타냅니다. snapshot은 changed를 담지 않습니다. 실제 String은 불변이며 가변 요소의 깊은 복사 보장은 아닙니다. source 나중 추가 뒤 요소3 java, nested, changed snapshot List.copyOf(tags) 추가 전 요소2 유지 같은 String 요소 String 요소 java String 요소 nested 나중 추가한 요소 changed 점선은 목록의 요소 참조 snapshot은 changed를 안 담음 String 자체는 불변입니다
목록 멤버십의 복사와 요소 참조의 한계
대상나중 변경과의 관계원문 결과·한계
원본 목록 sourcesource.add("changed")로 원본의 요소 수가 2에서 3이 됩니다.
source=[java, nested, changed]
캡처한 목록 snapshot추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다.
capture=java,nested
목록 안 요소복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다.가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다.
원본 목록 source
나중 변경과의 관계
source.add("changed")로 원본의 요소 수가 2에서 3이 됩니다.
원문 결과·한계
source=[java, nested, changed]
캡처한 목록 snapshot
나중 변경과의 관계
추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다.
원문 결과·한계
capture=java,nested
목록 안 요소
나중 변경과의 관계
복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다.
원문 결과·한계
가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다.

참조를 다시 대입하지 않는다는 규칙과 참조 대상의 불변성은 다릅니다. 실제 main은 formatter 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.

이 예의 조건

  • String 요소는 불변이며 가변 요소의 깊은 복사를 보장하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
4나중 추가 뒤 요소 3개
8추가 전 요소 2개 유지
원 도표의 설명과 주석
구분내용
전체 읽기 정보목록 멤버십의 복사와 요소 참조의 한계 목록 멤버십의 복사와 요소 참조의 한계 LocalTagSnapshotExercise는 java,nested의 목록 스냅숏을 캡처한다. 원본에 changed를 추가해도 formatter의 결과는 유지되지만 List.copyOf는 요소 자체를 깊게 복사하지 않는다. 목록 복사 · 원소 참조 목록 멤버십의 복사와 요소 참조의 한계 List.copyOf로 얻은 목록은 source의 나중 추가를 반영하지 않습니다. 원소 객체 자체를 깊게 복사하는 것은 아닙니다. 목록 멤버십의 복사와 요소 참조의 한계 대상 나중 변경과의 관계 원문 결과·한계 원본 목록 source source.add("changed") 로 원본의 요소 수가 2 에서 3 이 됩니다. source=[java, nested, changed] 캡처한 목록 snapshot 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다. capture=java,nested 목록 안 요소 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다. 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다. 원본 목록 source 나중 변경과의 관계 source.add("changed") 로 원본의 요소 수가 2 에서 3 이 됩니다. 원문 결과·한계 source=[java, nested, changed] 캡처한 목록 snapshot 나중 변경과의 관계 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다. 원문 결과·한계 capture=java,nested 목록 안 요소 나중 변경과의 관계 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다. 원문 결과·한계 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다. 참조를 다시 대입하지 않는다는 규칙과 참조 대상의 불변성은 다릅니다. 실제 main은 formatter 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
복사 전

source가 두 String 요소를 보유합니다.

  • source: java,nested
  • 요소 수: 2
  • 멤버십 snapshot의 기준 시점을 정한다.
snapshot 생성

List.copyOf(tags)로 두 요소를 보관합니다.

  • snapshot: java,nested
  • String 요소: 같은 참조 공유
  • 목록 구조 복사는 요소 객체 자체를 복제하지 않는다.
source 추가

changed를 외부 목록에 넣습니다.

  • source: java,nested,changed / 3
  • snapshot: java,nested / 2
  • 이후 멤버십 변경이 기존 snapshot에 도달하지 않는다.

목록 멤버십의 복사와 요소 참조의 한계

List.copyOf 생성 뒤 source에 changed를 추가해 snapshot 멤버십은 유지되고 String 요소 참조는 공유됩니다. 복사 시점과 이후 추가를 분리하면 얕은 복사의 경계가 명확합니다.

목록 멤버십의 복사와 요소 참조의 한계

LocalTagSnapshotExercise는 java,nested의 목록 스냅숏을 캡처한다. 원본에 changed를 추가해도 formatter의 결과는 유지되지만 List.copyOf는 요소 자체를 깊게 복사하지 않는다.

목록 복사 · 원소 참조

목록 멤버십의 복사와 요소 참조의 한계

List.copyOf로 얻은 목록은 source의 나중 추가를 반영하지 않습니다. 원소 객체 자체를 깊게 복사하는 것은 아닙니다.

목록 멤버십 분리와 같은 String 요소 참조 source는 java,nested에서 changed를 나중에 추가한 세 요소 목록이고 snapshot은 추가 전에 source를 인수 tags로 전달하고 List.copyOf(tags)로 받은 두 요소 목록입니다. 점선은 두 목록이 같은 String 요소를 참조함을 나타냅니다. snapshot은 changed를 담지 않습니다. 실제 String은 불변이며 가변 요소의 깊은 복사 보장은 아닙니다. source 나중 추가 뒤 요소3 java, nested, changed snapshot List.copyOf(tags) 추가 전 요소2 유지 같은 String 요소 String 요소 java String 요소 nested 나중 추가한 요소 changed 점선은 목록의 요소 참조 · 멤버십 복사로 이후 추가는 격리 · String 자체는 불변
목록 멤버십 분리와 같은 String 요소 참조 source는 java,nested에서 changed를 나중에 추가한 세 요소 목록이고 snapshot은 추가 전에 source를 인수 tags로 전달하고 List.copyOf(tags)로 받은 두 요소 목록입니다. 점선은 두 목록이 같은 String 요소를 참조함을 나타냅니다. snapshot은 changed를 담지 않습니다. 실제 String은 불변이며 가변 요소의 깊은 복사 보장은 아닙니다. source 나중 추가 뒤 요소3 java, nested, changed snapshot List.copyOf(tags) 추가 전 요소2 유지 같은 String 요소 String 요소 java String 요소 nested 나중 추가한 요소 changed 점선은 목록의 요소 참조 snapshot은 changed를 안 담음 String 자체는 불변입니다
목록 멤버십의 복사와 요소 참조의 한계
대상나중 변경과의 관계원문 결과·한계
원본 목록 sourcesource.add("changed")로 원본의 요소 수가 2에서 3이 됩니다.
source=[java, nested, changed]
캡처한 목록 snapshot추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다.
capture=java,nested
목록 안 요소복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다.가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다.
원본 목록 source
나중 변경과의 관계
source.add("changed")로 원본의 요소 수가 2에서 3이 됩니다.
원문 결과·한계
source=[java, nested, changed]
캡처한 목록 snapshot
나중 변경과의 관계
추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다.
원문 결과·한계
capture=java,nested
목록 안 요소
나중 변경과의 관계
복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다.
원문 결과·한계
가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다.

참조를 다시 대입하지 않는다는 규칙과 참조 대상의 불변성은 다릅니다. 실제 main은 formatter 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.

이 예의 조건

  • String 요소는 불변이며 가변 요소의 깊은 복사를 보장하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
4나중 추가 뒤 요소 3개
8추가 전 요소 2개 유지
원 도표의 설명과 주석
구분내용
전체 읽기 정보목록 멤버십의 복사와 요소 참조의 한계 목록 멤버십의 복사와 요소 참조의 한계 LocalTagSnapshotExercise는 java,nested의 목록 스냅숏을 캡처한다. 원본에 changed를 추가해도 formatter의 결과는 유지되지만 List.copyOf는 요소 자체를 깊게 복사하지 않는다. 목록 복사 · 원소 참조 목록 멤버십의 복사와 요소 참조의 한계 List.copyOf로 얻은 목록은 source의 나중 추가를 반영하지 않습니다. 원소 객체 자체를 깊게 복사하는 것은 아닙니다. 목록 멤버십의 복사와 요소 참조의 한계 대상 나중 변경과의 관계 원문 결과·한계 원본 목록 source source.add("changed") 로 원본의 요소 수가 2 에서 3 이 됩니다. source=[java, nested, changed] 캡처한 목록 snapshot 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다. capture=java,nested 목록 안 요소 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다. 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다. 원본 목록 source 나중 변경과의 관계 source.add("changed") 로 원본의 요소 수가 2 에서 3 이 됩니다. 원문 결과·한계 source=[java, nested, changed] 캡처한 목록 snapshot 나중 변경과의 관계 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다. 원문 결과·한계 capture=java,nested 목록 안 요소 나중 변경과의 관계 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다. 원문 결과·한계 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다. 참조를 다시 대입하지 않는다는 규칙과 참조 대상의 불변성은 다릅니다. 실제 main은 formatter 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
복사 전

source가 두 String 요소를 보유합니다.

  • source: java,nested
  • 요소 수: 2
  • 멤버십 snapshot의 기준 시점을 정한다.
snapshot 생성

List.copyOf(tags)로 두 요소를 보관합니다.

  • snapshot: java,nested
  • String 요소: 같은 참조 공유
  • 목록 구조 복사는 요소 객체 자체를 복제하지 않는다.
source 추가

changed를 외부 목록에 넣습니다.

  • source: java,nested,changed / 3
  • snapshot: java,nested / 2
  • 이후 멤버십 변경이 기존 snapshot에 도달하지 않는다.

2번의 출력은 다음과 같습니다.

views=7/10

label과 limit는 지역 변수이므로 ViewReporter가 만들어질 때 그 값인 문자열 "views"와 정수 10이 복사됩니다. 반면 views는 바깥 객체의 필드라서 report()를 호출하는 순간 quiz를 통해 읽습니다. create 안에서 5로 바뀌었다가 main에서 다시 7로 바뀌었으므로, 호출 시점의 값인 7이 나옵니다. 객체를 만들 때의 1도, create가 바꾼 5도 아닙니다.

limit = 20;을 추가하면 컴파일되지 않습니다. limit는 지역 클래스가 캡처하는 매개변수인데, 다시 대입하면 사실상 final이 아니게 되기 때문입니다. javac는 report() 안에서 limit를 쓰는 줄에 local variables referenced from an inner class must be final or effectively final 오류를 표시합니다. 대입이 객체를 만든 뒤에 있다는 점은 상관이 없습니다. 같은 자리의 views = 5;는 필드에 대한 대입이라서 문제가 되지 않습니다.

다음 글

익명 클래스로 동작 전달하기에서는 이름 없이 선언과 생성을 한 번에 하는 익명 클래스를 다룹니다. 익명 클래스도 지역 클래스와 같은 방식으로 변수를 캡처하므로, 이 글에서 본 사실상 final 규칙과 바깥 객체 연결이 그대로 적용됩니다.