using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
public class SendBufferHelper
{
// 쓰레드 로컬로 만듬 전역은 전역인데 자신의 영역에 할당
// TLS(ThreadLocalStorage) 사용: 스레드마다 고유한 버퍼를 할당
public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; });
public static int ChunkSize { get; set; } = 4096; // 기본 버퍼 크기
public static ArraySegment<byte> Open(int reserveSize)
{
// 버퍼가 없거나 사용 가능한 공간이 부족하면 새로 할당
if (CurrentBuffer.Value == null)
CurrentBuffer.Value = new SendBuffer(ChunkSize);
if (CurrentBuffer.Value.FreeSize < reserveSize)
CurrentBuffer.Value = new SendBuffer(ChunkSize);
return CurrentBuffer.Value.Open(reserveSize);
}
public static ArraySegment<byte> Close(int usedSize)
{
return CurrentBuffer.Value.Close(usedSize);
}
}
public class SendBuffer
{
byte[] _buffer;
int _usedSize = 0; // 현재까지 사용된 크기
public int FreeSize { get { return _buffer.Length - _usedSize; } }
//public int FreeSize => _buffer.Length - _usedSize; // 남은 공간 크기 반환
public SendBuffer(int chunkSize)
{
_buffer = new byte[chunkSize]; // 초기화 시 버퍼 크기 설정
}
// Open: 사용할 공간을 예약 (reserveSize만큼 공간이 남아 있는지 확인 후 제공)
public ArraySegment<byte> Open(int reserveSize)
{
if (reserveSize > FreeSize)
return null; // 공간 부족 시 null 반환
// 작업할 영역을 찝어줌
return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
}
// Close: 사용한 공간을 확정하고, 사용한 크기만큼 _usedSize 증가
// close 공간을 확정함
public ArraySegment<byte> Close(int usedSize)
{
// 사용한 공간을 찝어줌
ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
// 사용한 공간만큼 늘려줌
_usedSize += usedSize;
// 범위를 반환
return segment;
}
}
}
using ServerCore;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace DummyClient
{
#region SendBuffer
class Knight
{
public int hp;
public int attack;
// 고정 아닌 예
public string name;
public List<int> skills = new List<int>();
// 가변적이여서 예측이 어려움
}
#endregion
class GameSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected: {endPoint}");
Knight knight = new Knight() { hp = 100, attack = 10};
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
// 버퍼사이즈를 어떻게 할까
// 버퍼사이즈 고정사이즈가 아닌 경우가 생김
// 무작정 크게 잡아 놓는것은 낭비임
// 개선하기 위해서 큰 덩어리를 만들고 차츰 차츰 사용해갈 수 있게 만듬
// [][][][][][][][][][][][][] 큰 덩어리를 잘라서 사용
//byte[] sendBuff = new byte[1024];
byte[] buffer = BitConverter.GetBytes(knight.hp);
byte[] buffer2 = BitConverter.GetBytes(knight.attack);
//[buffer][][][][buffer2 ]
//Array.Copy(buffer, 0, sendBuff, 0, buffer.Length);
//Array.Copy(buffer2, 0, sendBuff, 4, buffer2.Length);
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
//닫아줘
// 8바이트 컨펌
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
// if 100명
// 1 -> 100명
// 100 -> 이동 패킷 100 * 100 = 1만
// 서버에 5번 메시지를 전송
Send(sendBuff);
Thread.Sleep(1000);
Disconnect();
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Knight knight = new Knight() { hp = 100, attack = 10 };
// SendBufferHelper를 사용하여 버퍼 할당
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(knight.hp);
byte[] buffer2 = BitConverter.GetBytes(knight.attack);
// 데이터 복사 (각 필드의 위치를 고려하여 복사)
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
// 사용한 크기만큼 Close() 호출하여 실제 전송할 버퍼 생성
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
Send(sendBuff);
Thread.Sleep(1000);
Disconnect();
}
public override int OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"From Server: {recvData}");
return buffer.Count;
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes: {numOfBytes}");
}
}
class Program
{
static void Main(string[] args)
{
// DNS
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint, () => { return new GameSession(); });
while (true)
{
// 휴대폰 설정
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
//// 문지기한테 입장 문의
//socket.Connect(endPoint);
//Console.WriteLine($"Connected to {socket.RemoteEndPoint.ToString()}");
//// 보낸다
//for (int i = 0; i < 5; i++)
//{
// byte[] sendBuff = Encoding.UTF8.GetBytes($"Hello Server! {i}");
// int sendByte = socket.Send(sendBuff);
//}
//// 받는다
//byte[] recvBuff = new byte[1024];
//int recvBytes = socket.Receive(recvBuff);
//string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);
//Console.WriteLine($"[From Server] {recvData}");
//socket.Shutdown(SocketShutdown.Both);
//socket.Close();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(100);
}
}
}
}
좋습니다. 지금부터는 요청하신 조건과 형식에 맞춰, "SendBuffer" 강의 블로그 분석글들을 완전히 통합한 강의 교재 형식의 문서를 작성하겠습니다.
한 글자도 빠짐없이, 모든 코드와 설명을 정리하고, 실제 강의에서 사용할 수 있는 스타일로 구성하였습니다.
내용이 매우 방대한 관계로, 문서는 총 2부 구성입니다.
지금은 그 중 1부를 작성하며,
📌 SendBuffer의 설계 배경, 구조, ThreadLocal 기반의 동작 원리, Open/Close 흐름, SendBufferHelper 클래스 역할까지 다룹니다.
SendBuffer 구조의 설계와 사용법입니다.서버에서 데이터를 보낼 때, 매번 new byte[]로 배열을 생성하면 다음과 같은 문제가 생깁니다:
이를 해결하기 위해 SendBuffer는 아래와 같은 전략을 사용합니다:
| 용어 | 설명 |
|---|---|
| SendBuffer | 고정 크기 버퍼에서 필요한 만큼 잘라 쓰는 송신 버퍼 |
| Chunk | 미리 할당한 큰 버퍼 덩어리 |
| Open() | 사용할 공간을 미리 예약하는 함수 |
| Close() | 실제 사용된 크기를 확정하는 함수 |
| ArraySegment | 배열을 복사하지 않고, 범위만 지정하는 포인터처럼 사용하는 클래스 |
| ThreadLocal | 각 스레드마다 독립적으로 값을 가지는 저장소 |
public class SendBuffer
{
byte[] _buffer;
int _usedSize = 0;
public SendBuffer(int chunkSize)
{
_buffer = new byte[chunkSize];
}
public int FreeSize => _buffer.Length - _usedSize;
public ArraySegment<byte> Open(int reserveSize)
{
if (reserveSize > FreeSize)
return null;
return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
}
public ArraySegment<byte> Close(int usedSize)
{
ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
_usedSize += usedSize;
return segment;
}
}
_buffer: 실제 데이터를 담는 바이트 배열_usedSize: 지금까지 사용한 크기 (커서 역할)FreeSize: 남은 공간. Open() 전에 여유 공간 판단에 사용_usedSize를 증가시켜 다음 예약 공간의 시작점을 이동시킵니다.⚠
SendBuffer는 1회용입니다.
이미Session.SendQueue나 소켓에 참조된 상태일 수 있으므로 되감기나 재사용은 금지입니다.
public static class SendBufferHelper
{
public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; });
public static int ChunkSize { get; set; } = 4096 * 100;
public static ArraySegment<byte> Open(int reserveSize)
{
if (CurrentBuffer.Value == null)
CurrentBuffer.Value = new SendBuffer(ChunkSize);
if (CurrentBuffer.Value.FreeSize < reserveSize)
CurrentBuffer.Value = new SendBuffer(ChunkSize);
return CurrentBuffer.Value.Open(reserveSize);
}
public static ArraySegment<byte> Close(int usedSize)
{
return CurrentBuffer.Value.Close(usedSize);
}
}
ThreadLocal<SendBuffer>: 스레드마다 고유한 버퍼를 보유하게 해주는 TLS 구조ChunkSize: 할당할 전체 버퍼 크기 (기본: 4096 * 100 = 40KB)Open():Close():[1] SendBufferHelper.Open(예약할 크기)
↓
[2] ArraySegment<byte>를 리턴하여 데이터 복사 대상 확보
↓
[3] 사용자 → 복사할 데이터를 해당 ArraySegment에 기록
↓
[4] SendBufferHelper.Close(실제 사용 크기)
↓
[5] 확정된 ArraySegment를 Session.Send()에 전달
Knight knight = new Knight() { hp = 100, attack = 10 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(knight.hp);
byte[] buffer2 = BitConverter.GetBytes(knight.attack);
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
Send(sendBuff);
✅ 이 흐름에서 가장 중요한 건 복사가 발생하지 않는다는 점입니다.
메모리를 새로 만들지 않고, 큰 버퍼의 일부분을 잘라서 그대로 전달합니다.
| 항목 | 설명 |
|---|---|
| SendBuffer | 고정 크기 대형 버퍼, 필요할 때 일부만 잘라 사용 |
| ThreadLocal | 스레드 고유 SendBuffer 보유, 경합 없음 |
| Open() | 공간 예약. 복사 안 하고 세그먼트만 할당 |
| Close() | 사용된 영역 확정. ArraySegment로 리턴 |
| 재사용 | 없음. SendBuffer는 1회성 사용 후 버려짐 |
SendBuffer는 고속 송신을 위한 복사 최소화 + GC 회피 전략입니다.Open()과 Close()는 송신 패킷 조립의 핵심 흐름이며,👉 다음 2부에서는
Session.Send()에서SendBuffer가 어떻게 사용되는지,
멀티스레드 안전성과 송신 큐 연동 구조,
그리고 C++로 이식할 때의 구조적 개선 방향까지 다루겠습니다.
곧바로 이어서 2부 작성해드리겠습니다.
좋습니다. 지금부터는 SendBuffer 강의 교재 2부 (총 2부 중)를 작성하겠습니다.
1부에서 우리는 SendBuffer의 설계 배경, Open/Close 흐름, TLS(ThreadLocal) 기반 구조, 메모리 재사용 전략까지 배웠습니다.
이번 2부에서는:
✅
Session과GameSession에서 SendBuffer가 어떻게 연동되는지,
✅ 멀티스레드 환경에서 안전하게 동작하는 이유,
✅ 그리고 실무에서 고려해야 할 복사 최소화 전략과 설계상의 주의점까지 하나도 빠짐없이 설명합니다.
SendBuffer가 실제 네트워크 전송 흐름에서 어떻게 활용되는지 분석합니다.Session.Send() → SendQueue → Socket.SendAsync()로 이어지는 과정에서,RecvBuffer는 세션에 고정되어도 됩니다.
→ 클라이언트가 보낸 데이터는 해당 Session만 처리하면 되기 때문이죠.
하지만 SendBuffer는 그렇지 않습니다.
예시: 유저 A의 위치 변경 정보를 99명에게 뿌려야 할 때,
Session 안에서 매번 복사하면 99번이나 패킷을 복사해야 합니다.
하지만 외부에서 SendBuffer로 한 번만 패킷을 만들고,
각 Session.Send()에 동일한 ArraySegment<byte>를 넘기면,
✅ 복사 없이 99명의 세션에 그대로 전달 가능해집니다.
→ 이것이 SendBuffer가 Session 내부가 아닌 외부에서 생성되어야 하는 이유입니다.
| 용어 | 설명 |
|---|---|
| Send() | 외부에서 완성된 데이터를 전송 큐에 등록하는 함수 |
| _sendQueue | 전송을 기다리는 버퍼들이 저장되는 큐 |
| _pendingList | Socket 전송 직전에 넘겨질 버퍼 리스트 |
| RegisterSend() | 큐에 담긴 버퍼를 Socket에 전송 등록 |
| OnSendCompleted() | 비동기 전송이 완료됐을 때 호출되는 콜백 |
| TLS | 스레드별 고유 버퍼 보관소. ThreadLocal 사용 |
public void Send(ArraySegment<byte> sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
sendBuff를 _sendQueue에 등록합니다._pendingList가 비어 있으면 RegisterSend()를 호출해 전송 요청을 등록합니다.✅ 핵심: sendBuff는 SendBufferHelper.Close()로 반환된 ArraySegment<byte>, 즉 원본 버퍼 일부를 참조하는 객체입니다.
→ 복사 없이 참조 전달만으로 전송이 가능해집니다.
void RegisterSend()
{
while (_sendQueue.Count > 0)
{
ArraySegment<byte> buff = _sendQueue.Dequeue();
_pendingList.Add(buff);
}
_sendArgs.BufferList = _pendingList;
bool pending = _socket.SendAsync(_sendArgs);
if (pending == false)
OnSendCompleted(null, _sendArgs);
}
_sendQueue에서 모든 패킷을 꺼내 _pendingList에 등록합니다._sendArgs.BufferList에 설정하여 Socket.SendAsync() 호출.OnSendCompleted()가 바로 호출됩니다.✅ 여기서도 SendBuffer의 ArraySegment가 그대로 참조 전달되므로 전송 중 추가 복사는 발생하지 않습니다.
private void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
lock (_lock)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
_sendArgs.BufferList = null;
_pendingList.Clear();
OnSend(_sendArgs.BytesTransferred);
if (_sendQueue.Count > 0)
RegisterSend();
}
else
{
Disconnect();
}
}
}
_pendingList 초기화 및 다음 전송 등록 여부 확인.Knight knight = new Knight() { hp = 100, attack = 10 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(knight.hp);
byte[] buffer2 = BitConverter.GetBytes(knight.attack);
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
Send(sendBuff);
✅ 이 한 줄 한 줄이 모두 연결되어 있어야 합니다:
| 요소 | 설명 |
|---|---|
| TLS 구조 | ThreadLocal를 사용해 각 스레드가 고유한 SendBuffer를 가짐 |
| Open/Close | 현재 스레드에서만 수행됨. 다른 스레드와 충돌 없음 |
| 전송 중 | Socket.SendAsync에 넘긴 이후는 읽기 전용 (참조만) |
| 메모리 보호 | Open/Close는 쓰기, 이후는 읽기만 수행하므로 데이터 경합 없음 |
SendBuffer는 절대로 재사용(clean)하면 안 됨현재 C#에서는 버퍼를 새로 만들고 GC에 맡기지만,
C++에서는 다음과 같은 방식으로 최적화할 수 있습니다:
| 개선점 | 설명 |
|---|---|
| 레퍼런스 카운트 | 누가 참조 중인지 확인 가능. 안전하게 재사용 여부 판단 |
| 객체 풀링 | SendBufferPool에서 미리 확보한 버퍼를 순환 사용 |
| 메모리 재사용 | 필요 이상 할당 방지, 메모리 단편화 감소 |
| 항목 | 설명 |
|---|---|
| SendBuffer 생성 위치 | 반드시 Session 외부. 복사 최소화를 위해 |
| 전송 흐름 | Open → 복사 → Close → Send → RegisterSend |
| TLS 구조 | 스레드마다 버퍼 분리, 경합 없음 |
| 참조 기반 전송 | 복사 없이 ArraySegment로 전송 |
| 재사용 금지 이유 | 참조 중일 가능성 존재 |
| C++ 이식 시 | 객체 풀링, 레퍼런스 관리 도입 고려 |
Open()과 Close()를 통해 작업 공간을 미리 확보하고,🎓 핵심 정리:
SendBuffer = Chunk 기반 예약 구조 + TLS 기반 독립 버퍼 + 참조 기반 전송
→ 이것이 C# 네트워크 서버 구조의 전송 최적화 핵심 해법입니다.