중첩 클래스 선택 기준
비공개 중첩 타입 노출 실패를 재현하고 정적 중첩·내부·지역·익명 클래스와 람다를 게시판과 명령 실행기에 올바르게 배치합니다.
중첩 클래스의 목적은 줄 수를 줄이는 것이 아니라 사용 범위와 소유 관계를 표현하는 것입니다.
특정 바깥 인스턴스가 필요 없는 보조 타입은 정적 중첩 클래스, 바깥 상태에 종속된 협력자는 내부 클래스, 한 메서드 안의 이름 있는 구현은 지역 클래스, 일회성 구현은 익명 클래스가 후보입니다.
함수형 인터페이스의 한 동작은 람다가 더 간결할 수 있습니다.
비공개 중첩 타입의 접근 제한
Board 안에서만 쓰기로 한 Post를 외부 코드가 변수 타입으로 사용하려 하면 접근 제어가 컴파일 단계에서 막습니다.
public final class PrivateNestedExposureFailure {
public static void main(String[] args) {
BoardCatalog board = new BoardCatalog();
BoardCatalog.Post post = board.firstPost();
System.out.println(post);
}
}
final class BoardCatalog {
private final Post first = new Post("가입 인사", "[email protected]");
Post firstPost() { return first; }
private static final class Post {
private final String title;
private final String author;
Post(String title, String author) {
this.title = title;
this.author = author;
}
}
}error: Post has private access in BoardCatalog공개 또는 패키지 메서드가 private 타입을 반환하는 설계도 소비자가 결과를 다루기 어렵습니다.
외부에는 제목 목록, PostView 같은 공개 불변 값, 또는 필요한 동작만 반환합니다.
구현 전용 Post는 Board 안에 남깁니다.
Board의 Post 관리 책임
Post는 Board 외부에서 직접 사용할 이유가 없습니다.
이 예제의 main은 addPost와 printPosts 메서드만 사용하므로 저장 구조와 Post 표현에 직접 의존하지 않습니다. 같은 최상위 클래스 안에서는 private 멤버에 접근할 수 있지만, 호출부는 구현 전용 타입을 직접 사용하지 않도록 작성했습니다.
public final class EncapsulatedBoardCatalog {
public static void main(String[] args) {
Board board = new Board(3);
board.addPost("가입 인사", "[email protected]");
board.addPost("Spring 질문", "[email protected]");
board.addPost("DB 설계", "[email protected]");
board.printPosts();
}
private static final class Board {
private final Post[] posts;
private int count;
Board(int capacity) {
posts = new Post[capacity];
}
void addPost(String title, String author) {
if (count == posts.length) throw new IllegalStateException("full");
posts[count++] = new Post(title, author);
}
void printPosts() {
System.out.println("count=" + count);
for (int index = 0; index < count; index++) {
System.out.println(posts[index].description());
}
}
private static final class Post {
private final String title;
private final String author;
Post(String title, String author) {
this.title = title;
this.author = author;
}
String description() { return title + " / " + author; }
}
}
}count=3
가입 인사 / [email protected]
Spring 질문 / [email protected]
DB 설계 / [email protected]Post는 Board 인스턴스 필드를 자동으로 읽지 않으므로 정적 중첩입니다.
capacity와 count가 필요하다면 명시적 인수를 받거나 Board 메서드가 처리합니다.
불필요한 바깥 참조를 만들지 않습니다.
중첩 형태별 역할 배치
다음 예제는 문법 전시가 아니라 각 소속 이유를 드러냅니다.
Entry는 독립 값, Summary는 특정 board 상태, 지역 클래스 Formatter는 render 호출의 구분자와 board의 게시글 목록, 익명 EntryFilter 구현은 한 번의 조회 조건을 담당합니다.
import java.util.ArrayList;
import java.util.List;
public final class NestedFormBoard {
public static void main(String[] args) {
Board board = new Board("mid-java");
board.add(new Board.Entry("nested", 40));
board.add(new Board.Entry("anonymous", 20));
System.out.println(board.summary().line());
System.out.println(board.render(" | "));
System.out.println(board.find(new EntryFilter() {
public boolean test(Board.Entry entry) { return entry.viewCount() >= 30; }
}));
}
private interface EntryFilter { boolean test(Board.Entry entry); }
private static final class Board {
private final String name;
private final List<Entry> entries = new ArrayList<>();
Board(String name) { this.name = name; }
void add(Entry entry) { entries.add(entry); }
Summary summary() { return new Summary(); }
String render(String separator) {
class Formatter {
String format() { return String.join(separator, entries.stream().map(Entry::title).toList()); }
}
return new Formatter().format();
}
List<Entry> find(EntryFilter filter) {
return entries.stream().filter(filter::test).toList();
}
final class Summary {
String line() { return name + ":count=" + entries.size(); }
}
static record Entry(String title, int viewCount) { }
}
}mid-java:count=2
nested | anonymous
[Entry[title=nested, viewCount=40]]EntryFilter가 함수형 인터페이스라 람다로 바꿀 수 있습니다.
지금은 익명 클래스 형태가 상위 타입 구현과 즉시 전달이라는 구조를 명시적으로 보여 줍니다.
Entry의 독립 값, Summary의 게시판 상태, Formatter의 호출별 구분자와 게시판 상태, 익명 필터의 매개변수 검사를 구분합니다.
NestedFormBoard의 네 구현을 실제 데이터와 대응합니다. 조회 조건은 조회 수 30 이상입니다.
| 구현과 선언 위치 | 이 예제가 읽는 값 | 입력 또는 결과 |
|---|---|---|
Entry정적 중첩 레코드 | 자신의 title과 viewCount. 바깥 Board 인스턴스는 필요 없습니다. | 입력 값nested · 40anonymous · 20 |
Summary내부 멤버 클래스 | 자신이 연결된 Board의 name과 entries.size(). | 출력mid-java:count=2 |
Formatter지역 클래스 | 이번 render 호출의 separator와 바깥 Board의 게시글 제목들. | 출력nested | anonymous |
익명 EntryFilterstatic main의 구현 | test에 전달된 entry의 조회 수를 30과 비교합니다. | 조회 결과에 남은 값 제목 nested조회 수 40 |
Entry정적 중첩 레코드- 이 예제가 읽는 값: 자신의
title과viewCount. 바깥Board인스턴스는 필요 없습니다.입력 또는 결과: 입력 값nested·40anonymous·20 Summary내부 멤버 클래스- 이 예제가 읽는 값: 자신이 연결된
Board의name과entries.size().입력 또는 결과: 출력mid-java:count=2 Formatter지역 클래스- 이 예제가 읽는 값: 이번
render호출의separator와 바깥Board의 게시글 제목들.입력 또는 결과: 출력nested | anonymous - 익명
EntryFilterstatic main의 구현 - 이 예제가 읽는 값:
test에 전달된entry의 조회 수를30과 비교합니다.입력 또는 결과: 조회 결과에 남은 값제목nested조회 수40
지역 Formatter는 인스턴스 메서드 안에 있어 바깥 게시판 상태도 읽습니다. 익명 EntryFilter는 static main 안에서 생성되며, 바깥 Board 인스턴스 없이 매개변수만 검사합니다.
람다를 이용한 동작 전달
익명 클래스와 람다가 언제나 같은 것은 아닙니다.
익명 클래스 내부의 this는 익명 객체를 가리킵니다. 람다의 this는 둘러싼 문맥과 같으며, 아래처럼 static main 안에서는 바깥 this를 사용할 수 없습니다.
람다에는 새 인스턴스 필드를 선언할 수 없습니다.
public final class AnonymousAndLambdaCommands {
public static void main(String[] args) {
Command anonymous = new Command() {
@Override
public String run(String title) {
return "anonymous=" + title;
}
};
Command lambda = title -> "lambda=" + title;
execute(anonymous, "nested");
execute(lambda, "capture");
}
private static void execute(Command command, String title) {
System.out.println(command.run(title));
}
@FunctionalInterface
private interface Command {
String run(String title);
}
}anonymous=nested
lambda=capture함수형 인터페이스에 @FunctionalInterface를 붙이면 추상 메서드가 둘 이상으로 바뀌는 실수를 컴파일러가 찾습니다.
구현 자체를 재사용하거나 상태와 여러 메서드가 필요하면 이름 있는 클래스로 승격합니다.
중첩 타입의 분리 시점
처음에는 바깥 클래스 전용이었어도 요구가 바뀔 수 있습니다.
다음 신호가 보이면 최상위 타입이나 별도 파일로 옮깁니다.
- 다른 바깥 클래스가 같은 구현을 필요로 한다.
- private 바깥 멤버보다 자체 상태와 규칙이 더 많다.
- 개별 단위 테스트와 문서가 필요하다.
- 바깥 파일 탐색이 어려울 정도로 길어진다.
- 양방향 private 접근 때문에 변경 원인을 추적하기 어렵다.
반대로 데이터 구조의 노드, 빌더 결과, 한 집계의 내부 항목처럼 외부 의미가 없는 타입은 중첩 상태가 캡슐화를 돕습니다.
중첩 클래스 최종 선택표
- 바깥과 관련 있지만 인스턴스 상태 불필요: 정적 중첩
- 특정 바깥 객체 상태에 강하게 의존: 내부
- 한 메서드에서 이름 있는 구현을 여러 번 생성: 지역 클래스
- 상위 타입의 일회성 구현: 익명 클래스
- 함수형 인터페이스에 짧은 동작 전달: 람다
- 여러 소비자와 독립 책임: 최상위
선택 뒤에도 캡처 수명, 가변 상태 공유, 접근 제어, 테스트 가능성을 검토합니다.
연습 문제
Outer 안에 static Nested, 내부 클래스 Inner를 만들고 main의 지역 클래스 Local, Hello 인터페이스의 익명 클래스 구현을 각각 생성해 이름을 출력하세요.
해설 보기
public final class NestedFormsExercise {
public static void main(String[] args) {
Outer.Nested nested = new Outer.Nested();
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
class Local implements Hello {
public void hello() { System.out.println("local"); }
}
Hello anonymous = new Hello() {
public void hello() { System.out.println("anonymous"); }
};
nested.hello();
inner.hello();
new Local().hello();
anonymous.hello();
}
private interface Hello { void hello(); }
private static final class Outer {
static final class Nested implements Hello {
public void hello() { System.out.println("static nested"); }
}
final class Inner implements Hello {
public void hello() { System.out.println("inner"); }
}
}
}static nested
inner
local
anonymous네 구현은 같은 Hello 역할로 실행되지만 선언 위치와 바깥 참조가 다릅니다.
실제 설계에서는 가장 좁고 명확한 소속만 선택합니다.