Effective Java ‐ Item 85⚠️ - dnwls16071/Backend_Summary GitHub Wiki

아이템 85 - 자바 직렬화의 대안을 찾으라

직렬화란 뭘까요?

  • 넓은 의미로 직렬화는 어떤 데이터를 다른 데이터의 형태로 변환하는 것을 말합니다.
  • 이팩티브 자바에서 말하는 직렬화(Serializable)란 바이트 스트림으로의 직렬화로 객체의 상태를 바이트 스트림으로 변환하는 것을 의미합니다.
  • 반대로 바이트 스트림에서 객체의 상태로 변환하는 건 역직렬화(Deserializable)라고 부릅니다.

그럼 바이트 스트림이란 뭘까요?

  • 스트림은 데이터의 흐름입니다. 데이터의 통로라고도 이야기하는데요.
  • 예를 들어, 웹 개발을 하다보면 클라이언트에서 서버에게 데이터를 보내는 일이 있습니다.
  • 이처럼 스트림은 클라이언트와 서버같이 어떤 출발지와 목적지로 입출력하기 위한 통로를 말합니다.
  • 자바는 이런 입출력 스트림의 기본 단위를 바이트로 두고 있고 입력으로는 InputStream, 출력으로는 OutputStream라는 추상클래스로 구현되어 있습니다.

왜 직렬화를 사용할까요?

  • 일단 자바에서 표현된 객체를 목적지에 보냈다고 했을 때 그 곳에서 아 이게 자바 객체구나~하고 바로 알 수가 없습니다. 목적지에서 객체를 알 수 있는 방법이 없습니다. 그래서 모두 다 알 수 있는 것으로 변환을 해줘야 합니다.
  • 여기서 변환을 도와주는 방법이 직렬화이며 직렬화를 통해서 바이트 스트림으로 변환해줄 수 있습니다.

왜 굳이 바이트 스트림으로 변환할까요?

  • 바이트인 이유는 컴퓨터에서 기본으로 처리되는 최소 단위가 바이트이기 때문입니다. 완전 최소 단위로 가면 비트로도 처리할 수 있겠지만 표현할 수 있는 방법이 0과 1로 너무 적어 하나의 단위로 묶었다고 합니다.
  • 이렇게 바이트 스트림으로 변환해야 네트워크, DB로 전송할 수 있고 목적지에서도 처리를 해줄 수 있습니다. 쉽게 생각하면 '출발지와 목적지 모두 알아들을 수 있는 byte라는 언어로 소통할 수 있도록' 이라고 말할 수 있겠네요.

바이트 직렬화 예시

  • 간단한 예시로 문자열을 서버로 보내는 TCP 클라이언트를 개발한다고 합시다.
  • 이때 문자열 → 바이트 직렬화가 사용됩니다.
  • 서버에 메시지를 보내기 위해 소켓에서 OutputStream(출력 스트림)을 가져와 출력하고 싶은 문자열(message)을 바이트로 바꿔 쓰게(write)됩니다.
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 5001));

byte[] bytes;
String message;

OutputStream os = socket.getOutputStream(); // 출력 스트림
message = "이펙티브 자바 파이팅~";
bytes = message.getBytes(StandardCharsets.UTF_8); // 문자열 -> 바이트
os.write(bytes); // 출력 스트림에 바이트를 쓰고
os.flush(); // flush를 날리면 그 소켓으로 출력이 된다

문자열 직렬화 예시

  • 웹 개발에서 자주 사용하는 데이터 형태인 JSON도 문자열 직렬화가 사용됩니다.
  • JSON 형태로 문자열 직렬화를 해줘야 합니다. Spring Boot에서는 Jackson2가 모두 해주고 있습니다.
@DisplayName("JSON 직렬화 테스트")
@Test
void jsonSerializable() {
    Person person = new Person("bingbong", 21);
    String json = String.format("{\"name\":\"%s\",\"age\":%d}", person.name, person.age);

    assertThat(json).isEqualTo("{\"name\":\"bingbong\",\"age\":21}");
}

어떻게 객체에서 바이트 직렬화를 할까요?

  • 객체에 Serializable 이라는 인터페이스를 구현하면 됩니다.
  • 참고로 Serializable 은 안에 아무것도 선언되어있지 않습니다. 이를 마커 인터페이스라고 부르는데요.
  • 말 그대로 직렬화를 할 수 있는 객체라고 알려주는 것입니다.
  • 그리고 나서 목적지까지 가는 바이트 스트림에 writeObject를 사용해주시면 됩니다.
@DisplayName("Serializable을 구현한 Person 객체 직렬화 테스트")
@Test
void writeObjectTest() throws IOException {
    Person person = new Person("bingbong", 21);

    byte[] serializedPerson;
    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(person);
            // 직렬화된 Person 객체
            serializedPerson = baos.toByteArray();
        }
    }

    // -84, -19, 0, 5, 115, 114, 0, 57, 99, 111, 109, 46, 98, 105, 110, 103, 98, 111, 110, 103, 46, 101, 102, 102, 101, 99, 116, 105, 118, 101, 106, 97, 118, 97, 46, 105, 116, 101, 109, 56, 53, 46, 83, 101, 114, 105, 97, 108, 105, 122, 97, 98, 108, 101, 84, 101, 115, 116, 36, 80, 101, 114, 115, 111, 110, -126, 113, -121, -86, 125, 92, 57, 9, 2, 0, 2, 73, 0, 3, 97, 103, 101, 76, 0, 4, 110, 97, 109, 101, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 21, 116, 0, 8, 98, 105, 110, 103, 98, 111, 110, 103
    assertThat(serializedPerson).isNotEmpty();
}

static class Person implements Serializable {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

그렇다면 역직렬화는?

@DisplayName("Serializable을 구현한 Person 객체 직렬화 후, 역직렬화 테스트")
@Test
void writeObjectTest2() throws IOException {
    Person person = new Person("bingbong", 21);

    byte[] serializedPerson;
    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(person);
            // 직렬화된 Person 객체
            serializedPerson = baos.toByteArray();
        }
    }

    Person deSerializedPerson = null;

    try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedPerson)) {
        try (ObjectInputStream ois = new ObjectInputStream(bais)) {
            // 역직렬화된 Person 객체
            deSerializedPerson = (Person) ois.readObject();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    assertThat(deSerializedPerson).isNotNull();
    assertThat(deSerializedPerson.name).isEqualTo("bingbong");
    assertThat(deSerializedPerson.age).isEqualTo(21);
}

이런 직렬화가 왜 문제가 될까요?

  • 직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점입니다.
  • 직렬화를 하고 나서 역직렬화를 할 때 문제가 됩니다.
  1. 객체를 읽는 readObject 메서드는 클래스 패스에 존재하는 거의 모든 타입의 객체를 만들어낼 수 있습니다.
    1. 반환 타입이 Object입니다
  2. 바이트 스트림을 역직렬화하는 과정에서 해당 타입 안의 모든 코드를 수행할 수 있습니다.
    1. 객체를 아예 불러올 수 있으므로 모든 코드를 수행할 수 있습니다.
  3. 그렇기에 타입 전체가 전부 공격 범위에 들어가게 됩니다.
  4. 용량도 다른 포맷에 비해서 몇 배 이상의 크기를 가집니다
  5. 또한 역직렬화 폭탄을 맞을 수도 있습니다.
  • 바이트 스트림으로 직렬화하는데는 시간이 별로 걸리지 않지만 역직렬화를 잘못하면 안으로 들어가서 hashCode 메서드를 계속 호출해야하기 때문에 시간이 엄청 오래걸립니다. 아래 예시가 있습니다.
@DisplayName("역직렬화 폭탄 테스트")
@Test
void deserializeBomb() {
    byte[] bomb = bomb();

    // 역직렬화를 하면 엄청 많은 시간이 걸린다.
    deserialize(bomb);
    assertThat(bomb).isNotEmpty();
}

static byte[] bomb() {
    Set<Object> root = new HashSet<>();
    Set<Object> s1 = root;
    Set<Object> s2 = new HashSet<>();
    for (int i = 0; i < 100; i++) {
        Set<Object> t1 = new HashSet<>();
        Set<Object> t2 = new HashSet<>();
        t1.add("foo");
        s1.add(t1);
        s1.add(t2);
        s2.add(t1);
        s2.add(t2);
        s1 = t1;
        s2 = t2;
    }
    return serialize(root); // 직렬화 수행
}
bookmark

그럼 어떻게 해야하나?

  • 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이라고 합니다. 직렬화를 피할 수 없고 역직렬화한 데이터가 안전한지 완전히 확신할 수 없다면 java 9에 나온 ObjectInputFilter를 사용하는 것도 방법입니다. 이는 데이터 스트림이 역직렬화되기 전에 필터를 적용해서 특정 클래스를 받아들이거나 거부할 수 있습니다.

출처

⚠️ **GitHub.com Fallback** ⚠️