본 문서는 프로젝트 내 노드 간 통신(Node ServiceMesh)을 담당하는 NodeCommunicator 및 NetMQ 기반 소켓 통신의 성능을 극대화하기 위한 설계 원칙과 최적화 기법을 정리합니다.
고성능 네트워크 엔진에서 가장 큰 병목은 잦은 메모리 할당(new byte[])으로 인한 가비지 컬렉션(GC) 스파이크와, 스레드 간 데이터 전달 시 발생하는 메모리 복사 오버헤드입니다. 이를 해결하기 위해 NetMQ의 Msg 구조체와 .NET의 ArrayPool<byte>를 결합한 제로 카피(Zero-copy) 및 메모리 풀링 기법을 적용합니다.
NetMQ는 기본적으로 내부 버퍼 풀(GCBufferPool)을 사용하지만, 대규모 동시 접속 환경에서는 .NET BCL의 ArrayPool<byte>를 사용하는 것이 성능상 압도적으로 유리합니다. 특히 범용 Shared 풀 대신 네트워크 전용으로 **독립된 풀(ArrayPool<byte>.Create)**을 구성하여 자원 격리와 최적의 튜닝을 달성합니다.
- 자원 격리: JSON 직렬화기나 ASP.NET Core 등 다른 시스템이 버퍼를 고갈시켜도 네트워크 엔진(NetMQ)은 안정적으로 버퍼를 공급받을 수 있습니다.
- 커스텀 튜닝: 게임 패킷의 특성과 동시 처리량에 맞춰 최대 배열 크기와 버킷당 개수를 세밀하게 조절할 수 있습니다.
using System.Buffers;
using NetMQ;
namespace SimpleNetEngine.Infrastructure.NetMQ
{
public class DedicatedNetMQBufferPool : IBufferPool
{
private readonly ArrayPool<byte> _pool;
public DedicatedNetMQBufferPool(int maxArrayLength = 1024 * 64, int maxArraysPerBucket = 50000)
{
// 네트워크 엔진 전용으로 거대한 크기의 독립 풀 생성
_pool = ArrayPool<byte>.Create(maxArrayLength, maxArraysPerBucket);
}
public byte[] Take(int size)
{
return _pool.Rent(size);
}
public void Return(byte[] buffer)
{
_pool.Return(buffer);
}
}
}애플리케이션 시작 시(예: Program.cs 또는 HostedService 초기화 단계) 글로벌 풀을 교체합니다.
NetMQ.BufferPool.SetCustomBufferPool(new DedicatedNetMQBufferPool());Msg.InitPool(size)를 호출하면 앞서 등록한 커스텀 버퍼 풀(DedicatedNetMQBufferPool)에서 버퍼를 빌려옵니다. Span을 이용해 직렬화하면 중간에 byte[] 복사가 발생하지 않습니다.
public void SendOptimized(byte[] identity, IMessage message)
{
int payloadSize = message.CalculateSize();
var payloadMsg = new Msg();
payloadMsg.InitPool(payloadSize); // 커스텀 풀에서 버퍼 대여
// 복사 없이 직접 Write
message.WriteTo(payloadMsg.Slice());
var identityMsg = new Msg();
identityMsg.InitGC(identity, identity.Length);
try
{
// RouterSocket의 경우 Identity 프레임 선전송 (More 플래그)
_routerSocket.Send(ref identityMsg, true);
_routerSocket.Send(ref payloadMsg, false);
}
finally
{
// [중요] Send 직후 Close 호출
identityMsg.Close();
payloadMsg.Close();
}
}수신 시에는 msg.Move를 사용하여 버퍼의 소유권을 포인터만으로 이전합니다.
public void OnReceiveReady(object sender, NetMQSocketEventArgs e)
{
Msg identityMsg = new Msg();
Msg bodyMsg = new Msg();
identityMsg.InitEmpty();
bodyMsg.InitEmpty();
try
{
// 소켓 버퍼에서 직접 수신 (복사 없음)
e.Socket.Receive(ref identityMsg);
if (identityMsg.HasMore)
{
e.Socket.Receive(ref bodyMsg);
}
// 수신된 bodyMsg의 소유권을 NodePacket으로 이전 (Move)
var nodePacket = NodePacket.Create(ref bodyMsg);
OnProcessPacket?.Invoke(nodePacket);
}
finally
{
identityMsg.Close();
bodyMsg.Close(); // 이미 Move되었다면 무시됨
}
}GameServer에서 클라이언트로 보내는 응답 패킷을 생성할 때, Gateway에서 발생하는 버퍼 재할당 및 복사 오버헤드를 없애기 위해 사전에 EndPointHeader 공간을 포함하여 메모리를 구성(Pre-allocation)하는 강력한 최적화 기법입니다.
Gateway를 거쳐 Client로 전달되는 패킷은 다음과 같이 하나의 연속된 버퍼로 할당되어 전송됩니다.
[GSCHeader] | [EndPointHeader] | [GameHeader] | [Message Payload]
-
GameServer (직렬화 및 Headroom 확보)
- GameServer는 응답 패킷을 만들 때, Gateway와 Client 간의 TCP 프레이밍 규칙인
EndPointHeader가 들어갈 자리를 미리 계산하여 포함시킵니다. - 이때
EndPointHeader.TotalLength는 EndPointHeader의 크기(4) + GameHeader의 크기(8) + Message의 크기를 정확히 합산하여 기록합니다.
- GameServer는 응답 패킷을 만들 때, Gateway와 Client 간의 TCP 프레이밍 규칙인
-
Gateway (복사 없는 슬라이싱 라우팅)
- Gateway는 수신된 패킷에서
GSCHeader만 읽어 타겟 클라이언트를 확인합니다. - 이후 데이터를 새 버퍼로 복사하지 않고,
EndPointHeader부터 시작하는 나머지 데이터 영역을 단순히 **Slice(포인터 오프셋 이동)**만 수행하여 TCPSocket.SendAsync로 넘깁니다. - 결과: Gateway 노드의 할당(Allocation) 0, 복사(Copy) 0.
- Gateway는 수신된 패킷에서
public static Msg CreateOptimizedReplyPacket(GSCHeader routeHeader, int msgId, IMessage replyMsg)
{
int payloadSize = replyMsg.CalculateSize();
// TotalLength 계산: EndPointHeader(자신 포함) + GameHeader + Payload
int tcpTotalLength = EndPointHeader.Size + GameHeader.Size + payloadSize;
// 전체 Msg 크기 계산: GSCHeader + tcpTotalLength
int totalSize = GSCHeader.Size + tcpTotalLength;
Msg msg = new Msg();
msg.InitPool(totalSize); // NetMQ 내부 풀(또는 커스텀 풀)에서 단일 할당
var span = msg.Slice();
int offset = 0;
// 1. [GSC Header] 기록 (내부 라우팅용)
MemoryMarshal.Write(span.Slice(offset, GSCHeader.Size), in routeHeader);
offset += GSCHeader.Size;
// 2. [EndPoint Header] 기록 (클라이언트가 해석할 프레이밍 헤더)
var endPointHeader = new EndPointHeader { TotalLength = tcpTotalLength };
MemoryMarshal.Write(span.Slice(offset, EndPointHeader.Size), in endPointHeader);
offset += EndPointHeader.Size;
// 3. [Game Header] 기록
var gameHeader = new GameHeader { MsgId = msgId /*, SequenceId 등 */ };
MemoryMarshal.Write(span.Slice(offset, GameHeader.Size), in gameHeader);
offset += GameHeader.Size;
// 4. [Message Payload] 기록
replyMsg.WriteTo(span.Slice(offset, payloadSize));
return msg;
}- 메모리 수명 주기 (Lifecycle): Gateway에서 Slice된
Msg의 버퍼를 비동기 TCP 전송(await SendAsync)에 사용할 경우, 전송이 완전히 끝날 때까지 해당Msg를Close()하여 풀로 반환해서는 안 됩니다. - 관심사 분리: GameServer 비즈니스 로직(UserController)이
EndPointHeader를 직접 알게 하지 말고, Middleware나 패킷 전송 유틸리티 클래스 등에서 캡슐화하여 덧붙이는 구조로 설계해야 합니다.
- Send 직후 항상 Close 호출
_socket.Send(ref msg)가 성공하면 내부적으로 **Move**가 발생하여 호출 측의msg는 비워집니다.- 전송 직후
msg.Close()를 호출하는 것은 매우 안전하며, 예외 상황(네트워크 오류 등) 발생 시 누수를 방지하는 권장 패턴입니다. - 실제 버퍼 반환은 NetMQ 내부 엔진이 전송을 완전히 끝내고 내부 참조 카운트가 0이 될 때
IBufferPool.Return을 통해 자동 수행됩니다.
- InitExternal은 사용하지 않음
- 최신 NetMQ 코어에서는 콜백을 지원하는
InitExternal이 존재하지 않습니다. - 따라서 외부 풀을 수동으로 연동하는 대신, 글로벌
IBufferPool을SetCustomBufferPool로 등록하고 **InitPool**을 호출하여 라이브러리에 생명 주기 관리를 위임하는 것이 정석입니다.
- 최신 NetMQ 코어에서는 콜백을 지원하는