안녕하세요. 개발자 stark입니다!
오늘은 제가 kafka 소스코드를 살펴보다 발견한 serialVersionUID에 대해서 얘기해보고자 합니다. 실제로 Kafka에서 사용되는 예외 클래스(WakeupException)를 예시로 들어 직렬화/역직렬화 과정에서 serialVersionUID가 어떤 영향을 미치는지 더 자세히 보여드리겠습니다.
1. 초기 버전의 Kafka 관련 예외 클래스
먼저 오픈소스 프로젝트인 Kafka 소스코드에 있는 예외 클래스를 확인해 봅시다.
필드에 serialVersionUID = 1L이 선언되어 있습니다.
package org.apache.kafka.common;
public class KafkaException extends RuntimeException {
private static final long serialVersionUID = 1L;
public KafkaException(String message, Throwable cause) {
super(message, cause);
}
public KafkaException(String message) {
super(message);
}
public KafkaException(Throwable cause) {
super(cause);
}
public KafkaException() {
super();
}
}
초기 버전에서 WakeupException은 별도 필드 없이 KafkaException을 상속하기만 합니다.
동일하게 필드에 serialVersionUID = 1L이 선언되어 있습니다.
package org.apache.kafka.common.errors;
import org.apache.kafka.common.KafkaException;
public class WakeupException extends KafkaException {
private static final long serialVersionUID = 1L;
}
이 상태에서 WakeupException 객체를 직렬화하여 파일에 저장한다고 가정해 봅시다.
// 직렬화 예시 코드 (초기 버전)
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
public class SerializeDemo {
public static void main(String[] args) throws Exception {
WakeupException original = new WakeupException("Initial version");
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("wakeupException.dat"))) {
out.writeObject(original);
}
System.out.println("초기 버전의 WakeupException 직렬화 완료");
}
}
이 시점에서는 문제가 전혀 없습니다. wakeupException.dat 파일에는 serialVersionUID = 1L 기준의 WakeupException 객체가 저장되어 있습니다.
참고사항: .dat 확장자란?
.dat 파일은 특정한 규칙이 정해진 확장자가 아니라, 단순히 "데이터(data)"를 담는 파일이라는 의미로 흔히 사용되는 확장자일 뿐입니다. 예를 들어, Java 직렬화 과정에서 ObjectOutputStream을 통해 객체를 바이너리 형태로 파일에 기록하면, 그 결과물은 특정 포맷의 바이트 스트림입니다. 이 때 .dat는 그저 이진 데이터(Binary Data)를 담고 있다는 것을 나타내기 위해 관습적으로 붙인 이름입니다.
즉, wakeupException.dat라는 파일명은 직렬화된 WakeupException 객체를 바이너리 데이터로 저장한 파일일 뿐이며, .dat 확장자를 사용한 특별한 이유나 표준이 있는 것은 아닙니다. 개발자가 식별하기 편하게 .dat를 붙였을 뿐이고, .bin, .data, 심지어 아무 확장자가 없어도 파일 내용 자체에는 큰 차이가 없습니다.
2. 클래스 구조 변경 - 새로운 필드 추가 (호환성 유지)
이제 요구사항 변경으로 WakeupException 클래스에 새로운 필드를 추가해 봅시다.
KafkaException 내부 구조가 변경되었거나, WakeupException 자체에 새로운 필드(예: private final String reason;)를 추가한다고 가정합시다. 이때 호환성을 유지하고 싶다면 serialVersionUID를 그대로 두면 됩니다. 그러면 이전에 직렬화된 데이터인 (wakeupException.dat 파일)도 그대로 역직렬화가 가능합니다(새로운 필드는 기본값 null로 세팅됨).
예를 들어, 아래와 같이 KafkaException을 변경해 봅시다.
serialVersionUID는 여전히 1L를 유지하고 String 타입의 detail이라는 필드만 한 개 추가합니다.
package org.apache.kafka.common;
public class KafkaException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String detail; // 새로운 필드 추가
public KafkaException(String message, Throwable cause) {
super(message, cause);
this.detail = null; // 혹은 "default-detail" 등 기본값 할당
}
public KafkaException(String message, String detail) {
super(message);
this.detail = detail;
}
public KafkaException(String message) {
super(message);
this.detail = null;
}
public KafkaException(Throwable cause) {
super(cause);
this.detail = null;
}
public KafkaException() {
super();
this.detail = null;
}
public String getDetail() {
return detail;
}
}
WakeupException도 변경해 봅시다.
동일하게 serialVersionUID는 1L를 유지하고 String 타입의 reason 필드만 추가합니다.
package org.apache.kafka.common.errors;
public class WakeupException extends KafkaException {
private static final long serialVersionUID = 1L;
private final String reason; // 새로운 필드 추가
// 생성자 추가
public WakeupException(String message, String reason) {
super(message, "no-detail");
this.reason = reason;
}
// reason 필드에 대한 getter 메서드 추가
public String getReason() {
return reason;
}
}
이 새로운 코드로 역직렬화를 시도해 봅시다.
import java.io.FileInputStream;
import java.io.ObjectInputStream;
public class DeserializeDemo {
public static void main(String[] args) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("wakeupException.dat"))) {
WakeupException restored = (WakeupException) in.readObject();
System.out.println("역직렬화 완료: message=" + restored.getMessage()
+ ", reason=" + restored.getReason()
+ ", detail=" + restored.getDetail());
}
}
}
여기서 reason과 detail은 원래 없던 필드였으므로 null일 수 있지만, serialVersionUID가 동일하므로 역직렬화는 성공합니다. 즉, 호환성 유지가 가능합니다.
다음 목차에서 호환성을 깨는 예시를 보기전 다이어그램으로 미리 전체 흐름을 이해하고 넘어갑시다.
┌───────────────────┐
│ │
│ 직렬화(1L) │
│ WakeupException │
│ ────────────→ │ UID 1L 기반 데이터 (wakeupException.dat)
│ 초기 버전 │
└───────────────────┘
│
│ 역직렬화 시도
▼
┌───────────────────┐ ┌───────────────────┐
│ │ │ │
│ 역직렬화(1L) │ 동일한 UID │ 역직렬화(2L) │ UID 달라짐
│ WakeupException │ → 성공 │ WakeupException │ → 실패 (InvalidClassException)
│ 변경 버전(필드추가) │ │ 변경 버전(필드추가) │
└───────────────────┘ └───────────────────┘
3. 호환성을 깨고 싶을 때 - serialVersionUID 변경
이제 새로운 버전으로 넘어가면서 이전 버전과의 호환성을 의도적으로 끊고 싶다고 가정해 봅시다.
이 경우 serialVersionUID를 변경합니다. 예를 들어 KafkaException과 WakeupException 모두 serialVersionUID를 2L로 변경하면, 이전에 1L로 직렬화된 객체를 역직렬화 시도할 때 InvalidClassException이 발생합니다.
아래와 같이 KafkaException, WakeupException의 serialVersionUID를 2L로 변경해 봅시다.
public class KafkaException extends RuntimeException {
private static final long serialVersionUID = 2L; // 버전 변경
private final String detail;
public KafkaException(String message, String detail) {
super(message);
this.detail = detail;
}
// 새로운 버전에서는 detail을 바꿀 때 새 객체를 반환
public KafkaException changeDetail(String newDetail) {
return new KafkaException(this.getMessage(), newDetail);
}
public String getDetail() {
return detail;
}
}
public class WakeupException extends KafkaException {
private static final long serialVersionUID = 2L; // 버전 변경
private final String reason;
public WakeupException(String message, String reason) {
super(message, "no-detail");
this.reason = reason;
}
public String getReason() {
return reason;
}
}
이제 다시 DeserializeDemo 코드를 실행해 봅시다.
import java.io.FileInputStream;
import java.io.ObjectInputStream;
public class DeserializeDemo {
public static void main(String[] args) throws Exception {
// 이전에 UID=1로 직렬화된 wakeupException.dat 파일을 역직렬화 시도
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("wakeupException.dat"))) {
WakeupException restored = (WakeupException) in.readObject();
// 여기서 InvalidClassException 발생
System.out.println("역직렬화 완료: message=" + restored.getMessage()
+ ", reason=" + restored.getReason()
+ ", detail=" + restored.getDetail());
}
}
}
다음과 같은 예외가 발생할 것입니다.
java.io.InvalidClassException: WakeupException; local class incompatible:
stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
즉, 이전에 serialVersionUID=1L로 직렬화된 데이터는 serialVersionUID를 2L로 변경한 클래스와 호환되지 않아 역직렬화 실패가 일어납니다. 이는 serialVersionUID를 변경하여 이전에 직렬화된 객체들과의 호환성을 끊는 전략적 선택을 한 것입니다.
4. 언제 호환성 이슈가 생길까?
예전 데이터를 다시 읽어 복원하는 상황에 주로 호환성 이슈가 발생합니다.
1. 장기 보관된 캐시나 세션 데이터 복원
웹 서비스나 백엔드 시스템에서 사용자의 세션 정보나 특정 연산 결과를 직렬화하여 캐시(예: Redis, 파일 시스템)에 저장해 두고, 필요할 때 다시 불러와 빠르게 응답하는 경우가 있습니다. 시간이 지나 애플리케이션을 업데이트하고 난 뒤에도 이전에 캐싱해 둔 데이터를 복원해야 할 수 있습니다. 이때 직렬화된 세션이나 캐시 데이터가 이전 버전(1L)으로 직렬화되어 있다면, 새로운 버전(2L) 클래스로 역직렬화할 때 문제를 겪을 수 있습니다.
2. 이전 로그나 이벤트 데이터 재처리
이벤트 소싱(Event Sourcing)을 사용하는 시스템에서는 과거에 발생한 이벤트를 모두 직렬화해 장기간 보관합니다. 나중에 시스템을 재구성하거나, 장애 상황에서 복구할 때 과거 이벤트를 재생(Replay)하기 위해 해당 이벤트 데이터를 역직렬화해야 합니다. 이때 시스템이 업데이트되어 클래스 구조나 serialVersionUID가 변경된 상태라면, 옛날 이벤트 데이터를 문제없이 복원하는 것이 어려울 수 있습니다.
3. 데이터 마이그레이션 또는 분석 작업
서비스 운영 도중 데이터 포맷이나 클래스 구조가 바뀌었는데, 과거 데이터를 새 구조로 마이그레이션해야 하는 상황이 생길 수 있습니다. 이때 이전 버전으로 직렬화된 객체를 다시 읽어들여(역직렬화) 새로운 포맷으로 변환, DB에 재저장하거나 분석에 활용하려고 할 수 있습니다.
4. 서버 교체나 업그레이드 시 스냅샷 복원
서버 다운타임 없이 시스템을 업그레이드하기 위해 스냅샷(Snapshot) 형태로 상태를 저장하고, 업그레이드된 버전의 서버가 해당 스냅샷을 역직렬화하여 시스템 상태를 복원하는 경우가 있습니다. 이때 스냅샷이 이전 버전으로 직렬화되어 있다면 새로운 버전에서 역직렬화 시 호환성 문제가 발생할 수 있습니다.
5. 정리해 봅시다.
초기 버전(serialVersionUID = 1L)에서 객체를 직렬화한 뒤, 구조를 변경하더라도 같은 serialVersionUID를 유지하면 이전 직렬화된 데이터를 역직렬화하는 데 문제가 없습니다(새로운 필드는 기본값으로 채워짐).
serialVersionUID를 변경(1L → 2L)하면, 이전 버전의 직렬화 데이터는 더 이상 역직렬화할 수 없게 되어 InvalidClassException이 발생합니다. 이로써 의도적으로 호환성을 끊을 수 있습니다.
Kafka나 다른 오픈소스 라이브러리에서도 이러한 방식을 사용하여 이전 데이터와의 호환성을 유지하거나 끊으며, serialVersionUID를 통한 클래스 버전 관리는 직렬화 메커니즘의 핵심적인 요소입니다.
위와 같이 실제로 직렬화/역직렬화하는 과정을 통해, serialVersionUID가 단순한 숫자 필드가 아니라 직렬화된 객체와 현재 로드된 클래스 간의 "버전 호환성"을 관리하는 중요한 식별자임을 확인할 수 있습니다.
마무리하며
저는 serialVersionUID 필드를 gRPC, querydsl 등의 소스코드에서도 많이 봤습니다. 그러나 이 필드가 직렬화, 역직렬화를 위해 사용한다는 것에 대해서만 이해했지 실제로는 어떻게 동작하며 그래서 이게 코드에서 어떤 책임을 가지고 무슨 역할을 하는지에 대해서는 관심이 없었습니다.
이렇게 코드를 봐도 별 신경 쓰지 않고 개발하던 중 오픈소스인 Kafka 소스코드를 뜯어보다 serialVersionUID를 또 발견하게 되었고 대체 왜 이렇게 수많은 오픈소스 코드에서는 serialVersionUID를 사용하는지에 대해 궁금증이 생겼습니다. 이런 궁금증이 생긴 덕분에 serialVersionUID를 사용한 호환성 관리 방식을 배웠고 저도 이것을 사용할 일이 있을지에 대해서 새로운 고민을 해 볼 수 있게 되었습니다.
또 한 가지 궁금해진 것은 제가 낙관적 Lock을 적용시켜 보기 위해 @Entity 내부의 필드에 version 변수를 선언하고 @Version 어노테이션을 사용했던 적이 있는데 혹시 serialVersionUID와 version같이 필드명에 version이 붙은 것들은 혹시 어떤 관계나 작명을 하는 기준이 있는 것인지에 대한 궁금증이 생겼습니다. 관계성이 없을 것 같지만 뭔가 재미있을 것 같아서 알아본 뒤 혹시 차이점이 있다면 추후 정리해서 올려볼 예정입니다.
다들 읽어주셔서 감사합니다 :)