90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
📝 아이템 90: 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
🔹 핵심 요약
✅ Serializable을 구현한 클래스는 생성자 외의 방법으로도 인스턴스가 생성될 수 있어 취약점이 발생할 수 있음 ✅ 직렬화 프록시 패턴은 객체의 직렬화를 위임하여 보안 및 견고성 문제를 해결할 수 있음 ✅ 불변식이 복잡한 객체나 불변 클래스에 특히 유용한 방법 ✅ 성능상 비용이 있지만 보안과 견고성이 중요한 클래스에 적극 검토할 가치가 있음
📚 필수 개념 정리
🧩 직렬화와 취약점
직렬화(Serialization)
는 자바 객체를 바이트 스트림으로 변환하는 과정이며, 이는 객체를 파일에 저장하거나 네트워크로 전송할 때 유용하다.
하지만 직렬화는 일반 생성자를 우회하는 객체 생성 방법을 제공하므로 여러 취약점이 발생할 수 있다.
🔄 직렬화 취약점 예시
// 불변식을 지켜야 하는 기간(Period) 클래스
public final class Period implements Serializable {
private final Date start;
private final Date end;
/**
* @param start 시작 시각
* @param end 종료 시각
* @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException("시작 시각이 종료 시각보다 늦습니다.");
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
// 나머지 코드 생략
}
👇 이 클래스를 직렬화했다가 역직렬화하면 어떻게 될까요?
// 직렬화로 불변식을 깨뜨리는 공격
public static void main(String[] args) {
Period p = new Period(new Date(), new Date());
byte[] serialized = serialize(p);
// 직렬화된 바이트 스트림을 수정하여 불변식을 깨뜨림
// (바이트 스트림에서 날짜 필드 값 조작)
Period p2 = (Period) deserialize(serialized);
// p2는 시작 시각이 종료 시각보다 늦는 잘못된 상태!
}
👉 왜 이런 문제가 발생할까?
🚨 직렬화의 위험성
역직렬화는 생성자를 우회하는 인스턴스 생성 방법
바이트 스트림을 직접 조작하면 클래스의 불변식을 깨뜨릴 수 있음
객체의 내부 구현이 변경되면 이전 버전과의 호환성 문제 발생 가능
🔍 직렬화 프록시 패턴(Serialization Proxy Pattern)
💡 직렬화 프록시 패턴이란?
직렬화 프록시 패턴
은 쉽게 말해 "진짜 객체 대신 가짜(프록시)를 직렬화하자"는 아이디어다.
직접 객체를 직렬화하면 위험하니, 그 객체의 데이터만 담은 간단한 도우미 클래스를 만들어서 그것을 대신 직렬화하는 것이다.
🎭 비유하자면: 중요한 상품(객체)을 그대로 배송(직렬화)하는 대신, 상품의 설계도(프록시)만 배송하고 도착지(역직렬화)에서 그 설계도로 상품을 새로 만드는 것과 같다.
💫 작동 원리:
원본 객체 안에 작은 내부 클래스(프록시)를 만든다
직렬화할 때는 원본 대신 이 프록시가 대신 나간다
역직렬화할 때는 프록시가 원본 객체를 정상적인 방법으로 새로 만들어 준다
✨ 직렬화 프록시 패턴의 구현 단계
대리인 만들기: 원본 클래스 안에 내부 클래스로 프록시를 만듭니다
대리 배송 준비: writeReplace 메서드를 만들어서 "직렬화할 때는 나 대신 내 프록시를 내보내라"고 지시합니다
직접 배송 차단: readObject 메서드를 만들어 "누군가 프록시 없이 직접 나를 역직렬화하려 하면 오류를 발생시켜라"고 지시합니다
복원 지시서 첨부: 프록시 안에 readResolve 메서드를 만들어 "역직렬화될 때 원본 객체를 어떻게 다시 만들어야 하는지" 알려줍니다
// Period 클래스에 직렬화 프록시 패턴 적용
public final class Period implements Serializable {
private final Date start;
private final Date end;
// 생성자 및 접근자 메서드는 동일
// Period 클래스의 직렬화 프록시
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
// 직렬화 프록시 생성자
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID = 234098243823485285L; // 임의의 번호
// 역직렬화시 호출되어 바깥 클래스 인스턴스 생성
private Object readResolve() {
return new Period(start, end); // 정상적인 public 생성자 사용
}
}
// 직렬화 시 SerializationProxy 인스턴스로 대체
private Object writeReplace() {
return new SerializationProxy(this);
}
// 불법 역직렬화 시도 방어
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요합니다");
}
}
✅ 이제 직렬화 공격으로부터 안전한 클래스가 되었다!
👨🏫 위 코드가 실제로 어떻게 작동하는지 쉽게 이해해보자:
Period 객체를 직렬화하려고 하면:
자바는 먼저 writeReplace()를 확인함
이 객체는 직접 직렬화하지 말고 SerializationProxy를 대신 직렬화
SerializationProxy에는 날짜 데이터만 들어있어 안전하게 직렬화됨
역직렬화할 때:
직렬화된 데이터에서 SerializationProxy 객체가 먼저 복원됨
그 다음 readResolve()가 호출됨
이제 진짜 Period 객체를 새로 만들 차례
정상적인 Period 생성자를 통해 객체 생성 (여기서 모든 유효성 검사 수행!)
만약 누군가 악의적으로 Period를 직접 역직렬화하려 시도한다면:
readObject() 메서드가 발동해서 InvalidObjectException 예외를 던짐
이렇게는 역직렬화할 수 없고, 프록시를 통해야만 합니다!
🎯 직렬화 프록시의 장점
🌟 강력한 보안성 (이 패턴이 왜 좋은가?)
직렬화 프록시 패턴은 직렬화와 관련된 여러 위험으로부터 클래스를 보호합니다:
객체의 규칙(불변식) 보장:
일반 직렬화: "종료 시각이 시작 시각보다 늦어야 한다"는 규칙을 바이트 조작으로 깨트릴 수 있음
프록시 패턴: 역직렬화 시 항상 정상 생성자를 통해 객체를 만들기 때문에 규칙이 항상 지켜짐
내부 구현 감추기:
일반 직렬화: 객체의 모든 내부 구현이 바이트 스트림에 그대로 노출됨
프록시 패턴: 꼭 필요한 논리적 상태만 직렬화하므로 내부 구현이 덜 노출됨
코드 진화에 유연함:
일반 직렬화: 클래스 내부 구현을 바꾸면 이전에 직렬화된 데이터와 호환성 문제 발생
프록시 패턴: 논리적 상태만 직렬화하므로 내부 구현을 마음대로 바꿀 수 있음
✨ 직렬화 프록시의 유용한 특징
내부 구현 자유: 역직렬화 시 내부 구현(RegularEnumSet, JumboEnumSet)을 자유롭게 선택 가능
클래스 진화: 향후 새로운 구현체로 변경 가능
⚠️ 직렬화 프록시의 한계
🚫 적용할 수 없는 경우
모든 클래스에 직렬화 프록시 패턴을 적용할 수 있는 것은 아닙니다:
객체 그래프가 순환적인 클래스
직렬화 프록시로 순환 객체 그래프를 처리할 수 없음
클라이언트가 확장할 수 있는 클래스
하위 클래스의 상태가 프록시에 포함되지 않을 수 있음
특정 메서드 재정의가 필요한 클래스
readObject나 writeObject로 특별한 처리가 필요한 경우
💸 성능 비용
직렬화 프록시 패턴은 일반 직렬화보다 10-20% 정도 느림
성능이 중요한 경우 트레이드오프 고려 필요
📊 직렬화 방식 비교
일반 직렬화
• 구현 간단 • 높은 성능
• 보안 취약점 • 불변식 깨질 위험 • 구현 변경 어려움
직렬화 프록시 패턴
• 높은 보안성 • 불변식 보장 • 유연한 구현 변경
• 성능 저하 • 순환 참조 처리 불가 • 구현 복잡함
방어적 readObject
• 일반 직렬화보다 안전 • 성능 유지
• 구현 오류 가능성 • 내부 구현 변경 어려움
✅ 구현 체크리스트
🎯 결론
💡 직렬화 프록시 패턴을 사용하기 좋은 클래스:
가장 좋은 대상:
불변식이 있는 클래스들 (예: 날짜 범위, 양수만 허용하는 수치 클래스 등)
불변 클래스들 (한번 만들면 내부 값이 변하지 않는 클래스)
꼭 고려해볼 대상:
나중에 내부 구현을 바꿀 가능성이 높은 클래스들
해킹 위험이 있어 보안이 중요한 클래스들
💡 현실적인 조언:
🥇 최선책: 가능하면 직렬화 자체를 안 하는 게 제일 좋다
🥈 차선책: 직렬화가 필요하다면 직렬화 프록시 패턴을 고려하자
⚖️ 균형: 좀 느려도 안전한 게 좋을지, 빠르지만 위험할 수 있는지 결정하자
💭 느낀 점
💡 직렬화는 자바의 강력한 기능이지만, 그만큼 위험성도 크다는 것을 배웠다
💡 직렬화 프록시 패턴은 개념적으로 단순하지만 강력한 방어책이 될 수 있다는 점이 인상적이었다
💡 좋은 API 설계는 내부 구현 변경의 자유를 확보하는 것도 포함한다는 점을 다시 한번 상기하게 되었다
Last updated