← 문제로

34. ArrayPool<T> · Memory<T> · IMemoryOwner<T> 버퍼 풀링의 내부 동작

난이도 중
내 리뷰 · C#
해설 · C#

해설 — ArrayPool · Memory · IMemoryOwner 버퍼 풀링의 내부 동작

난이도: 중상

요약

ArrayPool<T>.Rent(n)"n 이상" 크기의 배열을 돌려준다(정확히 n이 아니다). 풀은 크기를 2의 거듭제곱 버킷으로 관리하므로 buffer.Length가 요청보다 클 수 있고, 게다가 이전 사용자의 데이터가 남아 있을 수 있다. 따라서 반드시 (a) 실제 유효 길이만큼만 쓰고, (b) 반납 후에는 배열을 만지지 않으며, (c) 민감 데이터는 clearArray:true로 반납해야 한다. async 경계를 넘겨 버퍼를 들고 가야 하면 Span<T>(ref struct, 스택 전용)가 아니라 Memory<T>/IMemoryOwner<T>가 필요하다.

핵심 개념 1: Rent 는 "at least" 계약

requested 100  -> buffer.Length 128     // 다음 버킷
requested 1000 -> buffer.Length 1024
requested 4096 -> buffer.Length 4096    // 정확히 버킷 경계
requested 5000 -> buffer.Length 8192    // 다음 버킷으로 올림

(정확한 값은 런타임/버전마다 다를 수 있으나 항상 >= 요청, 대개 2의 거듭제곱 버킷.)

  • 공유 풀(ArrayPool<T>.Shared)은 TlsOverPerCoreLockedStacksArrayPool로 구현되며, 길이를 버킷(16, 32, 64 … 2^k) 으로 나눠 스레드 로컬 + per-core 락 스택에 캐시한다. Rent는 요청을 담을 수 있는 최소 버킷을 찾아 그 크기의 배열을 준다.
  • 그래서 buffer.Length를 "내 데이터 길이"로 쓰면 안 된다. 유효 길이는 내가 따로 관리해야 한다(예: payloadLen).

핵심 개념 2: ProcessPacket 의 세 함정

  • (A) 정확한 길이 오용: 체크섬을 buf.Length(=버킷 크기, 예 8192)까지 순회한다. 실제 유효 바이트는 payloadLen뿐이고, 나머지는 직전 대여자가 남긴 쓰레기(또는 민감 데이터). 체크섬이 오염되고, 최악의 경우 다른 세션의 잔여 데이터를 읽는 정보 노출이 된다. → for (i < payloadLen).
  • (B)(C) 반납 후 사용(use-after-return): Return(buf) 직후 buf[0]를 읽는다(C). 반납된 배열은 즉시 다른 스레드가 Rent해 갈 수 있으므로, 그 내용을 읽는 것은 다른 사용자와의 데이터 레이스이자 논리적 use-after-free다. 반납 후 지역 참조는 null로 만들어 재사용을 원천 차단하는 게 안전하다.
  • 민감 데이터 클리어 누락: 세션 키·개인정보를 담았던 버퍼를 그냥 반납하면 다음 대여자가 그 잔여를 본다. Return(buf, clearArray: true) 로 지워서 반납한다(성능 트레이드오프 있음).

올바른 형태

public static void ProcessPacket(int payloadLen)
{
    byte[] buf = ArrayPool<byte>.Shared.Rent(payloadLen);
    try
    {
        FillFromSocket(buf, payloadLen);
        long sum = 0;
        for (int i = 0; i < payloadLen; i++)   // 유효 길이만
            sum += buf[i];
        Console.WriteLine($"checksum={sum}");  // 반납 전에 사용
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buf, clearArray: true); // 예외에도 반드시 반납
    }
}

try/finally로 예외 경로에서도 반드시 반납한다(안 하면 풀에서 배열이 영영 빠져 사실상 누수). 반납은 항상 함수의 마지막에, 그 후 배열 접근 없음.

핵심 개념 3: Span vs Memory (async 경계)

  • Span<T>ref struct 라서 힙에 올라갈 수 없다. 즉 필드로 저장 불가, await 를 가로질러 살아남을 수 없고, 람다/이터레이터에 캡처 불가. 상태 머신은 지역 변수를 힙 객체 필드로 승격하는데 ref struct는 그럴 수 없기 때문이다.
  • 따라서 await socket.ReadAsync(...) 처럼 비동기 경계를 넘겨 버퍼를 들고 가야 하면 Memory<T>(일반 struct, 힙 가능)를 쓴다. 풀에서 받으려면 MemoryPool<T>.Shared.Rent(n) 이 주는 IMemoryOwner<T>using으로 잡아 수명을 관리하고, owner.Memory.Slice(0, payloadLen) 로 유효 구간만 넘긴다.
  • 동기 핫패스의 짧은 구간 처리에는 Span<T>가 가장 빠르고(경계검사 제거·스택 전용), 경계를 넘기거나 저장해야 할 때만 Memory<T>로 승격한다.

흔한 오해·함정

  • "Rent(1000)이면 길이 1000짜리가 온다" → 아니다. >= 1000. 유효 길이는 직접 관리.
  • "풀 배열은 0으로 초기화돼 온다" → 아니다. clearArray:true로 반납하지 않는 한 이전 데이터가 남는다. Rent는 초기화를 보장하지 않는다.
  • "반납은 대충 안 해도 GC가 처리" → 반납 안 하면 그 배열은 풀에 못 돌아와 풀링 효과가 사라진다(성능 저하). 이중 반납은 더 위험(같은 배열이 두 대여자에게).
  • "new byte[n] 대비 항상 빠르다" → 작은 버퍼나 짧은 수명은 GC가 더 쌀 수 있다. 풀은 크고/빈번하고/수명이 명확한 버퍼에서 이득. 프로파일링으로 판단.

면접 포인트

  • "ArrayPool.Rent 의 계약은?" → "요청 이상" 크기, 내용 미초기화. 유효 길이 직접 관리, 민감 데이터는 clearArray 반납, try/finally 로 확실히 반납.
  • "왜 async 메서드에서 Span 을 못 들고 가나?" → Span<T>는 ref struct라 await를 넘는 상태 머신 필드가 될 수 없다. 경계를 넘기려면 Memory<T>/IMemoryOwner<T>.
  • "풀링이 GC 압박을 줄이는 원리?" → 할당/수집 대신 재사용해 Gen0 할당률과 LOH 단편화를 낮춘다. 단 수명·반납 규칙을 어기면 데이터 노출·레이스로 되돌아온다.