본문 바로가기

익명 클래스로 동작 전달하기

선언과 생성을 한 번에 하는 익명 클래스로 달라지는 동작만 메서드에 넘기는 방법을 배우고, 익명 객체를 다시 쓸 때 상태가 쌓이는 문제와 익명 클래스 안의 this가 가리키는 대상을 살펴봅니다.

앞 글의 지역 클래스는 메서드 안에서 이름을 붙여 선언한 뒤, 그 이름으로 new를 써서 객체를 만들었습니다. 그런데 그 클래스를 선언한 바로 아래에서 딱 한 번만 쓴다면 이름이 하는 일은 거의 없습니다. 이름을 짓고, 선언하고, 그 이름으로 다시 객체를 만드는 세 단계가 '이 자리에서 쓸 구현 하나'를 위해 필요할 뿐입니다.

익명 클래스(anonymous class)는 이 세 단계를 식 하나로 줄입니다. 이름 없는 클래스를 선언하면서 동시에 그 클래스의 객체를 하나 만듭니다. 이 글에서는 먼저 익명 클래스를 만드는 문법을 보고, 공통 흐름은 메서드 하나에 두고 달라지는 동작만 익명 객체로 넘기는 방법을 익힙니다. 그다음 익명 객체를 다시 쓸 때 상태가 쌓이는 문제, 익명 클래스 안의 this가 가리키는 대상, 익명 객체가 오래 살아남을 때 생기는 일을 차례로 살펴봅니다.

지역 클래스에서 이름을 지우면

다음은 게시글 제목과 조회 수를 출력하는 Printer 인터페이스를 지역 클래스로 구현한 코드의 일부입니다. 앞 글의 LocalPrinter처럼 메서드 안에서 이름을 붙여 선언하고, 바깥의 지역 변수 prefix를 캡처합니다.

String prefix = "daily";
class DailyPrinter implements Printer {
    @Override
    public void print(String title, int viewCount) {
        System.out.println(prefix + ":" + title + "=" + viewCount);
    }
}
Printer printer = new DailyPrinter();

DailyPrinter라는 이름은 바로 다음 줄에서 한 번 쓰이고 끝납니다. 이 이름을 지우고 선언과 생성을 한 식으로 합치면 다음과 같은 익명 클래스가 됩니다.

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

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

    private interface Printer {
        void print(String title, int viewCount);
    }
}
실행 결과
daily:anonymous=40
type=true

new Printer() { ... }는 이렇게 읽습니다. new 다음에 구현할 인터페이스 이름 Printer와 빈 괄호가 오고, 그 뒤 중괄호 안에 클래스 본문이 옵니다. 이 식 전체는 'Printer를 구현하는 이름 없는 클래스를 선언하고, 그 클래스의 객체를 하나 만들어 돌려준다'는 뜻입니다. 인터페이스는 원래 new Printer()처럼 객체로 만들 수 없지만, 여기서는 본문이 추상 메서드 print를 구현해 완성된 클래스가 되므로 그 클래스의 객체가 만들어집니다. 이 식은 변수 선언문의 초깃값 자리에 들어간 식일 뿐이므로, 선언문을 끝내는 세미콜론이 본문을 닫는 중괄호 뒤에 옵니다.

출력의 첫 줄은 print가 prefix와 제목, 조회 수를 이어 붙인 결과입니다. 익명 클래스 본문이 바깥 메서드의 지역 변수 prefix를 그대로 읽었습니다. 앞 글에서 본 지역 클래스의 캡처 규칙이 익명 클래스에도 똑같이 적용되므로, prefix는 final이거나 사실상 final이어야 합니다. 객체를 만든 뒤에 prefix = "weekly";처럼 다시 대입하면 local variables referenced from an inner class must be final or effectively final 오류가 납니다. 둘째 줄은 getClass()로 객체의 클래스 정보를 얻은 뒤 isAnonymousClass()로 그 클래스가 익명 클래스인지 물어본 결과이고, 답은 true입니다. 같은 코드를 지역 클래스 DailyPrinter로 실행하면 false가 나옵니다.

이름이 없어서 생기는 제약

이름이 없다는 사실에서 몇 가지 제약이 따라 나옵니다. 우선 생성자를 선언할 수 없습니다. 생성자는 클래스 이름과 같은 이름으로 선언하는데, 익명 클래스에는 부를 이름이 없기 때문입니다. 그래서 익명 객체의 처음 상태는 private int count = 0; 같은 필드 초기화식이나 캡처한 지역 변수로 정합니다. 클래스 본문에 이름 없이 중괄호만 쓴 인스턴스 초기화 블록도 쓸 수 있는데, 이 블록은 객체가 만들어질 때 실행됩니다.

또 같은 익명 클래스의 객체를 다른 곳에서 new로 다시 만들 수 없습니다. 객체를 만드는 곳은 익명 클래스를 선언한 그 식 하나뿐입니다. 그렇다고 만든 객체를 그 자리에서만 쓸 수 있는 것은 아닙니다. 객체의 참조는 변수에 담을 수도, 메서드 인수로 넘길 수도, 반환할 수도 있습니다.

인터페이스를 구현하는 대신 클래스를 상속하는 익명 클래스도 만들 수 있습니다. 추상 클래스 Greeter가 있다면 new Greeter("kim") { ... }는 Greeter를 상속한 이름 없는 클래스의 객체를 만들고, 괄호 안의 "kim"은 부모 클래스의 생성자로 전달됩니다. 인터페이스에는 생성자가 없으므로 new Printer("x") { ... }처럼 인수를 주면 anonymous class implements interface; cannot have arguments 오류가 납니다. 그리고 익명 클래스는 클래스 하나를 상속하거나 인터페이스 하나를 구현하는 것 중 하나만 할 수 있습니다. 인터페이스 여러 개를 함께 구현해야 하거나 초기화가 복잡하다면 이름 있는 클래스를 쓰세요.

공통 흐름은 한 곳에, 달라지는 동작만 넘기기

익명 클래스가 가장 자주 쓰이는 곳은 메서드에 '할 일'을 넘기는 자리입니다. 주사위를 굴리는 작업과 합계를 구하는 작업이 있고, 둘 다 시작과 끝에 로그를 남겨야 한다고 해 봅시다. 작업마다 start=와 end=를 출력하는 줄을 복사해 넣으면, 로그 형식을 바꿀 때 복사한 곳을 모두 찾아 고쳐야 합니다. 한 곳이라도 빠뜨리면 형식이 서로 달라집니다. 작업마다 다른 것은 가운데 동작뿐이니, 그 부분만 바꿔 끼울 수 있으면 됩니다.

방법은 가운데 동작을 객체로 받는 것입니다. 실행기 메서드 execute가 시작과 끝 출력을 맡고, 가운데에서 할 일은 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

execute는 넘겨받은 객체가 주사위인지 합계인지 모릅니다. 아는 것은 Process에 run()이 있다는 사실뿐이므로, 시작을 출력하고 run()을 부른 뒤 끝을 출력하는 일만 합니다. 출력의 첫 세 줄은 execute("dice", dice)에서 나왔습니다. start=dice와 end=dice는 execute가 출력했고, 가운데의 dice=5는 dice 객체의 run()이 출력했습니다. 다음 세 줄은 같은 과정을 sum 객체로 되풀이한 결과입니다.

주사위 값이 실행할 때마다 5로 같은 이유는 new Random(7)로 시드를 고정했기 때문입니다. 10장에서 본 것처럼 같은 시드로 만든 Random은 매번 같은 순서로 수를 내놓습니다. 또 dice 익명 객체에 random 필드가 있다는 점도 눈여겨보세요. 익명 클래스도 클래스이므로 필드를 가질 수 있고, 그 필드는 객체가 살아 있는 동안 값을 유지합니다. 이 사실은 뒤에서 상태가 쌓이는 문제로 다시 이어집니다.

이제 로그 형식을 바꾸려면 execute 한 곳만 고치면 됩니다. 새 작업을 추가할 때도 execute는 그대로 두고 Process 구현을 하나 더 만들면 됩니다. 8장의 전략 패턴과 같은 구조로, 바뀌지 않는 흐름과 바뀌는 동작을 서로 다른 곳에 둔 것입니다.

다만 이 execute에는 빈틈이 하나 있습니다. end=를 출력하는 줄은 run() 다음에 오는 평범한 문장이라서, run()이 예외를 던지면 실행되지 않습니다. 예외가 나도 끝 로그를 남겨야 한다면 그 처리도 execute에 두어야 합니다. 각 Process 구현에 맡기면 다시 복사가 시작되기 때문입니다. 이 빈틈은 글 끝의 연습 문제에서 try/finally로 메워 봅니다.

인수 자리에서 바로 만들어 넘기기

익명 객체를 꼭 변수에 담을 필요는 없습니다. 메서드를 부르는 괄호 안에서 바로 만들어 넘길 수도 있습니다. 구현이 짧고 다른 곳에서 다시 쓰지 않는다면, 무엇을 넘기는지가 호출하는 자리에서 바로 보이므로 읽기가 편합니다.

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

    private static void execute(BoardCommand command) {
        System.out.println("before");
        PostResult result = command.run();
        System.out.println("saved=" + result.title() + ":" + result.viewCount());
        System.out.println("after");
    }

    private interface BoardCommand { PostResult run(); }
    private record PostResult(String title, int viewCount) { }
}
실행 결과
before
saved=nested:50
after

main은 execute의 괄호 안에서 BoardCommand를 구현한 익명 객체를 만들어 곧바로 넘깁니다. 여기서 실행 순서를 정확히 짚어 두어야 합니다. 인수를 계산하는 시점에 만들어지는 것은 객체뿐이고, run()의 본문은 아직 실행되지 않습니다. 그래서 execute가 먼저 before를 출력하고, command.run()을 부르는 순간에야 익명 객체의 run()이 PostResult를 만들어 돌려줍니다. saved=nested:50은 execute가 그 결과를 받아 출력한 줄이고, 마지막으로 after가 출력됩니다. 즉 넘긴 것은 실행 결과가 아니라 '나중에 실행할 동작을 가진 객체'이고, 그 동작을 언제 실행할지는 받는 쪽이 정합니다.

BoardCommand처럼 추상 메서드가 하나뿐인 인터페이스를 함수형 인터페이스(functional interface)라고 합니다. 함수형 인터페이스를 구현할 때는 익명 클래스 대신 람다식(lambda expression)을 쓸 수 있습니다. 위의 호출은 execute(() -> new PostResult("nested", 50)); 한 줄로 줄어들고, 출력은 똑같습니다. 람다는 30장에서 자세히 배웁니다.

그래도 익명 클래스가 필요한 경우가 있습니다. 구현할 추상 메서드가 둘 이상일 때, 객체가 자기 필드를 가져야 할 때, 추상 클래스를 상속해야 할 때, 그리고 본문 안의 this가 그 객체 자신을 가리켜야 할 때입니다. 람다는 이 가운데 어느 것도 할 수 없습니다. 마지막의 this 차이는 뒤에서 따로 살펴봅니다.

같은 익명 객체를 다시 쓰면 상태가 이어진다

익명 클래스는 이름만 없을 뿐 보통 클래스와 똑같이 동작합니다. 필드를 가질 수 있고, 그 필드는 객체가 살아 있는 동안 값을 유지합니다. '익명'이라는 말이 한 번 쓰고 사라진다거나 상태가 없다는 뜻은 아닙니다. 이 점을 놓치면 다음과 같은 버그가 생깁니다.

다음 프로그램을 작성한 사람은 execute를 부를 때마다 실행이 새로 시작되어 count=1이 출력되기를 기대했습니다.

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 Process() { ... } 식은 main에서 한 번만 실행되므로 객체도 하나뿐이고, once는 그 객체를 가리킵니다. execute(once)를 두 번 부르면 같은 객체의 run()이 두 번 실행됩니다. execute는 run()을 부르기만 할 뿐, 객체를 새로 만들거나 필드를 0으로 되돌리지 않습니다. 그래서 count는 첫 호출에서 0에서 1이 되고, 둘째 호출에서는 그 값을 이어받아 1에서 2가 됩니다. 변수 이름을 once라고 지었다고 해서 객체가 한 번만 쓰이는 것도 아닙니다.

같은 익명 객체에서 이어지는 count

같은 익명 객체를 재사용하며 count가 0→1→2로 바뀌고 두 출력이 누적됩니다. 새 객체 생성 없이 같은 대상의 상태를 넘겨 보는 대표적 사례입니다.

같은 익명 객체에서 이어지는 count

AnonymousStateReuseBug는 익명 Process를 한 번 생성하고 execute에 같은 once 참조를 두 번 전달한다. 한 객체의 count가 0에서 1, 다시 2로 증가한다.

익명 객체 · 재사용된 상태

같은 익명 객체에서 이어지는 count

익명 Process는 한 번만 생성됩니다. 같은 객체를 두 번 호출하므로 count는 0에서 1, 다시 2로 이어집니다.

같은 익명 객체의0에서1,2로 이어지는 상태 상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은1,2입니다. 같은 once 객체의 변화 한 번 생성 count=0 이 단계 출력 없음 첫 execute count: 0→1 출력: count=1 둘째 execute count: 1→2 출력: count=2 같은 객체의 시간별 상태 · execute는 run만 호출 · 새 객체 생성이나 필드 초기화 없음
같은 익명 객체의0에서1,2로 이어지는 상태 상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은1,2입니다. 같은 once 객체의 변화 한 번 생성 count=0 이 단계 출력 없음 첫 execute count: 0→1 출력: count=1 둘째 execute count: 1→2 출력: count=2 상자는 같은 객체의 시간별 상태 execute는 run만 호출합니다 객체 재생성·필드 초기화 없음
  1. 한 번 생성

    같은 객체의 count
    int 필드의 초기값 0
    실제 출력
    출력 없음
  2. 첫 execute

    같은 객체의 count
    run()의 count++: 0 → 1
    실제 출력
    count=1
  3. 둘째 execute

    같은 객체의 count
    이전 값을 이어서 count++: 1 → 2
    실제 출력
    count=2

execute는 process.run()만 호출하며 객체를 새로 만들거나 필드를 초기화하지 않습니다. 독립 실행을 원한다면 생성 지점이나 상태 위치를 바꿔야 합니다. 원문은 그 수정안을 실행한 예제가 아닙니다.

이 예의 조건

  • once 익명 객체는 한 번 생성하고 재사용한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
1같은 익명 객체의 0에서 1,2로 이어지는 상태
2상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은 1,2입니다.
원 도표의 설명과 주석
구분내용
설명상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은 1,2입니다.
전체 읽기 정보같은 익명 객체에서 이어지는 count 같은 익명 객체에서 이어지는 count AnonymousStateReuseBug는 익명 Process를 한 번 생성하고 execute에 같은 once 참조를 두 번 전달한다. 한 객체의 count가 0에서 1, 다시 2로 증가한다. 익명 객체 · 재사용된 상태 같은 익명 객체에서 이어지는 count 익명 Process는 한 번만 생성됩니다. 같은 객체를 두 번 호출하므로 count는 0에서 1, 다시 2로 이어집니다. 한 번 생성 같은 객체의 count int 필드의 초깃값 0 실제 출력 출력 없음 첫 execute 같은 객체의 count run() 의 count++ : 0 → 1 실제 출력 count=1 둘째 execute 같은 객체의 count 이전 값을 이어서 count++ : 1 → 2 실제 출력 count=2 execute 는 process.run() 만 호출하며 객체를 새로 만들거나 필드를 초기화하지 않습니다. 독립 실행을 원한다면 생성 지점이나 상태 위치를 바꿔야 합니다. 원문은 그 수정안을 실행한 예제가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
객체 생성

count 0으로 once를 만듭니다.

  • count: 0
  • 출력: 없음
  • execute 호출이 객체 초기화를 반복하지 않는다.
첫 execute

같은 run이 count를 늘립니다.

  • count: 1
  • 동일 인스턴스의 필드가 첫 호출 결과를 보관한다.
둘째 execute

같은 객체를 다시 호출합니다.

  • count: 2
  • 필드가 0으로 돌아가지 않아 누적 상태가 이어진다.

같은 익명 객체에서 이어지는 count

같은 익명 객체를 재사용하며 count가 0→1→2로 바뀌고 두 출력이 누적됩니다. 새 객체 생성 없이 같은 대상의 상태를 넘겨 보는 대표적 사례입니다.

같은 익명 객체에서 이어지는 count

AnonymousStateReuseBug는 익명 Process를 한 번 생성하고 execute에 같은 once 참조를 두 번 전달한다. 한 객체의 count가 0에서 1, 다시 2로 증가한다.

익명 객체 · 재사용된 상태

같은 익명 객체에서 이어지는 count

익명 Process는 한 번만 생성됩니다. 같은 객체를 두 번 호출하므로 count는 0에서 1, 다시 2로 이어집니다.

같은 익명 객체의0에서1,2로 이어지는 상태 상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은1,2입니다. 같은 once 객체의 변화 한 번 생성 count=0 이 단계 출력 없음 첫 execute count: 0→1 출력: count=1 둘째 execute count: 1→2 출력: count=2 같은 객체의 시간별 상태 · execute는 run만 호출 · 새 객체 생성이나 필드 초기화 없음
같은 익명 객체의0에서1,2로 이어지는 상태 상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은1,2입니다. 같은 once 객체의 변화 한 번 생성 count=0 이 단계 출력 없음 첫 execute count: 0→1 출력: count=1 둘째 execute count: 1→2 출력: count=2 상자는 같은 객체의 시간별 상태 execute는 run만 호출합니다 객체 재생성·필드 초기화 없음
  1. 한 번 생성

    같은 객체의 count
    int 필드의 초기값 0
    실제 출력
    출력 없음
  2. 첫 execute

    같은 객체의 count
    run()의 count++: 0 → 1
    실제 출력
    count=1
  3. 둘째 execute

    같은 객체의 count
    이전 값을 이어서 count++: 1 → 2
    실제 출력
    count=2

execute는 process.run()만 호출하며 객체를 새로 만들거나 필드를 초기화하지 않습니다. 독립 실행을 원한다면 생성 지점이나 상태 위치를 바꿔야 합니다. 원문은 그 수정안을 실행한 예제가 아닙니다.

이 예의 조건

  • once 익명 객체는 한 번 생성하고 재사용한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
1같은 익명 객체의 0에서 1,2로 이어지는 상태
2상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은 1,2입니다.
원 도표의 설명과 주석
구분내용
설명상자는 새 객체 세 개가 아니라 같은 once 객체의 시간별 상태입니다. execute는 같은 process.run만 호출하며 객체나 필드를 다시 초기화하지 않습니다. 생성 때 출력은 없고 두 호출의 count 출력은 1,2입니다.
전체 읽기 정보같은 익명 객체에서 이어지는 count 같은 익명 객체에서 이어지는 count AnonymousStateReuseBug는 익명 Process를 한 번 생성하고 execute에 같은 once 참조를 두 번 전달한다. 한 객체의 count가 0에서 1, 다시 2로 증가한다. 익명 객체 · 재사용된 상태 같은 익명 객체에서 이어지는 count 익명 Process는 한 번만 생성됩니다. 같은 객체를 두 번 호출하므로 count는 0에서 1, 다시 2로 이어집니다. 한 번 생성 같은 객체의 count int 필드의 초깃값 0 실제 출력 출력 없음 첫 execute 같은 객체의 count run() 의 count++ : 0 → 1 실제 출력 count=1 둘째 execute 같은 객체의 count 이전 값을 이어서 count++ : 1 → 2 실제 출력 count=2 execute 는 process.run() 만 호출하며 객체를 새로 만들거나 필드를 초기화하지 않습니다. 독립 실행을 원한다면 생성 지점이나 상태 위치를 바꿔야 합니다. 원문은 그 수정안을 실행한 예제가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
객체 생성

count 0으로 once를 만듭니다.

  • count: 0
  • 출력: 없음
  • execute 호출이 객체 초기화를 반복하지 않는다.
첫 execute

같은 run이 count를 늘립니다.

  • count: 1
  • 동일 인스턴스의 필드가 첫 호출 결과를 보관한다.
둘째 execute

같은 객체를 다시 호출합니다.

  • count: 2
  • 필드가 0으로 돌아가지 않아 누적 상태가 이어진다.

실행마다 독립된 상태가 필요하다면 실행마다 새 객체를 만들어야 합니다. 익명 객체를 만들어 돌려주는 팩터리 메서드 create()를 두면 됩니다. 다음은 앞의 프로그램과 따로 실행하는 비교용 예제입니다.

src/AnonymousFreshProcess.java
public final class AnonymousFreshProcess {
    public static void main(String[] args) {
        execute(create());
        execute(create());
        Process reused = create();
        execute(reused);
        execute(reused);
    }

    private static Process create() {
        return new Process() {
            private int count;
            public void run() { System.out.println("count=" + ++count); }
        };
    }

    private static void execute(Process process) { process.run(); }
    private interface Process { void run(); }
}
실행 결과
count=1
count=1
count=1
count=2

create()를 부를 때마다 그 안의 new Process() { ... } 식이 다시 실행되어, count가 0인 새 객체가 만들어집니다. 처음 두 줄은 execute(create())가 매번 새 객체를 받았기 때문에 둘 다 1입니다. 나머지 두 줄은 create()를 한 번 불러 얻은 reused를 두 번 넘겼으므로 1과 2로 이어집니다. 여기서 execute(reused)를 한 번 더 부르면 3이 나오고, 그 뒤에 execute(create())를 부르면 새 객체이므로 다시 1이 나옵니다.

익명 클래스와 익명 객체를 구별하면 이 결과가 자연스럽게 이해됩니다. 익명 클래스는 컴파일할 때 하나 만들어지고, 객체는 그 new 식이 실행될 때마다 하나씩 만들어집니다. create()를 두 번 불러 얻은 두 객체는 getClass()로 보면 같은 클래스이지만, ==로 비교하면 서로 다른 객체입니다. 상태가 이어지는지는 익명이냐 아니냐가 아니라, 객체를 어디서 몇 번 만드느냐로 정해집니다.

다른 방법도 있습니다. 한 번의 실행 안에서만 필요한 값이라면 필드 대신 run() 안의 지역 변수로 두면 됩니다. 지역 변수는 호출이 끝나면 사라지므로 다음 호출로 넘어가지 않습니다. 반대로 실행 횟수를 세는 것이 정말 목적이라면, 익명 클래스 안에 숨기기보다 CountingProcess 같은 이름 있는 클래스를 만들고 count()처럼 상태를 읽는 메서드를 드러내는 편이 의도를 알아보기 쉽습니다. 어느 쪽이든 같은 동작을 매번 새로 만들지, 한 객체를 일부러 다시 쓸지가 객체를 만드는 코드에서 보여야 합니다.

익명 클래스 안의 this는 익명 객체를 가리킨다

this는 지금 실행 중인 인스턴스 메서드를 호출받은 객체를 가리킵니다. 익명 클래스의 메서드가 실행될 때 호출받은 객체는 익명 객체이므로, 익명 클래스 본문의 this는 익명 객체입니다. 익명 클래스를 감싼 바깥 객체가 아닙니다. 바깥 객체를 가리키려면 내부 클래스에서 본 것처럼 AnonymousAndLambdaThis.this와 같이 바깥 클래스 이름을 앞에 붙여야 합니다. 람다는 다릅니다. 람다는 새로운 this를 만들지 않으므로, 람다 본문의 this는 람다 바깥에서의 this와 같습니다.

세 가지를 한 프로그램에서 비교해 보겠습니다. buildAndRun은 인스턴스 메서드이므로, 그 안에서 만든 익명 객체와 람다에는 바깥 객체가 있습니다. 바깥 객체의 name은 owner이고, 익명 클래스는 같은 이름의 필드 name을 따로 가집니다.

src/AnonymousAndLambdaThis.java
public final class AnonymousAndLambdaThis {
    private final String name = "owner";

    public static void main(String[] args) {
        new AnonymousAndLambdaThis().buildAndRun();
    }

    private void buildAndRun() {
        Command anonymous = new Command() {
            private final String name = "anonymous";
            public String run() {
                return "this=" + this.name
                        + ", outer=" + AnonymousAndLambdaThis.this.name;
            }
        };
        Command lambda = () -> "lambda=" + this.name;
        System.out.println(anonymous.run());
        System.out.println(lambda.run());
    }

    private interface Command { String run(); }
}
실행 결과
this=anonymous, outer=owner
lambda=owner

익명 클래스의 run()에서 this.name은 익명 객체 자신의 필드이므로 anonymous이고, AnonymousAndLambdaThis.this.name은 바깥 객체의 필드이므로 owner입니다. 람다의 this.name은 람다 바깥, 즉 buildAndRun을 실행하고 있는 객체의 필드이므로 owner입니다. 같은 Command 역할을 하는 두 구현이 this.name이라는 같은 글자로 서로 다른 필드를 읽은 것입니다. 람다 본문의 this와 이름이 주변 문맥과 똑같이 해석된다는 규칙은 JLS 15.27.2에 정해져 있습니다.

이 차이를 놓치면 엉뚱한 값이 나옵니다. 익명 클래스 안에서 바깥 객체를 로그에 남기려고 this를 출력하면, 바깥 객체 대신 AnonymousAndLambdaThis$1@ 뒤에 16진수가 붙은 익명 객체의 기본 문자열이 찍힙니다. 또 이 예제처럼 익명 클래스에 바깥과 같은 이름의 필드가 있으면, this를 빼고 name만 써도 익명 객체의 필드가 읽힙니다. 가까운 범위의 이름이 바깥 이름을 가리기 때문입니다.

바깥 객체가 아예 없는 곳도 있습니다. static 메서드 안에서 만든 익명 클래스에는 연결된 바깥 객체가 없습니다. 다음은 static인 main에서 바깥 객체의 필드를 읽으려는 코드입니다.

lab/StaticContextOuterThis.java
public final class StaticContextOuterThis {
    private final String name = "owner";

    public static void main(String[] args) {
        Printer printer = new Printer() {
            @Override
            public void print() {
                System.out.println(StaticContextOuterThis.this.name);
            }
        };
        printer.print();
    }

    private interface Printer { void print(); }
}
컴파일 오류
lab/StaticContextOuterThis.java:8: error: non-static variable this cannot be referenced from a static context
                System.out.println(StaticContextOuterThis.this.name);
                                                         ^
1 error

static 메서드는 객체 없이 실행되므로 StaticContextOuterThis.this가 가리킬 객체가 없습니다. 같은 자리에서 람다 본문에 this를 써도 똑같은 오류가 납니다. 람다는 바깥의 this를 그대로 쓰는데, 바깥에 this가 없기 때문입니다. 반면 지역 변수 캡처는 static 메서드 안에서도 됩니다. 앞에서 본 AnonymousPostPrinter가 static인 main의 prefix를 읽은 것이 그 예입니다.

익명 클래스의 this와 바깥 문맥

익명 클래스 인스턴스 메서드의 this는 익명 객체다. 바깥 인스턴스 연결 여부는 선언 문맥에 달려 있으며 람다는 새 this를 도입하지 않는다. 객체 할당 여부와 this 의미는 별개다.

this 계약 · 주변 문맥

익명 클래스의 this와 바깥 문맥

익명 인스턴스 메서드의 this는 익명 객체입니다. 람다는 주변 this 의미를 사용하며, static main에는 바깥 this가 없습니다.

추가 인스턴스 예제에서 익명 this와 람다 this 본문의 추가 AnonymousAndLambdaThis 인스턴스 메서드에서 익명 this는 anonymous 필드를, 명시적 바깥 this와 람다는 owner 필드를 읽습니다. 점선은 바깥 이름 접근이고 실선은 반환 결과입니다. 원문 static main에는 해당 바깥 this가 없다는 문맥 차이를 유지합니다. AnonymousAndLambdaThis 추가 코드 · 인스턴스 메서드의 문맥 바깥 인스턴스 name=owner buildAndRun() 익명 Command lambda 본문 익명 객체의 this name=anonymous this.name → anonymous 바깥 한정 this → owner 주변 this 사용 this.name → owner 새 this 없음 익명 run의 반환 this=anonymous, outer=owner lambda의 반환 lambda=owner 점선은 바깥 이름 접근 · 실선은 반환값 · 원문의 static main에는 바깥 this 없음
추가 인스턴스 예제에서 익명 this와 람다 this 본문의 추가 AnonymousAndLambdaThis 인스턴스 메서드에서 익명 this는 anonymous 필드를, 명시적 바깥 this와 람다는 owner 필드를 읽습니다. 점선은 바깥 이름 접근이고 실선은 반환 결과입니다. 원문 static main에는 해당 바깥 this가 없다는 문맥 차이를 유지합니다. 추가 코드의 바깥 인스턴스 바깥 인스턴스 name=owner buildAndRun() 익명 Command 익명 객체의 this name=anonymous this.name → anonymous 바깥 한정 this → owner 익명 run의 반환 this=anonymous, outer=owner lambda 본문 주변 this 사용 this.name → owner 새 this 없음 lambda의 반환 lambda=owner 점선은 바깥 이름 접근 실선은 run의 반환 결과 추가 인스턴스 메서드 예제 원문의 static main에는 바깥 this가 없습니다
익명 클래스의 this와 바깥 문맥
표현·문맥this 또는 연결의 의미원문에 적용할 경계
익명 인스턴스 메서드this는 그 메서드가 호출된 익명 객체를 가리킵니다.자체 필드를 가질 수 있으며 같은 객체를 재사용하면 그 상태가 이어집니다.
바깥 Owner.this해당 바깥 인스턴스와 연결된 문맥에서만 그 인스턴스를 가리킬 수 있습니다.원문의 static main 안에서 선언된 익명 클래스에는 해당 바깥 인스턴스 연결이 없습니다. 지역 값 사용은 가능합니다.
람다 본문새 this를 도입하지 않고 주변 문맥의 의미를 사용합니다.주변이 static 문맥이면 사용할 바깥 this도 없습니다.
익명 인스턴스 메서드
this 또는 연결의 의미
this는 그 메서드가 호출된 익명 객체를 가리킵니다.
원문에 적용할 경계
자체 필드를 가질 수 있으며 같은 객체를 재사용하면 그 상태가 이어집니다.
바깥 Owner.this
this 또는 연결의 의미
해당 바깥 인스턴스와 연결된 문맥에서만 그 인스턴스를 가리킬 수 있습니다.
원문에 적용할 경계
원문의 static main 안에서 선언된 익명 클래스에는 해당 바깥 인스턴스 연결이 없습니다. 지역 값 사용은 가능합니다.
람다 본문
this 또는 연결의 의미
새 this를 도입하지 않고 주변 문맥의 의미를 사용합니다.
원문에 적용할 경계
주변이 static 문맥이면 사용할 바깥 this도 없습니다.

AnonymousPostPrinter는 static main의 지역 값 prefix를 사용해 daily:anonymous=40을 출력하고 익명 클래스 여부는 type=true입니다. 람다 평가는 새 객체를 만들 수도, 기존 객체를 참조할 수도 있으므로 “할당 없음”으로 일반화하지 않습니다.

더 깊이: 람다를 평가하면 객체가 생길까

람다가 새 this를 만들지 않는다고 해서, 람다를 평가할 때 객체가 생기지 않는다는 뜻은 아닙니다. 람다식의 값도 함수형 인터페이스를 구현한 객체입니다. 다만 JLS 15.27.4는 람다식을 평가할 때 새 객체를 만들 수도 있고 이미 있는 객체를 다시 돌려줄 수도 있다고 정해 둡니다. 이 글에서 사용한 JDK 21에서는 아무것도 캡처하지 않는 람다를 반복문에서 두 번 평가하면 같은 객체가 나왔고, 지역 변수를 캡처하는 람다는 평가할 때마다 다른 객체가 나왔습니다.

익명 클래스의 new 식은 실행될 때마다 반드시 새 객체를 만든다는 점과 다릅니다. 따라서 람다로 만든 객체를 ==로 비교하는 것처럼, 평가할 때마다 새 객체가 생기는지에 기대는 코드는 쓰지 않는 것이 좋습니다. 어느 쪽이 나올지는 언어 규칙이 아니라 JDK 구현이 정하기 때문입니다.

익명 객체가 오래 살아남을 때

익명 객체도 보통 객체처럼 참조가 남아 있는 동안 살아 있습니다. 메서드가 익명 객체를 반환하거나 필드, 컬렉션에 저장하면 그 객체는 자신을 만든 메서드가 끝난 뒤에도 계속 쓰일 수 있습니다. 이때 함께 붙잡혀 있는 것들이 있습니다. 캡처한 지역 변수의 값은 객체 안에 복사되어 있습니다. 그리고 인스턴스 메서드 안에서 만들어져 바깥 객체의 필드나 메서드를 쓰는 익명 객체는 그 바깥 객체를 가리키는 참조를 품고 있습니다.

문제는 바깥 객체가 클 때 생깁니다. 화면 하나를 나타내는 큰 객체가 자기 필드를 읽는 이벤트 처리기를 익명 클래스로 만들어, 프로그램 내내 살아 있는 목록에 등록했다고 해 봅시다. 화면을 닫아도 목록에 남은 처리기가 화면 객체를 가리키고 있으므로, 화면 객체와 그 객체가 참조하는 데이터는 가비지 컬렉션 대상이 되지 않습니다. 이런 일을 피하려면 익명 클래스가 바깥 객체 전체 대신 필요한 작은 값만 쓰게 하는 것이 좋습니다. 필요한 값을 지역 변수에 복사해 두고 그 지역 변수만 캡처하거나, 오래 보관할 구현은 바깥 객체와 연결되지 않는 정적 중첩 클래스나 최상위 클래스로 옮기면 됩니다.

더 깊이: 익명 클래스가 바깥 객체를 붙잡는 조건

언어 규칙으로 보면 인스턴스 메서드처럼 바깥 객체가 있는 문맥에서 선언한 익명 클래스는 그 바깥 객체와 연결됩니다. 이 연결을 객체 안에 어떻게 저장할지는 컴파일러가 정합니다. javac는 이런 내부 클래스에 바깥 객체를 가리키는 this$0 같은 합성 필드를 늘 만들어 왔는데, 바깥 객체를 전혀 쓰지 않는 클래스에서는 이 필드를 빼도록 바뀌었습니다(JDK-8271623).

이 글에서 사용한 JDK 21의 javac로 확인해 보면, 바깥 객체의 일반 필드를 읽는 익명 클래스에는 this$0 필드가 있고 바깥 객체를 쓰지 않는 익명 클래스에는 없습니다. 바깥 필드라도 컴파일 시점 상수라면 값이 코드에 직접 들어가므로 필드가 생기지 않습니다. 단, Serializable을 구현한 익명 클래스는 바깥 객체를 쓰지 않아도 이 필드가 남아 있었습니다. 이것은 언어 규칙이 아니라 컴파일러의 최적화입니다. 메모리 문제를 이 동작에 맡기기보다, 바깥 객체를 쓰지 않는 구조를 코드로 드러내는 편이 안전합니다.

익명 클래스의 이름은 컴파일러가 붙인다

익명 클래스도 컴파일하면 클래스 파일이 생깁니다. 앞의 ProcessTemplateRefactoring.java를 컴파일하면 ProcessTemplateRefactoring.class, ProcessTemplateRefactoring$Process.class와 함께 ProcessTemplateRefactoring$1.class, ProcessTemplateRefactoring$2.class가 생깁니다. $1은 dice의 익명 클래스이고 $2는 sum의 익명 클래스입니다.

JLS 13.1은 익명 클래스의 이진 이름(binary name)을 '바깥 클래스의 이진 이름 + $ + 숫자' 형식으로만 정하고, 어떤 숫자를 붙일지는 컴파일러가 정합니다. 이 예제에서 javac는 소스에 나온 순서대로 1과 2를 붙였고, dice 앞에 익명 클래스를 하나 더 추가하면 dice의 클래스 이름이 $1에서 $2로 바뀝니다. 한편 getClass().getSimpleName()은 익명 클래스에 대해 빈 문자열을 돌려줍니다.

이 이름은 예외의 스택 트레이스에도 그대로 나옵니다. dice의 run()에서 예외가 났다면 ProcessTemplateRefactoring$1.run처럼 숫자로 된 클래스 이름이 찍힙니다. 장애 로그에서 어떤 구현이 실패했는지 이름으로 알아봐야 한다면 이름 있는 클래스가 낫습니다. 또 이 이름은 코드 위치에 따라 바뀌므로 설정 파일이나 저장된 데이터, 리플렉션 조건문에 적어 두면 안 됩니다. 소스 어딘가에 익명 클래스 하나를 추가하는 것만으로 같은 이름이 다른 클래스를 가리키게 됩니다. 프로그램 바깥에 알려야 하는 것은 인터페이스 이름이나 명시적인 데이터로 정하고, 익명 클래스는 프로그램 안에서만 쓰는 일회성 구현으로 두세요.

지역 클래스, 익명 클래스, 람다 중에서 고르기

지금까지 본 세 가지 형태와 메서드 바깥에 선언하는 클래스를 상황별로 정리하면 다음과 같습니다.

상황고를 형태이유
함수형 인터페이스의 동작 하나만 넘긴다람다가장 짧고, this가 바깥과 같아 헷갈릴 일이 적다
한 번만 만들지만 필드나 여러 메서드가 필요하다익명 클래스선언과 생성이 한자리에 있어 쓰는 곳 가까이 둘 수 있다
한 메서드 안의 여러 곳에서 같은 구현의 객체를 만든다지역 클래스이름이 있어 원하는 곳에서 new로 만들 수 있다
여러 메서드나 클래스에서 다시 쓰고 따로 테스트한다멤버 클래스나 최상위 클래스메서드 밖으로 나와야 다른 곳에서 쓸 수 있다

줄 수만 보고 고르지는 않습니다. 호출하는 자리에서 바로 쓰이고, 쌓이는 상태가 없으며, 메서드 바깥으로 새어 나가지 않는 짧은 동작이라면 익명 클래스나 람다가 잘 맞습니다. 반대로 객체가 상태를 쌓아 가거나, 만든 메서드보다 오래 살아남거나, 장애 로그에서 이름으로 찾아야 하거나, 따로 테스트해야 한다면 이름 있는 클래스로 옮기는 것이 좋습니다. 익명 클래스 본문이 수십 줄로 길어져 호출하는 코드의 흐름이 끊길 때도 마찬가지입니다. 이름을 붙이면 그 구현이 무슨 일을 하는지를 이름이 먼저 설명해 줍니다.

정리

  • 익명 클래스는 new Printer() { ... }처럼 식 하나로 이름 없는 클래스를 선언하고 그 객체를 하나 만듭니다. 인터페이스 하나를 구현하거나 클래스 하나를 상속합니다.
  • 이름이 없어 생성자를 선언할 수 없고, 선언한 그 식에서만 객체를 만들 수 있습니다. 바깥 지역 변수는 지역 클래스와 같은 규칙으로 캡처합니다.
  • 공통 흐름은 실행기 메서드에 두고 달라지는 동작만 객체로 넘기면, 공통 부분을 한 곳에서 고칠 수 있습니다. 넘긴 동작이 언제 실행될지는 받는 쪽이 정합니다.
  • 익명 객체도 필드 값을 유지합니다. 같은 객체를 다시 넘기면 상태가 이어지므로, 실행마다 새 상태가 필요하면 실행마다 새 객체를 만들어야 합니다.
  • 익명 클래스 본문의 this는 익명 객체이고, 바깥 객체는 바깥클래스.this로 가리킵니다. 람다는 새 this를 만들지 않으며, static 문맥에는 가리킬 바깥 객체가 없습니다.
  • 익명 객체를 오래 보관하면 그 객체가 쓰는 바깥 객체도 함께 살아남을 수 있습니다. $1 같은 클래스 이름은 코드 위치에 따라 바뀌므로 바깥에 기록하지 않습니다.

연습 문제

  1. 다음 프로그램의 출력을 먼저 예상한 뒤 실행해 확인하세요. create()와 execute는 앞의 AnonymousFreshProcess와 같습니다.
src/SharedAndFresh.java
public final class SharedAndFresh {
    public static void main(String[] args) {
        Process shared = create();
        for (int round = 0; round < 3; round++) {
            execute(shared);
            execute(create());
        }
    }

    private static Process create() {
        return new Process() {
            private int count;
            public void run() { System.out.println("count=" + ++count); }
        };
    }

    private static void execute(Process process) { process.run(); }
    private interface Process { void run(); }
}
해설 보기
실행 결과
count=1
count=1
count=2
count=1
count=3
count=1

shared는 반복문에 들어가기 전에 한 번 만든 객체이므로, 반복할 때마다 같은 객체의 count가 1, 2, 3으로 늘어납니다. 반면 execute(create())는 반복할 때마다 create()를 새로 불러 count가 0인 객체를 받으므로 언제나 1을 출력합니다. 그래서 홀수 번째 줄은 1, 2, 3으로 늘어나고 짝수 번째 줄은 모두 1입니다. 익명 클래스는 하나지만 객체는 shared 하나와 반복마다 만든 세 개를 합쳐 네 개입니다.

  1. Process 실행 중에 IllegalStateException이 발생해도 end가 출력되도록 execute를 작성하세요. 조건은 다음과 같습니다.

    • Process.run()은 아무것도 출력하지 않고 IllegalStateException("board failed")를 던집니다.
    • execute는 시작할 때 start를, 끝날 때 end를 출력합니다.
    • main은 예외를 잡아 caught= 뒤에 예외 메시지를 붙여 출력합니다.

    다 작성했다면 run()이 예외를 던지는 대신 body를 출력하고 정상으로 끝나도록 바꿔 보세요. 세 줄이 어떤 순서로 나오는지, main의 catch가 실행되는지 예상해 보세요.

해설 보기
src/AnonymousFinallyExercise.java
public final class AnonymousFinallyExercise {
    public static void main(String[] args) {
        try {
            execute(new Process() {
                public void run() { throw new IllegalStateException("board 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=board failed

finally 블록은 try 블록이 정상으로 끝나든 예외로 중단되든 실행됩니다. run()이 예외를 던지면 try 블록은 그 자리에서 멈추고, finally가 end를 출력합니다. 예외를 던지는 동작 자체는 아무것도 출력하지 않습니다. finally는 예외를 처리하거나 없애지 않으므로, finally가 정상으로 끝나면 원래 예외가 그대로 execute 바깥으로 전달됩니다. 그 예외를 main의 catch가 잡기 때문에 end가 caught=board failed보다 먼저 나옵니다. 이 흐름은 JLS 14.20.2에서 finally가 정상으로 끝나는 경우에 해당합니다.

run()이 body를 출력하고 정상으로 끝나도록 바꾸면 출력은 start, body, end 순서이고 catch는 실행되지 않습니다. finally가 실행됐다고 해서 예외가 났다는 뜻은 아닙니다. finally 안에서 다시 예외를 던지거나 return하면 원래 예외가 사라지는 등 흐름이 달라지는데, 이런 경우는 12장의 try·catch·finally 실행 순서와 정리 코드에서 다룹니다.

아래 도표는 본문의 ProcessTemplateRefactoring에서 평범한 문장으로 출력한 end=dice와, 이 연습에서 finally로 출력한 end가 각각 어떤 경로로 실행되는지 나란히 보여 줍니다. 앞의 것은 run()이 정상으로 끝났기 때문에 실행됐을 뿐이고, 예외가 났다면 건너뛰었을 문장입니다.

일반 종료 문장과 finally의 실제 실행 순서

정상 callback과 별도 예외/finally의 실제 출력 순서를 대비합니다. run의 던짐→finally 정상 완료→main catch 순서를 단계화하면 정리와 복구의 차이가 드러납니다.

일반 종료 문장과 finally의 실제 실행 순서

ProcessTemplateRefactoring은 정상 run 뒤에 일반 end 문장을 실행한다. AnonymousFinallyExercise는 run이 던진 예외 뒤에 finally의 end를 실행하고 원래 예외를 main이 잡는다.

콜백 처리 · 정상과 예외 경로

일반 종료 문장과 finally의 실제 실행 순서

서로 다른 execute 구현을 비교합니다. 정상 예제의 후속 end와 예외 예제의 finally는 보장하는 경로가 다릅니다.

정상 콜백과 finally 예제의 제어 흐름 각 영역은 별도 프로그램입니다. 정상 원문은 dice의 일반 end 뒤 sum을 실행합니다. 예외 원문은 run에서 예외를 던지고 finally가 정상 완료한 다음 원래 예외를 main이 잡습니다. 던지는 동작 자체는 stdout이 아니며 finally 자체의 비정상 완료까지 일반화하지 않습니다. 정상 원문 execute 진입 start=dice run() dice=5 일반 후속 문장 end=dice 이후 sum 호출 start=sum sum=6 end=sum 일반 end는 예외 때 보장 안 됨 정상 원문은 dice 뒤 sum 실행 별도 finally 예제 execute 진입 start run()에서 던짐 IllegalStateException board failed 던질 때 출력 없음 finally 정상 완료 end 출력 원래 예외 유지 main의 catch caught=board failed finally가 정상 완료한 이 경로 catch는 finally 다음에 실행 영역마다 별도 프로그램 · 화살표는 실제 제어 흐름과 출력 순서
정상 콜백과 finally 예제의 제어 흐름 각 영역은 별도 프로그램입니다. 정상 원문은 dice의 일반 end 뒤 sum을 실행합니다. 예외 원문은 run에서 예외를 던지고 finally가 정상 완료한 다음 원래 예외를 main이 잡습니다. 던지는 동작 자체는 stdout이 아니며 finally 자체의 비정상 완료까지 일반화하지 않습니다. 정상 원문 execute 진입 start=dice run() dice=5 일반 후속 문장 end=dice 이후 sum 호출 start=sum sum=6 end=sum 일반 end는 예외 때 보장 안 됨 정상 원문은 dice 뒤 sum 실행 별도 finally 예제 execute 진입 start run()에서 던짐 IllegalStateException board failed 던질 때 출력 없음 finally 정상 완료 end 출력 원래 예외 유지 main의 catch caught=board failed finally가 정상 완료한 이 경로 catch는 finally 다음에 실행

정상 원문 · dice 먼저

실행기 진입
start=dice
run() 안
dice=5
종료 처리
end=dice

일반 후속 문장입니다.

호출자로 돌아감
다음 sum을 실행합니다.
start=sum
sum=6
end=sum

예외 원문 · finally 사용

실행기 진입
start
run() 안
IllegalStateException

메시지 "board failed"로 발생합니다. 이 동작 자체의 출력은 없습니다.

종료 처리
end

finally에서 출력하고 정상 완료합니다.

호출자로 돌아감
원래 예외가 main의 catch로 전달됩니다.

caught=board failed

정상 원문의 end는 run이 예외를 던져도 보장되는 finally가 아닙니다. 예외 원문에서는 finally가 정상 완료하므로 원래 예외가 유지됩니다. finally 자체가 비정상 완료하면 이 경로를 그대로 일반화할 수 없습니다.

이 예의 조건

  • 정상 callback과 예외/finally는 별도 프로그램이다.
  • finally가 정상 완료하는 원 경로이다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보일반 종료 문장과 finally의 실제 실행 순서 일반 종료 문장과 finally의 실제 실행 순서 ProcessTemplateRefactoring은 정상 run 뒤에 일반 end 문장을 실행한다. AnonymousFinallyExercise는 run이 던진 예외 뒤에 finally의 end를 실행하고 원래 예외를 main이 잡는다. 콜백 처리 · 정상과 예외 경로 일반 종료 문장과 finally의 실제 실행 순서 서로 다른 execute 구현을 비교합니다. 정상 예제의 후속 end와 예외 예제의 finally는 보장하는 경로가 다릅니다. 정상 원문 · dice 먼저 실행기 진입 start=dice run() 안 dice=5 종료 처리 end=dice 일반 후속 문장입니다. 호출자로 돌아감 다음 sum 을 실행합니다. start=sum sum=6 end=sum 예외 원문 · finally 사용 실행기 진입 start run() 안 IllegalStateException 메시지 "board failed" 로 발생합니다. 이 동작 자체의 출력은 없습니다. 종료 처리 end finally 에서 출력하고 정상 완료합니다. 호출자로 돌아감 원래 예외가 main의 catch로 전달됩니다. caught=board failed 정상 원문의 end는 run이 예외를 던져도 보장되는 finally가 아닙니다. 예외 원문에서는 finally가 정상 완료하므로 원래 예외가 유지됩니다. finally 자체가 비정상 완료하면 이 경로를 그대로 일반화할 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
execute 진입

현재 callback 실행 전에 시작을 출력합니다.

  • 정상: start=dice
  • 예외 예제: start
  • 공통 전처리가 callback보다 앞선다.
callback 실행

정상 dice는 5를 출력하고 별도 run은 예외를 던집니다.

  • 정상: dice=5
  • 별도 실패: IllegalStateException: board failed
  • throw 자체 stdout: 없음
  • 비정상 반환은 일반 후속 문장을 건너뛴다.
마무리와 호출자

정상은 end 뒤 sum, 예외는 finally end 뒤 catch로 갑니다.

  • 정상 후속: end=dice; start=sum,sum=6,end=sum
  • 예외 후속: end → caught=board failed
  • 정리의 정상 완료 뒤에도 원 예외가 호출자로 전달된다.

일반 종료 문장과 finally의 실제 실행 순서

정상 callback과 별도 예외/finally의 실제 출력 순서를 대비합니다. run의 던짐→finally 정상 완료→main catch 순서를 단계화하면 정리와 복구의 차이가 드러납니다.

일반 종료 문장과 finally의 실제 실행 순서

ProcessTemplateRefactoring은 정상 run 뒤에 일반 end 문장을 실행한다. AnonymousFinallyExercise는 run이 던진 예외 뒤에 finally의 end를 실행하고 원래 예외를 main이 잡는다.

콜백 처리 · 정상과 예외 경로

일반 종료 문장과 finally의 실제 실행 순서

서로 다른 execute 구현을 비교합니다. 정상 예제의 후속 end와 예외 예제의 finally는 보장하는 경로가 다릅니다.

정상 콜백과 finally 예제의 제어 흐름 각 영역은 별도 프로그램입니다. 정상 원문은 dice의 일반 end 뒤 sum을 실행합니다. 예외 원문은 run에서 예외를 던지고 finally가 정상 완료한 다음 원래 예외를 main이 잡습니다. 던지는 동작 자체는 stdout이 아니며 finally 자체의 비정상 완료까지 일반화하지 않습니다. 정상 원문 execute 진입 start=dice run() dice=5 일반 후속 문장 end=dice 이후 sum 호출 start=sum sum=6 end=sum 일반 end는 예외 때 보장 안 됨 정상 원문은 dice 뒤 sum 실행 별도 finally 예제 execute 진입 start run()에서 던짐 IllegalStateException board failed 던질 때 출력 없음 finally 정상 완료 end 출력 원래 예외 유지 main의 catch caught=board failed finally가 정상 완료한 이 경로 catch는 finally 다음에 실행 영역마다 별도 프로그램 · 화살표는 실제 제어 흐름과 출력 순서
정상 콜백과 finally 예제의 제어 흐름 각 영역은 별도 프로그램입니다. 정상 원문은 dice의 일반 end 뒤 sum을 실행합니다. 예외 원문은 run에서 예외를 던지고 finally가 정상 완료한 다음 원래 예외를 main이 잡습니다. 던지는 동작 자체는 stdout이 아니며 finally 자체의 비정상 완료까지 일반화하지 않습니다. 정상 원문 execute 진입 start=dice run() dice=5 일반 후속 문장 end=dice 이후 sum 호출 start=sum sum=6 end=sum 일반 end는 예외 때 보장 안 됨 정상 원문은 dice 뒤 sum 실행 별도 finally 예제 execute 진입 start run()에서 던짐 IllegalStateException board failed 던질 때 출력 없음 finally 정상 완료 end 출력 원래 예외 유지 main의 catch caught=board failed finally가 정상 완료한 이 경로 catch는 finally 다음에 실행

정상 원문 · dice 먼저

실행기 진입
start=dice
run() 안
dice=5
종료 처리
end=dice

일반 후속 문장입니다.

호출자로 돌아감
다음 sum을 실행합니다.
start=sum
sum=6
end=sum

예외 원문 · finally 사용

실행기 진입
start
run() 안
IllegalStateException

메시지 "board failed"로 발생합니다. 이 동작 자체의 출력은 없습니다.

종료 처리
end

finally에서 출력하고 정상 완료합니다.

호출자로 돌아감
원래 예외가 main의 catch로 전달됩니다.

caught=board failed

정상 원문의 end는 run이 예외를 던져도 보장되는 finally가 아닙니다. 예외 원문에서는 finally가 정상 완료하므로 원래 예외가 유지됩니다. finally 자체가 비정상 완료하면 이 경로를 그대로 일반화할 수 없습니다.

이 예의 조건

  • 정상 callback과 예외/finally는 별도 프로그램이다.
  • finally가 정상 완료하는 원 경로이다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보일반 종료 문장과 finally의 실제 실행 순서 일반 종료 문장과 finally의 실제 실행 순서 ProcessTemplateRefactoring은 정상 run 뒤에 일반 end 문장을 실행한다. AnonymousFinallyExercise는 run이 던진 예외 뒤에 finally의 end를 실행하고 원래 예외를 main이 잡는다. 콜백 처리 · 정상과 예외 경로 일반 종료 문장과 finally의 실제 실행 순서 서로 다른 execute 구현을 비교합니다. 정상 예제의 후속 end와 예외 예제의 finally는 보장하는 경로가 다릅니다. 정상 원문 · dice 먼저 실행기 진입 start=dice run() 안 dice=5 종료 처리 end=dice 일반 후속 문장입니다. 호출자로 돌아감 다음 sum 을 실행합니다. start=sum sum=6 end=sum 예외 원문 · finally 사용 실행기 진입 start run() 안 IllegalStateException 메시지 "board failed" 로 발생합니다. 이 동작 자체의 출력은 없습니다. 종료 처리 end finally 에서 출력하고 정상 완료합니다. 호출자로 돌아감 원래 예외가 main의 catch로 전달됩니다. caught=board failed 정상 원문의 end는 run이 예외를 던져도 보장되는 finally가 아닙니다. 예외 원문에서는 finally가 정상 완료하므로 원래 예외가 유지됩니다. finally 자체가 비정상 완료하면 이 경로를 그대로 일반화할 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
execute 진입

현재 callback 실행 전에 시작을 출력합니다.

  • 정상: start=dice
  • 예외 예제: start
  • 공통 전처리가 callback보다 앞선다.
callback 실행

정상 dice는 5를 출력하고 별도 run은 예외를 던집니다.

  • 정상: dice=5
  • 별도 실패: IllegalStateException: board failed
  • throw 자체 stdout: 없음
  • 비정상 반환은 일반 후속 문장을 건너뛴다.
마무리와 호출자

정상은 end 뒤 sum, 예외는 finally end 뒤 catch로 갑니다.

  • 정상 후속: end=dice; start=sum,sum=6,end=sum
  • 예외 후속: end → caught=board failed
  • 정리의 정상 완료 뒤에도 원 예외가 호출자로 전달된다.
  1. AnonymousAndLambdaThis의 람다 () -> "lambda=" + this.name을 익명 클래스로 바꾸되, 출력이 그대로 lambda=owner가 되게 하세요. 이때 익명 클래스에는 name 필드를 두지 않습니다. 람다 본문의 this.name을 그대로 옮겨 쓰면 어떻게 되는지도 설명하세요.
해설 보기
Command lambda = new Command() {
    public String run() {
        return "lambda=" + AnonymousAndLambdaThis.this.name;
    }
};

익명 클래스 본문의 this는 익명 객체이므로, 바깥 객체의 필드를 읽으려면 AnonymousAndLambdaThis.this.name처럼 바깥 클래스 이름을 붙여야 합니다. this.name을 그대로 옮기면 cannot find symbol 컴파일 오류가 납니다. 이 익명 클래스는 Object를 상속하고 Command를 구현했을 뿐, 바깥 클래스를 상속한 것이 아니기 때문입니다. 바깥 클래스 안에 선언되어 있다고 해서 바깥 클래스의 필드가 익명 객체의 필드가 되지는 않습니다.

한편 this를 빼고 "lambda=" + name이라고만 써도 lambda=owner가 출력됩니다. 익명 클래스 안에 name이 없으니 컴파일러가 바깥 범위에서 이름을 찾아 바깥 객체의 필드로 해석하기 때문입니다. 다만 나중에 익명 클래스에 같은 이름의 필드가 생기면 그 필드가 바깥 필드를 가리게 되므로, 바깥 객체를 뜻한다는 것이 분명히 드러나는 한정된 this 쪽이 더 안전합니다.

다음 글

다음 글에서는 이 장에서 배운 정적 중첩 클래스, 내부 클래스, 지역 클래스, 익명 클래스에 람다까지 더해 한 게시판 예제에 배치해 보고, 어떤 상황에서 어떤 형태를 고를지 기준을 정리합니다.