본문으로 건너뛰기

세션 직렬화: Serializable 구현과 성능 최적화

Tomcat 클러스터링과 Redis 세션 공유 모두 세션 데이터를 네트워크로 전송하거나 외부에 저장할 때 **직렬화(Serialization)**를 사용합니다. 직렬화를 올바르게 구현하지 않으면 NotSerializableException 오류가 발생하고, 잘못 구현하면 성능 저하나 버전 불일치 문제가 생깁니다.


직렬화란?

직렬화는 메모리에 있는 Java 객체를 바이트 스트림으로 변환하는 과정입니다. 역직렬화는 그 반대입니다.

[Java 객체]  ──직렬화──▶  [바이트 배열]  ──전송──▶  [바이트 배열]  ──역직렬화──▶  [Java 객체]
UserSession 네트워크/Redis UserSession

세션에 저장되는 객체의 직렬화 요구사항

// 세션에 저장되는 모든 객체는 Serializable 구현 필수
HttpSession session = request.getSession();
session.setAttribute("user", userObject); // userObject must be Serializable

Serializable 올바르게 구현하기

기본 구현

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;

public class UserSession implements Serializable {

// serialVersionUID는 반드시 명시적으로 선언
// 없으면 JVM이 자동 계산하는데, 클래스 변경 시 값이 바뀌어
// 구 버전 데이터를 역직렬화할 때 InvalidClassException 발생
private static final long serialVersionUID = 1L;

private Long userId;
private String username;
private String email;
private String role;
private LocalDateTime loginTime;
private List<String> permissions;

// transient: 직렬화에서 제외 (민감 데이터 또는 직렬화 불가 객체)
private transient String rawPassword; // 보안상 제외
private transient HttpSession httpSession; // 직렬화 불가

// 기본 생성자 필수 (역직렬화 시 사용)
public UserSession() {}

public UserSession(Long userId, String username, String role) {
this.userId = userId;
this.username = username;
this.role = role;
this.loginTime = LocalDateTime.now();
}

// getter/setter
public Long getUserId() { return userId; }
public String getUsername() { return username; }
public String getRole() { return role; }
// ...
}

중첩 객체도 Serializable 필요

// Permission 클래스도 Serializable 구현 필수
public class Permission implements Serializable {
private static final long serialVersionUID = 1L;

private String resource;
private String action;

// ...
}

// UserSession에서 Permission 리스트 사용
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;

private List<Permission> permissions; // Permission도 Serializable이어야 함
// ...
}

serialVersionUID 관리 전략

serialVersionUID가 잘못 관리되면 롤링 배포 중 구버전과 신버전이 함께 운영될 때 역직렬화 오류가 발생합니다.

버전 충돌이 발생하는 상황

// v1.0: userId, username만 있음
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private Long userId;
private String username;
}

// v1.1: email 필드 추가 — serialVersionUID 변경 안 함
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L; // 같은 값 유지
private Long userId;
private String username;
private String email; // 새 필드 — 역직렬화 시 null로 초기화됨 (정상 동작)
}

같은 serialVersionUID면 필드 추가 시 새 필드는 null로 역직렬화됩니다. 이는 의도한 동작입니다.

// 위험: 필드 타입 변경 또는 삭제
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId; // Long → String으로 변경: 역직렬화 실패!
}

원칙:

  • 필드를 추가하면 같은 serialVersionUID 유지 가능
  • 필드를 삭제하거나 타입을 변경하면 serialVersionUID를 증가시켜야 함

마이그레이션 전략

public class UserSession implements Serializable {
private static final long serialVersionUID = 2L; // 버전 증가

// 구버전 데이터 역직렬화 시 호환성 처리
private Object readResolve() throws java.io.ObjectStreamException {
// 필요한 기본값 설정
if (this.email == null) {
this.email = this.username + "@example.com";
}
return this;
}
}

Java 직렬화의 한계와 대안

Java 기본 직렬화는 성능과 크기 면에서 불리합니다.

UserSession 객체 (userId, username, role)
Java 직렬화: ~350 bytes, ~0.5ms
JSON (Jackson): ~80 bytes, ~0.2ms
Kryo: ~50 bytes, ~0.05ms

Spring Session + JSON 직렬화 (GenericJackson2JsonRedisSerializer)

Spring Session은 기본적으로 JSON으로 세션을 저장합니다. 별도 설정 없이도 Java 직렬화보다 빠르고 읽기 쉽습니다.

// Redis에 저장된 JSON 세션 예시
{
"@class": "com.example.model.UserSession",
"userId": 1001,
"username": "admin",
"role": "ADMIN",
"loginTime": ["java.time.LocalDateTime", [2024, 1, 15, 10, 30, 0, 0]],
"permissions": ["java.util.ArrayList", ["READ", "WRITE", "DELETE"]]
}

주의: JSON 직렬화 시 클래스 타입 정보(@class)가 함께 저장됩니다. 클래스 패키지나 이름이 바뀌면 역직렬화 실패. 리팩터링 시 주의.

세션에 저장하는 데이터 최소화 (가장 중요)

// ❌ 잘못된 패턴: 세션에 복잡한 객체 저장
session.setAttribute("user", userEntityWithAllRelations); // 수천 바이트
session.setAttribute("orders", allOrderHistory); // 수만 바이트

// ✅ 올바른 패턴: ID만 저장, 필요 시 DB에서 조회
session.setAttribute("userId", user.getId()); // 8 bytes
session.setAttribute("role", user.getRole()); // ~10 bytes
// 나머지 정보는 필요할 때 DB/캐시에서 조회

세션 크기가 작을수록 직렬화/역직렬화 시간과 Redis 메모리 사용량이 줄어듭니다.


직렬화 성능 측정

// 직렬화 성능 테스트
import java.io.*;

public class SerializationBenchmark {

public static void main(String[] args) throws Exception {
UserSession session = new UserSession(1001L, "admin", "ADMIN");

// 직렬화 시간 측정
long start = System.nanoTime();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(session);
byte[] serialized = baos.toByteArray();
long serializeTime = System.nanoTime() - start;

System.out.printf("직렬화 크기: %d bytes%n", serialized.length);
System.out.printf("직렬화 시간: %.3f ms%n", serializeTime / 1_000_000.0);

// 역직렬화 시간 측정
start = System.nanoTime();
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
ObjectInputStream ois = new ObjectInputStream(bais);
UserSession deserialized = (UserSession) ois.readObject();
long deserializeTime = System.nanoTime() - start;

System.out.printf("역직렬화 시간: %.3f ms%n", deserializeTime / 1_000_000.0);
}
}

세션 직렬화 체크리스트

배포 전 확인해야 할 사항들:

✅ 세션에 저장되는 모든 클래스에 implements Serializable 추가됨
✅ 모든 Serializable 클래스에 serialVersionUID 명시적 선언
✅ 직렬화 불가/불필요 필드에 transient 키워드 적용
✅ 중첩 객체(List, Map의 값 포함)도 모두 Serializable
✅ 세션에 저장하는 데이터가 최소화됨 (ID만 저장, 객체 전체 X)
✅ 필드 변경(삭제/타입 변경) 시 serialVersionUID 업데이트
✅ 롤링 배포 중 구버전 데이터 역직렬화 테스트 완료

실전 팁: NotSerializableException 해결

java.io.NotSerializableException: com.example.model.OrderDetail

이 오류가 발생하면:

  1. 스택 트레이스에서 직렬화 불가 클래스 이름 확인 (OrderDetail)
  2. 해당 클래스에 implements Serializable 추가
  3. 클래스 내부에 serialVersionUID = 1L 추가
  4. 필드 중 직렬화 불가 타입(Connection, InputStream 등)이 있으면 transient 처리
  5. 재배포 후 테스트
// 수정 전
public class OrderDetail {
private Long orderId;
private Connection dbConnection; // 직렬화 불가
}

// 수정 후
public class OrderDetail implements Serializable {
private static final long serialVersionUID = 1L;
private Long orderId;
private transient Connection dbConnection; // transient 추가
}