내부 클래스와 외부 인스턴스
바깥 객체 없이 내부 클래스를 생성하는 컴파일 실패를 재현하고 숨은 외부 참조·캡슐화·이름 가림·지역 클래스 도입을 다룹니다.
비정적 멤버 내부 클래스 인스턴스는 특정 바깥 인스턴스와 연결됩니다.
그래서 바깥의 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() 문법을 사용합니다.
보통은 바깥 클래스의 메서드가 내부 인스턴스를 만들어 반환해 문법과 생성 규칙을 숨깁니다.
내부 인스턴스와 바깥 참조
개념적으로 바깥 객체 안에 내부 객체가 있는 것처럼 보이지만 실제 힙에는 별도 객체로 생성됩니다.
이 예제의 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도 가비지 컬렉션되지 않을 수 있습니다.
짧은 작업 범위에 사용하거나 명시적 서비스로 분리합니다.
하나의 Editor가 바꾸는 Board는 first뿐이다
first용 Editor 호출 전후 first의 Entry만 교체되고 second는 유지됩니다. 두 Board의 정체성을 고정한 상태 전환으로 내부 객체의 바깥 참조 효과를 설명하기 좋습니다.
BoardInnerEditor는 first와 second를 만든 뒤 first.editor().renameFirst만 호출한다. first의 제목은 static nested로 교체되고 조회수40은 유지되며 second의 inner/50은 바뀌지 않는다.
내부 클래스 · 연결된 게시판
하나의 Editor가 바꾸는 Board는 first뿐이다
first에서 만든 Editor는 first의 첫 Entry를 교체합니다. second의 목록과 조회 수는 그대로입니다.
first
- 호출 직전 첫 Entry
title=nestedviewCount=40- 호출 뒤 첫 Entry
title=static nestedviewCount=40
second
- 호출 직전 첫 Entry
title=innerviewCount=50- 호출 뒤 첫 Entry
title=innerviewCount=50· 변경 없음
이 실행은 first용 Editor 하나만 만듭니다. entries.set(0, …)은 first 목록의 항목을 새 Entry로 교체하며, 기존 레코드 필드를 수정하거나 second의 목록을 바꾸지 않습니다.
이 예의 조건
- Editor는 first에 연결되며 second는 독립 Board이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각 영역은 같은 Board의 호출 전후 상태입니다. first에서 생성한 Editor는 first 목록의 첫 항목을 새 Entry로 교체하고 조회 수 40을 유지합니다. second의 inner와 50은 그대로입니다. 실선은 생성과 교체, 점선은 변경 없는 상태입니다. |
| 6 | nested |
| 7 | 40 |
| 12 | static nested |
| 13 | 40 |
| 16 | inner |
| 17 | 50 |
| 19 | inner |
| 20 | 50 |
| 구분 | 내용 |
|---|---|
| 설명 | 각 영역은 같은 Board의 호출 전후 상태입니다. first에서 생성한 Editor는 first 목록의 첫 항목을 새 Entry로 교체하고 조회 수 40을 유지합니다. second의 inner와 50은 그대로입니다. 실선은 생성과 교체, 점선은 변경 없는 상태입니다. |
| 전체 읽기 정보 | 하나의 Editor가 바꾸는 Board는 first뿐이다 하나의 Editor가 바꾸는 Board는 first뿐이다 BoardInnerEditor는 first와 second를 만든 뒤 first.editor().renameFirst만 호출한다. first의 제목은 static nested로 교체되고 조회 수 40은 유지되며 second의 inner/50은 바뀌지 않는다. 내부 클래스 · 연결된 게시판 하나의 Editor가 바꾸는 Board는 first뿐이다 first에서 만든 Editor는 first의 첫 Entry를 교체합니다. second의 목록과 조회 수는 그대로입니다. first 호출 직전 첫 Entry title=nested viewCount=40 호출 뒤 첫 Entry title=static nested viewCount=40 second 호출 직전 첫 Entry title=inner viewCount=50 호출 뒤 첫 Entry title=inner viewCount=50 · 변경 없음 이 실행은 first 용 Editor 하나만 만듭니다. entries.set(0, …) 은 first 목록의 항목을 새 Entry로 교체하며, 기존 레코드 필드를 수정하거나 second의 목록을 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 전 두 Board의 첫 항목을 확인합니다. |
|
|
| first Editor first.editor().renameFirst를 호출합니다. |
|
|
| 교체 후 first 목록의 Entry만 새 값으로 교체됩니다. |
|
|
하나의 Editor가 바꾸는 Board는 first뿐이다
first용 Editor 호출 전후 first의 Entry만 교체되고 second는 유지됩니다. 두 Board의 정체성을 고정한 상태 전환으로 내부 객체의 바깥 참조 효과를 설명하기 좋습니다.
BoardInnerEditor는 first와 second를 만든 뒤 first.editor().renameFirst만 호출한다. first의 제목은 static nested로 교체되고 조회수40은 유지되며 second의 inner/50은 바뀌지 않는다.
내부 클래스 · 연결된 게시판
하나의 Editor가 바꾸는 Board는 first뿐이다
first에서 만든 Editor는 first의 첫 Entry를 교체합니다. second의 목록과 조회 수는 그대로입니다.
first
- 호출 직전 첫 Entry
title=nestedviewCount=40- 호출 뒤 첫 Entry
title=static nestedviewCount=40
second
- 호출 직전 첫 Entry
title=innerviewCount=50- 호출 뒤 첫 Entry
title=innerviewCount=50· 변경 없음
이 실행은 first용 Editor 하나만 만듭니다. entries.set(0, …)은 first 목록의 항목을 새 Entry로 교체하며, 기존 레코드 필드를 수정하거나 second의 목록을 바꾸지 않습니다.
이 예의 조건
- Editor는 first에 연결되며 second는 독립 Board이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각 영역은 같은 Board의 호출 전후 상태입니다. first에서 생성한 Editor는 first 목록의 첫 항목을 새 Entry로 교체하고 조회 수 40을 유지합니다. second의 inner와 50은 그대로입니다. 실선은 생성과 교체, 점선은 변경 없는 상태입니다. |
| 6 | nested |
| 7 | 40 |
| 12 | static nested |
| 13 | 40 |
| 16 | inner |
| 17 | 50 |
| 19 | inner |
| 20 | 50 |
| 구분 | 내용 |
|---|---|
| 설명 | 각 영역은 같은 Board의 호출 전후 상태입니다. first에서 생성한 Editor는 first 목록의 첫 항목을 새 Entry로 교체하고 조회 수 40을 유지합니다. second의 inner와 50은 그대로입니다. 실선은 생성과 교체, 점선은 변경 없는 상태입니다. |
| 전체 읽기 정보 | 하나의 Editor가 바꾸는 Board는 first뿐이다 하나의 Editor가 바꾸는 Board는 first뿐이다 BoardInnerEditor는 first와 second를 만든 뒤 first.editor().renameFirst만 호출한다. first의 제목은 static nested로 교체되고 조회 수 40은 유지되며 second의 inner/50은 바뀌지 않는다. 내부 클래스 · 연결된 게시판 하나의 Editor가 바꾸는 Board는 first뿐이다 first에서 만든 Editor는 first의 첫 Entry를 교체합니다. second의 목록과 조회 수는 그대로입니다. first 호출 직전 첫 Entry title=nested viewCount=40 호출 뒤 첫 Entry title=static nested viewCount=40 second 호출 직전 첫 Entry title=inner viewCount=50 호출 뒤 첫 Entry title=inner viewCount=50 · 변경 없음 이 실행은 first 용 Editor 하나만 만듭니다. entries.set(0, …) 은 first 목록의 항목을 새 Entry로 교체하며, 기존 레코드 필드를 수정하거나 second의 목록을 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 전 두 Board의 첫 항목을 확인합니다. |
|
|
| first Editor first.editor().renameFirst를 호출합니다. |
|
|
| 교체 후 first 목록의 Entry만 새 값으로 교체됩니다. |
|
|
바깥 상태 의존과 캡슐화
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를 사용합니다.
Inspector.print(10)의 value는 매개변수10, this.value는 Inspector필드20, InnerShadowing.this.value는 바깥 인스턴스 필드30을 읽는다.
이름 가림 · 세 수신자
같은 value 이름이 선택하는 세 대상
같은 value 이름도 매개변수, 내부 객체, 바깥 객체 중 어느 값을 읽는지에 따라 10·20·30으로 나뉩니다.
value
- 선택되는 선언
- 메서드 매개변수
int value - 원문 출력
local=10
this.value
- 선택되는 선언
- 현재
Inspector객체의 필드 - 원문 출력
inner=20
InnerShadowing.this.value
- 선택되는 선언
- 연결된 바깥 InnerShadowing 객체의 필드
- 원문 출력
outer=30
출력 라벨은 local이지만 첫 값의 선언은 매개변수입니다. this가 바깥 객체로 바뀌는 것이 아니라, 마지막 식이 InnerShadowing.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() 팩터리로 생성합니다.
이번 입력은 targetCount=100, publishedCount=40입니다. 목표는 양수이고 게시 수는 0부터 목표까지입니다. 이 작은 입력에서는 publishedCount * 100도 int 범위 안입니다. 결과는 소수 부분을 버린 정수 퍼센트로 정합니다.
독립 입력 targetCount=200, publishedCount=50의 결과와, targetCount=0을 계산 전에 거부해야 하는 이유를 먼저 적어 보세요.
해설 보기
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%독립 입력의 계산은 50 * 100 / 200 = 25이므로 25%입니다. 목표 0은 분모가 0이므로 이 연습의 유효 입력이 아닙니다.
Progress는 특정 goal의 두 필드에 결합되어 있습니다.
큰 게시 수까지 같은 계산을 재사용하려면 곱셈 전에 long으로 넓혀야 합니다. 예를 들어 목표 2,000,000,000과 게시 수 1,000,000,000은 50%지만 원 int 곱셈은 넘칩니다. 기존 Progress 안에 다음 메서드를 별도로 추가해 비교할 수 있습니다. 원 percent는 그대로 둡니다.
int percentSafely() {
if (targetCount <= 0 || publishedCount < 0 || publishedCount > targetCount) {
throw new IllegalArgumentException("progress range");
}
return (int) ((long) publishedCount * 100 / targetCount);
}입력 범위를 먼저 검사했으므로 반환값은 0~100이고 int로 돌려도 됩니다. (long) (publishedCount * 100)처럼 곱셈 뒤에 바꾸면 이미 넘친 값을 복구하지 못합니다.
퍼센트 값만 필요하다면 단순 메서드가 낫고, 진행 상태 관련 행동이 늘어날 때 내부 객체가 의미를 가집니다.