본문

테크
멀티 스레드 프로그래밍 #2 가장 쉽게 설명한 Active Object 패턴

작성일 2023년 08월 23일
main

가장 쉽게 설명한 Active Object 패턴

지난 편에 이어서 멀티 스레드 프로그래밍의 두 번째 주제를 시작하겠습니다. 이번에 살펴볼 것은 Active Object 패턴인데요, 바로 설명하지 않고 필요한 기술을 하나씩 미리 살펴보고 최종적으로 Active Object를 설명하도록 하겠습니다.

이 글은 최대한 쉽게 설명하여 핵심에 집중하려고 합니다. 따라서 좀 더 깊게 공부하시고 싶은 분들은 POSA( Pattern-Oriented Software Architecture) 등의 서적이나 강의 자료를 참고하시기 바랍니다

스레드 관련 프로세스 감추기

파일에 여러 가지 로그를 남겨야 하는 상황을 가정해 보겠습니다. 로그를 남겨달라는 메시지만 LogWriter 객체에 알려주고, 로그를 어떻게 저장하는지에 대해서는 사용하는 입장에서는 중요하지 않습니다. 따라서 관심사를 분리하는 것이 좋습니다. 이런 경우 외부 객체는 스레드나 큐 등의 알고리즘에 대해서 모르더라도 문제가 없도록 캡슐화를 하는 것이 좋습니다.

지난 편에서 살펴본 Guarded Suspension 패턴에 스레드를 합쳐 놓은 형태로 LogWriter를 작성하였습니다. 이는 자주 사용되는 패턴입니다. 크게 설명할 필요도 없이 단순한 사용의 예입니다.

      publicclassLogWriter{privatereadonlystring _filePath;privatereadonly BlockingCollection _messageQueue = new BlockingCollection();privatereadonly Thread _logThread;publicLogWriter(string filePath){_filePath = filePath;_logThread = new Thread(WriteLog);_logThread.Start();}publicvoidInfo(string message){WriteMessage($"INFO: {message}");}publicvoidError(string message){WriteMessage($"ERROR: {message}");}privatevoidWriteMessage(string message){_messageQueue.Add($"{DateTime.Now}: {message}");}privatevoidWriteLog(){foreach (var message in _messageQueue.GetConsumingEnumerable()){using (var writer = new StreamWriter(_filePath, true)){writer.WriteLine(message);}}}}
      

GetConsumingEnumerable() 메소드는 큐에 데이터가 들어올 때마다 블록킹이 풀리는 형태로 동작합니다. 큐가 비어있을 때는 스레드를 블록해서 멈추고 대기 상태가 되며, 더 이상 자료가 없는 상태가 아니기 때문에 반복문이 중단되지 않습니다.

BlockingCollection 대신 지난 편에서 보았던 SuspensionQueue를 사용하셔도 됩니다.

Future 패턴

이번 글의 예제에서는 게임 서버 시나리오를 구현할 예정입니다. 그 중에서 로그인 과정에 대해서 생각해보겠습니다.

데이터베이스는 상대적으로 처리 속도가 느린 편입니다. 따라서 데이터베이스에 아이디와 암호를 요청하고 기다리다 보면, 게임 로직 처리에 지연이 발생하며, 결국 서비스에 큰 영향을 줄 수 있습니다. 이런 경우 별도의 스레드에게 느린 처리를 비동기적으로 요청하여 해결할 수 있습니다. 하지만, 비동기로 요청한 처리의 결과를 가져오는 방법이 필요합니다. 이때 사용할 수 있는 것이 바로 Future 패턴입니다.

C#에서 Task 클래스는 결과가 사용 가능해질 때까지 호출 스레드를 차단하는 Result라는 속성을 제공합니다. 아래의 예제에서는 비동기로 계산을 수행하고 미래 결과를 나타내는 Task를 반환하는 CalculateAsync() 메서드가 있습니다. Main() 메서드에서 CalculateAsync()를 호출하고 반환된 작업을 future 변수에 할당합니다. 그런 다음 다른 작업을 수행하고 마지막으로 future 작업의 Result 속성을 사용하여 계산 결과를 가져옵니다.

      classProgram{staticvoidMain(){Task future = CalculateAsync();Console.WriteLine("Doing some other work...");int result = future.Result;Console.WriteLine($"Calculation result: {result}");}staticasync Task CalculateAsync(){await Task.Delay(3000);return42; // 계산된 결과 반환}}
      
future
  • Main() 메서드에서 CalculateAsync() 메서드를 호출합니다. 이 메서드는 async 키워드를 사용하여 비동기로 선언되었습니다.
  • CalculateAsync() 메서드 내에서 await Task.Delay(3000)을 호출하여 3초 동안 대기합니다. 이 때 await 키워드를 사용하면, CalculateAsync() 메서드의 실행을 일시 중지하고 제어권을 호출자인 Main() 메서드에게 반환합니다.
  • Main() 메서드로 제어권이 넘어가면서, "Doing some other work..."를 출력합니다.
  • Main() 메서드에서 future.Result를 호출합니다. 이 속성은 Task의 결과를 가져오는데, 비동기 작업이 완료될 때까지 현재 스레드를 차단합니다.
  • 지정된 3초가 지나면 CalculateAsync() 메서드가 계산된 결과 42를 반환합니다. 이 값은 Task 객체의 Result 속성에 저장됩니다.
  • Main() 메서드에서 future.Result를 사용하여 계산된 결과를 가져옵니다. 이후 "Calculation result: 42"를 출력합니다.

다음 예제는 두 개의 비동기 실행 결과를 합쳐야 하는 경우를 보여줍니다. 이 글에서 필요한 상황은 아니지만 자주 사용되는 형태이기 때문에 소개하였습니다.

코드를 살펴보면 계산을 수행하고 미래 결과를 나타내는 Task int를 반환하는 CalculateAsync1 및 CalculateAsync2라는 두 개의 메서드가 있습니다. Main 메서드에서 두 메서드를 호출하고 반환된 작업을 task1 및 task2 변수에 할당합니다. 그런 다음 Task.WhenAll 메서드를 사용하여 두 작업이 완료될 때까지 기다리고 결과를 배열로 검색합니다. 마지막으로 결과를 더하고 총계를 출력합니다.

      classProgram{staticasync Task Main(){Task task1 = CalculateAsync1();Task task2 = CalculateAsync2();// 두 작업이 완료될 때까지 기다림int[] results = await Task.WhenAll(task1, task2);int total = results[0] + results[1];Console.WriteLine($"Total result: {total}");}staticasync Task CalculateAsync1(){await Task.Delay(3000);return42; // 계산된 결과 반환}staticasync Task CalculateAsync2(){await Task.Delay(5000);return58; // 계산된 결과 반환}}
      

Future 패턴을 사용하는 주요 이유는 다음과 같습니다:

  • 비동기 프로그래밍의 간결성: Future 패턴을 사용하면 비동기 코드를 마치 동기 코드처럼 읽고 작성할 수 있습니다.  async 와  await  키워드를 사용하면 복잡한 콜백이나 이벤트 핸들러 없이도 비동기 작업의 결과를 쉽게 처리할 수 있습니다.
  • 응답성 향상: 특히 UI 기반 응용 프로그램에서 Future 패턴을 사용하면 UI 스레드가 차단되지 않아 응용 프로그램의 응답성이 향상됩니다. 사용자는 작업이 백그라운드에서 실행되는 동안에도 UI와 상호 작용할 수 있습니다.
  • 리소스 최적화: 비동기 작업을 시작하면 스레드를 차단하지 않고 다른 작업을 계속 수행할 수 있습니다. 이로 인해 리소스 사용이 최적화되며, 특히 I/O 바운드 작업에서 이점이 있습니다.
  • 병렬 처리: 여러 비동기 작업을 동시에 시작하고, 모든 작업이 완료될 때까지 기다린 후 결과를 집계할 수 있습니다. 이를 통해 작업의 병렬 처리와 성능 향상을 달성할 수 있습니다.
  • 예외 처리의 간결성:  await 를 사용하면 비동기 작업에서 발생한 예외를 동기 코드에서와 같은 방식으로 처리할 수 있습니다. 이로 인해 코드의 가독성과 유지 관리성이 향상됩니다.
  • 확장성: Future 패턴은 확장성 있는 애플리케이션을 구축하는 데 도움을 줍니다. 서버 애플리케이션에서는 동시에 여러 요청을 처리할 수 있으므로, 더 많은 사용자 요청을 빠르게 처리할 수 있습니다.
  • 코드의 일관성: Future 패턴을 사용하면 동기 코드와 비동기 코드 사이의 차이를 최소화할 수 있습니다. 이로 인해 코드의 일관성이 유지되며, 개발자는 비동기 로직을 더 쉽게 이해하고 디버깅할 수 있습니다.

요약하면, C#에서 Future 패턴을 사용하면 비동기 프로그래밍을 더 쉽고 효과적으로 수행할 수 있으며, 애플리케이션의 성능과 응답성을 향상시킬 수 있습니다.

Command 패턴

Command 패턴에 대해 살펴보겠습니다. 이번에 다룰 내용은 Command 패턴의 전체적인 내용이 아니라, 다형성을 이용한 예제 코드에 중점을 둘 것입니다.

command

게임 서버 로직을 구현하는 과정에서 로그인할 경우와 사용자가 아이템을 획득했을 때, 데이터베이스에 접근해서 정보를 가져오거나 저장하는 동작을 처리하려고 합니다. 이때 데이터베이스를 조작하는 인터페이스를 통일하려고 하는데, 게임 로직을 처리하는 입장에서는 데이터베이스 조작의 내용을 구체적으로 알 필요가 없기 때문입니다.

이런 경우에 Command 패턴을 사용합니다. 요청하는 쪽에서는 구체적인 동작에 대해서는 관심을 가질 필요 없을 경우에 Command 패턴을 사용합니다. 호출 방법을 인터페이스를 통해서 통일해서 동일하게 취급하여 다양한 작업을 동일한 방법으로 요청할 수 있도록 합니다.

아래 코드는 예제를 위해서 만들어진 것으로 구체적인 동작은 하지 않는 의사코드입니다. Future 패턴을 이용해서 비동기적으로 결과를 가져와 사용할 수 있도록 하였습니다.

         publicinterfaceICommand{Task Execute();}publicclassLoginCommand : ICommand{privatestring _username;privatestring _password;publicLoginCommand(string username, string password){_username = username;_password = password;}publicasync Task Execute(){// TODO: 로그인 처리returntrue;}}publicclassSaveItemCommand : ICommand{privatestring _username;privatestring _item;publicSaveItemCommand(string username, string item){_username = username;_item = item;}publicasync Task Execute(){// TODO: 아이템 저장 처리returntrue;}}
         

이번 예제에서는 간단하게 필요한 구현으로 마무리했지만, Command 패턴은 인터페이스를 통일하는 것 뿐만 아니라 몇 가지 추가적인 이점이 있습니다. 아래는 Command 패턴을 사용할 때 얻을 수 있는 이점들입니다.

  • 분리 및 캡슐화: Command 패턴을 사용하면 요청을 수행하는 객체와 요청을 발생시키는 객체를 분리할 수 있습니다. 이를 통해 요청을 수행하는 로직을 캡슐화하여 다양한 요청을 동일한 방식으로 처리할 수 있습니다.
  • 재사용 및 조합: Command 객체를 재사용하여 다양한 작업을 수행할 수 있습니다. 또한 여러 Command 객체를 조합하여 복잡한 작업을 수행할 수 있습니다.
  • 지연 실행: Command 패턴을 사용하면 요청을 즉시 실행하지 않고 나중에 실행할 수 있습니다. 이를 통해 지연 실행, 예약 실행 등의 기능을 구현할 수 있습니다.
  • 취소 및 재실행: Command 패턴을 사용하면 실행한 요청을 취소하거나 재실행할 수 있습니다. 이를 통해 Undo/Redo 기능을 구현할 수 있습니다.
  • 로그 및 기록: Command 패턴을 사용하면 실행한 요청을 로그로 기록하거나 저장할 수 있습니다. 이를 통해 시스템의 상태를 복원하거나 문제를 진단할 수 있습니다.
  • 병렬 및 비동기 처리: Command 패턴을 사용하면 요청을 병렬로 처리하거나 비동기로 처리할 수 있습니다. 이를 통해 시스템의 성능을 향상시킬 수 있습니다.
  • 확장성: Command 패턴을 사용하면 새로운 요청을 쉽게 추가할 수 있습니다. 이를 통해 시스템의 확장성을 향상시킬 수 있습니다.
  • 테스트 용이성: Command 패턴을 사용하면 요청을 독립적으로 테스트할 수 있습니다. 이를 통해 시스템의 테스트 용이성을 향상시킬 수 있습니다.

이러한 이유로 Command 패턴은 요청을 수행하는 로직을 캡슐화하고, 재사용하며, 확장하는 데 유용합니다.

Active Object 패턴

이제 지금까지 설명한 재료를 이용해서 Active Object 패턴에 대해서 알아보도록 하겠습니다.

Active Object 패턴은 동시성 문제를 해결하기 위해 사용되는 디자인 패턴입니다. 이 패턴은 객체의 메서드 호출을 비동기적으로 처리하도록 설계되어 있습니다. Active Object 패턴은 요청을 큐에 저장하고, 별도의 스레드에서 큐에 저장된 요청을 순차적으로 처리합니다. 이를 통해 동시성 문제를 해결하고, 객체의 상태를 안전하게 관리할 수 있습니다.

아래의 코드는 게임 서버라는 가상 시나리오에서 Active Object를 활용하는 예제입니다. 두 개의 이벤트를 처리하고 있는데요. 사용자가 게임 서버에 로그인을 할 때 발생하는 OnLogin()과 사용자가 아이텝을 획득했을 때 이를 데이터베이스에 백업하려는 OnSaveItem() 이벤트를 다루고 있습니다.

OnLogin() 이벤트에서는 사용자의 암호를 데이터베이스에서 확인하고 그 결과를 이용해서 로그인 처리를 진행하고 있습니다. 따라서 비동기적으로 실행되는 데이터베이스 확인 작업을 await를 이용해서 기다리고 그 결과를 사용하는 과정입니다.

반면에 OnSaveItem()의 경우에는, 굳이 데이터베이스 처리 결과를 기다릴 필요가 없는 경우를 설명하고 있습니다. 게임 로직은 메모리에서 이뤄지고, 나중을 위해서 데이터베이스에 백업을 하는 시나리오입니다. 데이터베이스에 저장하는 동안 발생하는 오류 등의 처리는 캡슐화하고 GameServer에서는 관심을 주지 않도록 합니다. 이를 통해서 관심사를 철저히 분리하고 있습니다.

ActiveObject에게 비동기적으로 작업을 요청할 뿐이기 때문에 OnSaveItem()에서는 async / await를 사용할 필요가 없습니다.

         publicclassGameServer{privatestatic ActiveObject _activeObject = new ActiveObject();publicstaticasyncvoidOnLogin(Connection connection, string username, string password){var command = new LoginCommand(username, password);var loginTask = _activeObject.Enqueue(command);var result = await loginTask;if (!result) {connection.SendLoginFailed();return;}connection.SendLoginSuccess();// 로그인 완료 처리 필요}publicstaticvoidOnSaveItem(Connection connection, string username, string item){var command = new SaveItemCommand(username, item);_activeObject.Enqueue(command);// 캐릭터의 인벤토리에 아이템 추가}}
         

아래의 코드는 ActiveObject 클래스의 구현 코드입니다. 이 클래스는 Command 패턴을 사용하여 비동기적으로 명령을 실행하는 역할을 합니다.

         using CommandTaskTuple = Tuple>;publicclassActiveObject{private BlockingCollection _queue = new BlockingCollection();publicActiveObject(){Task.Run(() =>{foreach (var tuple in _queue.GetConsumingEnumerable()){var command = tuple.Item1;var tcs = tuple.Item2;var result = command.Execute().Result;tcs.SetResult(result);}});}public Task Enqueue(ICommand command){var tcs = new TaskCompletionSource();_queue.Add(Tuple.Create(command, tcs));return tcs.Task;}}
         
  • 이 코드는 Active Object 패턴을 구현한 ActiveObject 클래스입니다. 이 클래스는 비동기적으로 명령을 실행하는 역할을 합니다. ActiveObject 클래스는 다음과 같은 구성 요소로 이루어져 있습니다.
  • BlockingCollection: 이 클래스는 스레드 안전한 컬렉션으로, 여러 스레드에서 동시에 접근할 때 발생할 수 있는 동시성 문제를 해결해줍니다. 이 클래스를 사용하면 스레드가 큐에 항목을 추가하거나 제거할 때 동기화를 자동으로 처리해줍니다. 이를 통해 요청되는 작업들을 안전하게 저장하고 처리할 수 있습니다.
  • Guarded Suspension 패턴: 이 패턴은 스레드가 특정 조건이 충족될 때까지 대기하도록 하는 패턴입니다. ActiveObject 클래스에서는 BlockingCollection을 사용하여 이 패턴을 구현하고 있습니다. 큐에 명령이 추가될 때까지 스레드가 대기하게 됩니다.
  • ActiveObject() 생성자: 이 생성자에서는 별도의 스레드를 시작하여 큐에 저장된 명령을 순차적으로 실행합니다. 큐에서 명령을 가져와 Execute 메서드를 호출하여 명령을 실행하고, 그 결과를 TaskCompletionSource 객체에 설정합니다. 이를 통해 비동기적으로 명령을 실행하고 그 결과를 반환할 수 있습니다.
  • Enqueue() 메서드: 이 메서드는 명령을 큐에 저장하는 역할을 합니다. 명령과 그에 대한 결과를 나타내는 TaskCompletionSource 객체를 생성하여 큐에 저장합니다. 그리고 TaskCompletionSource를 이용하여, 아직 실행되지 않은 Command의 퓨처에 해당하는 Task 객체를 반환합니다.
  • TaskCompletionSource: 이 클래스는 비동기 작업의 결과를 나타내는 Task 객체를 생성하고, 그 결과를 설정하는 데 사용됩니다. 이를 통해 비동기 작업의 완료를 표시하고 결과를 반환할 수 있습니다.

다음은 Active Object 패턴을 사용할 때 얻을 수 있는 이점들입니다.

  • 동시성 제어: Active Object 패턴을 사용하면 동시성 문제를 해결하고, 객체의 상태를 안전하게 관리할 수 있습니다.
  • 비동기 처리: Active Object 패턴을 사용하면 객체의 메서드 호출을 비동기적으로 처리할 수 있습니다. 이를 통해 시스템의 응답성을 향상시킬 수 있습니다.
  • 코드 간결성: Active Object 패턴을 사용하면 동시성 제어와 비동기 처리를 위한 코드를 간결하게 작성할 수 있습니다.
  • 확장성: Active Object 패턴을 사용하면 새로운 요청을 쉽게 추가할 수 있습니다. 이를 통해 시스템의 확장성을 향상시킬 수 있습니다.

이러한 이유로 Active Object 패턴은 동시성 문제를 해결하고, 비동기 처리를 위한 코드를 간결하게 작성하는 데 유용합니다.

동시성 문제를 해결하는 Active Object 패턴

예제를 최대한 간편하고 설명하기 쉽게 구성하려고 노력했는데요. 실전에서라면 Command 생성 부분까지도 감추고 아래와 같은 형태로 구성하는 것도 괜찮아 보입니다.

         publicclassGameServer{privatestaticDatabaseController _databaseController = newDatabaseController();publicstaticasyncvoidonLogin(Connection connection, string username, string password){var result = await _databaseController.Login(username, password);if (!result){connection.SendLoginFailed();return;}connection.SendLoginSuccess();// 로그인 완료 처리 필요}publicstaticasyncvoidonSaveItem(Connection connection, string username, string item){_databaseController.SaveItem(username, item);// 캐릭터의 인벤토리에 아이템 추가}}
         

이번 글에서는 동시성 문제를 해결하는 Active Object 패턴에 대해 알아보았습니다. 이 패턴은 객체의 메서드 호출을 비동기적으로 처리하도록 설계되어 있으며, 요청을 큐에 저장하고 별도의 스레드에서 큐에 저장된 요청을 순차적으로 처리합니다. 이를 통해 동시성 문제를 해결하고, 객체의 상태를 안전하게 관리할 수 있습니다.

Active Object 패턴은 게임 서버와 같은 다중 사용자 환경에서 효과적으로 사용될 수 있습니다. 이 패턴을 사용하면, 사용자의 요청을 비동기적으로 처리하고, 동시에 발생하는 여러 요청을 안전하게 관리할 수 있습니다.

다음 글에서는 다른 동시성 패턴들을 소개하겠습니다. 계속해서 많은 관심 부탁드립니다. 감사합니다.

류종택[email protected]
Development TeamAPM Agent Developer

지금 바로
와탭을 경험해 보세요.