Java

[Java] 자바 직렬화에 대해서 (Serialization)

jngsngjn 2024. 10. 23. 22:50

1. 직렬화란? (Serialization)

직렬화란 자바의 객체바이트 스트림으로 변환하는 것을 말한다. 반대로 직렬화된 바이트 스트림을 다시 자바 객체로 변환하는 것은 역직렬화(Deserialization)라고 한다.

바이트 스트림이란? (Byte Stream)
- 데이터를 0과 1로 이루어진 이진 데이터(즉, 바이트 단위)로 처리하는 방식
- 컴퓨터에서 파일, 네트워크, 메모리 등에서 데이터를 읽고 쓰는 과정에서 사용되는 흐름
- 자바에서는 InputStream과 OutputStream 클래스를 통해 바이트 스트림을 처리함

 
주로 다음과 같은 상황에서 직렬화를 사용한다.
- 객체를 파일에 저장하여 나중에 재사용하고 싶을 때
- 네트워크를 통해 객체를 전송할 때
- 분산 시스템에서 객체를 원격으로 주고 받을 때 (예시 : RMI)

RMI란? (Remote Method Invation)
다른 실행 환경에 있는 객체의 메서드를 로컬에서 생성한 객체의 메서드와 다름 없이 호출할 수 있도록 하는 자바의 분산 객체 기술

 

(1) Serializable 인터페이스

객체를 직렬화하기 위해서 Serializable 인터페이스를 구현해야 한다. 이 인터페이스는 아무런 내용도 없는 마커 인터페이스이다. Serializable 인터페이스를 구현하는 것만으로 객체가 직렬화되는 이유는, 자바의 내장된 직렬화 메커니즘과 관련이 있다. 내부적으로 자바의 ObjectOutputStream ObjectInputStream 클래스가 직렬화 및 역직렬화 과정을 담당한다. 이 클래스들이 객체를 직렬화 또는 역직렬화 하기 전 Serializable 인터페이스 구현 여부를 확인하는 것이다. 만약 Serializable 인터페이스를 구현하지 않았는데 객체를 직렬화하려고 하면 런타임 예외가 발생한다.

(2) 직렬화 기본 개념

  • 클래스의 멤버 변수만 저장할 수 있으며 메서드는 저장되지 않는다.
  • 멤버 변수 중에서 민감한 정보는 transient 키워드를 붙여 제외시킬 수 있다. transient 키워드가 붙은 변수는 해당 변수 타입의 기본값으로 저장된다.
  • 직렬화할 클래스에 readObject(), writeObject() 메서드를 재정의함으로써 직렬화 방식을 커스텀할 수 있다.
  • Serializable 인터페이스를 구현한 상위 클래스를 상속받은 모든 하위 클래스들은 직렬화 할 수 있다.
  • Serializable 인터페이스를 구현하지 않은 상위 클래스를 상속받은 하위 클래스가 Serializable 인터페이스를 구현했다면, 하위 클래스가 직렬화 될 때 상위 클래스의 멤버 변수는 직렬화되지 않으며, 역직렬화 시 상위 클래스의 생성자가 호출되어 초기화된다. 이때 호출하는 상위 클래스의 생성자는 기본 생성자로, 기본 생성자가 존재하지 않으면 런타임 예외가 발생한다.

(3) SerialVersionUID

SerialVersionUID 직렬화된 객체의 버전을 식별하는 데 사용되는 고유한 ID이다. 자바 직렬화에서 클래스가 변경되었을 때 그 클래스와 이전에 직렬화된 객체 간의 호환성을 유지하기 위해 사용된다. 따로 명시하지 않으면 컴파일러가 자동으로 생성하며 클래스의 구조가 바뀔 때마다 이 값이 달라진다. 역직렬화 시 해당 객체가 직렬화 되었을 당시의 SerialVersionUID와 현재 클래스의 SerialVersionUID 값을 비교한다. 이 값이 일치하면 역직렬화가 진행되며 다르다면 런타임 예외가 발생한다. 따로 명시하지 않으면 클래스의 구조가 바뀔 때마다 이 값이 달라지기 때문에 대부분의 경우 클래스에 명시적으로 선언하는 것이 권장된다.

private static final long serialVersionUID = 1L;

2. 직렬화 예제 코드

Person.java - 직렬화할 클래스
참고로 String 클래스를 살펴보면 Serializable 인터페이스를 구현하고 있기 때문에 직렬화가 가능하다.

@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 1L;

    private String name;
    private int age;

    transient private String password;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Person(String name, int age, String password) {
        this.name = name;
        this.age = age;
        this.password = password;
    }
}

(1) Netty

MyServer

  • 객체를 자동으로 직렬화/역직렬화해주는 ObjectEncoder ObjectDecoder 클래스 사용
public class MyServer {

    private final int port;

    public MyServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel sc) {
                            ChannelPipeline pipeline = sc.pipeline();
                            pipeline.addLast(new ObjectEncoder());
                            pipeline.addLast(new ObjectDecoder(ClassResolvers.cacheDisabled(null)));
                            pipeline.addLast(new ServerHandler());
                        }
                    });
            ChannelFuture cf = bootstrap.bind(port).sync();
            cf.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

 
 
ServerHandler

  • 클라이언트로부터 받은 데이터(msg)가 Person 타입이거나 Person 타입의 하위 클래스일 경우 로그를 남기는 서버 핸들러
public class ServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof Person) {
            Person person = (Person) msg;
            System.out.println("[SERVER] person = " + person);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

 
 
MyClient

  • 생성자로 전달받은 host와 port를 가진 서버에 연결
  • 생성자로 전달받은 name과 age를 ClientHandler 생성자에 넣어 호출
public class MyClient {

    private final String host;
    private final int port;

    private final String name;
    private final int age;

    public MyClient(String host, int port, String name, int age) {
        this.host = host;
        this.port = port;
        this.name = name;
        this.age = age;
    }

    public void connect() throws InterruptedException {
        NioEventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel sc) {
                            ChannelPipeline pipeline = sc.pipeline();
                            pipeline.addLast(new ObjectEncoder());
                            pipeline.addLast(new ObjectDecoder(ClassResolvers.cacheDisabled(null)));
                            pipeline.addLast(new ClientHandler(name, age));
                        }
                    });
            ChannelFuture cf = bootstrap.connect(host, port).sync();
            cf.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }
}

 
 
ClientHandler

  • 전달받은 name과 age를 가진 Person 객체를 생성하여 서버에게 객체 전송
public class ClientHandler extends ChannelInboundHandlerAdapter {

    private final String name;
    private final int age;

    public ClientHandler(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        Person person = new Person(name, age);
        ctx.writeAndFlush(person);
        System.out.println("[CLIENT] 서버에게 객체 전송: " + person.getName());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

 
 
ServerApp

  • localhost:8080 서버 시작
public class ServerApp {
    public static void main(String[] args) throws InterruptedException {
        new MyServer(8080).start();
    }
}

 
 
ClientApp

  • 3개의 클라이언트 쓰레드가 서버와 연결
public class ClientApp {
    public static void main(String[] args) throws InterruptedException {
        Thread client1 = new Thread(() -> {
            try {
                new MyClient("localhost", 8080, "A", 10).connect();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        Thread client2 = new Thread(() -> {
            try {
                new MyClient("localhost", 8080, "B", 20).connect();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        Thread client3 = new Thread(() -> {
            try {
                new MyClient("localhost", 8080, "C", 30).connect();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        client1.start();
        client2.start();
        client3.start();

        client1.join();
        client2.join();
        client3.join();
    }
}

 
 
실행 결과

  • 객체를 TCP 네트워크 상에서 주고 받는 간단한 예제였다.

(2) File

FileExample.java

public class FileExample {
    public static void main(String[] args) {
        Person person = new Person("PersonA", 20, "1234");

        // 1. 객체를 파일에 직렬화하여 저장
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.ser"))) {
            oos.writeObject(person); // 객체 직렬화
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 2. 파일에서 객체를 역직렬화하여 읽어오기
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.ser"))) {
            Person deserializedPerson = (Person) ois.readObject();  // 객체 역직렬화
            System.out.println("deserializedPerson = " + deserializedPerson);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

 
 
실행 결과

    • 좌측 파일 목록에 person.ser 파일이 생성된 것을 확인할 수 있다.
    • person 객체 생성 시 비밀번호를 1234로 하여 생성했지만 역직렬화한 객체를 확인한 결과를 보니 null로 되어 있는 것을 확인할 수 있다. (transient 키워드 사용)

(3) Redis

RedisExample.java

  • Jedis 라이브러리 사용
public class RedisExample {
    public static void main(String[] args) {
        // Redis에 연결
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            // 1. 객체 생성
            Person person = new Person("Person_Redis", 30);

            // 2. 객체를 직렬화하여 바이트 배열로 변환
            byte[] serializedPerson = serialize(person);

            // 3. Redis에 직렬화된 객체 저장
            jedis.set("person".getBytes(), serializedPerson);

            // 4. Redis에서 바이트 배열로 객체 읽어오기
            byte[] personData = jedis.get("person".getBytes());

            // 5. 바이트 배열을 역직렬화하여 객체로 복원
            Person deserializedPerson = (Person) deserialize(personData);
            System.out.println("Redis에서 가져온 객체: " + deserializedPerson);
        }
    }

    // 객체를 직렬화하여 바이트 배열로 변환하는 메서드
    public static byte[] serialize(Object obj) {
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
             ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(obj);
            return bos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    // 바이트 배열을 객체로 역직렬화하는 메서드
    public static Object deserialize(byte[] data) {
        try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
             ObjectInputStream ois = new ObjectInputStream(bis)) {
            return ois.readObject();
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

 
 
실행 결과

  • 객체를 직렬화하여 캐시에 저장하고 다시 읽어와 역직렬화까지 잘되는 것을 확인할 수 있었다.

3. 직렬화 관련 주요 Exception

  • NotSerializableException : 객체를 직렬화 할 때, 그 클래스가 Serializable 인터페이스를 구현하지 않은 경우 발생
  • InvalidClassException
    • 직렬화된 객체를 역직렬화 할 때 클래스의 serialVersionUID가 맞지 않는 경우 발생
    • 멤버 변수의 타입이 달라졌을 때 발생. 예를 들어 int 타입 age의 Person 객체를 직렬화한 person.ser 파일이 이미 존재할 때, Person 클래스의 age 변수 타입을 long으로 변경하고 person.ser 파일을 역직렬화 할 때 해당 예외가 발생한다.
    • Serializable 인터페이스를 구현하지 않은 상위 클래스를 상속받은 하위 클래스가 Serializable 인터페이스를 구현한 경우, 역직렬화 시 상위 클래스의 생성자가 호출되어 초기화된다. 이때 호출하는 상위 클래스의 생성자는 기본 생성자로, 상위 클래스에 기본 생성자가 존재하지 않으면 해당 예외가 발생한다.

직렬화는 언뜻 보면 많은 이점이 있을 것 같다. 자바 객체를 바이트 스트림으로 변환하여 다른 곳에서 쉽게 주고받을 수 있다는 점은 큰 장점이다. 또한 대부분의 참조 변수, 예를 들어 컬렉션 같은 타입도 직렬화할 수 있다는 점은 유용하게 사용될 수 있다. 하지만, 직렬화에는 신중한 고민이 필요하다. 직렬화된 바이트 스트림은 데이터 용량이 상대적으로 크며, 한 번 직렬화된 객체 파일이 여러 곳에서 사용되기 시작하면, 해당 클래스의 구조를 쉽게 변경하기 어려워진다. 이로 인해 유지보수가 복잡해질 수 있다. 또한, 보안 측면에서 직렬화는 취약할 수 있다. 특히 역직렬화 시 악의적인 사용자가 데이터를 조작해 공격할 위험이 있다. 이 외에도 직렬화에는 많은 단점들이 존재한다. 그렇기 때문에 직렬화를 사용할 때는 그 이점과 단점을 잘 따져보고 신중하게 결정해야 한다.


참고 자료 : https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A7%81%EB%A0%AC%ED%99%94Serializable-%EC%99%84%EB%B2%BD-%EB%A7%88%EC%8A%A4%ED%84%B0%ED%95%98%EA%B8%B0#objectoutputstream_%EA%B0%9D%EC%B2%B4_%EC%A7%81%EB%A0%AC%ED%99%94