흔한 덕후의 잡동사니
유니티에서 네트워크 송수신하기 (네트워크 스레드 관련) 본문
멀티스레딩 환경에서 Unity 객체를 안전하게 다루는 방법 정리
Unity에서 네트워크를 사용하다 보면, 자연스럽게 비동기 처리나 멀티스레딩을 활용하게 된다. 하지만 Unity의 엔진 구조상, 메인 스레드가 아닌 곳에서 Unity API를 호출하면 문제가 발생한다. 특히 Transform이나 GameObject와 같은 객체를 백그라운드 스레드에서 직접 조작하는 것은 매우 위험하다.
이 글에서는 Unity에서 네트워크 데이터를 송수신할 때 발생할 수 있는 스레드 문제와, 이를 해결하기 위한 잡 큐(Job Queue) 구조에 대해 설명하려한다.
1. Unity 객체는 메인 스레드에서만 접근 가능하다
Unity의 대부분의 API는 메인 스레드에서만 호출 가능하다.
특히 다음과 같은 Unity 엔진 객체들은 다른 스레드에서 직접 접근하면 안 된다.
Transform.position, GameObject.SetActive(), MonoBehaviour.enabled, Text.text, Animator.Play() 등등...
// 백그라운드 스레드에서 실행 (잘못된 코드)
transform.position = new Vector3(1, 2, 3); // 예외 발생 가능
이러한 코드가 포함되면, 게임 실행 중에 예외가 발생하거나 알 수 없는 크래시가 일어난다.
심지어 오류 메시지가 발생하고 클라이언트는 계속 실행되고 있어서 메시지를 보지 못하면 문제가 없는 것처럼 느껴져 원인을 찾기 힘들었었다.
2. MonoBehaviour와 메인 스레드
Unity에서 MonoBehaviour를 상속한 스크립트는 렌더링 파이프라인과 함께 메인 스레드에서만 실행된다. 다음과 같은 함수들은 모두 메인 스레드에서 호출된다:
Update(), FixedUpdate(), LateUpdate(), Start(), Awake(), OnTriggerEnter() 등
참고 - https://docs.unity3d.com/kr/2019.4/Manual/ExecutionOrder.html
따라서 Unity API를 호출해야 한다면 이 함수들 내에서 처리해야 한다.
3. 비동기 네트워크 처리에서 발생하는 문제
유니티에서 네트워크 통신은 스레드를 하나 빼서 I/O를 처리하는데, 하나의 스레드가 매번 뺑뺑이를 돌기 보단 비동기 방식으로 잡을 예약하는 방식이 효율적이다. 둘다 만들어 봤지만 blocking 방식은 스레드가 반환이 안된다는 문제가 있다. 하나의 역할 전문적으로 담당하는 스레드가 있는 느낌이다. 그에 반에 잡을 예약하는 방식은 일감이 들어오면 그때마다 ThreadPool에서 스레드 하나를 배정받아 일감을 처리하는 느낌으로 반환이 된다는 점에서 한번에 사용 가능한 스레드의 갯수를 제한하는 경우 조금 더 효율적이라 볼 수 있는 것이다.
=> 그렇다면 의미가 있는가? 아니다. 의미가 없다고 느낀건 어짜피 그렇게까지 많은 스레드가 클라이언트에 필요할 것 같다는 생각이 들지 않아서이다. 한번에 사용하는 스레드의 최대 갯수를 측정해봐도 어짜피 ThreadPool에서 기본으로 설정되어 있는 최대 갯수를 넘기지 못했다.
결론은 이런 이유로 일반적으로 비동기 방식으로 송수신을 이룬다고 한다. 예를 들어, Socket(ReceiveAsync), gRPC() 등은 ThreadPool을 활용한다.
문제는 이 콜백이 워커 스레드에서 호출되기 때문에, 여기서 Unity API를 호출하면 다음과 같은 문제가 생긴다.
connector.OnResponseReceived += (response) =>
{
// 워커 스레드에서 Unity 객체 직접 접근 (안 된다)
someGameObject.transform.position = new Vector3(1, 0, 0);
};
위 코드는 컴파일은 되지만, 실행 중에 크래시가 발생하거나 Unity가 멈출 수 있다.
4. 해결 방법 - 메인 스레드 작업 큐 (Job Queue)
백그라운드 스레드에서 Unity API를 직접 호출하는 대신, 작업 요청만 저장해두고, 메인 스레드가 이를 확인해 실행하는 구조가 필요하다. 이 구조를 흔히 Job Queue라 부른다.
구조는 다음과 같다.
1. 워커 스레드에서 작업 요청을 잡 큐에 등록한다. (여기서 메인 스레드에 요청하는 작업은 thread-safe 해야한다.)
2. 메인 스레드의 Update()에서 큐를 확인하여, 순차적으로 작업을 실행한다.
잡 큐 구현 방식 (3가지 방법)
4-1. lock + 일반 큐 방식
Queue<Action> jobQueue = new Queue<Action>();
object queueLock = new object();
// 워커 스레드
lock (queueLock)
{
jobQueue.Enqueue(() => someGameObject.transform.position = new Vector3(0, 0, 0));
}
// 메인 스레드 (Update)
lock (queueLock)
{
while (jobQueue.Count > 0)
jobQueue.Dequeue().Invoke();
}
4-2. ConcurrentQueue<T> 사용
ConcurrentQueue<Action> jobQueue = new ConcurrentQueue<Action>();
// 워커 스레드
jobQueue.Enqueue(() => someGameObject.transform.position = new Vector3(1, 1, 1));
// 메인 스레드 (Update)
while (jobQueue.TryDequeue(out var action))
{
action.Invoke();
}
4-3. 이중 버퍼 방식
Queue<Action> front = new Queue<Action>();
Queue<Action> back = new Queue<Action>();
void EnqueueJob(Action job)
{
lock (back) { back.Enqueue(job); }
}
void Update()
{
lock (back)
{
var temp = front;
front = back;
back = temp;
}
while (front.Count > 0)
front.Dequeue().Invoke();
}
1, 2번과 3번의 차이는 이중 버퍼 방식은 일종의 프레임을 부여할 때 사용이 가능하다. 네트워크 프레임을 제어하고 싶다면, 메인 스레드에서 Update함수 관련 내용을 호출하는 시간을 코루틴으로 예약하면 되는 것이다.
5. 예약 작업 처리 방식
잡큐를 사용하는건 thread-safe하기에 좋지만 문제는 시간단위로 처리되어야하는 일감이 있다는 것이다. 일반적인 JobQueue는 즉시 실행할 작업만 처리하기 때문에, 시간 단위로 실행되어야 할 작업은 별도로 다루는 것이 좋다.
이런 일감은 다음과 같이 처리하면 좋다.
1. priority queue 방식으로 시간을 기준으로 잡을 예약하는 방식 - 모든 작업을 하나의 잡큐로 처리
2. 예약이 필요한 일감들만 따로 관리하는것도 좋다.시간 기반 작업은 별도로 관리하고, 일반 작업 큐와 분리하는 것이다.
나는 2번을 선호한다. 디버깅이 1번에 비해 간편하다.
정리
1. Unity의 Transform, GameObject 등은 메인 스레드에서만 접근 가능하다.
2. 네트워크 콜백이나 비동기 처리 등은 워커 스레드에서 실행되므로 Unity API를 직접 호출하면 안 된다.
3. 메인 스레드에서 Unity API를 안전하게 호출하려면 Job Queue 구조를 도입해야 한다.
4. 성능과 구조에 따라 lock, ConcurrentQueue, 이중 버퍼 방식 중에서 선택하면 된다.
=> Unity에서 안정적으로 네트워크 데이터를 송수신하고 싶다면, 스레드 간 책임을 명확하게 나누고 메인 스레드에서만 Unity 객체를 조작하는 구조를 반드시 지키자.
'GameEngine > Unity' 카테고리의 다른 글
Unity UI 레이아웃과 이미지 활용하기 (0) | 2025.03.06 |
---|---|
Collider 컴포넌트의 Trigger 옵션에 대하여 (0) | 2025.02.10 |
Raycast 와 그 사용법에 대하여 (0) | 2025.02.09 |
IL2CPP, Mono, AOT, JIT 개념 정리 (0) | 2025.02.06 |
C#의 object, Unity의 Object의 차이 (0) | 2025.02.05 |