내부 클래스와 외부 인스턴스
바깥 객체 없이 내부 클래스를 생성하는 컴파일 실패를 재현하고 숨은 외부 참조·캡슐화·이름 가림·지역 클래스 도입을 다룹니다.
비정적 내부 클래스 인스턴스는 항상 특정 바깥 인스턴스와 연결됩니다.
그래서 바깥의 private 인스턴스 필드와 메서드에 직접 접근할 수 있습니다.
이 연결은 긴밀한 협력을 간결하게 하지만 내부 객체가 바깥 객체 수명까지 붙잡고 책임을 과도하게 섞을 수 있습니다.
내부 클래스 생성 조건
다음 코드는 new Editor()가 어느 Board에 속해야 하는지 알 수 없어 컴파일되지 않습니다.
public final class InnerWithoutOuterFailure {
public static void main(String[] args) {
Board.Editor editor = new Board.Editor();
System.out.println(editor);
}
private static final class Board {
final class Editor { }
}
}error: an enclosing instance that contains Board.Editor is required외부에서 생성하려면 board.new Editor() 문법을 사용합니다.
보통은 바깥 클래스의 메서드가 내부 인스턴스를 만들어 반환해 문법과 생성 규칙을 숨깁니다.
내부 인스턴스와 바깥 참조
개념적으로 바깥 객체 안에 내부 객체가 있는 것처럼 보이지만 실제 힙에는 별도 객체로 생성됩니다.
컴파일러가 내부 객체에 바깥 참조를 보관해 필드 접근을 연결합니다.
import java.util.ArrayList;
import java.util.List;
public final class BoardInnerEditor {
public static void main(String[] args) {
Board first = new Board("first");
Board second = new Board("second");
first.add("nested", 40);
second.add("inner", 50);
first.editor().renameFirst("static nested");
System.out.println(first.summary());
System.out.println(second.summary());
}
private static final class Board {
private final String name;
private final List<Entry> entries = new ArrayList<>();
Board(String name) { this.name = name; }
void add(String title, int viewCount) { entries.add(new Entry(title, viewCount)); }
Editor editor() { return new Editor(); }
String summary() { return name + "=" + entries; }
private final class Editor {
void renameFirst(String title) {
Entry current = entries.getFirst();
entries.set(0, new Entry(title, current.viewCount()));
}
}
private record Entry(String title, int viewCount) { }
}
}first=[Entry[title=static nested, viewCount=40]]
second=[Entry[title=inner, viewCount=50]]first의 Editor는 first 참조를 기억하므로 second 목록을 바꾸지 않습니다.
Editor가 외부로 오래 살아남으면 first도 가비지 컬렉션되지 않을 수 있습니다.
짧은 작업 범위에 사용하거나 명시적 서비스로 분리합니다.
바깥 상태 의존과 캡슐화
Car와 Engine을 별도 최상위 클래스로 두고 Engine이 Car getter를 호출하면 Engine 때문에 불필요한 getter가 외부에도 공개될 수 있습니다.
내부 클래스 Engine은 바깥 private 필드를 직접 사용하고 외부 API에서 숨길 수 있습니다.
Board에서는 합계 계산기처럼 Board에 완전히 종속된 보조 구현에 적용할 수 있습니다.
import java.util.ArrayList;
import java.util.List;
public final class InnerSummaryEngine {
public static void main(String[] args) {
Board board = new Board("mid-java");
board.add(40);
board.add(50);
System.out.println(board.renderSummary());
}
private static final class Board {
private final String track;
private final List<Integer> viewCount = new ArrayList<>();
Board(String track) { this.track = track; }
void add(int value) { viewCount.add(value); }
String renderSummary() {
return new SummaryEngine().render();
}
private final class SummaryEngine {
String render() {
int total = viewCount.stream().mapToInt(Integer::intValue).sum();
return track + ":count=" + viewCount.size() + ", total=" + total;
}
}
}
}mid-java:count=2, total=90SummaryEngine를 외부가 생성하거나 호출할 이유가 없습니다.
다만 단순 합계 메서드 한 개라면 별도 내부 클래스 없이 Board 메서드가 더 간단합니다.
보조 객체가 여러 연산과 자체 상태를 가질 때 분리 효과가 커집니다.
이름 가림과 범위 우선순위
내부 메서드의 지역 변수, 내부 필드, 바깥 필드가 같은 이름이면 가까운 값부터 선택됩니다.
this.value는 내부 객체, Outer.this.value는 바깥 객체를 명시합니다.
public final class InnerShadowing {
private final int value = 30;
public static void main(String[] args) {
InnerShadowing outer = new InnerShadowing();
outer.new Inspector().print(10);
}
private final class Inspector {
private final int value = 20;
void print(int value) {
System.out.println("local=" + value);
System.out.println("inner=" + this.value);
System.out.println("outer=" + InnerShadowing.this.value);
}
}
}local=10
inner=20
outer=30명시 문법을 알더라도 같은 이름이 세 겹이면 읽기 어렵습니다.
업무 이름을 구체화해 이름 가림 자체를 줄이고, 생성자 매개변수처럼 의도가 명확한 관례에서만 this를 사용합니다.
지역 클래스의 사용 범위
클래스가 바깥 객체 전체가 아니라 한 메서드의 입력과 지역 값에만 의미가 있다면 메서드 블록에 선언할 수 있습니다.
public final class LocalFormatterIntroduction {
public static void main(String[] args) {
System.out.println(format("board", "inner", 50));
}
private static String format(String prefix, String title, int viewCount) {
class LineFormatter {
String render() {
return prefix + ":" + title + "=" + viewCount;
}
}
return new LineFormatter().render();
}
}board:inner=50지역 클래스는 prefix, title, viewCount를 캡처합니다.
이 값들이 왜 final 또는 사실상 final이어야 하는지는 다음 절에서 스택 수명과 함께 분석합니다.
내부 클래스 선택 기준
- 보조 객체가 특정 바깥 인스턴스 상태에 지속적으로 의존하는가?
- 외부에 노출하면 불필요한 getter와 결합이 생기는가?
- 보조 객체 수명이 바깥보다 길어져 참조 누수가 생기지 않는가?
- 정적 중첩와 명시적 생성자 주입으로 더 분명하게 표현할 수 없는가?
내부는 편의 문법이 아니라 강한 소속 관계를 표현합니다.
상태 공유가 필요 없으면 static을 우선합니다.
연습 문제
PublishQuota의 targetCount와 publishedCount를 읽는 내부 클래스 Progress를 만들고 퍼센트를 계산하세요.
PublishQuota의 progress() 팩터리로 생성합니다.
해설 보기
public final class InnerProgressExercise {
public static void main(String[] args) {
PublishQuota goal = new PublishQuota(100, 40);
System.out.println("progress=" + goal.progress().percent() + "%");
}
private static final class PublishQuota {
private final int targetCount;
private final int publishedCount;
PublishQuota(int targetCount, int publishedCount) {
this.targetCount = targetCount;
this.publishedCount = publishedCount;
}
Progress progress() { return new Progress(); }
final class Progress {
int percent() { return publishedCount * 100 / targetCount; }
}
}
}progress=40%Progress는 특정 goal의 두 필드에 결합되어 있습니다.
퍼센트 값만 필요하다면 단순 메서드가 낫고, 진행 상태 관련 행동이 늘어날 때 내부 객체가 의미를 가집니다.