CodeGym /행동 /JAVA 25 SELF /이진 직렬화에서 객체 동일성

이진 직렬화에서 객체 동일성

JAVA 25 SELF
레벨 44, 레슨 3
사용 가능

1. 소개

Java(그리고 전반적인 OOP)에서 각 객체는 동일성을 가진다 — 이는 단순히 필드 값들의 집합이 아니라 메모리에서 “그 객체 자체가 누구인가”를 의미한다. 두 객체는 “동등”할 수 있다(예를 들어, a.equals(b)가 true를 반환)지만, 동일성은 다를 수 있다(a != b). 동일성이란 두 변수가 메모리에서 동일한 객체를 가리키는 경우(a == b)를 말한다.

직렬화에서 왜 중요한가?

객체 그래프(예: 트리, 리스트 또는 동일한 하위 객체를 가리키는 두 객체 등)를 직렬화(저장)할 때는 필드 값뿐 아니라 객체들 간의 참조 구조 또한 보존하는 것이 중요하다.

그래프에 공유 참조(여러 객체가 동일한 하위 객체를 참조)나 순환 참조(A → B → A)가 있는 경우, 역직렬화 후에도 이러한 연결은 그대로 유지되어야 한다.

예시: 객체 A와 B가 있고, 둘 다 동일한 객체 C를 참조한다고 하자. 직렬화 후 역직렬화한 다음에도 a.c == b.c 이어야 한다(메모리에서 동일한 객체). 직렬화가 단순히 객체를 “복사”만 한다면, 복원 후에는 두 개의 서로 다른 C가 생성되어 원래와 다른 그래프가 된다.

2. ObjectOutputStream이 동일성을 해결하는 방식

Java의 이진 직렬화에는 ObjectOutputStream(쓰기)과 ObjectInputStream(읽기) 클래스 쌍이 사용된다.

ObjectOutputStream은 단순히 “객체를 파일에 쓰는” 것이 아니다. 더 똑똑하게 동작한다:

  • 하나의 기록 흐름(하나의 세션)에서 이미 직렬화된 모든 객체를 추적한다.
  • 이미 직렬화된 객체에 대한 참조가 다시 나타나면, 객체를 다시 쓰지 않고 특수한 “재참조”(reference handle)만 기록한다.
  • 역직렬화 시 ObjectInputStream은 구조를 복원하여 재참조들이 메모리의 동일한 객체를 가리키도록 한다.

이는 순환 참조에도 작동한다! 즉 A → B → A 같은 구조가 있어도 직렬화와 역직렬화가 무한 루프에 빠지거나 실패하지 않고, 참조가 올바르게 복원된다.

어떻게 구현될까?

ObjectOutputStream 내부에는 이미 직렬화한 객체를 저장하는 특수 테이블(identity map)이 있다. 새로운 객체를 만나면 테이블에 추가하고 전체를 직렬화한다. 테이블에 있는 객체를 만나면 스트림에는 “재참조”(handle)만 쓴다.

3. 예제로 보는 동작

예제 1: 순환 참조 그래프 (A → B, B → A)

import java.io.*;

class Node implements Serializable {
    String name;
    Node next;

    Node(String name) {
        this.name = name;
    }
}

public class CyclicSerializationDemo {
    public static void main(String[] args) throws Exception {
        Node a = new Node("A");
        Node b = new Node("B");
        a.next = b;
        b.next = a; // 순환!

        // 직렬화
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
            out.writeObject(a);
        }

        // 역직렬화
        Node a2;
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic.dat"))) {
            a2 = (Node) in.readObject();
        }

        System.out.println("a2.name = " + a2.name); // A
        System.out.println("a2.next.name = " + a2.next.name); // B
        System.out.println("a2.next.next == a2: " + (a2.next.next == a2)); // true!
    }
}

출력:

a2.name = A
a2.next.name = B
a2.next.next == a2: true

설명:
사이클이 그대로 유지되었다! 역직렬화 후 a2.next.next는 다시 a2를 가리킨다.

예제 2: 공유 참조 (A → C, B → C)

import java.io.*;

class Wrapper implements Serializable {
    String name;
    Object ref;

    Wrapper(String name) { this.name = name; }
}

public class SharedReferenceDemo {
    public static void main(String[] args) throws Exception {
        Wrapper a = new Wrapper("A");
        Wrapper b = new Wrapper("B");
        Wrapper c = new Wrapper("C");

        a.ref = c;
        b.ref = c;

        // 직렬화
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
            out.writeObject(new Wrapper[] {a, b});
        }

        // 역직렬화
        Wrapper[] arr;
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("shared.dat"))) {
            arr = (Wrapper[]) in.readObject();
        }

        Wrapper a2 = arr[0];
        Wrapper b2 = arr[1];

        System.out.println("a2.ref == b2.ref: " + (a2.ref == b2.ref)); // true!
        System.out.println("a2.ref.name = " + ((Wrapper)a2.ref).name); // C
    }
}

출력:

a2.ref == b2.ref: true
a2.ref.name = C

설명:
역직렬화 후 두 참조는 다시 동일한 객체를 가리킨다.

4. writeReplace와 readResolve의 관계

Java에서는 특수 메서드를 통해 (역)직렬화 과정에 “개입”할 수 있다:

writeReplace() — 객체가 직렬화되기 전에 호출된다. 원본 대신 직렬화할 다른 객체를 반환할 수 있다.
readResolve() — 객체가 역직렬화된 뒤 호출된다. 방금 생성된 객체 대신 사용할 다른 객체를 반환할 수 있다.

동일성에 미치는 영향:

  • writeReplace() 또는 readResolve()를 사용하면, 이 메서드들이 반환한 객체가 바로 참조 테이블에 저장된다.
  • 이로 인해 동일성이 바뀔 수 있다: readResolve()가 새로운/다른 객체를 반환하면 공유 참조가 서로 다른 인스턴스를 가리킬 수 있다.
  • 주의: 동일성을 “깨뜨릴” 수도 있고, 반대로 의도적으로 보장할 수도 있다(고전적인 예 — readResolve()를 통한 싱글턴: 역직렬화 후 모든 참조가 동일한 싱글턴 인스턴스를 가리키게 함).

5. 실습: 동일성 보존을 보여주는 코드

import java.io.*;

class Shared implements Serializable {
    String value;
    Shared(String value) { this.value = value; }
}

class Holder implements Serializable {
    String name;
    Shared shared;
    Holder(String name, Shared shared) {
        this.name = name;
        this.shared = shared;
    }
}

public class IdentityDemo {
    public static void main(String[] args) throws Exception {
        Shared c = new Shared("C");
        Holder a = new Holder("A", c);
        Holder b = new Holder("B", c);

        // 직렬화
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
            out.writeObject(new Holder[] {a, b});
        }

        // 역직렬화
        Holder[] arr;
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("identity.dat"))) {
            arr = (Holder[]) in.readObject();
        }

        Holder a2 = arr[0];
        Holder b2 = arr[1];

        System.out.println("a2.shared == b2.shared: " + (a2.shared == b2.shared)); // true!
        System.out.println("a2.shared.value = " + a2.shared.value); // C
    }
}

출력:

a2.shared == b2.shared: true
a2.shared.value = C

설명:
c 객체의 동일성이 보존되었다: 역직렬화 후 두 참조는 동일한 인스턴스를 가리킨다.

6. 정리 및 흔한 실수

오류 1: 공유 참조가 있는 객체를 개별적으로 직렬화함. 각 객체를 자신의 ObjectOutputStream 인스턴스로 기록하면, 객체들 사이의 참조는 보존되지 않는다 — 역직렬화된 인스턴스들은 원래 하나의 참조였더라도 서로 다른 객체가 된다.

오류 2: writeReplace()와 readResolve()의 부적절한 사용. 이 메서드들은 (역)직렬화 중 객체를 바꿔치기할 수 있으며, 그 결과 동일성이 변할 수 있다. 동작 원리를 이해하지 못하면 예상치 못한 인스턴스가 생성될 수 있다.

오류 3: 공유되는 변경 가능한 참조로 인한 예상치 못한 효과. 여러 객체가 하나의 변경 가능한 하위 객체(예: 리스트)를 참조한다면, 역직렬화 후에도 그대로 유지된다. 한 곳에서의 변경이 다른 모든 곳에 영향을 준다.

오류 4: 직렬화 후 “새로운” 객체를 기대함. 직렬화는 “새로운” 구조를 만드는 것이 아니라 원본과 동일한 참조를 복원한다. 캐시나 템플릿 객체를 다룰 때는 이 점이 예상과 다를 수 있다.

1
과제
JAVA 25 SELF, 레벨 44, 레슨 3
잠금
공유 문서에서의 공동 작업: 공유 객체 유지 📝
공유 문서에서의 공동 작업: 공유 객체 유지 📝
1
과제
JAVA 25 SELF, 레벨 44, 레슨 3
잠금
인벤토리 관리: 분리 저장 시 객체 동일성 📦
인벤토리 관리: 분리 저장 시 객체 동일성 📦
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION