본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 중첩 클래스와 내부 클래스

지역 클래스와 변수 캡처

캡처된 지역 변수 변경의 컴파일 실패를 재현하고 스택·힙 수명 차이, 사실상 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와 함께 연장된 것처럼 보입니다.

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


스택과 힙의 수명 차이

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

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이고 각 객체가 다른 값 스냅숏을 가집니다.


바깥 인스턴스 필드의 접근

내부 클래스 계열 클래스는 바깥 인스턴스 참조를 저장합니다.

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

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 값을 먼저 만들어 전달합니다.


가변 객체 참조 캡처의 위험

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]

캡처한 snapshot은 불변이며 원본과 구조를 공유하지 않습니다.

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