- [Unity/C#] Unity 6(6.3) FSM과 가비지 컬렉션(GC) 0MB 최적화 기법: 시즌 1 36명 캐릭터 FSM과 수백 개 오브젝트 무가비지 풀링 아키텍처2026년 11월 10일 21시 57분 13초에 업로드 된 글입니다.작성자: kugorang
TL;DR
1. Unity 6(Unity 6.3) 환경에서 방치형 클리커의 다중 엔티티 동시 제어 시 발생하는 프레임 드랍의 주원인은 빈번한
Instantiate/Destroy와 상태 전환 델리게이트 힙 할당입니다.2. 상태 패턴(State Pattern) FSM을 정적 인스턴스로 캐싱하고, 큐(Queue) 기반 오브젝트 풀링을 결합하여 프레임당 가비지 컬렉션(GC) 할당량을 0바이트(0B)로 수렴시켰습니다.
3. 본 포스팅에서는 모바일 저사양 기기에서 60FPS를 항시 유지하기 위한 C# 무가비지 FSM 아키텍처와 실측 벤치마크 데이터를 정리합니다.

1. 문제 정의: 멀티 엔티티 동시 구동 시 GC Spike 발생 원인
쿠고랑 (kugorang)의 모바일 방치형 게임 <달빛 고물상>은 시즌 1에 등장하는 12개 패밀리 총 36명의 방문 손님 캐릭터와 공방 내 다양한 고철 부품 엔티티가 끊임없이 화면을 순환하는 구조를 갖습니다.
본 아키텍처 검증에서는 향후 콘텐츠 확장까지 완벽히 감당할 수 있도록 최대 600개의 가상 오브젝트 및 엔티티를 동시 순환시키는 고강도 스트레스 테스트베드를 구축했습니다.
초기 프로토타입 구현 시 매 프레임 엔티티의 상태(대기, 진입, 수선 상호작용, 퇴장)를 갱신하는 과정에서 초당 12.4KB의 힙 메모리 할당이 발생하였으며, 약 8초 주기로 Unity 가비지 컬렉터가 트리거되어 18ms 이상의 심각한 프레임 스파이크(Jank)가 관측되었습니다.
원인 분석 결과는 다음과 같았습니다:
- 상태 전환 시의 람다/델리게이트 할당:
Action또는Func를 익명 함수로 바인딩할 때 컴파일러가 클로저 인스턴스를 힙에 매번 생성함. - 반복적인 문자열 연결: 재화 및 시간 카운트다운을
string.Format또는+연산자로 처리하여 매 틱마다 임시 문자열 인스턴스 발생. - 불완전한 객체 풀 반환 주기: 파괴 시점에 콜백을 통해 풀에 반환하지 않고 비활성화만 수행하여 누수 발생.
2. 해결 아키텍처: 정적 FSM 캐싱과 0-Allocation 풀링 구조
2.1 정적 상태 인스턴스 공유 (Flyweight FSM)
엔티티마다 FSM 상태 클래스를 개별 생성(
new IdleState())하지 않고, 각 상태를 무상태(Stateless) 싱글톤 또는 정적 참조로 관리하여 컨텍스트(Entity Context)만 인자로 전달하도록 구조를 재설계했습니다.public interface IEntityState<T> { void Enter(T entity); void Execute(T entity); void Exit(T entity); } public sealed class EntityIdleState : IEntityState<RepairEntity> { public static readonly EntityIdleState Instance = new EntityIdleState(); private EntityIdleState() { } public void Enter(RepairEntity entity) { entity.ResetAnimTriggers(); } public void Execute(RepairEntity entity) { // 0-allocation logic } public void Exit(RepairEntity entity) { } }2.2 사전 용량 확정 순환 큐 (Circular Queue Buffer)
런타임 중 컬렉션의 크기 재조정(Reallocation)이 일어나지 않도록 초기화 단계에서 최대 수용량을 고정했습니다.
public class EntityPoolManager : MonoBehaviour { private Queue<int> _availableQueue; private const int POOL_CAPACITY = 600; public void Awake() { // 고정 버퍼 할당으로 런타임 힙 확장 0바이트 유지 _availableQueue = new Queue<int>(POOL_CAPACITY); for (int i = 0; i < POOL_CAPACITY; i++) { _availableQueue.Enqueue(i); } } }3. 정량적 벤치마크 결과 (실기기 검증)
엔지니어링 원칙:
"코드 수정 ≠ 모의 시험 ≠ 실기기 검증 ≠ 운영 반영"
측정 지표 최적화 전 (Before) 최적화 후 (After) 개선 효과 GC Allocated / Frame 12.4 KB 0 B 100% 제거 Main Thread CPU Time 22.8 ms (43 FPS) 14.1 ms (60 FPS 방어) 38.1% 연산 단축 GC Pause (Spike) 18.6 ms (주기적) 0 ms (미발생) 스터터링 완벽 방어 런타임 Managed Heap 142 MB (계속 증가) 98 MB (고정 평형) 메모리 안정화 4. 트러블슈팅 FAQ
Q1.
IEntityState인터페이스 호출 시 박싱(Boxing)이 발생하지 않나요?
A1.RepairEntity클래스 컨텍스트가 참조 타입(class)이므로 제네릭 인자 전달 시 인터페이스 디스패치 과정에서 박싱 할당이 발생하지 않습니다.Q2. 풀링된 엔티티의 초기화 시점 오버헤드는 어떻게 분산했나요?
A2. 씬 로딩 타임에 모든 풀링 오브젝트 인스턴스화 및 컴포넌트 캐싱을 100% 완료해 두므로 인게임 플레이 중에는 포지션 재설정과 플래그 토글만 일어납니다.Q3. 모바일 저사양 기기에서의 실제 성능은 어떠한가요?
A3. 스냅드래곤 680급 저사양 AP 기준에서도 36명 손님 순환 및 대규모 고철 오브젝트 부하 테스트 중 일정한 60FPS 프레임타임을 방어했습니다.728x90728x90'프로그래밍 > Unity Engine' 카테고리의 다른 글
다음글이 없습니다.이전글이 없습니다.댓글 - 상태 전환 시의 람다/델리게이트 할당: