본문 바로가기

중첩 클래스의 종류

네 가지 중첩 형태를 분류하고 정적 중첩 클래스의 바깥 인스턴스 접근 실패·캡슐화·게시글 메시지 모델을 검증합니다.

클래스 안이나 메서드 블록 안에 다른 클래스를 선언하면 사용 범위와 결합 의도를 코드 구조로 표현할 수 있습니다.

크게 바깥 인스턴스 참조가 없는 정적 중첩 클래스와, 바깥 인스턴스 또는 지역 문맥에 연결되는 내부 클래스 종류로 나뉩니다.

가까이 둔다는 이유만으로 모든 클래스를 중첩하지 않고 외부 공개 필요와 상태 공유 여부를 판단합니다.


정적 중첩 클래스의 접근 범위

Nested는 Outer 타입 안에 선언됐지만 특정 Outer 객체에 소속되지 않습니다.

다음 코드는 instanceValue를 읽을 바깥 참조가 없어 컴파일되지 않습니다.

lab/StaticNestedOuterAccessFailure.java
public final class StaticNestedOuterAccessFailure {
    private int instanceValue = 40;

    private static final class Nested {
        int read() {
            return instanceValue;
        }
    }
}
컴파일 실패 관찰
error: non-static variable instanceValue cannot be referenced from a static context

정적 중첩 클래스는 바깥 타입의 static 멤버에는 접근할 수 있습니다.

여기서 static은 Nested 객체의 존재나 자신의 this를 없앤다는 뜻이 아닙니다. Nested.read는 인스턴스 메서드이므로 호출된 Nested의 this가 있습니다. 없는 것은 자동으로 연결된 바깥 인스턴스입니다. JLS의 중첩 클래스 규칙은 이 두 대상을 구별합니다.

예시

원래 실패 예제는 그대로 두고, 다음 파일을 따로 실행합니다. 하나의 Nested가 호출 인수로 받은 두 바깥 객체를 읽습니다. 바깥 필드가 private인 것과 바깥 참조가 없는 것은 다른 문제입니다.

src/StaticNestedExplicitOuter.java
public final class StaticNestedExplicitOuter {
    private final int instanceValue;

    private StaticNestedExplicitOuter(int value) { instanceValue = value; }

    public static void main(String[] args) {
        Nested nested = new Nested();
        System.out.println("nested=" + nested.ownValue());
        System.out.println("first=" + nested.read(new StaticNestedExplicitOuter(40)));
        System.out.println("second=" + nested.read(new StaticNestedExplicitOuter(50)));
    }

    private static final class Nested {
        private final int value = 20;
        int ownValue() { return this.value; }
        int read(StaticNestedExplicitOuter outer) { return outer.instanceValue; }
    }
}
nested=20
first=40
second=50

read의 outer는 null이 아닌 참조를 받는 예입니다. Nested를 만들 때 바깥 객체에 묶어 둔 것이 아니므로 같은 nested에 다른 outer 인수를 전달할 수 있습니다.

특정 인스턴스 데이터가 필요하면 생성자 인수로 명시적으로 전달하거나, 정말 바깥 인스턴스와 한 수명으로 움직인다면 non-static 내부 클래스를 검토합니다.

정적 중첩 객체의 this와 명시적 바깥 참조

Nested.read()는 인스턴스 메서드이므로 Nested 자신의 this는 있지만 암시적인 바깥 인스턴스는 없다. 원문의 instanceValue 접근은 실패하고 명시적인 바깥 참조를 전달하면 그 객체의 필드를 읽을 수 있다.

접근 규칙 · 세 표현 비교

정적 중첩 객체의 this와 명시적 바깥 참조

정적 중첩 클래스의 인스턴스 메서드에도 자신의 this는 있습니다. 바깥 객체의 필드는 명시적인 참조가 필요합니다.

Nested 자신의 this와 명시적 바깥 인수 위 영역은 원문의 컴파일 실패입니다. 아래 세 성공 호출은 본문에 추가한 StaticNestedExplicitOuter 프로그램이며 같은 Nested로 자신의20과 전달한 두 바깥 객체의40,50을 읽습니다. 점선은 식이 읽는 대상입니다. 원문 · 암시적 바깥 참조 없음 원문의 식 instanceValue 컴파일 오류 읽을 바깥 객체가 주어지지 않음 Nested 자신 같은 Nested의 호출 ownValue() this.value 읽는 대상과 출력 Nested 필드: 20 nested=20 첫 명시적 인수 같은 Nested의 호출 read에 바깥 객체 전달 outer.instanceValue 읽는 대상과 출력 바깥 instanceValue=40 first=40 둘째 명시적 인수 같은 Nested의 호출 read에 바깥 객체 전달 outer.instanceValue 읽는 대상과 출력 바깥 instanceValue=50 second=50 세 성공 호출은 StaticNestedExplicitOuter 추가 코드 · 점선은 식이 읽는 대상
Nested 자신의 this와 명시적 바깥 인수 위 영역은 원문의 컴파일 실패입니다. 아래 세 성공 호출은 본문에 추가한 StaticNestedExplicitOuter 프로그램이며 같은 Nested로 자신의20과 전달한 두 바깥 객체의40,50을 읽습니다. 점선은 식이 읽는 대상입니다. 원문 · 암시적 바깥 참조 없음 원문의 식 instanceValue 컴파일 오류 읽을 바깥 객체가 주어지지 않음 Nested 자신 같은 Nested의 호출 ownValue() this.value 읽는 대상과 출력 Nested 필드: 20 nested=20 첫 명시적 인수 같은 Nested의 호출 read에 바깥 객체 전달 outer.instanceValue 읽는 대상과 출력 바깥 instanceValue=40 first=40 둘째 명시적 인수 같은 Nested의 호출 read에 바깥 객체 전달 outer.instanceValue 읽는 대상과 출력 바깥 instanceValue=50 second=50 세 성공 호출은 추가 코드입니다 점선은 각 식이 읽는 대상을 뜻함
정적 중첩 객체의 this와 명시적 바깥 참조
표현식과 근거대상접근 경계
instanceValue
원문 컴파일 실패
바깥 인스턴스의 필드어느 바깥 객체인지 주어지지 않아 읽을 수 없습니다.
this
추가 범위 비교
현재 메서드를 실행하는 Nested 객체Nested 자신의 참조입니다. 바깥 객체를 뜻하지 않습니다.
outer.instanceValue
추가 접근 비교
명시적으로 전달한 바깥 객체outer가 해당 바깥 타입의 유효한 참조이면 읽을 수 있습니다.
instanceValue
원문 컴파일 실패
대상
바깥 인스턴스의 필드
접근 경계
어느 바깥 객체인지 주어지지 않아 읽을 수 없습니다.
this
추가 범위 비교
대상
현재 메서드를 실행하는 Nested 객체
접근 경계
Nested 자신의 참조입니다. 바깥 객체를 뜻하지 않습니다.
outer.instanceValue
추가 접근 비교
대상
명시적으로 전달한 바깥 객체
접근 경계
outer가 해당 바깥 타입의 유효한 참조이면 읽을 수 있습니다.

추가 비교는 원문 코드를 고친 실행 결과가 아닙니다. 같은 최상위 클래스 내부의 private 접근 허용과 바깥 객체 참조 유무는 별개입니다. outer가 null이면 필드 접근은 실패합니다.


네 가지 중첩 클래스

용어가 비슷하므로 “어디에 선언되는가”와 “무엇을 자동으로 기억하는가”를 기준으로 나눕니다.

  • 정적 중첩 클래스: 클래스 멤버 위치, 바깥 인스턴스 참조 없음
  • 내부 클래스: 인스턴스 멤버 위치, 생성에 바깥 인스턴스 필요
  • 지역 클래스: 메서드 블록 안, final 또는 사실상 final인 지역 값 접근. 바깥 인스턴스 연결은 선언 문맥에 따라 다르며 static 문맥에는 없음
  • 익명 클래스: 이름 없는 지역 클래스 형태, 선언과 인스턴스 생성을 한 표현식에서 수행

람다는 익명 클래스와 비슷하게 동작을 전달하지만 별도의 익명 클래스 인스턴스 의미와 this 해석이 다릅니다.

함수형 인터페이스를 다룰 때 뒤에서 선택 기준을 확장합니다.


Board의 정적 중첩 값

Entry가 Board 문맥에서만 의미 있지만 특정 Board 인스턴스 필드를 자동으로 읽을 필요는 없습니다.

정적 중첩 레코드로 두면 Board.Entry라는 소속을 드러내고 독립적으로 생성할 수 있습니다. 멤버 레코드는 static을 생략해도 암시적으로 정적입니다.

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

public final class StaticNestedPost {
    public static void main(String[] args) {
        Board board = new Board();
        board.add(new Board.Entry("nested", 40));
        board.add(new Board.Entry("inner", 50));

        System.out.println(board.entries());
        System.out.println("total=" + board.totalViewCount());
    }

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

        void add(Entry entry) { entries.add(entry); }
        List<Entry> entries() { return List.copyOf(entries); }
        int totalViewCount() { return entries.stream().mapToInt(Entry::viewCount).sum(); }

        static record Entry(String title, int viewCount) {
            Entry {
                if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
                if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
            }
        }
    }
}
[Entry[title=nested, viewCount=40], Entry[title=inner, viewCount=50]]
total=90

Entry는 바깥 entries 목록을 직접 만지지 않습니다.

검증된 값 하나를 표현하고 Board가 보관 책임을 가집니다.

최상위 파일로 꺼내 재사용할 필요가 생기면 중첩을 풀어도 됩니다.


구현 세부 타입의 은닉

NetworkMessage처럼 한 클래스 안에서만 사용하는 보조 타입은 비공개 정적 중첩 클래스로 숨길 수 있습니다.

공개 API는 String이나 역할 인터페이스처럼 소비자가 필요한 타입만 노출합니다.

src/EncapsulatedBoardMessage.java
public final class EncapsulatedBoardMessage {
    public static void main(String[] args) {
        BoardGateway network = new BoardGateway("local");
        System.out.println(network.send("객체 질문 공개"));
    }

    private static final class BoardGateway {
        private final String endpoint;

        BoardGateway(String endpoint) {
            this.endpoint = endpoint;
        }

        String send(String content) {
            NetworkMessage message = new NetworkMessage(content);
            return endpoint + ":" + message.payload();
        }

        private static final class NetworkMessage {
            private final String content;

            NetworkMessage(String content) {
                if (content == null || content.isBlank()) throw new IllegalArgumentException("content");
                this.content = content;
            }

            String payload() { return "[board] " + content; }
        }
    }
}
local:[board] 객체 질문 공개

main은 NetworkMessage의 존재를 몰라도 됩니다.

메시지 포맷 변경은 BoardGateway 내부 구현 변화로 제한됩니다.

중첩 클래스가 private여도 바깥 클래스와 중첩 클래스는 서로 private 멤버에 접근할 수 있지만, 책임 경계를 흐릴 정도로 남용하지 않습니다.


중첩 클래스 생성과 이름 해석

외부에서는 new Outer.Nested()로 만들며 Outer 인스턴스가 필요하지 않습니다.

바깥 클래스 본문에서는 new Nested()처럼 바깥 이름을 생략할 수 있습니다.

컴파일 결과 클래스 파일 이름에는 보통 $가 포함되지만 소스 설계는 자바 이름과 접근 제어를 기준으로 읽습니다.

src/NestedConstruction.java
public final class NestedConstruction {
    public static void main(String[] args) {
        Counter.Bucket first = new Counter.Bucket("java");
        Counter.Bucket second = Counter.newBucket("time");

        System.out.println(first.label());
        System.out.println(second.label());
    }

    private static final class Counter {
        static Bucket newBucket(String name) {
            return new Bucket(name);
        }

        static final class Bucket {
            private final String name;
            Bucket(String name) { this.name = name; }
            String label() { return "bucket=" + name; }
        }
    }
}
bucket=java
bucket=time

팩터리로 생성 규칙을 숨길 수도 있고 중첩 타입 자체를 공개할 수도 있습니다.

정적 중첩 값은 바깥 인스턴스 없이 생성한다

Board.Entry는 Board 타입에 속하지만 특정 Board 객체에 묶이지 않는다. Counter.Bucket의 직접 생성과 Counter.newBucket 팩터리는 모두 Counter 객체 없이 각각 이름을 가진 Bucket을 만든다.

정적 중첩 · 생성 경로

정적 중첩 값은 바깥 인스턴스 없이 생성한다

정적 중첩 값은 바깥 객체를 만들지 않고 생성합니다. Entry와 Bucket은 서로 다른 원문 예제의 타입입니다.

정적 중첩 값의 세 독립 생성 경로 각 영역은 별도 생성 예제입니다. 타입 이름에 바깥 타입이 붙어도 바깥 인스턴스가 자동으로 연결되는 것은 아닙니다. 실선은 생성 호출과 그 결과 값의 대응입니다. Entry의 직접 생성 Entry 생성 new Board.Entry "nested", 40 Entry 값 title=nested viewCount=40 Bucket의 직접 생성 Bucket 생성 new Counter.Bucket "java" Bucket 값 bucket=java Counter 객체 불필요 Bucket의 팩터리 팩터리 호출 Counter.newBucket "time" Bucket 값 bucket=time 내부에서 new Bucket 세 경로는 독립 예제 · 화살표는 생성 결과 · 바깥 인스턴스 자동 연결 없음
정적 중첩 값의 세 독립 생성 경로 각 영역은 별도 생성 예제입니다. 타입 이름에 바깥 타입이 붙어도 바깥 인스턴스가 자동으로 연결되는 것은 아닙니다. 실선은 생성 호출과 그 결과 값의 대응입니다. Entry의 직접 생성 Entry 생성 new Board.Entry "nested", 40 Entry 값 title=nested viewCount=40 Bucket의 직접 생성 Bucket 생성 new Counter.Bucket "java" Bucket 값 bucket=java Counter 객체 불필요 Bucket의 팩터리 팩터리 호출 Counter.newBucket "time" Bucket 값 bucket=time 내부에서 new Bucket 세 경로는 독립 예제입니다 화살표는 생성 결과를 뜻합니다

new Board.Entry
("nested", 40)

생성된 값 또는 label 결과
title=nested
viewCount=40
바깥 객체와의 관계
Board 인스턴스를 생성 인수로 받지 않습니다.

new Counter.Bucket
("java")

생성된 값 또는 label 결과
bucket=java
바깥 객체와의 관계
Counter 인스턴스 없이 직접 생성합니다.

Counter.newBucket
("time")

생성된 값 또는 label 결과
bucket=time
바깥 객체와의 관계
팩터리 안의 new Bucket(name)도 Counter 인스턴스를 요구하지 않습니다.

Entry 같은 멤버 레코드는 static을 생략해도 암시적으로 정적입니다. 실제 StaticNestedPost에서는 별도로 만든 Board가 Entry를 목록에 보관합니다. 이 생성 예제의 타입·생성자는 같은 최상위 클래스 안에서 접근하며, 다른 소비자에게 공개할지는 접근 제어로 정합니다.

반환 타입이 private이면 허용된 범위 밖에서는 그 타입 이름을 직접 사용할 수 없습니다. 다만 접근 가능한 메서드의 반환값을 Object나 접근 가능한 상위 타입으로 받는 것은 가능하므로, 소비자가 사용할 반환 타입을 기준으로 API 경계를 함께 설계합니다.


정적 중첩 클래스 선택 기준

  • 바깥 타입과 개념적으로 밀접하지만 특정 바깥 객체 상태가 필요 없는가?
  • 보조 타입을 외부 패키지 이름 공간에 노출할 이유가 없는가?
  • 함께 변경되는 코드가 가까이 있을 때 이해가 쉬운가?
  • 파일이 지나치게 커져 독립 테스트와 탐색이 어려워지지 않는가?

“관련 있음”만으로 중첩하면 거대한 클래스가 됩니다.

독립 책임과 재사용 소비자가 생기면 최상위 타입으로 분리합니다.


연습 문제

BoardRunner 안에 성공 여부와 메시지를 가진 정적 중첩 레코드 Result를 만들고, 조회 수가 40이면 성공 결과를 반환하세요.

이번 실행 입력은 title="nested", viewCount=40입니다. 판정 규칙은 조회 수가 양수면 성공, 0 이하면 실패로 정합니다. 성공 메시지는 title=조회수, 실패 메시지는 invalid입니다. 따라서 40은 이번 입력이며 40만 허용하는 조건은 아닙니다.

정답을 보기 전에 같은 title에 조회 수 0과 50을 각각 전달했을 때의 success와 message도 예상하세요.

해설 보기
src/StaticNestedResultExercise.java
public final class StaticNestedResultExercise {
    public static void main(String[] args) {
        BoardRunner.Result result = BoardRunner.run("nested", 40);
        System.out.println(result);
    }

    private static final class BoardRunner {
        static Result run(String title, int viewCount) {
            if (viewCount <= 0) return new Result(false, "invalid");
            return new Result(true, title + "=" + viewCount);
        }

        static record Result(boolean success, String message) { }
    }
}
Result[success=true, message=nested=40]

0은 Result[success=false, message=invalid], 50은 Result[success=true, message=nested=50]입니다. 성공 결과를 만들 때도 BoardRunner 인스턴스는 만들지 않습니다.

Result는 BoardRunner의 특정 인스턴스와 무관하므로 static 중첩 클래스가 맞습니다.

다른 실행기에서도 공유된다면 최상위 결과 타입을 검토합니다.