중첩 클래스 선택 기준
비공개 중첩 타입 노출 실패를 재현하고 정적 중첩·내부·지역·익명 클래스와 람다를 게시판과 명령 실행기에 올바르게 배치합니다.
중첩 클래스의 목적은 줄 수를 줄이는 것이 아니라 사용 범위와 소유 관계를 표현하는 것입니다.
특정 바깥 인스턴스가 필요 없는 보조 타입은 정적 중첩 클래스, 바깥 상태에 종속된 협력자는 내부 클래스, 한 메서드 안의 이름 있는 구현은 지역 클래스, 일회성 구현은 익명 클래스가 후보입니다.
함수형 인터페이스의 한 동작은 람다가 더 간결할 수 있습니다.
비공개 중첩 타입의 접근 제한
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("가입 인사", "kim@example.com");
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 외부에서 직접 사용할 이유가 없습니다.
addPost와 printPosts라는 공개 동작만 제공하면 저장 구조와 Post 표현을 바꿔도 main은 영향을 받지 않습니다.
public final class EncapsulatedBoardCatalog {
public static void main(String[] args) {
Board board = new Board(3);
board.addPost("가입 인사", "kim@example.com");
board.addPost("Spring 질문", "lee@example.com");
board.addPost("DB 설계", "park@example.com");
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
가입 인사 / kim@example.com
Spring 질문 / lee@example.com
DB 설계 / park@example.comPost는 Board 인스턴스 필드를 자동으로 읽지 않으므로 정적 중첩입니다.
capacity와 count가 필요하다면 명시적 인수를 받거나 Board 메서드가 처리합니다.
불필요한 바깥 참조를 만들지 않습니다.
중첩 형태별 역할 배치
다음 예제는 문법 전시가 아니라 각 소속 이유를 드러냅니다.
Entry는 독립 값, Summary는 특정 board 상태, 지역 클래스 Formatter는 render 호출의 구분자, 익명 클래스 Filter는 한 번의 조회 조건을 담당합니다.
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가 함수형 인터페이스라 람다로 바꿀 수 있습니다.
지금은 익명 클래스 형태가 상위 타입 구현과 즉시 전달이라는 구조를 명시적으로 보여 줍니다.
람다를 이용한 동작 전달
익명 클래스와 람다가 언제나 같은 것은 아닙니다.
익명 클래스 내부의 this는 익명 객체를 가리키고, 람다의 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 역할로 실행되지만 선언 위치와 바깥 참조가 다릅니다.
실제 설계에서는 가장 좁고 명확한 소속만 선택합니다.