Search
Duplicate

Java - 직렬화(Serialization)

글감
Java
작성자
작성 일자
2024/02/08 06:43
상태
완료
공개여부
공개
Date
생성자
작업자

직렬화와 역직렬화

직렬화(Serialize)
직렬화란 자바 언어에서 사용되는 Object 또는 Data를 다른 컴퓨터의 자바 시스템에서도 사용할 수 있도록 바이트 스트림(stream of bytes) 형태로 연속적인(serial) 데이터로 변환하는 포맷 변환 기술을 일컫는다.
역직렬화(Deserialize)
역직렬화는 직렬화의 반대개념으로, 바이트 스트림으로 변환되어 있는 데이터를 원래대로 자바 시스템의 Object 또는 Data로 변환하는 기술이다.
바이트 스트림 스트림은 클라이언트나 서버 간에 출발지-목적지로 입출력하기 위한 데이터가 흐르는 통로를 말한다. 자바는 스트림의 기본 단위를 바이트로 두고 있기 때문에, 네트워크나 데이터베이스로 전송하기 위해 최소 단위인 바이트 스트림으로 변환하여 처리한다.

직렬화 사용법

Serializable 인터페이스
객체를 직렬화 할 때 Serializable 인터페이스를 implements 하지 않으면, NotSerialiableException이 발생한다.
Serializable 인터페이스는 아무 내용도 없는 마커 인터페이스로써, 직렬화를 고려하여 작성된 클래스인가를 판단하는 기준으로 사용된다.
ObjectOutputStream
객체를 직렬화해서 스트림으로 출력할 때 ObjectOutputStream을 사용한다.
객체는 직렬화 될 때 오직 객체의 인스턴스 필드값만 저장하고, static 필드나 메서드는 직렬화하여 저장하지 않는다.
public static void main(String[] args) { // 직렬화할 객체 Customer customer = new Customer(1, "홍길동", "123123", 40); // 저장할 파일명 String fileName = "Customer.txt"; // 파일 스트림 객체 생성(try with resource) try ( FileOutputStream fos = new FileOutputStream(fileName); ObjectOutputStream out = new ObjectOutputStream(fos); ) { // 객체를 직렬화하여 바이트 스트림으로 변환 후 파일에 저장 out.writeObject(customer); } catch (IOExcetpion e) { e.printStackTrace(); } }
Java
복사
ObjectInputStream
바이트 스트림을 역직렬화하여 객체로 가져올 때는 ObjectInputStream을 사용한다.
역직렬화할 때는 직렬화된 대상의 클래스가 외부 클래스라면, 클래스 경로(Class path)에 존재해야 하며 import된 상태여야 한다.
public static void main(String[] args) { // 외부 파일명 String fileName = "Customer.txt"; // 파일 스트림 객체 생성 (try with resource) try( FileInputStream fis = new FileInputStream(fileName); ObjectInputStream in = new ObjectInputStream(fis) ) { // 바이트 스트림을 다시 자바 객체로 변환 (이때 캐스팅이 필요) Customer deserializedCustomer = (Customer) in.readObject(); System.out.println(deserializedCustomer); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } }
Java
복사
리스트 직렬화하기
만약 여러 객체를 직렬화하여 하나의 파일에 저장한다면, 역직렬화 할 때 해당 객체의 순서를 지켜서 역직렬화해야한다.
직렬화 해야하는 객체가 많다면 ArrayList와 같은 리스트에 담아, 해당 리스트 하나만 역직렬화하면 순서를 고려할 필요없이 객체 전부가 직렬화된다.
public static void main(String[] args) throws IOException, ClassNotFoundException { // 직렬화할 고객 객체 Customer hongildong = new Customer(1, "홍길동", "123123", 40); Customer sejong = new Customer(2, "세종대왕", "4556456", 55); Customer jumong = new Customer(3, "주몽", "789789", 25); // 외부 파일명 String fileName = "Customer.ser"; // 리스트 생성 List<Customer> customerList = new ArrayList<>(); customerList.add(hongildong); customerList.add(sejong); customerList.add(jumong); // 리스트 자체를 직렬화 하기 FileOutputStream fos = new FileOutputStream(fileName); ObjectOutputStream out = new ObjectOutputStream(fos); out.writeObject(customerList); out.close(); // 역직렬화 하여 리스트 객체에 넣기 FileInputStream fis = new FileInputStream(fileName); ObjectInputStream in = new ObjectInputStream(fis); List<Customer> deserializedCustomerList = (List<Customer>) in.readObject(); in.close(); System.out.println(deserializedCustomerList); }
Java
복사
직렬화에서 제외하기
객체를 직렬화할 때 객체의 모든 인스턴스를 직렬화하기에는 너무 무겁거나 중요한 정보는 외부에 노출시키고 싶지않다면, transient 키워드를 통해 직렬화 대상에서 제외되도록 할 수 있다.
transient 키워드가 붙은 인스턴스 변수는 원시 타입은 디폴트 값으로, 참조 타입은 null로 초기화 된다.
class Customer implements Serializable { int id; String name; transient String password; // 직렬화 대상에서 제외 int age; public Customer(int id, String name, String password, int age) { this.id = id; this.name = name; this.password = password; this.age = age; } ... }
Java
복사
커스텀 직렬화
ObjectInputStream의 readObject()와 ObjectOutputStream의 writeObject()는 기본적으로 모든 요소에 대해 직렬화한다. readObject()와 writeObject()를 직렬화할 클래스에 별도로 재정의하면, 직렬화를 선택적으로 할 수 있고 이를 커스텀 직렬화라 부른다.
민감한 정보의 경우 transient를 통해 직렬화하지 않을 수 있지만, 커스텀 직렬화를 통해서도 writeObject에서 원하는 필드만 쓰기 동작을 하도록 재정의 해줄 수도 있다.
class Customer implements Serializable { int id; // 고객 아이디 String name; // 고객 닉네임 String password; // 고객 비밀번호 int age; // 고객 나이 public Customer(int id, String name, String password, int age) { this.id = id; this.name = name; this.password = password; this.age = age; } // 직렬화 동작 재정의 private void writeObject(ObjectOutputStream out) throws IOException{ out.writeInt(id); out.writeObject(name); out.writeInt(age); } // 역직렬화 동작 재정의 private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException{ this.id = in.readInt(); this.name = (String) in.readObject(); this.age = in.readInt(); } ... }
Java
복사
public static void main(String[] args) throws IOException, ClassNotFoundException { Customer user = new Customer(1, "홍길동", "123123", 40); String fileName = "Customer.ser"; // 직렬화 하기 (한줄로 표현) ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName))); out.writeObject(user); out.close(); // 역직렬화 하기 (한줄로 표현) ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(fileName))); Customer deserialized = (Customer) in.readObject(); in.close(); System.out.println(deserialized); }
Java
복사
상속 관계에서의 직렬화
부모-자식 상속 관계에서 부모 클래스가 Serializable을 구현했다면, 자식 클래스는 Serializable을 구현하지 않더라도 직렬화가 가능하다.
반대로 부모는 Serializable을 구현하지 않은 채로 자식 클래스만 Serializable을 구현하고 자식 클래스를 직렬화하면, 부모 클래스의 인스턴스 필드는 무시되고 자식 필드만 직렬화가 된다.
따라서 상위 클래스까지 직렬화하려면 부모 클래스에 Serializable을 구현하던지, writeObject / readObject를 재정의하여 직접 직렬화 코드를 추가해야 한다.
class UserAccount { String name; String password; // ! 기본 생성자 없으면 InvalidClassException : no valid constructor 발생 public UserAccount() { } UserAccount(String name, String password) { this.name = name; this.password = password; } }
Java
복사
class UserInfo extends UserAccount implements Serializable { int age; int height; boolean marreid; UserInfo(String name, String password, int age, int height, boolean marreid) { super(name, password); this.age = age; this.height = height; this.marreid = marreid; } private void writeObject(ObjectOutputStream out) throws IOException { // 부모 필드 직렬화 out.writeUTF(name); out.writeUTF(password); // 자신 필드 직렬화 (메서드를 통해 한번에 처리) out.defaultWriteObject(); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { // 부모 필드 역직렬화 name = in.readUTF(); password = in.readUTF(); // 자신 필드 역직렬화 (메서드를 통해 한번에 처리) in.defaultReadObject(); } ... }
Java
복사

직렬화 버전 관리

SerialVersionUID (SUID)
Serializable을 구현하는 모든 클래스는 serialVersionUID라는 고유 식별번호를 부여받아, 클래스의 직렬화와 역직렬화 과정에서 동일한 특성(버전)을 가지는지 확인한다.
직렬화 이후 클래스의 내부 구성이 수정되면, 기존의 SUID와 현재의 SUID가 다르기 때문에 역직렬화가 되지 않고 InvalidClassException이 발생한다.
SUID 값을 명시하는 것은 필수가 아니지만, 명시하지 않으면 시스템이 런타임 중에 암호 해시함수를 적용해 자동으로 클래스 내에 생성한다.
클래스 버전 수동 관리
직렬화를 통해 데이터를 전송하거나 받는 경우, 송신자와 수신자 모두 같은 버전의 클래스를 가지고 있어야 한다.
만약 클래스의 변경이 조금이라도 발생한다면, 모든 사용자에게 재배포해야하는 애로사항이 생겨 프로그램을 관리하기 어렵게 만든다.
이런 이유로 웬만한 상황에서 SUID를 직접 명시해주어 수동으로 클래스 버전을 관리하는 것이 권장된다.
또한 SUID를 생성하는 시간도 많이 잡아먹기 때문에 수동으로 관리하는 것이 좋다.
class Member implements Serializable { // serialVersionUID 꼭 명시 할 것 private static final long serialVersionUID = 123L; private String name; private int age; private String address; // private String email; // 새로 추가한 클래스 구성 요소 ... }
Java
복사
이와 같이 수동으로 SUID를 설정하면, 새로운 필드가 추가되어도 해당 값을 null로 초기화할 뿐 역직렬화 과정에서 오류가 발생하지 않는다.
다만 수동으로 관리하는 도중 기존에 있던 필드의 타입이 변경되는 경우에는 incompatible type으로 에러가 발생한다. 이와 같이 수동으로 버전을 관리하는 경우에 필드의 타입이 변경되는 경우에 새로운 버전으로 바꾸어야 한다.
필드 타입 변경만 조심하면 될 것 같지만, 사실 직렬화할 때 자주 변경될 소지가 있는 클래스는 직렬화를 하지 않는 것이 좋다. 추후 버전에서 이전 버전에 완벽히 호환되도록 수정하는 것은 매우 어렵기 때문이다.

직렬화 예외

InvalidClassException
클래스의 SerialVersionUID가 다른 경우
클래스에 다른 데이터 타입을 포함한 경우(필드의 데이터 타입이 변경된 경우)
기본 생성자가 없는 경우
상속 관계에서 부모가 Serializable을 구현하지 않은 경우, 자식 클래스를 직렬화할 때 직렬화되지 않은 부모의 속성 정보들을 기본 생성자에서 가져오게 된다. 이때 부모의 생성자가 없는 경우 InvalidClassException이 발생한다.
NotSerializableException
Serializable을 구현하지 않은 클래스를 직렬화 하려하는 경우
직렬화할 클래스 내에 Serializable을 구현하지 않은 클래스를 멤버 변수로 가지고 있는 경우

직렬화 장점

자바 직렬화는 외부 파일이나 네트워크를 통해 객체 데이터를 주고 받을 때 사용된다.
이런 데이터를 주고 받는 포맷에는 웹(Web) 뿐만 아니라 게임에서도 설정 파일로 사용되는 등 범용적으로 사용되는 JSON이 있다. 직렬화는 오직 자바에서만 사용가능하지만, JSON의 경우에는 객체 데이터를 저장해두면 파이썬이나 자바스크립트에서도 사용이 가능하다.
이렇게 이미 범용적으로 사용되는 JSON이라는 데이터 포맷이 있는데 굳이 직렬화를 사용하는 이유는 직렬화의 여러 장점들 때문이다.
직렬화는 자바의 고유 기술인만큼 자바 시스템 개발에 최적화 되어있다.
자바의 무수히 많은 레퍼런스 타입에 대해 제약 없이 외부에 내보낼 수 있다.
이런 클래스와 인터페이스 타입들은 단순 파일 포맷으로 외부에 내보내려면 각 데이터를 매칭 시키는 별도의 파싱 작업이 필요하다.
직렬화는 다른 시스템에서는 사용하지 못하더라도, 별다른 파싱 작업 없이 다른 자바 시스템에서 역직렬화하여 바로 사용할 수 있다.
이런 장점들이 있지만, 직렬화의 단점들도 있으며 최근에는 범용적인 JSON을 이용하는 추세가 점점 늘고 있다.

직렬화 문제점

직렬화의 단점들
Serializable을 implements만 하면 직렬화하여 외부로 데이터를 간단하게 보낼 수 있지만, 사실 직렬화는 장점보다 단점이 더 많고 극명한 편이다.
직렬화된 객체는 용량을 많이 차지한다.
같은 정보를 직렬화로 저장하면 JSON으로 저장하는 것보다 거의 2배 이상 차이가 난다.
때문에 DB나 Cache 등 외부에 데이터를 저장하는 경우, 장기간 저장할 데이터는 직렬화를 지양해야 한다.
버그와 보안에 취약하다.
역직렬화는 생성자 없이 인스턴스화가 가능하기 때문에, 위험한 값을 대입하거나 불변식을 깨는 공격이 가능하다.
악성 코드를 역직렬화 요청하여 데이터 변조나 원격으로 코드를 실행시킬 수 있다.
가젯(gadget) 역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드
릴리즈 이후 수정이 어렵다.
클래스에 직렬화를 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 되어, 이후 해당 직렬화의 형태를 바꾸기가 어려워져 유지보수가 직렬화에 묶이게 된다.
클래스의 캡슐화가 깨진다.
직렬화를 하게되면 내부 변수를 private로 선언하더라도 외부에 그대로 노출된다.
테스트 요소가 많아진다.
직렬화 가능한 클래스가 업데이트 되면, 구버전의 직렬화 데이터가 신버전에서 역직렬화가 되는지와 그 반대의 경우 모두 테스트해봐야 한다.
직렬화는 신중히 고려하기
직렬화를 구현할 때 설계에 따른 이득과 비용을 잘 고려해야 한다.
자바에서는 BigInteger나 String과 같은 값 클래스나 컬렉션 클래스는 직렬화가 구현되어있고, 스레드 풀처럼 동작하는 객체 클래스는 대부분 구현되어있지 않다.
상속용 클래스와 인터페이스에 직렬화를 구현하려면 주의해서 사용해야 한다.
직렬화 가능한 클래스를 상속하는 것은 위에서 언급한 직렬화의 문제점들을 하위 클래스에 그대로 전이하는 것이나 다름없다.
만약 직렬화 클래스를 상속해야하고 불변식을 보장해야하는 인스턴스 필드가 있다면, finalize에 final을 붙여 재정의해서 하위 클래스에서 재정의하지 못하게 해야 finalizer 공격을 막을 수 있다.
내부 클래스는 직렬화를 구현하면 안된다.
내부 클래스의 직렬화 형태는 불분명하므로, 정적 내부 클래스(static inner class)가 아니라면 직렬화를 구현해서는 안된다.

역직렬화 방어 기법

커스텀 직렬화
readObject 메서드를 클래스에 재정의하여 직렬화 대신 readObject 메서드를 대신 실행시키는 커스텀 직렬화를 통해 방어적으로 사용할 수 있다.
readObject 내에 메서드 유효성 검사 로직을 추가하여, 역직렬화 시에 유효한 인스턴스를 만들어 낼 수 있다.
하지만 이 방법은 유효성 검사 로직을 하드 코딩해야하기 때문에, 실수할 여지가 있고 유지보수 측면에서 그다지 좋지 않다.
역직렬화 필터링
역직렬화 필터링은 데이터 스트림이 역직렬화 되기 전에, 필터링을 수행하여 클래스의 역직렬화를 허용하거나 허용하지 않도록 결정하는 방법이다.
함수형 인터페이스 ObjectInputFilter를 통해 역직렬화 되는 클래스의 클래스명을 비교해, ObjectInputFilter.Status.ALLOWED와 ObjectInputFilter.Status.REJECTED를 반환하는 일급 함수를 만든다.
// 1. 역직렬화 필터 만들기 ObjectInputFilter filter = (filterInfo) -> { Class<?> classObj = filterInfo.serialClass(); // 화이트 리스트 if (classObj.getName().equals("SuccessDeserializer")) { return ObjectInputFilter.Status.ALLOWED; // SuccessDeserializer 클래스일 경우 허용 } System.out.println("Rejected :" + classObj.getSimpleName()); return ObjectInputFilter.Status.REJECTED; // 그 이외에는 거절 }; // 2. 역직렬화 필터 등록 in.setObjectInputFilter(filter); // 3. 필터 적용된 채로 역직렬화 Object obj1 = in.readObject(); Object obj2 = in.readObject();
Java
복사
역직렬화를 모두 허용하되 일부 클래스를 거부하는 블랙리스트 방식과, 위와 같이 모두 거부하되 일부 클래스들만 허용하는 화이트리스트 방식이 있다.
직렬화 프록시 패턴
직렬화 프록시 패턴은 직렬화 시에 원본이 아닌 프록시 객체를 직렬화하고, 역직렬화 시 프록시 객체를 받아 원본 객체를 반환하는 기법이다.
class Member implements Serializable { private static final long serialVersionUID = 1L; private final String name; private final int age; public Member(String name, int age) { this.name = name; checkPositive(); this.age = age; } private void checkPositive() { if (this.age < 0) { throw new RuntimeException(new InvalidObjectException("age 값이 옳지 않습니다.")); } } // 직렬화 프록시 (정적 내부 클래스) private static class MemberProxy implements Serializable { private static final long serialVersionUID = 2L; private final String name; private final int age; // 생성자는 단 하나여야 하고, 바깥 클래스의 인스턴스를 매개변수로 받고 데이터를 복사 public MemberProxy(Member m) { this.name = m.name; this.age = m.age; } // 객체를 역직렬화 할때 호출되어, 역직렬화 결과를 readResolve 반환값으로 설정 private Object readResolve() { return new Member(name, age); // 역직렬화되면 최종적으로 Member 객체를 반환 } } // 객체를 직렬화 할때 호출되어, 직렬화 대상을 writeReplace를 통해 프록시를 반환하도록 제어 private Object writeReplace() { return new MemberProxy(this); // 프록시가 대신 직렬화 } // 대상 객체(Member)을 역직렬화 하지 못하게 막는다. // 애초에 프록시 객체로 직렬화하고 역직렬화하기 때문에 대상 객체가 역직렬화 될일이 없기 때문이다 private void readObject(ObjectInputStream ois) throws InvalidObjectException { throw new InvalidObjectException("프록시가 필요해요."); } }
Java
복사
내부에 중첩 클래스로 프록시 객체를 두고, writeReplace 메서드를 정의하여 직렬화 시 프록시 객체 생성자를 호출해 프록시 객체를 반환한다.
프록시 클래스에 readObject 메서드 정의하여, 역직렬화 시에 원본 객체의 생성자를 호출해 원본 객체를 반환한다.
또한 원본 클래스에 readObject 메서드를 정의하여, 원본 객체로 역직렬화 시도 시 에러를 던지도록 구현한다.
직렬화 프록시 패턴 장점
가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 차단할 수 있다.
필드들을 final로 선언하여 진정한 불변 객체를 만들 수 있다.
역직렬화때 readObject 재정의로 일일히 유효성 검사를 하지 않아도 된다.
직렬화 프록시 패턴 단점
직렬화와 역직렬화가 느려진다.
클라이언트가 마음대로 확장할 수 있는 클래스에는 적용할 수 없다.
클래스의 필드 객체가 서로를 참조하고 있는 상황(객체 그래프에 순환이 있는 클래스)에 적용할 수 없다.

직렬화 사용처

직렬화를 사용하면 캐싱 데이터를 영구 저장할 필요가 있을 때 사용할 수도 있다. JVM의 메모리에만 상주되어있는 객체 데이터가 시스템이 종료되더라도 다시 재사용될 수 있도록 영속화(Persistence)를 해두면 좋다. 이때 직렬화가 사용된다.
서블릿 세션(Servlet Session)
단순히 세션을 서블릿 메모리 위에서 운용한다면 직렬화가 필요없지만, 만약 세션 데이터를 저장하거나 공유가 필요한 경우 직렬화를 이용한다.
톰캣의 세션을 세션 클러스터링으로 서버 간에 데이터를 공유해야 할 때 사용된다.
캐시(Cache)
데이터베이스로부터 조회한 객체 데이터를 다른 모듈에서도 필요로 할 때, 객체를 직렬화하여 메모리나 외부 파일에 저장해두었다가 DB를 다시 조회하지 않고 캐시 데이터로 역직렬화하여 사용한다.
직렬화를 통해서만 캐시를 저장할 수 있는 것은 아니지만, 자바 시스템에서는 직렬화가 구현이 가장 간편하기 때문에 많이 사용된다.
최근에는 Redis나 Memcached와 같은 캐시 DB를 많이 사용하는 편이다.
자바 RMI(Remote Method Invocation)
자바 RMI는 원격 시스템 간의 메세지 교환을 위해 사용되는 기술로, 객체 데이터를 직렬화하여 송신한다.
최근에는 소켓을 사용하기 때문에 사용되지 않는다.