티스토리 뷰
스터디 목적으로 게재한 글입니다. 혹시 잘못된 정보가 있으면 알려주시면 감사하겠습니다.
1. 직렬화 개념
Serializable : 객체를 바이트 스트림으로 인코딩 하는 것
Desrializable : 바이트 스트림 인코딩하여 객체로 복원
(직렬화 과정) (역직렬화 과정)
Serializable -> ByteStream -> VM간 전송/File 저장/ 네트워크 전송 등 ->ByteStream -> Desirializable
2. 왜 직렬화를 쓸까 ?
기본적으로 Primitive 데이터 간 전송 혹은 DB/File에 저장 가능하지만 Reference 값을 데이터로 전송하여 파일이나 DB에 저장하는 것은 불가능합니다. 예를 들어 String hello = "안녕" 이라는 String 타입의 객체를 저장하게 되면 값이 0x001203 이런 주소 값으로 저장되는 걸 볼 수 있습니다.
이러한 Reference 타입의 데이터를 외부로 전송 혹은 저장하기 위하여 직렬화를 사용합니다. 결국 Reference값인 객체를 직렬화된 데이터는 최종적으로 Primitive한 값으로 변환됩니다.
결론적으로 이러한 Reference값들을 파일 저장/ 네트워크 전송/ VM 간 전송/ DB 저장의 목적으로 파싱 할 수 있는 데이터를 만들기 위하여 직렬화를 사용합니다.
(외부 API 서버에 정보를 주고받기 위해 표준화된 JSON 데이터를 쓰는 것처럼 생각하면 될 것 같습니다.)
3. JSON/XML을 쓰면 되지 왜 JAVA 언어에 종속적인 직렬화를 쓸까?
이번 스터디를 하면서 느꼈던 건 VM 간 전송 시에 쓸 것 같은 생각이 들었습니다. 사실 외부 API 서버로 데이터 전송은 직렬화로 구현할 수가 없다고 생각합니다. 외부 서버가 자바 이외의 언어로도 구성되어 있어 자바 언어에 종속적인 직렬화는 쓸 일이 없습니다. VM 간에 전송은 같은 JAVA 언어이기에 필요에 의해 직렬화를 구현하거나, 프런트 영역에서 AJAX로 비동기 처리를 할 때 JSON 방식이 아닌 직렬 화방식으로도 쓸 수 있을 것 같습니다. 아직 이 부분은 저도 실무경험이 없기에 피부로 와 닿는 부분은 아닙니다.
JAVA에서 직렬화를 쓰이는 예를 보면서 대략적인 감만 잡을 수 있었습니다.
- HttpServlet : 세션 상태를 캐시 하기 위해서 구현
- Component : GUI를 전송, 보관, 복원에 필요하여
- Throwable : RMI시 발생하는 예외를 서버에서 클라이언트로 전달하기 위해
- 세션 클러스터링 등.....
4. 직렬화, 역직렬화 간단한 사용법
01) 클래스에 implements Serializable 선언
public class Member implements Serializable {
//Field
private String name;
private String email;
private int age;
//Constructor
public Member(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
this.IQ = IQ;
this.team = team;
}
//.......생략
Serializable은 마커 인터페이스로 클래스에 'implements Serializable'을 명시만 해준다면 해당 객체는 직렬화 대상이 되어, 컴파일 단계에서 직렬화 과정이 수행됩니다. 만약 implements Serializable을 선언하지 않고 해당 클래스를 직렬화을 수행하면 직렬화 대상이 아님으로 컴파일 단계에서 'java.io.NotSerializableException'이 발생합니다.
* 마커 인터페이스 : 메서드가 하나도 없는 인터페이스, Type 체크를 위해 쓰인다. 쉬운 예로, A기업에 'java'사원이 출근하여 일을 하려면 기업 건물에 출입 전에 사원증을 찍고 들어가야 한다. 여기서 사원증이 기업 사원이란 걸 인증해주는 마커 인터페이스의 역할이라고 생각하면 될 것 같다.
02) 객체 직렬화 수행
public static void main(String[] args) throws Exception {
File file = new File("D:\\test.txt");
Member member = new Member("김동환", "abc@study.com", 31);
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(member);
oos.close();
ObjectOutputStream/ObjectInputStream은 객체를 입력, 출력할 수 있는 보조 스트림입니다. writeObject 메소드는 non-transient, non-static 필드를 대상으로 Member 클래스의 member 인스턴스 직렬화를 수행합니다.
* 필드에 transient를 선언하면 해당 필드는 직렬화 대상에서 제거됩니다.
Member 클래스는 직렬화의 물리적 구조 혹은 지켜야 할 규격?이고 member 인스턴스는 데이터 정보가 담긴 논리적 구조라고 생각됩니다.
위의 코드를 실행하면 D드라이브에 있는 test.txt파일에 아래와 같은 결과가 나옵니다.
사람이 읽을 수 없는 데이터 형식으로 저장되어 있는 걸 확인할 수 있습니다. 파일 정보에 담긴 객체 정보를 역직렬 화하여 객체화해보겠습니다.
3) 역직렬화 과정
public static void main(String[] args) throws Exception {
File file = new File("D:\\test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Member member2 = (Member)ois.readObject();
ois.close();
System.out.println(member2);
''test.txt'에 담긴 객체 정보를 역직렬 화하는 과정입니다. 다른 VM에 전송 시 Member 클래스는 import 되어있어야 역직렬 화가 가능합니다. readObject 메서드를 사용하여 파일에 있는 객체 정보를 member2로 인스턴스화 하였습니다. 로그를 찍어보면 아래와 같은 결과가 나옵니다.
제대로 된 값들이 들어 있는 걸 확인할 수 있습니다. 언뜻 이렇게 보면 자바의 직렬화는 간단해 보입니다. 하지만 이펙티브 자바와 다른 블로그들을 참고하면서 굉장히 까다로운 API란 걸 알게 되었습니다.
4) 직렬 버전 UID(serial version UID)
UID는 직렬화 대상 클래스의 고유 식별자 역할을 합니다. 모든 직렬화 가능 클래스에는 UID가 붙는데요. 1번 직렬 화과 정 예제를 보면 Member 클래스에 UID 필드를 생성하지 않았습니다. UID를 명시적으로 선언하지 않으면 시스템이 복잡한 과정을 거쳐 UID를 생성합니다.
기존 Member 클래스에 필드를 하나 추가해보겠습니다.
public class Member implements Serializable {
private String name;
private String email;
private int age;
private double iq; //추가된 필드
}
//...생략
public static void main(String[] args) throws Exception {
File file = new File("D:\\test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Member member2 = (Member)ois.readObject();
ois.close();
System.out.println(member2);
}
Member 클래스에 iq 필드가 추가되었습니다. 이를 실행하면 아래의 InvalidClassException이 발생합니다.
Exception in thread "main" java.io.InvalidClassException: SerializableTest.Member; local class incompatible: stream classdesc serialVersionUID = -6595993439742869931, local class serialVersionUID = -6816804225860732478
at java.io.ObjectStreamClass.initNonProxy(Unknown Source)
at java.io.ObjectInputStream.readNonProxyDesc(Unknown Source)
at java.io.ObjectInputStream.readClassDesc(Unknown Source)
at java.io.ObjectInputStream.readOrdinaryObject(Unknown Source)
at java.io.ObjectInputStream.readObject0(Unknown Source)
at java.io.ObjectInputStream.readObject(Unknown Source)
at SerializableTest.Main.main(Main.java:30)
오류를 확인해보면 stream classdesc serialversionUID가 자동 생성되었던걸 확인할 수 있습니다. 그런데 이번에는 필드 값이 바뀌면서 local class UID도 자동 생성되면서 값이 바뀌어 오류가 발생했습니다.
자동 생성된 UID의 문제는 직렬화 대상 클래스 중 필드 값이 하나라도 변경이 될 경우, 자동 생성되는 직렬 버전 UID 값이 바뀌게 되어 버전 관리가 쉽지 않습니다. 그래서 직렬화 대상 클래스에는 UID값을 명시하는 것이 직렬화 클래스 관리에 훨씬 효율적입니다.
위의 직렬화에 대한 간단한 사용법들을 훑어 봤습니다. implement serializable 선언과 UID 명시만 하면 직렬 화가 굉장히 간단해 보이지만, 여러 블로그와 이펙티브 자바를 참고하다 보니 사용하기 까다로운 API란 걸 알 수 있었습니다.
직렬화 사용 시 유의해야 할 점을 제가 이해한 부분만 추려내어 2가지 예제를 통해 알아보겠습니다.
클래스를 배포하고 나면 클래스 구현을 유연하게 바꾸기 어렵다
- 필드 Type 체크에 엄격
public class Member implements Serializable {
private static final long serialVersionUID = -6816804225860732478L;
private String name;
private String email;
private int age;
private int iq; //double -> int로 변경
//생략
}
기존 double iq 필드였던 Member 클래스를 직렬화하여 파일에 저장하였습니다. 그 후에 클래스 규칙이 바뀌어
double iq를 int iq로 바꾼 후에 역직렬화를 시도해보면 아래의 예외가 발생합니다. (실행코드는 생략하겠습니다.)
Exception in thread "main" java.io.InvalidClassException: SerializableTest.Member; incompatible types for field iq
at java.io.ObjectStreamClass.matchFields(Unknown Source)
at java.io.ObjectStreamClass.getReflector(Unknown Source)
at java.io.ObjectStreamClass.initNonProxy(Unknown Source)
at java.io.ObjectInputStream.readNonProxyDesc(Unknown Source)
at java.io.ObjectInputStream.readClassDesc(Unknown Source)
at java.io.ObjectInputStream.readOrdinaryObject(Unknown Source)
at java.io.ObjectInputStream.readObject0(Unknown Source)
at java.io.ObjectInputStream.readObject(Unknown Source)
at SerializableMainMethod.MemberMainMethod.main(MemberMainMethod.java:26)
InvalidClassException이 발생했는데 iq 필드의 타입의 불일치로 인해 발생한 걸 확인할 수 있습니다. 만약 필드 추가를 한다면 어떤 값이 나올까요? 직렬 버전 UID 예제에서는 UID가 명시적으로 선언되지 않아 UID 불일치로 인한 InvalidClassException 예외가 발생했었는데요. 이번에는 UID가 선언되어 그냥 null값이 추가됩니다.
public class Member implements Serializable {
private static final long serialVersionUID = -6816804225860732478L;
private String name;
private String email;
private int age;
private double iq;
private String address; //추가된 필드
//생략...
}
public static void main(String[] args) throws Exception {
File file = new File("D:\\test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Member member2 = (Member)ois.readObject();
ois.close();
System.out.println(member2);
}
추가되는 필드는 null값이 들어가지만, 필드 타입이 변경이 될 경우, 물리적 구조가 맞지 않아 예외가 발생한다는 것을 미루어보아 자주 바뀌는 환경 속에서 직렬화를 쓰면 관리하기가 까다로울 것 같습니다.
- 객체 그래프 관계
클래스 데이터 타입을 참조하고 있는 클래스를 직렬화 해보겠습니다.
public class Player implements Serializable{
private static final long serialVersionUID = 4906028383324707764L;
private String name;
private String position;
private Team team;
}
public class Team {
private String teamId;
private String teamName;
}
Player 클래스는 Team 객체를 데이터 타입으로 필드를 가지고 있습니다. Player만 직렬화 가능 클래스로 선언을 하였고, Team 클래스는 직렬화를 선언하지 않았습니다. 해당 코드를 실행해보면 아래의 예외가 발생합니다.
public static void main(String[] args) throws Exception{
File file = new File("D:\\team.txt");
Team team = new Team("1", "토트넘");
Player player = new Player("손흥민","공격수", team);
System.out.println(player);
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(player);
oos.close();
Team 클래스가 Serializable 대상이 아니라는 예외가 발생했습니다. 직렬화는 객체가 참조하는 모든 객체들을 직렬화 한다는 것을 알 수 있습니다. 여기서 상황에 맞게 직렬화를 수행하려면, Player 클래스의 team 필드에 transient를 선언하면 team필드를 제외하고 직렬화되는 방법과 Team클래스에도 implements Serializable를 선언하면 예외가 발생하지 않습니다.
너무 간단한 예제이긴 하지만, 직렬화를 구현할 때에는 자주 바뀌는 환경 속에서는 직렬화 구현을 고려해봐야 할 것 같습니다.
버그나 보안에 취약하다
- readObject는 언어 외적인 방법으로 인스턴스 생성
: readObject는 바이트 스트림을 인자로 받아 인스턴스를 생성하는 방식입니다. 역직렬화 과정인 readObject 구현 과정
을 보면 아시겠지만, 기존 생성자를 통해 인스턴스를 생성하는 방식이 아닙니다. 바이트스트림을 인자로 받아 인스턴
스를 생성하는 방식인걸 확인할 수 있습니다. 그래서 클래스의 생성자가 private이건 protected건 상관없이 직렬화
클래스는 readObject 메소드를 쓰는 순간 public 생성 방식이 되어버립니다.
이러한 생성 메커니즘은 버그나 보안에 취약하다고 이펙티브 자바에 나와있습니다.
<규칙 76 readObject 메소드는 방어적으로 구현하라 - effective java>
아래는 이펙티브 자바 책을 인용한 내용들입니다.
public final class Period implements Serializable{
//Field
private final Date start;
private final Date end;
/**
* @param start 구간 시작점
* @param end 구간 끝점; start 이전이 될 수 없다
* @throws IllegalArgumentException start가 end 이후인 경우 발생
* @throws NullPointerException start나 end가 null인 경우 발생
*/
//Constructor
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
//Method
public Date start() {return new Date(start.getTime());}
public Date end() {return new Date(end.getTime());}
public String toString() {return start + " - " + end;}
// 생략
}
위의 클래스를 직렬화 가능 클래스로 만드는데 문제가 없어 보입니다. 클래스 선언부에 implements Serializable만 붙이면 직렬화가 가능해 보이지만, 클래스의 불변식을 더 이상 만족시킬 수 없게 됩니다.
readObject는 public 생성자나 마찬가지이며, 생성자와 마찬가지로 인자의 유효성을 검사해야 하고, 인자를 방어적으로 복사해야 합니다.
문제는 readObject가 바이트 스트림을 인자로 받는 생성자여서 인위적으로 만든 바이트 스트림을 readObject에 인자로 넘길 때 생깁니다. 그래서 클래스의 불변식을 위반하는 객체를 만들 수 있게 됩니다.
public class BogusPeriod {
// 바이트 스트림을 꼭 실제 Period 객체로만 만들어 낼 수 있는 것은 아니다.
private static final byte[] serializedForm = new byte[] { (byte) 0xac,
(byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06, 0x50, 0x65, 0x72,
0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8, 0x2b, 0x4f, 0x46,
(byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02, 0x4c, 0x00, 0x03, 0x65,
0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f,
0x75, 0x74, 0x69, 0x6c, 0x2f, 0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c,
0x00, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00,
0x01, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61,
0x2e, 0x75, 0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68,
0x6a, (byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf, 0x6e,
0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03, 0x77, 0x08,
0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22, 0x00, 0x78 };
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// 지정된 직렬화 형식을 준수하는 객체 생성
private static Object deserialize(byte[] sf) {
try {
InputStream is = new ByteArrayInputStream(sf);
ObjectInputStream ois = new ObjectInputStream(is);
return ois.readObject();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
}
serializaedForm 필드는 Period 객체를 직렬화 해서 만들어진 바이트 스트림을 수작업으로 가공된 겁니다.
위를 실행해보면 end 날짜가 start날짜보다 더 빠른 시간이 출력됩니다.
(*이클립스로 실행해보면 오류가 발생합니다. 저 byte 배열이 특정 환경 속에서 실행이 되는 것 같습니다.)
이러한 인공적인 바이트스트림은 클래스의 불변식을 위반하는 객체를 생성하게 되는데, 이 문제를 피하려면 defaultReadObject 메서드를 호출하는 readObject메소드를 Period 클래스에 구현해서 역직렬화된 객체의 유효성을 검사하도록 해야 합니다. 기본적인 serializable 구현 이외에 추가적인 옵션사항들을 넣으려면 readObject나 writeObject를 직렬화 가능 클래스 내부에 메서드를 정의하면 사용자 정의 직렬화가 가능합니다.
public final class Period implements Serializable{
private final Date start;
private final Date end;
/**
* @param start 구간 시작점
* @param end 구간 끝점; start 이전이 될 수 없다
* @throws IllegalArgumentException start가 end 이후인 경우 발생
* @throws NullPointerException start나 end가 null인 경우 발생
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {return new Date(start.getTime());}
public Date end() {return new Date(end.getTime());}
public String toString() {return start + " - " + end;}
//유효성을 검사하는 readObject메서드
private void readObject(ObjectInputStream s)throws Exception{
System.out.println("readObject Come in");
s.defaultReadObject();
System.out.println("start : "+start);
if(start.compareTo(end)>0)
throw new InvalidObjectException(start + " after "+ end);
}
}
public static void main(String[] args) throws Exception {
File file = new File("D:\\period.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Period period2 = (Period) ois.readObject();
ois.close();
System.out.println(period2);
}
Period클래스 내부에 있는 readObject메소드를 이해하기 정말 어려웠습니다. 혹시 자세히 알고 계신분은 답변으로 남겨주시면 감사하겠습니다.
우선 제가 이해한 정보로는 readObject메서드가 가장 먼저 수행하는 부분은 s.defaultReadObject 메서드입니다. 다큐먼트를 참고해보니 현재 스트림에 있는 클래스의 non-static/non-filed를 읽어들입니다.
위 로그를 확인해보면 start필드 값이 찍힌걸 확인 할 수 있습니다. defaultReadObject가 직렬화된 클래스의 필드 값들을 읽어오고, if문의 유효성 검사의 start값은 defaultReadObject가 읽은 필드값에 해당된다고 추측할 수 있습니다.
(* 정확한 부분 아닙니다. 더 확인해보고 수정 업데이트 하겠습니다.)
완벽히 역직렬화 되기전에 defaultReadObject를 통해 유효성 검증을 할 수있다는 것을 볼 수 있었습니다.
위의 유효성 검증을 통해 readObject를 방어적으로 구현하였지만 다른 방식으로 외부에서 공격을 하는 방법이 있다고 합니다.
public class MutablePeriod {
//Period객체
public final Period period;
//객체의 start 필드. 원래는 접근할 수 없어야 한다.
public final Date start;
//객체의 end 필드. 원래는 접근할 수 없어야 한다.
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
//유효한 Period 객체를 직렬화
out.writeObject(new Period(new Date(), new Date()));
/*
* 악의적인 "previous object refs"fmf cnrk.
* Period 내부의 Date 필드에 대한 것이다. 상세한 내용은
* "Java 객체 직렬화 명세서(Java Object Serialization
* Specification)"의 6.4절 참조
*/
byte[] ref = {0x71, 0, 0x7e, 0, 5}; //Ref #5
bos.write(ref); //start필드
ref[4] = 4; //Ref #4
bos.write(ref); //end 필드
//Period와 훔쳐낸 Date 참조를 역직렬화
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
}catch(Exception e) {
throw new AssertionError(e);
}
}
예제를 실행해보면 end 날짜가 start 날짜보다 더 빠른걸 확인 할 수 있습니다.
아래는 위의 문제를 보완한 리팩토링된 Period 클래스입니다. 참조하고 있는 모든 필드를 방어적으로 복사하여 구현한걸 확인 할 수 있습니다.
public final class Period implements Serializable{
private static final long serialVersionUID = 5138914350953868369L;
private Date start;
private Date end;
/**
* @param start 구간 시작점
* @param end 구간 끝점; start 이전이 될 수 없다
* @throws IllegalArgumentException start가 end 이후인 경우 발생
* @throws NullPointerException start나 end가 null인 경우 발생
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {return new Date(start.getTime());}
public Date end() {return new Date(end.getTime());}
public String toString() {return start + " - " + end;}
private void readObject(ObjectInputStream s)throws Exception{
System.out.println("readObject Come in");
s.defaultReadObject();
start = new Date(start.getTime());
end = new Date(end.getTime());
System.out.println(start);
System.out.println(end);
if(start.compareTo(end)>0)
throw new InvalidObjectException(start + " after "+ end);
}
}
final 필드는 방어적으로 복사하기가 불가능하여 비 final 필드로 선언하였고, readObject 메서드 내부에 start와 end 필드값을 설정한 부분을 볼 수 있습니다. 이렇게 구현하면 아까전의 MutablePeriod클래스를 실행하면 정상적인 출력결과가 나오게 됩니다.
위의 내용말고도 Effective Java에 직렬화에 대한 더 상세하고 많은 내용들이 있습니다. 수박 겉 햙기 식으로 직렬화를 공부하였지만 직렬화를 구현할려면 고려할 사항들이 정말 많은 API란 걸 확인 할 수 있었습니다.
참고사이트 및 책
- Effective JaVa 2판
- http://woowabros.github.io/experience/2017/10/17/java-serialize.html(우아한 형제들 기술블로그)
- https://okky.kr/article/224715
- https://devbox.tistory.com/entry/Java-%EC%A7%81%EB%A0%AC%ED%99%94