이 코드, 안전해 보이시나요?
객체를 만들 때 불변 조건이 맞지 않으면 예외를 던진다. 자바 개발자라면 거의 반사적으로 짜는 코드입니다.
public class BankAccount {
private final String owner;
private final long balance;
public BankAccount(String owner, long balance) {
if (balance < 0) { // 이 객체가 절대 어겨선 안 되는 불변 조건
throw new IllegalArgumentException("잔액은 음수일 수 없습니다");
}
this.owner = owner;
this.balance = balance;
}
public void transfer(long amount) {
// 송금 로직
}
}
잔액이 음수면 예외가 터지니까 잘못된 BankAccount는 만들어지지 않고, 따라서 그 객체로 transfer()가 호출될 일도 없다. 논리적으로 완벽해 보입니다.
그런데 이 코드는 뚫립니다. 생성자의 검증을 통과하지 못한, 음수 잔액짜리 객체로 transfer()가 호출될 수 있어요.
먼저 위협 모델부터 짚고 갑시다. 이 공격이 실제로 문제가 되는 상황은 좁습니다. 남이 내 클래스를 상속해 악성 코드를 같은 JVM에 심을 수 있을 때, 곧 라이브러리·SDK를 배포하거나 신뢰할 수 없는 코드를 한 프로세스에서 돌리던 시절(옛 애플릿·
SecurityManager샌드박스)이 그렇습니다. 내 코드만 도는 평범한 서버 앱이라면,extends해서 클래스를 내 classpath에 넣을 수 있는 공격자는 이미 더 쉬운 길로 이긴 상태예요. 그러니 대부분의 앱 개발자에게 이 글은 “지금 당장 뚫리고 있다”는 경보가 아니라, 왜 클래스에final을 붙여야 하는가에 대한 설계 논증에 가깝습니다. 그래도 메커니즘은 알아둘 값어치가 있어요.new가 우리 생각과 다르게 동작한다는 걸 드러내거든요.
사실 이런 코드, 생각보다 훨씬 많습니다
BankAccount가 특수한 사례라서 그런 게 아닙니다. 생성자에서 예외를 던지는 코드 자체는 이미 여러분의 코드베이스 곳곳에 있어요. (흔한 건 이 “모양”이지, 전부가 실제로 공격당한다는 뜻은 아닙니다. 위에서 말한 조건이 맞아야 하니까요. 다만 이 모양이 얼마나 익숙한지는 봐둘 만합니다.)
유틸리티 클래스의 인스턴스화 차단
public class StringUtils { // final을 빠뜨렸다
private StringUtils() {
throw new AssertionError("인스턴스화할 수 없습니다");
}
public static boolean isEmpty(String s) { ... }
}
정적 메서드만 모아둔 클래스를 만들 때 관용적으로 쓰는 패턴입니다. 생성자를 private으로 막아 new StringUtils()를 못 하게 하는 거죠.
그런데 왜 throw까지 넣을까요? private으로 막았는데 굳이 예외까지 던지는 게 과해 보이지만, 이유가 있습니다. 리플렉션(Reflection)을 쓰면 private 생성자도 호출할 수 있기 때문입니다.
Constructor<StringUtils> c = StringUtils.class.getDeclaredConstructor();
c.setAccessible(true); // private 접근 제어를 꺼버린다
StringUtils utils = c.newInstance(); // 생성자에 throw가 없다면 여기서 객체가 만들어진다
setAccessible(true) 한 줄이면 private이 무력화됩니다. 즉 private 생성자는 컴파일 타임에는 막지만 런타임에는 못 막습니다.
그래서 생성자 안에 throw를 넣습니다. 리플렉션으로 호출에 성공하더라도 생성자가 실행되는 순간 예외가 터지니, 객체는 끝내 만들어지지 않아요. (이때 예외는 newInstance()가 InvocationTargetException으로 감싸서 던집니다.)
참고로 리플렉션은 공격 도구만은 아닙니다. Spring이 빈을 만들고 의존성을 주입하는 것, Jackson이 JSON을 객체로 변환하는 것 모두 리플렉션 기반이에요.
여기까지는 잘 짠 코드입니다. 그런데 한 가지, 클래스 선언에 final이 빠져 있죠? 지금은 사소해 보이는 이 한 글자가 뒤에서 안전과 위험을 가르는 갈림길이 됩니다.
null 체크
public class Order {
public Order(Customer customer) {
this.customer = Objects.requireNonNull(customer); // null이면 NPE
}
}
Objects.requireNonNull()은 값이 null이면 NullPointerException을 던지는 메서드입니다. 이름은 검사 같지만 실제로는 예외를 던지는 코드예요. 필수 값이 빠진 객체가 만들어지지 않도록, new로 직접 만드는 객체의 생성자에서 이렇게 막습니다.
(반대로 스프링이 주입하는 의존성에는 잘 쓰지 않습니다. 스프링은 필요한 빈을 못 찾으면 null을 넣는 게 아니라 기동 자체가 실패하고, 요즘 쓰는 Lombok @RequiredArgsConstructor도 final 필드에는 null 체크를 넣지 않기 때문입니다.)
그런데 이보다 알아채기 어려운 경우가 있습니다. **Lombok의 @NonNull**을 쓰면 코드 어디에도 throw가 안 보이거든요.
public class Order {
public Order(@NonNull Customer customer) { // throw가 없어 보인다
this.customer = customer;
}
}
Lombok은 컴파일 시점에 코드를 자동으로 끼워 넣는 라이브러리입니다. 위 코드는 컴파일되면서 실제로는 이렇게 바뀝니다.
public class Order {
public Order(Customer customer) {
if (customer == null) {
throw new NullPointerException("customer is marked non-null but is null");
}
this.customer = customer;
}
}
애노테이션 한 줄이 생성자 안의 throw 문으로 변신하는 거죠. 우리 눈에 throw가 안 보인다고 해서 없는 게 아닙니다. 참고로 이건 생성자만의 이야기가 아니라, 일반 메서드 파라미터에 붙여도 똑같이 메서드 본문 맨 앞에 null 체크가 생성됩니다.
주의:
@NotNull과는 다릅니다 이름이 비슷한jakarta.validation.@NotNull(또는javax.validation)은 전혀 다르게 동작합니다. 이쪽은 코드를 생성하지 않아요. 애노테이션은 그냥 표시로만 남아있고, 실행 중에 별도의 검증기(Spring의@Validated프록시나Validator)가 그 표시를 읽어서 검사합니다. 검증기가 붙어있지 않으면 아무 일도 일어나지 않죠. 정리하면, Lombok@NonNull은 컴파일 시점에throw를 만들어내고, validation@NotNull은 실행 시점에 남이 검사해줍니다. 지금 이야기하는 “생성자 안의 예외”에 해당하는 건 Lombok 쪽입니다.
값 객체의 유효성 검증
public class Money {
public Money(long amount) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 음수일 수 없습니다");
}
this.amount = amount;
}
}
값 객체가 스스로 불변 조건(invariant, 그 객체가 늘 만족해야 하는 규칙. 여기선 “금액은 음수일 수 없다”) 을 지키게 하는 건, DDD 같은 특정 방법론을 따르지 않더라도 널리 권장되는 설계입니다.
리소스를 여는 생성자
public class FileReaderWrapper {
public FileReaderWrapper(String path) throws IOException {
this.stream = new FileInputStream(path); // 파일 없으면 예외
}
}
생성자에서 파일이나 커넥션을 열면, 실패는 곧 예외입니다.
전부 흔하고, 대부분 “잘 짠 코드”로 통하는 패턴들입니다. 그리고 흥미로운 건 이 중 일부는 실제로 위험하지 않다는 점이에요. 겉보기엔 똑같이 생성자에서 예외를 던지는데 말이죠.
그 차이가 어디서 갈리는지 보려면, 먼저 공격이 어떻게 이뤄지는지부터 봐야 합니다.
어떻게 뚫리나
공격자는 BankAccount의 소스 코드를 건드리지 않습니다. 자기 코드만 두 개 작성해요.
먼저 훔친 참조를 보관할 창고를 하나 만듭니다. 특별할 것 없는, 그냥 static 필드 하나짜리 클래스입니다.
public class StolenRef {
public static BankAccount instance; // 훔친 객체를 여기 보관한다
}
그리고 BankAccount를 상속받는 공격용 클래스를 하나 더 만듭니다.
public class Attack extends BankAccount {
public Attack() {
super("attacker", -1); // 부모 생성자 실행 → balance < 0 → 예외 발생
}
@Override
protected void finalize() {
StolenRef.instance = this; // 생성에 "실패"한 객체인데, 여기서 this가 살아있다
}
}
super("attacker", -1)는 부모 BankAccount의 생성자를 실행합니다. 잔액 -1이 불변 조건(balance < 0)에 걸려, 생성자가 IllegalArgumentException을 던지죠. 여기까지는 의도대로예요. 객체 생성은 실패했습니다.
문제는 finalize()입니다. 잠시 뒤에 new Attack()이 이 예외를 내며 실패하면, finalize() 안의 this는 바로 그 실패한 객체를 가리키게 됩니다. Attack은 BankAccount를 상속했으니, BankAccount 타입 변수에 그대로 담을 수 있고요.
이제 이렇게 씁니다.
try {
new Attack();
} catch (IllegalArgumentException e) {
// 예외를 그냥 삼킨다
}
System.gc(); // GC 유도
Thread.sleep(100); // finalize 실행 대기
StolenRef.instance.transfer(1_000_000); // 검증을 통과하지 않았는데 호출된다
System.gc()는 GC를 “요청”할 뿐 실행을 보장하지 않고,finalize()도 별도 스레드에서 언젠가 호출됩니다. 실제 공격 코드는 성공할 때까지 반복하지만, 여기서는 흐름을 보여주려고 단순하게 적었습니다.
생성자에서 예외가 났으니 객체는 만들어지지 않았어야 합니다. 그런데 StolenRef.instance에는 멀쩡히 참조가 들어있고, 그 참조로 transfer()가 호출됩니다. 생성자의 검증 로직이 통째로 우회된 겁니다.
이걸 파이널라이저 공격(Finalizer Attack) 이라고 부릅니다.
왜 이런 일이 벌어지나
두 가지 개념이 맞물려 있습니다.
부분 초기화 객체
new로 객체를 만들 때 실제 순서는 이렇습니다.
- JVM이 힙에 객체 공간을 먼저 할당하고, 필드를 기본값(
0,null)으로 채운다 - 그다음 생성자가 실행된다
- 생성자 중간에 예외 발생
- 생성자의 나머지가 실행되지 않는다
여기서 중요한 건 1번이 2번보다 먼저라는 점입니다. 즉 생성자가 실패해도 객체 자체는 이미 메모리에 만들어져 있습니다. 다만 생성자를 끝까지 못 돌려서 필드가 절반만 채워진, 불완전한 상태일 뿐이죠.
이걸 부분 초기화 객체(partially initialized object) 라고 합니다. 정상적이라면 아무도 이 객체를 참조하지 못하니 그대로 GC에 수거되어 사라집니다.
finalize()
finalize()는 Object에 정의된 메서드로, 객체가 GC로 회수되기 직전에 JVM이 자동으로 호출합니다. 개발자가 부르는 게 아니라 JVM이 “이거 지울 건데 정리할 거 있으면 해라” 하고 불러주는 거예요.
이제 두 개를 합쳐보면 공격이 보입니다.
- 생성자에서 예외 발생
- 부분 초기화 객체가 메모리에 남는다
- 아무도 참조하지 않으니 GC 대상이 된다
- GC 직전에
finalize()가 자동 호출된다 - 그런데 그
finalize()는 공격자가 오버라이드한 것이다 - 공격자가
this를static필드에 저장 → 참조 탈취
GC가 “이제 지워야지” 하고 부른 메서드에서, 객체가 스스로 자기 참조를 밖으로 내보내며 되살아나는 셈입니다.
여기서 헷갈리기 쉬운 두 가지
“Attack이 상속했는데도 GC 대상인가요?”
네. 상속은 클래스끼리의 관계일 뿐, 객체를 붙잡아두는 참조가 아닙니다.
그런데 그전에 짚고 갈 게 있어요. new Attack()을 하면 객체가 몇 개 만들어질까요?
public class BankAccount {
private String owner; // 부모 필드
private long balance; // 부모 필드
}
public class Attack extends BankAccount {
private int attackCount; // 자식 필드
}
BankAccount 객체 하나와 Attack 객체 하나, 이렇게 두 개가 생길 것 같지만 아닙니다. 힙에 만들어지는 건 부모 칸과 자식 칸이 한 몸으로 합쳐진 하나의 객체(인스턴스) 입니다. 이 글에서는 이걸 편의상 “덩어리” 라고 부르겠습니다.
힙에 만들어지는 객체 1개
┌──────────────────┐
│ owner │ ← BankAccount에서 물려받은 칸
│ balance │ ← BankAccount에서 물려받은 칸
│ attackCount │ ← Attack이 추가한 칸
└──────────────────┘
그럼 super()는 뭘까요? 부모 객체를 새로 만드는 게 아니라, 이미 만들어진 그 하나의 덩어리 위에서 부모가 정의한 생성자 코드를 실행하는 것뿐입니다. 위 그림에서 부모 칸을 채우는 작업이죠.
여기서 순서가 헷갈리기 쉽습니다. new Attack()을 하면 자식 생성자에 제어가 먼저 들어옵니다. 하지만 그 첫 줄이 super()(안 적어도 컴파일러가 넣어줍니다)라, 부모 생성자가 끝까지 실행된 뒤에야 자식 생성자의 나머지가 돕니다. 그리고 부모의 생성자도 자기 첫 줄에서 또 자기 부모의 super()를 부르죠. 이 사슬은 결국 최상위 부모인 Object까지 거슬러 올라갑니다.
즉 Object → BankAccount → Attack 순서로 위에서 아래로 칸이 채워집니다. 호출은 자식이 먼저지만, 실제 초기화는 부모부터 끝난다는 뜻이에요. 뒤에서 이 순서가 공격의 핵심이 됩니다.
이걸 알면 finalize() 안의 코드가 이해됩니다. 앞에서 만든 창고가 이거였죠.
public class StolenRef {
public static BankAccount instance; // 타입이 BankAccount
}
// finalize() 안에서:
StolenRef.instance = this; // BankAccount 타입 칸에 Attack의 this가 담긴다
전혀 이상한 일이 아니에요. 그 덩어리 안에 BankAccount의 모든 것이 이미 들어있으니까요. Attack은 BankAccount를 상속했으니 “Attack is-a BankAccount”입니다. 자식 타입을 부모 타입 변수에 담는 이 동작이 바로 다형성(업캐스팅) 이고요. 별개의 물건을 억지로 끼워 넣는 게 아니라, 원래부터 BankAccount이기도 한 객체인 거죠.
그리고 앞에서 본 “부분 초기화”도 결국 같은 이야기입니다. 생성자가 실패했다는 건 저 덩어리의 칸들이 다 안 채워졌다는 뜻이지, 덩어리 자체가 없다는 뜻이 아니에요. 덩어리는 이미 힙에 있습니다. 그래서 훔쳐갈 수 있는 거고요.
이제 GC 이야기로 돌아오면, 그 덩어리를 가리키는 참조가 하나도 없다는 게 핵심입니다.
try {
new Attack(); // 예외 발생 → 결과를 담을 변수조차 없다
} catch (IllegalArgumentException e) { }
// 이 시점에 그 객체를 가리키는 참조가 세상에 하나도 없다 → GC 대상
여기서 “왜 Attack a = new Attack();처럼 변수에 담지 않았지?“라는 의문이 들 수 있습니다. 두 가지를 나눠 봐야 합니다.
먼저, 담고 싶어도 못 담습니다. 대입(a = ...)은 new가 성공적으로 끝나야 일어나는데 생성자가 예외를 던져버렸으니, 변수를 선언했더라도 아무것도 안 들어가요. 그래서 이 객체를 가리키는 참조는 자연히 하나도 없습니다.
그리고 공격자에게는 그게 오히려 유리합니다. 만약 어떻게든 참조가 남아 있으면 그 객체는 GC 대상이 안 되고, GC가 안 되면 finalize()도 안 불려 공격이 성립하지 않거든요. 공격자는 이 객체가 빨리 쓰레기가 되기를 바라는 겁니다. 즉 “참조가 없다”는 사실이, 공격을 막기는커녕 오히려 공격의 조건을 채워 줍니다.
“finalize()가 실행돼야만 담기는 건가요?”
맞습니다. StolenRef.instance = this는 finalize() 안에 들어있으니, 그 메서드가 호출되지 않으면 아무 일도 일어나지 않아요. 그래서 공격 코드가 System.gc()로 GC를 유도하고 Thread.sleep()으로 기다리는 겁니다.
이 공격의 아이러니가 여기 있습니다. GC가 “이제 지우겠다”며 부른 메서드가, 오히려 그 객체를 되살려냅니다. 지우려는 행위 자체가 부활의 계기가 되는 거죠.
아까 그 코드들은 어떻게 되나
이제 판정 기준이 명확해졌습니다. 공격이 성립하려면 공격자가 그 클래스를 상속해서 finalize()를 오버라이드할 수 있어야 합니다. 질문은 하나로 압축돼요.
이 클래스, 상속할 수 있나?
앞에서 봤던 코드들을 이 질문으로 다시 보겠습니다.
위험한 쪽
public class Money {
public Money(long amount) {
if (amount < 0) throw new IllegalArgumentException();
this.amount = amount;
}
}
public class에 public 생성자입니다. 누구든 extends Money를 할 수 있고, super(-1)로 예외를 유발한 뒤 finalize()로 참조를 훔칠 수 있어요. 앞서 본 BankAccount, Order도 전부 같은 모양입니다.
안전한 쪽
public class StringUtils { // final을 빠뜨렸는데도
private StringUtils() { // 생성자가 private
throw new AssertionError("인스턴스화할 수 없습니다");
}
}
유틸리티 클래스는 final을 깜빡했는데도 안전합니다. 이유는 private 생성자예요.
서브클래스는 반드시 부모 생성자를 super()로 호출해야 하는데, 그 생성자가 private이면 접근할 수 없습니다.
public class Attack extends StringUtils {
public Attack() {
super(); // 컴파일 에러: StringUtils() has private access
}
}
super()를 생략해도 컴파일러가 자동으로 넣어주기 때문에 결과는 같습니다. 상속이 막히니 악성 finalize()를 심을 방법도 없어요. private 생성자가 의도치 않게 방어막 역할까지 해준 셈입니다.
뒤집어 말하면, 안전을 지키는 건 final이 아니라 private이라는 접근 제어입니다. 만약 생성자를 protected나 public으로 열면 자식이 super()로 접근할 수 있어 상속이 다시 가능해지고, 공격 경로도 다시 열립니다. 이 점은 뒤(4번 해법)에서 다시 문제가 됩니다.
(엄밀히는 같은 파일 안의 중첩 클래스라면 private에 접근할 수 있어 상속이 가능합니다. 다만 그건 이미 그 클래스의 소스를 고칠 수 있다는 뜻이라 공격 시나리오로는 의미가 없죠.)
원래부터 안전한 쪽
public record Money(long amount) {
public Money {
if (amount < 0) throw new IllegalArgumentException();
}
}
Java 16부터 쓸 수 있는 레코드(record)는 자동으로 final 입니다. 왜 그럴까요? 레코드는 “이 필드들이 이 값의 전부”라는 걸 보장하는 투명한 데이터 운반체예요. 자식이 상속으로 상태를 덧붙이면 그 보장이 깨집니다. 그래서 설계 단계에서 아예 final로 못 박아 상속을 막았죠. extends를 시도하면 컴파일 에러가 납니다.
그래서 컴팩트 생성자에서 검증을 해도 이 문제에서 자유롭습니다. 값 객체를 레코드로 만들면 별도 조치 없이 해결된다는 뜻이에요.
정리하면 갈림길은 딱 하나입니다. 상속을 막았는가. final이든 private 생성자든 record든, 상속을 차단한 클래스는 안전하고 열어둔 클래스는 위험합니다.
그래서 어떻게 짜야 하나
1. 상속이 필요 없다면: final
공격이 성립하려면 공격자가 서브클래스를 만들어 finalize()를 오버라이드해야 합니다. 상속을 막으면 이 경로가 통째로 사라집니다.
public final class BankAccount { // 상속 불가
...
}
String, Integer 같은 표준 라이브러리 클래스도 final입니다. 주된 이유는 불변성 보장이지만, 결과적으로 상속을 막는다는 점에서 같은 효과를 냅니다.
그리고 현실적으로, 대부분의 클래스는 애초에 상속을 염두에 두고 설계되지 않았습니다. “상속보다 조합(composition over inheritance)“은 널리 권장되는 설계 습관인데(이 원칙 자체는 그것대로 큰 주제라 여기선 이름만 남깁니다), 이걸 따른다면 클래스 대부분은 final이어도 아무 문제가 없어요. 이 한 줄이 사실상 정답입니다.
트레이드오프: 가장 간단하고 확실하다. 대신 상속이 실제로 필요한 설계(프레임워크 확장점, 템플릿 메서드 패턴 등)에는 쓸 수 없다. 그럴 땐 아래 3번으로 간다.
상속이 조금은 필요하다면 Java 17의 sealed도 선택지입니다. final이 상속을 전부 막는다면, sealed는 허용할 자식 클래스만 골라서 지정하는 문법이에요.
public sealed class Payment permits CardPayment, BankTransfer { }
public final class CardPayment extends Payment { } // 자식은 final,
public non-sealed class BankTransfer extends Payment { } // sealed, non-sealed 중 하나여야 한다
permits 뒤에 적힌 둘만 상속할 수 있고, 그 외의 클래스가 extends Payment를 시도하면 컴파일 에러가 납니다. 필요한 확장은 열어두면서, 모르는 곳에서 임의로 상속되는 일은 막을 수 있죠.
트레이드오프: 필요한 확장만 골라 열 수 있다. 대신 non-sealed을 붙인 자식은 상속이 다시 활짝 열리므로, 그 클래스만큼은 final이나 아래 3번 검증으로 따로 지켜야 한다.
2. 유틸리티 클래스라면: final + private 생성자
정적 메서드만 모아둔 유틸 클래스는 인스턴스화할 이유도, 상속할 이유도 없습니다.
public final class StringUtils {
private StringUtils() {
throw new AssertionError("인스턴스화할 수 없습니다");
}
public static boolean isEmpty(String s) {
return s == null || s.isEmpty();
}
}
앞에서 봤듯 private 생성자만으로도 상속은 이미 막힙니다. 그런데도 final을 함께 붙이는 이유는 두 가지예요. 선언 첫 줄에서 “이 클래스는 확장 대상이 아니다”를 드러내는 것, 그리고 나중에 누군가 생성자 접근 제어자를 무심코 바꿔도 방어막이 남아있게 하는 것입니다.
Lombok을 쓴다면 @UtilityClass 하나로 final + private 생성자가 자동 처리됩니다.
트레이드오프: 유틸 클래스라는 좁은 경우에만 해당하지만, 그 경우엔 사실상 잃는 게 없다. 인스턴스화도 상속도 원래 필요 없으니까.
3. 상속을 열어둬야 한다면: 검증을 super() 앞으로
final을 붙일 수 없다면, 자바 명세에 있는 규칙 하나를 이용할 수 있습니다. JLS(자바 언어 명세, Java Language Specification. 자바가 어떻게 동작해야 하는지 규정한 공식 표준 문서) 12.6.1의 내용이에요.
객체는 자신의 생성자가
Object의 생성자를 호출하고 그 호출이 예외 없이 성공적으로 끝나기 전까지는 finalize 대상이 되지 않는다.
풀어 쓰면, Object 생성자가 완료되기 전에 예외가 터지면 그 객체는 애초에 finalize 명단에 오르지 않습니다. finalize()가 호출될 일이 없으니 공격도 불가능하죠.
그럼 앞에서 본 BankAccount는 왜 뚫렸을까요? 순서 때문입니다.
public BankAccount(String owner, long balance) {
// ← 여기서 암묵적 super()(부모가 Object이니 곧 Object 생성자)가 먼저 실행된다 (완료!)
if (balance < 0) {
throw new IllegalArgumentException(); // 그 다음에 예외
}
}
자바는 전통적으로 super()가 무조건 첫 문장이어야 했습니다. 그래서 검증은 언제나 Object 생성자 뒤에 올 수밖에 없었고, 예외가 터질 시점에는 객체가 이미 finalize 명단에 등록된 상태였어요. 이게 이 공격이 성립한 근본 이유입니다.
그런데 Java 25의 JEP 513(Flexible Constructor Bodies) 이 이 제약을 풀었습니다. 이제 super() 앞에 문장을 쓸 수 있어요.
public class BankAccount {
public BankAccount(String owner, long balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수일 수 없습니다"); // super() 앞에서 터진다
}
super(); // Object 생성자는 여기서
this.owner = owner;
this.balance = balance;
}
}
Object 생성자가 실행되기도 전에 예외가 나므로, 객체는 finalize 대상이 되지 않습니다. 상속을 열어두면서도 공격은 막는 방법이에요. 검증이 생성자 안에 그대로 남아있으니 자식 클래스가 super()를 불러도 반드시 검증을 통과해야 하고요.
트레이드오프: 상속을 열어둔 채 막는 가장 깔끔한 방법이지만, Java 25 이상에서만 됩니다. 그리고 2026년 현재 프로덕션에서 25를 쓰는 곳은 드물어요. 그래서 이건 지금 당장의 해법이라기보단 앞으로의 표준에 가깝습니다.
Java 25 미만이라면, this()의 인자가 위임받은 생성자보다 먼저 평가된다는 점을 이용해 검증을 인자 자리로 밀어넣는 옛 기법도 있긴 합니다. 다만 코드만 봐선 의도가 안 보이는 기교라 실무에서 거의 안 쓰고, 리뷰에서 “이게 뭐냐”는 말을 듣기 십상이에요. 옛 버전에서 정말 막아야 한다면 차라리 클래스를 final로 만드는 게 낫습니다.
4. 검증을 아예 밖으로 빼는 방법
정적 팩토리 메서드로 객체 생성 자체를 막는 방법도 있습니다.
public class BankAccount {
private BankAccount(String owner, long balance) {
this.owner = owner;
this.balance = balance;
}
public static BankAccount create(String owner, long balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수일 수 없습니다"); // 검증은 여기서
}
return new BankAccount(owner, balance); // 통과해야만 new가 실행된다
}
}
검증에서 걸리면 new가 실행조차 되지 않으니, 훔쳐갈 객체 자체가 생기지 않습니다.
다만 여기엔 함정이 있어요. 위 코드처럼 생성자를 private으로 두면 안전하지만, 상속을 허용하려고 protected로 열면 자식 클래스가 create()를 건너뛰고 곧바로 객체를 만들 수 있습니다.
public class Bypass extends BankAccount { // 생성자를 protected로 열었을 때
public Bypass() {
super("attacker", -1); // 부모 '생성자'로 직행, create()는 안 거친다
}
}
핵심은 super()가 부모 생성자를 직접 부른다는 점입니다. 정적 팩토리 create()는 그냥 별개의 static 메서드라, super()가 그걸 대신 불러줄 일이 없어요. 그래서 자식은 검증을 통째로 건너뛰고 객체를 만듭니다. 이번엔 finalize() 같은 기교조차 필요 없죠.
트레이드오프: 검증을 생성자 밖으로 빼 create() 경로를 강제하지만, 이건 생성자가 private이라 상속이 불가능할 때만 성립한다. 상속을 허용하려고 생성자를 여는 순간 위처럼 우회되므로, 상속과 “생성자 밖 검증”은 같이 쓸 수 없다. 상속이 필요하면 3번, 필요 없으면 1번(final)이 답이다.
5. 한동안 표준이었던 방법: 빈 final finalize()
사실 이게 수년간 권장 방어책이었습니다(CERT 시큐어코딩 가이드에도 있었어요). 빈 finalize()를 final로 선언해, 공격자가 오버라이드하지 못하게 봉인하는 겁니다.
protected final void finalize() { } // 오버라이드를 봉인
원리상 방어는 확실합니다. 다만 지금은 finalize() 자체가 deprecated라, 이 낡은 API를 건드리는 것보다 클래스에 final을 붙이는 게 더 깔끔합니다. “쓰면 안 되는 방법”이라기보단, 더 나은 방법으로 대체된 방법이에요.
그런데 finalize()는 사라지는 중입니다
여기까지 읽고 이런 생각이 드셨다면 감이 좋으신 겁니다.
“잠깐,
finalize()는 없어진다고 하지 않았나?”
맞습니다. 다만 아직 없어지지 않았습니다. (이 글은 최신 LTS인 JDK 25 기준입니다. 버전이 오르면 아래 상태가 달라질 수 있으니, 나중에 읽는다면 이 시점을 기준으로 다시 확인하세요.)
finalize(): Java 9에서 deprecated, Java 18(JEP 421)에서 “제거 예정”으로 지정SecurityManager: JDK 24(JEP 486)에서 영구 비활성화
SecurityManager를 나란히 둔 건, 맨 앞에서 말한 그 “신뢰 못 할 코드를 같은 JVM에서 격리하던” 보안 모델이 바로 SecurityManager였기 때문입니다. 그게 걷히면서, 이 공격이 서 있던 무대도 함께 저물고 있어요.
여기서 정확히 짚고 갈 게 있어요. “제거 예정으로 지정”은 제거됐다는 뜻이 아닙니다. JEP 421이 정한 로드맵은 이렇습니다.
- 제거 예정으로 표시하고, 끌 수 있는 옵션(
--finalization=disabled)을 제공한다 ← 지금 여기 - 향후 릴리스에서 기본값을 비활성화로 바꾼다
- 그 후 릴리스에서 완전히 제거한다
즉 로드맵은 아직 1단계에 머물러 있습니다. 기본값을 비활성화로 바꾸는 2단계 JEP는 아직 나오지 않았어요. 그래서 finalize()는 여전히 기본으로 동작하고, 이 공격이 딛고 선 언어 구멍도 아직 열려 있습니다. 다만 맨 앞에서 말했듯, 실제로 악용되는 건 신뢰할 수 없는 코드가 같은 JVM에 들어오는 좁은 상황에서예요. 내 코드만 도는 앱이라면 이건 “지금 뚫린다”가 아니라 “그래서 final을 붙이자”는 이야기입니다.
물론 저물어가는 중인 건 맞습니다. JDK 내부의 finalize() 구현도 릴리스마다 하나씩 걷어내고 있고, 언젠가는 이 공격 자체가 역사 속으로 사라질 겁니다. 지금 당장 확실하게 막고 싶다면 실행 옵션으로 아예 꺼버릴 수도 있고요.
java --finalization=disabled -jar app.jar
그럼 “곧 사라질 거면 신경 안 써도 되지 않나?” 싶을 수 있는데, 두 가지 이유로 그렇지 않습니다.
하나, 지금 쓰는 JVM에서는 아직 동작합니다. 그리고 우리가 짠 코드가 어느 버전에서 얼마나 오래 돌지는 아무도 모릅니다. 레거시 시스템이 JDK 8에서 십 년째 돌고 있는 광경, 낯설지 않으시죠.
둘, 더 중요한 건 여기서 배우는 설계 감각입니다.
- 객체는 생성자가 실패해도 메모리에 존재한다.
new는 원자적 연산이 아닙니다. - 상속은 기본값이 아니라 선택이어야 한다. 열어둔 확장 지점은 언젠가 예상 못 한 방식으로 쓰입니다.
- 불변 조건 검증은 객체가 만들어지기 전에 끝내는 게 안전하다.
첫 번째 문장은 파이널라이저 공격만의 이야기가 아닙니다. 생성자가 끝나기도 전에 this를 외부(리스너·컬렉션·다른 스레드)에 넘겨 버리는 this 탈출(this escape) 도 뿌리가 같아요. 아직 덜 만들어진 객체가 밖으로 새어 나가는, 잘 알려진 버그입니다. 여기선 이름만 남깁니다.
언젠가 파이널라이저 공격이 사라지더라도, 이 세 문장은 그대로 남습니다. 그리고 세 문장 모두가 결국 같은 실천으로 수렴해요.
상속시킬 이유가 없는 클래스에는 final을 붙이자.
공격을 막기 위해서가 아니라, 설계 의도를 코드로 말하기 위해서요.
참고
- JEP 421: Deprecate Finalization for Removal
- The Java Language Specification, §12.6.1 Implementing Finalization
- JEP 513: Flexible Constructor Bodies
- JEP 486: Permanently Disable the Security Manager
- JEP 395: Records
- JEP 409: Sealed Classes
- SEI CERT Oracle Coding Standard for Java, MET12-J: Do not use finalizers