...
자바의 직렬화 & 역직렬화
직렬화(serialize)란 자바 언어에서 사용되는 Object 또는 Data를 다른 컴퓨터의 자바 시스템에서도 사용 할수 있도록 바이트 스트림(stream of bytes) 형태로 연속전인(serial) 데이터로 변환하는 포맷 변환 기술을 일컫는다. 그 반대 개념인 역직렬화는(Deserialize)는 바이트로 변환된 데이터를 원래대로 자바 시스템의 Object 또는 Data로 변환하는 기술이다.
이를 시스템적으로 살펴보면, JVM의 힙(heap) 혹은 스택(stack) 메모리에 상주하고 있는 객체 데이터를 직렬화를 통해 바이트 형태로 변환하여 데이터베이스나 파일과 같은 외부 저장소에 저장해두고, 다른 컴퓨터에서 이 파일을 가져와 역질렬화를 통해 자바 객체로 변환해서 JVM 메모리에 적재하는 것으로 보면 된다.
[ 바이트 스트림 이란? ]
스트림은 클라이언트나 서버 간에 출발지 목적지로 입출력하기 위한 데이터가 흐르는 통로를 말한다.
자바는 스트림의 기본 단위를 바이트로 두고 있기 때문에, 네트워크, 데이터베이스로 전송하기 위해 최소 단위인 바이트 스트림으로 변환하여 처리한다.
직렬화 사용처
직렬화를 응용한다면 휘발성이 있는 캐싱 데이터를 영구 저장이 필요할 때 사용할 수도 있다.
예를들어 JVM의 메모리에서만 상주되어있는 객체 데이터가 시스템이 종료되더라도 나중에 다시 재사용이 될수 있을때 영속화(Persistence)를 해두면 좋다. 이러한 특성을 살린 자바 직렬화는 실제로도 여러곳에 응용된다. 몇가지 살펴보면 다음과 같다.
서블릿 세션 (Servlet Session)
- 단순히 세션을 서블릿 메모리 위에서 운용한다면 직렬화를 필요로 하지 않지만, 만일 세션 데이터를 저장 & 공유가 필요할때 직렬화를 이용한다.
- 세션 데이터를 데이터베이스에 저장할때
- 톰캣의 세션 클러스터링을 통해 각 서버간에 데이터 공유가 필요할때
캐시 (Cache)
- 데이터베이스로부터 조회한 객체 데이터를 다른 모듈에서도 필요할때 재차 DB를 조회하는 것이 아닌, 객체를 직렬화하여 메모리나 외부 파일에 저장해 두었다가 역직렬화하여 사용하는 캐시 데이터로서 이용이 가능하다.
- 물론 자바 직렬화를 이용해서만 캐시를 저장할 수 있는 것은 아니지만 자바 시스템에서 만큼은 구현이 가장 간편하기 때문에 많이 사용된다고 보면 된다.
- 단, 요즘은 Redis, Memcached 와 같은 캐시 DB를 많이 사용하는 편이다.
자바 RMI (Remote Method Invocation)
- 자바 RMI는 원격 시스템 간의 메시지 교환을 위해서 사용하는 자바에서 지원하는 기술이다.
- 이 메세지에 객체 데이터를 직렬화하여 송신하는 것이다.
- 최근에는 소켓을 이용하기 때문에 안쓰이는 기술이다.
직렬화 vs JSON 비교
이처럼 자바 직렬화는 외부 파일이나 네트워크를 통해 클라이언트 간에 객체 데이터를 주고 받을 때 사용된다.
그런데 이미 개발을 조금 해보신 독자분들이라면 문득 이런 생각이 들 것이다. CSV, JSON 이라는 훌륭한 데이터 포맷이 있는데 굳이 직렬화가 필요하느냐라는 의문점이다.
실제로 JSON은 웹(Web) 뿐만 아니라 게임 쪽에서도 설정 파일로 쓰이거나 데이터를 교환할때 범용적으로 사용된다. 그리고 직렬화는 오로지 자바 프로그램에서만 사용이 가능하지만, JSON 형태로 객체 데이터를 저장해두면 파이썬, 자바스크립트에서도 범용적으로 사용이 가능하다.
그렇다면 그냥 JSON을 이용하면 되지 굳이 직렬화를 공부해서 사용해야 하는 이유가 뭐가 있을까?
이에 대해서 먼저 자바 직렬화의 장점에 대해 알아보자면 다음과 같다.
자바 직렬화 장점
첫번째는, 직렬화는 자바의 고유 기술인 만큼 당연히 자바 시스템에서 개발에 최적화되어 있다.
두번째는, 자바의 광활한 레퍼런스 타입에 대해 제약 없이 외부에 내보낼 수 있다는 것이다.
예를들어 기본형(int, double, string) 타입이나 배열(array)과 같은 타입들은 왠만한 프로그래밍 언어가 공통적으로 사용하는 타입이기 때문에, 이러한 값들을 JSON 으로도 충분히 상호 이용이 가능하다.
하지만 자바의 온갖 컬렉션이나 클래스, 인터페이스 타입들은 어떨까? 혹은 사용자가 커스텀으로 자료형 타입을 만들어 사용하고 있는 경우, 단순 파일 포맷만으로는 타입 갯수가 한계가 있다. 그래서 이들을 외부에 내보내기 위해선 각 데이터를 매칭시키는 별도의 파싱(parsing)이 필요하다.
그에 반해, 직렬화를 이용하면 비록 파이썬이나 자바스크립트와 같은 다른 시스템에서는 사용하지는 못할지라도, 직렬화 기본 조건만 지킨다면 하드한 작업없이 그냥 바로 외부에 보낼수가 있다. 그리고 역직렬화를 통해 읽어들이면 데이터 타입이 자동으로 맞춰지기 때문에 자바 클래스의 기능들을 곧바로 다시 이용할 수 있는 것이다. 별다른 파싱 없이 말이다. 그래서 직렬화된 문자열을 데이터베이스에 저장해두고 꺼내 쓰기도 한다.
이러한 직렬화의 고유한 장점들이 있지만, 반면에 치명적인 단점들도 존재한다. (이는 포스팅 마지막에서 다뤄본다)
그리고 요즘은 범용적인 JSON을 이용하는 추세가 점점 늘고 있다. 따라서 JSON 이냐 직렬화 이냐 에 대한 명확한 정답은 없고 '목적에 따라 적절히 써야 한다' 정도로 정리할 수 있을 것 같다.
자바 직렬화 사용법
객체 직렬화 & 역직렬화 하기
Serializable 인터페이스
우선 객체를 직렬화하기 위해선 java.io.Serializable 인터페이스를 implements 해야 된다. 그렇지 않으면 NotSerializableException 런타임 예외가 발생된다.
Serializable 인터페이스는 아무런 내용도 없는 마커 인터페이스 로서, 직렬화를 고려하여 작성한 클래스인지를 판단하는 기준으로 사용된다.
import java.io.Serializable;
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;
}
@Override
public String toString() {
return "Customer{" +
"id=" + id +
", password='" + password + '\'' +
", name='" + name + '\'' +
", age=" + age +
'}';
}
}
ObjectOutputStream 객체 직렬화
직렬화(스트림에 객체를 출력) 에는 ObjectOutputStream을 사용한다.
객체가 직렬화될때 오직 객체의 인스턴스 필드값 만을 저장한다. static 필드나 메서드는 직렬화하여 저장하지 않는다.
아래 코드는 외부 파일에 객체를 직렬화하여 저장하는 예제이다.
public static void main(String[] args) {
// 직렬화할 고객 객체
Customer customer = new Customer(1, "홍길동", "123123", 40);
// 외부 파일명
String fileName = "Customer.ser";
// 파일 스트림 객체 생성 (try with resource)
try (
FileOutputStream fos = new FileOutputStream(fileName);
ObjectOutputStream out = new ObjectOutputStream(fos)
) {
// 직렬화 가능 객체를 바이트 스트림으로 변환하고 파일에 저장
out.writeObject(customer);
} catch (IOException e) {
e.printStackTrace();
}
}
코드를 실행하면 Customer.ser 파일이 생성된다. 파일을 .ser 확장명을 사용했는데, .txt 로 해도 문제 없다. 다만 직렬화된 파일이라는 것을 명시하는 것이 좋기 때문에 네이밍을 .ser 이나 .obj 로 많이 지정하는 편이다.
그리고 파일 내용을 보면 사람이 읽을 수 없는 문자 형태로 되어있는 걸 볼 수 있다.
ObjectInputStream 객체 역직렬화
역직렬화(스트림으로부터 객체를 입력)에는 ObjectInputStream을 사용한다.
단, 역직렬화 할때 주의사항이 있는데, 직렬화 대상이 된 객체의 클래스가 외부 클래스라면, 클래스 경로(Class Path)에 존재해야 하며 import 된 상태여야 한다.
아래 코드는 외부 파일을 읽어 역직렬화하여 다시 자바 객체화 하는 예제이다.
public static void main(String[] args) {
// 외부 파일명
String fileName = "Customer.ser";
// 파일 스트림 객체 생성 (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();
}
}
이렇게 역직렬화를 이용하게되면, 직렬화된 외부 파일만 있으면 생성자로 객체 초기화 없이 바로 객체에 정보를 가져와 인스턴스화 하여 사용할 수 있게 되는 마법 같은 기능을 얻을 수 있게 된다.
직렬화 개체를 리스트로 관리
만약 여러개의 객체를 직렬화하고 이를 역직렬화 한다면 주의해야할 사항이 있다.
역직렬화 할 때는 직렬화할 때의 순서와 일치 해야 된다는 점인데, 예를들어 객체 customer1, customer2, customer3 순서로 직렬화 했다면, 역직렬화 할 때도 customer1, customer2, customer3 의 순서로 받아야 된다. (파일에 직렬화 순서대로 바이트 문자가 기재되어 지니 당연한 소리이긴 하다)
따라서 직렬화할 객체가 많다면 ArrayList와 같은 컬렉션에 저장해서 관리 하는것이 좋다. ArrayList 하나만 역직렬화하면 되므로 객체의 순서를 고려할 필요가 없어지기 때문이다. 이는 위의 '직렬화와 JSON 비교 파트'에서 직렬화의 장점에 대해 소개할때 자바 고유의 클래스, 인터페이스를 고대로 직렬화 / 역직렬화할 수 있다고 말한 바 있다.
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);
}
직렬화 요소 제외
객체의 모든 인스턴스를 직렬화하기에는 너무 무겁거나 혹은 중요한 정보는 외부에 노출시키고 싶지 않은 경우, 직렬화할 요소를 직접 선택할 필요가 있다.
transient 키워드
간단하게 변수 정의문 옆에 transient 키워드를 명시해주면 알아서 직렬화 대상에서 제외 되도록 할 수 있다. transient가 붙은 인스턴스 변수의 값은 그 타입의 기본값으로 직렬화 된다.
- Primitive 타입 : 각 타입의 디폴트 값 (int는 0)
- Reference 타입 : 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;
}
...
}
커스텀 직렬화
readObject / writeObject 재정의
직렬화 & 역직렬화할때 호출되는 readObject() 와 writeObject() 는 기본적으로 모든 요소에 대해 자동 직렬화 한다. 그런데 이 메서드들을 직렬화할 클래스에 별도로 재정의 해주면 직렬화를 선택적으로 조작할 수 있게 된다. 이를 커스텀 직렬화 라고도 불리운다.
예를들어 Customer 클래스의 비밀번호 필드값은 민감한 정보라 직렬화하지 않겠다하면, writeObject 메서드에서 password 필드 부분만 빼고 쓰기 동작을 하도록 재정의 해줄 수 있다. 이외에도 상세한 조건문이나 로직을 가미해서 직렬화를 커스텀 조작 할 수 있다.
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();
}
@Override
public String toString() {
return "Customer{" +
"id=" + id +
", password='" + password + '\'' +
", name='" + name + '\'' +
", age=" + age +
'}';
}
}
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);
}
객체 상속 관계에서의 직렬화
만약 부모-자식 상속 관계에서 부모 클래스가 Serializable을 구현했다면 자식 클래스는 Serializable을 구현하지 않아도 직렬화가 가능하다. 그러면 반대로 부모 클래스는 Serializable을 구현하지 않고 자식 클래스만 구현했다면 어떤 방식으로 직렬화될까?
직렬화할때 부모 클래스의 인스턴스 필드는 무시되고 자식 필드만 직렬화가 된다. 따라서 상위 클래스의 필드까지 직렬화하려면 부모 클래스가 Serializable을 구현하도록 설정하던지, 위에서 다뤄본 writeObject / readObject 메서드를 재정의하여 직접 직렬화 코드를 추가 하면 된다.
예를 들어 UserInfo 클래스가 UserAccount를 상속하는데, UserInfo 클래스에만 Serializable이 구현되어있다고 하자. UserAccount 부모 클래스의 필드까지 직렬화하고 싶다면 UserAccount에 직렬화 메서드를 재정의 해주고 추가로 직렬화할 부분을 기재하면 된다.
class UserAccount {
String name;
String password;
// ! 기본 생성자 없으면 InvalidClassException : no valid constructor 발생
public UserAccount() {
}
UserAccount(String name, String password) {
this.name = name;
this.password = password;
}
}
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();
}
@Override
public String toString() {
return "UserInfo{" +
"age=" + age +
", height=" + height +
", marreid=" + marreid +
", name='" + name + '\'' +
", password='" + password + '\'' +
'}';
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
UserInfo u1 = new UserInfo("홍길동", "123123", 33, 170, true);
UserInfo u2 = new UserInfo("임꺽정", "456456", 12, 180, false);
String fileName = "UserInfo.ser";
// 직렬화
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName)));
out.writeObject(u1);
out.writeObject(u2);
out.close();
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(fileName)));
// 객체를 읽을때는 출력한 순서와 일치
UserInfo u3 = (UserInfo) in.readObject();
UserInfo u4 = (UserInfo) in.readObject();
System.out.println(u3);
System.out.println(u4);
in.close();
}
자바 직렬화 버전 관리
SerialVersionUID
Serializable 인터페이스를 구현하는 모든 직렬화된 클래스는 serialVersionUID(이하 SUID) 이라는 고유 식별번호를 부여 받는다. 이 식별 ID는 클래스를 직렬화, 역직렬화 과정에서 동일한 특성을 갖는지 확인하는데 사용된다. 그래서 클래스 내부 구성이 수정될 경우, 기존에 직렬화한 SUID와 현재 클래스의 SUID 버전이 다르기 때문에 이를 인지하고 InvalidClassException 예외가 발생시켜 값 불일치 되는 현상을 미연에 방지한다.
단, 직렬화 스펙 상 serialVersionUID 값 명시는 필수가 아니며, 만일 클래스에 SUID 필드를 명시하지 않는다면, 시스템이 런타임에 클래스의 이름, 생성자 등과 같이 클래스의 구조를 이용해 암호 해시함수를 적용해 자동으로 클래스 안에 생성하게 된다.
예를들어 다음 Member 클래스를 직렬화시켜 Member.ser 파일로 저장하고 서비스에서 이를 가져와 역직렬화하여 사용한다고 가정해보자.
class Member implements Serializable {
private String name;
private int age;
private String address;
...
}
그런데 Member 클래스에 이메일 필드를 추가해야 한다는 명세서가 왔다. 그래서 Member 클래스에 email 인스턴스 필드를 추가하였고 평소처럼 프로그램을 실행시켰다.
class Member implements Serializable {
private String name;
private int age;
private String address;
private String email; // 새로 추가한 클래스 구성 요소
...
}
그러나 결과는 위와 같은 시뻘건 에러가 우리를 반겨줄 것이다.
앞선 예제에서도 직렬화 클래스를 선언할 때 SUID 값을 생략했지만 내부적으로 식별번호가 생성되어 있어 나중에 클래스를 수정하게 된다면 SUID 값도 변하게 되어 역직렬화시 오류가 생기는 것이다.
클래스 버전 수동 관리
만일 네트워크로 객체를 직렬화하여 전송하거나 협업을 하는 경우 수신자와 송신자 모두 같은 버전의 클래스를 가지고 있어야 할텐데, 만일 클래스가 조금만 변경사항이 있으면 모든 사용자에게 재배포해야 하는 애로사항이 생겨 프로그램을 관리하기 어렵게 만든다.
따라서 직렬화 클래스는 왠만한 상황에선 serialVersionUID 를 직접 명시해주어 클래스 버전을 수동으로 관리하는 것을 권장하는 편이다. SUID를 직접 명시해주면 클래스의 내용이 변경되어도, 클래스의 버전이 시스템이 자동 생성된 값으로 변경되지 않기 때문이다. 이외에도 런타임에 SUID를 생성하는 시간도 많이 잡아먹기 때문에 미리 명시를 강력히 권장되는 바이다.
serialVersionUID 는 아래와 같이 private static final 제어자로 선언해야 하며 타입은 long 이다.
class Member implements Serializable {
// serialVersionUID 꼭 명시 할 것
private static final long serialVersionUID = 123L;
private String name;
private int age;
private String address;
// private String email; // 새로 추가한 클래스 구성 요소
...
}
이제 SUID를 선언한 Member 클래스를 직렬화하여 외부 파일로 추출하고, email 필드를 클래스에 새로 추가하고 역직렬화 해보자. 그러면 email 필드는 알아서 제외(null로 초기화)되고 역직렬화됨을 볼 수 있다.
이렇게 클래스 내에 serialVersionUID를 정의해주면, 클래스의 내용이 바뀌어도 클래스의 버전이 유지됨으로, 비록 필드가 매칭되지 않더라도 일단은 역직렬화 동작 자체는 행하도록 할 수 있다.
SerialVersionUID 자동 생성하기
serialVersionUID는 정수값이라 어떠한 값으로도 지정할 수 있지만, 단순한 값이면 겹칠 우려가 있기 때문에 서로 다른 클래스간에 같은 값을 갖지 않도록 serialversion 값을 생성해주는 프로그램을 사용하는 것이 좋다.
JVM을 설치할때 같이 설치되는 serialver.exe를 사용해서 생성된 값을 이용할 수 있지만 번거로우므로, IntelliJ를 이용해 간단한 설정만으로 클릭 한번 만으로 SUID 값을 생성해 줄 수 있는 방법을 소개해본다.
SerialVersionUID 수동 관리 유의사항
클래스 serialVersionUID를 명시하더라도 절대 만능이 아니다. 위와 같이 단순히 필드 변수 하나 추가하는 정도는 문제가 없겠지만 필드 타입을 변경하는 상황에서는 버전 수동 관리를 하여도 예외를 막을순 없다.
예를들어 Member 클래스의 age를 int 형 에서 long 형으로 업데이트하고 역직렬화를 해보면 아래와 같이 incompatible type 에러가 발생하게 된다.
자바에서 직렬화를 사용할 때 예외가 발생하거나 주의 해야 하는 상황을 정리해보면 다음과 같다.
- 멤버 변수를 추가할 때 (영향 없음 - 기본값으로 설정)
- 멤버 변수가 삭제될 때 (영향 없음)
- 멤버 변수의 이름이 바뀔 때 (영향 없음 - 값이 할당되지 않음)
- 멤버 변수의 접근 제어자 변경 (영향 없음)
- 멤버 변수의 타입이 바뀔 때 (영향 있음)
- 멤버 변수에 static 와 transient 추가 (영향 없음)
위의 주의사항을 보면 필드 타입 변경만 조심하면 될것 같지만, 사실 직렬화를 사용할 때는 자주 변경될 소지가 있는 클래스의 객체는 그냥 직렬화를 사용하지 않는 것이 좋다. 추후 버전에서 이전 버전에 영향없이 소스 코드를 수정하는 것은 매우 어렵기 때문이다.
자바 직렬화 예외
InvalidClassException
InvalidClassException 예외는 직렬화를 이용하다보면 흔히 접할 수 있는 오류이다. 이 예외는 아래 3가지 주요 원인으로 인해 Serialized 또는 Deserialized 할 수 없다는 것을 의미한다.
- 클래스의 SerialVersionUID 버전이 다른 경우
- 클래스에 다른 데이터 타입을 포함한 경우
- 기본 생성자가 없는 경우
이중 1번과 2번은 이미 위에서 다룬바 있다. 그래서 이번에는 3번의 디폴트 생성자가 없는 경우를 살펴보자.
no valid constructor
InvalidClassException 중 생성자가 없다는 예외는 클래스 상속 관계에서의 직렬화를 행하였을때 발생한다.
클래스 상속 관계 구조에서의 직렬화는 자식 클래스부터 직렬화한 뒤 부모 클래스로 이동해서 직렬화 하게 된다. 그리고 객체를 역직렬화할때 반대로 부모 클래스 부터 시작해서 상속 구조를 따라 내려가게 된다.
그런데 만일 부모 클래스가 non-serializable(직렬화를 구현하지 않은) 한다면 역직렬화하는 과정에서 직렬화되지 않은 부모의 속성 정보들을 기본 생성자를 통해서 가져오게 된다. 그런데 만일 기본 생성자가 없다면 불러올 유효한 생성자(vaild constructor)가 없어서 오류가 나는 것이다.
예를 들어 UserInfo의 부모 클래스인 UserAccount가 name 과 password를 받는 커스텀 생성자를 정의하게 되면, 기존의 기본 생성자는 사라지게 된다. 이 상태에서 직렬화 및 역직렬화를 수행하게 되면 no valid constructor 예외가 발생되게 된다.
class UserAccount {
String name;
String password;
// ! 기본 생성자 없으면 InvalidClassException : no valid constructor 발생
// public UserAccount() {}
UserAccount(String name, String password) {
this.name = name;
this.password = password;
}
}
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;
}
...
}
따라서 상속 관계에서의 직렬화를 행할때는 non-serializable 클래스에 대해서 반드시 기본 생성자를 일부로 정의를 해야 된다는 점을 잊지 말아야 된다.
NotSerializableException
또한 주의해야 할 사항은 클래스는 Serializable을 구현하고 있지만 다른 클래스의 객체를 멤버 변수로 가지고 있을 경우, 그리고 그 참조하고 있는 클래스가 Serializable을 구현하지 않았을 경우이다.
예를들어 아래와 같이 Object 객체를 필드로 참조하고 있을 경우, Object는 Serializable을 구현하지 않았기 때문에 NotSerializableException이 발생하면서 직렬화에 실패하게 된다.
class UserInfo implements Serializable {
String name;
int age;
boolean marreid;
Object obj;
}
만일 Object가 Serializable을 구현했다면 모든 클래스가 직렬화 될 수 있을 것이기 때문에 최고 조상인 Object는 기본적으로 직렬화를 구현하고 있지 않다.
따라서 Serializable를 구현한 클래스 내부에 직렬화 하지않은 다른 객체를 참조하고 있는 필드가 있을 경우 해당 클래스도 직렬화가 가능하도록 Serializable 인터페이스를 구현하도록 설정하거나 transient 키워드를 통해 직렬화 제외 설정을 해주어야 한다.
추가로 String 타입도 일종의 클래스인데 직렬화가 가능한 이유는, String 클래스 명세를 보면 Serializable 인터페이스를 구현하고 있기 때문이다.
자바 직렬화 문제점
이 파트는 자바(Java) 개발자라면 한번쯤 읽어봐야 하는 조슈아 블로크(Joshua Bloch)의 저서 이펙티브 자바 3/E 판의 직렬화 부분을 먹기 좋게(기억하기 좋게) 요약 구성한 글이다. ✍️
Serializable 인터페이스를 클래스에 implements 하면 아주 간단하게 직렬화 가능한 클래스가 되어 외부로 내보낼 수 있다. 그러나 간단한 선언 방법과 다르게 직렬화 구현 대가는 매우 비싸다. 직렬화를 구현한 순간부터 많은 위험성을 갖게 되기 때문이다. 이에 대해서 자세히 알아보도록 하자.
자바 직렬화의 대안을 찾으라
포스팅 초반에 JSON과의 비교를 통해 직렬화의 장점에 대해 소개를 하였지만, 사실 직렬화는 장점보다 단점이 극명하게 많다.
1. 직렬화는 용량이 크다
직렬화는 객체에 저장된 데이터값 뿐만 아니라 타입 정보, 클래스 메타 정보를 가지고 있으므로 용량을 은근히 많이 차지한다. 그래서 같은 정보를 직렬화로 저장하느냐 JSON으로 저장하느냐는 파일 용량 크기가 거의 2배 이상 차이가 난다.
따라서 DB, Cache 등에 외부에 저장할때, 장기간 동안 저장하는 정보는 직렬화를 지양해야 된다.
2. 역직렬화는 위험하다
결론부터 말하자면 직렬화 설정 자체는 문제는 없지만, 남이 만든 것을 역직렬화 과정에서 나도 모르게 공격당할 위험성이 있다.
역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드를 가젯(gadget) 이라고 부르는데, 바이트 스트림을 역직렬화하는 ObjectInputStream의 readObject() 메서드를 호출하게 되면 객체 그래프가 역직렬화되어 classpath 안의 모든 타입의 객체를 만들어 내게 되는데, 해당 타입 객체안의 모든 코드를 수행할수 있게 되므로 나의 프로그램 코드 전체가 공격 범위에 들어가게 된다.
또는 객체를 직렬화하여 외부로 전송하는 과정에서 중간에 누가 가로채 파일 바이트 내용을 조작하여, 송신자가 역직렬화하는 과정에서 인스턴스에 위험한 값을 대입시켜 불변을 깨는 식으로의 공격도 가능하다. 왜냐하면 역직렬화는 생성자 없이 인스턴스화가 가능하기 때문에 보이지 않는 생성자 라고도 불리운다.
따라서 신뢰할 수 없는 데이터는 절대 역직렬화 하면 안되며, 직렬화의 잠재적인 위험성을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
3. 직렬화를 대체 할 수 없다면
만일 Serializable을 구현한 클래스만 지원하는 프레임워크를 사용해야 하거나 레거시 시스템 때문에 어쩔수 없이 클래스에 직렬화를 설정해야 할 경우, 직렬화를 피할 수 없다면 역직렬화 필터링(ObjectInputFilter)과 같은 역직렬화 방어 기법들을 사용하면 된다. 데이터 스트림이 역직렬화되기 전에 필터 조건문을 수행하여 특정 클래스만 허용하거나 제외하도록 할 수 있다.
그러나 관련된 모범 사례를 따라서 직렬화 가능 클래스들을 공격에 대비하도록 작성한다 해도 여전히 취약하기 때문에 가장 확실한 방법은 역직렬화를 안하는 것이다.
직렬화를 구현할지는 신중히 결정하라
1. 릴리즈 후에 수정이 어렵다
클래스가 Serializable을 구현하게 되면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 되는 것이다. 그래서 직렬화을 구현한 클래스가 널리 퍼지면 그 직렬화 형태도 영원히 지원해야한다. 클래스의 내부 구현을 수정한다면 원래의 직렬화 형태와 달라지게 되기 때문이다.
즉, Serializable을 구현한 순간부터 해당 객체의 유지보수는 직렬화에 묶이게 되는 것이다.
2. 클래스 캡슐화가 깨진다
만일 직렬화할 클래스에 private 멤버가 있어도 직렬화를 하게 되면 그대로 외부로 노출되게 된다. (직렬화를 제외하려면 별도로 transient 설정해야 된다)
따라서 Serializable을 구현하면 직렬화 형태가 하나의 공개 API가 되어 캡슐화가 깨지게 된다.
3. 버그와 보안에 취약하다
자바에서는 객체를 생성자를 이용해 만든는 것이 기본이다. 하지만 역직렬화는 언어의 기본 메커니즘을 우회하여 객체를 바로 생성하도록 한다. 즉, 역직렬화는 숨은 생성자 이기도 한 것이다.
문제는 만일 어느 객체가 생성자를 통해 인스턴스화 할때 불변식이나 허가되지 않은 접근을 설정하였을 경우 이를 무시하고 생성된다는 점이다.
예를들어 아래 Member 클래스는 생성자 입력값으로 이상한 값을 넣을 경우 이를 걸러내는 로직이 있는데, Member 객체를 직렬화하고 역직렬화할 경우 age에 음수값이 들어가도 이를 걸러낼수 없는 것이다. (이 부분은 위에서 소개한 역직렬화 방어 기법 중 하나인 직렬화 프록시 패턴으로 극복은 할 수 있다)
class Member implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public Member(String name, int age) {
if(age < 0){
throw new IllegalArgumentException();
}
this.name = name;
this.age = age;
}
}
4. 새로운 버전을 릴리즈할 때 테스트 요소가 많아진다
만일 직렬화 가능한 클래스가 업데이트되면, 구버전의 직렬화 형태가 신버전에서 역직렬화가 가능한지 테스트해야 할 것이다. 즉, 테스트의 양이 직렬화 가능 클래스의 수와 릴리즈 횟수에 비례하게 된다.
5. 구현 여부는 쉽게 결정할 것이 아니다
Serializable을 꼭 구현해야 한다면 클래스를 설계할 때마다 따르는 이득과 비용을 잘 고려해야 한다.
예를 들어 BigInteger와 Instant 같은 '값' 클래스와 컬렉션 클래스는 Serializable을 구현하였고 스레드 풀처럼 '동작' 하는 객체를 표현한 클래스는 대부분 구현하지 않았다.
6. 상속용 클래스와 인터페이스에 직렬화 구현에 주의해야 한다
상속 목적으로 설계된 클래스와 인터페이스를 Serializable을 구현한다는 것은, 위에서 언급한 자바 직렬화의 위험성을 고르란히 하위 클래스에게 전이하게 되는 것과 다름 없기 때문이다.
그렇지만 만일 클래스가 직렬화 및 확장이 모두 가능하다게 하고 싶다면 몇 가지 주의사항이 있다.
인스턴스 필드의 값 중에 불변식을 보장해야할 게 있다면 반드시 하위 클래스에서 Object 클래스의 finalize 메서드를 재정의하지 못하게 해야한다. finalize를 재정의하면서 final 키워드를 붙여서 선언하면 된다.
그리고 인스턴스 필드중 기본값 int는 0, Object는 null 등으로 설정되면 위배되는 불변식이 있다면 readObjectNoData 메서드를 반드시 추가해야한다.
7. 내부 클래스는 직렬화를 구현하면 안된다
내부 클래스(inner class)의 직렬화 형태는 불분명하므로 Serializable을 구현하면 안된다.
단 정적 내부 클래스(static inner class)는 Serializable 을 구현해도 상관없다.
이처럼 직렬화는 많은 단점과 위험 요소가 존재한다.
그러나 직렬화는 1997년에 탄생하여 여전히 자바 생태계 곳곳에 쓰이고 있다. 그래서 만일 어쩔수 없이 Serializable를 구현해야 한다라면, 그에 따른 비용이 적지 않으니 클래스를 설계 할때마다 이득과 비용을 잘 저울질해야 한다. 하지만 시간과 노력을 들여서라도 JSON 등으로 데이터 표현으로 마이그레이션하는 것을 추천하는 바이다.
# 참고자료
이펙티브 자바 Effective Java 3/E
https://techblog.woowahan.com/2550/
https://www.benchresources.net/serialization-and-de-serialization-in-java/
https://www.studytonight.com/java-examples/java-serialization-and-deserialization
https://youtu.be/3iypR-1Glm0
https://www.scaler.com/topics/transient-keyword-in-java/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.