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
#region PacketSession
class Packet
{
// int 보다 4바이트 아낌
// 만약 10000명이면 40000바이트를 아낌
public ushort size;
public ushort packetId;
}
#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만
Packet packet = new Packet() { size = 4, packetId = 7 };
// 서버에 5번 메시지를 전송
Send(sendBuff);
Thread.Sleep(1000);
Disconnect();
#region PacketSession
for (int i = 0; i < 5; i++)
{
//byte[] sendBuff = Encoding.UTF8.GetBytes($"Hello World! {i}");
//Send(sendBuff);
//ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
//byte[] buffer = BitConverter.GetBytes(packet.size);
//byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
//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(packet.size);
//Send(sendBuff);
}
#endregion
}
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);
}
}
}
}
좋습니다. 지금부터 사용자가 요청하신 조건과 형식에 따라,
총 3개의 분석글을 완전히 통합한 PacketSession 강의 블로그 통합 강의 교재를 제공하겠습니다.
이 자료는 강의 내용을 완벽하게 재해석하고, 실제 강의에서 사용하는 스타일로 구성되며,
내용이 방대하므로 총 2부로 나누어 작성하겠습니다.
이번에는 1부를 작성하며, 이어서 2부도 자연스럽게 연결될 수 있도록 구성하겠습니다.
예를 들어, 우리가 보낸 패킷이 다음과 같이 조각나올 수 있습니다:
Client가 보낸 실제 패킷: [size][packetId][payload]
Server 수신 분할 예:
1) [size]만 먼저 옴
2) [size][packetId]까지 옴
3) 여러 개가 붙어서 [size][packetId][payload][size][packetId][payload] 형태로 옴
➡️ 문제는 도착한 데이터의 경계를 알 수 없다는 것입니다.
ushort size를 먼저 두어 패킷의 전체 길이를 나타냅니다. ushort packetId를 두어 패킷의 종류를 구분합니다.즉, 하나의 패킷은 항상 다음과 같은 고정 포맷을 갖습니다:
// 패킷 포맷 (총 최소 4바이트)
ushort size; // 전체 패킷 길이 (자기 자신 포함)
ushort packetId; // 이 패킷이 무엇을 의미하는지
...payload... // 이후 데이터
이 구조는 모든 패킷 조립과 해석의 기준이 됩니다.
Session 클래스에서 수신 구조를 확장한 PacketSession을 설계합니다.1) 수신 버퍼에 최소한 [size] 만큼의 데이터가 들어왔는지 확인
2) size만큼 데이터가 모두 도착했으면 → 패킷 완성 → OnRecvPacket() 호출
3) 남은 데이터가 있다면 루프를 돌아 계속 처리
➡️ 즉, PacketSession은 헤더 기반으로 패킷을 안전하게 분리하고,
완성된 데이터만 컨텐츠 계층으로 넘겨주는 핵심 클래스입니다.
| 용어 | 설명 |
|---|---|
TCP 스트림 | 연속된 바이트 흐름. 패킷 단위가 존재하지 않음 |
Packet | size + packetId + payload 구조의 전송 단위 |
PacketSession | Session을 상속받아, 수신된 데이터를 패킷 단위로 분리하는 추상 클래스 |
HeaderSize | 패킷의 최소 헤더 크기. 현재는 ushort size = 2바이트 |
OnRecv() | Session 클래스의 수신 콜백 함수. PacketSession에서는 sealed 처리 |
OnRecvPacket() | 완성된 하나의 패킷이 도착했을 때 호출되는 추상 함수 |
ArraySegment<byte> | byte 배열의 특정 범위를 참조하기 위한 C# 구조체 |
BitConverter | byte 배열 ↔ ushort, int 등의 변환을 담당하는 유틸리티 |
public abstract class PacketSession : Session
{
public static readonly int HeaderSize = 2; // 현재는 size(ushort)만 기준
Session 클래스를 기반으로 확장한 추상 클래스입니다.HeaderSize = 2로 ushort size만을 기준으로 패킷 구분을 수행합니다.public sealed override int OnRecv(ArraySegment<byte> buffer)
{
int processLength = 0;
while (true)
{
if (buffer.Count < HeaderSize)
break;
ushort dataSize = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
if (buffer.Count < dataSize)
break;
OnRecvPacket(new ArraySegment<byte>(buffer.Array, buffer.Offset, dataSize));
processLength += dataSize;
buffer = new ArraySegment<byte>(
buffer.Array,
buffer.Offset + dataSize,
buffer.Count - dataSize
);
}
return processLength;
}
sealed override: 이 함수는 PacketSession 내부 로직으로 고정 → 하위 클래스에서 오버라이드 불가while (true) 루프: 한 번에 여러 개 패킷이 들어오는 경우까지 처리 가능buffer.Count < HeaderSize: 최소 2바이트 이상 들어오지 않으면 패킷 크기조차 읽을 수 없음 → 대기BitConverter.ToUInt16(): 앞 2바이트에서 전체 패킷 길이를 추출buffer.Count < dataSize: 전체 패킷이 아직 다 안 들어왔으면 → 대기OnRecvPacket(...): 완성된 패킷만 넘겨줌processLength 누적 + buffer를 남은 영역으로 조정 → 다음 패킷 반복 처리 가능public abstract void OnRecvPacket(ArraySegment<byte> buffer);
| 항목 | 설명 |
|---|---|
| TCP의 문제 | 패킷 단위가 없기 때문에 조각 수신이 발생함 |
| 해결 방법 | size 기반 헤더 구조로 패킷을 조립 |
| PacketSession 역할 | 수신된 데이터를 완성된 패킷 단위로 분리해서 넘겨줌 |
| OnRecv() | 봉인(sealed)되어 내부에서만 패킷 조립 |
| OnRecvPacket() | 완성된 패킷만 콘텐츠 계층으로 전달하는 확장 포인트 |
| 구조적 이점 | 연속 수신, 조각 수신, 누락 등 모든 상황에 안전하게 대응 가능 |
size 기반의 구조를 도입하고,OnRecvPacket()에서 완성된 데이터를 받아 쓰기만 하면 되는 구조를 만들 수 있습니다.좋습니다. 앞서 1부에서는 PacketSession 구조의 설계 배경과 TCP 수신 버퍼에서 어떻게 패킷을 조립하고 분리하는지를 상세히 학습했습니다. 이제 이어지는 2부에서는 실제 서버와 클라이언트가 PacketSession을 활용하여 패킷을 전송하고 수신 처리하는 흐름, 그리고 그 과정에서 사용되는 SendBufferHelper, GameSession, Packet 구조까지 포함하여 모든 전송 프로토콜 흐름을 실습 예제로 이해해보겠습니다.
OnRecvPacket()을 통해 해석하는 일련의 흐름을 실습을 통해 분석합니다.SendBufferHelper를 활용한 전송 데이터 조립 방식과, Packet 및 LoginOkPacket 클래스의 확장 구조까지 다룹니다.size와 packetId를 먼저 보내는가?TCP는 메시지 단위가 아닌 바이트 스트림이기 때문에, “어디서부터 어디까지가 하나의 패킷인가”를 알려주는 기준이 반드시 필요합니다.
이를 위해 우리는 모든 패킷 앞에 [size][packetId]를 붙여 전송합니다.
size: 패킷 전체 길이(헤더 + 바디 포함)packetId: 이 패킷이 어떤 의미인지 식별할 ID이 구조 덕분에 우리는 여러 개 패킷이 연속으로 오거나, 조각나서 올 경우에도 안정적으로 패킷을 분리하고 처리할 수 있습니다.
| 용어 | 설명 |
|---|---|
Packet | 모든 패킷의 공통 헤더 구조 (size + packetId) |
LoginOkPacket | Packet을 상속받아 실제 게임 컨텐츠용 패킷을 구현한 예시 |
SendBufferHelper | ThreadLocal 기반의 TLS 버퍼를 관리하며, 송신 데이터를 효율적으로 구성 |
BitConverter.GetBytes() | 숫자형 데이터를 byte 배열로 변환 |
Array.Copy() | 한 byte 배열에서 다른 배열로 원하는 만큼 복사 |
OnRecvPacket() | 완성된 하나의 패킷이 도착했을 때 호출되는 함수 |
public class Packet
{
public ushort size;
public ushort packetId;
}
ushort 사용은 공간을 절약하고 네트워크 효율을 높이기 위한 선택입니다.public class LoginOkPacket : Packet
{
// 향후 캐릭터 목록, 스킬 목록 등 게임 데이터를 여기에 추가
}
public override void OnConnected(EndPoint endPoint)
{
Packet packet = new Packet() { size = 4, packetId = 7 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(packet.size);
byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuffer = SendBufferHelper.Close(packet.size);
Send(sendBuffer);
}
Packet 생성 후 SendBufferHelper.Open()으로 버퍼를 확보합니다.BitConverter.GetBytes()를 통해 ushort 데이터를 byte[]로 변환하고,Array.Copy()로 버퍼에 삽입한 뒤,SendBufferHelper.Close()로 실제 사용한 범위를 확정지어 Send()를 호출합니다.public override void OnRecvPacket(ArraySegment<byte> buffer)
{
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + 2);
Console.WriteLine($"Receive Packet size : {size}, ID : {id}");
}
PacketSession.OnRecv()이 내부에서 완성된 패킷만 이 메서드로 넘겨줍니다.buffer에서 size와 packetId를 추출하여 처리할 수 있습니다.for (int i = 0; i < 5; i++)
{
Packet packet = new Packet() { size = 4, packetId = 7 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(packet.size);
byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuffer = SendBufferHelper.Close(packet.size);
Send(sendBuffer);
}
PacketSession이 작동하여 각각을 정확하게 분리해 전달합니다.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;
}
PacketSession을 상속받아 구조화된 패킷을 처리해야 합니다.| 항목 | 설명 |
|---|---|
| Packet 구조 | [ushort size][ushort packetId]의 고정 헤더를 모든 패킷에 삽입 |
| 송신 흐름 | Open → BitConverter + Copy → Close → Send |
| 수신 흐름 | PacketSession.OnRecv()이 패킷 조립 → OnRecvPacket() 호출 |
| 연속 처리 | 여러 패킷이 붙어와도 하나씩 정확하게 분리해 처리 가능 |
| 확장 기반 | Packet을 상속한 다양한 패킷 정의로 게임 컨텐츠 전송 가능 |
PacketSession은 TCP의 스트림 기반 수신 문제를 해결하는 궁극적인 수단입니다.[size][packetId][payload] 형식을 사용하면, 수신이 조각나든, 연달아 붙든 상관없이 안정적으로 분리하고 처리할 수 있습니다.🎓 정리하면,
PacketSession은 게임 서버 개발에 있어 패킷 단위 통신을 가능하게 해주는 구조적 핵심 클래스입니다.
이 구조 없이 TCP로 안정적인 데이터 처리를 기대할 수 없고,
이 구조를 제대로 익혀야만 수만 명의 유저와도 충돌 없이 통신할 수 있는 서버를 설계할 수 있습니다.