Java의 Serialization(직렬화) - 1

홍성민·2022년 12월 23일
0

Java

목록 보기
2/3
post-thumbnail

🔥 Intro


Java의 Serialization(직렬화)은 네트워크를 통해 전송하거나, 파일로 저장하고, 나중에 사용하기 위해 DB에 저장하는 일들을 하기 위해 Object를 Stream으로 변환하게 도와줍니다. Deserialization(역직렬화) 는 Object Stream을 프로그램에서 사용할 Java 객체로 변환하는 과정을 말합니다.

Java의 Serialization(직렬화)는 처음엔 쉬워보이지만 밑에서 보게될 사소한 보안 및 무결성 문제가 있습니다. 그럼 Java 직렬화에 대해 알아보겠습니다.




🔥 Java의 Serializable


Serialization(직렬화) 는 Java 시스템 내부에서 사용하는 객체 혹은 데이터 즉, JVM의 메모리에 상주(heap, stack)되어있는 객체 데이터를 외부의 자바 프로그램에서도 사용할 수 있도록 byte(바이트) 형태로 변환하는 기술입니다. Deserialization(역직렬화) 는 반대로 byte 형태의 데이터를 객체로 변환하는 기술입니다.

class 객체를 직렬화하려면 java.io.Serializable 인터페이스를 implements 하여 구현해야 합니다. Java의 Serializable인터페이스는 아무것도 구현할 필드나 메서드가 없는 빈 인터페이스(marker interface)입니다.
ObjectInputStream과 ObjectOutputStream이 Serialization을 구현하기 때문에, 네트워크를 통해 전송하거나 파일로 저장하기 위한 래퍼(wrapper)만 있으면 됩니다. 간단하게 java의 직렬화를 예로 들어보겠습니다.


import java.io.Serializable;

public class User implements Serializable {

//	private static final long serialVersionUID = -15981268745621L;
	
	private String name;
	private int id;
	transient private int age;
	
	@Override
	public String toString(){
		return "User{name="+name+",id="+id+",age="+age+"}";
	}
	
	
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}
}


이 예는 단순히 몇개의 변수와 getter, setter 메서드를 가진 java bean이라는것을 인지해야합니다. 만약 직렬화를 하고싶지 않은 변수가 있다면 위 예시의 age처럼 변수의 선언부 앞에 ** 'transient' ** 를 붙여주면 됩니다. 이제 위의 예시를 직렬화, 역직렬화를 해보려 합니다. 그러기 위해서는 ObjectInputStream과 ObjectOutputStream을 사용하는 유틸리티 메서드가 필요합니다.



import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;


public class SerializationUtil {

	// 파일을 객체로 역직렬화
	public static Object deserialize(String fileName) throws IOException,
			ClassNotFoundException {
		FileInputStream fis = new FileInputStream(fileName);
		ObjectInputStream ois = new ObjectInputStream(fis);
		Object obj = ois.readObject();
		ois.close();
		return obj;
	}

	// 객체를 직렬화해서 파일로 저장
	public static void serialize(Object obj, String fileName)
			throws IOException {
		FileOutputStream fos = new FileOutputStream(fileName);
		ObjectOutputStream oos = new ObjectOutputStream(fos);
		oos.writeObject(obj);

		fos.close();
	}
}



여기서 메서드의 파라미터는 모든 Java 객체의 조상인 Object로 작성되었습니다. 이제 Java 직렬화를 테스트하기위한 테스터를 작성해보겠습니다.


import java.io.IOException;

public class SerializationTest {
	
	public static void main(String[] args) {
		String fileName="user.serial";
		User user = new User();
		user.setId(123);
		user.setName("senbro");
		user.setAge(13);
		
		//파일로 직렬화
		try {
			SerializationUtil.serialize(user, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		User newUser = null;
		try {
			newUser = (User) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("user Object - "+user);
		System.out.println("newUser Object - "+newUser);
	}
}



이제 위 예제를 실행해보면 다음과 같은 결과가 나옵니다.


user Object - User{name=senbro, id=123, age=13}
newUser Object - User{name=senbro, id=123, age=0}


age는 transient가 붙은 변수이기 때문에 파일로 저장되지 않았고, 그래서 이 파일을 다시 역직렬화한 객체에서도 확인되지 않았습니다. 비슷한 예로 static이 붙은 정적 변수의 경우에도 객체가 아니라 class에 속하기 때문에 직렬화 되지 않습니다.




🔥 Serialization(직렬화)를 사용한 class 수정


Java의 직렬화는 무시할 수 있는 수정이라면 수정하더라도 정상적으로 작동합니다. 무시할 수 있는 수정에는 다음과 같은 것들이 있습니다.

  • 새로운 변수 추가
  • 직렬화를 무시하기위한 transient 제거(이때는 새로운 변수가 추가된것으로 인식합니다)
  • 변수의 static 제거(이때도 새 변수로 인식)

그러나 하나 허용되지 않는 수정이 있는데, 바로 serailVersionUID입니다. 이것을 증명하기 위한 테스트로 위의 예시에서 이미 직렬화된 파일을 역직렬화하는 코드를 구현해보겠습니다.



import java.io.IOException;

public class DeserializationTest {

	public static void main(String[] args) {

		String fileName="User.serial";
		User newUser = null;
		
		try {
			newUser = (User) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		System.out.println("newUser Object::"+newUser);
	}
}


그리고 기존의 User Class에 password 변수와 그 변수에 대한 getter,setter 메서드를 추가해줍니다.



import java.io.Serializable;

public class User implements Serializable {

//	private static final long serialVersionUID = -15981268745621L;
	
	private String name;
	private int id;
	transient private int age;
    private String password;
	
	@Override
	public String toString(){
		return "User{name="+name+",id="+id+",age="+age+"}";
	}
	
	
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}
    
    public String getPassword() {
    	return password;
    }
    
    public void setPassword(String password) {
    	this.password = password;
    }
}


그리고 이제 DeserializationTest class를 실행해보면 다음과 같은 결과가 나옵니다.


java.io.InvalidClassException: com.senbro.test.Employee; local class incompatible: stream classdesc serialVersionUID = -15981268745621, local class serialVersionUID = -15321864285452
	at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:604)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1601)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1514)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1750)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1347)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:369)
	at com.senbro.test.SerializationUtil.deserialize(SerializationUtil.java:22)
	at com.senbro.test.DeserializationTest.main(DeserializationTest.java:13)
newUser Object::null


에러가 발생하는 이유는 이전 class의 serialVersionUID와 새 class의 serialVersionUID가 달랐기 때문입니다. 직렬화를 하는 class의 serialVersionUID를 정의하지 않으면 컴파일 중 자동으로 계산되어 할당됩니다. Java는 변수, 메서드, 클래스명, 패키지 등을 이용하여 고유한 숫자를 만듭니다. 만약 IDE를 사용하는 경우에 “The serializable class Employee does not declare a static final serialVersionUID field of type long”라는 에러를 만나게 될 수 있습니다. 이때 serialVersionUID의 값은 정수값이면 어떤 값으로 지정해도 상관없지만 다른 클래스와 같은 값을 갖지 않도록 serialver.exe를 사용해서 생성된 값을 사용하는것이 보통입니다. seruakver.exe는 커맨드 창에서 실행해야하며 다음은 serialver.exe를 사용한 예시입니다.



....jdk\bin> serialver {classpath}.User


이렇게 하면 프로그램에 따로 serialVersionUID를 정의할 필요없이 우리가 원하는 값으로 설정할 수 있습니다. serialVersionUID는 새로운 class가 같은 class의 새로운 버전이고 가능한 역직렬화를 해야한다고 역직렬화 프로세스에게 알려주기 위해 존재하는 식별번호입니다.



예를들어 User class에서 serialVersionUID 필드의 주석을 제거하고 SerializationTest 프로그램을 실행합니다. 그리고 다시 DeserializationTest 프로그램을 실행하면 User class의 변경사항이 직렬화 프로세스와 호환되어 역직렬화도 성공하는것을 확인할 수 있습니다.



🔥 Java의 Externalizable 인터페이스


직렬화 프로세스를 보면 java는 자동으로 직렬화를 진행합니다. 하지만 가끔 우리는 데이터의 무결성을 유지하기 위해 객체 데이터를 숨기고 싶을 때가 있습니다. 이럴 때, 우리는 _java.io.Externalizable_ 인터페이스를 구현하여 writeExternal()과 readExternal() 메서드를 이용해 직렬화를 구현할 수 있습니다.




import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

public class Person implements Externalizable{

	private int id;
	private String name;
	private String gender;
	
	@Override
	public void writeExternal(ObjectOutput out) throws IOException {
		out.writeInt(id);
		out.writeObject(name+"xyz");
		out.writeObject("abc"+gender);
	}

	@Override
	public void readExternal(ObjectInput in) throws IOException,
			ClassNotFoundException {
		id=in.readInt();

		name=(String) in.readObject();
		if(!name.endsWith("xyz")) throw new IOException("corrupted data");
		name=name.substring(0, name.length()-3);
		gender=(String) in.readObject();
		if(!gender.startsWith("abc")) throw new IOException("corrupted data");
		gender=gender.substring(3);
	}

	@Override
	public String toString(){
		return "Person{id="+id+",name="+name+",gender="+gender+"}";
	}
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getGender() {
		return gender;
	}

	public void setGender(String gender) {
		this.gender = gender;
	}

}


위의 예시에서 stream으로 변환하기 전 필드 값을 바꾸고, 데이터를 읽고있는 도중에 다시 값을 되돌렸습니다. 이런방법으로 우리는 데이터 무결성을 유지할 수 있습니다. 만약 데이터 무결성 유지에 실패하면 exception이 발생할 수 있습니다. 그럼 어떻게 작동하는지 테스트코드를 작성해보겠습니다.




import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class ExternalizationTest {

	public static void main(String[] args) {
		
		String fileName = "person.serial";
		Person person = new Person();
		person.setId(1);
		person.setName("senbro");
		person.setGender("Male");
		
		try {
			FileOutputStream fos = new FileOutputStream(fileName);
			ObjectOutputStream oos = new ObjectOutputStream(fos);
		    oos.writeObject(person);
		    oos.close();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		FileInputStream fis;
		try {
			fis = new FileInputStream(fileName);
			ObjectInputStream ois = new ObjectInputStream(fis);
		    Person p = (Person)ois.readObject();
		    ois.close();
		    System.out.println("Person Object Read="+p);
		} catch (IOException | ClassNotFoundException e) {
			e.printStackTrace();
		}
	    
	}
}


위의 테스트 코드를 실행하면 아래와 같은 결과가 나옵니다.

Person Object Read=Person{id=1,name=senbro,gender=Male}



그렇다면 serializable과 Externalizable중 어떤것을 쓰는것이 더 좋을까요?
결론부터 말하자면 Serializable을 쓰는것이 더 좋습니다. 그 이유는 이 글을 끝까지 읽어보면 알 수 있습니다.




다음편에 계속...

🔥 REFERENCE

https://www.digitalocean.com/community/tutorials/serialization-in-java#serialVersionUID




profile
모두의개발

0개의 댓글