내부 클래스와 바깥 인스턴스
static 없이 선언한 내부 클래스가 바깥 객체와 어떻게 연결되는지, 그 객체를 만드는 방법과 연결이 가져오는 편리함과 비용을 배웁니다.
앞 글의 정적 중첩 클래스는 바깥 객체와 연결되어 있지 않아서, 바깥 객체의 필드가 필요하면 그 객체를 인수로 넘겨야 했습니다. 그런데 게시판의 글을 고치는 편집기처럼, 보조 객체가 처음부터 끝까지 게시판 하나만 상대한다면 어떨까요? 메서드를 부를 때마다 같은 게시판을 넘기는 일은 번거롭고, 다른 게시판을 잘못 넘기는 실수도 생길 수 있습니다.
static 없이 선언한 멤버 클래스, 즉 내부 클래스(inner class)는 이 문제를 언어 차원에서 풉니다. 내부 클래스의 객체는 만들어지는 순간 바깥 클래스의 객체 하나와 연결되고, 그 뒤로는 그 바깥 객체의 필드와 메서드를 이름만으로 씁니다. 이 글에서는 내부 클래스 객체가 바깥 객체와 연결된다는 것이 무슨 뜻인지, 그런 객체를 어떻게 만드는지, 그리고 그 연결이 어떤 비용을 가져오는지 차례로 살펴봅니다.
자기를 만든 게시판만 고치는 편집기
게시판 Board마다 글 목록이 있고, 첫 글의 제목을 바꾸는 편집기 Editor가 있다고 해 봅시다. Editor를 static 없이 Board 안에 선언하면, Editor 안에서 entries라고만 써도 그 편집기를 만든 게시판의 목록을 가리킵니다.
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.get(0);
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]]main은 게시판 두 개를 만들고 first에서 얻은 편집기로만 제목을 바꿉니다. 출력을 보면 first의 첫 글은 제목이 static nested로 바뀌고 조회 수 40은 그대로이며, second의 글은 전혀 바뀌지 않았습니다. Editor 클래스의 코드는 하나뿐이고 그 안에는 어느 게시판인지 적혀 있지 않은데, 어떻게 first의 목록만 골라서 고쳤을까요?
답은 first.editor() 안의 new Editor()에 있습니다. 내부 클래스의 객체를 만들 때는 언제나 바깥 객체가 하나 정해지고, 새 객체는 그 바깥 객체를 기억합니다. 인스턴스 메서드 editor() 안에서 new Editor()라고 쓰면, 그 메서드를 호출한 객체인 this가 바깥 객체가 됩니다. 그래서 first.editor()로 만든 편집기는 first를 기억하고, renameFirst 안의 entries는 '이 편집기를 만든 게시판의 entries', 즉 first의 목록으로 해석됩니다. second를 고치려면 second.editor()로 편집기를 따로 만들어야 합니다.
renameFirst가 기존 Entry의 제목만 고치지 않고 entries.set(0, ...)으로 첫 칸에 새 Entry를 넣는 이유는 레코드의 필드를 바꿀 수 없기 때문입니다. 대신 기존 글의 조회 수를 읽어 새 글에 그대로 넘기므로 40이 유지됩니다.
하나의 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만 새 값으로 교체됩니다. |
|
|
'내부' 클래스라는 이름 때문에 편집기 객체가 게시판 객체 안에 들어 있다고 상상하기 쉽지만, 실제로는 그렇지 않습니다. Editor 객체는 힙에 Board 객체와 따로 만들어지고, Editor 객체 안에 바깥 Board 객체를 가리키는 참조가 하나 있을 뿐입니다. 이 참조가 메모리에 어떤 영향을 주는지는 뒤에서 다시 봅니다.
Editor가 entries를 이름만으로 쓸 수 있는 데에는 두 가지가 겹쳐 있다는 점도 구별해 둘 만합니다. entries가 private이어도 접근할 수 있는 것은 앞 글에서 본 것처럼 같은 최상위 클래스 안이기 때문이고, 이는 정적 중첩 클래스도 마찬가지입니다. 내부 클래스가 더하는 것은 '어느 객체의 entries인지'를 따로 적지 않아도 된다는 점입니다.
내부 클래스 객체를 만드는 방법
정적 중첩 클래스는 new Board.Entry(...)처럼 어디서든 바로 만들 수 있었습니다. 내부 클래스도 같은 방식으로 만들면 어떻게 될까요?
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 { }
}
}lab/InnerWithoutOuterFailure.java:3: error: an enclosing instance that contains Board.Editor is required
Board.Editor editor = new Board.Editor();
^
1 error오류 메시지는 'Board.Editor를 품을 바깥 인스턴스(enclosing instance)가 필요하다'는 뜻입니다. main은 static 메서드라서 this가 없고, new Board.Editor()라는 식에도 어느 Board에 연결할지가 적혀 있지 않습니다. 내부 클래스 객체는 바깥 객체 없이는 만들 수 없으므로 컴파일러가 거부합니다.
바깥 객체를 직접 지정하려면 new 앞에 바깥 객체의 참조와 점을 붙입니다. 앞 코드의 main을 다음처럼 고치면 컴파일됩니다.
Board board = new Board();
Board.Editor editor = board.new Editor();board.new Editor()는 'board를 바깥 객체로 삼아 Editor를 만들라'는 뜻입니다. 그렇다면 앞 예제의 editor() 메서드에서는 왜 그냥 new Editor()라고 써도 되었을까요? 바깥 클래스의 인스턴스 메서드 안에서 쓴 new Editor()는 this.new Editor()를 줄여 쓴 것이기 때문입니다. 인스턴스 메서드에는 this가 있으니 생략할 수 있고, 바깥 클래스의 static 메서드에는 this가 없으니 생략할 수 없습니다.
실무 코드에서 board.new Editor() 같은 문법은 자주 보이지 않습니다. 보통은 앞 예제의 editor()처럼 바깥 클래스가 내부 객체를 만들어 돌려주는 메서드를 둡니다. 그러면 바깥 코드는 낯선 문법을 몰라도 되고, 어느 바깥 객체에 연결되는지도 호출한 객체로 자연스럽게 정해집니다. 참고로 board.new Editor()에서 board가 null이면 NullPointerException이 납니다. 연결할 바깥 객체가 없으니 객체를 만들 수 없는 것입니다.
바깥 객체를 붙잡아 두는 비용
내부 클래스 객체가 바깥 객체를 기억한다는 것은, 내부 객체가 살아 있는 동안 바깥 객체도 함께 살아 있다는 뜻입니다. JVM 메모리 구조에서 본 것처럼 가비지 컬렉터는 어디에서도 닿을 수 없게 된 객체만 회수합니다. 그런데 Editor 객체가 Board를 가리키고 있으니, Editor에 닿을 수 있는 한 Board에도 닿을 수 있습니다.
예를 들어 first.editor()로 얻은 편집기를 오래 유지되는 목록에 넣어 두었다고 해 봅시다. 프로그램의 다른 곳에서 first를 더는 쓰지 않아도, 편집기가 남아 있는 동안 first와 그 안의 글 목록은 메모리에서 치워지지 않습니다. 게시판이 글을 수천 개 가지고 있다면, 작은 편집기 하나 때문에 그 글 전부가 남는 셈입니다. 이런 일은 바깥 객체를 저장하는 줄이 코드에 보이지 않기 때문에 알아차리기 어렵습니다.
그래서 내부 클래스 객체는 바깥 객체와 함께 쓰이고 함께 버려지는 짧은 작업에 쓰는 것이 안전합니다. 바깥 객체보다 오래 보관해야 한다면 정적 중첩 클래스로 만들고, 꼭 필요한 값만 생성자로 넘기는 편이 낫습니다. 그러면 그 객체가 무엇을 붙잡고 있는지가 필드 선언으로 코드에 드러납니다.
더 깊이: 컴파일러가 만드는 바깥 참조 필드
JVM에는 내부 클래스라는 개념이 따로 없습니다. javac는 내부 클래스에 바깥 객체를 담을 숨은 필드를 추가하고, 생성자에 바깥 객체를 받는 매개변수를 하나 더해서 이 연결을 구현합니다. 위 예제를 컴파일한 뒤 JDK에 포함된 javap 도구로 Editor의 클래스 파일을 들여다보면 그 모습이 보입니다. 명령은 javap -p -cp out 'BoardInnerEditor$Board$Editor'이고, -p는 private 멤버까지 보여 달라는 옵션입니다. 클래스 이름의 $를 셸이 다르게 해석하지 않도록 작은따옴표로 감쌌습니다.
Compiled from "BoardInnerEditor.java"
final class BoardInnerEditor$Board$Editor {
final BoardInnerEditor$Board this$0;
private BoardInnerEditor$Board$Editor(BoardInnerEditor$Board);
void renameFirst(java.lang.String);
}소스에는 없던 this$0 필드가 바깥 Board 객체를 가리키는 참조이고, 생성자가 Board를 받도록 바뀌어 있습니다. first.editor() 안의 new Editor()는 실제로는 first를 이 생성자에 넘기는 호출로 컴파일되고, renameFirst 안의 entries는 this$0.entries로 읽힙니다. 앞에서 말한 메모리 문제의 정체가 바로 이 필드입니다.
필드 이름과 형태는 언어 명세가 정한 것이 아니라 컴파일러 구현의 선택입니다. 예를 들어 JDK 18부터 javac는 바깥 객체를 전혀 쓰지 않는 내부 클래스에는 이 필드를 만들지 않습니다. 다만 JDK 21의 javac로 확인해 보면 직렬화할 수 있는(Serializable) 내부 클래스는 예외여서, 바깥 객체를 쓰지 않아도 필드가 남습니다. 어느 쪽이든 최적화일 뿐이고, 그런 클래스도 바깥 객체가 있어야만 만들 수 있다는 언어 규칙은 그대로입니다. 바깥 객체가 필요 없다면 컴파일러에 기대지 말고 static을 붙이는 것이 정확한 표현입니다.
바깥의 private 상태를 쓰는 보조 객체
내부 클래스가 특히 잘 맞는 경우는 바깥 객체의 내부 상태를 깊이 알아야 하는 보조 객체입니다. 자동차 Car와 엔진 Engine을 각각 최상위 클래스로 만들고, Engine이 Car의 연료량이나 속도를 읽어야 한다고 해 봅시다. 그러면 Car는 Engine 하나를 위해 getFuel() 같은 메서드를 공개해야 하고, 그 메서드는 Engine과 상관없는 모든 코드에도 열려 버립니다. Engine을 Car의 내부 클래스로 만들면 Car의 private 필드를 바로 읽을 수 있으므로, 이런 메서드를 공개하지 않아도 됩니다.
게시판에서는 요약 문자열을 만드는 계산기에 같은 생각을 적용할 수 있습니다. 다음 SummaryEngine은 Board의 private 필드인 track과 viewCounts를 그대로 읽습니다.
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> viewCounts = new ArrayList<>();
Board(String track) {
this.track = track;
}
void add(int viewCount) {
viewCounts.add(viewCount);
}
String renderSummary() {
return new SummaryEngine().render();
}
private final class SummaryEngine {
String render() {
int total = 0;
for (int viewCount : viewCounts) {
total += viewCount;
}
return track + ":count=" + viewCounts.size() + ", total=" + total;
}
}
}
}mid-java:count=2, total=90renderSummary()가 new SummaryEngine()으로 계산기를 만들면, 그 계산기는 renderSummary()를 호출한 board에 연결됩니다. 그래서 render() 안의 track은 mid-java이고, viewCounts에 든 40과 50을 더한 합계 90과 개수 2가 출력됩니다. SummaryEngine은 private이고 renderSummary() 안에서만 잠깐 쓰이므로, 바깥 코드는 이런 클래스가 있는지조차 알 필요가 없습니다. 계산기는 render()가 끝나면 더는 쓰이지 않으니, 앞에서 본 메모리 문제도 생기지 않습니다.
다만 이 정도 계산이라면 사실 Board의 private 메서드 하나로 충분합니다. 클래스를 하나 더 만들면 읽는 사람이 따라갈 곳도 하나 늘어나기 때문입니다. 내부 클래스로 나누는 효과는 보조 객체가 여러 메서드와 자기만의 상태를 갖게 될 때 커집니다. 예를 들어 요약 형식을 고르는 옵션을 받고, 중간 계산 결과를 필드에 담아 여러 메서드에서 나눠 쓰는 경우입니다.
이름이 겹칠 때: this와 바깥 클래스 이름.this
내부 클래스에서는 이름 하나가 가리킬 수 있는 후보가 늘어납니다. 메서드의 매개변수와 지역 변수, 내부 클래스 자신의 필드, 바깥 클래스의 필드가 모두 같은 이름이라면 어느 것이 선택될까요?
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이름만 쓰면 가장 가까운 선언이 선택됩니다. print 안에서 value는 매개변수이므로 10이 나옵니다. 매개변수도 메서드 안의 지역 변수이므로 출력 라벨을 local이라고 붙였습니다. 이처럼 안쪽 선언이 바깥쪽의 같은 이름을 가리는 것을 이름 가림(shadowing)이라고 합니다.
가려진 필드를 쓰려면 누구의 필드인지 적어야 합니다. this.value의 this는 지금 print를 실행하는 Inspector 객체이므로 20이 나옵니다. 내부 클래스 안에서도 this는 바깥 객체가 아니라 내부 클래스의 객체라는 점에 주의하세요. 바깥 객체를 가리키려면 InnerShadowing.this처럼 바깥 클래스 이름을 붙인 this를 쓰고, 그래서 마지막 줄에는 바깥 객체의 필드 30이 나옵니다. main에서 outer.new Inspector()로 만들었으므로 이 Inspector의 바깥 객체는 outer입니다.
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로 바깥 인스턴스를 명시합니다.
이 문법을 안다고 해서 같은 이름을 세 겹으로 쓰는 것이 좋은 코드는 아닙니다. 읽는 사람이 줄마다 어느 value인지 따져야 하기 때문입니다. 실제 코드에서는 pageSize, defaultPageSize처럼 역할이 드러나는 이름을 붙여 가림 자체를 줄이는 편이 낫습니다. 생성자에서 this.name = name;처럼 매개변수와 필드 이름을 맞추는 관례는 의도가 분명하니 그대로 써도 됩니다.
한 메서드 안에서만 쓰는 지역 클래스
클래스가 바깥 객체 전체가 아니라 한 메서드의 입력에만 관련된다면, 그 메서드 본문 안에 선언할 수도 있습니다. 이런 클래스를 지역 클래스(local class)라고 합니다.
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=50LineFormatter는 format 메서드 안에서 선언되었으므로 그 메서드 안에서만 이름을 쓸 수 있습니다. 지역 변수처럼 선언한 줄 아래에서만 쓸 수 있고, 같은 클래스의 다른 메서드는 이런 클래스가 있는지 모릅니다. 그리고 render() 안에서 format의 매개변수 prefix, title, viewCount를 그대로 읽습니다. 지역 클래스가 자신을 감싼 메서드의 변수를 이렇게 가져다 쓰는 것을 캡처(capture)라고 합니다.
format이 static 메서드라는 점도 눈여겨보세요. 앞 글의 표대로 static 메서드 안의 지역 클래스에는 바깥 객체가 없으므로, LineFormatter 안에서 LocalFormatterIntroduction의 인스턴스 필드는 쓸 수 없습니다. 반면 인스턴스 메서드 안에서 선언한 지역 클래스는 내부 클래스처럼 바깥 객체에도 연결됩니다.
그런데 format이 반환된 뒤에도 LineFormatter 객체가 살아남는다면, 이미 사라진 매개변수를 어떻게 읽을까요? 또 캡처한 변수에 다른 값을 대입하면 어떻게 될까요? 이 질문은 다음 글에서 이어서 다룹니다.
내부 클래스로 둘지 판단하기
내부 클래스가 맞는지는 다음 질문으로 판단할 수 있습니다.
- 보조 객체가 특정 바깥 객체 하나의 상태를 계속 읽거나 바꾸는가?
- 최상위 클래스로 따로 두면, 그 객체만을 위한 공개 메서드가 바깥 클래스에 생기는가?
- 보조 객체를 바깥 객체보다 오래 보관하지 않는가?
- 정적 중첩 클래스에 필요한 값을 생성자로 넘기는 방식으로는 의도를 더 분명하게 나타낼 수 없는가?
마지막 질문이 특히 중요합니다. 내부 클래스는 '이 객체는 바깥 객체 하나에 딸려 있다'는 강한 관계를 나타내는 도구이지, 바깥 필드를 편하게 읽는 지름길이 아닙니다. 바깥 객체가 필요 없다면 static을 붙이는 것을 기본으로 삼으세요.
정리
static없이 선언한 멤버 클래스인 내부 클래스의 객체는 만들어질 때 바깥 객체 하나와 연결되고, 그 바깥 객체의 필드와 메서드를 이름만으로 씁니다.- 바깥 클래스의 인스턴스 메서드 안에서 쓴
new Editor()는this.new Editor()의 줄임입니다. 바깥 객체가 없는static문맥에서는board.new Editor()처럼 바깥 객체를 직접 지정해야 합니다. - 내부 객체가 살아 있는 동안 바깥 객체도 회수되지 않습니다. 오래 보관할 객체는 정적 중첩 클래스로 만들고 필요한 값만 넘깁니다.
- 내부 클래스 안의
this는 내부 객체입니다. 바깥 객체는InnerShadowing.this처럼 바깥 클래스 이름을 붙여 가리킵니다. - 메서드 안에서 선언한 지역 클래스는 그 메서드 안에서만 쓰이고, 메서드의 매개변수와 지역 변수를 캡처할 수 있습니다.
연습 문제
PublishQuota는 목표 게시 수targetCount와 실제 게시 수publishedCount를 가집니다. 두 필드를 읽어 달성률을 정수 퍼센트(소수점 아래 버림)로 계산하는 내부 클래스Progress를 만들고,PublishQuota의progress()메서드가Progress객체를 돌려주게 하세요. 목표 100, 게시 40으로 실행해progress=40%를 출력하세요. 실행하기 전에 목표 200, 게시 50일 때의 결과와, 목표가 0이면 무슨 일이 생길지도 먼저 예상해 보세요.- 다음 코드는 컴파일되지 않습니다. 어느 줄에서 왜 오류가 나는지 설명하고,
Editor를 내부 클래스로 유지한 채 오류를 없애는 방법을 두 가지 제시하세요.
public final class StaticEditorFactory {
private static final class Board {
private int edits;
static Editor create() {
return new Editor();
}
final class Editor {
void edit() {
edits++;
}
}
}
}해설 보기
1번은 다음과 같이 작성할 수 있습니다.
public final class InnerProgressExercise {
public static void main(String[] args) {
PublishQuota quota = new PublishQuota(100, 40);
System.out.println("progress=" + quota.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%quota.progress()가 만든 Progress는 quota에 연결되므로, percent() 안의 두 필드는 quota의 100과 40입니다. 40 × 100 / 100 = 40이 나옵니다. 곱셈을 나눗셈보다 먼저 하는 데에는 이유가 있습니다. publishedCount / targetCount * 100처럼 쓰면 정수 나눗셈 40 / 100이 먼저 계산되어 0이 되고, 결과도 0%가 됩니다.
목표 200, 게시 50이면 50 × 100 / 200 = 25이므로 progress=25%입니다. 목표가 0이면 0으로 나누게 되어 percent()를 호출하는 순간 ArithmeticException: / by zero가 납니다. 그러니 실제 코드라면 PublishQuota의 생성자에서 목표가 양수인지 먼저 검사해 0인 목표로는 객체가 만들어지지 않게 해야 합니다.
int 범위도 생각해 볼 만합니다. 목표 2,000,000,000에 게시 1,000,000,000이면 답은 50%여야 하지만, publishedCount * 100이 int의 최댓값(약 21억)을 넘어 1215752192라는 엉뚱한 값이 되고 결과는 0%가 됩니다. 큰 값까지 다루려면 곱하기 전에 long으로 넓혀야 합니다. 다음 메서드를 Progress 안에 추가하면 범위 검사와 함께 큰 값도 올바르게 계산합니다.
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)처럼 곱셈 결과를 바꾸면 이미 넘쳐 버린 값을 바꾸는 것이라 소용이 없습니다. 끝으로, 퍼센트 하나만 필요하다면 PublishQuota에 percent() 메서드를 두는 편이 더 간단합니다. 남은 개수나 달성 여부처럼 진행 상태에 관한 기능이 늘어날 때 Progress를 따로 두는 의미가 커집니다.
2번은 return new Editor(); 줄에서 non-static variable this cannot be referenced from a static context 오류가 납니다. create의 new Editor()는 this.new Editor()의 줄임인데, create는 static 메서드라서 바깥 객체로 삼을 this가 없기 때문입니다. 메시지에 this가 등장하는 것도 그래서입니다.
첫 번째 방법은 create에서 static을 지워 인스턴스 메서드로 만드는 것입니다. 그러면 board.create()를 호출한 board가 this이자 바깥 객체가 됩니다. 두 번째 방법은 static을 유지하되 바깥 객체를 매개변수로 받아 static Editor create(Board board) { return board.new Editor(); }처럼 직접 지정하는 것입니다. Editor에 static을 붙이는 것은 답이 될 수 없습니다. edit()이 바깥 객체의 edits 필드를 바꾸므로 Editor에는 바깥 객체가 꼭 필요하기 때문입니다.
다음 글
지역 클래스와 변수 캡처에서는 이 글 끝에서 본 지역 클래스를 자세히 다룹니다. 메서드가 끝난 뒤에도 지역 클래스 객체가 캡처한 값을 쓸 수 있는 이유와, 캡처한 변수에 다시 대입할 수 없는 이유를 메모리의 관점에서 살펴봅니다.