1. 순환 참조란 무엇인가?
순환 참조는 객체(또는 컬렉션)가 직접 또는 간접적으로 자기 자신을 가리키는 상황입니다. 컬렉션에서는 생각보다 자주 발생하며, 특히 복잡한 자료구조를 만들거나 그래프를 다룰 때 자주 마주칩니다.
실전 예시
- 두 객체가 서로를 참조:
예를 들어, User 클래스가 있고 그 안에 Profile에 대한 참조가 있으며, Profile에는 다시 User로의 참조가 있을 수 있습니다. - 컬렉션이 자기 자신을 포함:
가장 단순하고 “재미있는” 예:
List<Object> list = new ArrayList<>();
list.add(list); // 앗! list가 자기 자신을 포함함
- 객체 그래프:
서로 연결된 객체들, 예를 들어 각 노드가 부모와 자식을 가리키는 트리 노드.
시각화
graph LR A[User] -- profile --> B[Profile] B -- user --> A
또는 컬렉션의 경우:
graph TD L[List] -- add(self) --> L
왜 문제가 될 수 있을까?
직렬화기가 사이클을 추적하지 못하면 포함된 객체를 끝없이 직렬화하려고 하다가 스택이 넘칠 수 있으며(StackOverflowError), 결국 실패합니다. 좋은 소식: Java 표준 직렬화는 이런 상황을 알고 있고 잘 처리합니다!
2. 순환 참조에서 Java 표준 직렬화는 어떻게 동작하는가?
ObjectOutputStream을 통해 객체를 직렬화할 때, Java는 해당 스트림에서 이미 직렬화된 객체들을 자동으로 추적합니다. 직렬화기가 동일 객체를 다시 만나면 내용을 다시 쓰지 않고 이미 직렬화된 객체에 대한 특수한 참조를 기록합니다. 덕분에 사이클을 포함한 매우 복잡한 구조도 올바르게 직렬화할 수 있습니다.
예: 자기 자신을 포함하는 컬렉션
자기 자신을 포함하는 컬렉션을 직렬화해 봅시다. 농담이 아닙니다 — 이런 코드는 컴파일되고 실제로 동작합니다:
import java.io.*;
import java.util.*;
public class CyclicListDemo {
public static void main(String[] args) throws Exception {
List<Object> list = new ArrayList<>();
list.add("Hello, cyclic world!");
list.add(list); // 자기 자신을 추가
// 직렬화
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic_list.ser"))) {
out.writeObject(list);
}
// 역직렬화
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic_list.ser"))) {
List<?> deserialized = (List<?>) in.readObject();
System.out.println(deserialized.get(0)); // "Hello, cyclic world!"
System.out.println(deserialized.get(1) == deserialized); // true!
}
}
}
결과:
— 첫 번째 요소는 평범한 문자열입니다.
— 두 번째 요소는... 컬렉션 자신입니다! deserialized.get(1) == deserialized 검사는 true를 반환합니다.
Java는 루프에 빠지거나 다운되지 않고, 참조 구조를 정확히 복원했습니다.
내부적으로 어떻게 동작할까?
ObjectOutputStream은 내부에 직렬화된 객체의 “레지스트리”를 유지합니다. 이미 직렬화된 객체라면 그 내용이 아니라 해당 객체에 대한 특수 참조(handle)만을 스트림에 기록합니다. 역직렬화 시 ObjectInputStream이 동일한 연결 관계를 복원합니다.
3. 문제점과 제약
- 의도치 않게 거대한 그래프를 직렬화.
데이터 구조가 매우 크고 교차 참조가 많다면 직렬화에 시간이 오래 걸리고 파일이 매우 커질 수 있습니다. - 클래스 구조 변경.
객체를 직렬화한 뒤 클래스 구조를 변경(예: 필드 추가/삭제)하면 역직렬화 시 InvalidClassException이 발생할 수 있습니다. 특히 사이클에 관여하는 필드가 변경되는 경우에 더 위험합니다. - 커스텀 직렬화 시의 문제.
writeObject와 readObject를 직접 구현한다면 사이클을 올바르게 처리해야 합니다. 기본 메서드(defaultWriteObject/defaultReadObject) 호출을 잊으면 직렬화기가 사이클을 추적하지 못합니다. - 다른 포맷(예: JSON)으로 직렬화.
Java 표준 직렬화(ObjectOutputStream)는 사이클을 처리하지만, Jackson이나 Gson으로 JSON에 직렬화할 때는 사이클 때문에 StackOverflowError나 예외가 발생할 수 있습니다. 이런 라이브러리들은 기본 설정으로는 사이클을 처리하지 못하므로 명시적인 설정이 필요합니다.
4. 순환 참조 처리
Java 표준 직렬화에서
모든 것이 “바로” 동작합니다! 별도의 처리가 필요 없습니다 — Java가 사이클을 자동으로 감지하고 참조 구조를 보존합니다.
수동 처리: 다른 포맷으로 직렬화할 때
- 참조 대신 식별자 사용.
다른 객체에 대한 참조를 저장하는 대신 고유 식별자를 저장하세요. 역직렬화 후 해당 id로 연결을 복원합니다. - 전용 애너테이션 또는 설정.
Jackson에서는 @JsonIdentityInfo나 @JsonBackReference/@JsonManagedReference 조합으로 사이클 직렬화를 제어할 수 있습니다. - 직렬화 전에 사이클 제거.
사이클을 만드는 필드를 일시적으로 null로 만들거나, transient 또는 애너테이션으로 제외합니다.
예: 사이클이 있는 그래프 직렬화
각 사용자가 다른 사용자의 친구가 될 수 있는 사용자 그래프라는 더 복잡한 구조를 살펴봅시다.
import java.io.*;
import java.util.*;
class User implements Serializable {
String name;
List<User> friends = new ArrayList<>();
User(String name) { this.name = name; }
public String toString() {
return name + " (" + friends.size() + " friends)";
}
}
public class CyclicGraphDemo {
public static void main(String[] args) throws Exception {
User alice = new User("Alice");
User bob = new User("Bob");
User charlie = new User("Charlie");
// 사이클을 만들며 친구 관계 설정
alice.friends.add(bob);
bob.friends.add(charlie);
charlie.friends.add(alice); // 사이클!
// 직렬화
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
out.writeObject(alice);
}
// 역직렬화
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("users.ser"))) {
User restoredAlice = (User) in.readObject();
System.out.println(restoredAlice);
System.out.println(restoredAlice.friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0).friends.get(0) == restoredAlice); // true!
}
}
}
결과:
— 사이클 구조가 복원됩니다: 친구를 세 번 따라가면 다시 Alice로 돌아옵니다.
— Java는 혼란에 빠지거나 루프에 갇히지 않았습니다.
5. 순환 참조를 다룰 때 흔한 실수
오류 №1: 사이클 지원 없이 JSON으로 직렬화. 사이클이 있는 객체를 Jackson이나 Gson으로 아무 설정 없이 직렬화하면, 대부분 StackOverflowError가 발생합니다. 예를 들어 Node 클래스에서 각 노드가 부모와 자식을 참조한다면, 이런 트리를 JSON으로 직렬화할 경우 무한 중첩이 일어납니다.
오류 №2: 클래스 구조 변경. 직렬화 이후 클래스 구조(예: 필드 추가)를 변경하면, 오래된 파일의 역직렬화 중 호환성 문제가 발생할 수 있습니다. 특히 사이클에 관여하는 복잡한 그래프에서는 더욱 치명적입니다.
오류 №3: 사이클을 고려하지 않은 수제 직렬화. writeObject/readObject를 직접 구현하면서 defaultWriteObject를 호출하지 않으면, Java가 사이클을 추적하지 못해 직렬화가 루프에 빠지거나 역직렬화 시 참조 구조가 깨질 수 있습니다.
오류 №4: 컬렉션이 실수로 자기 자신을 포함. 경험이 적은 개발자는 요소를 복사하다가 컬렉션을 자기 자신에게 추가해 사이클을 만들기도 합니다. 직렬화 자체는 동작하겠지만, 프로그램의 논리는 이상하고 예측 불가능해질 수 있습니다.
GO TO FULL VERSION