Java 직렬화 – Java 직렬화

Java에서의 직렬화는 JDK 1.1에 소개되었으며 Core Java의 중요한 기능 중 하나입니다.

Java에서의 직렬화

Java에서의 직렬화는 우리에게 객체를 스트림으로 변환할 수 있는 기능을 제공하여 네트워크로 보낼 수 있거나 파일로 저장하거나 나중에 사용할 목적으로 DB에 저장할 수 있게 합니다. 역직렬화는 프로그램에서 사용할 실제 Java 객체로 객체 스트림을 변환하는 과정입니다. Java에서의 직렬화는 처음에는 매우 쉽게 사용할 수 있지만, 이 글의 후반부에서 살펴볼 몇 가지 사소한 보안 및 무결성 문제가 있습니다. 이 자습서에서는 다음과 같은 주제를 다룰 것입니다.

  1. Java에서 Serializable
  2. 직렬화 및 serialVersionUID를 사용한 클래스 리팩토링
  3. Java Externalizable 인터페이스
  4. Java 직렬화 메서드
  5. 상속을 사용한 직렬화
  6. 직렬화 프록시 패턴

Java에서 직렬화 가능

만약 클래스 객체를 직렬화하려면, java.io.Serializable 인터페이스를 구현하면 됩니다. Java에서 직렬화는 마커 인터페이스이며 구현해야 할 필드나 메서드가 없습니다. 이것은 클래스를 직렬화하도록 선택하는 프로세스와 같습니다. Java에서 직렬화는 ObjectInputStreamObjectOutputStream에 의해 구현되므로 파일에 저장하거나 네트워크를 통해 전송하려면 이를 래퍼로 사용하면 됩니다. 간단한 Java 직렬화 프로그램 예제를 살펴보겠습니다.

package com.journaldev.serialization;

import java.io.Serializable;

public class Employee implements Serializable {

//	private static final long serialVersionUID = -6470090944414208496L;
	
	private String name;
	private int id;
	transient private int salary;
//	private String password;
	
	@Override
	public String toString(){
		return "Employee{name="+name+",id="+id+",salary="+salary+"}";
	}
	
	//getter 및 setter 메서드
	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 getSalary() {
		return salary;
	}

	public void setSalary(int salary) {
		this.salary = salary;
	}

//	public String getPassword() {
//		return password;
//	}
//
//	public void setPassword(String password) {
//		this.password = password;
//	}
	
}

여기서 간단한 Java 빈이며 몇 가지 속성과 getter-setter 메서드가 있는 것을 알 수 있습니다. 객체 속성을 스트림으로 직렬화하지 않으려면 급여 변수처럼 transient 키워드를 사용할 수 있습니다. 이제 객체를 파일에 쓰고 동일한 파일에서 역직렬화하려면 ObjectInputStreamObjectOutputStream을 사용하는 유틸리티 메서드가 필요합니다.

package com.journaldev.serialization;

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

/**
 * A simple class with generic serialize and deserialize method implementations
 * 
 * @author pankaj
 * 
 */
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();
	}

}

이 메서드의 인수가 모든 자바 객체의 기본 클래스인 객체와 작동함을 주목하세요. 이는 일반적인 성격을 갖도록 이렇게 작성되었습니다. 이제 자바 직렬화를 실제로 보기 위해 테스트 프로그램을 작성해 봅시다.

package com.journaldev.serialization;

import java.io.IOException;

public class SerializationTest {
	
	public static void main(String[] args) {
		String fileName="employee.ser";
		Employee emp = new Employee();
		emp.setId(100);
		emp.setName("Pankaj");
		emp.setSalary(5000);
		
		// 파일에 직렬화합니다
		try {
			SerializationUtil.serialize(emp, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		Employee empNew = null;
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("emp Object::"+emp);
		System.out.println("empNew Object::"+empNew);
	}
}

위의 직렬화를 위한 테스트 프로그램을 자바에서 실행하면 다음 출력을 얻습니다.

emp Object::Employee{name=Pankaj,id=100,salary=5000}
empNew Object::Employee{name=Pankaj,id=100,salary=0}

급여가 일시적인 변수이기 때문에 해당 값은 파일에 저장되지 않았으며 따라서 새 객체에서 검색되지 않았습니다. 마찬가지로 정적 변수 값도 클래스에 속하기 때문에 객체가 아니므로 직렬화되지 않습니다.

직렬화 및 serialVersionUID로 클래스 리팩토링

자바의 직렬화는 무시할 수 있는 경우 일부 클래스의 변경을 허용합니다. 직렬화 과정에 영향을주지 않는 클래스의 변경 중 일부는 다음과 같습니다:

  • 클래스에 새 변수 추가
  • 변수를 일시적에서 비일시적으로 변경하면 직렬화에 대해 새 필드가 있는 것처럼 작동합니다.
  • 변수를 정적에서 비정적으로 변경하면 직렬화에 대해 새 필드가 있는 것처럼 작동합니다.

하지만 이러한 변경 사항이 작동하려면 java 클래스에 클래스에 대한 serialVersionUID가 정의되어야합니다. 이전 테스트 클래스에서 이미 직렬화 된 파일의 역직렬화를 위해 테스트 클래스를 작성해 봅시다.

package com.journaldev.serialization;

import java.io.IOException;

public class DeserializationTest {

	public static void main(String[] args) {

		String fileName="employee.ser";
		Employee empNew = null;
		
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("empNew Object::"+empNew);
		
	}
}

그리고 Employee 클래스에서 “password” 변수와 해당 getter-setter 메서드의 주석을 해제하고 실행하십시오. 다음과 같은 예외가 발생합니다;

java.io.InvalidClassException: com.journaldev.serialization.Employee; local class incompatible: stream classdesc serialVersionUID = -6470090944414208496, local class serialVersionUID = -6234198221249432383
	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.journaldev.serialization.SerializationUtil.deserialize(SerializationUtil.java:22)
	at com.journaldev.serialization.DeserializationTest.main(DeserializationTest.java:13)
empNew Object::null

그 이유는 이전 클래스와 새 클래스의 serialVersionUID가 다르기 때문입니다. 실제로 클래스가 serialVersionUID를 정의하지 않으면 자동으로 계산되어 클래스에 할당됩니다. Java는 이 고유한 long 숫자를 생성하기 위해 클래스 변수, 메서드, 클래스 이름, 패키지 등을 사용합니다. IDE를 사용하는 경우 “직렬화 가능한 클래스 Employee가 long 형식의 정적 final serialVersionUID 필드를 선언하지 않습니다”라는 경고가 자동으로 표시됩니다. Employee 클래스에 대해 클래스 serialVersionUID를 생성하기 위해 java 유틸리티 “serialver”를 사용할 수 있습니다. 아래 명령으로 실행할 수 있습니다.

SerializationExample/bin$serialver -classpath . com.journaldev.serialization.Employee

이 프로그램에서 직렬 버전을 생성하는 것이 필수적이라는 것은 아닙니다. 우리는 이 값을 원하는대로 할당 할 수 있습니다. 단지 새로운 클래스가 동일한 클래스의 새 버전이며 가능한 경우 역직렬화 프로세스에서 역직렬화되어야 함을 알려주기 위해 있어야합니다. 예를 들어, Employee 클래스에서 serialVersionUID 필드의 주석을 해제하고 SerializationTest 프로그램을 실행하십시오. 그런 다음 Employee 클래스에서 password 필드의 주석을 해제하고 DeserializationTest 프로그램을 실행하면 객체 스트림이 성공적으로 역직렬화되는 것을 볼 수 있습니다. 이는 Employee 클래스의 변경 사항이 직렬화 프로세스와 호환되기 때문입니다.

Java Externalizable 인터페이스

자바 직렬화 프로세스를 주의하면, 이것은 자동으로 이루어집니다. 때로는 객체 데이터를 은폐하여 무결성을 유지하고 싶을 때가 있습니다. 이를 위해 java.io.Externalizable 인터페이스를 구현하고 직렬화 프로세스에서 사용할 writeExternal()readExternal() 메서드의 구현을 제공할 수 있습니다.

package com.journaldev.externalization;

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;
	}

}

스트림으로 변환하기 전에 필드 값을 변경하고, 읽기 중에 변경 사항을 되돌린 것을 주목하세요. 이렇게 함으로써 어떤 종류의 데이터 무결성을 유지할 수 있습니다. 스트림 데이터를 읽은 후 무결성 검사가 실패하면 예외를 throw할 수 있습니다. 이것이 실제로 작동하는지 확인하기 위해 테스트 프로그램을 작성해 보겠습니다.

package com.journaldev.externalization;

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.ser";
		Person person = new Person();
		person.setId(1);
		person.setName("Pankaj");
		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=Pankaj,gender=Male}

그러면 자바에서 직렬화에 어떤 것을 사용하는 것이 더 나은가요? 실제로는 Serializable 인터페이스를 사용하는 것이 더 좋습니다. 기사의 끝에 도달할 때까지 그 이유를 알게 될 것입니다.

Java 직렬화 방법

우리는 자바에서 직렬화가 자동으로 이루어지며 우리가 필요한 것은 Serializable 인터페이스를 구현하는 것뿐입니다. 구현은 ObjectInputStream 및 ObjectOutputStream 클래스에 있습니다. 그러나 데이터를 저장하는 방식을 변경하고 싶다면 어떻게 해야 할까요? 예를 들어 객체에 민감한 정보가 있고 저장/검색 전에 암호화/복호화를 하고 싶다면 클래스에 제공할 수 있는 네 가지 메서드가 있습니다. 이러한 메서드가 클래스에 존재하면 직렬화 목적으로 사용됩니다.

  1. readObject(ObjectInputStream ois): 이 메서드가 클래스에 있으면 ObjectInputStream readObject() 메서드가 스트림에서 객체를 읽는 데 이 메서드를 사용합니다.
  2. writeObject(ObjectOutputStream oos): 이 메서드가 클래스에 있으면 ObjectOutputStream writeObject() 메서드가 스트림에 객체를 쓰는 데 이 메서드를 사용합니다. 일반적으로 객체 변수를 난독화하여 데이터 무결성을 유지하는 데 사용됩니다.
  3. Object writeReplace(): 이 메서드가 존재하면 직렬화 프로세스 후에 이 메서드가 호출되고 반환된 객체가 스트림에 직렬화됩니다.
  4. Object readResolve(): 이 메서드가 존재하면 역직렬화 프로세스 후에 호출되어 최종 객체를 호출자 프로그램에 반환합니다. 이 메서드의 사용 중 하나는 직렬화된 클래스로 Singleton 패턴을 구현하는 것입니다. Serialization and Singleton에서 더 읽어보세요.

일반적으로 위의 메소드를 구현할 때는 서브클래스가 이를 재정의하지 못하도록 비공개로 유지됩니다. 이들은 직렬화 목적으로만 사용되며 비공개로 유지함으로써 보안 문제를 피할 수 있습니다.

상속을 통한 직렬화

가끔 직렬화 인터페이스를 구현하지 않은 클래스를 확장해야 할 때가 있습니다. 자동 직렬화 동작에 의존하고 상위 클래스에 상태가 있는 경우, 해당 상태는 스트림으로 변환되지 않아 이후에 검색되지 않을 것입니다. 여기서 readObject()와 writeObject() 메소드가 실제로 도움을 줄 수 있는 곳입니다. 이들을 구현함으로써 우리는 슈퍼 클래스의 상태를 스트림에 저장한 다음 나중에 검색할 수 있게 됩니다. 이를 실제로 보겠습니다.

package com.journaldev.serialization.inheritance;

public class SuperClass {

	private int id;
	private String value;
	
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public String getValue() {
		return value;
	}
	public void setValue(String value) {
		this.value = value;
	}	
}

SuperClass는 간단한 자바 빈이지만 Serializable 인터페이스를 구현하지 않습니다.

package com.journaldev.serialization.inheritance;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectInputValidation;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SubClass extends SuperClass implements Serializable, ObjectInputValidation{

	private static final long serialVersionUID = -1322322139926390329L;

	private String name;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
	
	@Override
	public String toString(){
		return "SubClass{id="+getId()+",value="+getValue()+",name="+getName()+"}";
	}
	
	//상위 클래스 상태를 저장/초기화하기 위한 직렬화 도우미 메소드 추가
	private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
		ois.defaultReadObject();
		
		//읽기와 쓰기의 순서가 동일해야 함에 주목하세요
		setId(ois.readInt());
		setValue((String) ois.readObject());	
	}
	
	private void writeObject(ObjectOutputStream oos) throws IOException{
		oos.defaultWriteObject();
		
		oos.writeInt(getId());
		oos.writeObject(getValue());
	}

	@Override
	public void validateObject() throws InvalidObjectException {
		//여기에서 객체를 유효성 검사합니다
		if(name == null || "".equals(name)) throw new InvalidObjectException("name can't be null or empty");
		if(getId() <=0) throw new InvalidObjectException("ID can't be negative or zero");
	}	
}

주의할 점은 스트림에 대한 추가 데이터를 쓰고 읽을 때 순서가 같아야 한다는 것입니다. 데이터를 읽고 쓰는 데 안전하게 만들기 위해 몇 가지 논리를 넣을 수 있습니다. 또한 이 클래스는 ObjectInputValidation 인터페이스를 구현하고 있음에 주의하세요. validateObject() 메서드를 구현함으로써 비즈니스 검증을 추가하여 데이터 무결성이 손상되지 않도록 할 수 있습니다. 테스트 클래스를 작성하고 직렬화된 데이터에서 슈퍼 클래스 상태를 검색할 수 있는지 확인해 보겠습니다.

package com.journaldev.serialization.inheritance;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class InheritanceSerializationTest {

	public static void main(String[] args) {
		String fileName = "subclass.ser";
		
		SubClass subClass = new SubClass();
		subClass.setId(10);
		subClass.setValue("Data");
		subClass.setName("Pankaj");
		
		try {
			SerializationUtil.serialize(subClass, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		try {
			SubClass subNew = (SubClass) SerializationUtil.deserialize(fileName);
			System.out.println("SubClass read = "+subNew);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}
}

위의 클래스를 실행하면 다음과 같은 출력이 나옵니다.

SubClass read = SubClass{id=10,value=Data,name=Pankaj}

따라서 이 방법으로는 Serializable 인터페이스를 구현하지 않은 슈퍼 클래스의 상태를 직렬화할 수 있습니다. 이 전략은 슈퍼 클래스를 변경할 수 없는 제3자 클래스일 때 유용합니다.

직렬화 프록시 패턴

자바에서의 직렬화는 다음과 같은 심각한 문제점이 있습니다;

  • 자바 직렬화 프로세스를 깨뜨리지 않고 클래스 구조를 크게 변경할 수 없습니다. 따라서 나중에 일부 변수가 필요하지 않더라도, 역 호환성을 위해 그대로 유지해야 합니다.
  • 직렬화는 매우 큰 보안 위험을 초래할 수 있으며, 공격자는 스트림 순서를 변경하여 시스템에 피해를 입힐 수 있습니다. 예를 들어, 사용자 역할이 직렬화되고 공격자가 스트림 값을 admin으로 변경하여 악성 코드를 실행할 수 있습니다.

자바 직렬화 프록시 패턴은 직렬화를 통해 더 높은 보안을 달성하는 방법입니다. 이 패턴에서는 내부에 개인 정적 클래스가 직렬화 목적으로 프록시 클래스로 사용됩니다. 이 클래스는 주 클래스의 상태를 유지하기 위해 설계되었습니다. 이 패턴은 readResolve()writeReplace() 메서드를 올바르게 구현하여 실행됩니다. 먼저 직렬화 프록시 패턴을 구현한 클래스를 작성한 다음 이해를 돕기 위해 분석하겠습니다.

package com.journaldev.serialization.proxy;

import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Data implements Serializable{

	private static final long serialVersionUID = 2087368867376448459L;

	private String data;
	
	public Data(String d){
		this.data=d;
	}

	public String getData() {
		return data;
	}

	public void setData(String data) {
		this.data = data;
	}
	
	@Override
	public String toString(){
		return "Data{data="+data+"}";
	}
	
	//직렬화 프록시 클래스
	private static class DataProxy implements Serializable{
	
		private static final long serialVersionUID = 8333905273185436744L;
		
		private String dataProxy;
		private static final String PREFIX = "ABC";
		private static final String SUFFIX = "DEFG";
		
		public DataProxy(Data d){
			//보안을 위한 데이터 숨김
			this.dataProxy = PREFIX + d.data + SUFFIX;
		}
		
		private Object readResolve() throws InvalidObjectException {
			if(dataProxy.startsWith(PREFIX) && dataProxy.endsWith(SUFFIX)){
			return new Data(dataProxy.substring(3, dataProxy.length() -4));
			}else throw new InvalidObjectException("data corrupted");
		}
		
	}
	
	//직렬화된 객체을 DataProxy 객체로 교체
	private Object writeReplace(){
		return new DataProxy(this);
	}
	
	private void readObject(ObjectInputStream ois) throws InvalidObjectException{
		throw new InvalidObjectException("Proxy is not used, something fishy");
	}
}
  • DataDataProxy 클래스는 Serializable 인터페이스를 구현해야 합니다.
  • DataProxy는 Data 객체의 상태를 유지할 수 있어야 합니다.
  • DataProxy는 내부 개인 정적 클래스이므로 다른 클래스에서 액세스할 수 없습니다.
  • DataProxy는 Data를 인수로 취하는 단일 생성자를 가져야 합니다.
  • Data 클래스는 writeReplace() 메서드를 제공하여 DataProxy 인스턴스를 반환해야 합니다. 따라서 Data 객체가 직렬화될 때 반환된 스트림은 DataProxy 클래스입니다. 그러나 DataProxy 클래스는 외부에서 보이지 않으므로 직접 사용할 수 없습니다.
  • DataProxy 클래스는 readResolve() 메서드를 구현하여 Data 객체를 반환해야 합니다. 따라서 Data 클래스가 역직렬화될 때 내부적으로 DataProxy가 역직렬화되고 readResolve() 메서드가 호출되면 Data 객체를 얻을 수 있습니다.
  • 마침내 Data 클래스에 readObject() 메서드를 구현하고 해커 공격을 피하기 위해 InvalidObjectException을 throw합니다.

작은 테스트를 작성하여 구현이 제대로 동작하는지 확인합시다.

package com.journaldev.serialization.proxy;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class SerializationProxyTest {

	public static void main(String[] args) {
		String fileName = "data.ser";
		
		Data data = new Data("Pankaj");
		
		try {
			SerializationUtil.serialize(data, fileName);
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		try {
			Data newData = (Data) SerializationUtil.deserialize(fileName);
			System.out.println(newData);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}

}

위 클래스를 실행하면 콘솔에 아래 출력이 표시됩니다.

Data{data=Pankaj}

data.ser 파일을 열면 DataProxy 객체가 파일에 스트림으로 저장된 것을 확인할 수 있습니다.

Java 직렬화 프로젝트 다운로드

이것이 Java에서의 직렬화에 관한 모든 것입니다. 간단하지만 신중하게 사용하고 기본 구현에 의존하지 않는 것이 항상 좋습니다. 위 링크에서 프로젝트를 다운로드하여 더 많이 배워보세요.

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