이펙티브 자바 완벽 공략 1부
아이템 8. finalizer와 cleaner 사용을 피하라
아이템 8. finalizer와 cleaner 사용을 피하라
- 아이템 8. finalizer와 cleaner 사용을 피하라
자바에서 객체의 생명 주기는 GC가 관리하지만, 자원의 생명 주기까지 GC에 맡기는 순간 위험해진다. finalizer와 cleaner는 그 대표적인 예다.
자바에서 자원 정리는 생각보다 위험한 주제다. 겉보기에는 “GC가 알아서 정리해주겠지”라는 유혹이 강하지만, finalizer와 cleaner는 그 기대를 정면으로 배신한다.
이 아이템의 핵심 메시지는 단순하다.
자원을 반납해야 하는 클래스는 AutoCloseable을 구현하고, 클라이언트가 명시적으로 close()를 호출하게 하라.
그 외의 방법은 모두 “안전망”일 뿐이다.
핵심 정리
finalizer와cleaner는 즉시 수행된다는 보장이 없다- 경우에 따라 아예 실행되지 않을 수도 있다
finalizer실행 중 예외가 발생하면 정리 작업이 처리되지 않을 수 있다- 두 메커니즘 모두 심각한 성능 문제를 유발할 수 있다
finalizer는 보안 취약점(finalizer attack)의 원인이 된다- 자원을 반납해야 하는 클래스는 반드시
AutoCloseable을 구현하고,close()호출 또는try-with-resources를 사용해야 한다
finalizer의 문제점
public class FinalizerIsBad {
@Override
protected void finalize() throws Throwable {
System.out.print("");
}
}
finalize()는 객체가 GC 대상이 되었을 때 호출될 수도 있는 메서드다. 언제 실행될지, 심지어 실행될지조차 전혀 보장되지 않는다.
finalizer 큐 적체 문제
public class App {
/**
* 코드 참고 https://www.baeldung.com/java-finalize
*/
public static void main(String[] args) throws InterruptedException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
int i = 0;
while(true) {
i++;
new FinalizerIsBad();
if ((i % 1_000_000) == 0) {
Class<?> finalizerClass = Class.forName("java.lang.ref.Finalizer");
Field queueStaticField = finalizerClass.getDeclaredField("queue");
queueStaticField.setAccessible(true);
ReferenceQueue<Object> referenceQueue = (ReferenceQueue) queueStaticField.get(null);
Field queueLengthField = ReferenceQueue.class.getDeclaredField("queueLength");
queueLengthField.setAccessible(true);
long queueLength = (long) queueLengthField.get(referenceQueue);
System.out.format("There are %d references in the queue%n", queueLength);
}
}
}
}
finalize()가 정의된 객체는 Finalizer 큐로 이동한다- 이 큐는 단일 스레드에서 처리된다
- 객체 생성 속도가 처리 속도를 앞지르면 큐가 빠르게 적체된다
- 그 결과 GC 지연, 메모리 증가, 전체 애플리케이션 성능 저하로 이어진다
보안 문제: Finalizer Attack
생성자가 예외를 던져 객체 생성이 실패했는데도, finalize()가 실행되면서 미완성 객체에 접근 가능해지는 문제가 있다.
그래서 보안적으로 중요한 클래스는 아예 finalize()를 두면 안 된다.
Cleaner 사용법
Cleaner는 Java 9부터 도입되었지만, finalizer의 안전한 대체재가 아니라 “제한적인 보조 수단”에 가깝다.
Cleaner의 정체
- 내부적으로 PhantomReference 사용
- 객체가 GC 대상이 되면 백그라운드 스레드에서 Runnable 실행
- 실행 보장 없음 (중요)
자원 반납용 안전망으로 사용
- 내부적으로
PhantomReference를 사용한다 - 객체가 GC 대상이 된 이후에 동작한다
- 호출 여부는 여전히 보장되지 않는다
- 다만
close()호출을 놓쳤을 때의 최후의 안전망으로는 의미가 있다
네이티브 피어(native peer) 자원 회수
- JNI로 할당된 메모리
- OS 레벨 리소스
- 네이티브 라이브러리 내부 상태 등
단, 다음 조건을 만족해야 한다.
- 성능 저하를 감당할 수 있을 것
- 네이티브 피어가 치명적인 자원을 들고 있지 않을 것
- 즉시 회수되지 않아도 시스템에 문제가 없을 것
👉 즉시 자원 회수가 필요하다면 Cleaner가 아니라 close()를 사용해야 한다.
잘못된 Cleaner 사용 예
public class BigObject {
private List<Object> resource;
public BigObject(List<Object> resource) {
this.resource = resource;
}
public static class ResourceCleaner implements Runnable {
private List<Object> resourceToClean;
public ResourceCleaner(List<Object> resourceToClean) {
this.resourceToClean = resourceToClean;
}
@Override
public void run() {
resourceToClean = null;
System.out.println("cleaned up.");
}
}
}
public class CleanerIsNotGood {
public static void main(String[] args) throws InterruptedException {
Cleaner cleaner = Cleaner.create();
List<Object> resourceToCleanUp = new ArrayList<>();
BigObject bigObject = new BigObject(resourceToCleanUp);
cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));
bigObject = null;
System.gc();
Thread.sleep(3000L);
}
}
System.gc()호출에 의존한다- 실행 여부가 불확실하다
- 즉시 자원 반납이 불가능하다 → 주 자원 관리 방식으로는 부적절하다
Cleaner + AutoCloseable 패턴
// cleaner 안전망을 갖춘 자원을 제대로 활용하는 클라이언트
public class Adult {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("안녕~");
}
}
}
// 코드 8-1 cleaner를 안전망으로 활용하는 AutoCloseable 클래스
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
private static class State implements Runnable {
int numJunkPiles;
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override public void run() {
System.out.println("Cleaning room");
numJunkPiles = 0;
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override public void close() {
cleanable.clean();
}
}
- 정상적인 경우
close()가 즉시 자원을 반납한다 close()가 호출되지 않았을 경우에만 Cleaner가 안전망 역할을 한다
// cleaner 안전망을 갖춘 자원을 제대로 활용하지 못하는 클라이언트
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("Peace out");
// GC 호출에 의존해서는 안 된다
System.gc();
}
}
권장하는 AutoCloseable
- 자원을 보유한 클래스는 반드시
AutoCloseable을 구현해야 한다 - 사용자는
try-with-resources로 자원 사용 범위를 명확히 해야 한다
public class AutoClosableIsGood implements Closeable {
private BufferedReader reader;
public AutoClosableIsGood(String path) {
try {
this.reader = new BufferedReader(new FileReader(path));
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(path);
}
}
@Override
public void close() {
try {
reader.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
public class App {
public static void main(String[] args) {
try (AutoClosableIsGood good = new AutoClosableIsGood("")) {
// 자원 사용
} // 자동 반납
}
}
완벽 공략
- p42: Finalizer 공격
- p43: AutoCloseable의 필요성
- p45: 정적이 아닌 중첩 클래스는 바깥 객체의 참조를 가진다
- p45: 람다 역시 바깥 객체의 참조를 갖기 쉽다
정적이 아닌 중첩 클래스의 문제점
class InnerClass {
public void hello() {
OuterClass.this.hi();
}
}
- non-static 중첩 클래스는 자동으로 바깥 객체 참조를 가진다
- Cleaner의 Runnable이 바깥 객체를 참조하면 GC가 불가능해진다
- 따라서 Cleaner에서 사용하는 Runnable은 반드시 static 중첩 클래스여야 한다
- Cleaner의 Runnable이 outer 객체를 참조하면?
- GC 불가
- Cleaner가 실행되지 않음
- 자원 누수
람다 역시 바깥 객체를 캡처한다
private Runnable instanceLambda = () -> {
System.out.println(value);
};
- 람다는 바깥 인스턴스를 쉽게 캡처한다
- 결과적으로 this 참조가 숨어 들어간다
- Cleaner에서 사용하면 순환 참조가 발생할 수 있다
- Cleaner 작업에는 람다 사용을 피해야 한다
Finalizer 공격 (Finalizer Attack)
만들다 만 객체를 finalize 메소드에서 사용하는 방법
Finalizer 공격은 “생성에 실패한 객체를 finalize에서 사용하는 공격 기법”이다. 객체 생성이 정상적으로 끝나지 않았음에도, finalize()가 호출되면서 의도하지 않은 메서드 실행이 가능해지는 치명적인 보안 문제다.
공격 개념
- 생성자에서 예외가 발생해 객체가 완전히 만들어지지 않음
- 그럼에도 GC 과정에서
finalize()가 호출됨 - 하위 클래스에서
finalize()를 오버라이딩하여 보안적으로 중요한 메서드를 실행
즉, “만들다 만 객체”를 이용한 공격이다.
공격 대상 코드
public class Account {
private String accountId;
public Account(String accountId) {
this.accountId = accountId;
if (accountId.equals("푸틴")) {
throw new IllegalArgumentException("푸틴은 계정을 막습니다.");
}
}
public void transfer(BigDecimal amount, String to) {
System.out.printf("transfer %f from %s to %s\n", amount, accountId, to);
}
}
의도:
"푸틴"이라는 계정은 생성 단계에서 차단- 생성자가 예외를 던지므로 객체는 정상 생성되지 않아야 함
공격 코드 (하위 클래스)
public class BrokenAccount extends Account {
public BrokenAccount(String accountId) {
super(accountId);
}
@Override
protected void finalize() throws Throwable {
this.transfer(BigDecimal.valueOf(100), "keesun");
}
}
무슨 일이 벌어지는가?
new BrokenAccount("푸틴")- 부모 생성자에서 예외 발생 → 객체 생성 실패
- 하지만 GC 과정에서
finalize()는 호출될 수 있음 transfer()실행 → 차단되어야 할 계좌에서 송금 발생
👉 생성자 검증 로직이 완전히 무력화된다.
왜 이런 일이 가능한가?
finalize()는 생성자 실행 여부와 무관하게 호출될 수 있다- 생성자가 실패했어도, JVM 입장에서는 “힙에 할당된 객체”로 인식
- 하위 클래스에서
finalize()를 재정의하면 보안 로직을 우회할 수 있는 실행 경로가 열린다
이 때문에 Effective Java에서는 finalizer를 “보안적으로 치명적인 기능”이라고 명시한다.
Finalizer 공격 방어 방법
1. 상속을 허용하지 않는 경우 (가장 강력한 방법)
public final class Account {
...
}
- 클래스 자체를
final로 선언 - 하위 클래스 생성 불가
- Finalizer 공격 원천 차단
2. 상속이 필요한 경우
상속은 허용하되, finalize()만큼은 재정의할 수 없게 막는다.
@Override
protected final void finalize() throws Throwable {
// 아무 일도 하지 않음
}
이 방식의 효과
final메서드는 하위 클래스에서 오버라이딩 불가- 상속은 가능하지만
finalize()공격 경로 차단 - 결과적으로 Finalizer 공격 방어 가능
정리
- Finalizer 공격은 “생성자 실패 + finalize 재정의” 조합에서 발생
- 보안적으로 중요한 클래스에
finalize()가 존재하는 순간 공격 표면이 열린다 - 가장 좋은 해결책은 👉 애초에 finalizer를 사용하지 않는 것
- 불가피하다면 👉
final클래스 또는final finalize()로 방어
AutoCloseable
AutoCloseable은 try-with-resources 문법을 지원하기 위한 인터페이스다. 이 인터페이스를 구현한 객체는 try 블록이 종료되는 시점에 자동으로 close()가 호출되어 자원을 반납하게 된다.
즉, 자바에서 자원 생명 주기를 명시적으로 관리하기 위한 표준 계약이다.
AutoCloseable의 핵심 메서드
void close() throws Exception
close()는 자원을 반납하는 책임을 가진 메서드다- 인터페이스 정의상
Exception을 던질 수 있도록 되어 있다 하지만 구현체에서는 다음을 권장한다
- 가능한 한 구체적인 예외 타입을 던질 것
- 더 나아가, 가능하다면 예외를 던지지 않도록 구현할 것
이유
try-with-resources에서는 여러 자원이 중첩될 수 있다이때
close()에서 발생한 예외는- 기존 예외를 가리거나
- suppressed exception으로 밀려날 수 있다
- 자원 정리 과정에서 예외가 남발되면 디버깅과 오류 추적이 어려워진다
👉 close()는 “실패 가능성이 낮고, 조용해야 하는 메서드”다.
Closeable과의 차이점
Closeable은 AutoCloseable의 하위 인터페이스다.
public interface Closeable extends AutoCloseable {
void close() throws IOException;
}
차이점 정리
1. 던지는 예외 타입
AutoCloseableException- 범용 인터페이스
CloseableIOException- I/O 자원(File, Stream, Socket 등)에 특화
👉 I/O 계열 자원이라면 Closeable을 구현하는 것이 의미상 더 적절하다
2. idempotent(멱등성) 요구 사항
Closeable의close()는 반드시 idempotent 해야 한다- 여러 번 호출해도 결과가 같아야 한다
- 이미 닫힌 자원을 다시 닫아도 문제가 없어야 한다
AutoCloseable에는 명시적인 멱등성 요구는 없다- 하지만 실무에서는 동일하게 멱등성을 지키는 것이 강력히 권장된다
// 좋은 close() 구현의 특징
@Override
public void close() {
if (closed) {
return;
}
closed = true;
// 자원 반납
}
언제 무엇을 써야 할까?
- 파일, 스트림, 소켓 등 I/O 자원 →
Closeable - DB 커넥션, 락, 네이티브 자원, 논리적 리소스 →
AutoCloseable
공통 원칙은 하나다.
자원을 직접 들고 있다면 반드시 AutoCloseable 계열을 구현하라.
이 섹션의 핵심 메시지
AutoCloseable은 try-with-resources를 위한 자원 관리 계약close()는- 가능한 한 예외를 던지지 말고
- 던진다면 구체적으로
- 반드시 멱등적으로 구현할 것
- Cleaner나 finalizer보다 명시적인
close()가 항상 우선이다