Windows Forms에서 효율적으로 무거운 작업을 처리하는 방법

Windows Forms는 사실 이미 주 스레드가 메시지 루프 처리를 위하여 이미 루프를 돌고 있는 상태이다. 그렇기 때문에 사실 별 생각없이 부르는 함수나 정의해 놓은 루프 구문은 주 스레드의 메시지 루프 처리를 방해하게 된다. 물론, 필요에 의해서 정의해놓은 함수나 루프 구문이 방해의 요소라고 하는 것은 너무한 일이지만 생각보다 이렇게 정의해놓은 함수나 루프 구문이 우리가 생각하는 것보다는 훨씬 무겁다는 점이다.

그렇다면 Windows Forms에서는 원하는 일을 마음껏 한다는게 불가능한 일인가? 그렇지 않다. 지금 이 글에서 소개하려는 비동기 패턴을 이용한다면 전혀 걱정할 필요가 없다.

1단계: 병목 현상을 일으키는 반복 구문을 찾아보기

병목 현상을 일으키는 반복 구문을 선정하는 것이 중요하다. 지금 소개하려는 패턴도 결국은 무한정 사용할 수 있는 리소스는 아니기 때문에 이 단계에서 어떤 루프를 선택하느냐가 중요하다. 그렇지만 생각보다 루프를 선정하는 일은 간단하다.

쉬운 설명을 위하여 아래의 “말도 안되는” 쓰레기 코드를 잠깐 살펴보기로 한다.



for (int i = 0; i < Int32.MaxValue; i++)
{
 StringBuilder oBuffer = new StringBuilder();


 for (int j = 0; j < 16; j++)
 {
  Random oRandom = new Random();
  oBuffer.Append(((char)oRandom.Next(‘a’, ‘z’)).ToString());
 }


 this.Text = oBuffer.ToString();
}


이 코드는 보이는 그대로 21억회 이상의 루프를 돌며 매번 StringBuilder 객체를 만들고 여기에 16개의 문자를 찍어서 창의 제목을 바꾸는 일을 한다. 터무니없는 코드이지만 메시지 루프를 방해하기에 이정도면 충분하다. 우리가 이 글에서 시험해 볼 코드 역시 바로 이 코드이다.

2단계: 반복 구문을 Thread Pool 비동기 패턴으로 변환하기

2단계 제목에 쓰여있듯이 이 글에서 살펴보려는 그 해결책의 키워드는 바로 Thread Pool이다. Thread Pool은 시스템의 Thread Pool을 이용해도 좋고 직접 만든 Thread Pool을 이용해도 좋을 것이다. 하지만 여기서는 .NET Framework가 제공하는 Thread Pool을 써보기로 한다.

위의 코드를 비동기 패턴으로 바꾸면서 중요한 몇 가지가 있다.

1. Thread Pool의 비동기 패턴은 “예약”의 개념이다. – Thread Pool은 콜백 개체들을 큐에 쌓아놓고 큐에서 꺼내어 처리하는 방식이다. 물론, 한 번에 하나씩 꺼내어 처리하는 것이 아니라 몇 개의 예약된 스레드를 이용하여 분할 처리를 한다. 따라서 큐에서 없어진 작업은 그걸로 끝이다. 같은 작업을 또 시키려면 어떻게 해야 할까? 그렇다. 반복적인 예약이 그 답이다. 예약을 하기 위하여 다시 같은 함수를 호출하기 때문에 흡사 “재귀 호출”처럼 보일 수 있지만 실제로는 “재귀 호출”이 아니다. 왜냐면 예약이라는 작업 자체는 단지 큐에 콜백 객체를 더하고 나오는 것 뿐이기 때문이다.

2. 비동기는 어쨌든 다중 스레드인데 Windows Forms하고는 궁합이 안맞잖아? – 하지만 .NET Framework 2.0의 무명 대리자와 System.Action<T> 및 System.Predicate<T>, Form.Invoke 메서드를 이용하면 생각하는 것 만큼 궁합이 나쁘지 않다. 왜 그런지는 3단계의 실제 코드에서 살펴보기로 하자.

3. 다중 스레드에 대한 처리가 필요하지 않을까? – 병목 구간을 치환할 곳이 많아지고, 이렇게 치환된 곳이 동시에 실행될 필요가 있거나 그럴 확률이 높다면 필요할 수 있겠다. 하지만 비동기로 치환하였다고 할지라도 “이벤트”를 사용하여 비동기 작업 전체가 완료되었다는 것을 통지할 수 있기 때문에 주 스레드가 결과를 수신할 수 있도록 이벤트를 노출시킨다면, “이벤트 지향 프로그래밍”의 원리를 살릴 수도 있을 것이다.

3단계: 실전! 병목 구간을 비동기 패턴으로 바꾸기



[NonSerialized()]
private readonly WaitCallback HardWorkDelegate = new WaitCallback(this.HardWorkCallback);

private int m_nCount = 0;

private void HardWorkCallback(object oArgument)
{
 if (this.m_nCount.Equals(Int32.MaxValue – 1))
  return;


 StringBuilder oBuffer = new StringBuilder();


 for (int i = 0; i < 16; i++)
 {
  Random oRandom = new Random();
  oBuffer.Append(((char)oRandom.Next(‘a’, ‘z’)).ToString());
 }


 this.Invoke(new Action<string>(delegate(string strText)
 { this.Text = strText ?? String.Empty; }), oBuffer.ToString());


 ThreadPool.QueueUserWorkItem(this.HardWorkDelegate);
}


위의 루프와 비교했을 때 바뀐 점이 무엇이 있을까? 기본적으로 하는 일은 같다. 하지만 메서드의 시작 부분에 조건절을 두어 조건을 만족하였을 경우 함수를 나가도록 만든 점이 다르다. 그리고 조건에 만족하지 않는 경우에는 나머지 작업을 실행하되, ThreadPool 클래스의 QueueUserWorkItem 메서드를 호출하여 작업을 다시 예약한다. 이렇게 만듦으로서 루프가 아닌 비동기 패턴으로 완벽하게 바꿀 수 있다.

그러나 문제가 있다. Windows Forms는 다중 스레드에 의한 액세스가 취약하고 실제로도 이것을 검사하여 예외를 발생시키는 루틴까지 있다. 그렇다면 방법이 없는가? 물론 있다. 무명 메서드와 Action<T>, Predicate<T> 대리자, Invoke 메서드가 그 답이다.

System.Action<T> 대리자는 T 형식의 인수를 받는 대리자 틀이다. T에 올 수 있는 형식에는 제약이 없기 때문에 편리한 것을 집어넣으면 되고 이 점을 이용하여 창의 제목을 바꾸는 대리자를 Action<T>에 맞게 바꾼 것이 위의 예이다. 대리자 안에 서술되는 익명 함수는 결과적으로 해당 클래스의 일반 멤버로 편성되기 때문에 함수 하나를 새로 정의한다는 기분으로 this 키워드를 쓸 수 있는 것이다. 만약, 올바르게 실행되었는가를 판정하는 부분이 필요하다면 System.Predicate<T> 대리자를 이용하면 된다. System.Action<T> 대리자와 같지만 Boolean 반환 값이 있다는 점이 다르다. Predicate 대리자로 반환한 값은 Invoke 메서드의 반환 객체로 전달받을 수 있다.

4단계: 테스트와 생각해 볼 문제

위의 코드를 테스트해보면 아주 현란하게 바뀌는 창 제목을 볼 수 있을 것이다. 하지만 기왕 처리하는 거 좀 더 완벽하게 하고 싶은데 그런 생각으로 보면 걱정되는게 하나 있다. 바로 CPU 점유율이다. 이렇게 빨리 바뀌는거면 CPU 점유율이 너무 높이 치솟는것은 아닐까 걱정이 된다. 그렇다면 스레드의 우선 순위를 낮출 방법을 생각해 볼 수 있다. 무식한 방법이지만 Thread.Sleep을 이용하여 스레드의 우선 순위를 떨어뜨려가며 CPU 점유율을 낮출 수 있다. 하지만 더 좋은 방법이 얼마든지 있으므로 충분히 연구해봐야 할 문제이다.

댓글 남기기