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에서 제공하고 있습니다. 직접 붙이든 도구를 쓰든 요점은 같습니다. 버그가 발생한 순간의 상태는 그때가 아니면 남길 수 없습니다.