Development/Code

자바 직렬화 사용을 피해야 하는 이유

Danny Seo 2024. 6. 25. 20:11

자바 직렬화 사용을 피해야 하는 이유

오늘날 대부분의 백엔드 서비스는 마이크로서비스 아키텍처를 기반으로 구현됩니다. 서비스는 비즈니스 기능에 따라 분리되어 디커플링을 실현하지만, 이로 인해 새로운 과제가 생깁니다. 서로 다른 비즈니스 서비스 간의 통신은 인터페이스를 통해 구현되어야 합니다. 두 서비스 간에 데이터 객체를 공유하려면 객체를 이진 스트림으로 변환한 후 네트워크를 통해 다른 서비스로 전송하고, 다시 객체로 변환하여 서비스 메서드에서 사용할 수 있어야 합니다. 이 인코딩 및 디코딩 과정을 직렬화(Serialization) 및 역직렬화(Deserialization)라고 합니다.

 

동시 요청이 많은 상황에서는 직렬화가 느리면 요청 응답 시간이 길어질 수 있으며, 직렬화된 데이터 크기가 크면 네트워크 처리량이 감소할 수 있습니다. 따라서 뛰어난 직렬화 프레임워크는 시스템의 전체 성능을 향상할 수 있습니다.

 

자바는 RMI(Remote Method Invocation) 프레임워크를 제공하여 서비스 간의 인터페이스를 노출하고 호출할 수 있으며, RMI는 데이터 객체에 대해 자바 직렬화를 사용합니다. 그러나 현재의 주류 마이크로서비스 프레임워크는 자바 직렬화를 거의 사용하지 않습니다. 예를 들어 SpringCloud는 JSON 직렬화를 사용합니다. 그 이유는 무엇일까요?

 

오늘은 자바 직렬화에 대해 깊이 알아보고, 최근 몇 년간 인기를 끌고 있는 Protobuf 직렬화와 비교하여 Protobuf가 최적의 직렬화를 어떻게 달성하는지 살펴보겠습니다.

자바 직렬화

단점에 대해 논의하기 전에, 자바 직렬화가 무엇인지 그리고 어떻게 작동하는지 이해해야 합니다.

 

자바는 객체를 바이너리 형식(바이트 배열)으로 직렬화하여 디스크에 쓰거나 네트워크로 출력할 수 있는 직렬화 메커니즘을 제공합니다. 또한 네트워크나 디스크에서 바이트 배열을 읽어 프로그램에서 사용할 객체로 역직렬화할 수 있습니다.

 

JDK는 ObjectInputStream과 ObjectOutputStream이라는 두 개의 스트림 객체를 제공하며, 이는 Serializable 인터페이스를 구현한 클래스의 객체만 직렬화 및 역직렬화할 수 있습니다.

 

ObjectOutputStream의 기본 직렬화 방법은 객체의 비트랜지언트 인스턴스 변수를 직렬화합니다. 트랜지언트 인스턴스 변수나 정적 변수는 직렬화하지 않습니다.

 

Serializable 인터페이스를 구현하는 클래스에서는 serialVersionUID 버전 번호가 생성됩니다. 이 버전 번호의 목적은 역직렬화 시 직렬화된 객체가 역직렬화할 클래스와 일치하는지 확인하는 것입니다. 클래스 이름이 동일하더라도 버전 번호가 다르면 역직렬화에 실패합니다.

 

직렬화는 주로 writeObjectreadObject 메서드로 구현됩니다. 이 메서드들은 보통 기본값으로 제공되지만, Serializable 인터페이스를 구현하는 클래스에서 이를 재정의하여 직렬화 및 역직렬화 메커니즘을 사용자 정의할 수 있습니다.

 

추가로, 자바 직렬화는 writeReplace()readResolve()라는 두 가지 다른 메서드를 정의합니다. 전자는 직렬화 전에 직렬화된 객체를 교체하는 데 사용되며, 후자는 역직렬화 후 반환된 객체를 처리하는 데 사용됩니다.

자바 직렬화의 단점

일부 RPC 통신 프레임워크를 사용해보면, 이러한 프레임워크가 JDK에서 제공하는 직렬화를 거의 사용하지 않는다는 것을 알 수 있습니다. 일반적으로 잘 사용되지 않는 것은 비실용적일 가능성이 큽니다. JDK의 기본 직렬화의 단점을 살펴보겠습니다.

1. 언어 간 호환성 부족

현대 시스템 설계는 점점 더 다양해지고 있으며, 많은 시스템이 여러 언어를 사용하여 애플리케이션을 개발합니다. 예를 들어, 일부 대형 게임은 C++로 게임 서비스를 개발하고, Java/Go로 주변 서비스를 개발하며, Python으로 모니터링 애플리케이션을 개발합니다.

 

그러나 자바 직렬화는 현재 자바로 구현된 프레임워크에만 적용됩니다. 대부분의 다른 언어는 자바 직렬화 프레임워크를 사용하지 않거나 자바 직렬화 프로토콜을 구현하지 않습니다. 따라서 서로 다른 언어로 작성된 두 애플리케이션이 통신해야 할 때 객체를 직렬화 및 역직렬화하여 두 서비스 간에 전송할 수 없습니다.

2. 공격에 취약

자바 보안 코딩 가이드라인에 따르면, "신뢰할 수 없는 데이터의 역직렬화는 본질적으로 위험하므로 피해야 한다"라고 명시되어 있습니다. 이는 자바 직렬화가 안전하지 않다는 것을 의미합니다.

 

객체는 ObjectInputStreamreadObject() 메서드를 호출하여 역직렬화됩니다. 이 메서드는 사실상 매직 생성자로, 클래스 경로에 있는 Serializable 인터페이스를 구현한 거의 모든 객체를 인스턴스화할 수 있습니다.

 

이는 바이트 스트림의 역직렬화 동안 이 메서드가 임의의 종류의 코드를 실행할 수 있음을 의미합니다. 이는 매우 위험합니다.

 

오랜 역직렬화 시간이 필요한 객체의 경우, 코드를 실행하지 않고도 공격을 시작할 수 있습니다. 공격자는 순환 객체 체인을 생성한 후 직렬화된 객체를 프로그램에 전송하여 역직렬화할 수 있습니다. 이 상황은 hashCode 메서드 호출 횟수를 기하급수적으로 증가시켜 스택 오버플로우 예외를 발생시킬 수 있습니다. 다음 예제가 이를 잘 보여줍니다.

Set root = new HashSet();  
Set s1 = root;  
Set s2 = new HashSet();  
for (int i = 0; i < 100; i++) {  
   Set t1 = new HashSet();  
   Set t2 = new HashSet();  
   t1.add("foo"); // make t2 not equal to t1
   s1.add(t1);  
   s1.add(t2);  
   s2.add(t1);  
   s2.add(t2);  
   s1 = t1;  
   s2 = t2;   
} 

 

2015년 FoxGlove Security 팀의 breenmachine은 Apache Commons Collections를 통한 자바 역직렬화 취약점을 이용한 공격이 가능하다는 장문의 블로그 글을 게시했습니다. 이 취약점은 최신 버전의 WebLogic, WebSphere, JBoss, Jenkins, OpenNMS 등에 영향을 미쳐 주요 자바 웹 서버들이 공격에 노출되었습니다.

 

Apache Commons Collections는 자바 표준 라이브러리의 Collection 프레임워크를 확장하는 서드 파티 라이브러리로, 강력한 데이터 구조 타입과 다양한 컬렉션 유틸리티 클래스를 제공합니다.

 

이 공격 원리는 Apache Commons Collections가 체이닝된 임의의 클래스 함수 리플렉션 호출을 허용하는 데 있습니다. 공격자는 "자바 직렬화 프로토콜"이 구현된 포트를 통해 서버에 악성 코드를 업로드할 수 있으며, 이는 Apache Commons Collections의 TransformedMap에 의해 실행됩니다.

 

이 취약점은 어떻게 해결되었을까요?

 

많은 직렬화 프로토콜은 객체를 저장하고 검색하기 위한 데이터 구조를 정의했습니다. 예를 들어, JSON 직렬화, Protocol Buffers 등은 기본 타입과 배열 데이터 타입만을 지원하여 역직렬화 중에 불확실한 인스턴스를 생성하지 않도록 합니다. 이들의 설계는 단순하지만, 대부분의 시스템 데이터 전송 요구를 충족하기에 충분합니다.

 

이 취약점을 완화하는 한 가지 방법은 화이트리스트를 통해 역직렬화 객체를 제어하는 것입니다. 이는 resolveClass 메서드를 재정의하고 이 메서드에서 객체 이름을 검증하여 구현할 수 있습니다. 코드는 다음과 같습니다:

@Override
protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    if (!desc.getName().equals(Bicycle.class.getName())) {
        throw new InvalidClassException(
            "Unauthorized deserialization attempt", desc.getName());
    }
    return super.resolveClass(desc);
}

3. 큰 직렬화 스트림 크기

직렬화 후의 이진 스트림 크기는 직렬화 성능을 반영합니다. 직렬화 후의 바이너리 배열 크기가 클수록 더 많은 저장 공간을 차지하며, 저장 하드웨어 비용이 증가합니다. 네트워크를 통해 전송할 경우 대역폭을 더 많이 소모하여 시스템 처리량에 영향을 미칠 수 있습니다.

 

자바 직렬화에서 ObjectOutputStream은 객체를 이진 인코딩으로 변환하는 데 사용됩니다. 이 직렬화 메커니즘이 ByteBuffer의 이진 배열과 비교하여 생성되는 이진 배열 크기에 차이가 있을까요?

 

간단한 예를 통해 비교해 보겠습니다:

class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String userName;
    private String password;
    // getters and setters omitted
}

User user = new User();
user.setUserName("test");
user.setPassword("test");

ByteArrayOutputStream os = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(user);

byte[] testByte = os.toByteArray();
System.out.print("ObjectOutputStream byte encoding length: " + testByte.length + "\n");

ByteBuffer byteBuffer = ByteBuffer.allocate(2048);
byte[] userName = user.getUserName().getBytes();
byte[] password = user.getPassword().getBytes();
byteBuffer.putInt(userName.length);
byteBuffer.put(userName);
byteBuffer.putInt(password.length);
byteBuffer.put(password);

byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
System.out.print("ByteBuffer byte encoding length: " + bytes.length + "\n");

 

실행 결과:

ObjectOutputStream byte encoding length: 99
ByteBuffer byte encoding length: 16

 

위 예제에서 볼 수 있듯이, 자바 직렬화로 생성된 바이너리 배열 크기가 ByteBuffer로 생성된 배열 크기보다 몇 배 더 큽니다. 따라서 자바 직렬화 후 스트림이 커지며, 이는 결국 시스템의 처리량에 영향을 미칩니다.

4. 낮은 직렬화 성능

직렬화 속도 역시 직렬화 성능의 중요한 지표입니다. 직렬화가 느리면 네트워크 통신 효율에 영향을 미쳐 시스템 응답 시간이 증가합니다. 위 예제를 사용하여 자바 직렬화와 NIO의 ByteBuffer를 이용한 인코딩 성능을 비교해 보겠습니다:

User user = new User();
user.setUserName("test");
user.setPassword("test");

long startTime = System.currentTimeMillis();

for (int i = 0; i < 1000; i++) {
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(os);
    out.writeObject(user);
    out.flush();
    out.close();
    byte[] testByte = os.toByteArray();
    os.close();
}

long endTime = System.currentTimeMillis();
System.out.print("ObjectOutputStream serialization time: " + (endTime - startTime) + "\n");

long startTime1 = System.currentTimeMillis();

for (int i = 0; i < 1000; i++) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(2048);

    byte[] userName = user.getUserName().getBytes();
    byte[] password = user.getPassword().getBytes();
    byteBuffer.putInt(userName.length);
    byteBuffer.put(userName);
    byteBuffer.putInt(password.length);
    byteBuffer.put(password);

    byteBuffer.flip();
    byte[] bytes = new byte[byteBuffer.remaining()];
}

long endTime1 = System.currentTimeMillis();
System.out.print("ByteBuffer serialization time: " + (endTime1 - startTime1) + "\n");

 

실행 결과:

ObjectOutputStream serialization time: 29
ByteBuffer serialization time: 6

위 예제에서 자바 직렬화의 인코딩 시간이 ByteBuffer의 인코딩 시간보다 훨씬 오래 걸리는 것을 확인할 수 있습니다.

자바 직렬화를 Protobuf 직렬화로 대체

현재 업계에는 자바 기본 직렬화의 몇 가지 단점을 피하는 여러 훌륭한 직렬화 프레임워크가 있습니다. 최근 인기를 끌고 있는 프레임워크로는 FastJson, Kryo, Protobuf, Hessian 등이 있습니다. 이들 중 하나를 사용하여 자바 직렬화를 완전히 대체할 수 있으며, 여기서는 Protobuf 직렬화 프레임워크를 추천합니다.

 

Protobuf는 구글에서 개발한 직렬화 프레임워크로, 여러 언어를 지원합니다. 주요 웹사이트의 비교 테스트에서 인코딩 및 디코딩 시간과 이진 스트림 크기 측면에서 일관되게 우수한 성능을 보여줍니다.

 

Protobuf는. proto 파일을 기반으로 하며, 이 파일은 필드와 그 타입을 설명합니다. 이 파일을 사용하여 다양한 언어에 맞는 데이터 구조 파일을 생성할 수 있습니다. 데이터 객체를 직렬화할 때 Protobuf는 .proto 파일 설명을 기반으로 Protocol Buffers 형식의 인코딩을 생성합니다.

Protocol Buffers의 저장 형식 및 작동 방식

Protocol Buffers는 경량 및 효율적인 구조화된 데이터 저장 형식입니다. T-L-V(Tag — Length — Value) 데이터 형식을 사용하여 데이터를 저장합니다. 여기서 T는 필드의 순차적인 태그를 나타내며, Protocol Buffers는 객체의 각 필드를 순차적인 태그와 연관시킵니다. 이 정보는 생성된 코드에 의해 보장됩니다. 직렬화할 때는 필드 이름을 나타내기 위해 정수를 사용하여 전송 트래픽을 크게 줄입니다. L은 값의 바이트 길이를 나타내며, 일반적으로도 1바이트만 필요합니다. V는 필드 값의 인코딩 값을 나타냅니다. 이 데이터 형식은 구분자나 공백이 필요 없어 불필요한 필드 이름을 줄입니다.

 

Protobuf는 자체 인코딩 방법을 정의하며, 자바/파이썬 등 언어의 모든 기본 데이터 타입을 거의 매핑할 수 있습니다. 각기 다른 인코딩 방법은 서로 다른 데이터 타입에 대응하며, 다양한 저장 형식을 사용할 수 있습니다.

예를 들어, Varint 인코딩 된 데이터를 저장할 때는 데이터가 고정된 공간을 차지하기 때문에 바이트 길이(Length)를 저장할 필요가 없습니다. 따라서 Protocol Buffers의 실제 저장 형식은 T — V가 되어 저장 공간을 1바이트 줄입니다.

Protobuf

 

Protobuf는 Varint 인코딩 방법을 정의하며, 이는 가변 길이 인코딩 방법입니다. 데이터 타입의 각 바이트의 마지막 비트는 플래그 비트(msb)로, 현재 바이트 뒤에 다른 바이트가 있는지 나타냅니다. 0은 현재 바이트가 마지막 바이트임을, 1은 이 바이트 뒤에 또 다른 바이트가 있음을 나타냅니다.

 

예를 들어, int32 타입의 숫자는 일반적으로 4바이트가 필요합니다. Varint 인코딩 방법을 사용하면 매우 작은 int32 숫자를 1바이트로 표현할 수 있습니다. 대부분의 정수형 데이터의 값이 256 미만이므로 이 방법은 데이터를 효과적으로 압축할 수 있습니다.

 

int32는 양수와 음수를 모두 표현하기 때문에 마지막 비트는 양수와 음수를 나타내는 데 사용됩니다. Varint 인코딩 방법에서는 마지막 비트가 플래그 비트로 사용됩니다. 그렇다면 양수와 음수를 어떻게 표현할까요? 음수를 나타내기 위해 int32/int64를 사용하면 여러 바이트가 필요합니다. Varint 인코딩 타입에서는 음수를 Zigzag 인코딩을 통해 부호 없는 숫자로 변환하고, 이를 sint32/sint64로 표현하여 음수를 나타냅니다. 이 방법으로 인코딩 후 바이트 수가 크게 줄어듭니다.

 

이러한 Protobuf의 데이터 저장 형식은 저장 데이터에 대해 우수한 압축 효과를 제공할 뿐만 아니라 인코딩 및 디코딩 성능도 매우 효율적입니다. Protobuf의 인코딩 및 디코딩 과정은. proto 파일 형식과 Protocol Buffers의 고유한 인코딩 형식을 결합하여 간단한 데이터 작업과 비트 시프트 연산만으로 완료할 수 있습니다. Protobuf는 뛰어난 종합 성능을 갖춘 직렬화 프레임워크입니다.

마무리

네트워크 전송이든 디스크 지속성이든 데이터를 바이트코드로 인코딩해야 합니다. 프로그램에서 사용하는 데이터 타입이나 객체는 메모리에 기반하므로 이 데이터를 인코딩하여 이진 바이트 스트림으로 변환해야 합니다. 데이터를 수신하거나 재사용해야 할 때는 이진 바이트 스트림을 디코딩하여 메모리 데이터로 변환해야 합니다. 우리는 보통 이 두 과정을 직렬화와 역직렬화라고 부릅니다.

 

자바의 기본 직렬화는 Serializable 인터페이스를 통해 구현됩니다. 클래스가 이 인터페이스를 구현하고 기본 버전 번호를 생성하면(이를 수동으로 설정할 필요는 없음) 해당 클래스는 자동으로 직렬화 및 역직렬화를 구현합니다.

 

자바의 기본 직렬화는 편리하지만, 보안 취약성, 언어 간 호환성 부족, 성능 저하 등의 결점이 있습니다. 따라서 자바 직렬화 사용을 피할 것을 강력히 추천합니다.

 

주요 직렬화 프레임워크를 살펴보면 FastJson, Protobuf, Kryo는 각기 독특하며, 성능과 보안성이 업계에서 인정받고 있습니다. 자신의 비즈니스 요구에 따라 적절한 직렬화 프레임워크를 선택하여 시스템의 직렬화 성능을 최적화할 수 있습니다.