Foundations

서비스에 손쉽게 트랜잭션 적용하기

Juval Lowy

코드는 MSDN 코드 갤러리에서 다운로드할 수 있습니다.
온라인으로 코드 찾아보기

목차

상태 관리와 트랜잭션
호출별 트랜잭션 서비스
인스턴스 관리와 트랜잭션
세션 기반 서비스와 VRM
장기 실행 트랜잭션 서비스
트랜잭션 동작
IPC 바인딩에 컨텍스트 추가
InProcFactory와 트랜잭션

오류 복구는 프로그래밍의 가장 근본적인 문제라고 할 수 있습니다. 오류가 발생한 후에는 응용 프로그램이 자체적으로 오류 발생 전의 상태로 복원되어야 하기 때문입니다. 동시에 실행될 수도 있는 여러 개의 하위 작업으로 구성된 작업을 수행하는 응용 프로그램이 있다고 가정해봅니다. 그리고 이러한 개별 하위 작업은 다른 하위 작업과는 관계없이 실패하거나 성공할 수 있습니다. 이 경우 하위 작업 중 하나에서 오류가 발생하면 시스템이 일관되지 않은 상태에 빠지게 됩니다.

한쪽 계좌에 대변 기입하고 다른 쪽 계좌에 차변 기입하는 방식으로 두 계좌 사이에 자금을 이체하는 금융 응용 프로그램을 예로 들어 보겠습니다. 한쪽 계좌에 성공적으로 차변 기입했지만 다른 쪽 계좌에 대변 기입하지 못할 경우 자금이 어느 쪽에도 존재할 수 없게 되어 일관되지 않은 상태가 발생하고, 반대로 성공적으로 대변 기입했지만 차변 기입에 실패할 경우에도 마찬가지로 일관되지 않은 상태가 발생합니다. 이러한 경우에 시스템을 원래 상태로 복원하여 오류를 복구하는 것은 항상 응용 프로그램에서 이루어져야 합니다.

그런데 몇 가지 이유로 실제로 구현하기는 말만큼 쉽지 않습니다. 첫째, 대규모 작업의 경우 부분적 성공 또는 실패의 순열 수가 금방 감당할 수 없을 정도로 많아집니다. 따라서 개발자가 그 유형과 처리 방법에 대해 잘 알고 있는 손쉬운 복구 사례만 다루는 경우가 많아 개발 및 유지 관리 비용이 많이 들고 제대로 작동하지 않는 경우도 많은 취약한 코드가 만들어집니다. 둘째, 복합 작업이 상위 작업의 일부일 수 있고 개발자의 제어권을 벗어난 범위에서 오류가 발생하면 코드가 완벽하게 실행되더라도 실행을 취소해야 합니다. 즉, 작업의 관리 및 구조에 있어서 관련 항목들이 밀접하게 결합되어 있음을 의미합니다. 마지막으로, 특정 사용자가 작업을 롤백하여 오류를 복구할 경우 다른 사용자가 암시적으로 오류 상태에 빠지게 되므로 사용자의 작업을 시스템과 상호 작용해야 하는 다른 사용자로부터 격리해야 합니다.

이렇듯 강력한 오류 복구 코드를 직접 작성하는 것은 거의 불가능합니다. 그리고 이는 이미 잘 알려진 사실입니다. 1960년대에 소프트웨어가 비즈니스에 사용되기 시작한 이래 더 나은 복구 관리 방법의 필요성은 끊임없이 대두되었습니다. 이 문제는 트랜잭션이라는 훌륭한 솔루션으로 해결할 수 있습니다. 트랜잭션은 개별 작업이 실패할 경우 전체 작업이 하나의 원자성 작업으로서 실패하는 작업의 집합입니다. 트랜잭션을 사용하는 경우 복구할 항목이 없으므로 복구 논리를 작성할 필요가 없습니다. 모든 작업이 성공하여 복구할 것이 없거나 모두 실패하여 시스템 상태에 아무런 영향도 주지 않았기 때문에 복구할 것이 없거나 둘 중 하나입니다.

트랜잭션을 사용할 때는 트랜잭션 리소스 관리자를 사용해야 합니다. 리소스 관리자는 트랜잭션이 중단된 경우 트랜잭션에서 변경된 내용을 모두 롤백하거나 트랜잭션이 커밋된 경우 변경 내용을 유지할 수 있습니다. 또한 트랜잭션을 진행하는 동안 리소스 관리자가 해당 트랜잭션을 제외한 다른 연결 주체가 리소스에 액세스하고 롤백 가능한 변경 내용을 표시하지 못하도록 차단하므로 격리 기능이 제공됩니다. 바꿔 말하면 트랜잭션이 리소스 관리자 이외의 리소스에 액세스할 경우 트랜잭션이 중단되면 해당 리소스에 대한 변경 내용이 롤백되지 않아 복구가 필요하게 되므로 그러한 리소스에 액세스해서는 안 된다는 의미이기도 합니다.

일반적으로 리소스 관리자는 데이터베이스, 메시지 큐 등과 같은 장기 실행 리소스입니다. 그러나 2005년 5월호 MSDN Magazine 기사 "Can't Commit? 트랜잭션을 공통 형식으로 가져오는 .NET의 일시적 리소스 관리자"에서 Transactional<T>이라는 범용 VRM(일시적 리소스 관리자)를 구현하는 방법을 소개한 바 있습니다.

public class Transactional<T> : ...
{
   public Transactional(T value);
   public Transactional();
   public T Value
   {get;set;}
   /* Conversion operators to and from T */
}

Transactional<T>에 정수나 문자열 같은 직렬화 가능한 형식 매개 변수를 지정하면 해당 형식이 앰비언트 트랜잭션에 자동으로 열거되고, 트랜잭션 결과에 따라 변경 내용을 커밋 또는 롤백하고, 현재 변경 내용을 다른 트랜잭션으로부터 격리하는 완전한 일시적 리소스 관리자로 바뀝니다.

그림 1은 Transactional<T>의 사용 예를 보여 줍니다. 범위가 완료되지 않았기 때문에 트랜잭션이 중단되고 number 및 city 값이 트랜잭션 실행 전 상태로 되돌려집니다.

그림 1 Transactional<T> 사용

Transactional<int> number = new Transactional<int>(3);
Transactional<string> city = new Transactional<string>("New York, ");

city.Value += "NY"; //Can use with or without transactions
using(TransactionScope scope = new TransactionScope())
{
   city.Value = "London, ";
   city.Value += "UK";
   number.Value = 4;
   number.Value++;
}
Debug.Assert(number == 3); //Conversion operators at work
Debug.Assert(city == "New York, NY");

해당 기사에서는 Transactional<T> 외에 System.Collections.Generic에 포함된 모든 컬렉션(예: TransactionalDictionary<K,T>)의 트랜잭션 버전과 트랜잭션 배열도 제공했습니다. 이러한 컬렉션은 트랜잭션 버전과 비 트랜잭션 버전이 모두 있는 다형적 특성을 지니며 두 버전이 동일한 방식으로 사용됩니다.

상태 관리와 트랜잭션

트랜잭션 프로그래밍의 궁극적인 목적은 시스템을 일관된 상태로 유지하는 것입니다. WCF(Windows Communication Foundation)의 경우 시스템 상태는 서비스 인스턴스의 메모리 내 상태와 리소스 관리자에 따라 결정됩니다. 리소스 관리자는 트랜잭션 결과에 따라 자체 상태를 자동으로 관리하지만 메모리 내 개체 또는 정적 변수의 경우에는 자동으로 관리되지 않습니다.

이러한 상태 관리 문제에 대한 해법으로서 서비스를 상태 인식 서비스로 개발하여 상태를 사전에 관리할 수 있습니다. 트랜잭션 간에 서비스는 리소스 관리자에 상태를 저장해야 합니다. 각 트랜잭션이 시작될 때 서비스는 리소스에서 상태를 검색하여 트랜잭션에 리소스를 열거해야 합니다. 그리고 트랜잭션이 끝나면 서비스는 리소스 관리자에 상태를 다시 저장합니다. 이 기법은 훌륭한 상태 자동 복구 기능을 제공합니다. 즉, 인스턴스 상태의 변경 내용이 트랜잭션의 일부로서 모두 커밋되거나 롤백됩니다.

트랜잭션이 커밋되면 다음에 서비스가 상태를 가져올 때 트랜잭션 실행 후 상태를 가져오게 됩니다. 반면 트랜잭션이 중단된 경우에는 다음에 트랜잭션 실행 전 상태를 가져옵니다. 어떤 경우든 서비스는 새 트랜잭션에서 액세스할 수 있도록 준비된 상태를 일관되게 제공합니다.

트랜잭션 서비스 작성 시에 해결해야 할 문제는 두 가지가 더 남아 있습니다. 첫째는 어떻게 서비스가 트랜잭션의 시작과 끝을 인식하여 상태를 가져오거나 저장할 수 있도록 할 것인지의 문제입니다. 서비스는 범위가 여러 서비스와 컴퓨터까지 이르는 대규모 트랜잭션의 일부일 수도 있습니다. 그리고 호출 간에 언제라도 트랜잭션이 끝날 수 있습니다. 이 경우 누가 서비스를 호출하여 상태를 저장하도록 알려야 할까요? 두 번째로, 격리와 관련한 문제가 있습니다. 여러 클라이언트가 서로 다른 트랜잭션에서 같은 서비스를 동시에 호출할 수 있습니다. 이 경우 한쪽 트랜잭션 변경 내용을 다른 트랜잭션에서 변경된 상태로부터 어떻게 격리해야 할까요? 원래 서비스 상태를 변경한 트랜잭션이 중단되고 변경 내용이 롤백된 경우 다른 트랜잭션이 서비스 상태에 액세스하여 그 값에 따라 작업을 수행하면 해당 트랜잭션이 일관되지 않은 상태가 됩니다.

이 두 문제는 메서드 경계를 트랜잭션 경계와 같게 하면 해결할 수 있습니다. 서비스는 모든 메서드 호출 시작 시에 리소스 관리자에서 상태를 읽고 각 메서드 호출이 끝날 때 리소스 관리자에 상태를 저장해야 합니다. 이렇게 하면 메서드 호출 간에 트랜잭션이 끝나면 서비스의 상태가 트랜잭션 결과에 따라 그대로 유지되거나 롤백됩니다. 또한 메서드를 호출할 때마다 리소스 관리자에서 상태를 읽고 저장하면 리소스 관리자가 동시 트랜잭션 사이에서 상태에 대한 액세스를 격리할 수 있기 때문에 격리 문제도 해결됩니다.

서비스가 메서드 경계와 트랜잭션 경계를 동일시하므로 서비스 인스턴스도 메서드 호출이 끝날 때마다 트랜잭션의 결과를 커밋할지 아니면 롤백할지를 결정해야 합니다. 서비스의 관점에서 보면 트랜잭션은 메서드가 결과를 반환할 때 완료됩니다. WCF에서 이러한 동작은 OperationBehavior 특성의 TransactionAutoComplete 속성을 통해 자동으로 이루어집니다. 이 속성이 true로 설정된 경우, 작업에 처리되지 않은 예외가 없으면 WCF는 자동으로 트랜잭션을 커밋하도록 응답하고, 처리되지 않은 예외가 있으면 트랜잭션을 중단하도록 응답합니다. 다음에서 보듯이 TransactionAutoComplete는 true로 기본 설정되므로 모든 트랜잭션 메서드가 자동 완료 기능을 기본적으로 사용하게 됩니다.

//These two definitions are equivalent:
[OperationBehavior(TransactionScopeRequired = true,
                   TransactionAutoComplete = true)]   
public void MyMethod(...)
{...}

[OperationBehavior(TransactionScopeRequired = true)]   
public void MyMethod(...)
{...}

WCF 트랜잭션 프로그래밍에 대한 자세한 내용은 2007년 5월호에서 필자의 Foundations 칼럼 "WCF의 트랜잭션 전달"을 참조하십시오.

호출별 트랜잭션 서비스

호출별 서비스를 사용할 경우 호출 결과가 반환되면 인스턴스가 소멸됩니다. 따라서 호출 간에 상태를 저장하는 데 사용되는 리소스 관리자가 인스턴스의 범위 밖에 있어야 합니다. 또한 클라이언트와 서비스가 리소스 관리자에서 인스턴스를 만들고 제거하는 역할을 담당할 작업에 대해 서로 동의해야 합니다.

서비스 형식이 동일한 여러 인스턴스가 같은 리소스 관리자에 액세스할 수도 있으므로 서비스 인스턴스가 리소스 관리자에서 상태를 찾아 상태에 대해 바인딩할 수 있도록 하는 매개 변수가 모든 작업에 포함되어야 합니다. 필자는 이러한 매개 변수를 인스턴스 ID라고 합니다. 또한 클라이언트는 저장소에서 인스턴스 상태를 제거하는 전용 작업을 호출해야 합니다. 상태 인식 트랜잭션 개체와 호출별 개체의 동작 요구 사항은 같습니다. 즉, 두 가지 모두 메서드 경계에서 상태를 검색하고 저장합니다. 호출별 서비스의 경우 모든 리소스 관리자를 서비스 상태를 저장하는 데 사용할 수 있습니다. 데이터베이스를 사용할 수도 있고 그림 2와 같이 VRM을 사용할 수도 있습니다.

그림 2 VRM을 사용한 호출별 서비스

[ServiceContract]
interface IMyCounter
{
   [OperationContract]
   [TransactionFlow(TransactionFlowOption.Allowed)]
   void Increment(string instanceId);

   [OperationContract]
   [TransactionFlow(TransactionFlowOption.Allowed)]
   void RemoveCounter(string instanceId);
}
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerCall)]
class MyService : IMyCounter
{
   static TransactionalDictionary<string,int> m_StateStore = 
                               new TransactionalDictionary<string,int>();

   [OperationBehavior(TransactionScopeRequired = true)]
   public void Increment(string instanceId)
   {
    if(m_StateStore.ContainsKey(instanceId) == false)
      {
         m_StateStore[instanceId] = 0;
      }
      m_StateStore[instanceId]++;
      Trace.WriteLine(m_StateStore[instanceId]); 
   }
   [OperationBehavior(TransactionScopeRequired = true)]
   public void RemoveCounter(string instanceId)
   {
    if(m_StateStore.ContainsKey(instanceId))
      {
         m_StateStore.Remove(instanceId);
      }
   }
}

//Client side:
MyCounterClient proxy = new MyCounterClient();

using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment("MyInstance");
   scope.Complete();
}    

//This transaction will abort since the scope is not completed 
using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment("MyInstance");
} 

using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment("MyInstance");
   proxy.RemoveCounter("MyInstance");
   scope.Complete();
}

proxy.Close();

//Traces:
1
2
2

인스턴스 관리와 트랜잭션

WCF에서는 서비스 인스턴스가 메서드 경계와 트랜잭션 경계를 동일시하고 상태를 인식하게 되므로 모든 인스턴스 상태가 메서드 경계에서 제거됩니다. 기본적으로 트랜잭션이 완료되면 WCF는 서비스 인스턴스를 제거하여 메모리에 일관성을 저해할 수 있는 항목이 남아 있지 않도록 합니다.

모든 트랜잭션 서비스의 수명 주기는 ServiceBehavior 특성의 ReleaseServiceInstanceOnTransactionComplete 속성을 통해 제어됩니다. ReleaseServiceInstanceOnTransactionComplete가 true(기본값)로 설정되어 있는 경우 메서드가 트랜잭션 실행을 완료하면 서비스 인스턴스를 폐기하므로 인스턴스 프로그래밍 모델의 관점에서 보았을 때 사실상 모든 WCF 서비스를 호출별 서비스로 바꾸는 것과 같습니다.

이러한 투박한 방식은 WCF에서 비롯된 것이 아닙니다. MTS 이래 COM+과 엔터프라이즈 서비스를 통해 Microsoft 플랫폼에 구현되는 모든 분산 트랜잭션 프로그래밍 모델에서는 트랜잭션 개체와 호출별 개체를 동일시했습니다. 이러한 기술의 설계자는 트랜잭션과 관련하여 개체의 상태를 제대로 관리할 것이라고 신뢰하지 않아 난해하고 직관적이지 않은 프로그래밍 모델을 사용한 것입니다. 가장 큰 단점은 대부분의 개발자가 익숙한 일반 Microsoft .NET Framework 개체의 세션 기반 상태 저장 프로그래밍 모델을 더 선호하지만 트랜잭션을 활용하기 위해서는 쉽지 않은 호출별 프로그래밍 모델(Figure 2 참조)을 선택해야 한다는 점입니다.

개인적으로 트랜잭션을 호출별 인스턴스화와 동일시하는 것은 필요악이지만 이론적으로는 잘못된 것이라고 항상 생각해왔습니다. 호출별 인스턴스화 모드는 확장성이 필요한 경우에만 선택해야 하고 트랜잭션은 개체 인스턴스 관리 및 응용 프로그램의 확장성과는 완전히 별개로 구분해야 합니다.

응용 프로그램을 확장해야 하는 경우 호출별 모드를 선택하고 트랜잭션을 사용하면 문제 없이 작동합니다. 그러나 확장성이 필요없다면(대부분의 응용 프로그램에서 일반적인 경우) 서비스를 세션 기반의 상태 저장 트랜잭션 서비스로 구현해야 합니다. 이 칼럼의 나머지 부분에서는 일반 서비스에 트랜잭션을 사용하면서 세션 기반 프로그래밍 모델을 사용하고 유지하는 문제에 대한 필자의 해결 방법을 소개합니다.

세션 기반 서비스와 VRM

WCF에서는 ReleaseServiceInstanceOnTransactionComplete를 false로 설정하여 트랜잭션 서비스에서 세션 의미 체계를 유지할 수 있습니다. 이 경우 WCF는 트랜잭션과 관련하여 서비스 인스턴스의 상태를 서비스 관리자가 전적으로 관리하도록 하고 관여하지 않습니다. 모든 메서드 호출이 각각 다른 트랜잭션에 있을 수 있고 트랜잭션이 같은 세션의 두 메서드 호출 간에 종료될 수도 있으므로 세션별 서비스의 경우에는 여전히 메서드 경계를 트랜잭션 경계와 동일시해야 합니다. 호출별 서비스와 마찬가지로(또는 이 칼럼에서 다루지 않는 다른 고급 WCF 기능을 사용하여) 이러한 상태를 수동으로 관리할 수도 있지만 그림 3과 같이 서비스 멤버에 VRM을 사용해도 됩니다.

그림 3 세션별 트랜잭션 서비스를 통해 VRM 사용

[ServiceBehavior(ReleaseServiceInstanceOnTransactionComplete = false)]
class MyService : IMyContract
{
   Transactional<string> m_Text = new Transactional<string>("Some initial value");
   TransactionalArray<int> m_Numbers = new TransactionalArray<int>(3);

   [OperationBehavior(TransactionScopeRequired = true)]
   public void MyMethod()
   {
      m_Text.Value = "This value will roll back if the transaction aborts";

      //These will roll back if the transaction aborts
      m_Numbers[0] = 11;
      m_Numbers[1] = 22;
      m_Numbers[2] = 33;
   }
}

VRM을 사용하면 상태 저장 프로그래밍 모델을 구현할 수 있습니다. 즉, 서비스 인스턴스가 트랜잭션과는 관계없는 것처럼 간단하게 상태에 액세스합니다. 즉, 상태의 변경 내용이 트랜잭션과 함께 모두 커밋되거나 롤백됩니다. 그러나 그림 3은 전문적인 프로그래밍 모델로서 나름의 단점이 있습니다. VRM, 정확한 멤버 정의, 항상 모든 트랜잭션이 필요하도록 모든 작업을 구성하고 완료 시에 인스턴스가 해제되지 않도록 해야 한다는 원칙 등 상당한 기본 지식이 필요합니다.

장기 실행 트랜잭션 서비스

2008년 10월호의 이 칼럼("장기 실행 서비스의 상태 관리")에서 WCF의 장기 실행 서비스 지원에 대해 설명한 바 있습니다. 장기 실행 서비스는 모든 작업 시에 구성된 저장소에서 상태를 검색한 다음 다시 저장소에 상태를 저장합니다. 상태 저장소는 트랜잭션 리소스 관리자일 수도 있고 아닐 수도 있습니다.

물론 서비스가 트랜잭션인 경우에는 트랜잭션 저장소만 사용하고 각 작업의 트랜잭션에 해당 저장소를 열거해야 합니다. 이렇게 하면 트랜잭션이 중단되었을 때 상태 저장소가 트랜잭션 실행 전 상태로 롤백됩니다. 그러나 WCF는 서비스가 인스턴스를 상태 저장소에 전파하도록 설계되었는지를 알 수 없고 저장소가 SQL Server 2005 또는 SQL Server 2008 같은 트랜잭션 리소스 관리자인 경우에도 기본적으로 트랜잭션에 저장소를 열거하지 않습니다. 트랜잭션을 전파하고 기본 저장소를 열거하도록 WCF에 지시하려면 DurableService 특성의 SaveStateInOperationTransaction 속성을 true로 설정하면 됩니다.

[Serializable]
[DurableService(SaveStateInOperationTransaction = true)]
class MyService: IMyContract
{...}

SaveStateInOperationTransaction은 false로 기본 설정되므로 상태 저장소가 트랜잭션에 관여하지 않습니다. 트랜잭션 서비스의 경우에만 SaveStateInOperationTransaction을 true로 설정했을 대 효과가 있기 때문에 그러한 경우 WCF는 서비스의 모든 작업에 대해 TransactionScopeRequired가 true로 설정되어 있거나 필수 트랜잭션 흐름이 있는 것으로 간주합니다. TransactionScopeRequired가 true로 설정된 채로 작업이 구성되어 있으면 작업의 앰비언트 트랜잭션이 저장소를 열거하는 데 사용됩니다.

트랜잭션 동작

DurableService 특성의 경우 장기 실행(durable)이라는 단어가 꼭 장기 실행 동작만을 지칭하는 것이 아니기 때문에 잘못된 이름이라고도 할 수 있습니다. 여기서 장기 실행(durable)은 WCF가 모든 작업 시에 자동으로 구성된 저장소에서 서비스 상태를 역직렬화한 다음 다시 직렬화함을 의미합니다. 마찬가지로, 사전 설정된 추상 공급자 클래스에서 파생된 공급자로도 충분하므로 지속성 공급자 동작이 꼭 지속성인 것은 아닙니다.

실질적으로 장기 실행 서비스 인프라는 트랜잭션과 관련하여 서비스 상태를 관리하는 데 활용 가능한 직렬화 인프라로, 내부적으로 일시적 리소스 관리자를 사용하며 서비스 인스턴스는 서비스 상태 관리에 관여하지 않습니다. 이는 WCF의 트랜잭션 프로그래밍 모델을 간소화할 뿐만 아니라 단순한 개체와 일반 서비스에 뛰어난 트랜잭션 프로그래밍 모델을 활용할 수 있도록 해줍니다.

첫 단계로 TransactionalMemoryProviderFactory와 TransactionalInstanceProviderFactory라는 두 가지 메모리 내 트랜잭션 공급자 팩토리를 정의해야 합니다. TransactionalMemoryProviderFactory는 정적 TransactionalDictionary<ID,T>를 사용하여 서비스 인스턴스를 저장합니다. 이 사전은 모든 클라이언트와 세션에서 공유됩니다. 호스트가 실행되는 한 TransactionalMemoryProviderFactory는 클라이언트가 서비스에 연결하고 연결을 끊도록 허용합니다. TransactionalMemoryProviderFactory를 사용하는 경우 DurableOperation 특성의 CompletesInstance 속성을 사용하여 저장소에서 인스턴스 상태를 제거하는 완료 작업을 지정해야 합니다.

반면 TransactionalInstanceProviderFactory는 각 세션마다 전용 Transactional<T> 인스턴스를 지정합니다. 따라서 세션이 닫히면 서비스 상태가 가비지로 수집되므로 완료 작업이 필요 없습니다.

다음으로 그림 4와 같이 TransactionalBehavior 특성을 정의합니다. TransactionalBehavior는 이러한 구성을 수행하는 서비스 동작 특성입니다. TransactionalBehavior는 먼저 서비스 설명에 SaveStateInOperationTransaction가 true로 설정된 DurableService 특성을 주입합니다. 둘째로, AutoCompleteInstance 속성 값에 따라 지속성 동작에 대해 TransactionalMemoryProviderFactory 또는 TransactionalInstanceProviderFactory의 사용을 추가합니다. AutoCompleteInstance가 true(기본값)로 설정되어 있으면 TransactionalInstanceProviderFactory가 사용됩니다. 마지막으로 TransactionRequiredAllOperations 속성이 true(기본값)로 설정되어 있으면 TransactionalBehavior가 모든 서비스 작업 동작에 대해 TransactionScopeRequired를 true로 설정하여 모든 작업에 앰비언트 트랜잭션을 제공합니다. TransactionRequiredAllOperations를 명시적으로 false로 설정하면 서비스 개발자가 트랜잭션 방식으로 처리할 작업을 선택할 수 있습니다.

그림 4 TransactionalBehavior 특성

[AttributeUsage(AttributeTargets.Class)]
public class TransactionalBehaviorAttribute : Attribute,IServiceBehavior
{
   public bool TransactionRequiredAllOperations
   {get;set;}

   public bool AutoCompleteInstance
   {get;set;}

   public TransactionalBehaviorAttribute()
   {
      TransactionRequiredAllOperations = true;
      AutoCompleteInstance = true;   
   }
   void IServiceBehavior.Validate(ServiceDescription description,
                                  ServiceHostBase host) 
   {
      DurableServiceAttribute durable = new DurableServiceAttribute();
      durable.SaveStateInOperationTransaction = true;
      description.Behaviors.Add(durable);

      PersistenceProviderFactory factory;
      if(AutoCompleteInstance)
      {
         factory = new TransactionalInstanceProviderFactory();
      }
      else
      {
         factory = new TransactionalMemoryProviderFactory();
      }

      PersistenceProviderBehavior persistenceBehavior = 
                                new PersistenceProviderBehavior(factory);
      description.Behaviors.Add(persistenceBehavior);

      if(TransactionRequiredAllOperations)
      {
         foreach(ServiceEndpoint endpoint in description.Endpoints)
         {
            foreach(OperationDescription operation in endpoint.Contract.Operations)
            {
               OperationBehaviorAttribute operationBehavior =  
                  operation.Behaviors.Find<OperationBehaviorAttribute>();
               operationBehavior.TransactionScopeRequired = true;
            }
         }
      }
   }
   ...
} 

TransactionalBehavior 특성을 기본값으로 사용하면 클라이언트가 인스턴스 ID를 관리하거나 인스턴스 ID와 상호 작용할 필요가 없습니다. 클라이언트는 그림 5와 같이 컨텍스트 바인딩 중 하나에 프록시를 사용하여 바인딩을 통해 인스턴스 ID를 관리하면 됩니다. 서비스는 정수를 멤버 변수로 사용합니다. 물론 장기 실행 동작 때문에 인스턴스는 호출별 서비스처럼 메서드 경계에서 비활성화되지만 프로그래밍 모델은 일반 .NET 개체와 같습니다.

그림 5 TransactionalBehavior 특성 사용

[ServiceContract]
interface IMyCounter
{
   [OperationContract]
   [TransactionFlow(TransactionFlowOption.Allowed)]
   void Increment();
}

[Serializable]
[TransactionalBehavior]
class MyService : IMyCounter
{
   int m_Counter = 0;

   public void Increment()
   {
      m_Counter++;
      Trace.WriteLine(m_Counter);
   }
}
//Client side:
MyCounterClient proxy = new MyCounterClient();

using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
   scope.Complete();
}    

//This transaction will abort since the scope is not completed 
using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
} 

using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
   scope.Complete();
}

proxy.Close();

//Traces:
1
2
2

IPC 바인딩에 컨텍스트 추가

TransactionalBehavior에는 컨텍스트 프로토콜을 지원하는 바인딩이 필요합니다. WCF는 기본 바인딩 WS(웹 서비스) 바인딩, TCP 바인딩을 지원하지만 IPC(프로세스 간 통신, 파이프라고도 함) 바인딩은 지원하지 않습니다. IPC 바인딩을 지원한다면 IPC를 통한 TransactionalBehavior를 사용함으로써 세부 호출에 IPC를 활용할 수 있으므로 유용할 것입니다. 이러한 이유로 다음과 같이 NetNamedPipeContextBinding 클래스를 정의했습니다.

public class NetNamedPipeContextBinding : NetNamedPipeBinding
{
   /* Same constructors as NetNamedPipeBinding */

   public ProtectionLevel ContextProtectionLevel
   {get;set;}
}

NetNamedPipeContextBinding은 기본 클래스와 동일하게 사용됩니다. 이 바인딩을 다른 기본 바인딩과 마찬가지로 프로그래밍 방식으로 사용할 수 있지만 응용 프로그램 .config 파일에 사용자 지정 바인딩을 사용하려면 WCF에 사용자 지정 바인딩이 정의되어 있는 위치를 알려야 합니다. 이는 응용 프로그램별로 구현할 수도 있지만 다음과 같이 machine.config의 NetNamedPipeContextBindingCollectionElement 도우미 클래스를 참조하여 컴퓨터의 모든 응용 프로그램에 적용하는 편이 쉽습니다.

<!--In machine.config-->
<bindingExtensions>
   ...
   <add name = "netNamedPipeContextBinding" 
        type = "ServiceModelEx.                NetNamedPipeContextBindingCollectionElement,
                ServiceModelEx"
   />
</bindingExtensions>

NetNamedPipeContextBinding은 워크플로 응용 프로그램에도 사용할 수 있습니다.

그림 6에는 NetNamedPipeContextBinding 구현 및 지원 클래스 중 일부가 나와 있습니다. 전체 구현은 이달의 코드 다운로드에 포함되어 있습니다. NetNamedPipeContextBinding의 생성자는 모두 실제 생성 작업을 NetNamedPipeBinding의 기본 생성자에 위임하고 컨텍스트 보호 수준을 기본값인 ProtectionLevel.EncryptAndSign으로 설정하는 초기화 작업만 수행합니다.

그림 6 NetNamedPipeContextBinding 구현

public class NetNamedPipeContextBinding : NetNamedPipeBinding
{
   internal const string SectionName = "netNamedPipeContextBinding";

   public ProtectionLevel ContextProtectionLevel
   {get;set;}

   public NetNamedPipeContextBinding()
   {
      ContextProtectionLevel = ProtectionLevel.EncryptAndSign;
   }
   public NetNamedPipeContextBinding(NetNamedPipeSecurityMode securityMode): 
                                                      base(securityMode)
   {
      ContextProtectionLevel = ProtectionLevel.EncryptAndSign;
   }
   public NetNamedPipeContextBinding(string configurationName)
   {
      ContextProtectionLevel = ProtectionLevel.EncryptAndSign;
      ApplyConfiguration(configurationName);
   }

   public override BindingElementCollection CreateBindingElements()
   {
      BindingElement element = new ContextBindingElement(ContextProtectionLevel,
                            ContextExchangeMechanism.ContextSoapHeader);

      BindingElementCollection elements = base.CreateBindingElements();
      elements.Insert(0,element);

      return elements;
   }

   ... //code excerpted for space
}

모든 바인딩 클래스의 핵심은 CreateBindingElements 메서드입니다. NetNamedPipeContextBinding은 바인딩 요소로 이루어진 기본 바인딩 클래스에 액세스하여 ContextBindingElement를 추가합니다. 이 요소를 컬렉션에 삽입하면 컨텍스트 프로토콜에 대한 지원이 추가됩니다.

나머지 구현은 관리 구성을 가능하도록 하기 위한 단순한 기록에 불과합니다. 바인딩 섹션 구성 이름을 받는 ApplyConfiguration 메서드가 생성자에 의해 호출됩니다. ApplyConfiguration은 ConfigurationManager 클래스를 사용하여 .config 파일에서 netNamedPipeContextBinding 섹션을 구문 분석하고 거기서 다시 NetNamedPipeContextBindingElement를 구분 문석합니다. 이 바인딩 요소는 ApplyConfiguration 메서드를 호출하여 바인딩 인스턴스를 구성하는 데 사용됩니다.

NetNamedPipeContextBindingElement의 생성자는 구성 속성의 기본 클래스 Properties 컬렉션에 컨텍스트 보호 수준에 대한 단일 속성을 추가합니다. ApplyConfiguration을 호출하는 NetNamedPipeContextBinding.ApplyConfiguration의 결과로서 호출되는 OnApplyConfiguration에서 메서드는 먼저 기본 요소를 구성한 다음 구성된 수준에 따라 컨텍스트 보호 수준을 설정합니다.

NetNamedPipeContextBindingCollectionElement type은 NetNamedPipeContextBinding을 NetNamedPipeContextBindingElement에 바인딩하는 데 사용됩니다. 이렇게 하면 NetNamedPipeContextBindingCollectionElement를 바인딩 확장으로 추가할 때 구성 관리자가 인스턴스화하고 바인딩 매개 변수로 제공할 형식을 알 수 있습니다.

InProcFactory와 트랜잭션

TransactionalBehavior 특성은 익숙한 .NET 프로그래밍 모델에 영향을 주지 않으면서 응용 프로그램의 거의 모든 클래스를 트랜잭션으로 처리할 수 있도록 해줍니다. WCF는 매우 세부적인 수준으로 사용하도록 설계되지 않은 것이 단점입니다. 즉, 여러 호스트를 만들고 열고 닫아야 하며 서비스 및 클라이언트 섹션 수가 많아 응용 프로그램 .config 파일을 관리하기가 어렵게 됩니다. 이 문제를 해결하기 위해 필자는 WCF 프로그래밍이라는 책의 제2판에서 WCF를 통해 서비스 클래스를 인스턴스화할 수 있는 InProcFactory라는 클래스를 정의했습니다.

public static class InProcFactory
{
   public static I CreateInstance<S,I>() where I : class
                                         where S : I;
   public static void CloseProxy<I>(I instance) where I : class;
   //More members
}

InProcFactory를 사용하는 경우 호스트를 명시적으로 관리하거나 클라이언트 또는 서비스 .config 파일을 사용하지 않고도 클래스 수준에서 WCF를 활용할 수 있습니다. 모든 클래스 수준에서 TransactionalBehavior 프로그래밍 모델에 액세스할 수 있도록 하기 위해 InProcFactory 클래스는 트랜잭션 흐름이 활성화된 NetNamedPipeContextBinding을 사용합니다. InProcFactory는 그림 5의 정의를 사용하여 그림 7과 같은 프로그래밍 모델을 구현합니다.

그림 7 TransactionalBehavior와 InProcFactory 결합

IMyCounter proxy = InProcFactory.CreateInstance<MyService,IMyCounter>();

using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
   scope.Complete();
}    

//This transaction will abort since the scope is not completed 
using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
} 
using(TransactionScope scope = new TransactionScope())
{
   proxy.Increment();
   scope.Complete();
}

InProcFactory.CloseProxy(proxy);

//Traces:
Counter = 1
Counter = 2
Counter = 2

그림 7의 프로그래밍 모델은 일반 C# 클래스와 동일합니다. 단, 소유권 오버헤드는 없으면서 코드에서 트랜잭션의 이점을 완벽하게 활용할 수 있습니다. 이는 메모리 자체는 물론 모든 개체까지 트랜잭션 방식으로 구현되는 차세대 아키텍처를 향한 첫 걸음이라고 생각합니다.

그림 8에는 알아보기 쉽게 일부 코드를 제거한 InProcFactory 구현이 나와 있습니다. InProcFactory의 정적 생성자는 응용 프로그램 도메인마다 한 번씩 호출되어 각각에 GUID를 사용한 새 고유 기본 주소를 할당합니다. 따라서 같은 컴퓨터의 전체 응용 프로그램 도메인과 프로세스에 InProcFactory를 여러 번 사용할 수 있습니다.

그림 8 InProcFactory 클래스

public static class InProcFactory
{
   struct HostRecord
   {
      public HostRecord(ServiceHost host,string address)
      {
         Host = host;
         Address = new EndpointAddress(address);
      }
      public readonly ServiceHost Host;
      public readonly EndpointAddress Address;
   }
   static readonly Uri BaseAddress = new Uri("net.pipe://localhost/" + 
                                             Guid.NewGuid().ToString());
   static readonly Binding Binding;
   static Dictionary<Type,HostRecord> m_Hosts = new Dictionary<Type,HostRecord>();

   static InProcFactory()
   {
      NetNamedPipeBinding binding = new NetNamedPipeContextBinding();
      binding.TransactionFlow = true;
      Binding = binding;
      AppDomain.CurrentDomain.ProcessExit += delegate
                                             {
                         foreach(HostRecord hostRecord in m_Hosts.Values)
                                                {
                                                 hostRecord.Host.Close();
                                                }
                                             };
   }


public static I CreateInstance<S,I>() where I : class
                                         where S : I
   {
      HostRecord hostRecord = GetHostRecord<S,I>();
      return ChannelFactory<I>.CreateChannel(Binding,hostRecord.Address);
   }
   static HostRecord GetHostRecord<S,I>() where I : class
                                          where S : I
   {
      HostRecord hostRecord;
      if(m_Hosts.ContainsKey(typeof(S)))
      {
         hostRecord = m_Hosts[typeof(S)];
      }
      else
      {
         ServiceHost host = new ServiceHost(typeof(S),BaseAddress);
         string address = BaseAddress.ToString() + Guid.NewGuid().ToString();
         hostRecord = new HostRecord(host,address);
         m_Hosts.Add(typeof(S),hostRecord);
         host.AddServiceEndpoint(typeof(I),Binding,address);
         host.Open();
      }
      return hostRecord;
   }
   public static void CloseProxy<I>(I instance) where I : class
   {
      ICommunicationObject proxy = instance as ICommunicationObject;
      Debug.Assert(proxy != null);
      proxy.Close();
   }
}

InProcFactory는 서비스 형식을 특정 호스트 인스턴스에 매핑하는 사전을 내부적으로 관리합니다. 특정 형식의 인스턴스를 만들기 위해 CreateInstance가 호출되면 GetHostRecord라는 도우미 메서드를 사용하여 사전을 조회합니다. 사전에 해당 서비스 형식이 없는 경우 이 도우미 메서드는 그 형식에 대해 호스트 인스턴스를 만들고 GUID를 고유 파이프 이름으로 사용하여 해당 호스트에 끝점을 추가합니다. 그러면 CreateInstance가 호스트 레코드에서 끝점 주소를 가져와 ChannelFactory<T>를 사용하여 프록시를 만듭니다.

클래스를 처음 사용할 때 호출되는 정적 생성자에서 InProcFactory는 프로세스 종료 이벤트를 구독하여 프로세스 종료 시에 모든 호스트를 닫습니다. 마지막으로 InProcFactory는 클라이언트가 프록시를 닫을 수 있도록 프록시에서 ICommunicationObject를 쿼리하여 닫는 CloseProxy 메서드를 제공합니다. 트랜잭션 메모리의 이점을 활용하는 방법은 유용한 정보 보충 기사 "트랜잭션 메모리란?"을 참조하십시오.

트랜잭션 메모리란?

동시 코드를 작성할 때 발생하는 모든 문제를 해결할 것으로 많은 이들로부터 기대를 모으고 있는 새로운 공유 데이터 관리 기술인 트랜잭션 메모리에 대해 들어보셨을 것입니다. 또한 트랜잭션 메모리가 자체 기능 외에 더 큰 유용성을 제공한다거나 단순한 조사 도구에 불과하다는 이야기도 들어보셨을 겁니다. 사실 트랜잭션 메모리는 극단적인 이 두 가지 평가의 중간 정도로 평가하는 것이 적절할 것입니다.

트랜잭션 메모리는 개별 잠금을 관리하는 번거로움을 덜어 줍니다. 대신 잘 정의된 순차적 블록으로 프로그램을 구성할 수 있습니다. 이러한 블록은 데이터베이스 관련 용어로 작업 또는 트랜잭션이라고 하는 단위로 구성됩니다. 그러면 기본 런타임 시스템, 컴파일러, 하드웨어 또는 그 조합에서 격리 또는 일관성이 보장되도록 할 수 있습니다.

일반적으로 기본 트랜잭션 메모리 시스템은 세부적인 수준에서 낙관적 동시성 제어를 제공합니다. 트랜잭션 메모리 시스템은 매번 리소스를 잠그는 대신 경합이 없는 것으로 가정합니다. 또한 이러한 가정이 잘못된 것으로 감지되면 트랜잭션에서 변경되어 아직 커밋되지 않은 변경 내용을 롤백합니다. 그런 다음 구현 방식에 따라 트랜잭션 메모리 시스템이 경합 없이 완료할 수 있을 때까지 코드 블록을 재실행할 수도 있습니다. 개발자가 창조적인 양보 논리를 지정하거나 코드로 작성하고 직접 메커니즘을 다시 시도하지 않아도 이 시스템은 자동으로 경합을 감지하고 관리할 수 있습니다. 낙관적이고 세부적인 동시성 제어와 경합 관리가 구현되어 있고 구체적인 잠금을 지정하고 관리할 필요가 없는 경우 동시성의 이점을 활용하는 구성 요소를 사용하여 순차적으로 문제를 해결할 수 있습니다.

트랜잭션 메모리는 기존 잠금 메커니즘으로는 쉽게 수행할 수 없는 결합 기능을 제공합니다. 일밙거으로 여러 작업 또는 개체를 하나로 결합하려면 여러 작업 또는 개체를 하나의 잠금으로 래핑하여 잠금의 세분화 수준을 높여야 합니다. 트랜잭션 메모리는 코드를 대신하여 세분화된 잠금을 자동으로 관리하고 교착 상태를 방지하여 확장성을 저해하거나 교착 상태를 발생시키지 않으면서 결합 기능을 제공합니다.

현재 대규모로 구현된 상업용 트랜잭션 메모리 솔루션은 없습니다. 라이브러리, 언어 확장 또는 컴파일러 지시문을 사용한 실험적 소프트웨어 솔루션이 대학에서 발표되거나 웹에 게시되어 있을 뿐입니다. 고도의 첨단 동시 환경에는 제한적인 트랜잭션 메모리를 제공하는 하드웨어도 있지만 이러한 하드웨어를 활용하는 이러한 하드웨어를 활용하는 소프트웨어에서 명시적 사용이 드러나지 않습니다. 여러 연구 기관에서 트랜잭션 메모리에 대한 연구가 활발하게 진행 중이므로 몇 년 내에 보다 실용적인 제품이 등장하리라 기대됩니다.

원자성(완전히 실행되거나 전혀 실행되지 않는 실행 특성)은 물론, 트랜잭션 프로그램에서 얻을 수 있는 사후 관리 편의성, 품질 및 기타 이점을 제공하기 위해 동시 트랜잭션 기술과 함께 사용할 수 있는 일시적 리소스 관리자를 만드는 방법을 설명하는 칼럼도 이번 호에 포함되어 있습니다. 트랜잭션 메모리는 비교적 간단한 기본 런타임 소프트웨어 또는 하드웨어 구성 요소를 사용하여 모든 임의 데이터 형식에 대해 이와 유사한 기능을 제공하고, 직접 리소스 관리자를 만들지 않고도 원자성을 구현할 수 있도록 할뿐만 아니라 확장성, 격리 및 결합 기능을 중점적으로 제공합니다. 트랜잭션 메모리가 상용화되면 프로그래머가 단순한 프로그래밍 모델과 같은 일시적 리소스 관리자의 이점을 활용할 수 있는 것은 물론, 트랜잭션 메모리 관리자를 통해 확장성도 높일 수 있게 될 것입니다.

— Dana Groff, 선임 프로그램 관리자, Microsoft 병렬 컴퓨팅 플랫폼 팀

질문이나 의견이 있으면 mmnet30@microsoft.com으로 보내시기 바랍니다.

Juval Lowy 는 IDesign에서 WCF 교육 및 WCF 아키텍처 컨설팅을 담당하는 소프트웨어 설계자이며 최근 저서로는 Programming WCF Services, 제2판(O'Reilly, 2008)이 있습니다. 그는 실리콘밸리의 Microsoft 지역 책임자로도 활동하고 있습니다. 문의 사항이 있으면 www.idesign.net으로 문의하십시오.