질문의 역사: Java는 ObjectOutputStream 및 ObjectInputStream API를 통해 JDK 1.1에서 기본 바이너리 직렬화를 도입하여 객체 그래프가 지속성 또는 네트워크 전송을 위해 바이트 스트림으로 평평해지는 프로토콜을 확립했습니다. 이 규격은 ObjectInputStream이 대상 객체를 sun.misc.Unsafe 또는 직접 리플렉션을 사용하여 메모리를 할당한다고 명시하고 있으며, 이는 생성자를 완전히 우회합니다. 이 설계 선택은 인스턴스화를 제한하기 위해 비공개 생성자에 의존하는 싱글톤 패턴과 근본적으로 충돌합니다.
문제: 클래스가 Serializable을 구현하면, 역직렬화 프레임워크는 생성자 논리를 실행하지 않고 allocateInstance를 호출하여 새로운 인스턴스를 생성합니다. 비공식 생성자와 정적 팩토리를 통해 유일한 존재를 enforce하는 싱글톤의 경우, 이러한 침입은 힙 내에서 두 개의 별개 객체를 생성하여 정체성 동등성 보장을 깨뜨립니다. 결과적으로, 전역이 되도록 설계된 정적 상태는 여러 인스턴스에 분할되어 응용 프로그램이 단일 제어 지점에 의존하는 데에 있어 일관성이 없는 동작을 초래합니다.
해결책:
readResolve 메서드는 Serializable 계약에서 정의된 역직렬화 후크로, 클래스를 통해 역직렬화된 객체를 반환되기 전에 정적 인스턴스로 대체할 수 있습니다. protected Object readResolve() throws ObjectStreamException와 정확한 시그니처를 가진 메서드를 선언함으로써, 개발자는 새로 생성된 중복을 가로채고 정적 INSTANCE 필드를 대신 반환할 수 있습니다. 이 대체는 스트림 해상도 과정 내에서 원활하게 발생하며, 잘못된 객체를 가비지 수집으로 폐기하면서 싱글톤의 무결성을 유지합니다.
public class Configuration implements Serializable { private static final Configuration INSTANCE = new Configuration(); private String dbUrl; private Configuration() { this.dbUrl = System.getenv("DB_URL"); } public static Configuration getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }
DatabaseConfig 싱글톤이 연결 풀 매개변수 및 자격 증명을 관리하는 분산 마이크로서비스 아키텍처를 고려해 보십시오. 서비스는 이 구성을 직렬화하여 배포 후의 차가운 시작을 가속하기 위해 Redis와 같은 분산 캐시에 저장합니다. 수평 확장 이벤트 중에 새 서비스 인스턴스는 이 이진 블롭을 검색하고 역직렬화하여 기본 역직렬화 프로토콜을 우발적으로 트리거합니다.
방어 조치가 없으면 ObjectInputStream은 JVM에 저장된 정적 INSTANCE와는 별개의 DatabaseConfig 객체를 인스턴스화합니다. 이러한 중복은 새 인스턴스가 정적 구조 동안 수행된 초기화 후크가 없으며, 이는 오래된 데이터베이스 끝점이나 초기화되지 않은 자격 제공자를 가리킬 수 있는 분리 뇌 시나리오를 만들어냅니다. 결과적으로 응용 프로그램은 중복 연결 풀의 생성으로 인해 자원 누수를 겪으며, 데이터베이스 연결 한계를 초과하고 클러스터 전반에 걸쳐 연쇄적인 실패를 초래합니다.
한 가지 접근 방식은 싱글톤을 Enum 유형으로 변경하여 JVM이 명세에 따라 열거형이 싱글톤임을 보장하고 설계에 따라 직렬화저항을 제공하도록 활용하는 것입니다. 장점: 직렬화 메커니즘은 이름 조회를 통해 열거형 상수를 자동으로 처리하며, 인스턴스 생성 자체를 방지합니다. 단점: 열거형은 추상 클래스를 확장할 수 없으므로 아키텍처 유연성이 제한되며, 지연 초기화 의미가 없어져 클래스 초기화 중에 무거운 구성 내용을 조기에 로드할 수 있습니다.
또는 기존 클래스 내에서 readResolve 메서드를 구현하면 역직렬화 완료 후 정적 INSTANCE를 반환할 수 있습니다. 장점: 이 방법은 상속 계층을 유지하고 복잡한 초기화 논리를 지원하며 중복 생성을 명시적으로 방지합니다. 단점: 개발자는 이 메서드를 간과하는 경우가 많으며, 싱글톤 인스턴스화 자체가 지연 초기화되고 정적 초기화 중에 스레드 안전성이 보장되지 않는 경우 신중한 동기화가 필요합니다.
세 번째 옵션은 Externalizable로 전환하여 writeExternal 및 readExternal를 통해 직렬화 스트림을 수동으로 제어하여 전체 상태가 아닌 구성 식별자만 기록하는 것입니다. 장점: 이는 객체 내부를 직렬화하지 않고 인스턴스 생성 공격을 방지하며, 대신 readExternal 동안 안전한 저장소에서 구성을 가져옵니다. 단점: 이는 상당한 보일러플레이트 코드를 도입하며 애플리케이션 버전 간의 스트림 형식에 대한 이전 호환성을 유지해야 하므로 유지 관리 부담이 증가합니다.
엔지니어링 팀은 DatabaseConfig가 공유 감사 로깅 기능을 위해 추상 BaseConfiguration 클래스를 확장해야 했기 때문에 솔루션 2인 readResolve를 구현하여 정적 INSTANCE를 반환하기로 선택했습니다. 이들은 동기화 문제를 피하기 위해 지연 초기화를 피하고 역직렬화가 발생하기 전에 싱글톤이 존재하도록 보장했습니다. 이 접근 방식은 최소한의 코드 간섭과 중복 인스턴스 취약점에 대한 강력한 보호를 균형 있게 유지했습니다.
구현 후 부하 테스트를 통해 캐시된 구성을 역직렬화해도 동일한 객체 참조가 반환되어 중복 연결 풀이 제거됨을 확인했습니다. 서비스는 데이터베이스 연결 소모 없이 수평으로 확장되었으며, 메모리 프로파일링을 통해 가비지 수집 주기 후에 힙에 추가 DatabaseConfig 인스턴스가 남아있지 않음을 검증했습니다. 이 해결책은 아키텍처의 확장성을 유지하면서 직렬화 공격에 대한 싱글톤 계약을 강화했습니다.
역직렬화된 싱글톤에서 readObject와 readResolve 간의 상호작용이 일시적 필드 상태에 어떻게 영향을 미칩니까?
readObject는 스트림에서 전체 객체 상태를 재구성하며, 역직렬화가 완료된 후 JVM이 객체를 완전하다고 간주하기 전에 일시적 필드에 대한 사용자 정의 초기화 논리를 실행합니다. 그 후 readResolve가 실행되며, 만약 그것이 다른 정Canonical 인스턴스를 반환하면 JVM은 완전히 재구성된 임시 객체를 폐기하며, readObject 중 계산된 일시적 값을 포함합니다. 개발자는 readResolve 내에서 그러한 일시적 데이터가 필요한 경우 정Canonical 인스턴스로 복사해야 하며, 하지만 진정한 싱글톤에서는 일시적 필드가 일반적으로 직렬화 스트림보다 정Canonical 상태에서 다시 도출되어야 합니다.
왜 Externalizable을 구현하면 readResolve가 제공하는 보호를 우회할 수 있습니까?
Externalizable 인터페이스는 writeExternal 및 readExternal를 통해 클래스에 직렬화 제어를 완전히 이전하여 readResolve를 검사하는 표준 ObjectInputStream의 defaultReadObject 메커니즘을 우회합니다. readExternal이 새로 생성된 인스턴스를 채우면, 스트림은 이를 최종 객체로 간주하고 readResolve를 호출하지 않고 직접 반환합니다. 이러한 아키텍처적 차이로 인해 Externalizable을 사용하는 개발자는 일반적으로 InvalidObjectException을 발생시키거나 상태를 싱글톤에 명시적으로 병합하는 방식으로 readExternal 내에서 인스턴스 제어 논리를 수동으로 구현해야 합니다. 따라서 자동 대체 후크에 의존할 수 없습니다.
Java Record 유형 내에서 readResolve가 올바르게 작동하지 않도록 하는 요소는 무엇입니까?
레코드는 반사 기반 필드 채우기 대신 그들의 정Canonical 생성자 및 구성 요소 접근자 메서드를 통해 직렬화 및 역직렬화되므로, 역직렬화 과정에서 readResolve가 대체할 수 있는 빈 껍질 객체를 생성하지 않습니다. JVM은 역직렬화된 구성 요소 값을 사용하여 정Canonical 생성자를 호출하여 레코드를 재구성하고, readResolve는 인스턴스가 완전히 생성되고 변하지 않기 때문에 적용할 수 없습니다. 레코드로 싱글톤과 유사한 동작을 구현하려면 개발자는 대신 @Serial로 표시된 정적 팩토리 메서드를 사용하여 사용자 정의 직렬화 프록시를 생성하거나, 엄격한 인스턴스 제어가 필요한 경우 표준 클래스를 사용하는 것으로 레코드를 포기해야 합니다.