C# Job System과 ECS로 몬스터·투사체 병렬 처리하기
Unity C# Job System과 ECS, Burst Compiler를 활용해 몬스터와 투사체 연산을 병렬화하는 원리와 구현 기법을 살펴봅니다. 성능 향상은 작업 부하와 대상 플랫폼에서 프로파일링해 확인합니다.

메인 스레드 병목과 데이터 중심 설계의 필요성
화면에 많은 몬스터나 탄막 투사체가 있는 게임에서는 CPU 메인 스레드가 병목이 될 수 있습니다. 각 객체가 MonoBehaviour의 Update()를 사용하는 설계도 한 가지 선택지지만, 실제 병목은 객체 수만으로 정해지지 않으므로 Profiler로 확인해야 합니다.
MonoBehaviour 객체 수가 증가한다고 매 프레임 GC 할당이 필연적으로 발생하는 것은 아닙니다. 다만 많은 객체의 호출·간접 메모리 접근 비용이 문제가 되는 프로젝트라면 데이터 배치와 업데이트 방식을 프로파일링해 비교할 수 있습니다.

연속 메모리 접근이 중요하다면 데이터 중심 설계(Data-Oriented Design)를 검토할 수 있습니다. AoS 대신 SoA로 필요한 좌표나 속성만 모으면 접근 패턴에 따라 캐시 효율이 좋아질 수 있지만, 실제 이득은 데이터와 작업 부하에 따라 달라집니다.
접근 비용을 설명하기 위한 개념적 모델은 다음처럼 쓸 수 있습니다. 이는 실행 시간을 예측하는 정량식은 아닙니다.
실제 실행 시간에서 계산과 메모리 접근이 차지하는 비율은 플랫폼과 데이터 접근 패턴에 따라 달라집니다. SoA가 항상 캐시 미스를 최소화하거나 성능을 크게 높인다고 단정하지 말고 측정 결과로 판단해야 합니다.
C# Job System을 통한 멀티스레드 병렬화
Unity의 C# Job System은 작업과 의존성을 스케줄링해 여러 스레드에서 실행할 수 있게 합니다. NativeContainer 접근 안전 검사는 지원하지만 모든 경쟁 조건이나 데드락을 자동으로 막아 주는 것은 아니며, 특히 정적 데이터 접근 등은 코드에서 관리해야 합니다.
NativeArray<T>는 관리 힙이 아니라 네이티브 메모리를 가리키는 관리형 값 형식 래퍼입니다. IJobParallelFor는 독립된 인덱스 작업을 병렬 스케줄링하는 데 사용할 수 있지만, 어느 스레드가 작업을 실행하는지와 분할 방식은 Job System이 결정합니다.
flowchart TD
A[메인 스레드: Job 생성 및 Schedule] --> B[Job Scheduler Queue]
B --> C[Worker Thread 1: 1~2500번 몬스터 처리]
B --> D[Worker Thread 2: 2501~5000번 몬스터 처리]
B --> E[Worker Thread 3: 5001~7500번 몬스터 처리]
B --> F[Worker Thread 4: 7501~10000번 몬스터 처리]
C & D & E & F --> G[JobHandle.Complete 메인 스레드 동기화]
위 다이어그램은 1만 개 위치 연산이 여러 작업자에게 분산되는 개념도이며, 스레드 수와 인덱스 분할은 고정값이 아닙니다. Job System은 상황에 따라 메인 스레드에서도 작업을 실행할 수 있습니다.
다음은 IJobParallelFor를 활용하여 수만 개 투사체의 위치를 병렬로 업데이트하는 C# 코드 예제입니다.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
[BurstCompile]
public struct MovementJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Positions;
[ReadOnly] public NativeArray<float3> Velocities;
public NativeArray<float3> OutputPositions;
public float DeltaTime;
public void Execute(int index)
{
OutputPositions[index] = Positions[index] + Velocities[index] * DeltaTime;
}
}
Entities(ECS)와 Burst Compiler의 시너지
C# Job System과 Entities, Burst는 데이터 중심의 병렬 계산에 활용할 수 있습니다. 성능 향상 여부와 폭은 작업·플랫폼·코드에 따라 달라지므로 비교 프로파일링이 필요합니다.
Entities는 동일한 Archetype의 엔티티를 연속된 메모리 청크에 저장합니다. 청크 크기와 세부 규칙은 Entities 버전에 따라 확인해야 합니다. Burst는 호환되는 IL/.NET 코드를 LLVM을 사용해 네이티브 코드로 변환하고 최적화하지만, 모든 코드가 SIMD로 벡터화되는 것은 아닙니다.

IJobEntity를 사용하면 ECS의 컴포넌트 쿼리를 기반으로 병렬 연산을 간결하게 작성할 수 있습니다.
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
public struct Velocity : IComponentData
{
public float3 Value;
}
[BurstCompile]
public partial struct ProjectileMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float deltaTime = SystemAPI.Time.DeltaTime;
var job = new MoveJob
{
DeltaTime = deltaTime
};
state.Dependency = job.ScheduleParallel(state.Dependency);
}
[BurstCompile]
public partial struct MoveJob : IJobEntity
{
public float DeltaTime;
public void Execute(ref LocalTransform transform, in Velocity velocity)
{
transform.Position += velocity.Value * DeltaTime;
}
}
}
실전 적용 시 주의사항과 성능 팁
멀티스레드와 ECS 패키지를 도입할 때 자주 직면하는 성능 병목 포인트와 해결 방안입니다.
1. Structural Change 최소화
엔티티의 생성, 파괴, 컴포넌트 추가/삭제 같은 구조적 변경은 동기화 비용을 만들 수 있습니다. ECB에 변경을 기록하고 적절한 시스템 시점에 재생하면 작업 의존성과 변경 시점을 관리하기 쉽지만, 반드시 프레임 끝에서만 처리해야 하는 것은 아닙니다.
2. JobHandle 동기화 지점 지연
Job을 생성한 직후 JobHandle.Complete()를 호출하면 메인 스레드가 워커 스레드의 작업 완료를 기다리느라 대기 상태에 빠집니다. Job 스케줄링은 가능한 한 프레임 초반(예: Update)에 수행하고 완료 동기화는 데이터가 실제로 필요한 프레임 후반(예: LateUpdate 또는 렌더링 직전)까지 미루는 것이 좋습니다.
3. NativeContainer 메모리 누수 방지
NativeArray, NativeList 등의 구조체는 비관리형 메모리를 사용하므로 가비지 컬렉터가 해제해주지 않습니다. 사용이 끝난 컨테이너는 반드시 .Dispose()를 호출하거나 Job 완료 시 자동 해제되도록 .Dispose(jobHandle)를 활용해야 메모리 누수를 막을 수 있습니다.



