마샬링(Marshalling) 이란?
C#과 같은 Managed 환경은 가비지 컬렉터(GC)가 메모리를 관리하며, 데이터가 메모리에 배치되는 방식의 런타임에 의해 유동적으로 결정될 수 있다. 반면 C/C++ 같은 Unmanaged 환경은 개발자가 직접 메모리를 제어하며, 메모리 레이아웃이 고정되어 있다.
이런 서로 다른 두 환경에서 데이터를 교환하려면 번역 과정이 필요하게 된다.
예를 들어 C#의 string 타입은 내부적으로 UTF-16 인코딩을 사용하는 객체지만, C++ 에서는 const char* (UTF-8 또는 ASCII) 형태의 포인터 배열로 돼있다.
이때 C#의 문자열을 C++이 이해할 수 있는 메모리 구조로 복사하고 변환해 주는 작업이 마샬링이다.
Blittable 타입과 Non-Blittable 타입
마샬링 성능을 이해하려면 두 타입의 특성을 알아야 한다.
Blittable 타입
Managed 와 Unmanaged 양쪽에서 메모리 표현 방식이 완전히 동일한 타입. 변환 과정 없이 메모리 복사(Bit-block transfer)만으로 전달되므로 오버헤드가 거의 없다. (byte, sbyte, short, ushort, int, uint, long, ulong, float, double, IntPtr 등)
Non-Blittable 타입
양쪽의 메모리 표현 방식이 달라 마샬러(Marshaler)가 개입해 데이터를 변환해야 하는 타입으로 오버헤드가 발생한다. (bool, stirng, object, 클래스, 가변 배열 등)
참고로 C#의 bool은 4바이트를 차지하지만 C++의 bool은 일반적으로 1바이트다.
Photon Fusion 에서의 실전 활용
유니티 멀티플레이어 개발에서 널리 사용되는 Photon Fusion 같은 네트워크 엔진을 사용할 때, 이 Blittable/Non-Blittable 개념을 활용해 메모리 최적화가 가능하다.
Fusion은 완벽한 클라이언트 예측과 롤백을 위해 매 틱마다 게임의 전체 상태를 저장하고 복원한다.
만약 이 상태 데이터가 C#의 매니지드 힙(Managed Heap)에 파편화되어 있다면, 매 틱마다 데이터를 직렬화하고 마샬링하는 데 엄청난 CPU 연산과 GC가 발생할 것이다.
따라서 Fusion은 상태 데이터를 통째로 언매니지드 메모리*Unmanaged Memory) 버퍼에 연속적으로 배치하여, 마샬링 오버헤드 없이 Memcpy (메모리 블록 복사) 만으로 스냅샷을 찍고 롤백하는 방식을 취한다.
Case 1 : 지옥의 string 마샬링 피하기
채팅 메시지나 플레이어의 닉네임을 동기화해야 한다고 가정해 보자.
Non-Blittable 타입인 string을 아무 생각 없이 사용했다간 네트워크 버퍼에 C# string을 밀어 넣는 과정에서 인코딩 변환과 힙 메모리 할당(마샬링)이 발생한다.
이 때 고정 크기 버퍼(Fixed Buffer) 을 사용하는 것으로 최적화 전략을 취할 수 있다.
네트워크 틱마다 갱신될 수 있는 핫 패스(Hot path)에서 순수 string의 사용은 쥐약이다.
그래서 Fusion 에서는 이를 해결하기 위해 NetworkString<_16>, NetworkString<_32> 같은 전용 타입을 제공한다.
// 최악의 접근: 매 틱마다 GC 할당 및 마샬링 오버헤드 발생
[Networked] public string PlayerName { get => default; set; }
// 최적화된 접근: 32바이트 언매니지드 메모리 공간을 직접 차지함 (Blittable)
[Networked] public NetworkString<_32> PlayerName { get => default; set; }
NetworkString은 내부적으로 C#의 fixed 키워드와 unsafe 구조체를 활용하여, 포인터 기반의 고정 크기 바이트 배열로 문자를 처리한다. 즉, Non-Blittable인 문자열을 강제로 Blittable한 구조체로 포장하여 마샬링 비용을 0으로 만든 것이다.
Case 2 : bool 대신 비트 마스킹(Bit-masking)
C#의 bool은 4바이트를 차지하는 Non-Blittable 타입이다.
때문에 캐릭터의 상태를 동기화하기 위해 bool 변수를 여러개 선언하면, 메모리 낭비는 물론 Unmanaged 버퍼로 넘어갈 때 오버헤드가 누적된다.
이 때 상태를 하나의 Blittable 타입(int, byte)으로 압축 할 수 있다.
bool 대신에 Blittable 타입인 byte나 int 하나를 선언하고, 비트 연산자를 이용해 여러 상태를 플래그(Flag)로 관리하면 마샬링 오버헤드 없이 깔끔하게 메모리가 복사된다.
// 비효율적인 상태 동기화 (마샬링 대상이며 메모리 낭비 심함)
[Networked] public NetworkBool IsJumping { get; set; }
[Networked] public NetworkBool IsAttacking { get; set; }
[Networked] public NetworkBool IsCrouching { get; set; }
// 최적화된 접근: 1개의 int(4바이트)에 32가지 상태를 Blittable하게 압축
[Networked] public int PlayerStateFlags { get; set; }
// 비트 연산을 통한 상태 제어 (마샬링 없음, Bit-block transfer 보장)
public void SetJumping(bool state) {
if (state)
PlayerStateFlags |= (1 << 0);
else
PlayerStateFlags &= ~(1 << 0);
}
Case 3 : 가변 데이터 통신을 위한 메모리 풀링 (Memory Pooling)
앞에서 언급한 NetworkString<_16> 이나 int 비트 마스킹은 데이터의 크기가 고정되어 있을 때 사용하는 고정 크기 버퍼(Fixed Buffer) 방식이다. 구조체 내부에 메모리를 인라인으로 박아 넣어 힙 할당 자체를 없애는 것이다.
하지만 네트워크 통신이나 C++ 플러그인과 데이터를 주고받다 보면, 크기가 매번 변하는 가변 배열을 마샬링 하는 경우가 생긴다.
크기가 유동적이니 구조체 안에 고정된 크기로 박아둘ㄷ 수도 없다.
이때 매 프레임 new byte[size]를 호출해 배열을 생성하면 GC 부하가 생긴다.
이를 해결하는 방법이 바로 메모리 풀링(Memory Pooling) 이다.
메모리 할당은 하드에 읽기 쓰기를 빼면 가장 느린 작업 중 하나이기에 메모리 할당 자체를 줄이는 게 좋다는 발상으로 근본적으로 오브젝트 풀링과 같은 발상이다.
객체의 논리적 껍데기를 재사용하는 오브젝트 풀링과 달리, 메모리 풀링은 거대한 순수 메모리 덩어리 (바이트 배열이나 언매니지드 포인터 공간)를 하나 통쨰로 빌려두고, 필요한 만큼 잘라서 쓰고 반납하는 기법이다.
C# 최신 버전과 유니티에서는 이를 위해 System.Buffers.ArrayPool<T> 를 기본적으로 제공한다.
이는 Managed 영역에서 발생하는 배열 할당을 극단적으로 줄여주며, 이를 바탕으로 Marshal.UnsafeAddrOfPinnedArrayElement나 Fixed 구문을 조합해 마샬링 오버헤드를 최소화할 수 있다.
using System.Buffers;
using System.Runtime.InteropServices;
// 최악의 접근: 매번 새로운 배열을 생성하여 GC 폭발 및 마샬링 오버헤드 발생
void SendVariableData_Bad(int requiredSize)
{
byte[] buffer = new byte[requiredSize]; // 매 프레임 GC 할당
NativePlugin.Send(buffer, requiredSize);
}
// 최적화된 접근: ArrayPool을 이용한 메모리 풀링
void SendVariableData_Good(int requiredSize)
{
// 1. ArrayPool에서 필요한 크기 '이상'의 메모리 덩어리를 빌려온다. (GC 할당 0)
byte[] pooledBuffer = ArrayPool<byte>.Shared.Rent(requiredSize);
try
{
// 2. 관리형 배열을 고정(Pinning)하여 포인터만 C++(Unmanaged)로 넘긴다.
// 메모리 복사(Bit-block transfer)조차 발생하지 않는 Zero-Copy 마샬링
unsafe
{
fixed (byte* pBuffer = pooledBuffer)
{
NativePlugin.Send(pBuffer, requiredSize);
}
}
}
finally
{
// 3. 사용이 끝난 메모리 덩어리를 풀에 반납한다.
ArrayPool<byte>.Shared.Return(pooledBuffer);
}
}
하지만 현시점에서 이렇게까지 최적화를 하며 작업하는 경우는 일반적인 게임 클라이언트 개발에서 많지 않다고 한다.
누군가가 비트단위 쪼개가며 메모리 풀링을 하고 있다면 높은 확률로 연배가 있으실 거라는 이야기를 들었다... (응애 개발자는 존경스러울 따름이다...)
'Unity' 카테고리의 다른 글
| Unity : C#과 Unity에서의 암묵적 Boolean 값 변환 => if (gameObject) 가능?? (0) | 2026.03.02 |
|---|---|
| Unity : 같은 PC에서 클라이언트 여러개 실행시 멈추는 현상 (Firestore) (0) | 2026.02.08 |
| Unity : 관리자 콘솔 구현 및 프리징 현상 개선 (0) | 2026.02.08 |
| Unity : Firebase 등록 및 Auth로 (이메일/소셜) 로그인 구현 (0) | 2026.01.11 |
| Unity Network : Photon Pun2 - PhotonView, IPunObservable, RPC (0) | 2026.01.07 |