C#/ASP.NET

[C#] asp.net core Memory Leak 분석 2 - CancellationTokenSource

HyoSeong 2025. 3. 3. 21:55
반응형
Memory Leak 시리즈

- Memory Leak 분석 1 - 들어가기 앞서,, 
- Memory Leak 분석 2 - CancellationTokenSource

 

이 글을 진행하기 앞서, CancellationToken 에 대한 설명을 간단히 해야 할 것 같은데,,

 

(귀찮으니 나중에 별도 게시글로 작성을 하고, 링크를 추가하도록 하겠습니다)


Live Service를 대상으로 약 4시간 간격으로 dotnet-dump 를 진행하여 두 메모리를 Compare하였다.

 

Byte[], string 등 많은 객체들이 증가하고 있었지만, 그 중 CancellationTokenSource관련 객체들이 수만개씩 늘어나 있는것을 확인하였다.

 

빠르게 CancellationTokenSource를 사용하는 코드를 찾아보니 한 코드가 눈에 띄었다.

 

 

 

서비스에는 API동작이 끝난 후, Response와 관련이 없는 추가 동작을 진행해야 하는 경우를 대비하기 위해,

Background Service가 돌아가고 있다. (당연히 Singleton이다.)

 

가령, 오래 걸리는 작업에 대한 시작 요청을 보낸 뒤, 해당 작업을 처리하기 위함이라거나,

API 통계 처리 등을 해당 service를 통해 진행한다.

 

(가끔 이 부분에서 지연이 발생하는데, 이것이 end user까지의 응답 지연으로 이어지지 않았으면 하는 의도이다)

 

아무튼, 이 코드는 대략 이런식이다.

(이 코드를 참고했었던 것 같다.)

public class BackgroundWorker : BackgroundService
{
    private readonly IQueue _queue;

    private readonly IServiceScopeFactory _serviceScopeFactory;

    public BackgroundWorker(
        IQueue queue,
        IServiceScopeFactory serviceScopeFactory)
    {
        _queue = queue;
        _serviceScopeFactory = serviceScopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        await Task.Yield();

        while (cancellationToken.IsCancellationRequested is false)
        {
            // 작업을 가져오고
            var (item, ownCancelToken) = _queue.Dequeue(cancellationToken);

            if (ownCancelToken.IsCancellationRequested)
            {
                continue;
            }
			
            // Token 을 결합한 뒤,
            var combinedCancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ownCancelToken);

            // Scope 를 생성
            using var scope = _serviceScopeFactory.CreateScope();
            
            var mediator = scope.ServiceProvider.GetService<IMediator>();
			
            // 요청받은 작업을 처리한다.
            await mediator.Publish(item, combinedCancelTokenSource.Token);
        }
    }
}

 

 

우선, 이 코드에는 두 가지 심각한 결함이 있다. (Memory Leak을 제외하더라도 하나가 더 있다)

 

바로 보인다면, 당신을 제 스승으로 삼겠습니다. (꾸벅)

 

우선 메모리 Leak 부터 살펴보자,

 

CancellationTokenSource

요 녀석은 여러 CancellationToken을 합쳐주는 녀석이라고 생각하면 편하다.

 

각기 다른 곳에서 Cancel요청이 들어올 수 있기 때문이다.

 

위 로직에선, 

1. BackgroundService에서 Stop 요청이 왔을 경우,

 - 이는 Shut-down 요청이 들어올 경우가 있겠다.

2. item에서 받아온 CancellationToken에서 Stop요청이 왔을 경우

 - 이는 실 사용자로부터 온 Cancel 요청일 가능성이 크다. (BackgroundQueue 에서 꺼내 처리하기 때문에, 현실적으로 Cancel요청이 들어올 확률은 지극히 낮지만.)

 

아무튼 이런 경우, 대부분 CancellationToken은 하나만 인자로 넘길 수 있기 때문에, 이를 합쳐주는 작업이 필요하고, 그게 바로 CancellationTokenSource인 것이다.

(그게 아니라면 CancellationToken을 IEnumerable 로 받거나, params로 받아야 할 수도 있다 ㅋㅋ)

 

* CancellationTokenSource에 대한 더 자세한 내용은 이 링크를 참고

 

아무튼, 두 Token을 Link 하는 과정 중에, 두 객체가 서로 엮이게 되고,

BackgroundService는 SINGLETON 이기 때문에, 해당 Service가 죽기 전까지 Queue에 들어온 모~~든 Token이 할당 해제되지 않고 남아있게 되는 것이다. (GC 입장에서는 당연한 행동이다)

 

그렇다면 어떻게 해결해야 할까?

 

=> Dispose!!

 

CancellationTokenSource는 기본적으로 IDisposable을 구현하고 있어, Dispose해주거나, using 하면 된다..

(길게 썼지만 참,, 허무한 이슈)

 

그렇다면 코드의 나머지 두번째 문제점은 무엇일까?

 

이건 얼마전에 사소한 문제로 알게된 것인데,

 

기본적으로 Web request는 ExceptionHandler 를 구현하여 Exception이 터져도 큰 문제가 발생하지 않지만,

BackgroundService는 그런 대응을 하지 않을 경우, 내부 로직에서 Exception이 터지면 BackgroundService가 그냥 죽어버린다.

restart를 구현하는것도 하나의 방법일 수도 있지만, try-catch만 잘해주어도 큰 문제가 발생하지 않는다.

반응형