제가 와탭에 합류한지 4개월 정도되었는데요. 그동안 기존 코드를 수정하거나 새로운 기능을 추가하면서 경험했던 것들 중에서 성능에 관한 주제로 정리해보았습니다. 실전 코드를 모두 옮겨오는 것이 어려워 상황 설명이 조금 부족할 수 있습니다. 이 글이 성능 최적화에 대한 기본적인 이해를 돕는데 도움이 되었으면 합니다.
멀티 스레딩 상황에서 종종 발생하는 동시성 문제를 해결하기 위해 보통 락(Lock)을 사용하고는 합니다. 락 사용은 올바르게 관리되지 않을 경우에는 성능 저하를 일으킬 수 있는데요. 특히, 락이 걸려있는 상태에서 수행 시간이 길어지는 경우, 이는 성능에 치명적인 영향을 미칠 수 있습니다.
레거시 코드에서는 Finish() 메서드(Method) 의 동작을 보장하기 위해 락을 사용했습니다. IsFinished라는 부울 변수를 변경하기 전에 락을 확인하고, 이미 IsFinished가 참이라면 메서드의 실행을 중지합니다. 이러한 방식은 멀티 스레드 상황에서 Finish() 메서드가 중복 실행되는 것을 방지할 수 있지만, 락 사용으로 인한 성능 저하가 우려됩니다.
privatebool IsFinished = false;publicvoidFinish(DateTimeOffset finishTimestamp){lock (_lock){if (!IsFinished){// ...IsFinished = true;}}}
이 문제를 해결하기 위해 Interlocked 클래스를 활용하는 방법을 소개합니다. Interlocked 클래스의 메서드는 원자적 연산을 제공하여 스레드 안전성을 보장하며, 동시에 락을 사용하지 않아 성능 저하를 최소화하는 이점을 가지고 있습니다. 자바에서는 Atomic이 같은 기능을 제공합니다.
아래 코드에서는 Interlocked.Exchange() 메서드를 사용하여 IsFinished 값을 1로 변경하고 이전 값을 반환합니다. 반환값이 0이 아니라면 이미 한 번 변경된 상태이므로, 이전 코드와 동일한 의미를 가집니다. 이렇게 하면 기존에 락을 사용해야 했던 부분을 락 없이 원자적으로 처리할 수 있어 성능 향상에 도움이 됩니다.
privateint IsFinished = 0;publicvoidFinish(DateTimeOffset finishTimestamp){if (Interlocked.Exchange(ref IsFinished, 1) != 0) return;// ...}
아래 캡쳐 화면은 두 코드의 실행 시간을 비교한 것입니다. 상황에 따라서는 더욱 극단적인 차이를 보일 수도 있습니다.
이번에는 조금 더 복잡한 상황에서 Interlocked를 응용하는 방법에 대해서 알아보겠습니다.
InterlockedQueue는 특수한 상황에서 설계된 큐의 구현 클래스입니다. APM 개발에는 주의할 것들이 있는데요. 그중에서도 APM 프로그램이 고객의 성능과 안정성에 영향을 주기 쉬운 구조라는 점입니다. 따라서 성능과 안정성에 유의하며 개발에 집중하고 있습니다. 아래는 닷넷(.NET)에서 액티브 스택을 적용하기 위해서 과정에서 여러가지 시도를 한 일부 내용입니다.
주기적으로 발생하는 트랜잭션 정보를 큐에 가지고 있다가 스택을 덤프하는 스레드가 주기적으로 이를 처리해야 하는 상황입니다. 전형적인 생산자-소비자(Producer-consumer) 패턴입니다. ConcurrentQueue를 이용해서 간단하게 처리할 수도 있겠지만, 락에 의한 컨텍스트 스위칭 비용이 우려되어 Interlocked를 이용해서 새로운 큐를 만들어서 테스트 해보았습니다.
using System;using System.Threading;using System.Collections.Generic;namespaceWhaTap.Trace.Utils{classNode{publicNode(T value){Value = value;}public T Value;public Node Previous = null;}publicclassInterlockedQueue{publicboolEnqueue(T value){var newNode = new Node(value);var tail = _tail;newNode.Previous = tail;var previous = Interlocked.CompareExchange(ref _tail, newNode, tail);return previous == newNode.Previous;}publicboolTryEnqueue(T value, int tryCount){while (tryCount-- > 0){if (Enqueue(value)) returntrue;}returnfalse;}public List Get(){var list = new List();var stack = new Stack();var tail = Interlocked.Exchange(ref _tail, null);while (tail != null){stack.Push(tail.Value);tail = tail.Previous;}while (stack.Count > 0){list.Add(stack.Pop());}return list;}private Node _tail = null;}}
Enqueue를 실패할 수도 있는 알고리즘이기 때문에 성공할 때까지 여러번 시도하는 메소드도 추가하였는데요. 액티브 스택은 일부 누락이 되더라도 크게 문제가 될 것이 없어서 최대 8번정도까지 반복하게 했으며, 데모 테스트 상황에서는 거의 실패가 일어나지 않았습니다. 그리고 ConcurrentQueue와 달리 그동안 쌓인 데이터를 Get에서 리스트로 한 번에 가져오는 점이 다릅니다.
테스트 결과는 CPU 100% 상황의 데모 프로그램이 실행되는 상황에서 InterlockedQueue를 적용하자 40% 미만으로 떨어졌습니다. 데모 프로그램의 다른 기능들로 인해서 두 큐 클래스의 성능 차이를 정확하게 측정한 것은 아니지만 의미있는 수치였습니다.
InterlockedQueue는 Linked list 구조로 되어 있는데요. 끝자리에 해당하는 지표는 _tail 변수에 저장됩니다. 새로운 데이터를 추가할 때에는 _tail에 해당하는 데이터 뒤에 붙이면 됩니다. 이때 다른 아래 그림과 같이 다른 스레드가 함께 경쟁하게 되면, 여러 개의 데이터가 꼬리에 연결되지만, _tail이 자신을 가리키도록 하는 과정에서 Interlocked를 사용하기 때문에 결과적으로 하나의 데이터만 꼬리 붙이기에 성공하게 됩니다.
소프트웨어 개발이 언제나 그렇듯이 추가적인 요구사항을 뒤늦게 알게 되었는데요. 스택 덤프가 성능에 영향을 주는 것을 최소화하기 위해서 일정 개수 미만의 정보만 캡쳐해서 분석해야한다는 점을 알게 되었습니다. 그래서 이번에는 InterlockedQueue보다 좀더 개선된 InterlockedRingBuffer를 만들어서 적용해보았습니다.
using System;using System.Threading;using System.Collections.Generic;publicclassInterlockedRingBuffer{publicInterlockedRingBuffer(int capacity){Capacity = capacity;tail = 0;buffer = new T[Capacity];}publicvoidEnqueue(T value){var index = Interlocked.Increment(ref tail);buffer[index % Capacity] = value;if (index > Capacity){Interlocked.CompareExchange(ref tail, index % Capacity, index);}}publicvoidIterate(Action action){int currentTail = tail + (Capacity -1);currentTail %= Capacity;for (int i = 0; i < Capacity; i++){action(buffer[currentTail % Capacity]);currentTail++;}}publicvoidIterate(Func action){int currentTail = tail + (Capacity - 1);currentTail %= Capacity;for (int i = 0; i < Capacity; i++){if (action(buffer[currentTail % Capacity])) break;currentTail++;}}publicint Capacity { get; privateset; }privatereadonly T[] buffer;privateint tail;}
최근 데이터만 가져오면 되기 때문에 버퍼가 풀인 상태에서 새로운 데이터가 들어오면 가장 오래된 데이타를 덮어 쓰는 구조입니다. 큐에 적재할 때 실패를 고려할 필요가 없어져서 조금이지만 성능 향상도 있었습니다.
var index = Interlocked.Increment(ref tail);
InterLocked를 이용해서 index를 하나씩 쌓아 올려가면서 큐에 데이터를 넣기 때문에 멀티 스레드 상황에서도 충돌이 일어나지는 않습니다. index가 버퍼의 크기를 벗어나면 처음부터 다시 시작하기 위해서 % 연산자를 사용하고 있는데요.
buffer[index % Capacity] = value;
Capacity의 데이터타입이 부호가 없으면서 최대 크기가 Capacity로 나눠 떨어진다면 문제가 없겠으나, 그렇지 않은 경우를 위해서 주기적으로 tail의 값을 조절해줘야 합니다. 이때도 스레드 세이프를 염두해둬야 하는데요. 아래 코드는 멀티 스레드 상황에서도 안전하게 동작합니다.
CompareExchange()는 tail의 값이 index와 같지 않으면 동작하지 않게 되는데요. 다른 스레드가 중간에 끼어 들어서 tail 값이 변경되었다면, 동작하지 않기 때문에 충돌하지 않게 됩니다.
if (index > Capacity){Interlocked.CompareExchange(ref tail, index % Capacity, index);}
사용 예제 코드는 다음과 같습니다.
int count = 0;_steps.Iterate((step) =>{try{if (step.IsFinished) returnfalse;count++;if (count > Settings.Instance.ActiveStackCount) returntrue;captureCallStack(step);}catch (Exception e){WhaTapLogs.Error($"Failed to capture call stack: {e.Message}");returntrue;}// Iteration will stop when return true.returnfalse;});
이 글을 통해 성능 최적화에 대한 몇 가지 전략과 실제 코드를 살펴보았습니다. 1편에서는 Interlocked를 활용한 큐와 버퍼의 구현 등을 다뤄보았는데요. 이어지는 2편에서는 다른 다양한 방법을 통해 성능을 향상시킬 수 있는 방법에 대해서 알아보겠습니다.
한가지 첨언을 하자면, 성능 최적화는 항상 트레이드오프가 존재하며, 모든 상황에 적합한 '완벽한' 방법은 없습니다. 따라서 개발자는 항상 주어진 상황과 요구사항을 고려하여 적절한 최적화 전략을 선택해야 합니다. 이 글이 그러한 결정을 내리는 데 도움이 되었기를 바랍니다.