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

안동민 개발노트

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

익명 클래스와 동작 전달

재사용된 익명 객체의 가변 상태 누적 문제를 관찰하고 지역·익명 클래스 구현으로 공통 전후 흐름과 가변 동작을 분리합니다.

익명 클래스는 이름 없이 상위 타입을 구현하거나 상속하면서 즉시 인스턴스를 만듭니다. 한 번만 필요한 작은 전략을 호출 위치 가까이에 둘 수 있습니다. 하지만 익명이라는 사실이 무상태를 보장하지 않으며 같은 인스턴스를 재사용하면 내부 필드도 계속 누적됩니다.

명령 재사용과 상태 누적

호출자는 매 실행이 독립적으로 1을 출력할 것으로 기대했지만 같은 익명 객체가 count 필드를 보관합니다.

lab/AnonymousStateReuseBug.java
public final class AnonymousStateReuseBug {
    public static void main(String[] args) {
        Process once = new Process() {
            private int count;

            @Override
            public void run() {
                count++;
                System.out.println("count=" + count);
            }
        };

        execute(once);
        execute(once);
    }

    private static void execute(Process process) {
        process.run();
    }

    private interface Process { void run(); }
}
의도하지 않은 결과
count=1
count=2

익명 객체도 일반 객체와 같은 수명과 상태를 가집니다. 실행마다 새 인스턴스가 필요하면 팩터리에서 만들거나 상태를 지역 변수로 옮깁니다. 정말 누적 명령이라면 이름 있는 클래스와 명시적 상태 메서드가 더 이해하기 쉽습니다.

익명 클래스의 선언과 생성

지역 클래스는 이름을 선언하고 new로 생성하지만 익명 클래스는 new Interface() { ... } 안에서 구현과 생성을 함께 합니다. 생성자 이름은 없으므로 초기 값은 캡처하거나 인스턴스 초기화 블록·필드 초기화를 사용하지만 복잡한 초기화에는 이름 있는 클래스가 낫습니다.

src/AnonymousStudyPrinter.java
public final class AnonymousStudyPrinter {
    public static void main(String[] args) {
        String prefix = "daily";
        Printer printer = new Printer() {
            @Override
            public void print(String topic, int minutes) {
                System.out.println(prefix + ":" + topic + "=" + minutes);
            }
        };

        printer.print("anonymous", 40);
        System.out.println("type=" + printer.getClass().isAnonymousClass());
    }

    private interface Printer {
        void print(String topic, int minutes);
    }
}
daily:anonymous=40
type=true

prefix는 사실상 final로 캡처됩니다. 클래스 이름으로 다시 생성하거나 다른 위치에서 재사용할 수 없으므로 한 호출 문맥에 한정된 구현에 사용합니다.

공통 흐름과 가변 동작의 분리

주사위, 합계, 학습 기록 출력마다 시작·종료 메시지를 복사하면 정책 변경이 여러 곳에 퍼집니다. hello(Process) 같은 실행기가 공통 전후 흐름을 소유하고 Process 구현이 가운데 동작만 제공합니다.

src/ProcessTemplateRefactoring.java
import java.util.Random;

public final class ProcessTemplateRefactoring {
    public static void main(String[] args) {
        Process dice = new Process() {
            private final Random random = new Random(7);
            public void run() { System.out.println("dice=" + (random.nextInt(6) + 1)); }
        };

        Process sum = new Process() {
            public void run() { System.out.println("sum=" + (1 + 2 + 3)); }
        };

        execute("dice", dice);
        execute("sum", sum);
    }

    private static void execute(String name, Process process) {
        System.out.println("start=" + name);
        process.run();
        System.out.println("end=" + name);
    }

    private interface Process { void run(); }
}
start=dice
dice=5
end=dice
start=sum
sum=6
end=sum

실행기는 구체 동작을 모르고 Process 계약만 호출합니다. 예외가 나도 종료 로그를 남겨야 한다면 try/finally 정책을 실행기에 둡니다. 익명 클래스 구현마다 전후 흐름을 복사하지 않습니다.

학습 기록 명령의 즉시 전달

변수에 담지 않고 메서드 인수 자리에서 익명 객체를 만들 수 있습니다. 구현이 짧고 재사용하지 않을 때 의도가 가장 가까이 있습니다.

src/InlineAnonymousStudyCommand.java
public final class InlineAnonymousStudyCommand {
    public static void main(String[] args) {
        execute(new StudyCommand() {
            @Override
            public StudyResult run() {
                return new StudyResult("nested", 50);
            }
        });
    }

    private static void execute(StudyCommand command) {
        System.out.println("before");
        StudyResult result = command.run();
        System.out.println("saved=" + result.topic() + ":" + result.minutes());
        System.out.println("after");
    }

    private interface StudyCommand { StudyResult run(); }
    private record StudyResult(String topic, int minutes) { }
}
before
saved=nested:50
after

메서드가 하나인 함수형 인터페이스라면 람다로 더 짧게 표현할 수 있습니다. 여러 메서드를 구현하거나 자체 필드, 초기화, 명시적 this가 필요하면 익명 클래스가 여전히 의미를 가집니다.

this와 객체 수명

익명 클래스 본문의 this는 그것을 만든 바깥 객체가 아니라 새 익명 객체를 가리킵니다. 바깥 인스턴스가 필요하면 Owner.this처럼 한정해야 합니다. 이 차이를 놓치면 로그에 소유자 이름을 넣으려다 익명 객체의 toString()을 출력하거나, 같은 이름의 필드를 잘못 읽는 버그가 생깁니다. 람다의 this는 새 객체를 만들지 않고 바깥 문맥의 this를 가리키므로 두 표현을 단순히 문법만 다른 것으로 보면 안 됩니다.

캡처한 값의 수명도 함께 봐야 합니다. 반환된 익명 객체가 오래 보관되면 그 객체가 참조한 바깥 인스턴스와 필드도 회수되지 않을 수 있습니다. 화면이나 세션처럼 큰 소유자를 장기 캐시에 붙인 명령이 특히 위험합니다. 필요한 작은 값만 지역 변수로 복사해 캡처하거나, 장기 보관할 전략은 정적 중첩·최상위 구현으로 올려 암묵적 소유자 연결을 끊습니다.

익명 클래스의 JVM 이름은 컴파일 순서에 따라 Owner$1처럼 정해지는 구현 세부 사항입니다. 그 이름을 설정 파일, 직렬화 식별자, 리플렉션 분기 조건으로 저장하면 코드 위치가 바뀌기만 해도 호환성이 깨집니다. 외부 경계에는 인터페이스 이름과 명시적인 데이터만 노출하고, 익명 객체는 프로세스 내부의 일회성 구현으로 제한합니다.

따라서 선택 기준은 줄 수만이 아닙니다. 호출 즉시 실행되고 상태가 없으며 외부로 새지 않는 짧은 동작이면 익명 클래스가 적합합니다. 객체가 누적 상태를 갖거나 수명이 호출보다 길고, 장애 로그에서 구현 이름을 식별해야 하거나 독립 테스트가 필요하면 이름 있는 구현으로 승격합니다. 같은 동작을 매번 새로 만들지, 한 객체를 의도적으로 재사용할지도 생성 지점에서 드러내야 합니다.

지역·익명·람다의 선택

  • 같은 메서드 안에서 구현을 두 번 이상 생성하고 이름으로 설명하고 싶다: 지역 클래스
  • 한 번만 쓰고 여러 메서드나 필드가 필요하다: 익명 클래스
  • 함수형 인터페이스의 한 동작만 전달한다: 람다
  • 여러 위치에서 재사용하고 독립 테스트한다: member 또는 최상위 class

익명 구현이 수십 줄로 커지면 이름과 파일을 주어 책임을 드러냅니다. 호출부 근접성보다 읽기 흐름과 재사용 가능성이 중요해지는 지점입니다.

연습 문제

Process 실행 중 IllegalStateException이 발생해도 end가 출력되도록 execute를 작성하세요. main에서 예외를 잡아 메시지를 확인합니다.

해설 보기
src/AnonymousFinallyExercise.java
public final class AnonymousFinallyExercise {
    public static void main(String[] args) {
        try {
            execute(new Process() {
                public void run() { throw new IllegalStateException("study failed"); }
            });
        } catch (IllegalStateException error) {
            System.out.println("caught=" + error.getMessage());
        }
    }

    private static void execute(Process process) {
        System.out.println("start");
        try {
            process.run();
        } finally {
            System.out.println("end");
        }
    }

    private interface Process { void run(); }
}
start
end
caught=study failed

finally는 예외를 삼키지 않고 정리 정책만 보장합니다. 다음 장에서 예외를 어디서 복구하고 어디로 전달할지 더 깊게 다룹니다.