직렬화와 역직렬화
•
직렬화(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는 원격 시스템 간의 메세지 교환을 위해 사용되는 기술로, 객체 데이터를 직렬화하여 송신한다.
◦
최근에는 소켓을 사용하기 때문에 사용되지 않는다.