소켓에 둘이 동시에 쓰면 프레임이 섞인다
틱 스레드가 몬스터 위치를 보냅니다. 동시에 패킷 핸들러 스레드가 공격 결과를 보냅니다. 둘 다 같은 플레이어의 소켓에 씁니다.
await Socket.SendAsync(프레임, SocketFlags.None, ct);
이 한 줄이 원자적이면 문제가 없습니다. 그런데 아닙니다.
SendAsync는 요청한 바이트를 다 못 보낼 수 있습니다. 그 순간 다른 프레임이 끼어듭니다.
부분 전송이 무엇을 망가뜨리나
TCP 는 메시지 단위가 없습니다. 바이트가 흐를 뿐입니다. 그래서 어디까지가 한 패킷인지 우리가 정해야 하고, 흔한 방법이 앞에 길이를 붙이는 것입니다.
frame = new byte[4 + length];
Array.Copy(lengthBytes, 0, frame, 0, 4); // 앞 4바이트 = 뒤에 올 길이
Array.Copy(protobufData, 0, frame, 4, length);
받는 쪽은 4바이트를 읽어 길이를 알아내고 딱 그만큼 더 읽습니다.
byte[] lengthBytes = await ReceiveExactAsync(4, ct);
int length = BitConverter.ToInt32(lengthBytes, 0);
byte[] protobufData = await ReceiveExactAsync(length, ct);
이 규칙은 바이트 순서가 어긋나지 않는다는 전제 위에 서 있습니다. 그 전제가 깨지면 이렇게 됩니다.
한 프레임이 깨지고 끝나는 게 아닙니다. 스트림 전체가 어긋납니다. 받는 쪽은 쓰레기를 길이로 읽고, 그다음 읽기 위치가 또 틀리고, 계속 그렇습니다.
제 코드 주석에 이 문장이 그대로 있습니다.
이게 없으면 여러 스레드가 같은 소켓에
SendAsync를 동시에 걸고, 부분 전송이 일어나는 순간 한 프레임 중간에 다른 프레임 바이트가 끼어들어 길이-프리픽스 파싱이 깨진다(치명).
왜 lock 으로 안 하나
lock (Socket) { await Socket.SendAsync(...); } 로 하면 될 것 같습니다. 그런데 안 됩니다.
lock 블록 안에서는 await 를 못 씁니다. 문법적으로 막혀 있습니다.
자물쇠는 스레드가 잡는 것인데 await 뒤는 다른 스레드에서 이어질 수 있어서, 놓을 사람이 사라지기 때문입니다.
SemaphoreSlim 을 쓰면 await 가 되긴 합니다. 그런데 앞에서 본 원칙을 어깁니다.
잠근 채로 남을 부르면 안 됩니다. 네트워크 전송은 가장 오래 걸리는 종류의 일입니다.
그리고 더 큰 문제가 있습니다. 이 서버에서 보내는 쪽 중 하나는 틱 스레드입니다. 틱 스레드가 소켓 하나를 기다리며 서 있으면 그 방의 모든 몬스터가 같이 멈춥니다. 느린 클라이언트 한 명이 방 전체를 세웁니다.
그래서 줄을 세운다
기다리게 하는 대신 넣고 바로 돌아가게 만듭니다. 실제로 쓰는 사람은 따로 한 명 둡니다.
private readonly Channel<byte[]> _sendQueue = Channel.CreateBounded<byte[]>(
new BoundedChannelOptions(SendQueueCapacity)
{
SingleReader = true, // SendLoop 하나만 읽는다 = 프레임 원자성의 근거
SingleWriter = false, // 틱·패킷 스레드가 함께 넣는다
FullMode = BoundedChannelFullMode.Wait,
});
Channel<T> 는 스레드 안전한 큐입니다. 넣는 쪽과 꺼내는 쪽이 서로 다른 스레드여도 됩니다.
SingleReader = true 가 이 글의 핵심입니다. 읽는 사람이 하나라고 선언하면
소켓에 실제로 쓰는 스레드가 하나로 고정됩니다. 그러면 프레임이 섞일 수가 없습니다.
한 스레드만 만지게 하기를 소켓에 적용한 것입니다.
보내는 쪽은 이렇게만 합니다.
// 틱 스레드가 생산자라 절대 블록시키지 않는다 → TryWrite.
if (!_sendQueue.Writer.TryWrite(frame))
{
// 큐가 가득 찼다
}
TryWrite 는 기다리지 않습니다. 넣거나 실패하거나 둘 중 하나고 즉시 돌아옵니다.
틱 스레드가 여기서 멈출 일이 없습니다.
꺼내는 쪽은 하나뿐이고 한 프레임을 끝까지 쓴 다음 다음 것을 씁니다.
private async Task SendLoopAsync(CancellationToken ct)
{
while (await _sendQueue.Reader.WaitToReadAsync(ct))
{
while (_sendQueue.Reader.TryRead(out var frame))
{
int offset = 0;
while (offset < frame.Length)
{
int sent = await Socket.SendAsync(
new ArraySegment<byte>(frame, offset, frame.Length - offset),
SocketFlags.None, ct);
if (sent == 0)
throw new SocketException((int)SocketError.ConnectionReset);
offset += sent;
}
}
}
}
안쪽 while (offset < frame.Length) 가 부분 전송을 처리합니다.
SendAsync 가 5바이트만 보냈으면 남은 것을 이어서 보냅니다.
이 루프를 도는 동안 다른 프레임은 아예 시작되지 않습니다. 읽는 사람이 나뿐이니까요.
순서는 보존된다
큐를 끼우면 순서가 흐트러질 것 같은데 그렇지 않습니다.
한 스레드가 연달아 넣은 것은 넣은 순서대로 나갑니다. 소비자가 하나라 꺼내는 순서가 곧 넣은 순서입니다. 그래서 “입장 시 로스터를 여러 패킷으로 나눠 보내는” 것 같은 기존 코드가 그대로 동작합니다.
다만 서로 다른 스레드가 넣은 것끼리의 순서는 보장되지 않습니다. 그건 원래도 보장이 없었으니 잃은 게 아닙니다.
잃은 것도 있다
정직하게 적어 둘 것이 하나 있습니다. 이 구조로 바꾸면서 메서드의 의미가 바뀌었습니다.
await session.SendPacketAsync(packet);
전에는 이 await 가 끝나면 전선에 나갔다는 뜻이었습니다. 지금은 큐에 들어갔다는 뜻입니다.
그래서 보내자마자 연결을 끊으면 그 패킷은 유실됩니다. 제 코드에서는 그런 호출부가 없어서 넘어갔지만, 있었다면 그 자리를 전부 고쳐야 했습니다.
성능을 얻으면서 보장 하나를 내려놓은 것입니다. 이런 건 주석에 적어 두는 편이 낫습니다. 나중에 그 보장을 믿고 코드를 짜는 사람이 나옵니다.
어디서 또 쓰나
이 모양은 소켓만의 것이 아닙니다. 여럿이 쓰는데 한 명만 써야 하는 자원이면 전부 같습니다.
- 로그 파일 하나에 여러 스레드가 쓸 때
- 콘솔 출력
- 시리얼 포트나 하드웨어 핸들
- 유니티에서 백그라운드 작업의 결과를 메인 스레드로 넘길 때
마지막이 특히 그렇습니다. 유니티 API 는 메인 스레드에서만 부를 수 있으니
다른 스레드에서 만든 결과를 큐에 넣고 Update() 에서 꺼내 쓰는 방식이 자주 쓰입니다.
같은 발상입니다.
정리
SendAsync는 요청한 만큼 다 못 보낼 수 있습니다. 그 틈에 다른 프레임이 끼어듭니다- 길이 프리픽스는 바이트 순서를 전제로 합니다. 한 바이트만 어긋나면 그 연결은 끝입니다
lock안에서는await를 못 씁니다. 세마포어를 써도 잠근 채로 네트워크를 기다리게 됩니다- 틱 스레드를 기다리게 하면 방 전체가 멈춥니다
Channel로 줄을 세우고 소비자를 하나로 고정하면 프레임이 섞일 수 없습니다- 넣는 쪽은
TryWrite로 기다리지 않습니다 - 대신 “보냈다” 의 의미가 “큐에 넣었다” 로 바뀝니다. 그건 적어 둬야 합니다