Unity에서 버그 캡처를 최소한으로 시작하기

로그 링 버퍼 후킹, 게임 상태 스냅샷, 파일로 남기기까지 — Unity 프로젝트에 붙이는 최소 코드와 스레드 주의점.

  • qa
  • unity
  • bug-report

Unity 버그 리포트 3부작 — ① 재현이 실패하는 이유 · ② 항상 기록하는 접근 · ③ Unity 최소 구현

앞의 두 글에서 무엇이 필요한지(1편)와 어떤 접근을 택할지(2편)를 정리했습니다. 이 글은 그중 영상을 뺀 나머지 — 로그와 게임 상태 — 를 Unity 프로젝트에 붙이는 최소 코드입니다.

전부 갖출 필요는 없습니다. 이 둘만 붙여도 재현율이 눈에 띄게 올라갑니다.

1. 로그를 링 버퍼에 모은다

예외를 놓치지 않으려면 로그 콜백을 후킹합니다. 메인 스레드 밖에서 터지는 예외까지 잡으려면 logMessageReceivedThreaded를 씁니다.

다만 이 선택에는 대가가 따릅니다. 이 콜백은 임의의 스레드에서 호출됩니다. 버퍼 접근은 lock으로 감싸야 하고, Time 계열 API는 메인 스레드 전용이라 워커 스레드에서 호출하면 UnityException이 납니다. 하필 "워커 스레드 예외까지 잡겠다"는 바로 그 상황에서만 터지기 때문에, 개발 중에는 멀쩡하다가 배포 후에 드러나기 쉽습니다.

readonly object _lock = new object();
double _lastMainThreadTime;

void OnEnable()
{
    // 워커 스레드 예외도 포함 (메인 스레드만 필요하면 logMessageReceived)
    Application.logMessageReceivedThreaded += OnLog;
}

void OnDisable()
{
    Application.logMessageReceivedThreaded -= OnLog;
}

void OnLog(string condition, string stackTrace, LogType type)
{
    // Time.* 는 메인 스레드 전용 — 워커 스레드에서는 마지막 값으로 대체한다
    double time;
    try
    {
        time = Time.realtimeSinceStartupAsDouble;
        _lastMainThreadTime = time;
    }
    catch
    {
        time = _lastMainThreadTime;
    }

    // 타입으로 거르지 않는다. 예외 직전의 일반 로그가 원인을 가리키는 경우가 많고,
    // 링버퍼는 용량이 고정이라 전부 담아도 메모리가 늘지 않는다.
    lock (_lock)
    {
        _ringBuffer.Add(new LogEntry {
            Time    = time,
            Type    = type,
            Message = condition,
            Stack   = stackTrace,
        });
    }
}

_ringBuffer는 고정 길이 배열에 쓰기 인덱스를 순환시키는 정도면 충분합니다. 최근 N개만 남기고 오래된 것부터 덮어씁니다.

2. 게임 상태와 환경을 스냅샷한다

이쪽은 캡처 시점에 한 번만 모으면 됩니다. 티켓에서 "인벤토리에서요"로 뭉개지던 부분이 여기서 채워집니다.

// Unity 2021.3+ 기준 (target-typed new 사용)
using System;
using System.Collections.Generic;
using UnityEngine.SceneManagement;

static Dictionary<string, string> Snapshot()
{
    // timeScale 영향을 받지 않도록 unscaledDeltaTime 을 쓰고 0 나누기를 막는다.
    // 일시정지 상태에서 캡처하는 일이 흔하다.
    float dt  = Time.unscaledDeltaTime;
    float fps = dt > 0f ? 1f / dt : 0f;

    return new()
    {
        ["scene"]      = SceneManager.GetActiveScene().name,
        ["playtime"]   = Time.realtimeSinceStartupAsDouble.ToString("F1"),
        ["frame"]      = Time.frameCount.ToString(),
        ["fps"]        = fps.ToString("F0"),
        // Profiler API 는 development build 밖에서 0 을 반환한다
        ["memoryMB"]   = (GC.GetTotalMemory(false) / 1048576).ToString(),
        ["resolution"] = $"{Screen.width}x{Screen.height}",
        ["graphics"]   = SystemInfo.graphicsDeviceType.ToString(),
        ["quality"]    = QualitySettings.names[QualitySettings.GetQualityLevel()],
        ["build"]      = Application.version,
    };
}

여기에 프로젝트별 정보를 얹으면 좋습니다. 인벤토리 개수, 퀘스트 진행도, 접속 서버, 활성 이벤트처럼 1편에서 본 조건 유실에 직접 대응하는 값들입니다.

두 코드가 같은 기준 시각(Time.realtimeSinceStartupAsDouble)을 쓰는 것이 요점입니다. 나중에 영상 프레임과 맞추려면 공통 시간축이 있어야 합니다.

3. 파일로 남긴다

여기까지 와야 "모으기만 하고 못 남기는" 상태를 벗어납니다.

using System.IO;
using System.Linq;

void Update()
{
    if (!Input.GetKeyDown(KeyCode.F9)) return;

    LogEntry[] logs;
    lock (_lock) logs = _ringBuffer.ToArray();   // 스냅샷을 떠서 잠금을 짧게 유지

    var dir  = Path.Combine(Application.persistentDataPath, "bug-reports");
    Directory.CreateDirectory(dir);
    var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");

    File.WriteAllText(Path.Combine(dir, $"{stamp}-state.json"), ToJson(Snapshot()));
    File.WriteAllLines(Path.Combine(dir, $"{stamp}-logs.txt"), logs.Select(l => l.ToString()));
}

핫키 한 번에 두 파일이 떨어집니다. 이 지점부터는 확장이 대체로 명확합니다. 영상 롤링 버퍼를 붙이고, 파일을 티켓 시스템으로 올리고, 팀이 공유하는 뷰어에서 같은 타임라인으로 보는 순서입니다.


저희는 이 흐름을 Unity 플러그인으로 만들어 Rekon에서 제공하고 있습니다. 직접 붙이든 도구를 쓰든 요점은 같습니다. 버그가 발생한 순간의 상태는 그때가 아니면 남길 수 없습니다.