김현우
  • [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)가 관측되었습니다.

    원인 분석 결과는 다음과 같았습니다:

    1. 상태 전환 시의 람다/델리게이트 할당: Action 또는 Func를 익명 함수로 바인딩할 때 컴파일러가 클로저 인스턴스를 힙에 매번 생성함.
    2. 반복적인 문자열 연결: 재화 및 시간 카운트다운을 string.Format 또는 + 연산자로 처리하여 매 틱마다 임시 문자열 인스턴스 발생.
    3. 불완전한 객체 풀 반환 주기: 파괴 시점에 콜백을 통해 풀에 반환하지 않고 비활성화만 수행하여 누수 발생.

    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 / Frame12.4 KB0 B100% 제거
    Main Thread CPU Time22.8 ms (43 FPS)14.1 ms (60 FPS 방어)38.1% 연산 단축
    GC Pause (Spike)18.6 ms (주기적)0 ms (미발생)스터터링 완벽 방어
    런타임 Managed Heap142 MB (계속 증가)98 MB (고정 평형)메모리 안정화

    4. 트러블슈팅 FAQ

    Q1. IEntityState 인터페이스 호출 시 박싱(Boxing)이 발생하지 않나요?
    A1. RepairEntity 클래스 컨텍스트가 참조 타입(class)이므로 제네릭 인자 전달 시 인터페이스 디스패치 과정에서 박싱 할당이 발생하지 않습니다.

    Q2. 풀링된 엔티티의 초기화 시점 오버헤드는 어떻게 분산했나요?
    A2. 씬 로딩 타임에 모든 풀링 오브젝트 인스턴스화 및 컴포넌트 캐싱을 100% 완료해 두므로 인게임 플레이 중에는 포지션 재설정과 플래그 토글만 일어납니다.

    Q3. 모바일 저사양 기기에서의 실제 성능은 어떠한가요?
    A3. 스냅드래곤 680급 저사양 AP 기준에서도 36명 손님 순환 및 대규모 고철 오브젝트 부하 테스트 중 일정한 60FPS 프레임타임을 방어했습니다.

    728x90
    728x90
    댓글