CodeGym /행동 /JAVA 25 SELF /객체 직렬화 입문: 왜 필요한가

객체 직렬화 입문: 왜 필요한가

JAVA 25 SELF
레벨 42 , 레슨 0
사용 가능

1. 직렬화가 필요한 이유

여러분의 객체가 휴가에 가져갈 짐이라고 상상해 보세요. 직렬화는 그 모든 짐을 특별한 컨테이너에 포장해 수하물로 맡기거나 우편으로 보낼 수 있게 하는 과정입니다. 역직렬화는 그 컨테이너를 다시 풀어 원래 상태의 물건을 꺼내는 것입니다.

본질적으로 직렬화는 객체를 파일에 저장하거나 네트워크로 전송하거나 메모리에 보관할 수 있는 바이트 스트림으로 변환합니다. 역직렬화는 그 반대로 스트림에서 객체를 복원합니다. 아주 단순화하면, 직렬화는 객체를 “얼려” 두었다가 나중에 “해동”하여 같은 상태로 되돌리는 것과 같습니다.

프로그램 실행 사이에 객체 상태 저장

가장 흔한 시나리오 중 하나는 프로그램 상태 저장입니다. 예를 들어 사용자 목록, 게임 결과, 애플리케이션 설정 등이 있습니다. 이런 것들은 객체 형태 그대로 보관하는 것이 편리합니다. 실행 사이에 데이터가 사라지지 않도록 파일로 직렬화하고, 다음 실행 시 역직렬화하여 복구합니다.

좋은 예시는 일반적인 게임 저장입니다. 플레이어가 레벨을 진행하면 진행 상황을 “얼려서” 직렬화를 통해 파일에 기록합니다. 다음 날 게임을 실행하면 진행 상황이 “해동”됩니다. 파일의 데이터가 다시 객체로 변환되어 플레이어는 중단했던 지점에서 이어서 플레이할 수 있습니다.

간단한 저장 기능을 만들어 봅시다:

import java.io.*;

// Player 클래스는 Serializable을 구현해야 함
class Player implements Serializable {
    String name;
    int score;

    Player(String name, int score) {
        this.name = name;
        this.score = score;
    }
}

public class GameSaveExample {
    public static void main(String[] args) throws Exception {
        // Player 객체 생성
        Player player = new Player("Ihor", 1500);

        // --- 저장(직렬화) ---
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("save.dat"))) {
            out.writeObject(player);
            System.out.println("진행 상황이 저장되었습니다!");
        }

        // --- 로드(역직렬화) ---
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("save.dat"))) {
            Player loaded = (Player) in.readObject();
            System.out.println("진행 상황 로드됨: " + loaded.name + " (점수 " + loaded.score + ")");
        }
    }
}

주의: 이 코드가 동작하려면 Player 클래스가 인터페이스 Serializable을 구현해야 합니다. 자세한 내용은 다음 강의에서 다룹니다!

  • Player는 이름과 점수 필드를 가진 일반 클래스이며, 인터페이스 Serializable을 구현합니다(implements Serializable).
  • ObjectOutputStream이 객체를 파일 "save.dat"에 기록합니다.
  • ObjectInputStream이 같은 객체를 다시 읽어옵니다.
  • 그 결과 실제 저장 파일이 생기며, 다음 실행 시 프로그램이 동일한 상태의 플레이어 객체를 로드합니다.

네트워크와 JVM 간 객체 전송

분산 시스템에서는 서로 다른 프로그램이나 심지어 다른 머신 사이에서 객체를 전달해야 하는 경우가 자주 있습니다. 예를 들어 클라이언트와 서버가 메시지를 주고받아야 할 수 있습니다. 직렬화를 사용하면 한쪽에서 객체를 “포장”해 네트워크로 보내고, 다른 쪽에서 “풀어서” 사용할 수 있습니다.

예: 클라이언트가 서버로 주문 객체(Order)를 보냅니다. 서버는 이를 수신하고 역직렬화하여 처리합니다.

Java 기술에서의 활용

  • RMI (Remote Method Invocation): 원격 객체의 메서드를 호출할 수 있게 해 주며, 인자와 반환값을 전달하기 위해 직렬화가 필요합니다.
  • HTTP 세션: 서블릿에서 컨테이너 재시작 시 세션의 객체가 직렬화됩니다.
  • JMS (Java Message Service): 컴포넌트 간 메시지가 직렬화될 수 있습니다.
  • 캐싱: 객체를 디스크나 분산 저장소의 캐시에 보관하기 위해 직렬화할 수 있습니다.

캐싱과 이식성

중간 결과를 빠르게 저장하려면(예: 캐싱) 직렬화가 훌륭한 도구입니다. 객체를 직렬화해 디스크나 메모리에 저장해 두었다가, 재계산 없이 빠르게 복원할 수 있습니다.

2. 직렬화 사용 시나리오 예시

사용자 컬렉션을 파일로 저장

예를 들어, User 클래스가 있다고 합시다:

public class User {
    String name;
    int age;
    // ... 기타 필드
}

그리고 사용자 목록이 있습니다:

List<User> users = new ArrayList<>();
users.add(new User("바샤", 25));
users.add(new User("마샤", 30));
// ... 등등

이 목록을 파일로 저장하려면 직렬화하면 됩니다. 필요할 때 역직렬화하여 동일한 사용자들이 들어 있는 똑같은 목록을 얻을 수 있습니다. 참고로 User 클래스(및 그 필드들)는 직렬화를 지원해야 하므로 Serializable을 구현해야 합니다.

클라이언트와 서버 간 메시지 전송

고전적인 예는 채팅입니다. 사용자가 메시지를 작성하면 Message 객체가 직렬화되어 네트워크로 전송됩니다. 서버는 바이트 스트림을 수신하고 객체를 역직렬화해 처리한 뒤, 필요하면 다시 전달합니다.

import java.io.*;
import java.net.*;

// Message는 Serializable이어야 함
class Message implements Serializable {
    String text;

    Message(String text) {
        this.text = text;
    }
}

// 서버
class Server {
    public static void main(String[] args) throws Exception {
        try (ServerSocket serverSocket = new ServerSocket(5000)) {
            System.out.println("서버가 연결을 기다리는 중...");
            Socket socket = serverSocket.accept();
            System.out.println("클라이언트가 연결되었습니다!");

            try (ObjectInputStream in = new ObjectInputStream(socket.getInputStream())) {
                Message msg = (Message) in.readObject();
                System.out.println("수신한 메시지: " + msg.text);
            }
        }
    }
}

// 클라이언트
class Client {
    public static void main(String[] args) throws Exception {
        try (Socket socket = new Socket("localhost", 5000)) {
            try (ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream())) {
                Message msg = new Message("안녕, 서버!");
                out.writeObject(msg);
                System.out.println("메시지를 전송했습니다!");
            }
        }
    }
}

동작 방식:

  1. 먼저 Server를 실행합니다(연결을 기다립니다).
  2. 그다음 Client를 실행합니다( "localhost:5000" 에 연결합니다).
  3. 클라이언트가 Message 객체를 직렬화하여 소켓으로 전송합니다.
  4. 서버는 바이트 스트림을 수신하고 역직렬화하여 텍스트를 출력합니다.

여기서는 소켓(ServerSocket, Socket)을 사용합니다. 이는 네트워크 상호작용을 위한 메커니즘으로, 자세한 동작은 나중에 학습하게 됩니다. 지금 중요한 것은 네트워크 세부사항이 아니라 개념입니다. 클라이언트가 Message 객체를 만들고 직렬화하여 전송하면, 서버는 바이트 스트림을 받아 역직렬화로 객체를 복원하고 메시지를 출력합니다. 즉, 아직 ServerSocketSocket이 무엇인지 완전히 이해하지 못하더라도, 직렬화의 가치를 보여 주는 예시입니다. 덕분에 객체를 “포장”해 네트워크로 보냈다가, 다른 쪽에서 불필요한 변환 없이 바로 풀어 사용할 수 있습니다.

객체 캐싱

대규모 애플리케이션에서는 성능 향상을 위해 캐싱을 자주 사용합니다. 예를 들어, 복잡한 계산 결과를 직렬화하여 캐시(파일, 데이터베이스, 분산 저장소)에 저장합니다. 다음 요청에서는 객체를 역직렬화하여 빠르게 결과를 복원할 수 있습니다.

import java.io.*;

// 캐시하려는 계산 결과
class Result implements Serializable {
    int value;

    Result(int value) {
        this.value = value;
    }
}

public class CacheExample {
    private static final String CACHE_FILE = "cache.dat";

    public static void main(String[] args) throws Exception {
        Result result;

        // 캐시 존재 여부 확인
        File file = new File(CACHE_FILE);
        if (file.exists()) {
            // 캐시에서 결과 로드
            try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(file))) {
                result = (Result) in.readObject();
                System.out.println("캐시에서 로드됨: " + result.value);
            }
        } else {
            // '무거운' 계산(예제에서는 단순히 제곱)
            int x = 12345;
            System.out.println("계산 중... (시간이 걸립니다)");
            result = new Result(x * x);

            // 결과를 캐시에 저장
            try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(file))) {
                out.writeObject(result);
                System.out.println("캐시에 저장됨: " + result.value);
            }
        }
    }
}

3. 직렬화의 제약과 위험

직렬화는 강력한 도구이지만 주의할 점도 있습니다. 주요 제약과 위험을 살펴봅시다.

모든 객체를 직렬화할 수 있는 것은 아님

Java에서는 모든 객체가 기본적으로 직렬화 가능한 것은 아닙니다. 예를 들어 외부 자원(파일, 네트워크 연결, 입출력 스트림)과 연결된 객체는 직렬화 대상이 아닙니다. 이는 당연합니다. “열려 있는 파일”이나 “활성 네트워크 연결”을 직렬화할 수는 없습니다. 이들의 상태는 운영체제와 런타임 환경에 의존하기 때문입니다.

예: 필드 타입이 FileInputStream인 클래스를 직렬화할 수 없습니다. 직렬화를 시도하면 오류가 발생합니다.

보안 문제

직렬화는 잠재적인 보안 구멍이 될 수 있습니다. 신뢰할 수 없는 소스(예: 인터넷)에서 받은 데이터를 역직렬화하면, 공격자가 악의적인 바이트 스트림을 주입해 프로그램이 예기치 않게 동작하거나 심지어 악성 코드가 실행될 수도 있습니다.

규칙: 신뢰할 수 없는 소스의 데이터를 절대 역직렬화하지 마십시오! 알 수 없는 발신자로부터 온 소포를 여는 것과 같습니다. 내부에 무엇이 들어 있을지 모릅니다.

버전 호환성

클래스 구조를 변경했다면(예: 필드를 추가하거나 삭제) 이전에 직렬화한 객체가 새 버전의 클래스와 호환되지 않을 수 있습니다. 이로 인해 역직렬화 시 오류가 발생할 수 있습니다. 자세한 내용은 다음 강의에서 다룰 예정입니다.

성능

Java의 바이너리 직렬화는 꽤 빠르지만 항상 가장 압축적이지는 않으며, 다른 프로그래밍 언어와의 교환에 늘 편리한 것은 아닙니다. 외부 시스템과의 교환에는 종종 텍스트 포맷(JSON, XML)이 사용됩니다.

4. 처음 직렬화를 접할 때 흔한 실수

오류 №1: 인터페이스 Serializable을 구현하지 않은 객체를 직렬화하려는 경우.
결과적으로 NotSerializableException이 발생합니다. 클래스에 implements Serializable을 명시하고, 모든 필드도 직렬화 가능하도록 관리하는 것을 잊지 마세요!

오류 №2: 직렬화할 수 없는 필드가 있는 객체를 직렬화함.
클래스에 직렬화를 지원하지 않는 타입의 필드(예: 스트림이나 데이터베이스 연결)가 있으면 직렬화가 동작하지 않습니다. 해결책은 해당 필드를 transient로 표시하는 것입니다(자세한 내용은 뒤에서 다룹니다).

오류 №3: 신뢰할 수 없는 소스의 데이터를 역직렬화함.
이는 보안 취약점으로 이어지거나 심지어 악성 코드 실행으로 이어질 수 있습니다. 여러분의 프로그램이 직렬화한 데이터만 신뢰하세요!

오류 №4: 직렬화 후 클래스 구조를 변경함.
객체를 저장해 둔 뒤 클래스에서 필드를 추가/삭제하면 역직렬화 시 오류가 발생하거나 “이상한” 값이 나타날 수 있습니다. 자세한 내용은 다음 강의에서 설명합니다.

1
과제
JAVA 25 SELF, 레벨 42, 레슨 0
잠금
문자열을 ZIP 아카이브로 압축하기
문자열을 ZIP 아카이브로 압축하기
1
과제
JAVA 25 SELF, 레벨 42, 레슨 0
잠금
ZIP 아카이브에서 파일 추출
ZIP 아카이브에서 파일 추출
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION