닷넷 직렬화 방법 - 이진 직렬화(Binary Serialization) 사용 패턴
많은 분들이 이미 직렬화에 대한 기능에 대해서는 아시겠지만. 그래도 한번 먼저 설명을 하고 나서 정형적인 구현 패턴에 대해서 이야기 해보도록 하겠습니다.
1. 이진 직렬화(Binary Serialization)
우선, 간단하게 직렬화가 가능한 클래스 예부터 들어보겠습니다.
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;
namespace ConsoleApplication1
{
[Serializable()]
class TestA
{
public int A
{
get { return this.a; }
set { this.a = value; }
}
int a;
public override string ToString()
{
return a.ToString();
}
}
class Program
{
static void Main(string[] args)
{
TestA test = new TestA();
test.A = 5;
BinaryFormatter bf = new BinaryFormatter();
MemoryStream ms = new MemoryStream();
bf.Serialize(ms, test);
ms.Position = 0;
TestA copyTest = bf.Deserialize(ms) as TestA;
Console.WriteLine(copyTest);
}
}
}
너무 쉽지요. ^^ 그냥 간단하게 "Serializable" 특성만 클래스에 명시해 주면 됩니다. 그다음은 BinaryFormatter 클래스가 해당 클래스의 Object Graph에 따라 모든 값들을 알아서 직렬화 해줍니다.
"Serializable" 특성을 지정해 준다는 것이 중요하지요. 만약, 다음과 같이 Serializable 특성을 포함하지 않은 클래스를 정의해서 TestA 클래스의 필드로 추가해 주면 이번에는 오류가 발생하고 맙니다.
class Unserializable
{
int b;
}
[Serializable()]
class TestA
{
public int A
{
get { return this.a; }
set { this.a = value; }
}
int a;
Unserializable u;
;
}
Unhandled Exception: System.Runtime.Serialization.SerializationException:
Type 'ConsoleApplication1.Unserializable' in Assembly
'ConsoleApplication1, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' is not marked as serializable.
이런 경우, 오류를 벗어나기 위해서는 2가지 방법을 쓸 수 있습니다.
- Unserializable 클래스에 Serializable 특성을 부여
- TestA::u 멤버 변수에 "NonSerialized" 특성을 부여
현실적으로 볼 때, NonSerialized 특성을 적용해 볼 수 있는 가장 좋은 예는 "핸들"에 해당하는 값입니다.
2. ISerializable 인터페이스
최근 마이크로소프트의 응용 프로그래밍 개발 분야의 흐름을 본다면, "선언적인 방법"과 "프로그래밍을 통한 방법"을 제공하는 것을 볼 수 있습니다. 일례로 WCF의 경우에 config에 설정할 수 있는 모든 요소들을 프로그래밍으로도 설정할 수 있도록 해주고 있지요.
사실, 벌써부터 이진 직렬화에서는 그러한 방법이 사용되고 있었습니다.
우선, "선언적인 방법"에 해당하는 것이 위에서 살펴본 "Serializable", "NonSerialized" 특성이고, "프로그래밍을 통한 방법"에 해당하는 것이 바로 이번에 살펴보는 ISerializable 인터페이스를 구현하는 것입니다.
일단, 해당 클래스에서 ISerializable 인터페이스를 구현한다고 하면, "Serializable" 특성은 반드시 줘야 하지만 기본적으로 아무런 값도 "직렬화"되지 않습니다. 이제부터는 수작업으로 ISerializable.GetObjectData 메서드를 정의하고 그 안에서 직렬화 될 데이터를 필드를 지정하고, 역직렬화 과정에 참여할 특별한 생성자를 재정의해 주어야 합니다.
위에서 예를 든 클래스에 ISerializable 인터페이스를 구현한다면 다음과 같이 바뀔 수 있습니다.
[Serializable()]
class TestA : ISerializable
{
public int A
{
get { return this.a; }
set { this.a = value; }
}
public TestA()
{
}
protected TestA(SerializationInfo info, StreamingContext context)
{
this.a = info.GetInt32("A");
}
int a;
Unserializable u;
public override string ToString()
{
return a.ToString();
}
#region ISerializable Members
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("A", this.a);
}
#endregion
}
이 외에,
IDeserializationCallback 인터페이스가 있는데, 이를 클래스에 구현해 주는 경우 BinaryFormatter는 역직렬화가 완료된 시점에 IDeserializationCallback.OnDeserialization 메서드를 호출해 주어, 클래스의 역직렬화와 관계된 작업을 마무리 할 수 있는 기회를 주게 됩니다. (OnDeserialization 메서드의 인자로 sender가 넘어오긴 하지만, 현재는 미구현 상태입니다. .NET 3.5 베타에서 테스트해 봐도 마찬가지인 것을 보면, 그다지 구현할 의지가 없어 보입니다. ^^)
자... 일단 여기까지가 기본 지식이고.
이제는, "클래스 상속"의 입장을 고려하면서 직렬화 기능을 구현하기 위한 적절한 패턴을 찾아보도록 하겠습니다.
예를 들기 위해, 위에서 정의했던 "TestA" 클래스를 상속받는 "TestB" 클래스를 작성해 보겠습니다.
[Serializable]
class TestB : TestA, ISerializable
{
public int B { get { return this.b; } set { this.b = value; } }
public TestB() {}
protected TestB(SerializationInfo info, StreamingContext context)
: base(info, context)
{
this.b = info.GetInt32("B");
}
int b;
#region ISerializable Members
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue("B", this.b);
}
#endregion
}
자, 이렇게 상속 클래스를 정의해서 컴파일 하자마자 벌써부터 패턴화해야 할 것이 나오기 시작했습니다. 왜냐하면, 컴파일러가 다음과 같은 경고를 발생하기 때문입니다.
D:\temp2\ConsoleApplication1\Program.cs(75,17): warning CS0114:
'ConsoleApplication1.TestB.GetObjectData(System.Runtime.Serialization.SerializationInfo, System.Runtime.Serialization.StreamingContext)'
hides inherited member
'ConsoleApplication1.TestA.GetObjectData(System.Runtime.Serialization.SerializationInfo, System.Runtime.Serialization.StreamingContext)'.
To make the current member override that implementation, add the override keyword.
Otherwise add the new keyword.
이를 방지하기 위해 TestA와 TestB의 GetObjectData 메서드를 다음과 같이 각각 수정해야 합니다.
TestA:
public virtual void GetObjectData(SerializationInfo info, StreamingContext context);
TestB:
public override void GetObjectData(SerializationInfo info, StreamingContext context);
여기서 끝이 아닙니다. 만약 특정 클래스를 상속받아서 처리하는 과정에서 "기반 클래스"에서 제공되는 "역직렬화"를 사용자 정의하고 싶은 경우가 발생할 수 있을 텐데, 이런 경우 위의 구조만 가지고서는 "기반 클래스"의 소스를 직접 수정하지 않고는 방법이 없습니다.
왜일까요?
가령... 이런 예를 들어볼까요? TestB.b 변수의 값이 만약 0이라면, 모든 직렬화 과정을 생략하고 싶은 상황을 가정해 보겠습니다. 이런 경우, GetObjectData의 직렬화 과정은 그런대로 다음과 같이 프로그래밍이 가능합니다.
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("B", this.b);
if (this.B != 0)
{
base.GetObjectData(info, context);
}
}
하지만. 역직렬화 시에는 어떻게 할 수 있을까요?
protected TestB(SerializationInfo info, StreamingContext context)
{
this.B = info.GetInt32("B");
if (this.B == 0)
{
return;
}
// base(info, context); 오류 발생
}
보시는 것처럼, 직렬화에 관계된 TestB 생성자는 더 이상 base 클래스의 직렬화 생성자를 연결해서 호출할 수 없음은 물론이고, 명시적으로 기반 클래스의 생성자를 코드 내에서 호출할 수도 없기 때문에, 이런 경우에는 기반 클래스의 직렬화 코드 내용을 수정하거나, 아니면 해당 코드 내용을 동일하게 복사해서 위의 주석 처리된 부분에 교체해서 넣어주어야 합니다.
사실, 동일한 내용을 복사해 주는 것은 코드 중복이 발생함으로 인해서 그다지 권장되지 않습니다. 왜냐하면, 나중에 해당 base 클래스가 구현된 라이브러리가 버전 업그레이드가 되었을 때, 만약 해당 base 클래스의 직렬화 코드에 변화가 발생했다면, 그러한 사실도 모른 체 TestB는 여전히 기존 버전의 직렬화 과정을 구현한 코드를 담고 있을 것이기 때문입니다.
자... 그렇다면 이러한 불일치를 발생시키지 않기 위해, "공통 라이브러리"를 제작하는 개발자라면 아예 라이브러리에 담겨질 클래스들의 직렬화 코드는 다음과 같은 식으로 구현을 해주는 것이 바람직하겠습니다.
기반 클래스
protected TestA(SerializationInfo info, StreamingContext context)
{
SetObjectData(info, context);
}
protected virtual void SetObjectData(SerializationInfo info, StreamingContext context)
{
// 역직렬화 코드
}
위와 같이 해주면... 해결이 되었나요? 이젠 TestB 클래스는 다음과 같이 구현해 줄 수 있습니다.
protected TestB(SerializationInfo info, StreamingContext context)
{
this.B = info.GetInt32("B");
if (this.B == 0)
{
return;
}
base.SetObjectData(info, context);
}
물론, 위와 같이 특별한 상황이 아닌 경우에는 다음과 같은 코드를 작성해 주면 됩니다.
protected TestB(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
protected override void SetObjectData(SerializationInfo info, StreamingContext context)
{
this.b = info.GetInt32("B");
base.SetObjectData(info, context);
}
정리해 보면, 2가지 원칙을 세워볼 수 있겠습니다.
1. 반드시 Serializable 클래스의 GetObjectData 메서드는 virtual로 만들어준다.
2. 반드시 Serializable 클래스의 역직렬화는 별도의 virtual SetObjectData 메서드를 만들어서 처리한다.
프로그램을 개발하다 보면, 다른 사람/업체가 만든 라이브러리를 거의 필연적으로 사용하게 됩니다. 최근 들어서는, 그런 라이브러리의 대부분이 "소스 코드"와 함께 제공되어 자유로운 확장이 가능하도록 허용하고 있습니다.
하지만, 여기서 정확히 짚고 넘어가야 할 것이 있습니다. 바이너리와 함께 제공된 "소스 코드"가 정말로 자유로운 확장을 보장해 주고 있는 것인가??? 하는 것입니다. 만약, 그 라이브러리가 향후 "절대 업데이트 하지 않는다"라는 가정이 있다면, 그 "소스 코드"는 실제로 무한한 확장 가능성을 열어두고 있는 것입니다.
반면, 앞으로도 여러분들의 제품이 계속해서 업데이트 되는 그 라이브러리를 바탕으로 빌드되는 것이라면, "소스 코드"를 수정하는 것에 대해서는 상당히 신중한 판단을 해야 합니다. 일단, 소스 코드를 수정하기 시작하게 되면, 향후 업데이트 되어 나오게 될 해당 라이브러리의 업그레이드 버전의 혜택은 포기하는 것과 다름 없기 때문입니다.
위에서 살펴본 직렬화 코드를 그러한 관점에서 바라보셔야 합니다.
여러분들이 "공통 라이브러리" 제작자라면, 반드시 위와 같은 원칙으로 기반 클래스를 제작해 주어야 합니다. 그렇지 않으면, 공통 라이브러리 사용자들은 여러분들의 소스 코드를 수정해서 영영 여러분들의 라이브러리가 업데이트 되는 혜택을 받을 수 있는 기회를 끊어 버린 것이 되거나... 아니면 위와 같은 경우가 필요한 사용자들의 "기반 클래스 소스 코드 수정 요청"이 발생하여 적절하지 않은 시점의 패치가 이뤄질 수 있습니다.
[이 토픽에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]