1. 제네릭 컬렉션의 직렬화와 역직렬화
Java의 제네릭(일반화)은 마법이 아니라 컴파일러가 제공하는 일종의 환상에 가깝습니다. 컴파일 단계에서 제네릭 타입 인자 정보는 소거됩니다(이를 type erasure, 또는 “타입 소거”라고 합니다). 즉, 런타임에는 List<String> 컬렉션이 List<Object>나 List<Integer>와 구별되지 않습니다. 모두 그냥 List일 뿐이며, JVM은 그 안에 어떤 타입이 들어 있는지 알지 못합니다.
예제를 살펴봅시다:
List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(stringList.getClass() == intList.getClass()); // true!
여기엔 약간의 트릭이 있습니다. 우리는 두 개의 리스트를 만듭니다 — 하나는 문자열용, 다른 하나는 숫자용입니다. 컴파일러 단계에서 Java는 List<String>에는 문자열만, List<Integer>에는 숫자만 넣도록 엄격히 검사합니다. 그러나 프로그램이 실행되면 이러한 차이는 사라집니다. JVM 관점에서 두 객체는 모두 단순한 ArrayList이며, 그 안에 어떤 요소가 들어가야 하는지 더 이상 확인할 수 없습니다. 그래서 두 리스트의 클래스 비교(stringList.getClass() == intList.getClass())는 true를 반환합니다.
따라서 중요한 결론: Java의 제네릭은 주로 컴파일 단계에서의 편의성과 타입 안정성을 위해 존재합니다. 하지만 런타임에는 이러한 “라벨”이 사라집니다. 따라서 컬렉션을 직렬화하면 파일에는 데이터만 저장되고, 제네릭 타입 정보는 저장되지 않습니다. 즉, 값 목록은 저장되지만, 파일만 보고 그것이 List<String>였는지, List<Object>였는지, List<Integer>였는지는 알 수 없습니다.
또 다른 예: List<String>의 직렬화와 역직렬화
import java.io.*;
import java.util.*;
public class GenericSerializationDemo {
public static void main(String[] args) throws Exception {
List<String> fruits = new ArrayList<>();
fruits.add("사과");
fruits.add("바나나");
fruits.add("오렌지");
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("fruits.ser"))) {
oos.writeObject(fruits);
}
// 역직렬화
List<String> loadedFruits;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("fruits.ser"))) {
loadedFruits = (List<String>) ois.readObject();
}
System.out.println(loadedFruits); // [사과, 바나나, 오렌지]
}
}
다음 줄에 주목하세요:
loadedFruits = (List<String>) ois.readObject();
여기서는 결과를 명시적으로 List<String>으로 캐스팅하지만, 런타임에서는 실제로 단순한 ArrayList입니다. 컴파일러는 이것이 실제로 문자열 리스트인지 확인할 수 없으며, 만약 문자열이 아닌 것이 들어 있다면 프로그램 실행 중에 ClassCastException이 발생합니다.
2. 제네릭 컬렉션 역직렬화 시 문제점
원소 타입 정보의 손실
제네릭 파라미터 정보가 소거되므로, 역직렬화 후 Java는 컬렉션에 기대한 타입의 객체만 들어 있다고 보장할 수 없습니다. 여러분이 받는 것은 “raw 타입” 컬렉션(raw type)일 뿐이며, 컴파일러는 경고하지 않지만 문제는 런타임에 드러날 수 있습니다.
문제 시연
List rawList = new ArrayList();
rawList.add("고양이");
rawList.add(42); // Integer!
// 역직렬화
List<String> loadedCats = (List<String>) ois.readObject();
String cat = loadedCats.get(1); // ClassCastException!
Unchecked 캐스트 경고
컴파일러는 잠재적 문제를 경고합니다:
Note: GenericSerializationDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
이 경고는 검증 없이 타입을 캐스팅하고 있으며, 컬렉션에 예상치 못한 타입의 객체가 들어 있을 수 있음을 의미합니다.
3. 제네릭 컬렉션 직렬화의 특징
파일에는 제네릭 파라미터 정보가 없음
List<String>과 List<Integer>를 직렬화하더라도, 파일에는 문자열이었는지 숫자였는지에 대한 정보가 전혀 없습니다. 컬렉션의 내용은 “있는 그대로” — 객체들이 순서대로 직렬화됩니다.
직렬화된 파일을 텍스트 편집기로 열어 보면, 거기에는 <String>이나 <Integer>라는 단어가 전혀 없습니다. 이런 정보는 오직 소스 코드와 컴파일러 수준에만 존재합니다.
예: 서로 다른 컬렉션 직렬화
List<Integer> numbers = Arrays.asList(1, 2, 3);
List<String> words = Arrays.asList("하나", "둘", "셋");
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.ser"))) {
oos.writeObject(numbers);
oos.writeObject(words);
}
파일 test.ser에는 ArrayList 타입의 객체 두 개만 있을 뿐, 제네릭 파라미터 정보는 없습니다.
‘raw’ 컬렉션 역직렬화의 문제
제네릭 파라미터 없이(raw type) 리스트를 직렬화하고, 이를 List<String>으로 역직렬화하면, 컴파일러는 타입의 정합성을 확인할 수 없으며 런타임 오류가 발생할 수 있습니다.
4. 제네릭 컬렉션 직렬화 모범 사례
예상되는 원소 타입을 문서화하세요.
API가 컬렉션을 직렬화한다면, 어떤 원소 타입이 예상되는지 반드시 명시하세요. 예: “이 메서드는 직렬화된 List<User>를 반환합니다.”
역직렬화 후 원소 타입을 검증하세요.
컬렉션을 역직렬화한 뒤에는 모든 원소가 예상한 타입인지 확인하는 것이 좋습니다(특히 데이터 출처를 신뢰할 수 없는 경우).
for (Object obj : loadedList) {
if (!(obj instanceof String)) {
throw new IllegalStateException("문자열을 예상했지만 발견됨: " + obj.getClass());
}
}
불변 컬렉션을 사용하세요.
읽기 전용으로만 컬렉션을 직렬화한다면 List.copyOf, Collections.unmodifiableList 같은 불변 컬렉션을 사용하세요. 역직렬화 후 데이터가 우연히 변경되는 것을 방지할 수 있습니다.
하나의 컬렉션에 여러 타입을 섞지 마세요.
서로 다른 타입의 요소를 가진 컬렉션(예: 다양한 클래스를 담은 List<Object>)을 직렬화하지 않도록 주의하세요. 역직렬화를 복잡하게 만들고 오류를 유발할 수 있습니다.
경고 억제를 신중하게 사용하세요.
올바른 타입의 컬렉션을 역직렬화한다는 것을 확신한다면, @SuppressWarnings("unchecked")로 컴파일러 경고를 억제할 수 있습니다:
@SuppressWarnings("unchecked")
List<String> loaded = (List<String>) ois.readObject();
하지만 이는 신중하게 사용하세요 — 문제를 프로덕션까지 가려 버리기 쉽습니다.
5. 사용자 정의 클래스가 들어 있는 컬렉션의 직렬화/역직렬화 예
다음과 같은 User 클래스가 있다고 가정해 봅시다:
import java.io.Serializable;
public class User implements Serializable {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return name + " (" + age + ")";
}
}
사용자 목록을 직렬화해 봅시다:
List<User> users = Arrays.asList(
new User("알리사", 30),
new User("밥", 25)
);
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
oos.writeObject(users);
}
// 역직렬화
List<User> loadedUsers;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("users.ser"))) {
loadedUsers = (List<User>) ois.readObject();
}
System.out.println(loadedUsers); // [알리사 (30), 밥 (25)]
잘 작동합니다! 그러나 누군가 직렬화된 파일에 다른 클래스의 객체를 끼워 넣으면, 요소를 User로 읽으려 할 때 ClassCastException이 발생할 수 있습니다.
6. 중첩 제네릭 컬렉션의 직렬화
컬렉션은 List<List<String>>, Map<String, List<User>> 등과 같이 중첩될 수 있습니다. Java는 이러한 구조를 재귀적으로 직렬화하지만, 규칙은 동일합니다:
- 모든 중첩 컬렉션과 요소는 직렬화 가능해야 합니다.
- 제네릭 파라미터 정보는 여전히 소거됩니다.
예: 리스트의 리스트 직렬화
List<List<String>> matrix = new ArrayList<>();
matrix.add(Arrays.asList("a", "b"));
matrix.add(Arrays.asList("c", "d"));
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("matrix.ser"))) {
oos.writeObject(matrix);
}
// 역직렬화
List<List<String>> loadedMatrix;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("matrix.ser"))) {
loadedMatrix = (List<List<String>>) ois.readObject();
}
System.out.println(loadedMatrix); // [[a, b], [c, d]]
7. 유용한 팁과 주의점
서로 다른 구현 간 제네릭 컬렉션 직렬화
가끔은 한 컬렉션 구현을 직렬화하고, 다른 구현으로 역직렬화하려는 경우가 있습니다. 예를 들어, ArrayList로 직렬화해 놓고 LinkedList로 역직렬화하려 하면, 타입 캐스팅 오류가 발생합니다:
List<String> list = new ArrayList<>();
// ...
List<String> loaded = (LinkedList<String>) ois.readObject(); // ClassCastException!
조언: 직렬화했던 것과 동일한 타입으로 역직렬화하거나, 특정 구현이 중요하지 않다면 인터페이스(List)를 사용하세요.
라이브러리 사용(예: Gson, Jackson)
JSON 라이브러리(Gson, Jackson 등)는 제네릭이 있는 컬렉션을 직렬화/역직렬화할 수 있지만, 타입 소거 때문에 역직렬화 시 명시적으로 타입을 지정해야 합니다. Gson 예:
Type type = new com.google.gson.reflect.TypeToken<List<User>>(){}.getType();
List<User> users = gson.fromJson(json, type);
8. Map과 Set에서의 제네릭과 직렬화
위 규칙은 제네릭을 사용하는 다른 컬렉션에도 동일하게 적용됩니다:
- Map<String, Integer>를 직렬화해도 키와 값의 타입 정보는 저장되지 않습니다.
- 역직렬화 시에는 필요한 타입으로 캐스팅해야 하며, 내용물의 타입에 유의해야 합니다.
예: Map 직렬화
Map<String, Integer> scores = new HashMap<>();
scores.put("바샤", 90);
scores.put("페챠", 85);
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("scores.ser"))) {
oos.writeObject(scores);
}
// 역직렬화
Map<String, Integer> loadedScores;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("scores.ser"))) {
loadedScores = (Map<String, Integer>) ois.readObject();
}
System.out.println(loadedScores); // {바샤=90, 페챠=85}
9. 제네릭 컬렉션 직렬화에서 흔한 오류
오류 №1: 역직렬화 시 ClassCastException. 컬렉션을 List<String>으로 역직렬화했는데, 그 안에 다른 타입의 객체가 들어 있으면 런타임에 ClassCastException이 발생합니다. 항상 컬렉션의 내용을 확인하세요!
오류 №2: 직렬화할 수 없는 요소로 인한 NotSerializableException. 컬렉션의 요소 중 하나라도 Serializable을 구현하지 않으면 직렬화는 NotSerializableException으로 실패합니다. 컬렉션에 들어갈 수 있는 모든 클래스가 직렬화 가능인지 확인하세요.
오류 №3: 제네릭 파라미터 정보 손실. 역직렬화 후에는 제네릭 파라미터에 의존하지 마세요 — 런타임에는 존재하지 않습니다. 데이터의 정확성에 의심이 든다면 명시적으로 타입을 검증하세요.
오류 №4: 컬렉션 구현 불일치. ArrayList로 직렬화해 놓고 LinkedList로 역직렬화하면 캐스팅 오류가 발생합니다. 직렬화했던 것과 동일한 타입으로 역직렬화하도록 하세요.
오류 №5: 클래스 버전 불일치. 직렬화 이후에 컬렉션 요소 클래스의 구조가 변경되었다면(예: 필드 추가), 역직렬화 시 오류가 발생할 수 있습니다. 버전 관리를 위해 serialVersionUID를 사용하세요.
GO TO FULL VERSION