지역 클래스와 변수 캡처
캡처된 지역 변수 변경의 컴파일 실패를 재현하고 스택·힙 수명 차이, 사실상 final 규칙, 바깥 필드 접근을 게시글 작업으로 이해합니다.
지역 클래스는 메서드 블록 안에 선언되며 그 범위의 매개변수와 지역 값을 사용할 수 있습니다.
하지만 메서드가 끝나면 스택 프레임의 지역 변수는 사라지고 지역 객체는 힙에서 더 오래 살아남을 수 있습니다.
자바는 필요한 지역 값을 객체에 복사해 캡처하고, 원본과 복사본이 어긋나지 않도록 값 변경을 제한합니다.
캡처 변수의 재대입 제한
prefix를 지역 클래스가 사용한 뒤 다른 문자열로 바꾸려 합니다.
캡처 대상은 final이거나 사실상 final이어야 하므로 실패합니다.
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 finalfinal을 명시하지 않아도 초기화 뒤 한 번도 재대입하지 않으면 사실상 final입니다.
객체 참조를 캡처한 경우 참조 재대입은 금지되지만 그 객체의 가변 메서드 호출까지 자동으로 막지는 않습니다.
지역 객체와 캡처 값의 수명
팩터리 메서드는 Printer를 반환하고 종료됩니다.
나중에 print를 호출해도 prefix와 viewCount가 출력됩니다.
실행 중 원래 스택 변수를 다시 찾는 것이 아니라 생성 시 객체에 저장한 값을 사용합니다.
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=40prefix와 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 제약은 값 스냅숏 의미를 단순하게 보존합니다.
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 메서드 등 정적 문맥에 선언한 지역·익명 클래스에는 그 바깥 인스턴스 연결이 없습니다.
지역 값 복사본이 아니라 힙에 있는 같은 바깥 객체 필드를 읽으므로 필드가 바뀌면 최신 값이 보입니다.
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/100targetCount는 생성 시 캡처한 100이고 publishedCount는 바깥 객체의 현재 40입니다.
한 출력에서 스냅숏과 최신 상태를 섞는 것이 요구에 맞는지 확인해야 합니다.
모두 한 시점이어야 하면 불변 Progress 값을 먼저 만들어 전달합니다.
CaptureSnapshotFactory의 세 객체는 각 반복의 capturedIndex 0,1,2를 사용한다. 별도 OuterFieldAndLocalCapture 예제는 캡처한 targetCount 100과 현재 publishedCount 40을 함께 출력한다.
반복 예제와 진행률 예제는 별도 프로그램입니다. 고정된 지역 값과 나중에 읽는 바깥 객체 필드를 구분합니다.
| 읽는 값 | 시간 기준 | 원문 값·결과 |
|---|---|---|
반복별 capturedIndex | 반복마다 새 지역 값을 초기화하고 그 값으로 만든 IndexedLabeler를 보관합니다. 루프 뒤에 호출해도 각 값이 다릅니다. | |
진행률의 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입니다. 이것은 객체 내부 전체 상태를 동결한다는 뜻이 아닙니다.
가변 객체 참조 캡처의 위험
List<String> tags 참조를 재대입하지 않으면 사실상 final이지만 tags.add(...)는 가능합니다.
지역 객체가 나중에 목록을 읽으면 생성 이후 추가된 값까지 봅니다.
캡처 시점 스냅숏이 필요하면 List.copyOf(tags)를 별도 지역 값으로 만들어 캡처합니다.
지역 클래스를 사용할 때 다음을 결정합니다.
- 캡처 값은 생성 시점 스냅숏인가, 공유 가변 상태인가?
- 객체가 메서드 범위를 벗어나 반환되는가?
- 큰 객체를 캡처해 불필요하게 수명을 늘리지 않는가?
- 같은 구현을 여러 메서드가 필요로 하지 않는가?
- 단일 메서드 함수라면 람다가 더 간단하지 않은가?
이름 있는 지역 클래스는 한 메서드 안에서 여러 메서드나 상태를 가진 작은 구현이 필요할 때 유용합니다.
연습 문제
가변 태그 목록을 받은 팩터리에서 불변 복사본을 만들고, 지역 클래스 Formatter가 그 복사본으로 문장을 만들게 하세요.
팩터리 반환 후 원본 목록을 바꿔도 출력은 유지되어야 합니다.
해설 보기
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"를 추가합니다.
| 대상 | 나중 변경과의 관계 | 원문 결과·한계 |
|---|---|---|
원본 목록 source | source.add("changed")로 원본의 요소 수가 2에서 3이 됩니다. | |
캡처한 목록 snapshot | 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다. | |
| 목록 안 요소 | 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의 String 요소는 불변입니다. | 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다. |
- 원본 목록
source - 나중 변경과의 관계:
source.add("changed")로 원본의 요소 수가2에서3이 됩니다.원문 결과·한계:source=[java, nested, changed] - 캡처한 목록
snapshot - 나중 변경과의 관계: 추가·삭제·교체할 수 없는 목록이며 원본에 나중에 추가한 요소를 반영하지 않습니다.원문 결과·한계:
capture=java,nested - 목록 안 요소
- 나중 변경과의 관계: 복사는 요소 자체의 깊은 복사가 아닙니다. 이 예제의
String요소는 불변입니다.원문 결과·한계: 가변 요소라면 그 요소의 상태 변경까지 막아 주지는 않습니다.
참조를 다시 대입하지 않는다는 규칙과 참조 대상의 불변성은 다릅니다. 실제 main은 formatter 결과를 먼저, 원본 목록을 다음에 출력합니다. 표는 두 저장 대상을 비교한 것이며 물리 메모리 배치나 항상 새 인스턴스를 만드는 계약을 뜻하지 않습니다.