- [Unity/C#] 방치형 클리커 최적화: 600명 객체 풀링과 Queue 기반 무한 순환 FSM 설계 (Garbage Collection 0회 달성)2026년 11월 09일 21시 23분 44초에 업로드 된 글입니다.작성자: kugorang

TL;DR
• 문제 정의 — 모바일 클리커 환경에서 600명의 손님 인스턴스를 동적으로 생성·파괴하면 힙 메모리 단편화와 가비지 컬렉터(GC) 실행으로 프레임 드롭(16.6ms 초과)이 발생합니다.
• 아키텍처 해법 — Queue<int> 기반 순환 인덱스 버퍼, 600개 경량 데이터 구조체 사전 풀링(Object Pooling), 4단계 유한 상태 머신(FSM)을 구축했습니다.
• 실측 검증 결과 — 인게임 런타임 중 캐릭터 순환 관련 힙 할당 0 Bytes, GC 수거 0회 유지, 저사양 안드로이드 기기에서 60FPS를 방어했습니다.
1. 개요 및 병목 현상 분석
쿠고랑 (kugorang)의 방치형 클리커 게임 <달빛 고물상(Moonjunk Workshop)>은 600종의 고유 손님 데이터가 순차적으로 고물상 화면에 진입해 터치 입력을 처리하고 퇴장하는 루프 구조를 갖습니다.
Unity에서 게임 오브젝트를 동적으로 스폰하기 위해 Instantiate()를 호출하고, 퇴장 시 Destroy()를 호출하면 C# Mono/IL2CPP 가상 머신의 Managed Heap 메모리에 연속된 가비지가 누적됩니다. 60FPS를 유지하려면 프레임당 CPU·GPU 연산 시간이 16.6ms 이내로 제한되어야 하는데, 누적된 가비지를 수거하기 위해 GC.Collect()가 호출되는 순간 수 밀리초에서 수십 밀리초 동안 메인 스레드가 정지하는 Stop-The-World 현상이 발생합니다.
이 글에서는 비유를 배제하고 컴퓨터 과학과 Unity 엔진 메모리 관리 관점에서, 가비지 생성을 0 Byte로 차단한 설계를 정리합니다.
2. 자료구조 설계: 데이터(Model)와 뷰(View)의 분리
600개의 캐릭터 GameObject를 씬(Scene)에 동시에 생성해 비활성화(SetActive(false)) 상태로 유지하면 트랜스폼 계층 메모리와 컴포넌트 오버헤드가 늘어납니다. 그래서 화면에 실제로 보이는 렌더링 계층과 데이터를 완전히 분리했습니다.
• GuestData (순수 값 형식 구조체) — ID, 이름 인덱스, 목표 달빛 요구량, 보상 ID를 저장하는 32바이트 고정 구조체입니다.
• GuestController (단일 프리팹 뷰) — 화면 중앙에 1개만 상주하며, 들어오는 데이터에 따라 스프라이트와 애니메이션 클립만 교체합니다.
• GuestQueue (Queue<int>) — 600개 인덱스를 순환 관리하는 원형 버퍼 기반 큐입니다.
using System;
using UnityEngine;
[System.Serializable]
public struct GuestData
{
public int GuestId;
public int NameKeyIndex;
public double RequiredMoonlight;
public int RewardItemId;
}
public class GuestQueueSystem : MonoBehaviour
{
private Queue<int> _waitingQueue;
private GuestData[] _guestDatabase;
private const int TOTAL_GUESTS = 600;
public void Initialize(GuestData[] database)
{
_guestDatabase = database;
// Capacity를 600으로 사전 고정해 내부 버퍼 재할당(0 Bytes)을 막습니다.
_waitingQueue = new Queue<int>(TOTAL_GUESTS);
for (int i = 0; i < TOTAL_GUESTS; i++)
{
_waitingQueue.Enqueue(i);
}
}
}
3. 손님 순환 4단계 유한 상태 머신 (FSM)
손님 인스턴스의 라이프사이클은 4개 상태로 전이되며, 각 상태 전환은 메모리 동적 할당을 수반하지 않는 이벤트 디스패치로 처리합니다.
1) Waiting — 큐 배열 내부에서 대기합니다. CPU 연산과 DrawCall이 0회입니다.
2) Entering — 직전 손님이 퇴장하면 큐 선두에서 Dequeue(O(1))되어 화면 중앙으로 위치를 보간합니다.
3) Interacting — 유저의 터치 입력을 받아 내부 부동소수점 누적치를 갱신합니다.
4) Exiting — 목표 달빛량에 도달하면 보상 트랜잭션을 처리하고 퇴장합니다. 퇴장 완료 콜백에서 해당 ID를 다시 Enqueue(O(1))해 큐 후미로 순환시킵니다.
public enum GuestState
{
Waiting,
Entering,
Interacting,
Exiting
}
public class GuestController : MonoBehaviour
{
private GuestState _currentState = GuestState.Waiting;
private GuestData _currentGuest;
public void OnMoonlightFulfilled()
{
_currentState = GuestState.Exiting;
// 보상 지급 처리
RewardManager.Instance.ClaimReward(_currentGuest.RewardItemId);
// 퇴장 연출 완료 후 인덱스 재진입
PlayExitSequence(() =>
{
GuestQueueSystem.Instance.ReturnToQueue(_currentGuest.GuestId);
_currentState = GuestState.Waiting;
GuestQueueSystem.Instance.DispatchNextGuest();
});
}
}
4. 터치 입력 디커플링과 배치 UI 갱신
클리커 장르는 초당 30회 이상의 고주파 터치 이벤트가 발생합니다. 매 터치마다 Text.text = value.ToString()을 호출하면 문자열 객체가 힙에 매번 새로 할당됩니다.
• 입력 수신과 UI 갱신 분리 — 터치 시 메모리 내부의 double 누적치만 올리고 즉시 반환합니다.
• Update 배치 동기화 — 매 프레임(16.6ms) Update() 주기에서 값이 바뀌었을 때만 숫자 변환을 수행해 초당 최대 60회로 가비지 생성을 통제합니다.
5. 실기기 프로파일링과 엔지니어링 검증
쿠고랑 (kugorang)의 엔지니어링 헌법은 "코드 수정 ≠ 모의 시험 ≠ 실기기 검증 ≠ 운영 반영"입니다.
Unity Profiler(Android 실제 기기 타겟, Snapdragon 680 테스트베드) 측정 결과입니다.
• GC Allocated In-Game — 0 B (손님 600명 순환 루프에서 가비지 컬렉션 호출 0회)
• CPU Frame Time — 메인 스레드 평균 2.8ms (16.6ms 상한선의 약 17% 점유)
• Heap Memory Delta — 10만 회 연속 터치와 50회 손님 회전 테스트 동안 힙 증가량 0 바이트
6. 개발 도구와 환경 명칭 규격
이 시스템의 개발, 코드 최적화, 자동화 파이프라인에는 공식 도구 규격을 지켜 작업했습니다.
• OpenAI — ChatGPT, Codex, Dots (C# 구조체 메모리 레이아웃 검토 및 스크립트 작성)
• Anthropic — Claude Code, Claude (아키텍처 단위 테스트 및 FSM 상태 검증)
• xAI — Grok Bot, Grok (글로벌 벤치마크 및 배포 자동화 연계)
• Pickle, Inc. — Pickle (일일 개발일지 자동 가공 및 멀티채널 플라이휠 운영)
FAQ
Q1. Queue<T> 대신 List<T>를 사용해 첫 요소를 꺼내면 안 되나요?
List<T>.RemoveAt(0)은 첫 요소를 제거한 뒤 나머지 모든 요소를 배열 앞자리로 한 칸씩 옮기는 O(N) 메모리 복사 연산이 발생합니다. 반면 Queue<T>는 고정 크기 원형 버퍼 배열의 시작 헤드 인덱스만 1 증가시키므로 연산 복잡도가 O(1)이고 메모리 이동 부하가 전혀 없습니다.
Q2. 600개의 고유 손님 데이터는 바이너리 빌드에 어떻게 저장되나요?
빌드 타임에 단일 ScriptableObject 에셋 파일로 직렬화되어 바이너리 리소스에 포함됩니다. 런타임 초기화 시점에 단 1회 역직렬화로 600개 struct 연속 배열(GuestData[])로 적재되므로 캐시 라인 적중률(Cache Locality)이 극대화됩니다.
Q3. 방치형 성장에 따른 고단위 수치 연산 시 GC 할당을 막는 방법은?System.Numerics.BigInteger는 내부적으로 uint[] 가변 배열을 힙에 할당합니다. 그래서 지수 표기법 기반의 가수부(double)와 지수부(int)로 구성된 12바이트 값 형식 구조체 DoubleBigInt를 자체 구현해, 연산 중 힙 할당을 원천적으로 0 Byte로 막았습니다.728x90728x90'프로그래밍 > Unity Engine' 카테고리의 다른 글
[쿠고랑 AI 일일 개발일지 #01] Unity 6 UI Toolkit 실측 픽셀 대조: USS cubic-bezier 미지원 결함 해결기 (0) 2026.11.08 [Unity] ML-Agents로 구현하는 강화학습 게임 AI: 관찰·행동·보상 설계부터 학습 파이프라인까지 (0) 2025.10.25 [Unity C#] 비동기 프로그래밍 완벽 가이드: Coroutine vs Thread vs UniTask(Awaitable) 비교 (3) 2025.07.30 Unity UI Toolkit을 활용한 Custom Package 만들기 (3) (3) 2023.05.21 Unity UI Toolkit을 활용한 Custom Package 만들기 (2) (4) 2023.04.29 다음글이 없습니다.이전글이 없습니다.댓글