자바의 직렬화 기술에 대한 대한 두 번째 이야기입니다. 실제 자바 직렬화를 실무에 적용해보면서 주의해야 할 부분에 대해 이야기해보려고합니다.

자바 직렬화 실제 업무에서 사용해보기

자바 직렬화는 자바 개발자 입장에서는 상당히 쉽고 빠르게 사용할 수 있도록 만든 기술입니다.

JSON 또는 CSV 등 형태의 포맷을 이용하면 직렬화 또는 역직렬화시에 특정 라이브러리를 도입해야 쉽게 개발이 가능하며, 구조가 복잡하면 직접 매핑시켜줘야 하는 작업도 포함하게 됩니다.
그것에 비해 자바 직렬화는 비교적 복잡한 객체도 큰 작업 없이 (java.io.Serializable 인터페이스만 구현해주면) 기본 자바 라이브러리만 사용해도 직렬화와 역직렬화를 할 수 있습니다.

하지만 등가교환이라는 말이 있듯이 쉽게 이용할 수 있는 만큼 실제 업무에서 사용할 때에는 신경 써야 하는 부분이 있습니다.
제 경험에 빗대어서 신경 써야 할 부분에 대해 몇 가지 이야기해보겠습니다.

역직렬화시 클래스 구조 변경 문제

앞서서 예시를 들은 woowahan.blog.exam1.Member (이하 Member) 클래스를 기준으로 이야기해보겠습니다.

    public class Member implements Serializable {
        private String name;
        private String email;
        private int age;
      // 생략
    }

예제에서 Member 클래스가 있습니다. 이 클래스의 객체를 직렬화 시켜보겠습니다.
아래에의 문자열은 직렬화된 Member 클래스의 객체 문자열입니다. 테스트에 용의 하도록 Base64로 인코딩하였습니다.

Base64.getEncoder().encodeToString(serializedMember);
rO0ABXNyABp3b293YWhhbi5ibG9nLmV4YW0xLk1lbWJlcgAAAAAAAAABAgAESQADYWdlSQAEYWdlMkwABWVtYWlsdAASTGphdmEvbGFuZy9TdHJpbmc7TAAEbmFtZXEAfgABeHAAAAAZAAAAAHQAFmRlbGl2ZXJ5a2ltQGJhZW1pbi5jb210AAnquYDrsLDrr7w=

이 문자열을 바로 역직렬화 시키면 바로 Member 객체로 변환합니다. (테스트할 때에는 반드시 패키지도 동일해야 합니다.)

Member 클래스의 구조 변경에 대한 문제를 확인해보겠습니다.

    public class Member implements Serializable {
        private String name;
        private String email;
        private int age;
        // phone 속성을 추가
        private String phone;
      // 생략
    }

우리가 보통 원하는 것은 phone 멤버 변수가 추가되어도 기존 멤버 변수의 기존 멤버 변수는 채워지길 원합니다. phonenull 되어 있더라도 말이죠.
이전에 자바 직렬화된 데이터를 역직렬화 시켜 보겠습니다.

    java.io.InvalidClassException: woowahan.blog.exam1.Member; local class incompatible: stream classdesc serialVersionUID = -8896802588094338594, local class serialVersionUID = 7127171927132876450

이렇게 클래스의 멤버 변수 하나만 추가되어도 java.io.InvalidClassException 예외가 발생합니다.
예외 메시지를 읽어보면 serialVersionUID의 정보가 일치하지 않기 때문에 발생한 것을 알 수 있습니다.
우리는 Member 클래스에서는 serialVersionUID 의 값을 -8896802588094338594 정보로 설정해준 적도 없으며, 7127171927132876450으로 변경한 적도 없습니다. 어떻게 된 일일까요?

그래서 자바 직렬화 스펙을 확인해보았습니다. (링크)

    It may be declared in the original class but is not required. 
    The value is fixed for all compatible classes. 
    If the SUID is not declared for a class, the value defaults to the hash for that class. 

간단히 정리해보겠습니다.

  • SUID(serialVersionUID) 필수 값은 아니다.
  • 호환 가능한 클래스는 SUID값이 고정되어 있다.
  • SUID가 선언되어 있지 않으면 클래스의 기본 해쉬값을 사용한다. (해쉬값 알고리즘은 링크에서 확인이 가능합니다.)

serialVersionUID 를 직접 기술하지 않아도 내부 적으로 serialVersionUID 정보가 추가되며,
내부 값도 자바 직렬화 스펙 그대로 자동으로 생성된 클래스의 해쉬 값을 이라는 것을 확인할 수 있었습니다.
(링크 를 보면 알 수 있지만 해쉬값은 클래스 구조 정보를 이용해서 생성하는 것을 알 수 있습니다.)
serialVersionUID 정보를 기술하지 않는다고 해서 사용하지 않는 것이 아니 다라는 것이 확인되었습니다.

그럼 어떤 형태가 좋을까요?

    public class Member implements Serializable {
        private static final long serialVersionUID = 1L;
      
        private String name;
        private String email;
        private String phone;
      // 생략
    }

“조금이라도 역직렬화 대상 클래스 구조가 바뀌면 에러 발생해야 된다.” 정도의 민감한 시스템이 아닌 이상은 클래스를 변경할 때에
직접 serialVersionUID 값을 관리해주어야 클래스 변경 시 혼란을 줄일 수 있습니다.

물론 그렇게 해도 모든 것이 해결되는 것은 아닙니다. serialVersionUID 값이 동일할 때에도 신경 써야 할 부분이 생깁니다.
serialVersionUID 값이 동일할 때에도 어떠한 문제가 생길 수 있는지 몇 가지 살펴보겠습니다.

  1. 멤버 변수명은 같은데 멤버 변수 타입이 바뀔 때
         public class Member implements Serializable {
             private static final long serialVersionUID = 1L;
              
             private String name;
             private StringBuilder email;
             private int age;
           // 생략
         }
    

    기존 자바 직렬화된 데이터는 String 타입이었지만 StringBuilder클래스 타입으로 바꿔 봤습니다.

     java.lang.ClassCastException: cannot assign instance of java.lang.String to field woowahan.blog.exam1.Member.email of type java.lang.StringBuilder in instance of woowahan.blog.exam1.Member
    

    혹시 primitive 타입인 intlong으로 바꾸는 것은 괜찮지 않을까요?

         public class Member implements Serializable {
             private String name;
             private String email;
            // int -> long 변경 
             private long age;
           // 생략
         }
    
        java.io.InvalidClassException: woowahan.blog.exam1.Member; incompatible types for field age
    

    역시나 타입 예외가 발생했습니다. 자바 직렬화는 상당히 타입의 엄격하다는 것을 알 수 있습니다.

  2. 직렬화 자바 데이터에 존재하는 멤버 변수가 없애거나 추가했을 때
         public class Member implements Serializable {
             private static final long serialVersionUID = 1L;
             private String name;
             private String email;
             // age 멤버 변수 제거
           // 생략
         }
    
        Member{name='김배민', email='deliverykim@baemin.com'}
    

    에러는 발생하지 않습니다. 값 자체만 없어졌습니다. 그럼 멤버 변수를 추가해보겠습니다.

         public class Member implements Serializable {
             private static final long serialVersionUID = 1L;
             private String name;
             private String email;
             private int age;
             // 추가된 멤버 변수
             private String nick;
           // 생략
         }
    
         Member{name='김배민', email='deliverykim@baemin.com', age=25, nick=null}
    

    이번에도 에러가 발생하지 않습니다. 원하는 형태로 값이 채워졌네요.

자바 직렬화를 사용할 때 클래스 구조 변경 시 어떤 부분을 확인해야 할지 정리해보겠습니다.

  • 특별한 문제없으면 자바 직렬화 버전 serialVersionUID의 값은 개발 시 직접 관리해야 합니다.

  • serialVersionUID의 값이 동일하면 멤버 변수 및 메서드 추가는 크게 문제가 없습니다.
    그리고 멤버 변수 제거 및 이름 변경은 오류는 발생하지 않지만 데이터는 누락됩니다.

  • 역직렬화 대상의 클래스의 멤버 변수 타입 변경을 지양해야 합니다. 자바 역직렬화시에 타입에 엄격합니다.
    나중에라도 타입 변경이 되면 직렬화된 데이터가 존재하는 상태라면 발생할 예외를 경우의 수를 다 신경 써야 합니다.

  • 외부(DB, 캐시 서버, NoSQL 서버 등)에 장기간 저장될 정보는 자바 직렬화 사용을 지양해야 합니다. 역직렬화 대상의 클래스가 언제 변경이 일어날지 모르는 환경에서 긴 시간 동안 외부에 존재했던 직렬화된 데이터는 쓰레기(Garbage)가 될 가능성이 높습니다.
    언제 예외가 발생할지 모르는 지뢰 시스템이 될 수도 있습니다.

  • 개발자가 직접 컨트롤이 가능한 클래스의 객체가 아닌 클래스의 객체에 대해서는 직렬화를 지양해야 합니다. 개발자가 직접 컨트롤이 힘든 객체 란 보통 프레임워크 또는 라이브러리에서 제공하는 클래스의 객체를 이야기합니다. (사실 직접 변경 가능한 방법은 있지만 추천하진 않습니다.)
    그런 객체가 직접 serialVersionUID를 가지고 있기도 합니다. 그래서 개발 시에 편의상 직렬화 시켜 DB 또는 캐시 서버에 바로 저장하기도 하는데 이 부분에서 많은 문제가 발생합니다.
    • 예시)
      1. 프레임워크 또는 라이브러리가 버전업을 하면서 serialVersionUID 을 변경
      2. 테스트시에는 발생 안 하다가 운영에 반영

    생각 지도 못한 오류가 거품처럼 나기 시작할 것입니다. 이 부분은 사실 알아채기가 힘듭니다. 발생하기 위한 사전 조건도 많기 때문입니다. 차라리 이 글을 읽으신 분은 위와 같은 문제 사전에 차단하실 것을 추천합니다. 위와 관련된 예시는 스프링 시큐리티SecurityContextImpl클래스가 있습니다.
    SecurityContext 를 구현한 클래스 클래스로 링크 를 보면 확인할 수 있습니다. serialVersionUID 값이 스프링 시큐리티의 버전 값이기 때문에 버전이 변경될 때마다 신경 쓰입니다.

  • 결론
    • 자바 직렬화를 사용할 때에는 될 수 있으면 자주 변경되는 클래스의 객체는 사용 안 하는 것이 좋습니다. 변경에 취약하기 때문에 생각지도 못한 예외사항들이 발생할 가능성이 높습니다. 특히 역직렬 화가 되지 않을 때와 같은 예외처리는 기본적으로 해두는 것을 추천합니다.
      물론 직렬화 대상 클래스 변경과 같은 문제는 자바 직렬 화만 일어나는 문제는 아닙니다만 자바 직렬화 기술은 중간에 끼어들 여지가 없는 블랙박스에 가까워서 변경 부분에 취약한 문제가 존재합니다.

용량 문제

자바 직렬화시에 기본적으로 타입에 대한 정보 등 클래스의 메타 정보도 가지고 있기 때문에 상대적으로 다른 포맷에 비해서 용량이 큰 문제가 있습니다.
특히 클래스의 구조가 거대해지게 되면 용량 차이가 커지게 됩니다. 예를 들면 클래스 안에 클래스 또 리스트 등 이런 형태의 객체를 직렬화 하게 되면 내부에 참조하고 있는 모든 클래스에 대한 메타정보를 가지고 있기 때문에 용량이 비대해지게 됩니다.
그래서 JSON 같은 최소의 메타정보만 가지고 있으면 테스트로 된 포맷보다 같은 데이터에서 최소 2배 최대 10배 이상의 크기를 가질 수 있습니다.

    String base64Member = "rO0ABXNyABp3b293YWhhbi5ibG9nLmV4YW0xLk1lbWJlcgAAAAAAAAABAgAESQADYWdlSQAEYWdlMkwABWVtYWlsdAASTGphdmEvbGFuZy9TdHJpbmc7TAAEbmFtZXEAfgABeHAAAAAZAAAAAHQAFmRlbGl2ZXJ5a2ltQGJhZW1pbi5jb210AAnquYDrsLDrr7w=";
    byte[] serializedMember = Base64.getDecoder().decode(base64Member);
    System.out.printf("serializedMember (byte size = %s) \n", serializedMember.length);
    try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedMember)) {
        try (ObjectInputStream ois = new ObjectInputStream(bais)) {
            // 역직렬화된 Member 객체를 읽어온다.
            Object objectMember = ois.readObject();
            Member member = (Member) objectMember;
            // JSON으로 직렬화 했을 때 용량 체크
            String memberJson = objectMapper.writeValueAsString(member);
            System.out.println(memberJson);
            System.out.printf("json (byte size = %s)\n", memberJson.getBytes("utf-8").length);
        }
    }

결과

    serializedMember (byte size = 146)         
    {"name":"김배민","email":"deliverykim@baemin.com","age":25}
    json (byte size = 62)    

간단한 데이터이지만 위와 같이 용량 크기 두배 이상 차이가 납니다.

용량 문제는 생각보다 많은 곳에서 나타나는 문제입니다. 특히 직렬화된 데이터를 메모리 서버(Redis, Memcached)에 저장하는 형태를 가진 시스템에서 두드러집니다.
메모리 서버 특성상 메모리 용량이 크지 않기 때문에 핵심만 요약해서 기록하는 형태가 효율적입니다.
적은 데이터만 입력하는 시스템 구조라면 큰 문제는 발생하지 않습니다. 하지만 트래픽에 따라 데이터 기록이 급증하는 시스템은 유의해야 합니다.

그리고 이 부분을 강조하는 이유는 자바 웹 시스템에서 가장 많이 사용되는 스프링 프레임워크에서 기본적으로 지원하는 캐시 모듈 중 외부 시스템에 저장하는 형태에서 기본적으로 자바 직렬화 형태로 제공되기 때문입니다. (Spring Data Redis, Spring Session …)
기본적으로 프레임워크에서 자바 직렬화로 제공하는 이유는 앞서 말한 자바 직렬화 장점과 일맥상통합니다. 개발자가 신경 안 쓰고 빠르게 개발할 수 있기 때문입니다.
자바 직렬화 사용하는 시스템은 규모가 커지는 시점에서 반드시 다시 점검하여 보시길 바랍니다.

용량 문제 결론

일반 사용자를 대상으로 하는 B2C와 같은 시스템에서 자바 직렬화 정보를 외부 캐시 서버에 저장할 때에는 비효율적인 문제를 가지고 있습니다. (용량 크기에 따른 네트워크 비용과 캐시 서버 비용)
새롭게 스타트하는 서비스 같은 경우에는 생산성을 위해서 자바 직렬화를 그대로 이용한다고 해도 트랙픽이 지속적으로 증가할 때에는 JSON 형태 또는 다른 형태의 직렬화로 바꿔주는 것 고려해보시길 바랍니다.

호환성 이야기

이 부분은 기술적 오류 문제는 아닙니다. 단지 자바 직렬화를 이용해서 개발하면서 불편했던 부분을 이야기하려고 합니다.
자바 직렬화를 이용해서 외부 데이터를 저장하게 되면 제일 큰 아쉬움이 바로 자바에서만 사용할 수 있으면 읽을 수 있는 문제였습니다.
다른 언어를 이용해서 스크립트를 이용해서 여러 가지 처리를 하고 싶어도 불가능에 가깝습니다.
(파이썬에 자바 직렬화 분석하는 라이브러리가 있는 것은 확인해봤지만 사용은 못해봤습니다.)
만약 JSON으로 저장되어 있다면 MYSQL이나 REDIS 등 추가 라이브러리를 통해 조회도 가능하면 다른 언어를 통해서도 탐색 및 조작이 가능합니다

그리고 제가 이야기하고 싶은 것은 “긴 시간 동안 외부에 저장하는 의미 있는 데이터들은 자바 직렬화를 사용하지 말자.“입니다.

결론

자바 직렬화는 장점이 많은 기술입니다만 단점도 많습니다. 문제는 이 기술의 단점은 보완하기 힘든 형태로 되어 있기 때문에 사용 시 제약이 많습니다. 그래서 이 글을 적는 저는 직렬화를 사용할 때에는 아래와 같은 규칙을 지키려고 합니다.

  1. 외부 저장소로 저장되는 데이터는 짧은 만료시간의 데이터를 제외하고 자바 직렬화를 사용을 지양합니다.
  2. 역직렬화시 반드시 예외가 생긴다는 것을 생각하고 개발합니다.
  3. 자주 변경되는 비즈니스적인 데이터를 자바 직렬화을 사용하지 않습니다.
  4. 긴 만료 시간을 가지는 데이터는 JSON 등 다른 포맷을 사용하여 저장합니다.

참고

  • Java Object Serialization Specification version 6.0
    • https://docs.oracle.com/javase/8/docs/platform/serialization/spec/serial-arch.html

이전 포스팅으로 이동하기