몬스터 수백 마리 스폰돼도 안 버벅이게: Unity 성능 최적화 4가지 실전 기법

개발 노트 — 성능 최적화

왜 최적화가 필요했나

뱀파이어 서바이벌류 장르는 화면에 몬스터가 수십~수백 마리씩 동시에 존재하는 게 기본입니다. 이런 장르에서는 “일단 작동하게 만드는 것”과 “많은 오브젝트가 동시에 움직여도 버벅이지 않게 만드는 것” 사이에 꽤 큰 간극이 있습니다.

이 글에서는 실제 개발 중에 적용했던 최적화 기법 4가지를 정리합니다. 화려한 기술은 아니지만, 각각 이유가 명확하고 효과도 체감되는 것들이라 다른 프로젝트에도 바로 적용하실 수 있을 겁니다.

1.Physics2D.OverlapCircleNonAlloc — GC(가비지 컬렉션) 회피

몬스터 주변 범위를 탐지하거나, 공격 판정 범위를 체크할 때 흔히 Physics2D.OverlapCircleAll()을 씁니다. 문제는 이 함수가 호출할 때마다 새로운 배열을 할당한다는 점입니다.

// 매 호출마다 새 배열을 생성 → GC 부담 증가
Collider2D[] hits = Physics2D.OverlapCircleAll(position, radius);

수십~수백 마리의 몬스터가 매 프레임 이런 판정을 한다면, 매 프레임 새로운 배열이 계속 생성되고 버려지면서 가비지 컬렉션(GC) 부하가 쌓입니다. GC가 실행되는 순간 프레임 드랍(끊김)이 발생하기 쉽습니다.

해결책: NonAlloc 버전을 쓰고, 결과를 담을 배열을 미리 만들어서 재사용합니다.

// 미리 할당해둔 배열을 재사용 → 추가 할당 없음
private Collider2D[] _hitsBuffer = new Collider2D[20];

int count = Physics2D.OverlapCircleNonAlloc(position, radius, _hitsBuffer);
for (int i = 0; i < count; i++)
{
    // _hitsBuffer[i] 사용
}

이름 그대로 “추가 할당(Alloc) 없이(Non)” 동작하기 때문에, 매 프레임 호출해도 GC 부담이 크게 줄어듭니다.

2. Time.frameCount로 판정 로직 분산시키기

몬스터 하나하나가 매 프레임 AI 판단(타겟 탐색, 경로 계산 등)을 한다면, 몬스터 수가 늘어날수록 CPU 부하가 선형으로 증가합니다. 그런데 사실 대부분의 AI 판단은 매 프레임 할 필요가 없습니다.

// 모든 몬스터가 매 프레임 판정 → 부하 집중
void Update()
{
    CheckTarget();
}

해결책: 몬스터별로 다른 프레임에 판정이 실행되도록 나눠줍니다.

// 개체 ID를 기준으로 2~3프레임에 한 번씩만 실행되도록 분산
void Update()
{
    if (Time.frameCount % 3 == _myOffset)
    {
        CheckTarget();
    }
}

Time.frameCount % N을 활용하면, 몬스터들이 한 프레임에 몰려서 판정하는 대신 여러 프레임에 걸쳐 고르게 분산됩니다. 사용자 입장에서는 판정 주기가 살짝 늘어나는 정도라 체감이 거의 없지만, CPU 부하는 확실히 줄어듭니다.

3. Animator.StringToHash 캐싱

애니메이터 파라미터를 제어할 때 흔히 이렇게 씁니다.

// 매번 문자열을 해시로 변환 → 불필요한 연산 반복
animator.SetBool("IsAttacking", true);

SetBool, SetTrigger 같은 함수에 문자열을 직접 넘기면, Unity 내부적으로 매번 그 문자열을 해시값으로 변환하는 연산이 일어납니다. 한두 번이면 무시할 수준이지만, 매 프레임 여러 오브젝트에서 반복되면 누적됩니다.

해결책: 문자열을 미리 해시로 변환해서 캐싱해두고 재사용합니다.

// 클래스 상단에서 한 번만 변환
private static readonly int IsAttackingHash = Animator.StringToHash("IsAttacking");

// 이후로는 캐싱된 해시값 재사용
animator.SetBool(IsAttackingHash, true);

StringToHash는 한 번만 호출해서 정적 필드에 저장해두면, 이후로는 문자열 변환 과정 없이 바로 해시값을 사용할 수 있습니다.

4. WaitForSeconds 재사용

코루틴에서 대기할 때 흔히 이렇게 씁니다.

// 반복될 때마다 새 인스턴스 생성
while (true)
{
    yield return new WaitForSeconds(1f);
    DoSomething();
}

new WaitForSeconds(1f)는 호출될 때마다 새로운 객체를 생성합니다. 반복되는 코루틴에서 매번 같은 대기 시간을 쓴다면, 이것도 불필요한 메모리 할당입니다.

해결책: 같은 대기 시간을 여러 번 쓴다면 인스턴스를 미리 만들어두고 재사용합니다.

// 한 번만 생성해서 재사용
private static readonly WaitForSeconds OneSecond = new WaitForSeconds(1f);

while (true)
{
    yield return OneSecond;
    DoSomething();
}

네 가지 기법의 공통점

돌아보면 이 네 가지는 전부 같은 원칙을 다른 상황에 적용한 것입니다.

“반복되는 연산에서, 매번 새로 만들 필요 없는 걸 캐싱하거나 재사용하라.”

  • OverlapCircleNonAlloc → 배열 재사용
  • Time.frameCount 분산 → 판정 자체를 덜 반복
  • StringToHash 캐싱 → 해시 변환 결과 재사용
  • WaitForSeconds 재사용 → 코루틴 대기 객체 재사용

개별로 보면 사소한 최적화지만, 오브젝트 수가 많아질수록(수십, 수백 마리) 이런 사소한 절약이 누적되어 프레임 드랍 여부를 가르는 경우가 많습니다.

언제 이런 최적화를 해야 할까

모든 코드를 처음부터 이렇게 짤 필요는 없습니다. 오히려 과도한 최적화는 코드 가독성만 떨어뜨릴 수 있습니다. 이런 기법들은 특히 아래 상황에서 효과가 큽니다.

  • 매 프레임 반복 실행되는 코드 (Update, 코루틴 등)
  • 동시에 존재하는 오브젝트 수가 많은 경우 (다수의 몬스터, 투사체 등)
  • 프로파일러(Profiler)로 확인했을 때 GC Alloc이나 특정 함수 호출이 눈에 띄게 많은 경우

프로파일러 없이 감으로 최적화하기보다는, 먼저 Unity Profiler로 실제 병목 지점을 확인한 후 위 기법들을 적용하시는 걸 추천합니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤