반응형

1. 개요

 readObject 메서드의 역할을 이해하고, 방어적으로 작성해야하는 이유를 알아보자.

 

2. 직렬화, 역직렬화 예제

 일반적으로 Object를 직렬화할 땐 ObjectOutputStream을, 역직렬화 할땐 ObjectOutputStream 인스턴스 인스턴스를 사용하고, 각각 writeObject(), readObject() 메서드를 호출하여 실제 데이터를 처리한다. 간단한 예제를 통해 직렬화와 역직렬화의 흐름을 먼저 이해해보자.

1) Period 인스턴스에 대한 직렬화

 

 

public final class Period implements Serializable {

   private Date start;
   private Date end;

   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(start + " after " + end);
       }
   }

   public Date start() {
       return new Date(start.getTime());
   }

   public Date end() {
       return new Date(end.getTime());
   }
}

 

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date startDate = df.parse("2024-11-14");
Date endDate = df.parse("2024-11-15");
try(
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos)){

    oos.writeObject(new Period(startDate, endDate));

    byte[] serialized = baos.toByteArray();
    for(byte b : serialized){
        System.out.print(b +" ");
    }
}

 

결과화면은 다음과 같다.

 

직렬화되어 byte[] 타입으로 변환된 Period 인스턴스

 

 

2) 직렬화된 byte[] 데이터에 대한 역직렬화

    public static void main(String[] args) throws IOException, ParseException {

        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
        Date startDate = df.parse("2024-11-14");
        Date endDate = df.parse("2024-11-15");
        try(
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ObjectOutputStream oos = new ObjectOutputStream(baos)){

            oos.writeObject(new Period(startDate, endDate));  // 직렬화

            byte[] serialized = baos.toByteArray();

            Period period = deserializePeriod(serialized); // 역직렬화
            System.out.println(period.start());
            System.out.println(period.end());
        }
    }
    
    public static Period deserializePeriod(byte[] bytes) throws IOException {

        try(
                ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
                ObjectInputStream ois = new ObjectInputStream(bais)){

                Object object = ois.readObject();
                return (Period) object;
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

 

결과 화면은 다음과 같다.

역직렬화 후 메서드 출력 결과

 

 

3. readObject() 메서드의 문제 #1

readObject() 메서드가 수행하는 역할을 보면 byte[] 데이터를 기반으로 인스턴스를 생성하는 역할을 한다. 즉, 생성자의 역할을 하는 것이다. 일반적으로 생성자에는 validation을 통해 데이터의 불변식을 보장한다. 하지만 바이트 데이터가 조작되어 불변식이 깨진 Object에 대한 byte[] 데이터가 들어온다면 readObject() 메서드를 호출했을 때 별다른 validation 체크를 하지 않기때문에 불변식을 보장하지 못하게 된다.

public class BogusPeriod {
    
    // 정상적이지 않은 바이트스트림
    private static final byte[] serializedForm = {
        (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
        0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
        0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
        0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
        0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
        0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
        0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
        0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
        0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
        (byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
        0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
        0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
        0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
        0x00, 0x78
    };

    public static void main(String[] args) {
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }
    
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}

 

출력하면 다음과 같이 start보다 end가 더 이르다는 것을 확인할 수 있다. 생성자를 사용하지 않기 때문에 validation 체크가 이루어지지 않고, 최종적으로 생성된 Period 객체는 불변식이 깨져버렸다.

 


4. readObject() 메서드 커스텀

 

이를 해결하기 위해 readObject() 메서드를 생성해야한다. 역직렬화할 클래스 내에 private 접근 제어자로 readObject() 메서드가 정의되어 있을 경우 ObjectInputStream.readObject() 메서드 호출 시 새로 정의한 메서드를 호출하기 때문이다. (리플렉션 기반)

 단, 반드시 privcate 접근 제어자로 선언해야하고, 메서드 내에서 defaultWriteObject(),  defaultReadObject()와 같은 기본 직렬화, 역직렬화 메서드를 한번 호출해줘야한다.

 

public final class Period implements Serializable {
    private Date start;
    private Date end;

    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(start + " after " + end);
        }
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
        s.defaultReadObject();
        if (this.start.compareTo(this.end) > 0) { // 유효성 검사
            throw new InvalidObjectException(start + " after " + end);
        }
    }
}

 

그 후, 불변식이 깨진 byte[] 데이터를 넣어 역직렬화를 해보면 유효하지 않는 오브젝트로 판단하여 역직렬화에 대한 예외를 발생시킨다.

역직렬화 실패

 


5. readObject() 메서드의 문제 #2

하나의 문제가 더 있다. 직렬화된 스트림에 객체 참조 관련 바이트코드를 추가하면, 가변 Period 인스턴스를 만들 수 있다. Period 인스턴스를 가변 인스턴스로 사용할 수 있게 하는 MutablePeriod 클래스를 만든다.

public class MutablePeriod {
   public final Period period;

   public final Date start;

   public final Date end;

   public MutablePeriod() {
       try {
           ByteArrayOutputStream bos = new ByteArrayOutputStream();
           ObjectOutputStream out = new ObjectOutputStream(bos);

           // 불변식을 유지하는 Period 를 직렬화.
           out.writeObject(new Period(new Date(), new Date()));

           /*
            * 악의적인 start, end 로의 참조를 추가.
            */
           byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // 악의적인 참조
           bos.write(ref); // 시작 필드
           ref[4] = 4; // 악의적인 참조
           bos.write(ref); // 종료 필드

           // 역직렬화 과정에서 Period 객체의 Date 참조를 훔친다.
           ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
           period = (Period) in.readObject();
           start = (Date) in.readObject();
           end = (Date) in.readObject();
       } catch (IOException | ClassNotFoundException e) {
           throw new AssertionError(e);
       }
   }

 

여기서 직렬화한 바이트 코드에 [0x71, 0, 0x7e, 0, 5], [0x71, 0, 0x7e, 0, 4] 코드를 추가한다. 이 값은 Java 직렬화 프로토콜에서 객체 참조를 나타내는 바이트 코드이다.

 

    public static void main(String[] args) {
        MutablePeriod mp = new MutablePeriod();
        Period mutablePeriod = mp.period; // 불변 객체로 생성한 Period
        Date pEnd = mp.end; // MutablePeriod 클래스의 end 필드
        
        pEnd.setYear(78); // MutablePeriod 의 end 를 바꿨는데 ?
        System.out.println(mutablePeriod.end()); // Period 의 값이 바뀐다.
        
        pEnd.setYear(69);
        System.out.println(mutablePeriod.end());
    }

 

결과적으로 이러한 방법을 사용하게 되면, 불변 객체로 생성되어야할 Period 인스턴스의 end 값이 수정됨을 알 수 있다.

이를 해결하기 위해서는 readObject() 메서드 재정의 시 방어적 복사 코드를 추가해야한다.

 

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
   s.defaultReadObject();

   // 방어적 복사를 통해 인스턴스의 필드값 초기화
   start = new Date(start.getTime());
   end = new Date(end.getTime());

   // 유효성 검사
   if (start.compareTo(end) > 0)
       throw new InvalidObjectException(start +" after "+ end);
}

 

 

정리

 이런 문제는 누군가 고의적으로 바이트코드를 위조한 상황을 가정했을 때 발생하는 문제들이다. 직렬화한 코드를 아무나 접근할 수 있는 외부 서비스에 보냈을 때 불변식이 깨지고, 객체 참조까지 되는 것을 확인할 수 있다.

 앞선 챕터에서 Serializable은 신중하게 작성하라라는 내용이 있었다. 직렬화된 클래스가 외부로 나갔을 때 발생할 수 있는 여러 문제들이 있기 때문이다.

 이번 챕터 역시 이러한 문제를 겪지 않으려면 직렬화를 신중히 하고, 만약 직렬화가 필요하다면, 외부에서 접근할 수 없는 환경에 저장하고, 로드할 수 있도록 하는 것이 중요하다는 것을 다시 한번 느끼게 되었다.

반응형
반응형

1. 개요

직렬화(Serializable)의 개념을 이해하고, Serializable을 구현할 때 주의해야할 점을 알아보자.

 

2. 직렬화(Serializable)란?

 

인스턴스 >> Byte

 

인스턴스를 Byte로 변환하는 것을 말한다. 간혹 클래스를 Byte로 변환하는 것이라고도 하는데 목적 측면에서 보거나, 일반적으로 사용하는 방식을 보면 인스턴스를 직렬화하는게 더 맞는 표현이라고 생각한다.

 

 

3. 직렬화를 하는 이유

그렇다면 직렬화를 하는 이유는 뭘까? 도대체 뭘 위해서 직렬화를 하는걸까?

 

 

 

바로 인스턴스를 저장하거나, 전송하기 위함이다.

 

그럼 인스턴스를 저장하거나 전송하는 이유는 뭘까?

 

 

 

객체의 상태를 저장해야할 상황이 있기 때문이다. 아래 예시를 통해 알아보자.

 

4. 객체의 상태를 저장해야할 상황 

1) HTTP 세션 관리

 Spring 웹 어플리케이션에서는 HTTP 세션을 사용해 사용자 상태를 유지한다. 사용자의 ID나 이름, 국적 등과 같은 '상태값'을 세션에 저장해두고 필요할때마다 가져온다.

 만약 세션 클러스터링 환경이라면 어떨까? 세션을 다른 서버와 공유해야한다. 정확히 말하면 상태값을 갖고 있는 인스턴스를 다른 서버에 전달해야한다. 이를 위해 직렬화하고, Byte 데이터를 Stream 방식으로 전달하는 것이다.

 

2) 인메모리 저장소 활용(ex. Redis)

spring Session이나 캐싱기능을 위해 저장소로 Redis를 사용하고자 한다면, Redis에 저장할 인스턴스의 클래스에 Serializable을 구현했을 것이다. 상태 값을 가진 인스턴스를 Redis라는 외부 저장소에 전달해야하기 때문이다. 이때도 마찬가지로 외부로 전달하기 위해 직렬화를 한다.

 

3) 영속적인 저장 / 데이터 복원

 HTTP 세션, 클러스터링 환경, 인메모리 저장소는 서버가 재시작되면 저장된 데이터가 날아간다. 서버가 재시작되도 상태값을 유지하고 싶다면 상태 값을 필드로 구분하여 DB에 저장하는 방법도 있지만, 인스턴스 자체를 DB에 저장하는 방법도 있다. 디스크에 저장하는 방식이다. 이 경우 서버에 문제가 생기거나 재시작이 되어도 데이터를 쉽게 복원할 수 있다.

 

5. Serializable 주의사항

 

주의할 점이 많은 Serializable

1) 릴리스 한 뒤에는 수정이 어렵다.

클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다. 누군가 역직렬화를 하면 클래스 구조와 상태값들을 확인할 수 있기 때문이다. 그래서 이 클래스가 널리 퍼지게 된다면, 그 직렬화 형태도 영원히 지원해야 한다. 만약 특정 필드를 제거하거나, 타입을 바꾸는 등의 수정을 한다면, 타 시스템의 역직렬화 과정에서 에러가 발생할 수 있기 때문이다.

직렬화 가능 클래스를 만들고자 한다면, 길게 보고 수정이 일어나지 않는 형태로 주의해서 설계해야한다.

 

2) 버그와 보안 구멍

객체는 생성자를 사용해 만드는게 기본이다. 역직렬화는 기본 방식을 우회하는 생성 기법이다. 마치 숨은 생성자와 같다. 생성자를 우회한다는건 객체 초기화에 필요한 모든 내부 코드를 무시한다는 측면에서 버그를 유발 가능성을 재기한다.

 또한 공격자가 객체 내부를 들여다 볼 수 있다면, 직렬화된 코드를 조작하여 서버로 전달할 수 있다. 해당 서버에서 조작된 메서드를 호출이라도 한다면 공격자가 의도한 작업을 서버 내에서 실행하게 될것이다.

따라서 공격자가 이러한 데이터를 볼 수 없는 환경, 즉 private한 환경에서 사용해야한다.

 

3) 신버전을 릴리스할 때 테스트가 늘어난다.

 직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화 한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 테스트 해야한다. 직렬화 가능 클래스의 수가 많을수록 테스트할 횟수도 증가한다.

 

4) 상속용으로 설계된 클래스나 인터페이스는 Serializable을 구현하거나 확장해선 안된다.

해당 클래스를 클래스를 상속하거나 구현하는 모든 클래스는 앞선 문제들에 위험을 안고가게 된다.

 

 

그런데... 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는게 아닐까? 필자의 경우도 그랬던 기억이 있었다. 

 

6. 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는거 아냐?

직렬화 클래스를 수정해도 구버전 데이터를 역직렬화할 수 있는 조건이 있다. 아래 두 조건을 모두 만족해야한다.

 

첫째, SerialVersionUID 가 일치할때

 클래스가 변경되었지만 serialVersionUID가 동일하다면 역직렬화가 가능하다. 클래스 구조가 약간 달라져도 serialVersionUID가 같다면 역직렬화를 시도할 수 있다.

 

둘째, 새로운 필드가 추가되거나 제거됐을 때

 - 직렬화된 클래스에 신규 필드를 추가할 경우, 역직렬화 시 해당 필드는 기본값 (null, 0, false) 등으로 설정된다.

 - 기존 필드가 삭제되어도 역직렬화가 가능하다. 역직렬화 시 해당 필드는 무시된다.

 

위 두 조건을 만족하면 직렬화 클래스가 수정되도 역직렬화가 가능하다.

단, 클래스의 인터페이스 혹은 상속 구조를 변경하거나, 필드 타입, 클래스 이름이 변경되면 역직렬화에 실패하게 된다.

 

serialVersionUID
클래스의 직렬 버전을 나타내는 ID 로 모든 직렬화된 클래스는 이 값을 부여받는다. 만약 필드에 명시한다면 그 값이 ID가 되겠지만, 명시하지 않는다면 클래스의 정보를 기반으로 해시함수를 적용해 UID가 자동으로 부여된다.

 

즉, 추후 조금의 수정 가능성을 염두해둔다면 SerialVersionUID의 값을 지정해주는 것이 좋다.

 

 

7. 정리

 Serializable은 구현하기는 아주 쉽지만, 주의해야할 사항이 많다. 때문에 클래스의 여러 버전이 상호작용할 일이 없고, 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니라면 Serializable 구현은 아주 신중하게 이뤄져야한다. 상속할 수 있는 클래스라면 주의사항이 더욱 많아진다.

 

그럼 2만

퇴긘~

반응형

+ Recent posts