본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 중첩 클래스와 내부 클래스

중첩 클래스 선택 기준

비공개 중첩 타입 노출 실패를 재현하고 정적 중첩·내부·지역·익명 클래스와 람다를 학습 도서관과 명령 실행기에 올바르게 배치합니다.

중첩 클래스의 목적은 줄 수를 줄이는 것이 아니라 사용 범위와 소유 관계를 표현하는 것입니다. 특정 바깥 인스턴스가 필요 없는 보조 타입은 정적 중첩 클래스, 바깥 상태에 종속된 협력자는 내부 클래스, 한 메서드 안의 이름 있는 구현은 지역 클래스, 일회성 구현은 익명 클래스가 후보입니다. 함수형 인터페이스의 한 동작은 람다가 더 간결할 수 있습니다.

비공개 중첩 타입의 접근 제한

Library 안에서만 쓰기로 한 Book을 외부 코드가 변수 타입으로 사용하려 하면 접근 제어가 컴파일 단계에서 막습니다.

lab/PrivateNestedExposureFailure.java
public final class PrivateNestedExposureFailure {
    public static void main(String[] args) {
        StudyLibrary library = new StudyLibrary();
        StudyLibrary.Book book = library.firstBook();
        System.out.println(book);
    }
}

final class StudyLibrary {
    private final Book first = new Book("Java", "Kim");

    Book firstBook() { return first; }

    private static final class Book {
        private final String title;
        private final String author;
        Book(String title, String author) {
            this.title = title;
            this.author = author;
        }
    }
}
컴파일 실패 관찰
error: Book has private access in StudyLibrary

공개 또는 패키지 메서드가 private 타입을 반환하는 설계도 소비자가 결과를 다루기 어렵습니다. 외부에는 제목 목록, BookView 같은 공개 불변 값, 또는 필요한 동작만 반환합니다. 구현 전용 Book은 Library 안에 남깁니다.

Library의 Book 관리 책임

Book은 Library 외부에서 직접 사용할 이유가 없습니다. addBook과 printBooks라는 공개 동작만 제공하면 저장 구조와 Book 표현을 바꿔도 main은 영향을 받지 않습니다.

src/EncapsulatedStudyLibrary.java
public final class EncapsulatedStudyLibrary {
    public static void main(String[] args) {
        Library library = new Library(3);
        library.add("Java Basics", "Kim");
        library.add("Spring Core", "Lee");
        library.add("Database", "Park");
        library.printBooks();
    }

    private static final class Library {
        private final Book[] books;
        private int count;

        Library(int capacity) {
            books = new Book[capacity];
        }

        void add(String title, String author) {
            if (count == books.length) throw new IllegalStateException("full");
            books[count++] = new Book(title, author);
        }

        void printBooks() {
            System.out.println("count=" + count);
            for (int index = 0; index < count; index++) {
                System.out.println(books[index].description());
            }
        }

        private static final class Book {
            private final String title;
            private final String author;

            Book(String title, String author) {
                this.title = title;
                this.author = author;
            }

            String description() { return title + " / " + author; }
        }
    }
}
count=3
Java Basics / Kim
Spring Core / Lee
Database / Park

Book은 Library 인스턴스 필드를 자동으로 읽지 않으므로 정적 중첩입니다. capacity와 count가 필요하다면 명시적 인수를 받거나 Library 메서드가 처리합니다. 불필요한 바깥 참조를 만들지 않습니다.

중첩 형태별 역할 배치

다음 예제는 문법 전시가 아니라 각 소속 이유를 드러냅니다. Entry는 독립 값, Summary는 특정 log 상태, 지역 클래스 Formatter는 render 호출의 구분자, 익명 클래스 Filter는 한 번의 조회 조건을 담당합니다.

src/NestedFormStudyLog.java
import java.util.ArrayList;
import java.util.List;

public final class NestedFormStudyLog {
    public static void main(String[] args) {
        StudyLog log = new StudyLog("mid-java");
        log.add(new StudyLog.Entry("nested", 40));
        log.add(new StudyLog.Entry("anonymous", 20));

        System.out.println(log.summary().line());
        System.out.println(log.render(" | "));
        System.out.println(log.find(new EntryFilter() {
            public boolean test(StudyLog.Entry entry) { return entry.minutes() >= 30; }
        }));
    }

    private interface EntryFilter { boolean test(StudyLog.Entry entry); }

    private static final class StudyLog {
        private final String name;
        private final List<Entry> entries = new ArrayList<>();

        StudyLog(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::topic).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 topic, int minutes) { }
    }
}
mid-java:count=2
nested | anonymous
[Entry[topic=nested, minutes=40]]

EntryFilter가 함수형 인터페이스라 람다로 바꿀 수 있습니다. 지금은 익명 클래스 형태가 상위 타입 구현과 즉시 전달이라는 구조를 명시적으로 보여 줍니다.

람다를 이용한 동작 전달

익명 클래스와 람다가 언제나 같은 것은 아닙니다. 익명 클래스 내부의 this는 익명 객체를 가리키고, 람다의 this는 둘러싼 객체를 가리킵니다. 람다에는 새 인스턴스 필드를 선언할 수 없습니다.

src/AnonymousAndLambdaCommands.java
public final class AnonymousAndLambdaCommands {
    public static void main(String[] args) {
        Command anonymous = new Command() {
            @Override
            public String run(String topic) {
                return "anonymous=" + topic;
            }
        };

        Command lambda = topic -> "lambda=" + topic;

        execute(anonymous, "nested");
        execute(lambda, "capture");
    }

    private static void execute(Command command, String topic) {
        System.out.println(command.run(topic));
    }

    @FunctionalInterface
    private interface Command {
        String run(String topic);
    }
}
anonymous=nested
lambda=capture

함수형 인터페이스에 @FunctionalInterface를 붙이면 추상 메서드가 둘 이상으로 바뀌는 실수를 컴파일러가 찾습니다. 구현 자체를 재사용하거나 상태와 여러 메서드가 필요하면 이름 있는 클래스로 승격합니다.

중첩 타입의 분리 시점

처음에는 바깥 클래스 전용이었어도 요구가 바뀔 수 있습니다. 다음 신호가 보이면 최상위 타입이나 별도 파일로 옮깁니다.

  • 다른 바깥 클래스가 같은 구현을 필요로 한다.
  • private 바깥 멤버보다 자체 상태와 규칙이 더 많다.
  • 개별 단위 테스트와 문서가 필요하다.
  • 바깥 파일 탐색이 어려울 정도로 길어진다.
  • 양방향 private 접근 때문에 변경 원인을 추적하기 어렵다.

반대로 데이터 구조의 노드, 빌더 결과, 한 집계의 내부 항목처럼 외부 의미가 없는 타입은 중첩 상태가 캡슐화를 돕습니다.

중첩 클래스 최종 선택표

  • 바깥과 관련 있지만 인스턴스 상태 불필요: 정적 중첩
  • 특정 바깥 객체 상태에 강하게 의존: 내부
  • 한 메서드에서 이름 있는 구현을 여러 번 생성: 지역 클래스
  • 상위 타입의 일회성 구현: 익명 클래스
  • 함수형 인터페이스에 짧은 동작 전달: 람다
  • 여러 소비자와 독립 책임: 최상위

선택 뒤에도 캡처 수명, 가변 상태 공유, 접근 제어, 테스트 가능성을 검토합니다.

연습 문제

Outer 안에 static Nested, 내부 클래스 Inner를 만들고 main의 지역 클래스 Local, Hello 인터페이스의 익명 클래스 구현을 각각 생성해 이름을 출력하세요.

해설 보기
src/NestedFormsExercise.java
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 역할로 실행되지만 선언 위치와 바깥 참조가 다릅니다. 실제 설계에서는 가장 좁고 명확한 소속만 선택합니다.