Dev Language/EffectiveJava

[EffectiveJava] 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라

ydin 2025. 6. 7. 17:04

요약

불변식을 지키기 위해 인스턴스 수를 통제(싱글턴 구현)해야 한다면, 열거 타입 사용을 권장한다.

직렬화 + 인스턴스 수 통제가 모두 필요하다면 readResolve() + 모든 참조 타입 인스턴스 필드 transient 선언을 하도록 하자.

  1. 열거타입
  2. readResolve() + 모든 인스턴스 필드 transient 선언

 

1. 싱글턴 패턴 + Serializable

싱글턴 패턴을 구현한 경우, implements Serializable로 직렬화를 구현했다면 이는 더이상 싱글턴처럼 동작하지 않는다.

커스텀 직렬화하고, readObject()를 제공하더라도 더이상 싱글톤으로 작동하지 않는다. 왜냐하면 클래스 초기화 시 생성한 인스턴스가 아닌 다른 인스턴스를 반환하기 때문이다.

 

 2. 해결책 - readResolve()

readResolve()를 사용하면 위의 경우에도 싱글턴 속성을 유지할 수 있다.

  • readResolve() psuedo code
readResolve(역직렬화 후 새로 생성된 객체) {
	return 직렬화한 객체 참조;
}

 

  • readResolve() - 예시
// 인스턴스 통제를 위한 readResolve - readObject()로 인한 문제 해결 가능성 존재
private Object readResolve(매개변수) {
  // 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다. 
  return INSTANCE;
}
  • 이 readResolve()는 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환한다. 이때 Elvis의 인스턴스의 모든 필드를 transient로 선언해야 한다.

 

2-1. transient 사용

readResolve()를 인스턴스 통제 목적으로 사용한다면, 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다. 그렇지 않으면 역직렬화된 객체의 참조 공격의 가능성이 있다.

 

2-2. 객체 참조 공격

역직렬화할 인스턴스에 non-transient 필드가 존재한다고 가정해보자. 이 경우에는 필드 내용이 역직렬화 된 후에 readResolve()가 실행된다. 이렇게 필드 내용이 역직렬화되는 시점& readResolve()가 실행되기 전 에 잘 조작된 스트림을 사용해 역직렬화된 인스턴스를 훔쳐올 수 있다.

 

  • 공격 원리
[도둑 클래스]
- 필드(impersonator) : 훔칠 예정인 싱글턴 직렬화 참조를 저장하는 역할

[싱글턴 클래스]
- non-transient 불변 필드(INSTANCE) : 도둑의 인스턴스 참조가 저장됨

* 싱글턴이 도둑 클래스의 참조를 포함하므로 싱글턴이 역직렬화될 때 도둑의 readResolve()가 먼저 호출됨(Elvis의 readResolve()가 아닌)
* 도둑의 readResolve()가 수행될 때,
  도둑의 인스턴스 필드에는 역직렬화 중인 싱글턴의 readResolve()가 수행되기 전의 싱글턴 참조가 담긴다

 

  • steal 시점
(1) 역직렬화 (2) ——————> (3) readResolve() 실행(싱글턴 객체 반환)

(2)번 단계에서 instance steal이 발생할 수 있다.

 

  • 잘못된 싱글턴
    • transient가 아닌 참조 필드를 가지고 있다
    • Elvis가 역직렬화 될 때, INSTANCE에 담긴 도둑 클래스 참조로 인해 Elvis의 readResolve()가 아닌, ElvisStealer의 readResolve()가 실행된다
    public class Elvis implements Serializable {
        // transient가 아닌 참조 필드
        // transient가 아니기에 이후 도둑의 인스턴스 참조가 담길 예정
        public static final Elvis INSTANCE = new Elvis();
        private Elvis() {}
        
        private String[] favoriteSongs = { "Hound Dog", "HeartBreak Hotel" };
        
        public void printFavorites() {
            System.out.println(Arrays.toString(favoriteSongs));
        }
        
        private Object readResolve() {
            return INSTANCE;
        }
    }
    

 

  • 도둑 클래스
public class ElvisStealer implements Serializable {
    static Elvis impersonator; // 훔칠 Elvis 인스턴스의 참조를 저장할 필드
    private Elvis payload; // 기존의 싱글턴 Elvis를 받음
    
    // .. ElvisStealer 생성자 
    ElvisStealer(Elvis elvis) {
        this.payload = payload;
    }
    
    private Object readResolve() {
      // resolve 되기 전의 Elvis 인스턴스의 참조를 저장한다.
      impersonator = payload;
      
      // favoriteSongs 필드에 맞는 타입의 객체를 반환한다.
      return new String[] {"A Fool Such as I"};
    }
    
    private static final long serialVersinUID = 0;
}
    

 

  • 아래 코드를 실행하면 서로 다른 2개의 Elvis 인스턴스를 생성한다
public class ElvisImpersonator {
    // 진짜 Elvis 인스턴스로는 만들어질 수 없는 바이트 스트림
    private static final byte[] serializedForm = {
            (byte)0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05,
            0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte) 0xe6,
            (byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x86
            // .. 
    };

    public static void main(String[] args) {
        // ElvisStealer.impersonator 를 초기화한 다음
        // 진짜 Elvis(즉, Elvis.INSTANCE)를 반환한다.
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;
        
        elvis.printFavorites();
        impersonator.printFavorites();
    }
}

 

  • 결과
    • Elvis는 싱글턴 패턴으로 구현했음에도 두 개의 인스턴스가 생성된 것을 확인할 수 있다.
    • 따라서, 싱글턴인 경우에도 implements Serializable을 한다면 싱글턴이 깨질 수 있다
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

→ 싱글턴 + Serializable + readResolve()를 설정했더라도, non-transient 필드가 있다면 싱글턴이 깨진다.

 

2-3. 해결방법

위처럼 싱글턴 패턴인데도 직렬화 때문에 2개의 인스턴스가 생성되는 해결법으로는 2가지가 있는데, 다음과 같다.

  1. 인스턴스 필드 transient 선언 (transient favoriteSongs)
    1. 이 방법은 readResolve()를 사용해 순간적으로 만들어진 역직렬화된 인스턴스에 접근하지 못하는 로직을 구현해야하고, 이는 깨지기 쉽고 신경을 많이 써야해서 권장하지 않는다.
  2. Elvis를 원소 하나짜리 열거 타입으로 변경
    1. 저자는 이 방법을 더 권장!
    2. 직렬화 가능 인스턴스 통제 클래스 + 열거 타입으로 구현하면 선언한 상수 객체만 존재함을 자바가 보장한다

 

3. 열거 타입 싱글턴

열거 타입으로 싱글턴은 구현한 코드는 다음과 같다.

public enum EnumElvis {
    INSTANCE;

    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    
    public void printFavorite() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

열거 타입으로 싱글턴을 구현할 수 있지만, 모든 경우에 적용되는 것은 아니다. 만약 컴파일 타임에 어떤 인스턴스가 있는지 알기 어렵다면, 열거타입을 적용하기 어렵다. 이때는 readResolve로 직렬화한 인스턴스를 통제하면 된다.

 

4. readResolve() 접근성

  • final 클래스
    • private readResolve()
  • non-final 클래스
    • private readResolve() : 하위 클래스에서 사용 불가능
    • package-private readResolve() : 같은 패키지, 하위 클래스에서만 사용 가능
    • protected or public readResolve() : 재정의하지 않은 모든 하위 클래스에서 사용 가능하다
      • 다만, 재정의하지 않은 하위클래스를 역직렬화할 경우 상위 인스턴스 생성시 ClassCastException이 발생할 수 있다