안동민 개발노트

본문 시작

지역 클래스와 변수 캡처

캡처된 지역 변수 변경의 컴파일 실패를 재현하고 스택·힙 수명 차이, 사실상 final 규칙, 바깥 필드 접근을 게시글 작업으로 이해합니다.

지역 클래스는 메서드 블록 안에 선언되며 그 범위의 매개변수와 지역 값을 사용할 수 있습니다.

하지만 메서드가 끝나면 스택 프레임의 지역 변수는 사라지고 지역 객체는 힙에서 더 오래 살아남을 수 있습니다.

자바는 필요한 지역 값을 객체에 복사해 캡처하고, 원본과 복사본이 어긋나지 않도록 값 변경을 제한합니다.


캡처 변수의 재대입 제한

prefix를 지역 클래스가 사용한 뒤 다른 문자열로 바꾸려 합니다.

캡처 대상은 final이거나 사실상 final이어야 하므로 실패합니다.

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();
    }
}
컴파일 실패 관찰
error: local variables referenced from an inner class must be final or effectively final

final을 명시하지 않아도 초기화 뒤 한 번도 재대입하지 않으면 사실상 final입니다.

객체 참조를 캡처한 경우 참조 재대입은 금지되지만 그 객체의 가변 메서드 호출까지 자동으로 막지는 않습니다.


지역 객체와 캡처 값의 수명

팩터리 메서드는 Printer를 반환하고 종료됩니다.

나중에 print를 호출해도 prefix와 viewCount가 출력됩니다.

실행 중 원래 스택 변수를 다시 찾는 것이 아니라 생성 시 객체에 저장한 값을 사용합니다.

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

prefix와 viewCount의 생명 주기가 LocalPrinter와 함께 연장된 것처럼 보입니다.

실제 구현 세부 필드 이름에 의존하지 않고 “필요한 값을 캡처한다”는 언어 계약으로 이해합니다.

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

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

CapturedPostPrinter의 호출 순서입니다. 팩터리의 반환값은 Printer 참조이며, print에 전달하는 제목은 나중 호출의 인수입니다.

팩터리 반환 뒤에도 사용하는 캡처 값
실행 위치사용하는 값과 반환실제 출력
① 팩터리 생성·반환createPrinter("daily", 40)에서 LocalPrinter를 생성하고 참조를 반환합니다. prefix는 "daily", viewCount는 40입니다.이 단계의 출력 없음
② 반환 후 main반환된 참조를 printer에 보관한 뒤 다음 문장을 실행합니다.factory-finished
③ 나중 print 호출printer.print("local class")는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다.daily:local class=40
① 팩터리 생성·반환
사용하는 값과 반환: createPrinter("daily", 40)에서 LocalPrinter를 생성하고 참조를 반환합니다. prefix는 "daily", viewCount는 40입니다.
실제 출력: 이 단계의 출력 없음
② 반환 후 main
사용하는 값과 반환: 반환된 참조를 printer에 보관한 뒤 다음 문장을 실행합니다.
실제 출력: factory-finished
③ 나중 print 호출
사용하는 값과 반환: printer.print("local class")는 앞서 캡처한 값과 이번 제목 인수를 함께 사용합니다.
실제 출력: daily:local class=40

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


스택과 힙의 수명 차이

메서드 매개변수와 지역 변수는 호출 스택 프레임에 있고 메서드 종료와 함께 사라집니다.

new로 만든 LocalPrinter는 힙에 있으며 참조가 남아 있으면 계속 살아 있습니다.

지역 객체가 사라진 스택 값에 직접 접근할 수 없으므로 생성 시 값을 자기 상태로 복사합니다.

만약 캡처 뒤 원본 지역 변수를 바꿀 수 있다면 어느 값을 출력해야 하는지 문제가 생깁니다.

스택 값과 객체 안 복사본을 자동 동기화하려면 별도 공유 상자가 필요하고 동시성·수명 규칙이 복잡해집니다.

사실상 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

루프의 index는 매 반복 증가하므로 캡처할 수 없습니다.

반복마다 새로 초기화되는 capturedIndex는 그 반복 안에서 사실상 final이고 각 객체가 다른 값 스냅숏을 가집니다.


바깥 인스턴스 필드의 접근

이 예제처럼 인스턴스 메서드에 선언한 지역 클래스는 바깥 인스턴스와 연결됩니다. 반면 static 메서드 등 정적 문맥에 선언한 지역·익명 클래스에는 그 바깥 인스턴스 연결이 없습니다.

지역 값 복사본이 아니라 힙에 있는 같은 바깥 객체 필드를 읽으므로 필드가 바뀌면 최신 값이 보입니다.

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

targetCount는 생성 시 캡처한 100이고 publishedCount는 바깥 객체의 현재 40입니다.

한 출력에서 스냅숏과 최신 상태를 섞는 것이 요구에 맞는지 확인해야 합니다.

모두 한 시점이어야 하면 불변 Progress 값을 먼저 만들어 전달합니다.

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

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

반복 예제와 진행률 예제는 별도 프로그램입니다. 고정된 지역 값과 나중에 읽는 바깥 객체 필드를 구분합니다.

반복별 캡처 값과 바깥 객체의 현재 필드
읽는 값시간 기준원문 값·결과
반복별 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입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.


가변 객체 참조 캡처의 위험

List<String> tags 참조를 재대입하지 않으면 사실상 final이지만 tags.add(...)는 가능합니다.

지역 객체가 나중에 목록을 읽으면 생성 이후 추가된 값까지 봅니다.

캡처 시점 스냅숏이 필요하면 List.copyOf(tags)를 별도 지역 값으로 만들어 캡처합니다.

지역 클래스를 사용할 때 다음을 결정합니다.

  • 캡처 값은 생성 시점 스냅숏인가, 공유 가변 상태인가?
  • 객체가 메서드 범위를 벗어나 반환되는가?
  • 큰 객체를 캡처해 불필요하게 수명을 늘리지 않는가?
  • 같은 구현을 여러 메서드가 필요로 하지 않는가?
  • 단일 메서드 함수라면 람다가 더 간단하지 않은가?

이름 있는 지역 클래스는 한 메서드 안에서 여러 메서드나 상태를 가진 작은 구현이 필요할 때 유용합니다.


연습 문제

가변 태그 목록을 받은 팩터리에서 불변 복사본을 만들고, 지역 클래스 Formatter가 그 복사본으로 문장을 만들게 하세요.

팩터리 반환 후 원본 목록을 바꿔도 출력은 유지되어야 합니다.

해설 보기
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]

List.copyOf로 만든 snapshot은 원소의 추가·삭제·교체를 허용하지 않고 원본 목록의 이후 변경을 반영하지 않습니다. 다만 요소 자체를 깊게 복사하지 않으며, 이 예제의 요소는 불변 String입니다.

캡처 대상의 시점 의미를 코드로 확정했습니다.

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

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

LocalTagSnapshotExercise는 List.copyOf(tags)의 결과를 snapshot으로 캡처한 뒤 원본에 "changed"를 추가합니다.

목록 멤버십의 복사와 요소 참조의 한계
대상나중 변경과의 관계원문 결과·한계
원본 목록 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 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.