게임 씬 전환 GC 스파이크를 AI로 자동 프로파일링하는 방법
Unity 씬 전환 중 발생하는 GC 스파이크를 자동 감지하고 원인 후보와 성능 회귀 여부를 AI가 분류하도록 구성하는 실전 프로파일링 파이프라인을 소개합니다.

핵심 요약
Unity 씬 전환 구간을 SceneManager.sceneLoaded 이벤트 기준으로 측정하고 ProfilerRecorder, GC.CollectionCount(), 프레임 타임을 함께 수집하면 GC 스파이크를 자동으로 감지할 수 있습니다. 수집한 시계열 데이터를 임계값 규칙과 AI 분류기에 전달하면 원인 후보, 심각도, 회귀 여부를 빌드 리포트로 자동 생성할 수 있습니다.
씬 전환에서 GC 스파이크는 왜 발생하는가?
씬 전환은 기존 오브젝트 해제, 새 프리팹 생성, 컴포넌트 초기화, 주소 기반 에셋 로드, 문자열 처리, 컬렉션 확장이 한꺼번에 일어나는 구간입니다. 이때 관리 힙 할당량이 짧은 시간에 증가하면 Unity의 가비지 컬렉터가 수집을 수행하고 메인 스레드가 잠시 멈출 수 있습니다.
특히 다음 패턴이 자주 문제를 만듭니다.
| 원인 후보 | 전환 구간에서 나타나는 특징 | 확인 방법 |
|---|---|---|
| 대량 프리팹 인스턴스화 | 씬 로드 직후 GC.Alloc와 프레임 시간이 함께 증가 | Unity Profiler의 CPU Usage와 GC.Alloc 확인 |
List<T>, Dictionary<TKey,TValue> 재할당 | 초기화 함수가 실행될 때 할당이 계단식으로 증가 | Capacity 설정 전후 비교 |
| LINQ와 클로저 | 짧은 함수 호출이 반복되며 작은 할당이 누적 | Deep Profile 또는 코드 검색 |
| 문자열 연결과 포맷팅 | 로그, UI 바인딩, 키 생성 시 할당 증가 | 문자열 API와 로그 호출 점검 |
| 이벤트 구독 누락 | 씬을 반복 전환할수록 콜백과 객체가 누적 | 구독·해제 쌍 검증 |
| 리소스 해제 지연 | 이전 씬의 참조가 남아 수집 시점의 비용 증가 | 참조 그래프와 메모리 스냅샷 비교 |
GC 스파이크는 단순히 총 할당량이 많다는 뜻과 다릅니다. 같은 10MB 할당이라도 여러 프레임에 분산되면 문제가 작을 수 있고 한 프레임에 집중되면 로딩 끊김으로 이어질 수 있습니다.
자동 프로파일링은 어떤 데이터를 수집해야 하는가?
AI에게 원시 로그만 전달하면 원인 분석 품질이 낮아집니다. 먼저 씬 전환을 하나의 측정 윈도우로 정의하고 각 윈도우에 다음 데이터를 묶어야 합니다.
- 전환 전 씬과 전환 후 씬
- 빌드 번호, 브랜치, 커밋 해시
- 플랫폼과 그래픽 API
- 전환 시작 시각과 완료 시각
- 전환 중 최대 프레임 시간
- 프레임 시간 95·99퍼센타일
- 스레드별 관리 힙 할당량
- 세대별 GC 실행 횟수
- GC 전후 메모리 사용량
- 동일 시나리오의 반복 횟수와 중앙값
프레임 시간의 백분위수는 다음과 같이 계산할 수 있습니다.
여기서 는 각 프레임의 밀리초 단위 시간이고 는 0.95 또는 0.99입니다. 평균값만 사용하면 한두 번의 긴 멈춤이 가려질 수 있으므로 최대값과 백분위수를 함께 기록하는 편이 안전합니다.
Unity에서 씬 전환 측정기를 어떻게 구현할까?
아래 예시는 Unity 2022.3 LTS 계열 프로젝트에서 사용할 수 있는 형태의 간단한 측정기입니다. 실제 프로젝트에서는 개발 빌드 또는 프로파일링 전용 빌드에만 포함하고 릴리스 빌드에서는 비활성화하는 편이 좋습니다.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Unity.Profiling;
using UnityEngine;
using UnityEngine.SceneManagement;
public sealed class SceneGcProfiler : MonoBehaviour
{
[SerializeField] private float sampleWindowSeconds = 8f;
[SerializeField] private float gcSpikeThresholdMs = 8f;
private ProfilerRecorder gcAllocRecorder;
private readonly List<float> frameTimes = new();
private Stopwatch transitionWatch;
private string fromScene;
private string toScene;
private int gen0Before;
private int gen1Before;
private int gen2Before;
private long allocatedBefore;
private bool collecting;
private void Awake()
{
DontDestroyOnLoad(gameObject);
SceneManager.activeSceneChanged += OnActiveSceneChanged;
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void OnEnable()
{
gcAllocRecorder = ProfilerRecorder.StartNew(
ProfilerCategory.Memory,
"GC.Alloc",
256);
}
private void OnDisable()
{
if (gcAllocRecorder.Valid)
gcAllocRecorder.Dispose();
}
private void OnDestroy()
{
SceneManager.activeSceneChanged -= OnActiveSceneChanged;
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnActiveSceneChanged(Scene previous, Scene next)
{
fromScene = previous.name;
toScene = next.name;
gen0Before = GC.CollectionCount(0);
gen1Before = GC.CollectionCount(1);
gen2Before = GC.CollectionCount(2);
allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
transitionWatch = Stopwatch.StartNew();
frameTimes.Clear();
collecting = true;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (!collecting)
return;
Invoke(nameof(FinishWindow), sampleWindowSeconds);
}
private void Update()
{
if (!collecting)
return;
frameTimes.Add(Time.unscaledDeltaTime * 1000f);
}
private void FinishWindow()
{
collecting = false;
transitionWatch.Stop();
var report = SceneGcReport.Create(
fromScene,
toScene,
transitionWatch.Elapsed.TotalMilliseconds,
allocatedBefore,
GC.GetAllocatedBytesForCurrentThread(),
GC.CollectionCount(0) - gen0Before,
GC.CollectionCount(1) - gen1Before,
GC.CollectionCount(2) - gen2Before,
frameTimes,
gcAllocRecorder.Valid ? gcAllocRecorder.LastValue : 0,
gcSpikeThresholdMs);
Debug.Log(report.ToJson());
}
}
핵심은 씬 로드 완료 시점을 측정 종료로 보지 않는 것입니다. 로드 완료 이벤트 뒤에도 Awake, OnEnable, UI 바인딩, 첫 프레임 셰이더 준비 같은 작업이 이어질 수 있으므로 5~10초의 후속 윈도우를 별도로 관찰해야 합니다.
GC.GetAllocatedBytesForCurrentThread()는 현재 스레드 기준 값이므로 전체 프로세스 할당량으로 해석하면 안 됩니다. 메인 스레드의 씬 초기화 비용을 추적하는 지표로 사용하고 전체 메모리 변화는 Memory Profiler 스냅샷이나 플랫폼별 메모리 카운터로 보완해야 합니다.

AI 자동 판정 파이프라인은 어떻게 구성할까?
AI가 해야 할 일은 수집 자체가 아니라 여러 지표를 연결해 원인 후보를 우선순위화하는 것입니다. 다음 순서로 구성하면 CI에서도 재현 가능한 결과를 얻기 쉽습니다.
1단계: 기준선과 테스트 시나리오를 고정한다
씬 전환 경로를 테스트 케이스로 고정합니다. 예를 들어 Boot → Lobby, Lobby → Battle, Battle → Result처럼 실제 서비스에서 자주 실행되는 경로를 선택하고 각 경로를 동일한 횟수만큼 반복합니다.
테스트 환경도 함께 고정해야 합니다.
- 동일한 해상도와 프레임 제한
- 동일한 그래픽 품질 설정
- 동일한 테스트 계정과 세이브 데이터
- 동일한 에셋 번들 또는 Addressables 캐시 상태
- 웜업 실행 횟수
- 측정 반복 횟수
첫 실행은 셰이더 컴파일과 파일 캐시의 영향을 받을 수 있으므로 웜업 결과와 본 측정 결과를 분리합니다.
2단계: 규칙 기반으로 스파이크를 먼저 걸러낸다
AI 호출 전에 명확한 실패 규칙을 적용합니다. 예시는 다음과 같습니다.
| 판정 조건 | 의미 | 기본 대응 |
|---|---|---|
P99 프레임 시간 > 33.3ms | 30FPS 기준 프레임 드롭 가능성 | 성능 경고 |
최대 프레임 시간 > 100ms | 사용자가 체감할 정지 가능성 | 실패 후보 |
| Gen 0 수집 횟수 증가 | 단기 할당이 집중됨 | 원인 분석 |
| Gen 2 수집 발생 | 장수 객체 또는 힙 압박 가능성 | 심각도 상향 |
| 기준선 대비 P99 20% 이상 증가 | 성능 회귀 가능성 | 빌드 차단 검토 |
임계값은 모든 플랫폼에 동일하게 적용하지 않습니다. 모바일 저사양 기기와 데스크톱의 기준선을 분리하고 절대값과 이전 빌드 대비 변화율을 함께 사용해야 합니다.
3단계: 정규화된 JSON을 AI에 전달한다
AI 입력에는 전체 로그 대신 측정 결과를 정규화한 JSON을 사용합니다. 이렇게 하면 토큰 사용량이 줄고 빌드 간 비교도 쉬워집니다.
{
"build": {
"number": "2026.08.24.1530",
"commit": "abc1234",
"platform": "Android",
"deviceTier": "mid"
},
"transition": {
"from": "Lobby",
"to": "Battle",
"warmupRuns": 2,
"measuredRuns": 10,
"windowMs": 8000
},
"metrics": {
"medianFrameMs": 17.4,
"p95FrameMs": 28.1,
"p99FrameMs": 76.8,
"maxFrameMs": 142.5,
"gen0Collections": 4,
"gen1Collections": 1,
"gen2Collections": 0,
"mainThreadAllocatedBytes": 1843200,
"gcAllocLastValue": 32768
},
"baseline": {
"p99FrameMs": 41.2,
"mainThreadAllocatedBytes": 1105920
}
}
AI의 출력도 자유 형식 문장으로 받지 말고 JSON 스키마를 강제합니다.
{
"severity": "warning",
"regression": true,
"confidence": 0.86,
"suspectedCauses": [
{
"category": "scene-initialization-allocation",
"evidence": [
"P99 frame time increased by 86.4%",
"main thread allocation increased by 66.7%"
],
"nextChecks": [
"inspect Battle UI binding",
"compare List capacity and LINQ usage"
]
}
],
"recommendation": "capture CPU Usage Timeline and Memory Profiler snapshot"
}
AI는 원인을 확정하는 도구가 아니라 조사 순서를 정하는 도구로 운영해야 합니다. Battle UI binding이 원인이라고 단정하지 말고 CPU Usage Timeline에서 실제 호출 스택과 할당 위치를 다시 확인하도록 리포트 문구를 설계합니다.
씬 전환 GC 스파이크를 CI에서 어떻게 감지할까?
자동화의 목표는 매 빌드마다 같은 테스트를 실행하고 기준선과 비교한 뒤 사람이 확인할 정보만 남기는 것입니다.
- 테스트 런너가 지정된 씬 전환 시나리오를 반복 실행합니다.
- 개발 빌드가 씬 이름, 프레임 시간, GC 횟수, 할당량을 JSON Lines로 기록합니다.
- 수집기가 반복 측정값의 중앙값, P95, P99, 최대값을 계산합니다.
- 기준선과 비교해 규칙 기반 경고 또는 실패를 판정합니다.
- 판정된 데이터만 AI 분석기에 전달합니다.
- AI가 원인 후보와 다음 확인 항목을 구조화된 JSON으로 반환합니다.
- CI가 빌드 요약에 지표와 AI 분석 결과를 함께 첨부합니다.
flowchart TD
A[씬 전환 테스트 실행] --> B[ProfilerRecorder와 GC 카운터 수집]
B --> C[반복 측정값 정규화]
C --> D{기준선 대비 회귀 여부}
D -->|아니오| E[성능 리포트 저장]
D -->|예| F[AI 원인 후보 분류]
F --> G[호출 스택과 메모리 스냅샷 재확인]
G --> H[빌드 경고 또는 차단]
빌드 차단은 AI의 자연어 판단만으로 결정하지 않는 것이 좋습니다. 차단 여부는 P99 프레임 시간, 최대 프레임 시간, GC 횟수처럼 수치화된 규칙이 결정하고 AI 결과는 담당자가 빠르게 원인을 찾도록 돕는 보조 정보로 사용합니다.
오탐을 줄이려면 무엇을 조정해야 하는가?
씬 전환 성능 테스트는 환경 변동이 크기 때문에 단일 실행 결과로 회귀를 선언하면 오탐이 많아집니다.
첫째, 최소 5회 이상의 본 측정을 수행하고 중앙값과 P95를 함께 저장합니다. 둘째, 기기 성능 등급별로 기준선을 분리합니다. 셋째, Addressables 또는 에셋 번들의 캐시 상태를 cold, warm으로 구분합니다. 넷째, AI 프롬프트에 측정 신뢰도와 누락된 카운터를 명시합니다.
예를 들어 다음처럼 데이터 품질을 함께 전달할 수 있습니다.
{
"dataQuality": {
"sampleCount": 10,
"missingMetrics": [],
"cacheState": "warm",
"confidenceNote": "stable across 8 of 10 runs"
}
}
신뢰도 값은 AI가 임의로 만들어내게 두기보다 반복 횟수, 결측치, 변동계수 같은 계산 가능한 지표에서 산출하는 편이 낫습니다.
변동계수 가 높다면 동일 테스트를 더 반복하거나 결과를 관찰 필요로 분류해야 합니다. 기준선과 비교할 때는 평균 차이보다 분포의 겹침과 최대값을 함께 확인합니다.
최적화 수정 후 무엇을 다시 검증해야 하는가?
GC 스파이크가 확인되면 다음 항목을 우선 점검합니다.
씬 초기화 코드
- 반복문 안의 LINQ,
ToList(),Where(),Select()제거 또는 사전 계산 - UI 갱신 시 문자열 보간과
string.Format()호출 축소 - 컬렉션 생성 시 예상 크기를
Capacity로 예약 - 런타임 리플렉션과 반복적인 타입 조회 캐시
- 이벤트 구독과 해제를 동일한 생명주기에서 처리
오브젝트와 에셋 관리
- 대량 생성 구간에 오브젝트 풀 적용
- 씬 전환마다 새로 만드는 임시 버퍼 재사용
- 사용하지 않는 이전 씬 참조 해제
- Addressables 핸들 릴리스 시점 검증
Resources.UnloadUnusedAssets()호출 비용을 일반 프레임 측정과 분리
수정 후에는 같은 기기, 같은 캐시 상태, 같은 반복 횟수로 재실행합니다. 할당량이 줄었더라도 최대 프레임 시간이 개선되지 않았다면 GC 외에 동기 에셋 로드, 디스크 I/O, 셰이더 준비, 네이티브 플러그인 작업이 병목일 수 있습니다.

자주 묻는 질문(FAQ)
GC.Alloc이 증가하면 반드시 GC 멈춤이 발생하나요?
아닙니다. GC.Alloc은 관리 힙 할당량을 보여주는 지표이고 실제 수집 시간과는 다릅니다. 프레임 시간, GC.CollectionCount() 변화, CPU Usage Timeline을 함께 확인해야 합니다.
AI가 프로파일러를 대신해 원인을 확정할 수 있나요?
할 수 없습니다. AI는 지표와 호출 스택에서 가능성이 높은 원인과 다음 조사 항목을 정리하는 데 적합합니다. 최종 원인 확정은 Unity Profiler, Memory Profiler, 코드 검토로 수행해야 합니다.
모든 씬 전환을 CI에서 매번 측정해야 하나요?
핵심 사용자 경로와 과거 회귀가 발생했던 경로를 매 빌드 측정하고 전체 씬 조합은 야간 또는 릴리스 후보 빌드에서 실행하는 방식이 현실적입니다.
정리
씬 전환 GC 스파이크 자동화의 핵심은 GC.Alloc 하나를 보는 것이 아니라 전환 구간, 프레임 시간 분포, GC 횟수, 메인 스레드 할당량, 기준선 변화를 하나의 데이터셋으로 묶는 데 있습니다. 수치 기반 규칙으로 회귀를 판정하고 AI는 원인 후보와 조사 순서를 구조화하면 프로파일링 결과를 QA와 CI 파이프라인에서 반복적으로 활용할 수 있습니다.



