Unity·Unreal 실전

Unity DOTS 없이 멀티스레드로 수천 오브젝트 움직이기: TransformAccessArray 실전

kr-gamedev 2026. 6. 29. 09:03

총알 수천 발, 물고기 떼, 몰려오는 좀비, 부서진 파편. 화면 위에서 동시에 움직이는 오브젝트가 늘어나면 프레임이 무너진다. 원인은 단순하다. 모든 Update()가 메인 스레드 한 줄에서 순차 실행되기 때문이다. CPU 코어는 8개인데 한 개만 일하고 나머지는 놀고 있는 셈이다.

교과서적인 해답은 DOTS와 ECS다. 하지만 이미 GameObject와 프리팹으로 굴러가는 프로젝트를 ECS로 갈아엎는 비용은 현실적으로 감당하기 어렵다. 콜라이더, 애니메이터, 기존 컴포넌트 구조를 전부 다시 설계해야 하기 때문이다.

다행히 중간 지점이 있다. Unity의 C# Job System과 Burst 컴파일러는 GameObject를 그대로 둔 채로 이동 로직만 멀티스레드로 돌릴 수 있다. 핵심 도구가 TransformAccessArrayIJobParallelForTransform이다. (Unity 6 기준, 2022 LTS 이상 동일. Burst 1.8+ 필요)

TransformAccessArray가 필요한 이유

잡(Job) 안에서는 일반 Transform을 직접 만질 수 없다. Transform은 매니지드 객체라 스레드 안전하지 않고, Burst가 다룰 수도 없다. TransformAccessArray는 이 문제를 풀기 위한 특수 컨테이너다. Transform 배열을 네이티브 메모리 친화적인 형태로 감싸서, 잡이 여러 스레드에서 안전하게 위치와 회전을 건드릴 수 있게 해준다. 한 번 만들어두고 매 프레임 재사용하면 된다.

이동 잡 작성하기

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Jobs;

public class FishSchool : MonoBehaviour
{
    [SerializeField] Transform prefab;
    [SerializeField] int count = 5000;

    TransformAccessArray accessArray;
    NativeArray<Vector3> velocities;

    void Start()
    {
        var transforms = new Transform[count];
        velocities = new NativeArray<Vector3>(count, Allocator.Persistent);

        for (int i = 0; i < count; i++)
        {
            transforms[i] = Instantiate(prefab, Random.insideUnitSphere * 50f, Quaternion.identity);
            velocities[i] = Random.onUnitSphere * 2f;
        }
        accessArray = new TransformAccessArray(transforms);
    }

    void OnDestroy()
    {
        if (accessArray.isCreated) accessArray.Dispose();
        if (velocities.IsCreated) velocities.Dispose();
    }
}

[BurstCompile]
struct MoveJob : IJobParallelForTransform
{
    [ReadOnly] public NativeArray<Vector3> velocities;
    public float deltaTime;

    public void Execute(int index, TransformAccess transform)
    {
        transform.position += velocities[index] * deltaTime;
    }
}

[BurstCompile]가 붙은 잡 구조체는 네이티브 코드로 컴파일되어 일반 C# 대비 수배에서 수십배 빨라진다. Execute는 오브젝트 하나당 호출되고, Unity가 알아서 코어 수만큼 배치를 나눠 병렬로 실행한다. 5천 마리든 5만 마리든 작성하는 코드는 똑같다.

진짜 이득은 타이밍에 있다

여기서 흔한 실수 하나. Schedule 바로 뒤에 Complete를 부르면 메인 스레드는 잡이 끝날 때까지 멈춰서 기다리기만 한다. 그러면 병렬화의 의미가 없다.

제대로 된 패턴은 Update에서 예약만 하고, 메인 스레드가 다른 일(입력, UI, 사운드)을 처리한 뒤 LateUpdate에서 결과를 받는 것이다.

JobHandle moveHandle;

void Update()
{
    var job = new MoveJob
    {
        velocities = velocities,
        deltaTime = Time.deltaTime
    };
    // 예약만 하고 메인 스레드는 계속 진행
    moveHandle = job.Schedule(accessArray);
}

void LateUpdate()
{
    // 다른 작업이 끝난 뒤 여기서 결과 동기화
    moveHandle.Complete();
}

이렇게 하면 워커 스레드가 이동을 계산하는 동안 메인 스레드도 동시에 일한다. 여러 잡을 엮을 때는 Schedule의 두 번째 인자로 앞선 JobHandle을 넘겨 의존성을 연결할 수 있다. 예를 들어 속도를 계산하는 잡을 먼저 돌리고, 그 결과로 위치를 갱신하는 잡을 뒤에 거는 식이다.

반드시 알아야 할 함정

첫째, 잡 안에서는 position, rotation, localScale만 만질 수 있다. GetComponent, 부모 변경, InstantiateDestroy는 불가능하다. 이런 작업은 잡이 끝난 뒤 메인 스레드에서 처리해야 한다.

둘째, TransformAccessArrayAllocator.Persistent로 잡은 NativeArray는 반드시 OnDestroy에서 Dispose 해야 한다. 안 그러면 에디터에 메모리 누수 경고가 쌓인다.

셋째, 계층 구조가 성능을 좌우한다. IJobParallelForTransform은 같은 루트를 공유하는 Transform들을 같은 스레드로 묶는다. 쓰기 충돌을 막기 위해서다. 그래서 움직일 오브젝트는 부모 없이 평평하게 두는 편이 병렬화에 유리하다. 만약 위치를 읽기만 한다면 ScheduleReadOnly를 쓰면 이 제약 없이 자유롭게 병렬화된다.

정리

DOTS로 전면 전환할 여건이 안 되는 기존 프로젝트라면, Job System과 TransformAccessArray는 가장 비용 대비 효과가 좋은 카드다. GameObject 워크플로를 그대로 유지하면서 이동처럼 무거운 반복 연산만 떼어내 멀티스레드로 돌릴 수 있다. 핵심은 세 가지다. 잡 안에서는 Transform 좌표만 건드릴 것, Update에서 예약하고 LateUpdate에서 완료할 것, 네이티브 컨테이너는 꼭 해제할 것. 이 셋만 지키면 수천 오브젝트가 한꺼번에 움직여도 프레임은 끄떡없다.