Java의 Serialization(직렬화) - 2

홍성민·2023년 1월 2일
0

Java

목록 보기
3/3
post-thumbnail

🔥 Java 직렬화 메소드(Serialization Method)


우리는 자바의 직렬화가 자동으로 작동하고 이 직렬화에 필요한 모든것들은 Serializable 인터페이스를 구현한다는 것을 봤습니다. 그리고 이 구현은 ObjectInputStream과 ObjectOutputStream 클래스들을 통해 나타납니다. 하지만 우리가 직렬화를 자동화에 맡기지 않고 로직을 바꾸어 데이터를 저장하는 방법을 변경하길 원하는 경우에는 어떻게 해야할까요? 예를 들어, 객체 내부에 민감한 정보가 있고 이 데이터를 저장/수정하기 전에 암/복호화를 하고싶은 경우에는 어떻게 해야할까요?

이런 경우, 직렬화 작동을 변경하기 위해 Serializable을 구현한 클래스에 제공되는 4가지 메소드가 있습니다.
다음의 메소드들이 클래스 내부에서 보인다면, 그것들은 직렬화를 위해 사용되는것입니다.



1. readObject(ObjectInputStream ois)
ObjectInputStream readObject()메소드가 이 메소드를 통해 stream에서 객체를
읽어들입니다.

2. writeObject(ObjectOutputStream oos)
ObjectOutputStream writeObject() 메소드가 객체를 stream으로 write하기 위해
이 메소드를 사용합니다.
일반적으로 사용하는 방법으로는 데이터의 무결성 유지를 위해 객체의 변수를 불명확하게 하는
목적으로 사용됩니다.

3. Object writeReplace()
직렬화 프로세스 뒤에 이 메소드가 호출되고 return 된 객체는 stream으로 직렬화됩니다.

4. Object readResolve()
역직렬화 프로세스 뒤에 이 메소드를 호출한 프로그램에게 최종적인 객체를 return 해주기
위해 이 메소드가 호출됩니다. 이 메소드가 사용되는 방법 중 하나로, 직렬화된 클래스로
싱글톤 패턴을 구현하는 것입니다.



일반적으로 위의 메소드들을 구현할 동안, 하위 클래스들이 override 할 수 없게 하기 위해서 private으로 유지됩니다. 왜냐하면 위의 메소드들은 직렬화를 위한 목적으로만 의미를 가지고, private으로 유지함으로서 안전 문제를 피할 수 있기 때문입니다.



🔥상속을 통한 직렬화

우리는 가끔 Serializable 인터페이스를 구현하지 않은 클래스를 상속받아야할 때가 있습니다. 만약 우리가 자동 직렬화에 의존하고 상위 클래스가 어떤 상태를 갖고 있다면, 그것들은 stream으로 변환되지 않고 그 후에 검색도 되지 않을 것입니다. 이때 readObject() 메소드와 writeObject()메소드가 활약합니다. 이 구현을 통해, 우리는 상위 클래스의 상태를 stream으로 저장할 수 있고 그 후에 검색도 가능합니다. 그럼 소스를 통해 알아보겠습니다.



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 인터페이스를 구현하지 않는 간단한 java bean 입니다.


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()+"}";
	}
	
	//adding helper method for serialization to save/initialize super class state
	private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
		ois.defaultReadObject();
		
		//notice the order of read and write should be same
		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 {
		//validate the object here
		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() 메소드를 구현하면 데이터 무결성이 손상되지 않도록 비즈니스 유효성 검사를 실행할 수 있습니다. 테스트 클래스를 작성하고 직렬화된 데이터에서 상위 클래스의 상태를 검색할 수 있는지 확인해보겠습니다.


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("senbro");
		
		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=senbro}



이 방법을 이용해서 Serializable 인터페이스를 구현하지 않은 상위 클래스를 직렬화 할 수 있습니다.



🔥 직렬화 Proxy pattern


Java의 직렬화에는 다음과 같은 몇 가지 심각한 함정이 있습니다.

  • 자바 직렬화 프로세스를 중단하지 않고는 클래스 구조를 많이 변경할 수 없습니다. 그래서 나중에 일부 변수가 필요하지 않더라도 이전 버전과의 호환성을 위해서만 유지해야 합니다.
  • 직렬화는 엄청난 보안 위험의 원인이 되고, 공격자는 스트림 순서를 변경하고 시스템에 해를 끼칠 수 있습니다. 예를 들어 사용자 역할이 직렬화되고 공격자가 스트림 값을 변경하여 관리자로 만들고 악성 코드를 실행할 수 있습니다.

Java 직렬화 프록시 패턴은 직렬화로 보안을 강화하는 방법입니다. 이 패턴에서 내부의 private static class는 직렬화 목적을 위한 프록시 클래스로 사용됩니다. 이 클래스는 메인 클래스의 상태를 유지하는 방식으로 설계되었습니다. 이 패턴은 적절하게 readResolve() 및 writeReplace() 메서드를 implement하여 구현됩니다 . 먼저 직렬화 프록시 패턴을 구현하는 클래스를 작성하고 더 확실히 이해하기 위해 이를 분석해보겠습니다.




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



  • Data와 DataProxy클래스 모두 Serializable 인터페이스를 구현해야 합니다.
  • DataProxy는 Data 객체의 상태를 유지할 수 있어야 합니다.
  • DataProxy는 내부의 private static 클래스이므로 다른 클래스가 접근할 수 없습니다.
  • DataProxy는 Data 객체를 인자로 사용하는 단일 생성자가 있어야 합니다.
  • Data클래스는 DataProxy 인스턴스를 return 하는 writeReplace() 메서드를 제공해야 합니다. 따라서 Data 객체가 직렬화될 때 return되는 스트림은 DataProxy 클래스입니다. 그러나 DataProxy 클래스는 외부에서 볼 수 없으므로 직접 사용할 수 없습니다.
  • DataProxy클래스는 Data 객체를 return 하는 readResolve() 메서드를 구현해야 합니다. 따라서 Data 클래스가 역직렬화되면 내부적으로 DataProxy가 역직렬화되고 readResolve() 메서드가 호출되면 Data 객체를 얻습니다.
  • 마지막으로 Data 클래스에서 readObject() 메서드를 구현하고 Data 객체 스트림을 조작하고 parse 하려는 해커 공격을 피하기 위해 InvalidObjectException을 던집니다.


구현이 제대로 되었는지 확인하기 위해 작은 테스트를 작성해 봅시다.



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("senbro");
		
		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=senbro}



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

이것이 Java의 직렬화에 대한 전부입니다. 간단해 보이지만 신중하게 사용해야 하며 항상 기본 구현에 의존하지 않는 것이 좋습니다:-)

profile
모두의개발

0개의 댓글