-
[Java] 자바 직렬화에 대해서 (Serialization)Java 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 인터페이스를 구현한 경우, 역직렬화 시 상위 클래스의 생성자가 호출되어 초기화된다. 이때 호출하는 상위 클래스의 생성자는 기본 생성자로, 상위 클래스에 기본 생성자가 존재하지 않으면 해당 예외가 발생한다.
직렬화는 언뜻 보면 많은 이점이 있을 것 같다. 자바 객체를 바이트 스트림으로 변환하여 다른 곳에서 쉽게 주고받을 수 있다는 점은 큰 장점이다. 또한 대부분의 참조 변수, 예를 들어 컬렉션 같은 타입도 직렬화할 수 있다는 점은 유용하게 사용될 수 있다. 하지만, 직렬화에는 신중한 고민이 필요하다. 직렬화된 바이트 스트림은 데이터 용량이 상대적으로 크며, 한 번 직렬화된 객체 파일이 여러 곳에서 사용되기 시작하면, 해당 클래스의 구조를 쉽게 변경하기 어려워진다. 이로 인해 유지보수가 복잡해질 수 있다. 또한, 보안 측면에서 직렬화는 취약할 수 있다. 특히 역직렬화 시 악의적인 사용자가 데이터를 조작해 공격할 위험이 있다. 이 외에도 직렬화에는 많은 단점들이 존재한다. 그렇기 때문에 직렬화를 사용할 때는 그 이점과 단점을 잘 따져보고 신중하게 결정해야 한다.
'Java' 카테고리의 다른 글
[Java] JVM의 메모리 영역 (JVM Memory Structure) (0) 2024.10.29 [Java] Comparable과 Comparator (0) 2024.10.25 [Java] parseInt() 메서드 직접 구현하기 (0) 2024.09.04 [Java] Immutable 클래스와 String 클래스 (0) 2024.07.08 [Java] 오버로딩의 제약 조건 (1) 2024.06.30