← 문제로

3. 틱 루프의 GC 압박, LOH, 풀 이중 반납

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

해설 — 틱 루프의 GC 압박, LOH, 풀 이중 반납

난이도: 상

요약

풀을 도입했지만 핫패스 곳곳에서 숨은 힙 할당이 발생해 GC 압박이 그대로다. 매 틱·매 룸마다 문자열을 만들고(LogVerbose가 꺼져 있어도), params object[]로 int를 박싱한다. 결정적으로 EventMessage의 페이로드가 96KB(98,304바이트)LOH(Large Object Heap) 임계 85,000바이트를 넘겨 실제로 LOH에 할당된다. 게다가 Return두 번 호출(이중 반납) 해 같은 객체가 풀에 중복 등록되고, 이후 두 곳에서 동시에 Rent되어 상태 오염/데이터 손상과 풀 무결성 붕괴를 일으킨다. 풀이 (C)로 깨지거나 부하로 비면 96KB LOH 객체를 빈번히 새로 할당 → Gen2(LOH는 Gen2와 함께 수집됨) 컬렉션과 틱 스파이크를 만든다.

문제점

(A) LogVerbose용 문자열 연결 — 무조건 할당 (분류: 성능/메모리)

  • 증상: VerboseEnabled == false인데도 매 틱·매 룸마다 string 한 개씩 Gen0에 쌓인다. 60Hz × 룸 수 → 초당 수백 개.
  • 재현조건: 항상. 로그가 꺼져 있어도 문자열은 호출 전에 이미 만들어진다(인자 평가는 항상 일어남).
  • 근본원인: "tick " + tickNo + " room " + ...는 boxing 없는 string이지만 매번 새 string + 내부 임시 할당. 게다가 비활성 로그를 위해 비용을 지불한다. 핫패스 로그의 전형적 안티패턴.

(B) RecordStat(... params object[]) — 박싱 + 배열 할당 (분류: 성능/메모리)

  • 증상: 매 호출마다 (1) object[] 배열 1개, (2) 각 int 인자가 object박싱되어 힙 객체 생성. Gen0 압박.
  • 재현조건: 항상.
  • 근본원인: params object[]는 호출 시 배열을 할당하고, 값타입(int)을 넣으면 박싱(힙 할당 + 복사)이 일어난다. Convert.ToInt64(object)에서 다시 언박싱. 핫패스에서 값타입을 object로 다루는 건 금물.

(C) DrainBroadcast의 이중 반납 — 풀 무결성 붕괴 (분류: 정확성/메모리, 가장 심각)

  • 증상: 같은 EventMessage 인스턴스가 풀에 두 번 들어간다. 이후 서로 다른 두 Rent 호출이 동일 객체를 받아 동시에 사용 → 한 틱의 RoomId/Payload가 다른 틱에서 덮어써지는 데이터 손상, 그리고 풀의 논리적 크기가 부풀어 통계/추적이 깨진다. 최악의 경우 아직 브로드캐스트 큐에 있는 객체가 재대여되어 use-after-return.
  • 재현조건: 부하가 올라 Rent/Return이 잦아질수록 충돌 확률 급증.
  • 근본원인: 리팩터링 잔재로 Return을 두 번 호출. 풀은 "한 객체 = 한 번만 반납"이라는 불변식을 가지는데 이를 깬다. ConcurrentBag은 중복 추가를 막아주지 않는다.

(D) EventMessage.Payload = new byte[96 * 1024] — 실제 LOH 할당 (분류: 메모리)

  • 증상: 96KB = 98,304바이트는 LOH 임계 85,000바이트를 초과하므로 이 배열은 GC가 LOH(Large Object Heap) 에 둔다. 풀이 (C) 때문에 신뢰를 잃거나, 부하 급증으로 _bag이 비면 new EventMessage()가 자주 호출되어 96KB LOH 배열을 계속 할당한다. LOH는 Gen2와 함께(전체 GC에서만) 수집되므로, 이 할당이 잦으면 비싼 Gen2 컬렉션이 빈번해지고 틱 스파이크가 생긴다.
  • 재현조건: 풀 고갈(부하 급증 또는 (C)의 무결성 붕괴 이후) 시. 또한 모든 메시지가 항상 96KB를 점유해 메모리 풋프린트도 과대.
  • 근본원인: 메시지마다 최대치 버퍼를 고정 보유. 대부분 페이로드는 수 바이트인데 96KB를 점유한다. LOH는 기본적으로 컴팩션되지 않아(GCSettings.LargeObjectHeapCompactionMode로 명시 요청해야만 한 번 컴팩션) 단편화도 누적된다. 임계가 정확히 85,000바이트(≈83KB, 85KB가 아님) 라는 점도 핵심: 84KB짜리 버퍼였다면 SOH였겠지만 96KB는 확실히 LOH다.

수정안

핵심: (1) 로그는 가드 또는 구조적 로깅, (2) 통계는 박싱 없는 전용 메서드, (3) 이중 반납 제거 + 재대여 안전장치, (4) 페이로드는 ArrayPool<byte>로 풀링(LOH 직접 할당 제거).

public sealed class EventMessage
{
    public int RoomId;
    public int EventType;
    public byte[] Payload;      // (D) 풀에서 빌린 버퍼를 가리킴 (고정 96KB 배열 제거)
    public int PayloadLen;

    // 풀 재대여 방지용 플래그 (디버그/안전)
    internal bool InPool;

    public void Reset()
    {
        RoomId = 0; EventType = 0; PayloadLen = 0;
        // Payload는 ArrayPool로 별도 관리
    }
}

public sealed class MessagePool
{
    private readonly ConcurrentBag<EventMessage> _bag = new();

    public EventMessage Rent()
    {
        if (_bag.TryTake(out var m))
        {
            m.InPool = false;
            return m;
        }
        return new EventMessage();
    }

    public void Return(EventMessage m)
    {
        // (C) 이중 반납 방어: 이미 풀에 있으면 거부
        if (m.InPool)
            throw new InvalidOperationException("double return detected");
        m.Reset();
        m.InPool = true;
        _bag.Add(m);
    }
}
private void DrainBroadcast()
{
    while (_broadcastQueue.TryDequeue(out var msg))
    {
        Broadcast(msg);
        if (msg.Payload != null)           // (D) 빌린 버퍼 반납
        {
            ArrayPool<byte>.Shared.Return(msg.Payload);
            msg.Payload = null;
        }
        _pool.Return(msg);                 // (C) 단 한 번만
    }
}
public void Tick(int tickNo)
{
    foreach (var roomId in _activeRooms)
    {
        // (A) 가드로 비활성 시 문자열 자체를 안 만듦.
        // 더 나은 형태: 구조적 로깅(보간 문자열 핸들러 활용)
        if (VerboseEnabled)
            LogVerbose($"tick {tickNo} room {roomId} processing");

        var msg = _pool.Rent();
        msg.RoomId = roomId;
        msg.EventType = 7;
        // (D) 필요 크기만 풀에서 빌림. ArrayPool은 큰 버퍼를 재사용하므로
        //     매번 LOH에 새로 할당하지 않는다(풀 내부가 LOH 버킷을 보유).
        msg.Payload = ArrayPool<byte>.Shared.Rent(96 * 1024);
        msg.PayloadLen = FillPayload(msg.Payload, roomId, tickNo);

        _broadcastQueue.Enqueue(msg);

        RecordStat("room_event", roomId + tickNo); // (B) 박싱 없는 시그니처
    }
    DrainBroadcast();
}

// (B) params object[] 제거 → 값타입 전용
private void RecordStat(string name, long value)
{
    lock (s_stats)
    {
        s_stats.TryGetValue(name, out var prev);
        s_stats[name] = prev + value;
    }
}

LogVerbose($"...")if (VerboseEnabled) 가드 안에 있으므로 비활성 시 보간 문자열을 만들지 않는다. .NET 6+의 [InterpolatedStringHandler] 기반 로깅(예: ILogger.LogDebug)을 쓰면 가드 없이도 비활성 레벨에서 할당을 피한다.

주의: ArrayPool<byte>.Shared.Rent(n)요청보다 큰 버퍼를 줄 수 있다(2의 거듭제곱 버킷). 그래서 실제 길이는 별도 필드(PayloadLen)로 추적하고, 슬라이스(Span<byte>)로 다뤄야 한다. 또한 큰 버킷은 풀 자체가 내부적으로 LOH에 보관하지만, 매 틱 새로 할당하지 않고 재사용하므로 LOH로 가는 신규 할당이 사라진다 — 이게 핵심이다.

더 나은 설계

  1. struct 헤더 + 버퍼 분리: EventMessage를 가벼운 struct(또는 헤더만 클래스)로 두고 페이로드는 ArrayPool<byte>/Memory<byte>로 분리하면 메시지 자체 할당이 사라진다. 트레이드오프: struct 복사 비용, 큐에 넣을 때 ConcurrentQueue<T>는 T가 struct여도 박싱 없음(인터페이스 캐스팅만 피하면 됨).

  2. 풀 무결성 강화: InPool 플래그 외에 디버그 빌드에서 HashSet으로 대여/반납 추적, 또는 토큰/버전 카운터로 use-after-return을 탐지. 운영 빌드는 비용 0인 플래그만.

  3. LOH 회피 원칙: 85,000바이트 이상 객체는 LOH로 가고 기본적으로 컴팩션 안 됨(LargeObjectHeapCompactionMode.CompactOnce로 1회 명시 요청만 가능) → 큰 버퍼는 반드시 풀링(ArrayPool)하거나 청크로 분할. 본 코드처럼 메시지마다 큰 고정 배열을 들지 말 것. 임계를 외워둘 것: 85,000바이트(약 83KB이며 흔히 헷갈리는 64KB·85KB가 아니다).

  4. GC 모드 튜닝: 서버 GC(<ServerGarbageCollection>true) + Concurrent/Background GC, .NET 8의 DATAS 고려. LOH가 Gen2와 함께 수집되므로 LOH 할당을 줄이는 것이 곧 Gen2 빈도 감소다. 단, 근본은 "할당을 안 만드는 것"이다. GC 설정은 보조.

  5. 할당 측정: dotnet-counters(LOH 크기/Gen2 횟수 모니터링)/dotnet-trace, ETW, BenchmarkDotNet의 [MemoryDiagnoser]로 "틱당 바이트 할당"과 LOH 증가를 0에 수렴시키는 것을 목표로 회귀 테스트.

면접 포인트

  1. LOH의 임계 크기는 정확히 몇 바이트인가? (85,000바이트) 64KB(65,536B)짜리 배열은 LOH인가 SOH인가? (SOH — 임계 미만) LOH가 기본적으로 컴팩션되지 않는 이유와 단편화 대응책은?
  2. LOH는 어느 세대와 함께 수집되는가? (Gen2/전체 GC) 그래서 LOH 빈번 할당이 왜 틱 스파이크로 이어지는가?
  3. C#에서 박싱이 정확히 언제 일어나는가? params object[], Dictionary<string, object>, 인터페이스로 값타입 전달 등 핫패스에서 박싱이 숨는 곳을 예로 들어라. 오브젝트 풀에서 "이중 반납"이 왜 위험한가? (use-after-return, 동일 인스턴스 동시 사용, 풀 카운트 오염)