본문 바로가기

중첩 클래스의 종류와 정적 중첩 클래스

클래스 안에 클래스를 선언하는 네 가지 방법을 구분하고, 바깥 객체 없이 만들어 쓰는 정적 중첩 클래스를 언제 어떻게 쓰는지 배웁니다.

지금까지 이 교재의 예제는 Member나 Post 같은 클래스를 실행용 클래스 안에 private static final class로 선언해 왔습니다. 파일 하나만 컴파일해서 바로 실행하려고 그렇게 한 것인데, 이처럼 다른 클래스의 본문 안에 선언한 클래스를 중첩 클래스(nested class)라고 합니다. 반대로 파일마다 하나씩 만든 public class처럼 어떤 클래스에도 들어 있지 않은 클래스는 최상위 클래스(top-level class)라고 부릅니다.

중첩은 예제를 한 파일에 담는 요령에 그치지 않습니다. 게시판의 글 한 건을 나타내는 타입이 게시판 안에서만 의미가 있다면, 그 타입을 게시판 클래스 안에 두는 것만으로 '이 타입은 게시판에 딸린 것'이라는 관계가 코드에 드러납니다. private을 붙이면 바깥 코드가 아예 쓰지 못하게 숨길 수도 있습니다. 그렇다고 관련이 있다는 이유만으로 클래스를 계속 안에 넣으면 클래스 하나가 한없이 커집니다. 이 글에서는 중첩 클래스의 네 가지 형태를 구분한 뒤, 그중 가장 단순하고 자주 쓰는 정적 중첩 클래스를 언제 어떻게 쓰는지 살펴봅니다.

클래스 안에 클래스를 선언하는 네 가지 방법

다음 파일에는 중첩 클래스 네 종류가 모두 들어 있습니다. 실행할 내용은 없고, 선언 위치를 비교하려고 만든 코드입니다.

src/NestedKinds.java
public final class NestedKinds {
    static class StaticNested { }          // 1. 정적 중첩 클래스

    class Inner { }                        // 2. 내부 클래스

    void work() {
        class Local { }                    // 3. 지역 클래스

        Greeter greeter = new Greeter() {  // 4. 익명 클래스
            @Override
            public String greet() {
                return "hello";
            }
        };
    }

    interface Greeter {
        String greet();
    }
}

StaticNested와 Inner는 필드나 메서드처럼 클래스 본문에 바로 선언했으므로 멤버 클래스(member class)라고도 부르며, 둘의 차이는 static이 붙었는지뿐입니다. Local은 work 메서드의 본문 안에 선언했습니다. 네 번째는 new Greeter() { ... }라는 식 안에서 이름 없는 클래스를 선언하는 동시에 그 객체를 만듭니다. 맨 아래의 Greeter 인터페이스도 클래스 안에 선언한 중첩 타입인데, 중첩 인터페이스는 static을 쓰지 않아도 언제나 정적입니다.

네 형태를 가르는 기준은 두 가지입니다. 어디에 선언하는가, 그리고 만들어진 객체가 바깥 클래스의 특정 객체를 기억하는가입니다.

종류선언 위치바깥 객체와의 연결
정적 중첩 클래스(static nested class)클래스 본문, static을 붙임없음
내부 클래스(inner class)클래스 본문, static 없음만들 때 바깥 객체가 반드시 필요하고, 그 객체를 계속 기억함
지역 클래스(local class)메서드 같은 블록 안인스턴스 메서드나 생성자 안이면 있음, static 메서드 안이면 없음
익명 클래스(anonymous class)식 안, 이름 없이 선언과 생성을 한 번에지역 클래스와 같음

지역 클래스와 익명 클래스에는 특징이 하나 더 있습니다. 자신을 감싼 메서드의 매개변수와 지역 변수를 쓸 수 있는데, 그 변수가 final이거나 초기화한 뒤 한 번도 다시 대입하지 않은 변수일 때만 그렇습니다. 이 규칙이 왜 필요한지는 지역 클래스와 변수 캡처에서 자세히 다룹니다.

용어 하나를 짚어 두겠습니다. Java 언어 명세(JLS)는 static이 아닌 중첩 클래스, 즉 표의 아래 세 가지를 모두 내부 클래스라고 부릅니다. 하지만 흔히 '내부 클래스'라고 하면 표의 두 번째 줄인 멤버 클래스를 가리키고, 지역 클래스와 익명 클래스는 각자의 이름으로 부릅니다. 이 교재도 그렇게 씁니다.

람다식(lambda expression)도 익명 클래스처럼 동작을 짧게 만들어 전달하는 문법이지만, 클래스를 선언하는 것이 아니어서 this가 가리키는 대상 같은 세부가 다릅니다. 그 차이는 익명 클래스로 동작 전달하기에서, 람다 문법은 30장의 익명 클래스와 람다의 차이에서 다룹니다. 이 장은 정적 중첩 클래스에서 시작해 내부 클래스, 지역 클래스, 익명 클래스 순으로 진행하고, 마지막 글에서 무엇을 언제 고를지 정리합니다.

바깥 객체 없이 만드는 정적 중첩 클래스

게시판 Board가 글 목록을 보관하는 예를 보겠습니다. 글 한 건을 나타내는 Entry는 게시판 문맥에서만 의미가 있으므로 Board 안에 둡니다. 그런데 Entry 하나를 만들고 검사하는 데에는 어떤 게시판 객체의 필드도 필요하지 않습니다. 이럴 때 정적 중첩 타입이 잘 맞습니다.

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() {
            int total = 0;
            for (Entry entry : entries) {
                total += entry.viewCount();
            }
            return total;
        }

        static record Entry(String title, int viewCount) {
            Entry {
                if (title == null || title.isBlank()) {
                    throw new IllegalArgumentException("title");
                }
                if (viewCount < 0) {
                    throw new IllegalArgumentException("viewCount");
                }
            }
        }
    }
}

ArrayList는 원소를 넣는 만큼 길이가 늘어나는 목록이고, List.copyOf는 주어진 목록과 같은 원소를 같은 순서로 담은, 수정할 수 없는 목록을 돌려줍니다. 목록은 14장부터 자세히 다루므로, 여기서는 entries를 'Entry를 차례로 담는 상자' 정도로 읽으면 됩니다.

[Entry[title=nested, viewCount=40], Entry[title=inner, viewCount=50]]
total=90

main은 new Board.Entry("nested", 40)으로 글을 만듭니다. Board.Entry라는 이름은 이 타입이 Board에 속한다는 것을 보여 주지만, 글을 만드는 데 Board 객체는 쓰이지 않습니다. 실제로 board를 만들기 전에 Entry를 먼저 만들어도 아무 문제가 없습니다. 첫 줄은 목록을 출력한 것으로, 대괄호 안의 Entry[title=nested, viewCount=40]은 레코드가 자동으로 만든 toString의 형식입니다. 둘째 줄의 90은 totalViewCount가 두 글의 조회 수 40과 50을 더한 값입니다.

할 일도 깔끔하게 나뉩니다. Entry는 제목과 조회 수를 검사한 값 하나를 나타낼 뿐 Board의 목록을 건드리지 않고, 목록에 보관하고 합계를 내는 일은 Board가 합니다. entries()는 내부 목록 대신 List.copyOf로 만든 복사본을 돌려주므로, 바깥 코드가 받은 목록으로 게시판의 내용을 바꿀 수도 없습니다.

Entry 앞의 static은 사실 쓰지 않아도 됩니다. 클래스 안에 멤버로 선언한 레코드는 자동으로 정적이기 때문입니다. 레코드는 괄호 안에 적은 구성 요소만으로 상태가 정해지는 타입인데, 바깥 객체를 가리키는 숨은 참조가 생기면 적지 않은 상태가 하나 더 생기는 셈입니다. 여기서는 정적 중첩이라는 점을 드러내려고 일부러 static을 적었습니다. 중첩한 enum과 인터페이스도 마찬가지로 언제나 정적입니다.

정적 중첩 클래스 안에서도 바깥 클래스의 static 필드와 static 메서드는 이름만으로 쓸 수 있습니다. 그런 멤버는 객체마다 있는 것이 아니라 클래스에 하나뿐이어서, 어느 객체의 것인지 정할 필요가 없기 때문입니다. 문제가 되는 것은 객체마다 따로 있는 인스턴스 멤버입니다.

정적 중첩 클래스에는 바깥 객체가 없다

그렇다면 정적 중첩 클래스 안에서 바깥 클래스의 인스턴스 필드를 읽으면 어떻게 될까요? 다음 코드는 컴파일되지 않습니다.

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

    private static final class Nested {
        int read() {
            return instanceValue;
        }
    }
}
컴파일 오류
lab/StaticNestedOuterAccessFailure.java:6: error: non-static variable instanceValue cannot be referenced from a static context
            return instanceValue;
                   ^
1 error

이 메시지는 static 메서드와 정적 import에서 static 메서드인 main이 인스턴스 변수를 읽으려 했을 때와 같고, 이유도 같습니다. instanceValue는 StaticNestedOuterAccessFailure 객체마다 하나씩 있는데, Nested 객체는 어느 바깥 객체와도 연결되어 있지 않습니다. 바깥 객체는 하나도 없을 수도, 여러 개 있을 수도 있으니 컴파일러는 누구의 instanceValue를 읽으라는 것인지 정할 수 없습니다. 바깥 객체가 없다는 점에서 정적 중첩 클래스의 본문은 바깥 클래스의 static 메서드 안과 같은 처지이고, 오류 메시지가 'static context'라고 말하는 것도 그래서입니다.

여기서 static을 'Nested에는 객체도 this도 없다'는 뜻으로 오해하면 안 됩니다. read()는 인스턴스 메서드이므로 new Nested()로 만든 객체에서 호출되고, 그 안에서 this는 그 Nested 객체를 가리킵니다. 없는 것은 자동으로 연결된 바깥 객체뿐입니다. JLS의 중첩 클래스 규칙도 클래스 자신의 인스턴스와 바깥 인스턴스(enclosing instance)를 구별해서 다룹니다.

바깥 객체의 필드가 필요하면 어느 객체인지 직접 알려 주면 됩니다. 다음 프로그램에서 Nested의 read는 바깥 객체를 매개변수로 받습니다.

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

첫 줄의 20은 ownValue()가 this.value, 즉 Nested 자신의 필드를 읽은 결과입니다. 둘째와 셋째 줄은 같은 nested 객체에 서로 다른 바깥 객체를 넘긴 결과입니다. Nested는 만들 때 어떤 바깥 객체에도 묶이지 않았으므로, 호출할 때마다 원하는 바깥 객체를 넘겨 40도 읽고 50도 읽을 수 있습니다. 물론 null을 넘기면 outer.instanceValue를 읽는 순간 NullPointerException이 납니다.

instanceValue가 private인데도 읽을 수 있다는 점도 눈여겨보세요. 접근 제어자와 정보 은닉에서 본 것처럼 private 멤버는 그것을 둘러싼 최상위 클래스의 본문 어디에서나 쓸 수 있고, Nested도 그 본문 안에 있습니다. 즉 'private이라도 접근할 수 있는 위치인가'와 '읽을 바깥 객체가 있는가'는 별개의 질문이고, 앞의 컴파일 오류는 두 번째 질문 때문에 난 것입니다.

정적 중첩 객체의 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이면 필드 접근은 실패합니다.

특정 바깥 객체의 데이터가 필요할 때는 이처럼 메서드 인수나 생성자 인수로 넘기면 됩니다. 그런데 보조 객체가 처음부터 끝까지 바깥 객체 하나와만 일한다면, 매번 넘기기보다 만들 때 한 번 연결해 두는 편이 자연스럽습니다. 다음 글에서 다룰 내부 클래스가 바로 그런 클래스입니다.

바깥 클래스 이름을 붙여 부르기

정적 중첩 클래스를 바깥 클래스 밖에서 가리킬 때는 Board.Entry처럼 바깥 클래스 이름을 앞에 붙이고, 바깥 클래스의 본문 안에서는 이름만 씁니다. 다음 예제는 생성자를 직접 호출하는 방법과 팩터리 메서드(factory method)를 거치는 방법을 함께 보여 줍니다. 팩터리 메서드는 객체를 만들어 돌려주는 일을 하는 메서드입니다.

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

main은 NestedConstruction의 본문 안에 있지만 Counter의 본문 밖에 있으므로 Counter.Bucket이라고 씁니다. 반대로 Counter 안의 newBucket은 new Bucket(name)처럼 이름만 씁니다. 두 방법 모두 Counter 객체를 만들지 않았다는 점이 중요합니다. newBucket은 static 메서드라서 Counter.newBucket("time")으로 바로 부를 수 있고, 그 안의 new Bucket(name)에도 바깥 객체가 필요하지 않습니다.

생성자를 직접 쓰게 할지 팩터리 메서드만 쓰게 할지는 설계에 따라 정합니다. 팩터리 메서드를 쓰면 이름 검사 같은 생성 규칙을 한곳에 모을 수 있습니다. Counter가 자기 파일에 있는 공개 최상위 클래스라면, Bucket의 생성자를 private으로 바꿔서 Counter 밖의 코드가 반드시 팩터리를 거쳐야만 Bucket을 얻게 할 수도 있습니다.

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

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를 목록에 보관합니다. 이 생성 예제의 타입·생성자는 같은 최상위 클래스 안에서 접근하며, 다른 소비자에게 공개할지는 접근 제어로 정합니다.

소스 코드에서는 Counter.Bucket처럼 점으로 이름을 잇지만, 컴파일된 클래스 파일의 이름은 다릅니다. 이 예제를 컴파일하면 out 폴더에 NestedConstruction.class, NestedConstruction$Counter.class, NestedConstruction$Counter$Bucket.class 세 파일이 생깁니다. JVM 안에서는 중첩 클래스도 바깥 클래스 이름과 $로 이어 붙인 이름을 가진 별도의 클래스이기 때문입니다. 그래서 예외의 스택 트레이스에서 $가 들어간 클래스 이름을 보면 중첩 클래스라고 읽으면 됩니다. 다만 이 이름은 컴파일러가 붙이는 것이므로, 코드에서는 언제나 Counter.Bucket처럼 점으로 이은 이름을 씁니다.

구현용 타입을 private으로 숨기기

정적 중첩 클래스의 또 다른 쓰임은 한 클래스 안에서만 쓰는 보조 타입을 감추는 것입니다. 다음 BoardGateway는 게시글 내용을 받아 전송용 메시지로 바꾸는데, 그 메시지를 나타내는 NetworkMessage를 private 정적 중첩 클래스로 두었습니다. 바깥에 공개하는 send는 String을 받아 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은 send에 문자열을 넘기고 결과를 출력할 뿐, NetworkMessage라는 타입이 있다는 사실을 몰라도 됩니다. 그래서 나중에 메시지 형식을 바꾸거나 NetworkMessage를 아예 다른 방식으로 바꿔도 고칠 곳은 BoardGateway 안으로 한정됩니다. 이것이 가능한 것은 send의 매개변수와 반환 타입이 String처럼 누구나 쓸 수 있는 타입이기 때문입니다.

그런데 이 파일 안에서는 main도 마음만 먹으면 new BoardGateway.NetworkMessage("hi")라고 쓸 수 있습니다. 앞에서 본 대로 private은 최상위 클래스의 본문 전체에 열려 있고, main과 NetworkMessage는 같은 최상위 클래스 안에 있기 때문입니다. 숨기는 효과는 최상위 클래스 밖의 코드에서 나타납니다. BoardGateway를 자기 파일의 최상위 클래스로 옮기고, 다른 파일에서 NetworkMessage를 가리켜 보겠습니다.

lab/BoardGateway.java
public final class BoardGateway {
    public String send(String content) {
        NetworkMessage message = new NetworkMessage(content);
        return message.payload();
    }

    private static final class NetworkMessage {
        private final String content;

        NetworkMessage(String content) {
            this.content = content;
        }

        String payload() {
            return "[board] " + content;
        }
    }
}
lab/GatewayClient.java
public final class GatewayClient {
    public static void main(String[] args) {
        BoardGateway gateway = new BoardGateway();
        System.out.println(gateway.send("hello"));

        BoardGateway.NetworkMessage message = null;
    }
}

두 파일을 함께 컴파일하면(javac -d out lab/BoardGateway.java lab/GatewayClient.java) 다음 오류가 납니다. GatewayClient가 BoardGateway를 쓰는 곳은 send를 호출하는 줄과 NetworkMessage 타입으로 변수를 선언하는 줄 두 곳입니다. 둘 중 어느 줄이 거부되는지 보세요.

컴파일 오류
lab/GatewayClient.java:6: error: NetworkMessage has private access in BoardGateway
        BoardGateway.NetworkMessage message = null;
                    ^
1 error

gateway.send("hello")는 문제없이 컴파일되고, 거부된 것은 private 타입의 이름을 쓴 6번 줄 하나입니다. 이 줄을 지우면 두 파일이 컴파일되고 실행하면 [board] hello가 출력됩니다. 이처럼 공개 메서드만 남기고 구현용 타입을 숨기면, 바깥 코드가 그 타입에 기대어 작성되는 일 자체가 생기지 않습니다.

반대로 바깥에서 부를 수 있는 메서드가 private 중첩 타입을 그대로 돌려주면 곤란해집니다. 바깥 코드는 그 타입의 이름을 변수 타입으로 적을 수 없고, 그 타입에 선언된 메서드도 부를 수 없습니다. 할 수 있는 일은 결과를 Object 같은 접근 가능한 상위 타입으로 다루는 정도입니다. 바깥에 무엇을 돌려줄지는 중첩 클래스 고르는 기준에서 다시 다룹니다.

바깥 클래스와 중첩 클래스는 서로의 private 멤버에도 접근할 수 있습니다. 하지만 그렇다고 BoardGateway가 NetworkMessage의 content 필드를 직접 읽고 고치기 시작하면 두 클래스를 나눈 의미가 흐려집니다. 서로의 메서드를 통해 협력하게 두는 편이 읽기에도 고치기에도 좋습니다.

더 깊이: JVM이 보는 중첩 클래스

중첩 클래스는 Java 1.1에서 언어에 추가되었고, javac는 중첩 클래스를 별도의 최상위 클래스처럼 컴파일하는 방식으로 이를 구현했습니다. 그래서 중첩 클래스마다 NestedConstruction$Counter$Bucket.class 같은 클래스 파일이 따로 생기고, 지역 클래스와 익명 클래스에는 NestedKinds$1Local, NestedKinds$1처럼 번호가 붙은 이름이 붙습니다. 그러면 JVM이 보기에 서로 다른 클래스인 바깥 클래스와 중첩 클래스는 어떻게 서로의 private 멤버를 쓸 수 있을까요?

Java 10까지는 javac가 몰래 다리를 놓았습니다. 중첩 클래스가 바깥 클래스의 private 필드를 읽으면, 바깥 클래스에 access$000 같은 이름의 숨은 메서드(합성 메서드, synthetic method)를 만들고 그 메서드를 거쳐 읽게 했습니다. Java 11의 JEP 181은 JVM에 네스트(nest)라는 개념을 들여와 이 우회를 없앴습니다. 최상위 클래스가 네스트의 주인(nest host)이 되고 그 안의 중첩 클래스들은 같은 네스트의 구성원(nestmate)으로 클래스 파일에 기록되며, JVM은 같은 네스트 안의 private 접근을 그대로 허용합니다. 이 관계는 리플렉션으로도 볼 수 있어서, 예를 들어 앞 예제의 Board.Entry.class.getNestHost()는 최상위 클래스인 StaticNestedPost의 Class 객체를 돌려줍니다.

정적 중첩 클래스로 둘지 판단하기

정적 중첩 클래스가 잘 맞는 경우를 질문으로 정리하면 다음과 같습니다.

  • 바깥 타입과 개념적으로 묶여 있지만, 특정 바깥 객체의 상태는 필요 없는가?
  • 바깥 타입 밖에서는 쓸 일이 없어 숨겨 두고 싶은가? 또는 Board.Entry처럼 바깥 이름이 붙는 편이 뜻을 더 잘 드러내는가?
  • 함께 바뀌는 코드가 가까이 있어서 읽기 쉬워지는가?

반대로 중첩을 풀고 최상위 클래스로 꺼낼 때를 알려 주는 신호도 있습니다. 다른 클래스들도 그 타입을 쓰기 시작했거나, 바깥 클래스의 파일이 너무 길어져 찾고 테스트하기 어려워졌다면 따로 파일을 만드는 편이 낫습니다. '관련이 있다'는 이유 하나로 계속 중첩하면 결국 모든 것을 품은 거대한 클래스가 됩니다.

중첩하기로 했다면 static부터 붙이는 습관을 들이세요. 바깥 객체가 필요 없는데 static을 빠뜨리면 그 클래스는 내부 클래스가 됩니다. 그러면 바깥 객체 없이는 만들 수 없게 되고, 만든 객체가 바깥 객체를 붙잡아 두어 바깥 객체가 메모리에서 치워지지 못하는 일도 생깁니다. 그 비용은 다음 글에서 확인합니다.

정리

  • 다른 클래스의 본문 안에 선언한 클래스를 중첩 클래스라고 합니다. 선언 위치와 바깥 객체와의 연결에 따라 정적 중첩 클래스, 내부 클래스, 지역 클래스, 익명 클래스로 나뉩니다.
  • 정적 중첩 클래스는 바깥 클래스의 이름 아래에 있을 뿐 특정 바깥 객체에 묶이지 않습니다. new Board.Entry("nested", 40)처럼 바깥 객체 없이 만들 수 있습니다.
  • 정적 중첩 클래스의 인스턴스 메서드에도 자신의 this는 있습니다. 없는 것은 바깥 객체이므로, 바깥 인스턴스 필드가 필요하면 인수로 넘깁니다.
  • private 정적 중첩 클래스로 구현용 타입을 숨기면 최상위 클래스 밖의 코드는 그 타입을 쓸 수 없습니다. 같은 최상위 클래스 안에서는 private이 막지 않습니다.
  • 바깥 객체가 필요 없는 중첩 클래스에는 static을 붙이고, 여러 곳에서 쓰이기 시작하면 최상위 클래스로 꺼냅니다.

연습 문제

  1. BoardRunner 안에 성공 여부 success와 메시지 message를 가진 정적 중첩 레코드 Result를 만들고, static 메서드 run(String title, int viewCount)가 Result를 돌려주게 하세요. 조회 수가 양수면 성공이고 메시지는 제목=조회수 형식입니다. 조회 수가 0 이하면 실패이고 메시지는 invalid입니다. 제목 "nested"에 조회 수 40, 0, 50을 차례로 넣어 결과를 출력하되, 실행하기 전에 세 줄의 출력을 먼저 예상해 보세요.
  2. 다음 코드를 컴파일하면 어디에서 왜 오류가 나는지 설명하고, 오류를 없애는 방법을 두 가지 제시하세요.
lab/LibraryBook.java
public final class LibraryBook {
    private static int libraryCount = 3;
    private final String name = "central";

    static final class Book {
        String describe() {
            return name + " of " + libraryCount;
        }
    }
}
해설 보기

1번은 다음과 같이 작성할 수 있습니다.

src/StaticNestedResultExercise.java
public final class StaticNestedResultExercise {
    public static void main(String[] args) {
        System.out.println(BoardRunner.run("nested", 40));
        System.out.println(BoardRunner.run("nested", 0));
        System.out.println(BoardRunner.run("nested", 50));
    }

    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]
Result[success=false, message=invalid]
Result[success=true, message=nested=50]

40과 50은 양수이므로 title + "=" + viewCount로 만든 메시지가 담기고, 0은 첫 번째 if에 걸려 invalid가 됩니다. message=nested=40처럼 =가 두 번 보이는 것은 레코드의 toString이 이름=값 형식으로 출력하는데 메시지 자체에도 =가 들어 있기 때문입니다. 세 번 실행하는 동안 BoardRunner 객체는 한 번도 만들어지지 않았습니다. run이 static 메서드이고 Result를 만드는 데 BoardRunner의 상태가 전혀 필요 없으므로, Result는 정적 중첩 타입이 맞습니다. 다른 실행기들도 같은 결과 타입을 쓰게 된다면 그때는 최상위 타입으로 꺼낼 때입니다.

2번은 describe의 return 줄에서 non-static variable name cannot be referenced from a static context 오류가 납니다. Book은 정적 중첩 클래스라서 연결된 LibraryBook 객체가 없는데, name은 LibraryBook 객체마다 있는 인스턴스 필드이기 때문입니다. 같은 줄의 libraryCount는 static 필드라서 문제가 없습니다.

첫 번째 방법은 필요한 객체를 직접 넘기는 것입니다. String describe(LibraryBook library)로 바꾸고 library.name을 읽으면, 본문의 StaticNestedExplicitOuter처럼 어느 도서관의 이름인지가 분명해집니다. 생성자에서 LibraryBook이나 이름 문자열을 받아 필드에 저장해도 됩니다. 두 번째 방법은 Book이 언제나 도서관 하나에 속하는 관계라면 static을 지워 내부 클래스로 만드는 것입니다. 그러면 name을 그대로 읽을 수 있지만, 대신 Book을 만들 때마다 바깥 LibraryBook 객체가 필요해집니다. 이 방법은 다음 글에서 다룹니다. name에 static을 붙여도 컴파일은 되지만, 도서관마다 다른 이름이 모든 도서관에 하나뿐인 값으로 바뀌므로 뜻이 달라집니다.

다음 글

내부 클래스와 바깥 인스턴스에서는 static 없이 선언해 바깥 객체에 자동으로 연결되는 내부 클래스를 다룹니다. 이 글에서 인수로 넘겼던 바깥 객체를 내부 클래스는 만들 때 한 번 기억해 두고 계속 쓰는데, 그 편리함이 어떤 비용과 함께 오는지도 살펴봅니다.