Dev

serialVersionUID가 없는 Serializable Class를 수정해야 할 때

prostars 2020. 4. 23. 10:03

자바로 개발된 프로젝트를 유지 보수하다 보면 Object Serialization, Deserialization이 사용된 코드를 흔하게 볼 수 있다. 간단하게 Serializable 인터페이스를 구현했거나 Externalizable 인터페이스를 구현해서 JSON 등의 다른 포맷으로 영속화를 시켰는지는 여기서 중요하지 않다. serialVersionUID를 명시적으로 정의하지 않고 생략했다면, 모두 동일한 문제를 가지고 있다.

문제는 아래와 같은 상황에서 발생한다.

  1. A 객체를 직렬화하여 Redis나 DB 등 외부에 저장하고, 저장된 값을 A 객체로 역직렬화하여 사용하고 있다.
  2. 어느 날 A 객체에 필드를 추가하거나 삭제하는 수정을 한다.
  3. 새로 빌드한 서비스에서 수정된 A 객체로 외부에 저장된 값을 역직렬화하면 java.io.InvalidClassException 예외가 발생한다.

위의 상황은 로컬에서 단위 테스트하는 정도로는 사전에 발견하기 어렵다. 보통 테스트는 수정된 객체로 직렬화하고 역직렬화하기 때문이다. 수정하기 전 버전의 객체로 직렬화한 값을 따로 저장해두고 호환성 테스트까지 하는 경우는 드물다.

이 문제를 해결하는 방법은 간단하다. 위에서 발생한 java.io.InvalidClassException에는 직렬화된 객체의 serialVersionUID와 현재 버전의 serialVersionUID가 모두 출력되므로 직렬화된 객체의 serialVersionUID를 A 클래스에 명시적으로 정의하는 코드를 추가하면 된다. 하지만, 위와 같이 serialVersionUID의 정의를 누락한 클래스가 하나가 아니라면 매번 예외가 발생할 때까지 기다렸다가 대응할 수는 없다. 문제를 인지했을 때 직렬화하는 모든 클래스를 찾아서 serialVersionUID의 정의를 추가하는 것이 좋다.

serialVersionUID를 설명하는 문서에 따르면, serialVersionUID를 명시적으로 정의하지 않으면, 해당 클래스의 해시값을 serialVersionUID로 사용한다고 한다. 해당 해시값을 구하는 방법도 이 문서에 설명되어 있다. 참고로 Object.hashCode() 와는 다르다.
그리고, serialVersionUID를 정의하지 않았다고 컴파일 타임에 해당 클래스의 해시값을 serialVersionUID로 자동으로 추가해주는 것이 아니다. IntelliJ에서 class 파일을 디컴파일해보면 serialVersionUID에 대한 정의가 없다. 클래스를 로딩할 때 serialVersionUID의 명시적인 정의가 없다면 그때 해시값을 구해서 사용한다. ObjectStreamClass의 코드를 보면 관련 내용을 확인할 수 있다.

간단한 예제를 가지고 정리해보자. 예제를 단순화하기 위해서 Serializable을 구현했고, 예제의 전체 코드는 GitHub에서 받을 수 있다.
다음과 같이 serialVersionUID는 생략하고 Serializable 인터페이스를 구현한 SomeData Class가 있다.

이 SomeData를 다음과 같이 직렬화하자.

실행해보면 다음과 같은 결과를 볼 수 있다.

someData = { key: 1, value: first, date: 일요일 4월 19 2020 17:29:11.266 }
deserialized someData = { key: 1, value: first, date: 일요일 4월 19 2020 17:29:11.266 }

이 상태에서 Class SomeData에 ‘public Boolean enable;’ 의 주석을 풀고 다음 코드를 준비하고 실행한다.

실행해보면 다음과 같은 예외 메시지를 볼 수 있다.

com.company.SomeData; local class incompatible: stream classdesc serialVersionUID = -5926766225056420327, local class serialVersionUID = -4597010324217610162

여기서 -5926766225056420327는 ’file.dat’에 직렬화된 SomeData의 serialVersionUID이고, -4597010324217610162는 enable 필드를 추가한 SomeData의 serialVersionUID이다. SomeData에 serialVersionUID를 -5926766225056420327로 정의하고 다시 실행하면 문제없이 역직렬화되는 것을 볼 수 있다.

이 예제에서는 직렬화하는 Class가 하나뿐이라서 예외 메시지의 ‘stream classdesc serialVersionUID = -5926766225056420327’ 정보만으로 충분하여, 따로 serialVersionUID를 알아내거나 할 필요가 없다. 하지만, serialVersionUID가 여러 클래스에서 누락된 경우에는 각 클래스에 대해서 모두 예외를 발생시키고 수정하기에는 매우 번거롭다.

명시적으로 정의하지 않은 serialVersionUID를 알아내는 방법으로 ObjectStreamClass의 getSerialVersionUID() 함수를 직접 사용하는 방법과 serialver command를 사용하는 방법을 소개한다.

getSerialVersionUID() 함수

다음 코드를 준비하고 실행해서 생략한 serialVersionUID 값을 확인해보자.

‘serialVersionUID: -4597010324217610162’ 이 출력된 것을 볼 수 있다. 수정된 SomeData의 해시값은 -4597010324217610162이다. SomeData에 추가한 ‘public Boolean enable;’을 다시 주석으로 막고 PrintSerialVersionUID를 다시 실행하면, -5926766225056420327이 출력된다. getSerialVersionUID() 함수로 serialVersionUID의 값을 확인할 수 있다는 것을 확인했다.

문서에서 설명하는 것 처럼, 해시값은 컴파일러 구현등에 따라 달라질 수 있기 때문에 환경에 따라 다른 해시값이 출력될 것이다. 그래서, 문서에서는 항상 serialVersionUID를 정의하라고 권장한다.

serialver command

이번에는 JDK에 포함되어 있는 serialver command를 사용해보자. 사용법은 간단하다.

$ serialver -classpath path-files classnames

classnames에 들어가는 클래스 이름은 패키지 이름을 포함한 전체 이름이고, -classpath 에는 serialVersionUID를 알아내려는 클래스를 생성하는데 필요한 모든 클래스 패스를 -classpath 옵션에 지정해야 한다. 이 예제처럼 외부 종속이 전혀 없는 경우는 클래스 패스가 단순해서 다음과 같이 실행해 볼 수 있다.

$ serialver -classpath /Users/prostars/workspace/test/java/example_for_getting_serialVersionUID/target/classes com.company.SomeData
com.company.SomeData:    private static final long serialVersionUID = -5926766225056420327L;

이 예제에서는 com.company.SomeData 클래스 하나만 입력했지만, serialVersionUID를 알아낼 모든 클래스를 공백으로 구분해서 넣어주면 모두 출력되므로, 필요한 클래스를 나열해서 한 번에 다 알아내는 것을 추천한다.

프레임워크와 여러 라이브러리까지 사용하는 실제 프로젝트에서는 클래스 패스가 단순하지 않다. 클래스 패스는 각자의 개발환경에 따라 다를 것이다. IntelliJ를 사용하는 경우에는 쉽게 확인할 수 있다. 지금까지 빌드하고 실행했던 ‘WriteAndRead’, ‘ReadOnly’, ‘PrintSerialVersionUID’ 모두 SomeData 클래스를 사용하므로 아무거나 실행하거나, 마지막으로 실행했던 ‘PrintSerialVersionUID’의 출력창을 보면 다음과 같이 되어 있을 것이다.

위에서 흐릿하게 된 줄을 더블 클릭하면 펼쳐진다. 펼쳐진 내용에서 -classpath 가 지정된 부분을 볼 수 있을 것이다. 그 부분을 모두 복사해서 사용하면 된다. 

마지막으로, 주의할 것은 위에서 언급한 것처럼 컴파일러 구현에 따라서 다른 해시값을 리턴할 수 있으므로 실사용 환경에서 serialVersionUID를 알아내고 그 값을 해당 클래스에 정의해야 한다.

반응형