익명 클래스와 동작 전달
재사용된 익명 객체의 가변 상태 누적 문제를 관찰하고 지역·익명 클래스 구현으로 공통 전후 흐름과 가변 동작을 분리합니다.
익명 클래스는 이름 없이 상위 타입을 구현하거나 상속하면서 즉시 인스턴스를 만듭니다.
한 번만 필요한 작은 전략을 호출 위치 가까이에 둘 수 있습니다.
하지만 익명이라는 사실이 무상태를 보장하지 않으며 같은 인스턴스를 재사용하면 내부 필드도 계속 누적됩니다.
명령 재사용과 상태 누적
호출자는 매 실행이 독립적으로 1을 출력할 것으로 기대했지만 같은 익명 객체가 count 필드를 보관합니다.
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익명 객체도 일반 객체와 같은 수명과 상태를 가집니다.
실행마다 새 인스턴스가 필요하면 팩터리에서 만들거나 상태를 지역 변수로 옮깁니다.
정말 누적 명령이라면 이름 있는 클래스와 명시적 상태 메서드가 더 이해하기 쉽습니다.
AnonymousStateReuseBug는 익명 Process를 한 번 생성하고 execute에 같은 once 참조를 두 번 전달한다. 한 객체의 count가 0에서 1, 다시 2로 증가한다.
AnonymousStateReuseBug의 new Process() { ... }는 한 번만 평가됩니다. 두 execute(once)는 같은 객체를 호출합니다.
| 실행 시점 | 같은 객체의 count | 실제 출력 |
|---|---|---|
| 한 번 생성 | int 필드의 초기값 0 | 출력 없음 |
| 첫 execute | run()의 count++: 0 → 1 | count=1 |
| 둘째 execute | 이전 값을 이어서 count++: 1 → 2 | count=2 |
- 한 번 생성
- 같은 객체의 count:
int필드의 초기값0실제 출력: 출력 없음 - 첫 execute
- 같은 객체의 count:
run()의count++:0 → 1실제 출력:count=1 - 둘째 execute
- 같은 객체의 count: 이전 값을 이어서
count++:1 → 2실제 출력:count=2
execute는 process.run()만 호출하며 객체를 새로 만들거나 필드를 초기화하지 않습니다. 독립 실행을 원한다면 생성 지점이나 상태 위치를 바꿔야 합니다. 원문은 그 수정안을 실행한 예제가 아닙니다.
익명 클래스의 선언과 생성
지역 클래스는 이름을 선언하고 new로 생성하지만 익명 클래스는 new Interface() { ... } 안에서 구현과 생성을 함께 합니다.
생성자 이름은 없으므로 초기 값은 캡처하거나 인스턴스 초기화 블록·필드 초기화를 사용하지만 복잡한 초기화에는 이름 있는 클래스가 낫습니다.
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=trueprefix는 사실상 final로 캡처됩니다.
익명 클래스의 이름을 써서 다른 위치에서 직접 생성할 수는 없습니다. 다만 만든 객체를 참조로 전달하거나 다시 호출하는 것은 가능합니다.
공통 흐름과 가변 동작의 분리
주사위, 합계, 게시글 출력마다 시작·종료 메시지를 복사하면 정책 변경이 여러 곳에 퍼집니다.
hello(Process) 같은 실행기가 공통 전후 흐름을 소유하고 Process 구현이 가운데 동작만 제공합니다.
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 정책을 실행기에 둡니다.
익명 클래스 구현마다 전후 흐름을 복사하지 않습니다.
게시글 명령의 즉시 전달
변수에 담지 않고 메서드 인수 자리에서 익명 객체를 만들 수 있습니다.
구현이 짧고 재사용하지 않을 때 의도가 가장 가까이 있습니다.
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메서드가 하나인 함수형 인터페이스라면 람다로 더 짧게 표현할 수 있습니다.
여러 메서드를 구현하거나 자체 필드, 초기화, 명시적 this가 필요하면 익명 클래스가 여전히 의미를 가집니다.
this와 객체 수명
익명 클래스 본문의 this는 그것을 만든 바깥 객체가 아니라 새 익명 객체를 가리킵니다.
바깥 인스턴스와 연결된 문맥에서는 Owner.this처럼 한정해 그 인스턴스를 가리킵니다. static 문맥에서 선언한 익명 클래스에는 해당 바깥 인스턴스 연결이 없습니다.
이 차이를 놓치면 로그에 소유자 이름을 넣으려다 익명 객체의 toString()을 출력하거나, 같은 이름의 필드를 잘못 읽는 버그가 생깁니다.
람다는 새로운 this를 도입하지 않고 주변 문맥의 this 의미를 유지하므로 두 표현을 단순히 문법만 다른 것으로 보면 안 됩니다. 이것은 객체 할당이 없다는 뜻은 아니며, 람다 평가는 새 객체를 만들거나 기존 객체를 참조할 수 있습니다.
캡처한 값의 수명도 함께 봐야 합니다.
반환된 익명 객체가 오래 보관되면 그 객체가 참조한 바깥 인스턴스와 필드도 회수되지 않을 수 있습니다.
화면이나 게시글처럼 큰 소유자를 장기 캐시에 붙인 명령이 특히 위험합니다.
필요한 작은 값만 지역 변수로 복사해 캡처하거나, 장기 보관할 전략은 정적 중첩·최상위 구현으로 올려 암묵적 소유자 연결을 끊습니다.
익명 클래스의 JVM 이름은 컴파일 순서에 따라 Owner$1처럼 정해지는 구현 세부 사항입니다.
그 이름을 설정 파일, 직렬화 식별자, 리플렉션 분기 조건으로 저장하면 코드 위치가 바뀌기만 해도 호환성이 깨집니다.
외부 경계에는 인터페이스 이름과 명시적인 데이터만 노출하고, 익명 객체는 프로세스 내부의 일회성 구현으로 제한합니다.
따라서 선택 기준은 줄 수만이 아닙니다.
호출 즉시 실행되고 상태가 없으며 외부로 새지 않는 짧은 동작이면 익명 클래스가 적합합니다.
객체가 누적 상태를 갖거나 수명이 호출보다 길고, 장애 로그에서 구현 이름을 식별해야 하거나 독립 테스트가 필요하면 이름 있는 구현으로 승격합니다.
같은 동작을 매번 새로 만들지, 한 객체를 의도적으로 재사용할지도 생성 지점에서 드러내야 합니다.
익명 클래스 인스턴스 메서드의 this는 익명 객체다. 바깥 인스턴스 연결 여부는 선언 문맥에 달려 있으며 람다는 새 this를 도입하지 않는다. 객체 할당 여부와 this 의미는 별개다.
this의 의미와 객체 할당 여부는 별개입니다. 아래 비교는 언어 계약이며, 원문 main이 모든 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입니다. 람다 평가는 새 객체를 만들 수도, 기존 객체를 참조할 수도 있으므로 “할당 없음”으로 일반화하지 않습니다.
지역·익명·람다의 선택
- 같은 메서드 안에서 구현을 두 번 이상 생성하고 이름으로 설명하고 싶다: 지역 클래스
- 한 번만 쓰고 여러 메서드나 필드가 필요하다: 익명 클래스
- 함수형 인터페이스의 한 동작만 전달한다: 람다
- 여러 위치에서 재사용하고 독립 테스트한다: member 또는 최상위 class
익명 구현이 수십 줄로 커지면 이름과 파일을 주어 책임을 드러냅니다.
호출부 근접성보다 읽기 흐름과 재사용 가능성이 중요해지는 지점입니다.
연습 문제
Process 실행 중 IllegalStateException이 발생해도 end가 출력되도록 execute를 작성하세요.
main에서 예외를 잡아 메시지를 확인합니다.
해설 보기
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는 end를 출력하고 정상 완료하므로 원래 예외가 main의 catch로 전달됩니다.
다음 장에서 예외를 어디서 복구하고 어디로 전달할지 더 깊게 다룹니다.
ProcessTemplateRefactoring은 정상 run 뒤에 일반 end 문장을 실행한다. AnonymousFinallyExercise는 run이 던진 예외 뒤에 finally의 end를 실행하고 원래 예외를 main이 잡는다.
ProcessTemplateRefactoring의 정상 호출과 AnonymousFinallyExercise의 예외 호출을 비교합니다. 두 원문은 서로 다른 execute 구현입니다.
| 진행 위치 | 정상 원문 · dice 먼저 | 예외 원문 · finally 사용 |
|---|---|---|
| 실행기 진입 | start=dice | start |
run() 안 | dice=5 | IllegalStateException메시지 |
| 종료 처리 | end=dice일반 후속 문장입니다. | end
|
| 호출자로 돌아감 | 다음 sum을 실행합니다. | 원래 예외가 main의 catch로 전달됩니다.
|
- 실행기 진입
- 정상 원문 · dice 먼저:
start=dice예외 원문 · finally 사용:start run()안- 정상 원문 · dice 먼저:
dice=5예외 원문 · finally 사용:IllegalStateException메시지
"board failed"로 발생합니다. 이 동작 자체의 출력은 없습니다. - 종료 처리
- 정상 원문 · dice 먼저:
end=dice일반 후속 문장입니다.
예외 원문 · finally 사용:endfinally에서 출력하고 정상 완료합니다. - 호출자로 돌아감
- 정상 원문 · dice 먼저: 다음
sum을 실행합니다.start=sum sum=6 end=sum예외 원문 · finally 사용: 원래 예외가 main의 catch로 전달됩니다.caught=board failed
정상 원문의 end는 run이 예외를 던져도 보장되는 finally가 아닙니다. 예외 원문에서는 finally가 정상 완료하므로 원래 예외가 유지됩니다. finally 자체가 비정상 완료하면 이 경로를 그대로 일반화할 수 없습니다.