ASP.NET Core의 메모리 관리 및 GC(가비지 수집)

작성자: Sébastien RosRick Anderson

메모리 관리는 .NET과 같은 관리형 프레임워크에서도 복잡합니다. 메모리 문제를 분석하고 이해하는 것은 어려울 수 있습니다. 이 문서의 내용:

  • 많은 ‘메모리 누수’ 및 ‘GC가 작동하지 않는’ 문제로 인해 이 문서를 작성하게 되었습니다. 이러한 문제는 대부분 .NET Core에서 메모리 사용 방식을 이해하지 못하거나 측정 방법을 이해하지 못하여 발생했습니다.
  • 문제가 있는 메모리 사용을 보여 주고 대체 방법을 제안합니다.

.NET Core에서 GC(가비지 수집)가 작동하는 방식

GC는 각 세그먼트가 연속된 메모리 범위인 힙 세그먼트를 할당합니다. 힙에 배치된 개체는 0, 1 또는 2의 3가지 세대 중 하나로 분류됩니다. 세대는 GC가 앱에서 더 이상 참조하지 않는 관리형 개체에서 메모리를 해제하려고 시도하는 빈도를 결정합니다. 세대 번호가 낮을수록 GC는 더 자주 발생합니다.

개체는 수명에 따라 한 세대에서 다른 세대로 이동됩니다. 개체가 더 오래 지속될수록 더 높은 세대로 이동됩니다. 앞서 언급했듯이 세대가 높을수록 GC의 빈도가 낮습니다. 단기 수명 개체는 항상 0세대를 유지합니다. 예를 들어 웹 요청의 수명 동안 참조되는 개체는 수명이 짧습니다. 애플리케이션 수준 싱글톤은 일반적으로 2세대로 마이그레이션됩니다.

ASP.NET Core 앱이 시작되면 GC는 다음을 수행합니다.

  • 초기 힙 세그먼트를 위해 일부 메모리를 예약합니다.
  • 런타임이 로드될 때 메모리의 일부를 커밋합니다.

위의 메모리 할당은 성능상의 이유로 수행됩니다. 성능상의 이점은 연속 메모리의 힙 세그먼트에서 제공됩니다.

Gc. 주의 사항 수집

일반적으로 프로덕션의 ASP.NET Core 앱은 GC를 사용하지 않아야 합니다. 명시적으로 수집합니다. 최적이하의 시간에 가비지 수집을 유도하면 성능이 크게 저하됩니다.

Gc. 수집은 메모리 누수 조사 시 유용합니다. 호출 GC.Collect() 은 관리 코드에서 액세스할 수 없는 모든 개체를 회수하는 차단 가비지 수집 주기를 트리거합니다. 힙에서 연결할 수 있는 라이브 개체의 크기를 이해하고 시간이 지남에 따라 메모리 크기의 증가를 추적하는 유용한 방법입니다.

앱의 메모리 사용량 분석

전용 도구는 다음과 같은 메모리 사용량 분석에 도움이 될 수 있습니다.

  • 개체 참조 계산
  • GC가 CPU 사용량에 미치는 영향 측정
  • 각 세대에 사용되는 메모리 공간 측정

다음 도구를 사용하여 메모리 사용량을 분석합니다.

메모리 문제 감지

작업 관리자를 사용하여 ASP.NET 앱에서 사용 중인 메모리 양을 파악할 수 있습니다. 작업 관리자 메모리 값은 다음과 같습니다.

  • ASP.NET 프로세스에서 사용하는 메모리 양을 나타냅니다.
  • 앱에서 실행 중인 개체 및 기타 메모리 소비자(예: 네이티브 메모리 사용)를 포함합니다.

작업 관리자 메모리 값이 무기한으로 증가하고 평면화되지 않으면 앱에 메모리 누수가 있는 것입니다. 다음 섹션에서는 몇 가지 메모리 사용 패턴을 보여 줍니다.

샘플 표시 메모리 사용량 앱

MemoryLeak 샘플 앱은 GitHub에서 사용할 수 있습니다. MemoryLeak 앱은 다음과 같습니다.

  • 앱에 대한 실시간 메모리 및 GC 데이터를 수집하는 진단 컨트롤러를 포함합니다.
  • 메모리 및 GC 데이터를 표시하는 인덱스 페이지가 있습니다. 인덱스 페이지는 1초마다 새로 고쳐집니다.
  • 다양한 메모리 부하 패턴을 제공하는 API 컨트롤러를 포함합니다.
  • 지원되는 도구는 아니지만 ASP.NET Core 앱의 메모리 사용 패턴을 표시하는 데 사용할 수 있습니다.

MemoryLeak를 실행합니다. 할당된 메모리는 GC가 발생할 때까지 느리게 증가합니다. 도구가 데이터를 캡처하기 위해 사용자 지정 개체를 할당하기 때문에 메모리가 증가합니다. 다음 이미지는 0세대 GC가 발생할 때 MemoryLeak 인덱스 페이지를 보여 줍니다. API 컨트롤러의 API 엔드포인트가 호출되지 않아 차트에 0개의 RPS(초당 요청 수)가 표시됩니다.

Chart showing 0 Requests Per Second (RPS)

차트에는 메모리 사용량에 대한 다음 두 가지 값이 표시됩니다.

  • 할당된 메모리: 관리형 개체가 차지하는 메모리 양
  • 작업 집합: 현재 실제 메모리에 상주하는 프로세스의 가상 주소 공간에 있는 페이지 집합입니다. 표시된 작업 집합은 작업 관리자가 표시하는 값과 같습니다.

임시 개체

다음 API는 10KB 문자열 인스턴스를 만들고 클라이언트에 반환합니다. 각 요청에서 새 개체가 메모리에 할당되고 응답에 기록됩니다. 문자열은 .NET에 UTF-16 문자로 저장되므로 각 문자는 메모리에서 2바이트를 차지합니다.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

다음 그래프는 메모리 할당이 GC에 의해 영향을 받는 방식을 보여 주기 위해 비교적 적은 양의 부하로 생성되었습니다.

Graph showing memory allocations for a relatively small load

위의 차트는 다음을 보여 줍니다.

  • 4K RPS(초당 요청 수)
  • 0세대 GC 수집은 약 2초마다 발생합니다.
  • 작업 집합은 약 500MB로 일정합니다.
  • CPU는 12%입니다.
  • 메모리 소비 및 해제(GC를 통해)는 안정적입니다.

다음 차트는 머신에서 처리할 수 있는 최대 처리량에서 작성되었습니다.

Chart showing max throughput

위의 차트는 다음을 보여 줍니다.

  • 22K RPS
  • 0세대 GC 컬렉션은 초당 여러 번 발생합니다.
  • 앱이 초당 훨씬 더 많은 메모리를 할당했기 때문에 1세대 수집이 트리거됩니다.
  • 작업 집합은 약 500MB로 일정합니다.
  • CPU는 33%입니다.
  • 메모리 소비 및 해제(GC를 통해)는 안정적입니다.
  • CPU(33%)가 과도하게 활용되지 않으므로 가비지 수집은 많은 수의 할당을 유지할 수 있습니다.

워크스테이션 GC 및 서버 GC

.NET 가비지 수집기는 다음 두 가지 모드로 작동합니다.

  • 워크스테이션 GC: 데스크톱에 최적화되어 있습니다.
  • 서버 GC. ASP.NET Core 앱의 기본 GC입니다. 서버에 최적화되어 있습니다.

GC 모드는 프로젝트 파일 또는 게시된 앱의 파일에서 runtimeconfig.json 명시적으로 설정할 수 있습니다. 다음 태그는 프로젝트 파일의 ServerGarbageCollection 설정을 보여 줍니다.

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

프로젝트 파일에서 ServerGarbageCollection을 변경하려면 앱을 다시 빌드해야 합니다.

참고: 단일 코어가 있는 머신에서는 서버 가비지 수집을 사용할 수 없습니다. 자세한 내용은 IsServerGC를 참조하세요.

다음 이미지는 워크스테이션 GC를 사용하는 5K RPS 이하의 메모리 프로필을 보여 줍니다.

Chart showing memory profile for a Workstation GC

이 차트와 서버 버전의 차이점은 다음과 같이 상당히 크게 나타납니다.

  • 작업 집합이 500MB에서 70MB로 감소합니다.
  • GC는 2초 간격이 아니라 초당 여러 번 0세대 수집을 수행합니다.
  • GC는 300MB에서 10MB로 감소합니다.

일반적인 웹 서버 환경에서 CPU 사용량은 메모리보다 더 중요하므로 서버 GC가 더 효율적입니다. 메모리 사용률이 높고 CPU 사용량이 상대적으로 낮으면 워크스테이션 GC의 성능이 더 향상될 수 있습니다. 메모리가 부족한 여러 웹앱의 고밀도 호스팅을 예로 들 수 있습니다.

GC using Docker and small containers(Docker 및 소형 컨테이너를 사용하는 GC)

컨테이너화된 여러 앱이 한 컴퓨터에서 실행되는 경우 워크스테이션 GC가 서버 GC보다 더 좋은 성능을 보일 수 있습니다. 자세한 내용은 소형 컨테이너에서 서버 GC 실행소형 컨테이너에서 서버 GC 실행 시나리오 1부 – GC 힙의 하드 제한을 참조하세요.

영구 개체 참조

GC는 참조되는 개체를 해제할 수 없습니다. 참조되지만 더 이상 필요하지 않은 개체는 메모리 누수를 발생합니다. 앱에서 개체를 자주 할당하며, 더 이상 필요하지 않은 개체를 해제하지 못하면 시간이 지남에 따라 메모리 사용량이 증가합니다.

다음 API는 10KB 문자열 인스턴스를 만들고 클라이언트에 반환합니다. 이전 예제와의 차이점은 이 인스턴스가 정적 멤버에서 참조된다는 것입니다. 즉, 수집에는 사용할 수 없습니다.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

앞의 코드가 하는 역할은 다음과 같습니다.

  • 일반적인 메모리 누수의 한 예입니다.
  • 자주 호출하면 OutOfMemory 예외가 발생하여 프로세스가 크래시될 때까지 앱 메모리가 늘어납니다.

Chart showing a memory leak

이전 이미지에서 다음이 진행됩니다.

  • /api/staticstring 엔드포인트를 테스트하는 부하로 인해 메모리가 선형적으로 증가합니다.
  • GC는 2세대 수집을 호출하여 메모리 압력이 증가할 때 메모리를 해제하려고 시도합니다.
  • GC에서 누수된 메모리를 해제할 수 없습니다. 할당된 메모리 및 작업 집합은 시간에 따라 증가합니다.

캐싱과 같은 일부 시나리오에서는 메모리 압력이 강제로 해제될 때까지 개체 참조를 유지해야 합니다. WeakReference 클래스를 이 유형의 캐싱 코드에 사용할 수 있습니다. WeakReference 개체는 메모리 압력이 있을 때 수집됩니다. 기본 IMemoryCache 구현은 WeakReference를 사용합니다.

네이티브 메모리

일부 .NET Core 개체는 네이티브 메모리를 사용합니다. 네이티브 메모리는 GC에서 수집될 수 없습니다. 네이티브 메모리를 사용하는 .NET 개체는 네이티브 코드를 사용하여 해제해야 합니다.

.NET에서는 개발자가 네이티브 메모리를 릴리스할 수 있도록 하는 IDisposable 인터페이스를 제공합니다. Dispose가 호출되지 않더라도 올바르게 구현된 클래스는 종료자가 실행될 때 Dispose를 호출합니다.

다음 코드를 생각해 봅시다.

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider 는 관리되는 클래스이므로 요청이 끝날 때 모든 인스턴스가 수집됩니다.

다음 이미지는 fileprovider API를 계속 호출하는 동안 메모리 프로필을 보여 줍니다.

Chart showing a native memory leak

위의 차트는 메모리 사용량이 계속 늘어나고 있는 이 클래스의 구현과 관련된 명확한 문제를 보여 줍니다. 이것은 이 이슈에서 추적 중인 알려진 문제입니다.

사용자 코드에서 다음 중 하나로 인해 동일한 누수가 발생할 수 있습니다.

  • 클래스를 올바르게 해제하지 않습니다.
  • 삭제해야 하는 종속 개체의 Dispose 메서드를 호출하는 것을 잊어버린 경우.

대형 개체 힙

잦은 메모리 할당/해제 주기는 메모리를 조각화할 수 있으며, 이러한 현상은 대량의 메모리 청크를 할당하는 경우에 두드러집니다. 개체는 인접한 메모리 블록에 할당됩니다. 조각화를 완화하기 위해 GC는 메모리를 해제할 때 조각 모음을 시도합니다. 이 프로세스를 압축이라고 합니다. 압축 동안 개체가 이동될 수 있습니다. 대형 개체를 이동하면 성능이 저하됩니다. 이러한 이유로, GC는 LOH(대형 개체 힙)라는 ‘대형’ 개체용 특수 메모리 영역을 만듭니다. 85,000바이트(약 83KB)보다 큰 개체에는 다음이 적용됩니다.

  • LOH에 배치됩니다.
  • 압축되지 않습니다.
  • 2세대 GC 동안 수집됩니다.

LOH가 가득 차면 GC가 2세대 수집을 트리거합니다. 2세대 수집:

  • 기본적으로 속도가 느립니다.
  • 다른 모든 세대에서 수집을 트리거하는 비용도 추가로 발생합니다.

다음 코드는 LOH를 즉시 압축합니다.

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

LOH 압축에 대한 내용은 LargeObjectHeapCompactionMode를 참조하세요.

.NET Core 3.0 이상 버전을 사용하는 컨테이너에서는 LOH가 자동으로 압축됩니다.

이 동작을 설명하는 API는 다음과 같습니다.

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

다음 차트는 최대 부하 상태에서 /api/loh/84975 엔드포인트를 호출하는 메모리 프로필을 보여 줍니다.

Chart showing memory profile of allocating bytes

다음 차트에서는 /api/loh/84976 엔드포인트를 호출하는 메모리 프로필을 보여 주며 ‘1바이트만 추가로’ 할당합니다.

Chart showing memory profile of allocating one more byte

참고: byte[] 구조에 오버헤드 바이트가 있습니다. 바로 이때문에 84,976바이트가 85,000 제한을 트리거하게 됩니다.

위의 두 차트 비교:

  • 작업 집합은 두 시나리오에 대해 유사합니다(약 450MB).
  • LOH 미만 요청(84,975바이트)은 대부분 0세대 수집을 보여 줍니다.
  • LOH 초과 요청은 일정한 2세대 수집을 생성합니다. 2세대 수집은 비용이 많이 듭니다. 더 많은 CPU가 필요하고 처리량이 약 50% 감소합니다.

임시 대형 개체는 2세대 GC를 야기하므로 특히 문제가 됩니다.

성능을 최대화하려면 대형 개체 사용을 최소화해야 합니다. 가능하면 대형 개체는 분할합니다. 예를 들어 ASP.NET Core의 응답 캐싱 미들웨어는 캐시 항목을 85,000바이트보다 작은 블록으로 분할합니다.

다음 링크에서는 개체를 LOH 제한 아래로 유지하는 ASP.NET Core 방법을 보여 줍니다.

자세한 내용은 다음을 참조하세요.

HttpClient

HttpClient를 잘못 사용하면 리소스 누수가 발생할 수 있습니다. 데이터베이스 연결, 소켓, 파일 핸들 등의 시스템 리소스에는 다음이 적용됩니다.

  • 메모리보다 더 부족해집니다.
  • 누수될 경우 메모리보다 더 문제가 됩니다.

숙련된 .NET 개발자는 IDisposable을 구현하는 개체에 대해 Dispose를 호출해야 한다는 사실을 알고 있습니다. 일반적으로 IDisposable을 구현하는 개체를 삭제하지 않으면 메모리 누수가 발생하거나 시스템 리소스가 누수됩니다.

HttpClientIDisposable을 구현하지만 모든 호출에서 삭제하면 안됩니다. 대신 HttpClient를 다시 사용해야 합니다.

다음 엔드포인트는 요청이 있을 때마다 새 HttpClient 인스턴스를 만들고 삭제합니다.

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

부하 상태에서 다음 오류 메시지가 기록됩니다.

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

HttpClient 인스턴스가 삭제된 경우에도 운영 체제에서 실제 네트워크 연결을 해제하는 데 다소 시간이 소요됩니다. 계속해서 새 연결을 만들면 ‘포트 소모’가 발생합니다. 각 클라이언트 연결에는 자체 클라이언트 포트가 필요합니다.

포트 소모를 방지하는 한 가지 방법은 동일한 HttpClient 인스턴스를 재사용하는 것입니다.

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

HttpClient 인스턴스는 앱이 중지될 때 해제됩니다. 이 예제에서는 사용한 후에 삭제 가능한 모든 리소스를 삭제해야 하는 것은 아님을 보여 줍니다.

HttpClient 인스턴스의 수명을 처리하는 더 나은 방법은 다음을 참조하세요.

개체 풀링

이전 예제에서는 HttpClient 인스턴스를 정적으로 만들고 모든 요청에서 다시 사용하는 방법을 보여 주었습니다. 다시 사용하면 리소스 부족을 방지할 수 있습니다.

개체 풀링에는 다음이 적용됩니다.

  • 재사용 패턴을 사용합니다.
  • 만드는 데 비용이 많이 드는 개체용으로 설계되었습니다.

풀은 스레드 간에 예약 및 해제할 수 있는 미리 초기화된 개체의 컬렉션입니다. 풀은 제한, 미리 정의된 크기 또는 증가율과 같은 할당 규칙을 정의할 수 있습니다.

NuGet 패키지 Microsoft.Extensions.ObjectPool에는 이러한 풀을 관리하는 데 도움이 되는 클래스가 포함되어 있습니다.

다음 API 엔드포인트는 각 요청에서 난수로 채워진 byte 버퍼를 인스턴스화합니다.

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

다음 차트는 보통 수준의 부하에서 이전 API를 호출하는 경우를 보여 줍니다.

Chart showing calls to API with moderate load

위의 차트에서 0세대 수집은 초당 약 1번 발생합니다.

이전 코드는 ArrayPool<T>를 사용하여 byte 버퍼를 풀링하는 방식으로 최적화할 수 있습니다. 정적 인스턴스는 요청 간에 재사용됩니다.

이 방법의 다른 점은 풀링된 개체가 API에서 반환된다는 것입니다. 이는 다음을 의미합니다.

  • 개체는 메서드에서 반환되는 즉시 사용자가 제어할 수 없습니다.
  • 개체를 해제할 수 없습니다.

개체의 삭제를 설정하려면 다음을 수행합니다.

RegisterForDispose는 HTTP 요청이 완료된 경우에만 해제되도록 대상 개체에서 Dispose 호출을 처리합니다.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

풀링되지 않은 버전과 동일한 부하를 적용하면 다음과 같은 차트가 생성됩니다.

Chart showing fewer allocations

주요 차이점은 할당된 바이트이며 결과적으로 0세대 수집이 훨씬 줄어듭니다.

추가 리소스