본문 바로가기

중첩 클래스 고르는 기준

정적 중첩·내부·지역·익명 클래스와 람다가 각각 무엇에 연결되고 어떤 값을 읽는지 한 게시판 예제로 비교하고, 상황에 맞는 형태를 고르는 순서와 중첩 타입을 바깥으로 꺼낼 때를 정리합니다.

이 장에서는 클래스 안에 선언하는 정적 중첩 클래스와 내부 클래스, 메서드 안에 선언하는 지역 클래스와 익명 클래스를 차례로 배웠고, 앞 글에서는 익명 클래스와 람다를 비교했습니다. 문법은 하나씩 익혔지만, 실제로 코드를 쓸 때는 '이 클래스를 어디에 선언할까'를 매번 정해야 합니다. 같은 동작을 다섯 가지 형태 중 어느 것으로도 만들 수 있는 경우가 많아서, 문법만으로는 답이 나오지 않습니다.

기준은 줄 수가 아닙니다. 클래스를 어디에 선언하느냐는 이 타입을 누가 쓸 수 있는지, 그리고 이 타입의 객체가 무엇에 연결되는지를 코드 구조로 나타냅니다. 이 글에서는 먼저 중첩 타입을 private으로 숨기면 무엇이 막히는지 보고, 한 게시판 예제에 네 가지 중첩 형태를 배치해 각 형태가 읽는 값을 비교합니다. 그다음 람다로 바꿀 수 있는 조건을 확인하고, 형태를 고르는 순서와 중첩 타입을 바깥으로 꺼낼 때를 정리합니다.

private 중첩 타입은 바깥에서 이름조차 쓸 수 없다

게시판 클래스 BoardCatalog가 게시글을 Post라는 중첩 클래스로 관리한다고 해 봅시다. Post는 게시판 안에서만 쓰기로 하고 private static으로 선언했습니다. 그런데 게시판에는 첫 게시글을 돌려주는 firstPost() 메서드가 있고, 다른 클래스의 main이 이 메서드의 결과를 받으려 합니다.

lab/PrivateNestedExposureFailure.java
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;
        }
    }
}
컴파일 오류
lab/PrivateNestedExposureFailure.java:4: error: Post has private access in BoardCatalog
        BoardCatalog.Post post = board.firstPost();
                    ^
1 error

이 파일에는 최상위 클래스가 두 개 있습니다. BoardCatalog는 같은 파일에 있지만 PrivateNestedExposureFailure 안에 중첩되지 않은 별개의 클래스입니다. 접근 제어자와 정보 은닉에서 본 것처럼 private 멤버를 쓸 수 있는 범위는 그 멤버를 둘러싼 최상위 클래스의 본문 전체이므로, BoardCatalog 바깥의 main은 BoardCatalog.Post라는 타입 이름을 쓸 수 없습니다. 오류가 4번째 줄, 즉 변수의 타입을 적은 자리에서 나는 이유입니다.

눈여겨볼 점은 firstPost() 선언 자체는 오류가 아니라는 것입니다. 바깥에 공개된 메서드가 바깥에서 쓸 수 없는 타입을 반환해도 컴파일은 됩니다. 문제는 그 결과를 받는 쪽에서 생깁니다. 호출자는 결과를 Object post = board.firstPost();처럼 Object로 받을 수는 있지만, 그래서는 아무것도 할 수 없습니다. Post에 public 메서드를 추가해도 board.firstPost().title()처럼 호출하면 Post.title() is defined in an inaccessible class or interface 오류가 납니다. 타입에 접근할 수 없으면 그 타입의 멤버에도 접근할 수 없기 때문입니다.

그러니 고칠 곳은 호출자가 아니라 게시판의 설계입니다. Post를 숨기기로 했다면 바깥에는 Post가 아닌 것을 내보내야 합니다. 제목 목록처럼 이미 공개된 타입의 값을 돌려주거나, 바깥에 공개할 목적으로 따로 만든 PostView 같은 불변 값을 돌려주거나, 바깥이 하려던 일을 게시판의 메서드가 대신하면 됩니다. PostView로 고치는 방법은 글 끝의 연습 문제에서 직접 해 봅니다.

Post를 Board 안에 숨기고 동작만 공개하기

이번에는 바깥이 하려던 일을 게시판이 대신하는 방법입니다. 다음 Board는 게시글을 추가하는 addPost와 전체를 출력하는 printPosts만 드러내고, 게시글을 어떤 타입으로 어떻게 저장하는지는 안에 숨깁니다.

src/EncapsulatedBoardCatalog.java
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]

main은 제목과 작성자를 문자열로 넘기고 출력을 부탁할 뿐, Post라는 클래스가 있다는 것도, 게시글이 배열에 들어 있다는 것도 모릅니다. addPost가 받은 두 문자열로 Post 객체를 만들어 배열에 넣고, printPosts가 배열을 돌며 각 게시글의 description()을 출력합니다. 그래서 첫 줄에 count=3이 나오고, 이어서 추가한 순서대로 세 게시글이 출력됩니다. 나중에 배열을 다른 저장 방식으로 바꾸거나 Post에 필드를 더해도, addPost와 printPosts의 모양만 그대로라면 main은 고칠 필요가 없습니다.

다만 이 한 파일 예제에서는 컴파일러가 이 숨김을 강제하지 않습니다. Board가 EncapsulatedBoardCatalog 안에 중첩되어 있어서, Board.Post의 private은 최상위 클래스 EncapsulatedBoardCatalog 전체에서 통하기 때문입니다. 실제로 main에 new Board.Post("direct", "me")를 써도 컴파일되고 실행됩니다. 컴파일러가 막아 주는 것은 첫 예제처럼 최상위 클래스 바깥의 코드뿐이고, 같은 최상위 클래스 안에서는 Post를 쓰지 않기로 한 약속을 개발자가 지켜야 합니다.

Post를 정적 중첩 클래스로 둔 이유

Post는 게시판의 필드인 posts나 count를 읽지 않습니다. 자기 제목과 작성자만 있으면 할 일을 다 할 수 있으므로, 특정 Board 객체와 연결될 이유가 없습니다. 그래서 static을 붙여 정적 중첩 클래스로 선언했습니다. 정적 중첩 클래스 안에서 count를 읽으려 하면 non-static variable count cannot be referenced from a static context 오류가 납니다. 만약 Post가 게시판의 정보를 정말 알아야 한다면, 그 값을 생성자나 메서드의 인수로 넘기거나 그 일을 Board의 메서드가 맡게 하면 됩니다.

static을 빼서 내부 클래스로 만들어도 이 예제는 똑같이 동작합니다. 하지만 그러면 모든 Post 객체가 자신을 만든 Board 객체와 연결되고, Board 객체 없이는 Post를 만들 수 없게 됩니다. 앞 글에서 본 것처럼 그 연결 때문에 바깥 객체가 필요 이상으로 오래 살아남을 수도 있습니다. 쓰지도 않는 연결을 만들 이유가 없으므로, 바깥 객체가 필요 없는 멤버 클래스에는 static을 붙이는 것이 기본입니다. 조슈아 블로크의 『이펙티브 자바』 3판 아이템 24도 같은 이유로 멤버 클래스는 되도록 static으로 만들라고 권합니다.

한 게시판에 네 가지 중첩 형태 배치하기

이제 네 가지 중첩 형태를 한 게시판에 함께 배치해 보겠습니다. 문법을 늘어놓으려는 것이 아니라, 각 형태가 그 자리에 있는 이유를 비교하려는 예제입니다. 형태마다 읽는 값이 다르다는 점에 주목하세요.

  • Entry는 게시글 하나의 제목과 조회 수를 담는 정적 중첩 레코드입니다. 자기 값만 읽습니다.
  • Summary는 게시판의 이름과 게시글 수를 한 줄로 만드는 내부 클래스입니다. 자신이 연결된 게시판의 상태를 읽습니다.
  • Formatter는 render 메서드 안의 지역 클래스입니다. render를 호출할 때 받은 구분자와 게시판의 게시글 목록을 읽습니다.
  • EntryFilter의 익명 구현은 static인 main 안에서 만든 한 번짜리 조회 조건입니다. test로 전달받은 게시글 하나만 읽습니다.

이 예제는 게시글을 배열 대신 List에 담습니다. ArrayList는 크기가 자동으로 늘어나는 목록으로, 14장에서 자세히 배웁니다. 여기서는 add로 넣고, 향상된 for로 하나씩 꺼내고, size()로 개수를 세는 정도만 씁니다.

src/NestedFormBoard.java
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() {
                    List<String> titles = new ArrayList<>();
                    for (Entry entry : entries) {
                        titles.add(entry.title());
                    }
                    return String.join(separator, titles);
                }
            }
            return new Formatter().format();
        }

        List<Entry> find(EntryFilter filter) {
            List<Entry> found = new ArrayList<>();
            for (Entry entry : entries) {
                if (filter.test(entry)) {
                    found.add(entry);
                }
            }
            return found;
        }

        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]]

첫 줄은 Summary가 만들었습니다. summary()는 인스턴스 메서드이므로, 그 안의 new Summary()는 summary()를 호출받은 게시판, 즉 board에 연결된 Summary 객체를 만듭니다. line()은 그 게시판의 name과 entries.size()를 읽어 mid-java:count=2를 돌려줍니다. 이 값은 미리 복사해 둔 것이 아닙니다. Summary 객체를 변수에 담아 두고 게시글을 하나 더 추가한 뒤 line()을 다시 부르면 mid-java:count=3이 나옵니다. 내부 클래스는 연결된 바깥 객체의 현재 상태를 읽기 때문입니다.

둘째 줄은 지역 클래스 Formatter가 만들었습니다. format()은 두 곳에서 값을 가져옵니다. 구분자 separator는 render의 매개변수를 캡처한 값이고, entries는 render를 호출받은 게시판의 필드입니다. render가 인스턴스 메서드라서 그 안에 선언한 지역 클래스도 바깥 게시판과 연결되기 때문에 entries를 읽을 수 있습니다. 제목을 모은 목록을 String.join이 구분자 " | "로 이어 붙여 nested | anonymous가 됩니다.

셋째 줄은 익명 EntryFilter를 받은 find의 결과입니다. find는 게시글을 하나씩 꺼내 filter.test(entry)를 부르고, true가 나온 게시글만 새 목록에 담습니다. 조회 수 40인 nested는 30 이상이라 남고, 20인 anonymous는 빠집니다. 출력의 바깥 대괄호는 목록의 toString()이, 안쪽의 Entry[title=nested, viewCount=40]은 레코드가 자동으로 만든 toString()이 만든 문자열입니다. 이 익명 클래스는 static인 main 안에서 만들었으므로 연결된 바깥 객체가 없고, 판단에 쓰는 값은 전달받은 entry뿐입니다.

Entry 앞의 static은 사실 생략해도 결과가 같습니다. 레코드는 다른 클래스 안에 선언하면 언제나 정적 중첩 타입이 되기 때문입니다. 여기서는 Entry가 게시판 객체와 상관없는 독립된 값이라는 점을 드러내려고 적어 두었습니다. 그래서 main에서도 게시판 객체 없이 new Board.Entry("nested", 40)으로 만들 수 있습니다.

한 게시판에서 중첩 형태마다 읽는 값이 다르다

Entry의 독립 값, Summary의 게시판 상태, Formatter의 호출별 구분자와 게시판 상태, 익명 필터의 매개변수 검사를 구분합니다.

중첩 형태 · 읽는 값과 결과

한 게시판에서 중첩 형태마다 읽는 값이 다르다

한 게시판의 네 구현을 실제 입력과 출력에 대응합니다. 조회 조건은 30 이상이며 각 구현이 읽는 대상이 다릅니다.

같은 게시판에서 읽는 데이터와 필터의 조건 분기 Board가 보관한 두 Entry는 독립 값입니다. Summary는 Board 이름과 수, Formatter는 이번 구분자와 Board 제목들을 읽습니다. static main의 익명 필터는 전달받은 Entry의 조회수30 이상을 검사해 nested40을 남기고 anonymous20을 제외합니다. 점선은 읽는 데이터 관계, 실선은 결과와 조건 분기입니다. 같은 Board의 값과 협력자 · Entry 값은 바깥 인스턴스 연결 불필요 Board 상태 name=mid-java entries 두 개 Entry 값 nested: 40 anonymous: 20 Summary Board의 name와 entries.size() 읽음 지역 Formatter separator=" | " Board의 제목들 읽음 Summary 결과 mid-java:count=2 Formatter 결과 nested | anonymous static main의 EntryFilter · Board 바깥 객체 연결 없이 test(entry)를 검사 조회 수 ≥30? 각 Entry로 test 참 거짓 조회 결과에 남음 nested viewCount=40 조회 결과에서 제외 anonymous viewCount=20 점선: 읽는 데이터 관계 · 실선: 결과와 조건 분기 · 필터는 Entry 인수를 검사함
같은 게시판에서 읽는 데이터와 필터의 조건 분기 Board가 보관한 두 Entry는 독립 값입니다. Summary는 Board 이름과 수, Formatter는 이번 구분자와 Board 제목들을 읽습니다. static main의 익명 필터는 전달받은 Entry의 조회수30 이상을 검사해 nested40을 남기고 anonymous20을 제외합니다. 점선은 읽는 데이터 관계, 실선은 결과와 조건 분기입니다. 같은 Board의 값과 협력자 Board 상태 name=mid-java entries 두 개 Entry 값 nested: 40 anonymous: 20 Summary Board의 name와 entries.size() 읽음 Summary 결과 mid-java:count=2 지역 Formatter separator=" | " Board의 제목들 읽음 Formatter 결과 nested | anonymous static main의 조회 조건 조회 수 ≥30? 각 Entry로 test 참 거짓 조회 결과에 남음 nested viewCount=40 조회 결과에서 제외 anonymous viewCount=20 점선: 읽는 데이터 관계 실선: 결과와 조건 분기 필터는 Entry 인수를 검사함
한 게시판에서 중첩 형태마다 읽는 값이 다르다
구현과 선언 위치이 예제가 읽는 값입력 또는 결과
Entry
정적 중첩 레코드
자신의 title과 viewCount. 바깥 Board 인스턴스는 필요 없습니다.입력 값
nested · 40
anonymous · 20
Summary
내부 멤버 클래스
자신이 연결된 Board의 name과 entries.size().출력
mid-java:count=2
Formatter
지역 클래스
이번 render 호출의 separator와 바깥 Board의 게시글 제목들.출력
nested | anonymous
익명 EntryFilter
static main의 구현
test에 전달된 entry의 조회 수를 30과 비교합니다.조회 결과에 남은 값
제목 nested
조회 수 40
Entry
정적 중첩 레코드
이 예제가 읽는 값
자신의 title과 viewCount. 바깥 Board 인스턴스는 필요 없습니다.
입력 또는 결과
입력 값
nested · 40
anonymous · 20
Summary
내부 멤버 클래스
이 예제가 읽는 값
자신이 연결된 Board의 name과 entries.size().
입력 또는 결과
출력
mid-java:count=2
Formatter
지역 클래스
이 예제가 읽는 값
이번 render 호출의 separator와 바깥 Board의 게시글 제목들.
입력 또는 결과
출력
nested | anonymous
익명 EntryFilter
static main의 구현
이 예제가 읽는 값
test에 전달된 entry의 조회 수를 30과 비교합니다.
입력 또는 결과
조회 결과에 남은 값
제목 nested
조회 수 40

지역 Formatter는 인스턴스 메서드 안에 있어 바깥 게시판 상태도 읽습니다. 익명 EntryFilter는 static main 안에서 생성되며, 바깥 Board 인스턴스 없이 매개변수만 검사합니다.

EntryFilter는 추상 메서드가 하나뿐인 함수형 인터페이스이므로 익명 클래스 대신 람다를 넘길 수도 있습니다. board.find(entry -> entry.viewCount() >= 30)으로 바꿔도 출력은 같습니다. 이 예제에서는 '인터페이스를 구현한 객체를 만들어 바로 넘긴다'는 구조를 눈으로 보려고 익명 클래스로 썼지만, 실제 코드에서 이런 한 줄짜리 조건이라면 보통 람다를 씁니다.

람다로 바꿀 수 있는 인터페이스와 @FunctionalInterface

앞 글에서 본 것처럼 익명 클래스와 람다는 늘 바꿔 쓸 수 있는 것이 아닙니다. 익명 클래스 본문의 this는 익명 객체이지만 람다는 새 this를 만들지 않습니다. 또 람다에는 필드를 선언할 수 없고, 추상 메서드가 하나뿐인 인터페이스만 구현할 수 있습니다. 반대로 이런 것이 필요 없는 짧은 동작이라면 람다가 훨씬 간결합니다. 다음은 같은 Command 인터페이스를 두 방식으로 구현해 같은 실행기에 넘기는 예제입니다.

src/AnonymousAndLambdaCommands.java
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

execute는 넘겨받은 객체가 익명 클래스로 만들어졌는지 람다로 만들어졌는지 모르고, 알 필요도 없습니다. 둘 다 Command이므로 run(title)을 부르면 되고, 각 구현이 제목 앞에 자기 이름을 붙여 돌려준 문자열이 출력됩니다. 람다 title -> "lambda=" + title은 매개변수 title을 받아 화살표 오른쪽 식의 값을 돌려주는 동작으로, 익명 클래스의 run 메서드 본문과 같은 일을 합니다. 이 람다는 static인 main 안에 있으므로, 본문에 this를 쓰면 non-static variable this cannot be referenced from a static context 오류가 납니다.

Command 위에 붙은 @FunctionalInterface는 '이 인터페이스는 람다로 구현하라고 만든 것'이라는 표시입니다. 이 애노테이션이 동작을 바꾸지는 않습니다. 추상 메서드가 하나뿐인 인터페이스는 애노테이션이 없어도 함수형 인터페이스이고, 그래서 앞의 EntryFilter도 람다를 받을 수 있었습니다. 애노테이션의 쓸모는 누군가 나중에 Command에 추상 메서드를 하나 더 추가했을 때 드러납니다. 예를 들어 String undo(String title);을 추가하고 컴파일하면 다음 오류가 납니다.

컴파일 오류 (일부)
src/AnonymousAndLambdaCommands.java:20: error: Unexpected @FunctionalInterface annotation
    @FunctionalInterface
    ^
  Command is not a functional interface
    multiple non-overriding abstract methods found in interface Command

컴파일러는 이 오류 말고도 익명 클래스가 undo를 구현하지 않았다는 오류와 람다를 쓴 줄의 Command is not a functional interface 오류를 함께 보고해, 모두 3개의 오류가 나옵니다. 애노테이션이 없어도 람다를 쓴 줄에서는 오류가 납니다. 차이는 오류가 나는 위치입니다. 애노테이션이 없으면 오류는 람다를 쓴 곳에서만 나므로, 그 인터페이스를 람다로 쓰는 코드가 아직 없거나 다른 파일에 있다면 인터페이스를 바꾼 사람은 실수를 알아채지 못할 수 있습니다. 애노테이션이 있으면 인터페이스 선언 자체에서 바로 오류가 납니다. 실수가 일어난 바로 그 파일에서 알 수 있는 셈입니다.

람다로 충분한 동작이라도, 같은 구현을 여러 곳에서 다시 쓰거나 상태와 여러 메서드가 필요해지면 이름 있는 클래스로 옮기는 것이 좋습니다. 이름이 있어야 다른 곳에서 만들 수 있고, 따로 테스트할 수 있기 때문입니다.

중첩 타입을 바깥으로 꺼낼 때

처음에는 바깥 클래스에서만 쓰던 타입이라도 요구 사항이 바뀌면 사정이 달라집니다. 다음과 같은 신호가 보이면 중첩 타입을 최상위 클래스로 꺼내 별도 파일로 옮기는 것을 생각해 보세요.

  • 다른 클래스에서도 같은 구현이 필요해졌다. 중첩된 채로 두면 바깥 클래스를 통해서만 쓸 수 있어 의존 관계가 어색해집니다.
  • 중첩 타입이 바깥 클래스의 멤버를 쓰는 것보다 자기 상태와 규칙이 더 많아졌다. 이제는 독립된 개념이라는 뜻입니다.
  • 따로 단위 테스트를 하거나 문서를 써야 한다. 최상위 클래스여야 다루기 쉽습니다.
  • 바깥 파일이 길어져 원하는 코드를 찾기 어렵다.
  • 바깥 클래스와 중첩 타입이 서로의 private 멤버를 마구 읽고 써서, 어떤 변경이 어디에 영향을 주는지 따라가기 어렵다.

반대로 바깥 클래스를 떠나면 의미가 없는 타입은 중첩해 두는 편이 낫습니다. 자료구조의 노드, 객체를 만드는 과정에서만 쓰는 중간 상태, 한 집계 안에서만 쓰는 항목 같은 것들입니다. 이런 타입을 private으로 중첩하면 바깥에서 쓸 수 없게 되므로, 바깥 클래스의 사용법을 바꾸지 않고도 언제든 고칠 수 있습니다.

자바 표준 라이브러리에서도 같은 기준을 볼 수 있습니다. JDK 21 기준으로 OpenJDK의 연결 목록 LinkedList는 원소를 Node라는 private static 중첩 클래스에 담습니다. 노드는 자기 원소와 앞뒤 노드만 알면 되고 리스트 객체가 필요 없기 때문입니다. 반면 ArrayList의 원소를 차례로 꺼내 주는 반복자 Itr는 private 내부 클래스입니다. 반복자는 자신을 만든 바로 그 리스트의 원소를 읽어야 하기 때문입니다. 두 클래스 모두 라이브러리 내부 구현이라 private으로 숨겨져 있습니다. 한편 Map.Entry는 맵 안의 키와 값 한 쌍을 나타내는 public 중첩 인터페이스입니다. 맵과 떼어 놓으면 의미가 없지만, 맵을 쓰는 코드가 직접 다뤄야 하는 타입이라서 공개되어 있습니다. 이 클래스들은 14장부터 컬렉션을 배울 때 다시 만납니다.

질문 순서로 형태 고르기

어떤 형태를 쓸지 망설여진다면 다음 질문을 위에서부터 차례로 던져 보세요.

  1. 이 타입이 바깥 클래스를 떠나서도 의미가 있고 여러 곳에서 쓰이는가? 그렇다면 최상위 클래스로 만듭니다.
  2. 바깥 클래스에 속하지만 여러 메서드에서 쓰이는가? 그렇다면 멤버 클래스로 만듭니다. 이때 특정 바깥 객체의 상태가 필요하면 내부 클래스, 필요 없으면 정적 중첩 클래스입니다.
  3. 한 메서드 안에서만 쓰이는가? 그 메서드 안의 여러 곳에서 객체를 만들어야 하거나 이름으로 설명하고 싶다면 지역 클래스입니다.
  4. 객체를 한 곳에서 한 번만 만드는가? 함수형 인터페이스의 동작 하나만 넘기면 된다면 람다이고, 필드나 여러 메서드가 필요하거나 클래스를 상속해야 한다면 익명 클래스입니다.

질문의 순서에도 이유가 있습니다. 위의 질문일수록 타입을 쓸 수 있는 범위가 넓은 형태를 고릅니다. 위에서 '아니요'가 나올 때마다 범위를 한 단계씩 좁히다 보면, 필요한 만큼만 공개하는 가장 좁은 형태에 도착합니다.

형태이럴 때 고른다바깥과의 연결
최상위 클래스여러 곳에서 쓰고 따로 테스트한다없음
정적 중첩 클래스바깥 클래스와 관련 있지만 바깥 객체의 상태는 필요 없다바깥 객체와 연결되지 않음
내부 클래스특정 바깥 객체의 상태를 읽거나 바꿔야 한다자신을 만든 바깥 객체와 연결됨
지역 클래스한 메서드 안에서만 쓰고, 이름이 필요하거나 여러 번 만든다지역 변수를 캡처하고, 인스턴스 메서드 안이면 바깥 객체와도 연결됨
익명 클래스한 번만 만들고, 필드나 여러 메서드, 클래스 상속이 필요하다지역 클래스와 같음
람다함수형 인터페이스의 동작 하나를 넘긴다지역 변수를 캡처하고, this는 바깥의 this와 같음

형태를 고른 뒤에도 몇 가지는 따로 확인해야 합니다. 캡처한 값이나 연결된 바깥 객체가 필요 이상으로 오래 살아남지 않는지, 여러 곳에서 같은 가변 상태를 공유하고 있지 않은지, 접근 제어자가 의도한 만큼만 공개하는지, 그리고 테스트하기 어려운 곳에 중요한 규칙을 숨겨 두지 않았는지입니다.

정리

  • 클래스를 어디에 선언하느냐는 그 타입을 누가 쓸 수 있는지와, 그 객체가 무엇에 연결되는지를 나타냅니다. 줄 수를 줄이는 것이 목적이 아닙니다.
  • private 중첩 타입은 최상위 클래스 바깥에서 타입 이름도 멤버도 쓸 수 없습니다. 바깥에 공개하는 메서드는 숨긴 타입 대신 공개된 값이나 동작을 내보내야 합니다.
  • 같은 최상위 클래스 안에서는 private이 서로 통하므로, 한 파일 안의 숨김은 컴파일러가 아니라 개발자가 지켜야 합니다.
  • 바깥 객체의 상태가 필요 없는 멤버 클래스는 static으로 선언합니다. 내부 클래스는 연결된 바깥 객체의 현재 상태를 읽습니다.
  • 함수형 인터페이스의 동작 하나는 람다로 넘기고, @FunctionalInterface를 붙이면 추상 메서드가 늘어나는 실수를 인터페이스 선언에서 바로 알 수 있습니다.
  • 형태는 범위가 넓은 것부터 질문하며 좁혀 고르고, 다른 곳에서도 필요해지거나 독립된 규칙이 생기면 최상위 클래스로 꺼냅니다.

연습 문제

  1. 첫 예제의 BoardCatalog를 고쳐, 바깥의 main이 첫 게시글의 제목과 작성자를 읽을 수 있게 하세요. 조건은 다음과 같습니다.
    • Post는 계속 private static 중첩 클래스로 둡니다.
    • BoardCatalog 안에 바깥에 공개할 레코드 PostView(String title, String author)를 선언하고, firstPost()는 PostView를 반환합니다.
    • main은 받은 PostView를 출력하고, 제목만 한 줄 더 출력합니다.
해설 보기
src/PostViewExercise.java
public final class PostViewExercise {
    public static void main(String[] args) {
        BoardCatalog board = new BoardCatalog();
        BoardCatalog.PostView post = board.firstPost();
        System.out.println(post);
        System.out.println(post.title());
    }
}

final class BoardCatalog {
    private final Post first = new Post("가입 인사", "[email protected]");

    PostView firstPost() { return first.toView(); }

    record PostView(String title, String author) { }

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

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

        PostView toView() { return new PostView(title, author); }
    }
}
실행 결과
PostView[title=가입 인사, [email protected]]
가입 인사

PostView에는 private을 붙이지 않았으므로 같은 패키지의 코드가 BoardCatalog.PostView라는 이름을 쓸 수 있습니다. firstPost()는 숨겨 둔 Post를 그대로 내보내지 않고, toView()로 제목과 작성자를 새 PostView에 복사해 돌려줍니다. 첫 줄은 레코드가 자동으로 만든 toString()의 결과이고, 둘째 줄은 레코드의 읽기 메서드 title()이 돌려준 값입니다.

이렇게 하면 Post는 여전히 바깥에서 보이지 않으므로, 나중에 필드를 더하거나 구조를 바꿔도 PostView만 그대로 두면 호출자는 영향을 받지 않습니다. 또 레코드의 필드는 다시 대입할 수 없고 String은 바꿀 수 없는 값이라서, 호출자가 PostView를 통해 게시판 내부를 바꿀 방법도 없습니다.

  1. 다음 조건대로 네 가지 중첩 형태의 객체를 만들어 각각의 이름을 출력하세요.

    • Hello 인터페이스에는 void hello() 메서드 하나만 있습니다.
    • Outer 안에 정적 중첩 클래스 Nested와 내부 클래스 Inner를 선언합니다. Inner는 main에서 만든 outer 객체에 연결합니다.
    • static인 main 안에 지역 클래스 Local과 Hello의 익명 구현을 선언합니다.
    • Nested, Inner, Local, 익명 구현 순서로 hello()를 호출해 각각 static nested, inner, local, anonymous를 출력합니다.

    코드를 작성한 뒤 두 가지를 생각해 보세요. outer를 만드는 줄을 지우면 네 객체 중 무엇을 만들 수 없게 될까요? 그리고 네 객체의 참조를 모두 Hello 타입 변수에 담아 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 역할을 하지만, 선언된 위치와 바깥 객체와의 연결이 다릅니다. outer.new Inner()는 연결할 Outer 객체가 있어야 만들 수 있습니다. outer를 만드는 줄을 지우면 outer라는 변수가 없어져 이 줄이 컴파일되지 않고, 바깥 객체 없이 new Outer.Inner()로 만들려 해도 an enclosing instance that contains Outer.Inner is required 오류가 납니다. 반면 정적 중첩 클래스 Nested는 바깥 객체와 연결되지 않고, Local과 익명 구현은 static인 main 안에 선언되어 연결할 바깥 객체가 처음부터 없으므로 세 객체는 Outer 객체 없이도 만들 수 있습니다.

네 클래스는 모두 Hello를 구현하므로, 네 객체 모두 Hello 타입 변수에 담아 hello()를 호출할 수 있습니다. 공통 인터페이스로 다룰 수 있는지와 만들 때 바깥 객체가 필요한지는 서로 다른 질문입니다. 실제 설계에서는 이 중 필요한 연결만 가진 가장 좁은 형태를 고르면 됩니다.

  1. 다음 각 경우에 어떤 형태가 알맞은지 고르고, 그 이유를 적으세요.
    1. 직접 만드는 연결 목록 클래스 안에서, 원소 하나와 다음 노드의 참조를 담는 노드.
    2. 특정 게시판 객체의 게시글 배열을 처음부터 하나씩 꺼내 주는 커서 객체. 커서는 그 게시판의 배열을 읽어야 하고, 게시판 바깥에는 Cursor 인터페이스 타입으로만 알립니다.
    3. 어떤 메서드에 한 번 넘길 짧은 비교 규칙. 받는 쪽의 타입은 추상 메서드가 하나뿐인 인터페이스입니다.
    4. 한 메서드 안에서 설정을 다르게 해 세 번 만들어야 하고, 필드가 두 개 있는 구현.
해설 보기
  1. private 정적 중첩 클래스입니다. 노드는 자기 원소와 다음 노드만 알면 되고 리스트 객체의 상태가 필요 없으므로 static이 맞습니다. 리스트 바깥에서는 쓸 일이 없으니 private으로 숨깁니다. OpenJDK의 LinkedList.Node가 이 형태입니다.
  2. private 내부 클래스입니다. 커서는 자신을 만든 바로 그 게시판의 배열을 읽어야 하므로 바깥 객체와 연결되는 내부 클래스가 필요합니다. 바깥에는 Cursor 인터페이스로만 알리므로 클래스 자체는 private으로 숨길 수 있습니다. ArrayList의 반복자 Itr가 같은 구조입니다.
  3. 람다입니다. 함수형 인터페이스의 동작 하나를 한 번만 넘기고, 필드나 다른 메서드가 필요 없기 때문입니다.
  4. 지역 클래스입니다. 한 메서드 안에서만 쓰지만 객체를 세 번 만들어야 하므로 이름이 필요합니다. 익명 클래스는 그 식이 있는 한 곳에서만 객체를 만들 수 있습니다. 같은 구현이 다른 메서드에서도 필요해지면 멤버 클래스로 옮깁니다.

다음 글

다음 장에서는 예외를 다룹니다. 첫 글 반환 코드 대신 예외를 쓰는 이유에서는 실패를 반환값으로 알리는 방식이 왜 검사를 빠뜨리기 쉬운지부터 살펴봅니다. 앞 글의 연습 문제에서 finally로 끝 로그를 남긴 것처럼, 실패가 일어나도 프로그램의 흐름과 자원 정리를 지키는 방법을 12장에서 차례로 배웁니다.