Unity 로그 시스템 깊게 파기 ② — GC 할당 없는 링 버퍼 설계

로그 수집기가 GC 스파이크를 만들면 본말전도입니다. struct 엔트리, 고정 배열, 통제 가능한 할당과 불가능한 할당의 구분까지.

  • unity
  • logging
  • performance

Unity 로그 시스템 3부작 — ① 콜백의 함정 · ② GC 없는 링 버퍼 · ③ 빌드에서 달라지는 것 (③ 공개 예정)

앞 편에서 로그 콜백을 안전하게 받는 법을 다뤘습니다. 이번엔 받은 로그를 담는 쪽입니다. 요구사항은 역설적입니다 — 성능 버그를 잡으려고 만든 수집기가 GC 스파이크의 원인이 되면 안 됩니다. 진단 도구는 관측 대상을 오염시키지 않아야 합니다.

어디서 할당이 새는가

순진한 구현의 할당 지점을 세어 봅니다.

// ❌ 매 로그마다 할당 3곳
_logs.Add(new LogEntry {           // ① class 라면 힙 할당
    Message = $"[{type}] {condition}",  // ② 문자열 보간 = 새 문자열
    ...
});
if (_logs.Count > Max) _logs.RemoveAt(0);  // ③ List 앞쪽 제거 = 전체 복사

전투 씬에서 초당 수십 개 로그가 찍히는 프로젝트라면, 이 세 줄이 조용히 GC 압력을 만듭니다.

원칙 1. 엔트리는 struct, 저장소는 고정 배열

struct LogEntry            // class 가 아니라 struct — 배열에 인라인 저장된다
{
    public double Time;
    public LogType Type;
    public string Message;   // 참조만 저장 (아래 '통제 불가능한 할당' 참조)
    public string Stack;
}

sealed class LogRingBuffer
{
    readonly LogEntry[] _entries;   // 시작 시 1회 할당, 이후 재사용
    int _head;
    int _count;

    public LogRingBuffer(int capacity) => _entries = new LogEntry[capacity];

    public void Add(in LogEntry entry)
    {
        _entries[_head] = entry;              // struct 복사 — 힙 할당 없음
        _head = (_head + 1) % _entries.Length;
        if (_count < _entries.Length) _count++;
    }
}

List.RemoveAt(0) 같은 것도 없습니다. 순환 인덱스가 "오래된 것부터 덮어쓰기"를 공짜로 해 줍니다.

원칙 2. 통제 가능한 할당과 불가능한 할당을 구분한다

정직하게 말하면 이 수집기는 완전한 zero-alloc이 아닙니다. conditionstackTrace 문자열은 Unity가 콜백을 부르기 전에 이미 할당한 것입니다. 우리가 참조를 저장하든 안 하든 그 비용은 발생했습니다.

이 구분이 설계를 단순하게 만듭니다.

  • 이미 발생한 할당 (Unity가 만든 문자열): 참조만 저장. 복사·가공하지 않는 한 추가 비용 0
  • 우리가 만드는 할당 (보간, 포매팅, 컬렉션 재할당): 전부 제거 대상
  • 로그 자체의 할당이 문제라면: 수집기가 아니라 로그를 줄여야 합니다 — 3편에서 다룰 Debug.Log의 비용 문제입니다

포매팅($"[{type}] {condition}")은 저장 시점이 아니라 파일로 내보내는 시점에 합니다. 내보내기는 사용자가 캡처를 눌렀을 때 한 번이므로, 거기서의 할당은 스파이크가 아니라 단발성 비용입니다.

원칙 3. 스냅샷은 빌려 쓴다

캡처 시점에 ToArray()로 스냅샷을 뜨면 그 순간 배열 하나가 할당됩니다. 대부분의 프로젝트에서는 이 정도(캡처당 1회)는 허용해도 됩니다. 캡처가 잦거나 버퍼가 크다면 재사용 버퍼에 복사하는 방식으로 이것도 없앨 수 있습니다.

// 재사용 버퍼로 스냅샷 — 호출자가 버퍼를 소유한다
public int CopyTo(LogEntry[] dest)
{
    lock (_lock)
    {
        int n = Math.Min(_count, dest.Length);
        for (int i = 0; i < n; i++)
            dest[i] = _entries[(_head - n + i + _entries.Length) % _entries.Length];
        return n;
    }
}

인덱스 계산이 틀리기 쉬운 지점이므로(head 이전 n개를 시간순으로), 이 함수 하나는 테스트를 붙일 가치가 있습니다.

용량 산정 — 몇 개면 되는가

"최근 30초의 맥락"이 목표라면, 프로젝트의 평상시 로그 빈도 × 30초에 스파이크 여유를 곱해 잡습니다. 로그가 초당 10개인 프로젝트면 300 + 여유로 512 정도. 엔트리가 참조 2개 + 값 필드라 버퍼 자체의 메모리는 수십 KB 수준이고, 실제 메모리는 문자열들이 차지합니다 — 이건 버퍼 크기가 아니라 로그 양의 함수입니다.


이제 수집은 안전하고 저장은 조용합니다. 마지막 남은 변수는 환경입니다 — 에디터에서 완벽하게 돌던 이 코드가 IL2CPP release 빌드에서는 스택트레이스가 비어 있고, 어떤 API는 0만 반환합니다. 마지막 편에서 빌드가 바꾸는 것들을 다룹니다.