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)

💡 직렬화 프록시 패턴이란?

직렬화 프록시 패턴은 쉽게 말해 "진짜 객체 대신 가짜(프록시)를 직렬화하자"는 아이디어다. 직접 객체를 직렬화하면 위험하니, 그 객체의 데이터만 담은 간단한 도우미 클래스를 만들어서 그것을 대신 직렬화하는 것이다.

🎭 비유하자면: 중요한 상품(객체)을 그대로 배송(직렬화)하는 대신, 상품의 설계도(프록시)만 배송하고 도착지(역직렬화)에서 그 설계도로 상품을 새로 만드는 것과 같다.

💫 작동 원리:

  1. 원본 객체 안에 작은 내부 클래스(프록시)를 만든다

  2. 직렬화할 때는 원본 대신 이 프록시가 대신 나간다

  3. 역직렬화할 때는 프록시가 원본 객체를 정상적인 방법으로 새로 만들어 준다

직렬화 프록시 패턴의 구현 단계

  1. 대리인 만들기: 원본 클래스 안에 내부 클래스로 프록시를 만듭니다

  2. 대리 배송 준비: writeReplace 메서드를 만들어서 "직렬화할 때는 나 대신 내 프록시를 내보내라"고 지시합니다

  3. 직접 배송 차단: readObject 메서드를 만들어 "누군가 프록시 없이 직접 나를 역직렬화하려 하면 오류를 발생시켜라"고 지시합니다

  4. 복원 지시서 첨부: 프록시 안에 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("프록시가 필요합니다");
    }
}

이제 직렬화 공격으로부터 안전한 클래스가 되었다!

👨‍🏫 위 코드가 실제로 어떻게 작동하는지 쉽게 이해해보자:

  1. Period 객체를 직렬화하려고 하면:

    • 자바는 먼저 writeReplace()를 확인함

    • 이 객체는 직접 직렬화하지 말고 SerializationProxy를 대신 직렬화

    • SerializationProxy에는 날짜 데이터만 들어있어 안전하게 직렬화됨

  2. 역직렬화할 때:

    • 직렬화된 데이터에서 SerializationProxy 객체가 먼저 복원됨

    • 그 다음 readResolve()가 호출됨

    • 이제 진짜 Period 객체를 새로 만들 차례

    • 정상적인 Period 생성자를 통해 객체 생성 (여기서 모든 유효성 검사 수행!)

  3. 만약 누군가 악의적으로 Period를 직접 역직렬화하려 시도한다면:

    • readObject() 메서드가 발동해서 InvalidObjectException 예외를 던짐

    • 이렇게는 역직렬화할 수 없고, 프록시를 통해야만 합니다!


🎯 직렬화 프록시의 장점

🌟 강력한 보안성 (이 패턴이 왜 좋은가?)

직렬화 프록시 패턴은 직렬화와 관련된 여러 위험으로부터 클래스를 보호합니다:

  1. 객체의 규칙(불변식) 보장:

    • 일반 직렬화: "종료 시각이 시작 시각보다 늦어야 한다"는 규칙을 바이트 조작으로 깨트릴 수 있음

    • 프록시 패턴: 역직렬화 시 항상 정상 생성자를 통해 객체를 만들기 때문에 규칙이 항상 지켜짐

  2. 내부 구현 감추기:

    • 일반 직렬화: 객체의 모든 내부 구현이 바이트 스트림에 그대로 노출됨

    • 프록시 패턴: 꼭 필요한 논리적 상태만 직렬화하므로 내부 구현이 덜 노출됨

  3. 코드 진화에 유연함:

    • 일반 직렬화: 클래스 내부 구현을 바꾸면 이전에 직렬화된 데이터와 호환성 문제 발생

    • 프록시 패턴: 논리적 상태만 직렬화하므로 내부 구현을 마음대로 바꿀 수 있음

직렬화 프록시의 유용한 특징

  1. 내부 구현 자유: 역직렬화 시 내부 구현(RegularEnumSet, JumboEnumSet)을 자유롭게 선택 가능

  2. 클래스 진화: 향후 새로운 구현체로 변경 가능


⚠️ 직렬화 프록시의 한계

🚫 적용할 수 없는 경우

모든 클래스에 직렬화 프록시 패턴을 적용할 수 있는 것은 아닙니다:

  1. 객체 그래프가 순환적인 클래스

    • 직렬화 프록시로 순환 객체 그래프를 처리할 수 없음

  2. 클라이언트가 확장할 수 있는 클래스

    • 하위 클래스의 상태가 프록시에 포함되지 않을 수 있음

  3. 특정 메서드 재정의가 필요한 클래스

    • readObject나 writeObject로 특별한 처리가 필요한 경우

💸 성능 비용

  • 직렬화 프록시 패턴은 일반 직렬화보다 10-20% 정도 느림

  • 성능이 중요한 경우 트레이드오프 고려 필요


📊 직렬화 방식 비교

방식
👍 장점
👎 단점

일반 직렬화

• 구현 간단 • 높은 성능

• 보안 취약점 • 불변식 깨질 위험 • 구현 변경 어려움

직렬화 프록시 패턴

• 높은 보안성 • 불변식 보장 • 유연한 구현 변경

• 성능 저하 • 순환 참조 처리 불가 • 구현 복잡함

방어적 readObject

• 일반 직렬화보다 안전 • 성능 유지

• 구현 오류 가능성 • 내부 구현 변경 어려움


✅ 구현 체크리스트


🎯 결론

💡 직렬화 프록시 패턴을 사용하기 좋은 클래스:

  1. 가장 좋은 대상:

    • 불변식이 있는 클래스들 (예: 날짜 범위, 양수만 허용하는 수치 클래스 등)

    • 불변 클래스들 (한번 만들면 내부 값이 변하지 않는 클래스)

  2. 꼭 고려해볼 대상:

    • 나중에 내부 구현을 바꿀 가능성이 높은 클래스들

    • 해킹 위험이 있어 보안이 중요한 클래스들

💡 현실적인 조언:

  • 🥇 최선책: 가능하면 직렬화 자체를 안 하는 게 제일 좋다

  • 🥈 차선책: 직렬화가 필요하다면 직렬화 프록시 패턴을 고려하자

  • ⚖️ 균형: 좀 느려도 안전한 게 좋을지, 빠르지만 위험할 수 있는지 결정하자


💭 느낀 점

💡 직렬화는 자바의 강력한 기능이지만, 그만큼 위험성도 크다는 것을 배웠다

💡 직렬화 프록시 패턴은 개념적으로 단순하지만 강력한 방어책이 될 수 있다는 점이 인상적이었다

💡 좋은 API 설계는 내부 구현 변경의 자유를 확보하는 것도 포함한다는 점을 다시 한번 상기하게 되었다

Last updated