AI 기반 모바일 메모리 릭 추적과 C# 이벤트 핸들러 해제 누락 자동 탐지

Unity 모바일에서 씬 전환 뒤 메모리가 줄지 않는 원인을 Memory Profiler와 AI로 좁히고 Roslyn 분석기로 C# 이벤트 핸들러 해제 누락을 자동 탐지하는 절차를 소개합니다. IL2CPP 빌드 검증과 CI 적용 기준까지 함께 설명합니다.

✓ 복사 완료!
챕터자동화 테스트 & 회귀 검증
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
AI 기반 모바일 메모리 릭 추적과 C# 이벤트 핸들러 해제 누락 자동 탐지

핵심 요약

관리 메모리 릭은 GC 자체의 문제라기보다, 씬이 종료된 뒤에도 객체를 참조하는 루트가 남아 발생하는 경우가 많습니다. Unity Memory Profiler의 반복 스냅샷과 참조 경로를 확인하고 Roslyn Analyzer로 이벤트 구독·해제 쌍을 검사하면 원인 후보를 좁히는 데 도움이 됩니다.

AI는 원시 덤프를 직접 보고 단정하기보다 retained size, 참조 경로, 씬 반복 횟수, 이벤트 분석 결과를 구조화된 데이터로 받아 원인 후보를 정렬하고 다음 검증 절차를 제안하는 역할로 사용하는 것이 안전합니다.

모바일 메모리 릭은 왜 반복적인 씬 전환 뒤에 드러나는가?

Unity 모바일 메모리는 관리 힙과 네이티브 메모리뿐 아니라 GPU 리소스와 프로세스 수준의 사용량도 함께 관찰해야 합니다.

영역주요 대상대표적인 릭 원인확인 방법
Managed HeapC# 객체, MonoBehaviour, 컬렉션static 참조, 이벤트 구독, 캐시 미정리Unity Memory Profiler, GC 스냅샷
Native MemoryTexture2D, Mesh, AudioClipUnityEngine.Object 수명 관리 오류, Addressables 핸들 미반환Memory Profiler, native allocation 추적
GPU Memory텍스처, 버퍼, 렌더 타깃해상도별 리소스 중복, 렌더 타깃 미해제플랫폼 GPU 도구, 프레임 디버거
프로세스 메모리Android PSS, iOS memory footprintmanaged·native·GPU 리소스 변화와 함께 나타나는 프로세스 사용량 증가dumpsys meminfo, Xcode Instruments

특히 씬을 한 번 로드한 뒤에는 문제가 드러나지 않다가 같은 씬을 5~10회 반복하면 누적 증가가 보일 수 있습니다. 한 번의 GC 이후 메모리가 줄지 않았다는 사실만으로 릭을 확정해서는 안 됩니다. Unity의 allocator가 메모리를 재사용하거나 OS에 즉시 반환하지 않을 수 있기 때문입니다.

반복 테스트에서는 다음 값을 함께 기록합니다.

ΔMcycle=Mafter_N−Mafter_baseline\Delta M_{cycle} = M_{after\_N} - M_{after\_baseline}

여기서 M은 동일한 안정화 조건에서 측정한 메모리 사용량입니다. 중요한 것은 단일 시점의 크기보다 같은 시나리오를 반복했을 때 증가량이 수렴하는지 여부입니다.

C# 이벤트 핸들러는 어떻게 객체를 살려 두는가?

C# 이벤트의 publisher는 invocation list를 통해 subscriber의 delegate를 강한 참조로 보유합니다. subscriber가 씬에서 제거되어도 publisher가 살아 있으면 subscriber와 subscriber가 참조하는 UI, 모델, 텍스처까지 GC 루트에서 도달 가능할 수 있습니다.

가장 위험한 패턴은 다음과 같습니다.

public sealed class InventoryView : MonoBehaviour
{
    [SerializeField] private InventoryModel model;

    private void OnEnable()
    {
        model.Changed += HandleChanged;
    }

    private void HandleChanged(ItemChangedArgs args)
    {
        // UI 갱신
    }
}

OnEnable에서 구독했지만 OnDisable 또는 OnDestroy에서 해제하지 않았습니다. InventoryModel이 씬보다 오래 사는 서비스나 static 객체라면 InventoryView가 파괴된 뒤에도 이벤트 체인에 남을 수 있습니다.

수명 주기가 같은 경우에는 다음처럼 대칭을 맞춥니다.

public sealed class InventoryView : MonoBehaviour
{
    [SerializeField] private InventoryModel model;

    private void OnEnable()
    {
        model.Changed += HandleChanged;
    }

    private void OnDisable()
    {
        model.Changed -= HandleChanged;
    }

    private void HandleChanged(ItemChangedArgs args)
    {
        // UI 갱신
    }
}

익명 람다는 해제할 때 같은 delegate 인스턴스가 필요합니다. 따라서 다음과 같이 필드에 보관해야 합니다.

private Action<Item> _onItemChanged;

private void OnEnable()
{
    _onItemChanged = item => HandleItemChanged(item);
    model.ItemChanged += _onItemChanged;
}

private void OnDisable()
{
    if (_onItemChanged != null)
    {
        model.ItemChanged -= _onItemChanged;
        _onItemChanged = null;
    }
}

AI 기반 메모리 릭 탐지 시스템은 어떤 데이터를 결합하는가?

AI 분석에 retained size, 참조 경로, 반복 횟수, 이벤트 분석 결과를 구조화해 제공하면 검토 근거를 더 명확하게 정리할 수 있습니다. 원본 .snap 파일이나 전체 로그를 그대로 전달하기보다 분석기에 필요한 증거를 먼저 추출합니다.

입력 데이터추출할 필드AI가 판단할 수 있는 내용
Memory Profiler diff타입, 개수, retained size, 증가량반복 후 남는 객체 후보
Retained pathGC root, static 필드, publisher, subscriber객체를 유지하는 참조 경로
라이프사이클 로그씬 전환, OnEnable, OnDisable, OnDestroy 횟수구독과 해제의 비대칭
Roslyn 분석 결과이벤트 심볼, 구독 위치, 해제 위치, 진단 ID정적 코드상 누락 가능성
런타임 계측이벤트 호출 횟수, 객체 ID, 생성·파괴 횟수재현 중 실제 생존 여부

추천하는 분석 순서는 결정론적 필터를 먼저 적용하고 AI를 후순위로 배치하는 것입니다.

  1. 두 스냅샷의 타입별 개수와 retained size를 비교합니다.
  2. 씬 전환 이후에도 증가한 타입의 retained path를 추출합니다.
  3. path에 static 필드, 서비스 컨테이너, 이벤트 publisher가 있는지 표시합니다.
  4. Roslyn Analyzer의 이벤트 진단 결과와 런타임 라이프사이클 로그를 결합합니다.
  5. AI는 근거가 많은 후보부터 정렬하고 추가 검증 명령을 생성합니다.

후보 우선순위는 프로젝트에 맞게 조정하되 다음과 같은 점수 모델로 시작할 수 있습니다.

LeakScore=0.35R+0.25P+0.20L+0.20C\mathrm{LeakScore}=0.35R+0.25P+0.20L+0.20C

R은 정규화한 retained bytes, P는 반복 횟수에 따른 지속성, L은 생성·파괴와 라이프사이클의 불일치, C는 정적 코드 분석 증거입니다. 예시 점수를 비교하려면 네 항목을 같은 0~1 범위로 정규화해야 하며, 가중치는 프로젝트 데이터로 조정해야 합니다. 이 점수는 진단 보조용이지 릭 여부를 확정하는 값은 아닙니다.

Unity Memory Profiler에서 씬 반복 전후 Managed Heap과 Retained Size를 비교하는 화면

C# 이벤트 해제 누락은 어떻게 자동 탐지할까?

Microsoft.CodeAnalysis.CSharp 4.x 기반 Roslyn Analyzer를 별도 검사 프로젝트로 구성하면 분석 도구의 실행 환경을 Unity 에디터와 분리할 수 있습니다. 다만 Unity 타입을 의미 분석하려면 대상 프로젝트의 참조 어셈블리와 C# 언어 설정을 검사 환경에 반영해야 합니다. 핵심은 단순히 +=가 존재하는지 확인하는 것이 아니라 같은 이벤트 심볼과 같은 handler를 기준으로 수명 주기 쌍을 매칭하는 것입니다.

1단계: 이벤트 구독 지점을 수집한다

구문 트리에서 AddAssignmentExpression을 찾고 semantic model로 좌변이 실제 IEventSymbol인지 확인합니다. 다음 정보를 저장합니다.

  • publisher의 타입과 멤버 심볼
  • subscriber 인스턴스의 타입
  • handler 메서드 또는 delegate 필드
  • 구독이 발생한 메서드와 소스 위치
  • 해당 타입이 MonoBehaviour, IDisposable, 일반 C# 서비스 중 무엇인지

2단계: 수명 주기에 맞는 해제 지점을 매칭한다

Unity 컴포넌트는 보통 다음 규칙을 적용할 수 있습니다.

구독 위치우선 검사할 해제 위치진단 기준
OnEnableOnDisable동일 publisher·event·handler의 -= 필요
Awake, StartOnDestroy 또는 명시적 Dispose객체 수명 종료 전에 해제 필요
생성자, 초기화 메서드DisposeIDisposable 구현과 대칭 검사
static event 구독OnDisable, OnDestroy, Dispose일반 이벤트보다 높은 위험도로 표시
익명 람다 구독delegate 필드 해제동일 lambda 표현식만으로는 충분하지 않음

OnEnable과 OnDisable을 기계적으로 매칭하면 오탐이 생길 수 있습니다. publisher가 subscriber보다 먼저 파괴되는 구조라면 릭이 발생하지 않을 수 있으므로 DI 컨테이너 수명, 서비스 레지스트리, 씬 영속 객체 정보를 프로젝트 설정으로 제공하는 편이 좋습니다.

3단계: 진단 ID와 CI 정책을 연결한다

예시 진단 규칙은 다음과 같이 구성할 수 있습니다.

진단 ID의미권장 기본 심각도
CL001OnEnable의 이벤트 구독에 대응하는 OnDisable 해제 없음error
CL002초기화 메서드 구독에 대응하는 종료 해제 없음warning
CL003저장하지 않은 익명 lambda를 이벤트에 직접 구독error
CL004MonoBehaviour가 static event에 구독warning 또는 error
CL005구독과 해제의 publisher 또는 handler 심볼이 다름error

표의 심각도와 CI 차단 여부는 예시 정책입니다. publisher 수명처럼 코드에서 추론하기 어려운 조건 때문에 오탐이 생길 수 있으므로, 프로젝트별 수명 규칙과 결과를 검증한 뒤 높은 확신도의 진단만 오류로 승격해야 합니다.

.editorconfig에서는 검증된 진단만 오류로 승격하는 예를 다음처럼 구성할 수 있습니다.

dotnet_diagnostic.CL001.severity = error
dotnet_diagnostic.CL003.severity = error
dotnet_diagnostic.CL004.severity = warning
dotnet_diagnostic.CL005.severity = error

Unity 프로젝트에서는 모든 코드를 한 번에 검사하기보다 Assets/Scripts/Gameplay, Assets/Scripts/UI, Assets/Scripts/Services처럼 관리 대상 어셈블리를 나누는 것이 좋습니다. PR에서는 CL001, CL003, CL005를 차단하고 기존 코드의 경고는 별도 baseline 파일로 관리하면 신규 릭 유입을 먼저 막을 수 있습니다.

모바일에서 메모리 릭을 재현하고 검증하는 방법

1단계: 동일한 조건으로 빌드를 만든다

에디터 플레이 모드와 모바일 IL2CPP 빌드는 메모리 동작이 다를 수 있습니다. 다음 조건을 고정합니다.

  • 동일한 Unity LTS 패치 버전
  • 동일한 IL2CPP 설정과 Managed Stripping Level
  • Development Build 여부
  • 동일한 그래픽 품질, 해상도, Addressables 카탈로그
  • 대상 기기의 OS 버전과 메모리 등급

2단계: 씬 전환 시나리오를 자동 반복한다

예를 들어 로비에서 전투 씬으로 이동하고 다시 로비로 돌아오는 시나리오를 10회 반복합니다. 매 회차마다 로딩 완료 후 일정 시간 대기하고 강제 GC 여부를 동일하게 유지해야 합니다. 강제 GC를 사용했다면 테스트 조건에 명시해야 합니다.

Android에서는 다음 명령으로 프로세스 수준의 PSS 변화를 함께 기록할 수 있습니다.

adb shell dumpsys meminfo com.example.game

이 값만으로 managed leak을 구분할 수는 없습니다. Unity Memory Profiler의 managed snapshot과 native allocation 결과를 같은 회차에 연결해야 합니다.

3단계: 스냅샷 차이와 참조 경로를 추출한다

최소한 baseline, 1회차 종료, 5회차 종료, 10회차 종료 스냅샷을 비교합니다. 다음 조건을 만족하는 객체를 1차 후보로 올립니다.

  • 같은 타입의 인스턴스 수가 반복 횟수에 따라 증가
  • retained size가 감소하지 않음
  • GC root 또는 영속 서비스까지의 참조 경로가 존재
  • 씬 종료 후에도 MonoBehaviour 인스턴스가 남음
  • 이벤트 publisher의 invocation list에 subscriber가 남음

씬 전환 반복 테스트에서 객체 수와 Android PSS 증가량을 기록하는 대시보드

4단계: AI에는 증거와 불확실성을 함께 전달한다

AI 입력은 다음처럼 민감한 원본 데이터 대신 요약된 JSON으로 만들 수 있습니다.

{
  "build": {
    "unity": "2022.3.x",
    "backend": "IL2CPP",
    "platform": "Android"
  },
  "scenario": {
    "scene": "Battle",
    "cycles": 10,
    "managedGc": "same-condition"
  },
  "candidates": [
    {
      "type": "InventoryView",
      "countDelta": 9,
      "retainedBytesDelta": 184320,
      "retainedPath": [
        "InventoryService.Changed",
        "InventoryView.HandleChanged"
      ],
      "staticRoot": false,
      "diagnostics": ["CL001"]
    }
  ]
}

AI의 출력 형식도 고정하면 자동화하기 쉽습니다.

  • hypothesis: 가장 가능성 높은 원인
  • evidence: 판단에 사용한 retained path와 진단 ID
  • confidence: 높음·중간·낮음
  • nextTest: 원인 확인을 위한 최소 재현 테스트
  • suggestedPatch: 수정 방향과 예상 부작용

AI가 InventoryView를 릭이라고 판단하더라도 실제 패치 전에 publisher의 수명과 OnDisable 호출 여부를 검증해야 합니다. 근거가 없는 코드 수정 자동 생성은 이벤트 중복 구독이나 조기 해제 같은 새로운 버그를 만들 수 있습니다.

CI에서는 어떤 기준으로 메모리 릭 검사를 운영할까?

모바일 성능 검사는 모든 커밋에서 전체 디바이스 매트릭스를 실행하기 어렵습니다. 다음과 같이 비용을 나누면 현실적인 파이프라인을 만들 수 있습니다.

단계실행 내용실패 기준
Pull RequestRoslyn 이벤트 분석신규 CL001, CL003, CL005
Nightly대표 Android 기기 씬 반복 테스트프로젝트 baseline 대비 증가량 초과
Release CandidateAndroid·iOS 전체 시나리오메모리 예산 또는 크래시 임계치 초과
AI 리포트실패 후보의 증거 요약과 분류AI 단독으로 merge 차단하지 않음

메모리 예산은 기기와 게임 장르에 따라 다르므로 임의의 절대값을 공통 기준으로 사용하면 안 됩니다. 먼저 정상 빌드의 10회 반복 결과를 baseline으로 만들고 retainedBytesDelta, 객체 수 증가량, Android PSS 증가량을 각각 관리합니다.

실패 로그에는 다음 정보를 남기는 것이 좋습니다.

  1. 커밋과 빌드 식별자
  2. Unity 버전과 IL2CPP 설정
  3. 테스트 기기 및 OS
  4. 씬 반복 횟수와 로딩 대기 시간
  5. 증가한 타입 상위 목록
  6. retained path
  7. Roslyn 진단 ID와 소스 위치
  8. AI가 제안한 다음 검증 단계

자주 묻는 질문 (FAQ)

이벤트를 해제했는데도 메모리가 줄지 않으면 릭인가요?

아닙니다. GC가 아직 실행되지 않았거나 Unity allocator가 메모리를 재사용하기 위해 보유하고 있을 수 있습니다. 객체의 인스턴스 수와 retained path가 반복 테스트에서도 유지되는지 확인해야 합니다.

OnDisable과 OnDestroy 중 어디에서 이벤트를 해제해야 하나요?

OnEnable에서 구독하고 비활성화 중에도 이벤트를 받지 않아야 한다면 OnDisable에서 해제합니다. 컴포넌트의 전체 수명 동안 구독해야 한다면 Awake 또는 Start에서 구독하고 OnDestroy에서 해제하되 두 지점의 수명 의도를 코드 규칙으로 고정해야 합니다.

AI만으로 메모리 릭을 자동 확정할 수 있나요?

권장하지 않습니다. AI는 여러 증거를 결합해 우선순위를 정하고 재현 절차를 제안하는 데 적합합니다. 최종 판정은 반복 스냅샷, 참조 경로, 라이프사이클 로그, 코드 분석 결과가 일치할 때 내려야 합니다.

정리

모바일 메모리 릭 추적은 프로세스 메모리 숫자 하나를 보는 작업이 아니라 managed·native 메모리와 객체 수명 정보를 연결하는 작업입니다. C# 이벤트 해제 누락은 +=와 -=의 심볼을 비교하는 Roslyn 규칙으로 개발 단계에서 빠르게 차단할 수 있습니다.

가장 안정적인 자동화 구조는 정적 분석으로 명백한 누락을 먼저 잡고 Unity Memory Profiler와 디바이스 반복 테스트로 실제 생존 객체를 확인한 뒤 AI가 증거를 요약하고 다음 테스트를 추천하도록 구성하는 방식입니다.