---
title: "소켓에 둘이 동시에 쓰면 프레임이 섞인다"
description: "자물쇠로 못 막는 자리가 있습니다. 기다리게 하는 대신 줄을 세워 한 명만 쓰게 만듭니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-producer-consumer/
---
틱 스레드가 몬스터 위치를 보냅니다. 동시에 패킷 핸들러 스레드가 공격 결과를 보냅니다.
둘 다 같은 플레이어의 소켓에 씁니다.

```csharp
await Socket.SendAsync(프레임, SocketFlags.None, ct);
```

이 한 줄이 원자적이면 문제가 없습니다. 그런데 아닙니다.

> **`SendAsync` 는 요청한 바이트를 다 못 보낼 수 있습니다. 그 순간 다른 프레임이 끼어듭니다.**

## 부분 전송이 무엇을 망가뜨리나

TCP 는 메시지 단위가 없습니다. 바이트가 흐를 뿐입니다.
그래서 어디까지가 한 패킷인지 우리가 정해야 하고, 흔한 방법이 **앞에 길이를 붙이는 것**입니다.

```csharp
frame = new byte[4 + length];
Array.Copy(lengthBytes, 0, frame, 0, 4);   // 앞 4바이트 = 뒤에 올 길이
Array.Copy(protobufData, 0, frame, 4, length);
```

받는 쪽은 4바이트를 읽어 길이를 알아내고 딱 그만큼 더 읽습니다.

```csharp
byte[] lengthBytes = await ReceiveExactAsync(4, ct);
int length = BitConverter.ToInt32(lengthBytes, 0);
byte[] protobufData = await ReceiveExactAsync(length, ct);
```

이 규칙은 **바이트 순서가 어긋나지 않는다는 전제** 위에 서 있습니다. 그 전제가 깨지면 이렇게 됩니다.

<figure class="diagram"><svg viewBox="0 0 640 210" role="img" aria-label="두 프레임이 섞여 길이 프리픽스 파싱이 깨지는 모습"><text x="20" y="22" font-size="12.5" opacity="0.75">보내려던 것</text><rect x="20" y="32" width="60" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.45" /><text x="34" y="50" class="mono" font-size="11">[len 12]</text><rect x="80" y="32" width="150" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="120" y="50" class="mono" font-size="11">A 의 12바이트</text><rect x="250" y="32" width="60" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.45" /><text x="264" y="50" class="mono" font-size="11">[len 20]</text><rect x="310" y="32" width="180" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="360" y="50" class="mono" font-size="11">B 의 20바이트</text><text x="20" y="92" font-size="12.5" opacity="0.75">A 가 5바이트만 나가고 B 가 끼어들면</text><rect x="20" y="102" width="60" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.45" /><text x="34" y="120" class="mono" font-size="11">[len 12]</text><rect x="80" y="102" width="45" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="88" y="120" class="mono" font-size="11">A 1바이트</text><rect x="125" y="102" width="60" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.45" /><text x="139" y="120" class="mono" font-size="11">[len 20]</text><rect x="185" y="102" width="180" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="235" y="120" class="mono" font-size="11">B 의 20바이트</text><rect x="365" y="102" width="125" height="26" rx="3" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="382" y="120" class="mono" font-size="11">A 나머지 11바이트</text><text x="20" y="160" font-size="12.5">받는 쪽은 12 를 읽고 그 뒤 12바이트를 한 패킷으로 봅니다.</text><text x="20" y="180" font-size="12.5">그 안에 B 의 길이 헤더가 들어 있습니다. 여기서부터 모든 경계가 어긋납니다.</text></svg><figcaption>한 바이트만 어긋나도 그 연결은 이후 전부 쓰레기가 됩니다.</figcaption></figure>

**한 프레임이 깨지고 끝나는 게 아닙니다.** 스트림 전체가 어긋납니다.
받는 쪽은 쓰레기를 길이로 읽고, 그다음 읽기 위치가 또 틀리고, 계속 그렇습니다.

제 코드 주석에 이 문장이 그대로 있습니다.

> 이게 없으면 여러 스레드가 같은 소켓에 `SendAsync` 를 동시에 걸고, 부분 전송이 일어나는 순간
> **한 프레임 중간에 다른 프레임 바이트가 끼어들어** 길이-프리픽스 파싱이 깨진다(치명).

## 왜 lock 으로 안 하나

`lock (Socket) { await Socket.SendAsync(...); }` 로 하면 될 것 같습니다. 그런데 안 됩니다.

**`lock` 블록 안에서는 `await` 를 못 씁니다.** 문법적으로 막혀 있습니다.
자물쇠는 스레드가 잡는 것인데 `await` 뒤는 다른 스레드에서 이어질 수 있어서, 놓을 사람이 사라지기 때문입니다.

`SemaphoreSlim` 을 쓰면 `await` 가 되긴 합니다. 그런데 앞에서 본 원칙을 어깁니다.
**잠근 채로 남을 부르면 안 됩니다.** 네트워크 전송은 가장 오래 걸리는 종류의 일입니다.

그리고 더 큰 문제가 있습니다. 이 서버에서 보내는 쪽 중 하나는 **틱 스레드**입니다.
틱 스레드가 소켓 하나를 기다리며 서 있으면 **그 방의 모든 몬스터가 같이 멈춥니다.**
느린 클라이언트 한 명이 방 전체를 세웁니다.

## 그래서 줄을 세운다

기다리게 하는 대신 **넣고 바로 돌아가게** 만듭니다. 실제로 쓰는 사람은 따로 한 명 둡니다.

```csharp
private readonly Channel<byte[]> _sendQueue = Channel.CreateBounded<byte[]>(
    new BoundedChannelOptions(SendQueueCapacity)
    {
        SingleReader = true,   // SendLoop 하나만 읽는다 = 프레임 원자성의 근거
        SingleWriter = false,  // 틱·패킷 스레드가 함께 넣는다
        FullMode = BoundedChannelFullMode.Wait,
    });
```

`Channel<T>` 는 스레드 안전한 큐입니다. 넣는 쪽과 꺼내는 쪽이 서로 다른 스레드여도 됩니다.

**`SingleReader = true` 가 이 글의 핵심입니다.** 읽는 사람이 하나라고 선언하면
소켓에 실제로 쓰는 스레드가 하나로 고정됩니다. 그러면 프레임이 섞일 수가 없습니다.
**한 스레드만 만지게 하기**를 소켓에 적용한 것입니다.

보내는 쪽은 이렇게만 합니다.

```csharp
// 틱 스레드가 생산자라 절대 블록시키지 않는다 → TryWrite.
if (!_sendQueue.Writer.TryWrite(frame))
{
    // 큐가 가득 찼다
}
```

`TryWrite` 는 기다리지 않습니다. 넣거나 실패하거나 둘 중 하나고 즉시 돌아옵니다.
틱 스레드가 여기서 멈출 일이 없습니다.

꺼내는 쪽은 하나뿐이고 **한 프레임을 끝까지 쓴 다음** 다음 것을 씁니다.

```csharp
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바이트만 보냈으면 남은 것을 이어서 보냅니다.
**이 루프를 도는 동안 다른 프레임은 아예 시작되지 않습니다.** 읽는 사람이 나뿐이니까요.

## 순서는 보존된다

큐를 끼우면 순서가 흐트러질 것 같은데 그렇지 않습니다.

한 스레드가 연달아 넣은 것은 넣은 순서대로 나갑니다. 소비자가 하나라 꺼내는 순서가 곧 넣은 순서입니다.
그래서 "입장 시 로스터를 여러 패킷으로 나눠 보내는" 것 같은 기존 코드가 그대로 동작합니다.

다만 **서로 다른 스레드가 넣은 것끼리의 순서는 보장되지 않습니다.**
그건 원래도 보장이 없었으니 잃은 게 아닙니다.

## 잃은 것도 있다

정직하게 적어 둘 것이 하나 있습니다. 이 구조로 바꾸면서 **메서드의 의미가 바뀌었습니다.**

```csharp
await session.SendPacketAsync(packet);
```

전에는 이 `await` 가 끝나면 **전선에 나갔다**는 뜻이었습니다. 지금은 **큐에 들어갔다**는 뜻입니다.

그래서 보내자마자 연결을 끊으면 그 패킷은 유실됩니다.
제 코드에서는 그런 호출부가 없어서 넘어갔지만, 있었다면 그 자리를 전부 고쳐야 했습니다.

**성능을 얻으면서 보장 하나를 내려놓은 것**입니다. 이런 건 주석에 적어 두는 편이 낫습니다.
나중에 그 보장을 믿고 코드를 짜는 사람이 나옵니다.

## 어디서 또 쓰나

이 모양은 소켓만의 것이 아닙니다. **여럿이 쓰는데 한 명만 써야 하는 자원**이면 전부 같습니다.

- 로그 파일 하나에 여러 스레드가 쓸 때
- 콘솔 출력
- 시리얼 포트나 하드웨어 핸들
- 유니티에서 백그라운드 작업의 결과를 메인 스레드로 넘길 때

마지막이 특히 그렇습니다. 유니티 API 는 메인 스레드에서만 부를 수 있으니
다른 스레드에서 만든 결과를 큐에 넣고 `Update()` 에서 꺼내 쓰는 방식이 자주 쓰입니다.
**같은 발상입니다.**

## 정리

- `SendAsync` 는 요청한 만큼 다 못 보낼 수 있습니다. 그 틈에 다른 프레임이 끼어듭니다
- 길이 프리픽스는 바이트 순서를 전제로 합니다. **한 바이트만 어긋나면 그 연결은 끝입니다**
- `lock` 안에서는 `await` 를 못 씁니다. 세마포어를 써도 **잠근 채로 네트워크를 기다리게** 됩니다
- 틱 스레드를 기다리게 하면 방 전체가 멈춥니다
- `Channel` 로 줄을 세우고 **소비자를 하나로 고정**하면 프레임이 섞일 수 없습니다
- 넣는 쪽은 `TryWrite` 로 기다리지 않습니다
- 대신 "보냈다" 의 의미가 "큐에 넣었다" 로 바뀝니다. 그건 적어 둬야 합니다