Ch 15.4 직렬화 (Serialization)
프로그램 실행 중에 생성된 인스턴스(객체)들은 JVM 메모리(Heap)에만 존재하며, 프로그램이 종료되면 모두 사라집니다. 이러한 객체의 상태(필드에 저장된 값들)를 영구적으로 파일에 저장하거나, 네트워크를 통해 다른 서버의 JVM으로 전송하고 싶을 때 직렬화 를 사용합니다.
- 직렬화 (Serialization): 메모리에 있는 객체를 바이트(Byte) 스트림 형태로 연속된 데이터로 변환하는 과정
- 역직렬화 (Deserialization): 저장되거나 전송받은 바이트 스트림을 다시 원래의 객체 데이터로 복원하는 과정
- 객체를 파일에 저장했다가 나중에 복원
- 네트워크를 통해 다른 JVM에 객체 전달
- HTTP 세션 클러스터링 (Tomcat 세션 복제)
- RMI(Remote Method Invocation)
1. Serializable 인터페이스
객체를 직렬화하려면 반드시 해당 클래스가 java.io.Serializable 인터페이스를 구현(implements)해야 합니다. 이 인터페이스는 단지 "이 클래스는 직렬화가 허용됨"을 마킹하는 마커 인터페이스(Marker Interface) 로, 구현해야 할 메서드는 없습니다.
import java.io.Serializable;
// 직렬화가 가능한 UserInfo 클래스
class UserInfo implements Serializable {
// serialVersionUID: 직렬화 버전 관리 (뒤에서 자세히 설명)
private static final long serialVersionUID = 1L;
String name;
int age;
// transient: 직렬화 대상에서 제외 (보안 민감 데이터)
transient String password;
// static 필드도 직렬화 제외 (클래스 변수이지 인스턴스 변수가 아님)
static String appVersion = "1.0";
public UserInfo(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
@Override
public String toString() {
return "UserInfo{name='" + name + "', age=" + age +
", password=" + password + "}";
}
}
2. ObjectOutputStream / ObjectInputStream
객체를 직접 입출력하기 위해 전용 보조 스트림인 ObjectOutputStream, ObjectInputStream을 사용합니다.
import java.io.*;
public class SerializationBasic {
public static void main(String[] args) {
UserInfo u1 = new UserInfo("Alice", 30, "secret123");
String fileName = "userinfo.ser";
// ① 객체 직렬화 → 파일에 저장
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(fileName)))) {
oos.writeObject(u1);
System.out.println("직렬화 완료: " + u1);
} catch (IOException e) {
e.printStackTrace();
}
// ② 파일 → 역직렬화 → 객체 복원
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(fileName)))) {
UserInfo restored = (UserInfo) ois.readObject();
System.out.println("역직렬화 완료: " + restored);
// password는 transient이므로 null로 복원됨
} catch (Exception e) {
e.printStackTrace();
}
}
}
출력 결과:
직렬화 완료: UserInfo{name='Alice', age=30, password=secret123}
역직렬화 완료: UserInfo{name='Alice', age=30, password=null}
3. 여러 객체 직렬화 / 리스트 직렬화
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class MultiObjectSerialization {
public static void main(String[] args) throws Exception {
// 방법 1: 여러 객체를 순서대로 저장
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("users.ser"))) {
oos.writeObject(new UserInfo("Alice", 30, "pass1"));
oos.writeObject(new UserInfo("Bob", 25, "pass2"));
oos.writeObject(new UserInfo("Charlie", 35, "pass3"));
}
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("users.ser"))) {
// 저장 순서대로 읽어야 함
UserInfo u1 = (UserInfo) ois.readObject();
UserInfo u2 = (UserInfo) ois.readObject();
UserInfo u3 = (UserInfo) ois.readObject();
System.out.println(u1.name + ", " + u2.name + ", " + u3.name);
}
// 방법 2: List에 담아서 한 번에 저장 (권장)
List<UserInfo> users = new ArrayList<>();
users.add(new UserInfo("Dave", 28, "pass4"));
users.add(new UserInfo("Eve", 32, "pass5"));
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("userlist.ser"))) {
oos.writeObject(users); // List 자체를 직렬화
}
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("userlist.ser"))) {
@SuppressWarnings("unchecked")
List<UserInfo> restoredList = (List<UserInfo>) ois.readObject();
restoredList.forEach(u -> System.out.println(u.name));
}
}
}
4. serialVersionUID의 역할과 중요성
serialVersionUID는 직렬화된 클래스의 버전을 식별하는 고유 ID입니다. 역직렬화 시 JVM은 바이트 스트림의 UID와 현재 클래스의 UID가 일치하는지 확인합니다.
import java.io.*;
// 버전 1: 처음 직렬화할 때의 클래스
class Product implements Serializable {
private static final long serialVersionUID = 1L; // 명시적 선언 권장
String name;
double price;
Product(String name, double price) {
this.name = name;
this.price = price;
}
}
public class SerialVersionUIDExample {
public static void main(String[] args) throws Exception {
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("product.ser"))) {
oos.writeObject(new Product("노트북", 1_200_000.0));
}
// serialVersionUID를 변경하지 않으면 역직렬화 성공
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("product.ser"))) {
Product p = (Product) ois.readObject();
System.out.println(p.name + ": " + p.price); // 노트북: 1200000.0
}
// 만약 클래스에 필드를 추가하고 serialVersionUID를 바꾸면?
// InvalidClassException: serialVersionUID가 불일치!
// 반드시 serialVersionUID를 유지하거나 직렬화 파일을 재생성해야 함
}
}
serialVersionUID를 명시하지 않으면 JVM이 자동으로 계산합니다. 클래스에 필드나 메서드를 추가하면 자동 계산값이 바뀌어 기존 직렬화 데이터를 읽지 못하게 됩니다. 반드시 명시적으로 선언하세요.
5. transient 키워드
transient 키워드가 붙은 필드는 직렬화 대상에서 제외됩니다. 보안상 저장하면 안 되는 값이나, 직렬화 불가 타입의 필드에 사용합니다.
import java.io.*;
class BankAccount implements Serializable {
private static final long serialVersionUID = 1L;
String accountNumber; // 직렬화 O
String ownerName; // 직렬화 O
transient String pin; // 보안: 저장 제외
transient double cachedRate; // 재계산 가능한 캐시값
// 직렬화 불가 타입 (Serializable 미구현)을 transient로 제외
transient java.util.logging.Logger logger; // Logger는 직렬화 불가
BankAccount(String accountNumber, String ownerName, String pin) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.pin = pin;
}
// readObject: 역직렬화 후 transient 필드 재초기화
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 기본 역직렬화 수행
this.cachedRate = 3.5; // transient 필드 재초기화
this.logger = java.util.logging.Logger.getLogger(getClass().getName());
}
}
6. 역직렬화 보안 취약점
신뢰할 수 없는 소스에서 역직렬화를 수행하면 Remote Code Execution(RCE) 공격에 취약합니다. Apache Commons Collections, Spring Framework 등 많은 프레임워크에서 이 취약점이 발견되었습니다.
import java.io.*;
public class SecureDeserializationExample {
// 안전하지 않은 역직렬화
static Object unsafeDeserialize(byte[] data) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
return ois.readObject(); // 신뢰할 수 없는 데이터면 위험!
}
// 안전한 역직렬화: 허용 클래스만 역직렬화 (Java 9+ ObjectInputFilter)
static Object safeDeserialize(byte[] data) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
// Java 9+: ObjectInputFilter로 허용 클래스 목록 제한
ois.setObjectInputFilter(filterInfo -> {
Class<?> clazz = filterInfo.serialClass();
if (clazz == null) return ObjectInputFilter.Status.UNDECIDED;
// UserInfo만 허용
if (clazz == UserInfo.class) return ObjectInputFilter.Status.ALLOWED;
return ObjectInputFilter.Status.REJECTED;
});
return ois.readObject();
}
public static void main(String[] args) throws Exception {
// 직렬화
ByteArrayOutputStream baos = new ByteArrayOutputStream();
new ObjectOutputStream(baos).writeObject(new UserInfo("Alice", 30, "pass"));
byte[] bytes = baos.toByteArray();
// 안전한 역직렬화
UserInfo user = (UserInfo) safeDeserialize(bytes);
System.out.println("안전하게 복원: " + user.name);
}
}
7. 실전 예제: 객체를 파일에 저장하고 복원
import java.io.*;
import java.util.*;
// 주문 도메인 클래스
class Order implements Serializable {
private static final long serialVersionUID = 1L;
String orderId;
String productName;
int quantity;
double price;
Date orderDate;
Order(String orderId, String productName, int quantity, double price) {
this.orderId = orderId;
this.productName = productName;
this.quantity = quantity;
this.price = price;
this.orderDate = new Date();
}
double totalAmount() { return quantity * price; }
@Override
public String toString() {
return String.format("Order[%s] %s x%d = %.0f원",
orderId, productName, quantity, totalAmount());
}
}
// 주문 관리 서비스 (간단한 파일 기반 저장소)
class OrderStore {
private static final String FILE_PATH = "orders.ser";
@SuppressWarnings("unchecked")
public List<Order> loadOrders() {
File f = new File(FILE_PATH);
if (!f.exists()) return new ArrayList<>();
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(FILE_PATH)))) {
return (List<Order>) ois.readObject();
} catch (Exception e) {
System.err.println("주문 로드 실패: " + e.getMessage());
return new ArrayList<>();
}
}
public void saveOrders(List<Order> orders) {
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(FILE_PATH)))) {
oos.writeObject(orders);
System.out.println("주문 " + orders.size() + "건 저장 완료");
} catch (IOException e) {
System.err.println("주문 저장 실패: " + e.getMessage());
}
}
}
public class SerializationRealWorld {
public static void main(String[] args) {
OrderStore store = new OrderStore();
// 기존 주문 로드
List<Order> orders = store.loadOrders();
System.out.println("기존 주문: " + orders.size() + "건");
// 새 주문 추가
orders.add(new Order("ORD-001", "노트북", 1, 1_200_000));
orders.add(new Order("ORD-002", "마우스", 2, 35_000));
orders.add(new Order("ORD-003", "키보드", 1, 80_000));
// 저장
store.saveOrders(orders);
// 다시 로드하여 확인
List<Order> loadedOrders = store.loadOrders();
System.out.println("\n=== 저장된 주문 목록 ===");
loadedOrders.forEach(System.out::println);
double total = loadedOrders.stream()
.mapToDouble(Order::totalAmount)
.sum();
System.out.printf("%n총 주문 금액: %,.0f원%n", total);
}
}
8. 직렬화 대안: JSON (Jackson)
현대 애플리케이션에서는 Java 직렬화 대신 JSON을 주로 사용합니다.
// pom.xml 또는 build.gradle에 Jackson 추가 필요
// implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.0'
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.util.List;
import java.util.Arrays;
public class JacksonExample {
static class UserDto {
public String name;
public int age;
// 민감 정보는 @JsonIgnore로 제외
// @com.fasterxml.jackson.annotation.JsonIgnore
public String email;
public UserDto() {} // Jackson에서 역직렬화에 기본 생성자 필요
public UserDto(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
UserDto user = new UserDto("Alice", 30, "alice@example.com");
// 객체 → JSON 문자열
String json = mapper.writeValueAsString(user);
System.out.println("JSON: " + json);
// {"name":"Alice","age":30,"email":"alice@example.com"}
// JSON 문자열 → 객체
UserDto parsed = mapper.readValue(json, UserDto.class);
System.out.println("파싱: " + parsed.name + ", " + parsed.age);
// 파일에 JSON 저장
mapper.writerWithDefaultPrettyPrinter()
.writeValue(new File("user.json"), user);
// 파일에서 JSON 읽기
UserDto fromFile = mapper.readValue(new File("user.json"), UserDto.class);
System.out.println("파일에서 복원: " + fromFile.name);
}
}
9. 직렬화 방식 비교
| 항목 | Java 직렬화 | JSON (Jackson) | Protocol Buffers |
|---|---|---|---|
| 형식 | 바이너리 | 텍스트 | 바이너리 |
| 언어 호환성 | JVM 전용 | 모든 언어 | 모든 언어 |
| 가독성 | 없음 | 있음 | 없음 |
| 성능 | 보통 | 보통 | 매우 빠름 |
| 파일 크기 | 큼 | 보통 | 매우 작음 |
| 보안 | 취약 | 안전 | 안전 |
| 설정 복잡도 | 낮음 | 낮음 | 높음 |
| 권장 사용처 | 레거시 | REST API, 설정 | gRPC, 대용량 |
- Java 직렬화: 레거시 코드 유지보수, JVM 내부 RMI
- JSON (Jackson/Gson): REST API, 설정 파일, 로그 (가장 일반적)
- Protocol Buffers: 마이크로서비스 gRPC, 대용량 고성능 통신
- Avro/Thrift: 빅데이터 파이프라인 (Kafka 등)