Unity Object Pooling으로 다중 프로젝타일과 데미지 Text Canvas 최적화하기

Unity에서 대량의 프로젝타일과 데미지 텍스트를 Instantiate·Destroy로 처리할 때 발생하는 GC 할당, CPU 스파이크, Canvas 리빌드 비용을 Object Pooling과 분리된 UI 캔버스로 줄이는 실전 구현 방법을 정리합니다.

✓ 복사 완료!
챕터CPU & 메모리/GC 최적화
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity Object Pooling으로 다중 프로젝타일과 데미지 Text Canvas 최적화하기

핵심 요약

다수의 프로젝타일과 데미지 텍스트는 매번 Instantiate와 Destroy를 호출하지 말고 재사용 가능한 Object Pool에서 꺼내고 반납해야 한다. 월드 UI인 데미지 텍스트는 다른 HUD와 별도 Canvas로 분리해 변경 범위를 좁히면 UGUI의 Canvas 리빌드 비용도 줄일 수 있다.

이 글은 Unity 2022 LTS 이상과 C# 기준으로 발사체 수명 관리와 TextMeshPro 기반 데미지 텍스트 표시를 최소 구성으로 구현한다.

왜 다중 프로젝타일과 데미지 Text가 프레임 드롭을 만드는가?

적이 많고 자동 공격 빈도가 높은 전투에서는 짧은 시간에 수십~수백 개의 오브젝트가 생성·파괴된다. 비용은 단순히 GameObject 하나를 만드는 데서 끝나지 않는다.

작업주요 비용전투 중 문제
Instantiate컴포넌트 초기화, Transform 연결, 메모리 할당CPU 스파이크와 GC 압력
Destroy파괴 예약, 참조 정리수명 종료 시 비용 집중
TMP 텍스트 갱신문자열 생성, 메시 재생성빈번한 GC와 UI 갱신
하나의 대형 Canvas변경된 UI를 기준으로 배치 재구성HUD까지 함께 리빌드될 수 있음

특히 데미지 숫자는 타격마다 텍스트 내용과 위치가 달라진다. 월드 공간 UI를 고정 HUD와 같은 Canvas에 넣으면 숫자 하나의 갱신이 더 넓은 UI 영역의 재구성으로 이어질 수 있다.

프로젝타일 풀과 월드 공간 데미지 텍스트 Canvas가 분리된 Unity 전투 화면

Object Pooling은 어떻게 프로젝타일 생성을 줄일까?

Object Pooling은 미리 생성한 인스턴스를 비활성 상태로 보관하고 필요할 때 활성화해 재사용하는 방식이다. 핵심은 생성 여부가 아니라 대여와 반납의 수명 계약이다.

Npool≥Rpeak×TlifeN_{pool} \geq R_{peak} \times T_{life}
  • NpoolN_{pool}: 필요한 풀 크기
  • RpeakR_{peak}: 초당 최대 발사 수
  • TlifeT_{life}: 프로젝타일의 최대 생존 시간(초)

예를 들어 초당 최대 40발을 발사하고 최대 수명이 1.5초라면 기본 풀 크기는 60개 이상으로 잡는다. 폭발 분열이나 네트워크 지연처럼 순간 최대치가 불확실하면 여유분을 더한다.

Step 1. Unity 내장 ObjectPool<T>로 풀을 만든다

Unity 2021.1 이상에서는 UnityEngine.Pool.ObjectPool<T>를 사용할 수 있다. 별도 풀 프레임워크를 추가할 이유가 없다면 내장 API가 가장 간단하다.

using UnityEngine;
using UnityEngine.Pool;

public sealed class ProjectilePool : MonoBehaviour
{
    [SerializeField] private Projectile projectilePrefab;
    [SerializeField] private int defaultCapacity = 64;
    [SerializeField] private int maxSize = 256;

    private ObjectPool<Projectile> pool;

    private void Awake()
    {
        pool = new ObjectPool<Projectile>(
            Create,
            projectile => projectile.gameObject.SetActive(true),
            projectile => projectile.gameObject.SetActive(false),
            Destroy,
            collectionCheck: true,
            defaultCapacity,
            maxSize);
    }

    public Projectile Get() => pool.Get();

    public void Release(Projectile projectile) => pool.Release(projectile);

    private Projectile Create()
    {
        var projectile = Instantiate(projectilePrefab, transform);
        projectile.SetPool(this);
        return projectile;
    }

    private static void Destroy(Projectile projectile)
    {
        UnityEngine.Object.Destroy(projectile.gameObject);
    }
}

collectionCheck는 같은 인스턴스를 두 번 반납하는 실수를 감지하는 개발용 옵션이다. 릴리스 빌드에서 불필요한 검사를 피하고 싶다면 false로 설정할 수 있다.

Step 2. 프로젝타일의 종료 지점을 하나로 모은다

충돌, 사거리 초과, 타임아웃처럼 반납 사유는 여러 개지만 실제 반납은 Despawn() 한 곳에서 수행해야 중복 반납을 막을 수 있다.

using UnityEngine;

public sealed class Projectile : MonoBehaviour
{
    [SerializeField] private float speed = 24f;
    [SerializeField] private float maxLifetime = 1.5f;

    private ProjectilePool pool;
    private float expiresAt;
    private bool despawned;

    public void SetPool(ProjectilePool value) => pool = value;

    public void Launch(Vector3 position, Vector3 direction)
    {
        transform.SetPositionAndRotation(position, Quaternion.LookRotation(direction));
        expiresAt = Time.time + maxLifetime;
        despawned = false;
    }

    private void Update()
    {
        transform.position += transform.forward * (speed * Time.deltaTime);

        if (Time.time >= expiresAt)
            Despawn();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent<IDamageable>(out var target))
            target.TakeDamage(10);

        Despawn();
    }

    private void Despawn()
    {
        if (despawned) return;
        despawned = true;
        pool.Release(this);
    }
}

발사 코드는 Instantiate 대신 Get()과 Launch()만 호출한다.

var projectile = projectilePool.Get();
projectile.Launch(muzzle.position, muzzle.forward);

Step 3. 풀로 돌아오는 상태를 초기화한다

재사용 오브젝트는 이전 발사의 흔적을 남기면 안 된다. Rigidbody 속도, TrailRenderer, 파티클, 피격 대상 캐시, 타이머를 Launch() 또는 OnEnable()에서 초기화한다. 상태가 초기화되지 않는 문제는 풀링에서 가장 흔한 시각적 버그다.

데미지 Text Canvas는 왜 분리해야 하는가?

UGUI Canvas는 내부 요소가 변경되면 버텍스, 배치, 드로우 호출 관련 데이터를 다시 계산할 수 있다. 매 프레임 변하는 데미지 텍스트와 거의 변하지 않는 체력바·메뉴·인벤토리를 같은 Canvas에 두면 변경 빈도가 낮은 UI도 영향을 받을 수 있다.

Canvas포함할 요소갱신 빈도
HUD Canvas체력바, 스킬 버튼, 메뉴낮음
Damage Text Canvas피격 숫자, 치유 숫자, 크리티컬 표시높음
Popup Canvas보상, 알림, 퀘스트 토스트중간

데미지 텍스트 전용 Canvas는 보통 Screen Space - Overlay 또는 Screen Space - Camera로 두고 월드 좌표를 화면 좌표로 변환해 배치한다. 텍스트가 다른 월드 UI와 깊이 관계를 가져야 한다면 World Space Canvas를 선택하되 개별 Canvas를 텍스트마다 만들지는 않는다.

flowchart LR
    A[피격 이벤트] --> B[DamageTextPool.Get]
    B --> C[월드 좌표를 화면 좌표로 변환]
    C --> D[전용 Damage Text Canvas에 표시]
    D --> E[애니메이션 종료]
    E --> F[DamageTextPool.Release]

데미지 Text Object Pooling은 어떻게 구현할까?

TextMeshProUGUI 프리팹 구성

프리팹에는 TextMeshProUGUI, CanvasGroup, 아래 스크립트를 둔다. 데미지 텍스트 전용 Canvas 아래에서만 관리하고 레이캐스트 대상은 끈다.

using TMPro;
using UnityEngine;
using UnityEngine.Pool;

public sealed class DamageText : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI label;
    [SerializeField] private CanvasGroup canvasGroup;
    [SerializeField] private float duration = 0.7f;
    [SerializeField] private float riseDistance = 48f;

    private IObjectPool<DamageText> pool;
    private Vector2 start;
    private float elapsed;

    public void SetPool(IObjectPool<DamageText> value) => pool = value;

    public void Show(Vector2 screenPosition, int amount, bool critical)
    {
        transform.position = screenPosition;
        start = screenPosition;
        elapsed = 0f;
        canvasGroup.alpha = 1f;
        label.SetText(critical ? "CRIT {0}" : "{0}", amount);
        label.color = critical ? Color.yellow : Color.white;
    }

    private void Update()
    {
        elapsed += Time.deltaTime;
        var t = Mathf.Clamp01(elapsed / duration);
        transform.position = start + Vector2.up * (riseDistance * t);
        canvasGroup.alpha = 1f - t;

        if (t >= 1f)
            pool.Release(this);
    }
}

label.text = amount.ToString()보다 SetText("{0}", amount)를 쓰면 TMP의 문자열 처리 과정에서 불필요한 할당을 줄이는 데 유리하다. 단, 실제 GC 발생 여부는 사용하는 TMP 버전과 호출 경로에 따라 Profiler로 확인해야 한다.

월드 좌표를 전용 Canvas 좌표로 변환하기

Overlay Canvas에서는 Camera.WorldToScreenPoint 결과를 그대로 사용할 수 있다. Screen Space - Camera나 다른 Render Mode를 사용할 때는 RectTransformUtility.ScreenPointToLocalPointInRectangle로 Canvas 로컬 좌표를 구하는 편이 안전하다.

public void ShowDamage(Vector3 worldPosition, int amount, bool critical)
{
    var screen = Camera.main.WorldToScreenPoint(worldPosition);
    if (screen.z <= 0f) return;

    var text = damageTextPool.Get();
    text.Show(screen, amount, critical);
}

Camera.main은 태그 검색 비용이 있으므로 타격 이벤트마다 호출하지 말고 초기화 시 카메라 참조를 캐시하는 것이 좋다.

Screen Space Canvas에서 월드 좌표를 화면 좌표로 변환해 데미지 숫자를 표시하는 과정

프로젝타일과 데미지 Text 최적화는 무엇을 측정해야 할까?

최적화는 풀을 도입한 뒤에도 수치로 검증해야 한다. Unity Profiler에서 다음 항목을 같은 전투 장면과 같은 발사 조건으로 비교한다.

Profiler 항목확인할 내용기대 변화
CPU UsageInstantiate, Destroy, Canvas 관련 샘플순간 피크 감소
GC Alloc타격·발사 프레임의 할당량반복 할당 감소
UI 모듈Canvas Build Batch, Layout 관련 비용HUD 영향을 분리
Memory풀의 사전 할당 메모리예측 가능한 상한 유지

풀링은 GC를 줄이는 대신 비활성 오브젝트를 메모리에 유지한다. 따라서 모든 프리팹을 무조건 풀링할 필요는 없다. 전투 중 반복 생성되고 수명이 짧으며 동시 활성 수가 예측 가능한 오브젝트부터 적용하는 것이 효율적이다.

자주 묻는 질문 (FAQ)

풀 크기를 초과하면 어떻게 해야 하나요?

maxSize를 넘는 인스턴스는 풀에 보관하지 않고 파괴된다. 전투 의도상 누락되면 안 되는 효과라면 최대 동시 사용량을 Profiler로 측정해 defaultCapacity와 maxSize를 조정해야 한다.

모든 UI를 Canvas별로 분리해야 하나요?

아니다. 매 프레임 또는 자주 바뀌는 UI와 거의 바뀌지 않는 UI를 분리하는 것이 목적이다. Canvas를 과도하게 쪼개면 드로우 호출과 관리 복잡도가 늘 수 있다.

코루틴으로 수명을 관리해도 되나요?

가능하지만 재사용 시 코루틴 중복 실행과 취소 처리를 확실히 해야 한다. 단순한 이동·페이드에는 Update()에서 타이머를 처리하는 방식이 상태 추적이 더 간단하다.

정리

  1. 프로젝타일과 데미지 텍스트는 Instantiate·Destroy 대신 ObjectPool<T>에서 대여하고 반납한다.
  2. 충돌과 타임아웃을 하나의 Despawn()으로 모아 이중 반납을 막는다.
  3. 자주 변하는 데미지 텍스트는 HUD와 별도 Canvas로 분리하고 Unity Profiler로 GC Alloc과 Canvas 비용을 함께 확인한다.

이 구성은 대량 전투에서 발생하는 생성·파괴 비용과 UI 갱신 범위를 동시에 줄이는 출발점이다. 이후에도 병목이 남는다면 물리 충돌 수, TMP 머티리얼 수, 활성 텍스트의 동시 개수를 순서대로 측정해 다음 최적화 지점을 찾으면 된다.