C#이나 .NET Framework를 이용하여 프로그램 코드를 작성할 때, 실제로 실행 시간이 오래 걸리는 코드 뿐 아니라 입출력 작업이나 여러가지 외부적인 요인 (데이터베이스, 네트워크를 통한 원격 시스탬 액세스 등)에 의하여 요즈음은 언제 끝나는지 정확한 실행 시간을 측정할 수 없는, 실행 시간에 변수가 생기는 코드를 작성하는 일이 많습니다. 이러한 프로그래밍 코드는 늘 그렇듯이 사용자 인터페이스와는 친화적이지 않은 경우가 많습니다. (잘 아시다시피 큐를 기반으로 하는 사용자 인터페이스 메시징 처리에 방해가 되기 때문입니다.)
이러한 문제를 해결하기 위해서, Windows Forms에서는 BackgroundWorkerThread를 사용할 수 있고, Timer나 직접 Thread를 이용하는 경우도 있습니다. 하지만 잘못된 관점을 가지고 프로그램을 작성할 경우 관리하기 어려운 코드가 되는 문제가 있습니다. 좀 더 간결하고 알기 쉬운 비동기 코드를 만드려는 노력이 필요한 이유가 여기에 있습니다.
이번 글에서는 어렵게 스레드를 만들고 생명 주기를 관리하려는 노력 대신, 실제 로직에 집중해서 메서드의 실행을 비동기화하고, 원격에서 통제할 수 있는 명쾌한 방법을 하나 소개해볼까 합니다.
다음과 같은 임의의 메서드가 하나 있다고 가정하겠습니다. 컨셉을 이해하기 위함이므로 메서드 본문 안의 내용은 중요하지 않음을 미리 언급해두겠습니다.
public int CalcXYZ(int x, int y, int z) {
return x * y * z;
}
.NET Framework 3.5부터 새로 추가된 LINQ는 Lambda Expression과 함께 좀 더 적극적인 Type Inference를 가능하게 하기 위하여, Action과 Func 대리자들의 가짓수가 매우 다양해졌습니다. 이전 버전의 .NET Framework에서는 위의 메서드를 포장하기 위하여 아래와 같이 대리자 형식을 따로 정의해야 했습니다.
public delegate int CalcXYZDelegate(int x, int y, int z);
...
private CalcXYZDelegate f;
...
f = this.CalcXYZ;
물론 위와 같이 대리자를 따로 정의해서 담아두어도 좋습니다. 하지만, 타이핑하고 관리해야 할 코드의 분량을 조금이라도 줄일 방법을 찾는게 좋을 것입니다. 이를 위해서, 반환 형식이 있는 메서드이므로 Func<T1, T2, T3, TResult> 대리자를 사용할 수 있을 것입니다.
private Func<int, int, int, int> f;
...
f = this.CalcXYZ;
여기서 한 가지 기억해야 할 것은, 비동기 메서드를 만들기 위해서 비동기 작업을 발행하고 관리하는 주체로 대리자를 사용한다는 점입니다. 따라서, 대리자 인스턴스를 하나로 고정하기 위하여, private 멤버 변수로 선언하는 것이 중요합니다. 즉, 위의 코드를 전개하면 다음과 같은 클래스 선언이 나타난다고 할 수 있습니다.
public class MyClass {
public MyClass() : base() { f = this.CalcXYZ; }
private Func<int, int, int, int> f;
public int CalcXYZ(int x, int y, int z) { return x * y * z; }
}
이제 위의 MyClass 정의에 Begin/End 짝 메서드를 정의해보겠습니다. Begin/End 메서드에 대해서 설명하면, BeginXXXX 메서드는 메서드의 호출을 시도하고 비동기 작업을 예약하는 역할을 하며, EndXXXX 메서드는 메서드 호출이 완료되었을 때 결과를 회수하는 역할을 합니다. 이 때, BeginXXXX 메서드에 전달하는 콜백 함수의 호출을 활용하거나, BeginXXXX 메서드가 반환하는 IAsyncResult 및 그 안에 들어있는 스레드 이벤트 동기화 객체가 실행 흐름을 관리하는데 매우 중요한 역할을 담당하게 됩니다.
위의 정의에 내용을 좀 더 추가하면 다음과 같습니다. 굵게 강조 표시한 부분을 확인해 주세요.
public class MyClass {
public MyClass() : base() { f = this.CalcXYZ; }
private Func<int, int, int, int> f;
public int CalcXYZ(int x, int y, int z) { return x * y * z; }
public IAsyncResult BeginCalcXYZ(int x, int y, int z, AsyncCallback callback, object result) {
return f.BeginInvoke(x, y, z, callback, result);
}
public int EndCalcXYZ(IAsyncResult asyncResult) { return f.EndInvoke(asyncResult); }
}
규칙이 있음을 알 수 있습니다. 다시 살펴보면,
public <반환 형식> <메서드 이름>(<인수들>);
위의 메서드가 원형이라고 하면,
public IAsyncResult Begin<메서드 이름>(<인수들>, AsyncCallback callback, object result) { ... }
public <반환 형식> End<메서드 이름>(IAsyncResult asyncResult) { ... }
이와 같이 함수 호출의 시작과 끝을 관리하는 메서드로 분할하여 정의하고 있으며, 여기에 대한 실질적인 구현은 .NET Framework가 제공하는 대리자를 이용하여 처리하므로 어렵지 않게 비동기 메커니즘을 구현할 수 있게 되는 것입니다. 여기서 조금 더 응용하면, 만약 반환 형식이 void라면 그대로 적어도 무방하며 이 때에는 Func 대리자 대신 Action 대리자로 바꿔주면 됩니다. 인수들이 없다면? 당연히 생략하고 비동기 호출에서 필수적인 인자들만 맞추어 서술하면 끝납니다.
여기서 한 가지 더 이야기할 것은, 최근에 Windows 8 출시와 함께 WinRT가 등장하면서 급부상한 C# 5.0의 async 키워드와 TPL (Task Parallel Library)와의 상관 관계입니다. async 키워드를 직접 사용하지는 않는다 할지라도, WinRT를 자연스럽게 지원할 수 있는 방법이 바로 TPL에 대한 지원을 추가하는 것입니다. 바꾸어 말하면, async 키워드는 컴파일러의 서비스이므로, TPL에 대한 지원만 정확히 하고 있다면, C# 5.0 컴파일러가 TPL 기반의 비동기 코드를 손쉽게 작성할 수 있도록 도움을 준다는 의미도 됩니다.
위의 내용까지 구현했다면, TPL로 가는 길은 바로 코앞에 있는 셈입니다.
using System.Threading.Tasks;
...
public Task<int> CalcXYZAsync(int x, int y, int z) {
return Task<int>.Factory.FromAsync(this.BeginCalcXYZ, this.EndCalcXYZ, x, y, z, this);
}
위와 같은 코드만 작성하면 Begin/End 패턴을 즉시 TPL 기반의 비동기 패턴으로 변환할 수 있습니다. 여기에서도 규칙을 찾을 수 있는데, 다음과 같습니다.
return Task<<반환 형식>>.Factory.FromAsync(Begin<메서드 이름>, End<메서드 이름>, <인수들>, <this 또는 null>);
여기서 Task 클래스에 제네릭 인자를 지정하는 이유는, 메서드의 반환 형식에 대한 형식 안정성을 지키기 위함입니다. 만약 반환 형식이 void라면 다음과 같이 제네릭 인자 자체를 생략하면 됩니다.
public Task SomeMethod() {
return Task.Factory.FromAsync(Begin<메서드 이름>, End<메서드 이름>, <this 또는 null>);
}
위의 내용까지 포함한 수정된 MyClass 코드는 다음과 같습니다.
public class MyClass {
public MyClass() : base() { f = this.CalcXYZ; }
private Func<int, int, int, int> f;
public int CalcXYZ(int x, int y, int z) { return x * y * z; }
public IAsyncResult BeginCalcXYZ(int x, int y, int z, AsyncCallback callback, object result) {
return f.BeginInvoke(x, y, z, callback, result);
}
public int EndCalcXYZ(IAsyncResult asyncResult) { return f.EndInvoke(asyncResult); }
public Task<int> CalcXYZAsync(int x, int y, int z) {
return Task<int>.Factory.FromAsync(this.BeginCalcXYZ, this.EndCalcXYZ, x, y, z, this);
}
}
여기서 한 가지 염두에 두어야 할 것은, 기본 메서드의 매개 변수 갯수가 3개보다 많을 경우, FromAsync 메서드에서는 3개까지만 매개 변수를 전달할 수 있도록 선언이 되어 있기 때문에, IAsyncResult 형식의 객체를 받아서 Wrapping하거나, 템플릿 인자를 확장하거나, 중요도가 낮은 매개 변수들을 Dictionary 또는 별도의 POCO (Plain-old-CLR-object) 형식으로 정의하여 매개 변수로 사용하도록 하는 부수적인 절차가 필요합니다.
또한, 메서드 오버로딩을 비동기 메서드에도 적용하고자 하는 경우, 오버로딩에서 가장 핵심이 되는 메서드에 대해서 위의 패턴을 적용하고, 나머지는 핵심 메서드를 호출하는 과정을 동일하게 맞추어야 합니다. 예를 들어, 위의 CalcXYZ 메서드의 오버로딩으로 short 형식을 사용한다고 가정하면 다음과 같이 사용해야 함을 뜻합니다.
public class MyClass {
// ...
public int CalcXYZ(short x, short y, short z) { return CalcXYZ((int)x, (int)y, (int)z); }
public IAsyncResult BeginCalcXYZ(short x, short y, short z, AsyncCallback callback, object result) {
return BeginCalcXYZ((int)x, (int)y, (int)z, callback, result);
}
// EndCalcXYZ는 따로 정의하지 않습니다.
public Task<int> CalcXYZ(short x, short y, short z) { return CalcXYZAsync((int)x, (int)y, (int)z); }
// ...
}
이제 위와 같이 정의했으니, 호출하고 사용하는 방법을 알아보도록 하겠습니다.
비동기 메서드는 기본적으로 실행을 예약하고, 콜백 메서드를 통하여 실행 완료 통지를 받았을 때 따로 실행하도록 하는 것이 기본입니다. 하지만, 비동기로 실행하는 것과는 별개로 실행 흐름을 동기화해야 하는 경우도 있을 수 있는데, 일반적인 메서드 호출과는 다른 특별한 기능을 하나 더 이용할 수 있습니다. 바로 Time out 개념을 사용할 수 있다는 것인데, 이것은 Thread를 직접 제어할 때와는 또 다른 이점입니다.
MyClass inst = new MyClass();
IAsyncResult res = inst.BeginCalcXYZ(1, 2, 3, null, null);
res.WaitHandle.WaitOne();
if (res.IsCompleted) {
int val = inst.EndCalcXYZ(res);
Console.WriteLine(val);
} else {
throw new TimeoutException();
}
Begin/End 메서드는 위와 같이 사용합니다. 여기서 재미있는 부분은 콜백과 상태 관리 객체를 지정하지 않고 실행이 끝날 때 까지 기다리게 했다는 부분입니다. 그런데 한 가지 궁금증이 생깁니다. 이렇게 하면 비동기의 이점이 없는 것이 아닌가 하는 부분입니다.
그런데 WaitOne 메서드에 매개 변수를 하나 지정할 수 있습니다. 바로 밀리초 단위의 time out 대기 시간입니다. 이 값을 생략할 경우 자동으로 System.Threading.TimeOut.Infinite가 지정된 것과 같이 실행되며, 이 상수 필드의 값은 (-1)입니다. 즉, 실행이 끝날 때까지 이 코드를 실행하는 스레드의 실행을 동결한다는 것입니다. 이 값 대신 1000을 지정하면 1초 이내에 실행이 끝나지 않을 때 그 다음 코드로 바로 실행이 이어지는 것입니다. 여기서 IsCompleted 속성을 사용하여 실행이 정말 완료가 되었다면 결과를 회수하고, 그렇지 않으면 Timeout으로 처리할 수 있게 됩니다.
이러한 시나리오가 유용하게 쓰일 수 있는 곳은 매우 많습니다. 단순한 테스트 유닛 실행에서부터 네트워크 및 입출력 관련 처리에 이르기까지 매우 다양한데, 프로그램을 그저 응답 없음 상태로 내버려두는 것이 아니라 좀 더 주도적으로 실행 흐름을 관리할 수 있게 되는 것입니다.
Begin/End 대신 Async 메서드를 사용하는 경우, C# 5.0 이전의 언어를 사용할 경우 다음과 같이 코드를 작성할 수 있을 것입니다.
MyClass inst = new MyClass();
var res = inst.CalcXYZAsync(1,2,3);
res.Wait();
if (res.IsCompleted) {
int val = res.Result;
Console.WriteLine(val);
} else {
throw new TimeoutException();
}
그리고 C# 5.0부터는 좀 더 간결하게 아래와 같이 코드를 작성할 수 있을 것입니다.
public async void Method1() {
MyClass inst = new MyClass();
int val = await inst.CalcXYZAsync(1,2,3);
Console.WriteLine(val);
}
그런데 한 가지 남는 의문은, 전통적인 Event Driven 방식의 Windows 응용프로그램 개발 환경에서 단순히 이벤트 처리기를 사용하여 위의 코드를 직접 실행하면 프로그램이 굉장히 부자연스럽게 동작한다는 점입니다. 왜 그럴까요?
그 원인은 틀림없이 메시지를 처리하는 스레드에서 위의 코드를 실행하기 때문일 것입니다. 즉, 엄밀한 의미에서 모든 작업들은 비동기화 되어야하고, 비동기화된 스레드들의 상태를 모니터링하면서 사용자 인터페이스에 적절한 신호를 주어야 하는 셈입니다. 이렇게 본다면 GUI 프로그래밍은 매우 어려운 작업 중 하나가 될 수 있습니다. 그래서 기준이 하나 있어야 하는데, 바로 I/O Bound Operation에 한하여 위와 같이 작업을 비동기로 분할하여 상태 보고를 할 수 있도록 구성하는 방법을 적절히 사용해야 하는 것입니다. 그러한 방면으로 잘 포장된 것이 바로 BackgroundWorker 컴포넌트입니다.
어떤 방법을 사용하는가에 관계없이, 프로그래밍을 할 때에는 항상 실행 시간과 흐름 관리에 최선을 다해야 합니다. 디자인 패턴 이외에도, 시간이 오래 걸릴 수 있는 불확정성에 기대는 작업을 다룰 때 이러한 세밀한 노력이 얼마나 들어갔는지에 따라서 얼마나 완성도 높은 코드를 생산할 수 있는가가 결정될 것입니다.
'Tech > 닷넷 일반' 카테고리의 다른 글
Practical Code Writing Tips in C# (0) | 2013.10.16 |
---|---|
리소스 DLL을 EXE에 임베드하여 배포하기 (2) | 2013.10.02 |
NuGet 패키지 관리자를 이용한 TDD 초기 환경 구축하기 (0) | 2013.07.16 |
ActiveX를 사용하는 닷넷 Windows Service 만들기 (1) (0) | 2010.09.19 |