Java 직렬화....

Java 2007. 12. 10. 12:31
실제 세계에서의 직렬화
자바(tm) 직렬화 메커니즘은 단순성과 유연성으로 대표되는 자바 프로그래밍 언어의 가장 뛰어난 두 가지의 특징을 가지고 있습니다. 시리얼라이제이션은 보관되고 난 후 훗날의 사용을 위해 재구성 될 수 있는 객체, 즉, 영속하는 객체를 생성할 수 있도록 해줍니다. 예를 들어 한 프로그램에 사용한 객체를 같은 프로그램에서 이후에 다시 사용하는데에 직렬화가 필요한 것입니다.
시리얼라이제이션의 기본 메커니즘은 단순합니다. 그리고 필요에 따라 기본 직렬화를 커스터마이즈 할 수 있을 정도로 유연합니다. 이 팁은 객체를 어떻게 직렬화하는 지 보여줍니다. 그리고는 이 메커니즘의 유연성을 이용할 수 있는 세 가지 상황-새로운 버젼의 클래스, protected 데이터의 보호, 전체 클래스의 재생성-에 대해 말해줍니다
일단, 이 기초적인 예를 봅시다.
    import java.io.*; 
    public class Person implements Serializable
    { 
        public String firstName; 
        public String lastName; 
        private String password; 
        transient Thread worker; 
        public Person(String firstName, String lastName, String password) 
        { 
            this.firstName = firstName; 
            this.lastName = lastName; 
            this.password = password; 
        } 
        public String toString() 
        { 
            return new String(lastName + ", " + firstName); 
        } 
    } 

    class WritePerson 
    { 
        public static void main(String [] args) 
        { 
            Person p = new Person("Fred", "Wesley", "cantguessthis"); 
            ObjectOutputStream oos = null; 
            try 
            { 
                oos = new ObjectOutputStream(new FileOutputStream("Person.ser")); 
                oos.writeObject(p); 
            } 
            catch (Exception e) 
            { 
                e.printStackTrace(); 
            } 
            finally 
            { 
                if (oos != null)
                { 
                    try {oos.flush();} catch (IOException ioe) {} 
                    try {oos.close();} catch (IOException ioe) {} 
                } 
            } 
        } 
    } 

    class ReadPerson 
    { 
        public static void main(String [] args)
        { 
            ObjectInputStream ois = null; 
            try
            { 
                ois = new ObjectInputStream(new FileInputStream("Person.ser")); 
                Object o = ois.readObject(); 
                System.out.println("Read object " + o); 
            } 
            catch (Exception e)
            { 
                e.printStackTrace(); 
            } 
            finally
            { 
                if (ois != null)
                { 
                    try {ois.close();}
                    catch (IOException ioe) {} 
                } 
            } 
        } 
    } 
Person은 영속적으로 만들고 싶은 데이터를 가진 클래스입니다. 디스크에 보관하여서 이후의 세션에 다시 적재시켜야 할 수도 있습니다. 자바 기술을 이것을 아주 쉽게 해결해줍니다. 단지 Person 클래스가 java.io.Serializable 인터페이스를 구현한다고 선언해 주기면 하면 됩니다. Serializable 인터페이스는 아무 메소드도 없습니다. 이것은 단지 기본 직렬화 메커니즘을 사용하겠다고 자바 가상머신에 알려주는 역할만을 합니다.
Person을 컴파일 한 후, WritePerson을 실행하여 코드를 테스트 해 보십시오. WritePerson은 Person 객체를 위한 ObjectOutputStream을 생성한 후 이것을 Person.ser이라는 이름의 FileOutputStream에 기록합니다. 이것은 객체를 바이트의 스트림으로으로 포맷하고 Person.ser 파일에 저장한다는 것을 의미합니다. 그런 다음, ReadPerson을 실행합니다. 이것은 FileInputStream인 Person.ser 에서 ObjectInputStream을 생성해 내는 역할을 합니다. 다시 말해, 그것은 Person.ser로부터 바이트 스트림을 읽어내고 그것으로부터 Person 객체를 재구성함을 말합니다. ReadPerson은 이제 객체를 출력합니다. 아래에 보시는 것과 같습니다.
    Read object Wesley, Fred 
방금 이용하신 이 직렬화 메커니즘은 아주 광범위하고 다양한 상황들을 다룰 수 있도록 되어 있습니다. 한 객체를 직렬화 할 때, 모든 필드를 포함한 객체의 모든 상태를 저장하게 되는 것입니다. 이것은 퍼슨 예제에 나오는 패스워드 필드와 같이 private로 선언된 필드까지 저장함을 말하는 것입니다. 그러나, 어떤 필드가 영속적이지 않기를 원하는 경우도 있습니다. Person 예제에서 worker 스레드는 이 가상머신의 현재 세션에 대한 자원에 묶여 있습니다. Thread를 나중에 이용하기 위해 직렬화 하는 것은 논리에 맞지 않습니다. 다행스럽게도, 자바 프로그래밍 언어는 transient 선언을 제공합니다. transient라 표시 되어 있는 필드는 그 객체가 직렬화되는 과정에서 해당 필드는 저장되지 않음을 가리킵니다. worker 스레드가 transient로 선언되어 있기 때문에 Person 객체가 직렬화되는 과정에서 저장되지 않는 것을 보십시오.

 
 직렬화와 클래스 버젼 결정
기본 직렬화에서 문제가 발생한다면, 이제는 클래스를 약간 향상시킬 때입니다. Person 클래스를 판매한 후, Person의 나이를 추적하도록 결정했다고 생각해 봅시다. Person 클래스의 변형은 간단합니다.
    public class Person implements Serializable
    { 
        public String firstName; 
        public String lastName; 
        int age; 
        private String password; 
        transient Thread worker; 
        public Person(String firstName, String lastName, String password, int age)
        { 
            this.firstName = firstName; 
            this.lastName = lastName; 
            this.password = password; 
            this.age = age; 
        } 
        public String toString()
        { 
            return new String(lastName + ", " + firstName + " age " + age); 
        } 
    } 

    class WritePerson
    { 
        public static void main(String [] args)
        { 
            Person p = new Person("Fred", "Wesley", "cantguessthis", 31); 
            // 이후는 원본과 같습니다.
만약 누군가 오래된 Person.ser 파일의 스트림을 읽어들이기 위해 이 새로운 버젼의 Person을 이용한다면 어떻게 되겠습니까? ReadPerson을 다시 실행 시킴으로써 시도해보십시요. (WritePerson을 먼저 실행하면, 그 Person.ser 파일을 자동삭제하게 될 것이므로, 반드시 ReadPerson을 먼저 실행해야 합니다.) 이제는 더 이상 파일을 읽을 수 없게 되며, 대신 java.io.InvalidClassException이 발생하는 것을 볼 수 있습니다. 이것은 자바 직렬화 메커니즘이 변형된 클래스에 대해 민감하기 때문입니다. 한 클래스가 직렬화 되면, 클래스를 위한 64-bit "지문"이 계산됩니다. serialVersionUID라 불리는 이 지문은 직렬화 될 수 있는 모든 필드를 포함한 몇 가지의 클래스 데이터를 기초로 생성됩니다. 클래스에 새로운 필드(age)를 추가했기 때문에 serialVersionUID는 더 이상 일치하지 않고, 그렇기 떄문에 지난 Person.ser 파일을 읽을 수 없게 되는 것입니다.
클래스의 두 버젼들이 어느 쪽으로나 양립할 수 없는 경우 나타날 가능성이 큰 버그를 예방하기 때문에 이 신중한 접근은 매우 좋습니다. 그러나, 이 새로운 Person이 그 전의 것과 양립할 수 있는 경우도 있습니다. 또한, 원래의 형식으로 Person을 적재할 때 age 값이 정확히 설정되지 않을 수 있다는 것을 코드는 감지 할 수 있습니다. 이런 상황에서는 두 클래스들이 호환성이 있음을 자바로 하여금 알게 해야합니다. Person 클래스에 대한 serialVersionUID를 명확히 설정함으로써 그렇게 할 수 있습니다. 클래스에 다음과 같은 라인을 추가하면
    static final long serialVersionUID = /* some long integer */; 
자바 직렬화는 새로운 ID 를 계산해 내는 것 대신 그 ID를 쓰게 됩니다. 물론, 벌써 자바가 생성한 ID를 이용하여 원본 Person을 저장했기 때문에 이 정보는 약간 늦은 감이 있다고 할 수 있을 것입니다. 절망하지 마십시오. JDK(tm) 1.2의 serialver 명령행 도구를 이용하면 이미 존재하는 클래스으로부터 serialVersionUID를 추출하는 것이 가능합니다. 원본 Person 클래스를 다시 컴파일하고, "serialver Person" 명령을 입력하십시오. 그 결과로 다음과 같은 내용을 볼 수 있습니다.
    static final long serialVersionUID = 4070409649129120458L; 
새로운 버젼의 Person에 이 줄 전체를 삽입한 후, 다시 컴파일하십시오. 이제 ReadPerson을 실행함으로써 성공적으로 원본 Person을 적재할 수 있을 것입니다. 원본 포맷은 age 필드가 없었기 때문에 나이는 정확하지 못합니다. (기본 값인 0 으로 설정되어 있습니다.) 하지만, 최소한 Person 클래스의 첫 버젼에서 직렬화한 모든 데이터는 접근할 수 있습니다.

 
 직렬화와 데이터의 보호
위에서, 자바 직렬화는 private으로 선언된 데이터에 대해서도 동작함을 알 수 있었습니다. private 데이터는 한 개체의 상태에 필수적인 부분을 차지하기 때문에 반드시 필요합니다. private 필드를 제외하면 직렬화는 무의미하게 될 것입니다. 그러나 이것은 문제가 있습니다. 위에 있는 Person 예제에서, Fred Wesley는 패스워드 필드가 private이기 떄문에 그 누구도 자신의 패스워드를 알 수 없다고 생각합니다. 직렬화를 이용하면, Fred의 Person 인스턴스를 파일에 덤프하여 이 보호 장치를 뛰어 넘을 수 있습니다. 16진수 에디터에서 Person.ser 파일을 열면, 프레드의 패스워드 ("cantguessthis")는 모든 사람들에게 공개 됩니다.
이 보호 장치의 노출을 고치기 위해서는, 스트림에 데이터가 쓰여지는 방법을 조정할 필요가 있습니다. 자바 프로그래밍 언어는 이것을 다음의 두개의 메소드를 이용해 할 수 있게 해줍니다:
    private void writeObject(ObjectOutputStream stream) 
        throws IOException; 
 
    private void readObject(ObjectInputStream stream) 
        throws IOException, ClassNotFoundException; 
직렬화가 가능한 객체에서, writeObject 메소드는 클래스가 자신의 필드의 직렬화하는 것을 조정할 수 있게 해 줍니다. 그 반면에, readObject 메소드는 클래스가 자신의 필드의 역직렬화하는 것을 조정할 수 있게 해 줍니다. 다시 말해서, 직렬화 가능한 클래스에서 이 메소드들을 구현하게 되면, 이것이 기본적인 직렬화를 대체하게 됩니다. writeObject와 readObject를 사용하는 것은 스트림을 어떻게라도 할 수 있게 해줍니다. 하지만 Person의 경우에 해야 할 일은 단지 패스워드를 기호화하는 일 밖에 없습니다. 패스워드가 기호화된 후, 일반적인 직렬화 메카니즘에 주도권을 넘겨 주면 됩니다. defaultReadObject와 defaultWriteObject를 호출함으로써 기본 메카니즘에 직렬화를 넘길 수 있습니다.
이런 것들을 모두 보여주는 Person 클래스가 있습니다:
    import java.io.*; 
    public class Person implements Serializable
    { 
        public String firstName; 
        public String lastName; 
        int age; 
        private String password; 
        transient Thread worker; 
        static final long serialVersionUID = 4070409649129120458L; 

        //이것은 제대로 된 기호화 연산이 아니지만, 실행은 됩니다. 
        //하지만 조금 더 나은 것으로 대신해야 할 것입니다.
        static String crypt(String input, int offset)
        { 
            StringBuffer sb = new StringBuffer(); 
            for (int n=0; n<input.length(); n++)
            {
                sb.append((char)(offset+input.charAt(n)));
            }
            return sb.toString(); 
        } 

        // 실제 응용프로그램에서는 패스워드로의 액세스를 동조하도록 하면서 
        // 페스워드를 System.out 로 출력해서는 안됩니다! 
        private void writeObject(ObjectOutputStream stream) 
            throws IOException
        { 
            password = crypt(password, 3); 
            System.out.println("Password encyrpted as " + password); 
            stream.defaultWriteObject(); 
            password = crypt(password, -3); 
        } 
 
        private void readObject(ObjectInputStream stream) 
            throws IOException, ClassNotFoundException
        { 
            stream.defaultReadObject(); 
            password = crypt(password, -3); 
            System.out.println("Password decrypted to " + password); 
        } 
 
        public Person(String firstName, String lastName, String password, int age)
        { 
            this.firstName = firstName; 
            this.lastName = lastName; 
            this.password = password; 
            this.age = age; 
        } 

        public String toString()
        { 
            return new String(lastName + ", " + firstName + " age " + age); 
        } 
    }
writeObject가 stream.defaulWriteObject를 호출하여 기본 직렬화 메카니즘을 실행하기 전에 패스워드를 기호화 하는 것을 주시하십시오. reaObject 메서드는 이 과정을 반대로 수행합니다. 이 새로운 버젼을 시험하기 위해서 WritePerson과 ReadPerson 클래스를 이용하십시오. 패스워드가 더 이상 일반 텍스트로 볼 수 없다는 것을 알 수 있습니다. 또한, Person.ser 파일을 살펴 볼 수도 있지만 그 역시 노출 되어 있지 않습니다.

 
 직렬화와 전체 클래스의 재생성
만일 Person 클래스에 있는 필드를 변경해야 한다면 어떻게 되겠습니까? lastName 필드와 firstName 필드를 제거하고 하나의 fullName 필드를 만들기로 했다고 가정합시다. Person 클래스는 이번에는 아래와 같이 변합니다.
    import java.io.*;

    public class Person implements Serializable
    {
        public String fullName;
        int age;
        private String password;
        transient Thread worker;
        static final long serialVersionUID = 4070409649129120458L;

        //이것은 제대로 된 기호화 연산이 아니지만, 실행은 됩니다. 
        //하지만 조금 더 나은 것으로 대신해야 할 것입니다.
        static String crypt(String input, int offset)
        {
            StringBuffer sb = new StringBuffer();
            for (int n=0; n<input.length(); n++)
	        {
                sb.append((char)(offset+input.charAt(n)));    
            }
            return sb.toString();
        }

        // 실제 응용프로그램에서는 패스워드로의 액세스를 동조하도록 하면서 
        // 페스워드를 System.out 로 출력해서는 안됩니다! 
        private void writeObject(ObjectOutputStream stream) 
            throws IOException
       {
            password = crypt(password, 3);
            System.out.println("Password encyrpted as " + password);
            stream.defaultWriteObject();
            password = crypt(password, -3);
        }
    
        private void readObject(ObjectInputStream stream)  
            throws IOException, ClassNotFoundException
        {
            stream.defaultReadObject();
            password = crypt(password, -3);
            System.out.println("Password decrypted to " + password);
        }
    
        public Person(String firstName, String lastName, String password, int age)
        {
            this.fullName = lastName + ", " + firstName;
            this.password = password;
            this.age = age;
        }

        public String toString()
        {
            return new String(fullName + " age " + age);
        }
    }
이제 ReadPerson을 실행하여 이미 만들어진 Person.ser 파일을 읽어들여 봅시다. serialVersionUID를 이용했기 때문에 코드가 에러가 나지는 않습니다. 하지만 이것은 어디에도 쓸모가 없습니다. 새로운 클래스는 Person.ser 파일 내부의 lastName과 firstName 필드에 대응하는 필드 이름이 없습니다. 따라서 이러한 필드들은 무시됩니다. 반대로, Person.ser 파일에는 fullName 필드가 존재하지 않기 때문에, 정확한 fullName 값이 나타나지 않습니다. (대신, 기본값인 null 값으로 대체됩니다.)
defaultReadObject의 사용상의 문제점은, 스트림 필드의 값을 클래스의 필드의 이름을 이용해 적용한다는 것입니다. 일반적으로 이것은 많은 문제점으로부터 보호해 주지만, 이런 경우에는 필드의 이름이 적용되지 않습니다. 따라서 저장소로부터 필드를 읽는 방법을 관리해 주어야 합니다. 내포된 클래스인 ObjectInputStream.GetField 클래스를 이용하여 스트림에서 찾고자 하는 필드의 이름을 명시적으로 지정할 수 있습니다. 여기에 ObjectInputStream.GetField 클래스를 Person 클래스에서 이용하기 위한 방법이 있습니다.
    // readObject를 여기의 새 버젼으로 교체하십시오.
    private void readObject(ObjectInputStream ois) 
        throws IOException, ClassNotFoundException
    {
        ObjectInputStream.GetField gf = ois.readFields();

        // 새로운 버젼을 가지고 있기를 바라며....
        fullName = (String) gf.get("fullName", null);
        if (fullName == null)
        {
            // 이런. 구버젼입니다. fullName을 만들어냅니다.
            String lastName = (String) gf.get("lastName", null);
            String firstName = (String) gf.get("firstName", null);
            fullName = lastName + ", " + firstName;
        }
        age = gf.get("age", 0);
        password = (String) gf.get("password", null);
        password = crypt(password, -3);
        System.out.println("Password decrypted to " + password);
    }
우선, GetField 객체는 ObjectInputStream 객체의 readField 메소드를 호출함으로써 접근할 수 있습니다. 그리고 스트림의 "fullName" 클래스의 fullName 필드에 읽어들이도록 시도합니다. get 메소드의 두번째 인자는 fullName 필드가 없을 때 들어갈 기본값을 나타냅니다. 만일 기본값이 반환된다면, 메소드는 스트림이 구버젼의 포맷을 가지고 있다고 가정합니다. 그리고 lastName과 firstName 필드를 읽어들이기 위해 get 메소드를 이용합니다. 이것을 각각의 구버젼과 새버젼의 Person.ser에서 실행해 보십시오. 이제는 양쪽 다 동작할 것입니다.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
결론
마지막 Person 클래스는 간단히 Serializable 인터페이스를 구현한 원래의 클래스보다 더 많은 일을 합니다. 마지막 버젼은 serialVersionUID를 지정하고, 패스워드 필드의 상태를 관리하며, 읽고자 하는 필드의 이름을 지정합니다. 이러한 추가적인 작업은 더 큰 이득을 얻도록 합니다. 이런 기술들을 이용하면, 영속성 객체는 기존에 가지고 있던 능력들을 잃지 않고 새로운 능력을 추가하면서 구버젼에 대해서도 호환성을 갖게 됩니다.
자바 직렬화에 대해 더 많이 알고 싶다면, 다음 링크의 자바 직렬화 명세를 살펴보십시오.
http://java.sun.com/products/jdk/1.2/docs/guide/serialization/spec/serialTOC.doc.html
 

설정

트랙백

댓글