TCP에서 Send한 단위대로 Receive할 수 있다는 오해 ── 바이트 스트림으로 다루기 위한 수신 설계

· · TCP, Socket, Network, .NET, CSharp, ProtocolDesign, 운영, 기존 자산 활용

1. 먼저 짚어 둘 것

TCP 통신을 구현할 때 꽤 흔한 오해가 있습니다.

그것은,

송신 측이 Send / Write 한 단위대로, 수신 측에서도 Receive / Read 할 수 있다

라는 것입니다.

예를 들어, 송신 측이 다음과 같이 보냈다고 합시다.

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

이때, 수신 측에서 다음과 같이 3번 받을 수 있다고 생각해 버립니다.

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

하지만 TCP에서는 그렇다고 할 수 없습니다.

실제로는 다음 중 어느 것도 일어날 수 있습니다.

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

어느 쪽이든 TCP로서는 정상입니다.

TCP가 보장하는 것은, 대략적으로 말하면 「보낸 바이트열이 순서를 유지한 채, 중복 없이, 빠짐없이 도착하는 것」입니다. 보장하지 않는 것은 「애플리케이션이 Send 한 단위가, 수신 측의 Receive 단위로 보존되는 것」입니다.

그렇기 때문에 TCP를 사용하는 애플리케이션에서는, 수신 측에서 「지금 받은 바이트열이 어디부터 어디까지가 1개 메시지인지」를 판단하는 구조가 필요합니다.

이것을 애플리케이션 프로토콜의 프레이밍(framing)이라고 부릅니다.

이 글에서는 TCP의 SendReceive 관계에서 일어나기 쉬운 오해와, .NET / C#에서의 올바른 처리 방법을 정리합니다.

또한, 이 글에 나오는 코드는 빌드·실행할 수 있는 예제 일체(라이브러리, 루프백 TCP 데모, 분할·결합·중도 절단을 재현하는 유닛 테스트)로 GitHub에 공개되어 있습니다.

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

2. TCP는 「메시지」가 아니라 「바이트열」을 전달한다

먼저, TCP를 메시지 큐처럼 생각하지 않는 것이 중요합니다.

TCP는 애플리케이션으로부터 전달받은 데이터를 연속된 바이트열로 다룹니다.

예를 들어 송신 측이 다음과 같이 3번 Send 했다고 해도,

Send("ABC")
Send("DEF")
Send("GHI")

TCP 입장에서 보면, 이것은 최종적으로 다음과 같은 9바이트의 흐름입니다.

ABCDEFGHI

이 안에,

ABC | DEF | GHI

라는 애플리케이션 쪽의 경계가 남아 있는 것은 아닙니다.

수신 측은 어떤 시점에 「지금 수신 버퍼에 있는 만큼」을 읽습니다. 따라서 수신 결과는 다음과 같이 될 수 있습니다.

송신 측의 호출 수신 측에서 보이는 예
Send("ABC"), Send("DEF") Receive() 1회로 "ABCDEF"
Send("ABCDEF") Receive() 2회로 "AB", "CDEF"
Send("ABC"), Send("DEF"), Send("GHI") Receive() 3회로 "A", "BCDEFG", "HI"
Send("\u3042")와 같은 UTF-8 문자 멀티바이트 문자 중간에서 분할되는 경우도 있음

중요한 것은, 여기에 「이상」은 없다는 점입니다.

「가끔 수신 데이터가 빠진다」, 「여러 메시지가 붙어버린다」, 「문자가 깨진다」처럼 보이는 문제의 대부분은 TCP의 이상이 아니라, 수신 측이 TCP를 메시지 단위로 취급해 버리는 설계 실수입니다.

3. 왜 Send 단위로 수신할 수 있는 것처럼 보이는가

이 오해가 사라지지 않는 이유는, 로컬 환경이나 작은 데이터에서는 우연히 기대한 대로 보이는 경우가 많기 때문입니다.

개발 환경에서는 다음 조건이 갖춰지기 쉽습니다.

  • 클라이언트와 서버가 같은 머신, 또는 가까운 네트워크상에 있다
  • 데이터 양이 적다
  • 통신 상대가 바로 읽어 준다
  • CPU나 네트워크에 여유가 있다
  • 테스트가 수동이라 타이밍의 흔들림이 적다
  • Send 직후에 Receive 하고 있다

이런 조건에서는 한 번의 Send에 대해 한 번의 Receive로 읽히는 것처럼 보이는 경우가 있습니다.

그러나 프로덕션 환경에서는 조건이 달라집니다.

  • OS의 송수신 버퍼에 쌓인다
  • 여러 번의 작은 송신이 하나로 뭉쳐진다
  • 큰 송신이 TCP 세그먼트나 수신 버퍼의 사정으로 분할된다
  • 수신 측 스레드의 스케줄링이 지연된다
  • TLS, 프록시, 로드밸런서, VPN 등의 계층이 개입된다
  • 네트워크 지연이나 혼잡이 발생한다
  • Nagle 알고리즘이나 지연 ACK의 영향을 받는다

그 결과, 「개발 환경에서는 동작했는데, 프로덕션에서 가끔 깨진다」는 골치 아픈 문제가 됩니다.

네트워크 처리에서는 이 「우연히 동작한다」가 가장 위험합니다.

4. 흔히 있는 취약한 수신 코드

예를 들어, 다음과 같은 코드는 위험합니다.

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // 상대가 정상적으로 연결을 끊었다
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

이 코드는 「한 번의 ReadAsync로 1개 메시지를 얻을 수 있다」는 것을 전제로 하고 있지만, TCP에서는 그 전제가 성립하지 않습니다.

문제는 크게 3가지가 있습니다.

첫째는, 1개 메시지가 분할되는 것입니다.

송신: {"command":"login","user":"komura"}\n
수신1: {"command":"login",
수신2: "user":"komura"}\n

이 경우, 수신1만 JSON으로 파싱하려고 하면 실패합니다.

둘째는, 여러 메시지가 결합되는 것입니다.

송신1: {"command":"login"}\n
송신2: {"command":"get"}\n
수신: {"command":"login"}\n{"command":"get"}\n

이 경우, 1개의 JSON으로 파싱하려고 하면 실패합니다.

셋째는, 문자 코드의 경계에서 분할되는 것입니다.

UTF-8에서는 1개 문자가 여러 바이트가 되는 경우가 있습니다. ReadAsync의 경계가 문자의 경계와 일치한다는 보장은 없습니다.

그렇기 때문에 받은 바이트열을 매번 바로 Encoding.UTF8.GetString으로 문자열화하면, 멀티바이트 문자 중간에서 분할된 경우 깨질 가능성이 있습니다.

「받으면 바로 문자열화」가 아니라, 「메시지의 경계를 알 수 있을 때까지 바이트로 축적하고, 1개 메시지분이 모인 뒤에 디코딩한다」는 것이 기본입니다.

5. DataAvailable로 메시지 종료를 판단해서는 안 된다

다음과 같은 코드도 자주 볼 수 있습니다.

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

이것도 위험합니다. DataAvailable이 나타내는 것은 「그 순간에, 읽을 수 있는 데이터가 로컬 수신 버퍼에 있는가」이며, 애플리케이션상의 1개 메시지가 완료되었다는 것을 의미하지 않습니다.

예를 들어, 1개 메시지가 100바이트였다고 할 때, 처음 40바이트만 도착한 순간에 DataAvailabletrue가 되고, 그 40바이트를 읽은 직후 일시적으로 false가 되는 경우가 있습니다. 나머지 60바이트는 조금 뒤에 도착할 수도 있습니다.

이때, DataAvailable == false를 「메시지 종료」로 해석하면, 중간까지의 데이터를 1개 메시지로 처리해 버립니다.

DataAvailable은 읽기 루프의 최적화나 논블로킹적인 확인에 사용하는 경우는 있어도, 프로토콜의 경계 판정에는 사용하지 않는 것이 안전합니다.

6. 올바른 사고방식은 「수신」과 「해석」을 나누는 것

TCP의 수신 처리에서는 다음 두 가지를 나누어 생각하면 설계하기 쉬워집니다.

수신: TCP로부터 도착한 바이트열을 읽어, 버퍼에 쌓는다
해석: 버퍼에서, 애플리케이션상의 1개 메시지를 잘라낸다

Receive / Read는 어디까지나 「바이트를 읽는」 처리입니다. 이에 반해 「1개 메시지가 어디서 끝나는가」는 애플리케이션 프로토콜에서 정할 필요가 있습니다.

대표적인 방법은 다음 4가지입니다.

방식 내용 적합한 용도
고정 길이 항상 정해진 바이트 수를 1개 메시지로 삼는다 레거시 기기, 바이너리 전문, 제어계
구분자 \n 등, 특정 바이트열까지를 1개 메시지로 삼는다 커맨드, 로그, NDJSON, 간이 프로토콜
길이 프리픽스 앞부분에 본문 길이를 두고, 그 바이트 수만큼 본문을 읽는다 바이너리, JSON, MessagePack, Protocol Buffers 등
자기 기술 형식 HTTP의 Content-Length나 chunked처럼 형식 안에서 길이나 종료를 나타낸다 기존 프로토콜, 확장성이 필요한 통신

개인적으로는, 독자적인 프로토콜을 만든다면 먼저 길이 프리픽스 방식을 검토합니다. 본문에 개행이나 임의의 바이너리를 포함할 수 있는 점, 수신 측의 구현이 명확한 점, 최대 크기 제한을 넣기 쉬운 점이 이유입니다.

7. 길이 프리픽스 방식의 기본

길이 프리픽스 방식에서는 메시지를 다음과 같은 형식으로 만듭니다.

[4바이트 본문 길이][본문]

예를 들어, 본문이 UTF-8 JSON이고 본문 길이가 31바이트라면, 다음과 같이 보냅니다.

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
  본문 길이               본문

수신 측은 다음 순서로 처리합니다.

  1. 먼저 4바이트를 끝까지 읽는다
  2. 그 4바이트에서 본문 길이를 꺼낸다
  3. 본문 길이가 부적절하지 않은지 검증한다
  4. 본문 길이만큼 끝까지 읽는다
  5. 다 읽은 본문을 1개 메시지로 처리한다
  6. 다음 프레임을 읽는다

여기서 중요한 것은, 「4바이트 헤더도 분할될 수 있다」는 점입니다.

수신1: 00 00
수신2: 00 1F 7B 22 63 ...

따라서 헤더라고 해서 한 번의 Read로 4바이트를 얻을 수 있다고는 할 수 없습니다.

본문도 마찬가지입니다. Read의 반환값이 요청한 크기보다 작은 경우는 흔히 있습니다. 필요한 바이트 수가 정해져 있다면, 필요한 만큼 끝까지 읽는 루프를 작성할 필요가 있습니다.

8. .NET에서의 수신 구현 예: 길이 프리픽스 방식

다음은 .NET / C#에서 길이 프리픽스 방식의 프레임을 읽는 예입니다.

여기서는 앞부분 4바이트를 big-endian int로 하여 본문 길이로 사용하고 있습니다.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB. 용도에 맞게 결정한다

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // 프레임 중간이 아니라, 다음 프레임이 시작되기 전에 상대가 정상적으로 종료했다
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

사용하는 쪽은 다음과 같이 됩니다.

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // 상대가 프레임 경계에서 깔끔하게 연결을 끊었다
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

이 구현에서는 ReadAsync가 몇 바이트씩 반환하더라도 문제없습니다. 1바이트씩 반환되더라도, 헤더와 본문을 다 읽을 때까지 루프를 돌립니다.

반대로, 여러 메시지분의 데이터가 OS의 수신 버퍼에 쌓여 있는 경우에도, 맨 앞의 프레임만 본문 길이에 따라 잘라내고, 다음 루프에서 다음 프레임을 읽습니다.

또한, 현재의 .NET에서는 Stream.ReadExactly / ReadExactlyAsync를 사용할 수 있는 환경도 있습니다. 이 경우, 필요한 바이트 수를 다 읽는 처리를 표준 API에 맡길 수 있습니다. 다만, 연결 종료 시의 처리, 프레임 시작 전의 정상 종료, 중간에 끊긴 비정상 종료를 어떻게 구별할지는 애플리케이션 측에서 설계해 둘 필요가 있습니다.

9. 송신 측 구현 예

송신 측도 같은 프레임 형식에 따라 보냅니다.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

이 코드에서는 헤더와 본문을 별도로 WriteAsync하고 있습니다. 여기서도 오해가 생기기 쉬운데, 송신 측에서 헤더와 본문을 2번으로 나누어 WriteAsync해도, 수신 측에서 2번으로 나뉘어 읽힌다고는 할 수 없습니다.

수신 측에서는 다음과 같이 보일 수도 있습니다.

Read() => [헤더 4바이트 + 본문 일부]
Read() => [본문의 나머지]

혹은, 이럴 수도 있습니다.

Read() => [헤더의 앞쪽 2바이트]
Read() => [헤더의 뒤쪽 2바이트 + 본문 전체 + 다음 프레임의 헤더]

그렇기 때문에 수신 측은 「몇 번 Read했는가」가 아니라, 「프레임 형식에 따라 몇 바이트를 읽었는가」로 판단합니다.

10. Socket.Send를 직접 사용할 경우 송신 측도 반환값을 확인한다

NetworkStream.Write / WriteAsync를 사용하는 경우에는, 기본적으로 지정한 범위를 기록하는 API로 취급할 수 있습니다.

한편, Socket.Send를 직접 사용하는 경우에는 반환값에 주의가 필요합니다.

Socket.Send는 「송신할 수 있었던 바이트 수」를 반환합니다. 특히 논블로킹 소켓 등에서는 요청한 바이트 수보다 적은 바이트 수로 성공하는 경우가 있습니다.

그렇기 때문에 Socket.Send를 직접 사용한다면, 송신 측에도 「전부 보낼 때까지 반복하는」 처리가 필요합니다.

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

다만, 여기서 말하는 「보낼 수 있었다」는 「상대 애플리케이션이 그 메시지를 처리했다」는 의미가 아닙니다. 송신 API의 성공과 애플리케이션 프로토콜상의 성공 응답은 별개입니다.

예를 들어 업무적으로 「주문을 접수했다」, 「파일을 저장했다」, 「명령을 실행했다」를 확인하고 싶다면, TCP의 송신 성공이 아니라 상대 애플리케이션으로부터의 ACK나 응답 메시지를 프로토콜로 정의할 필요가 있습니다.

11. 구분자 방식을 사용할 때의 주의점

텍스트 프로토콜에서는 개행 구분을 사용하는 경우가 있습니다.

LOGIN komura secret\n
GET item-001\n
QUIT\n

이 방식은 이해하기 쉽고, 로그나 커맨드 형식과 잘 맞습니다.

다만, 다음 사항에 주의가 필요합니다.

  • 구분자가 본문 중에 나타나는 경우의 이스케이프 규칙을 정한다
  • \r\n\n의 처리를 정한다
  • 1행의 최대 길이를 정한다
  • 구분자가 올 때까지 무제한으로 메모리를 쌓지 않는다
  • UTF-8의 멀티바이트 문자가 분할되어도 깨지지 않도록 한다

특히, 다음과 같은 코드는 피해야 합니다.

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

이 코드는 수신한 범위의 마지막이 줄 중간일 수도 있다는 점도, UTF-8 문자 중간에서 분할될 가능성도 고려하지 않고 있습니다.

개행 구분을 사용한다면, 적어도 「바이트를 축적해서 개행 바이트를 찾고, 1줄분이 모인 뒤에 디코딩한다」거나, StreamReader.ReadLineAsync처럼 스트림상에서 줄을 읽는 API를 사용합니다.

다만, StreamReader.ReadLineAsync를 사용하는 경우에도 최대 줄 길이, 타임아웃, 취소, 연결 종료 시의 처리는 미리 설계해 두어야 합니다.

12. 고정 길이 방식을 사용할 때의 주의점

고정 길이 전문에서는, 「반드시 128바이트로 1개 메시지」와 같이 정합니다. 오래된 업무 시스템, 제어계, 기기 연동에서 볼 수 있는 방식입니다.

고정 길이라도 사고방식은 같습니다.

1개 메시지 = 128바이트

그렇다면, 수신 측은 128바이트를 다 읽을 때까지 루프를 돌립니다.

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

여기서도, 한 번의 ReadAsync로 128바이트가 반환된다고는 할 수 없습니다.

고정 길이는 경계가 명확해서 구현하기 쉬운 반면, 가변 길이 데이터를 다루기 어렵다, 향후 확장이 어렵다, 여백 처리가 번거롭다, 문자 코드 변환으로 바이트 수가 바뀐다는 문제가 있습니다.

13. Nagle을 비활성화해도 메시지 경계 문제는 해결되지 않는다

작은 데이터를 즉시 보내고 싶은 경우, Socket.NoDelay = true를 검토하는 경우가 있습니다. 이는 Nagle 알고리즘을 비활성화하는 설정입니다.

다만, NoDelay는 「작은 송신을 어떻게 모을 것인가」라는 송신 지연이나 효율에 관한 설정이며, 「Send 단위를 Receive 단위로 보존하는」 설정이 아닙니다.

즉, NoDelay = true로 해도, 다음 문제는 해결되지 않습니다.

  • 한 번의 Send가 여러 번의 Receive로 나뉜다
  • 여러 번의 Send가 한 번의 Receive로 합쳐진다
  • 문자 중간에서 분할된다
  • 수신 측이 메시지 경계를 판단할 수 없다

NoDelay는 레이턴시 조정으로서는 의미가 있지만, 프레이밍을 대신하지는 못합니다.

14. TLS나 SslStream을 사용해도 사고방식은 같다

SslStream을 사용해 TLS화하는 경우도, 애플리케이션 입장에서 보는 처리는 기본적으로 같습니다.

TLS에는 TLS 레코드라는 내부적인 단위가 있지만, 그것은 애플리케이션 메시지의 경계가 아닙니다.

SslStream.ReadAsync에서도, 애플리케이션이 기대하는 1개 메시지분이 한 번에 반환된다고는 할 수 없습니다.

따라서 TLS 유무와 관계없이, 애플리케이션 계층에서 다음 중 하나를 설계합니다.

  • 길이 프리픽스
  • 개행 등의 구분자
  • 고정 길이
  • 기존 프로토콜 형식

TLS는 암호화와 인증의 계층이며, 메시지 경계를 자동으로 만들어 주는 계층이 아닙니다.

15. 수신 루프에서 신경 써야 할 오류 처리

TCP의 수신 처리에서는 정상 흐름뿐만 아니라, 연결 종료나 중도 종료를 명확하게 다루는 것이 중요합니다.

Read / Receive의 반환값이 0인 경우, 일반적으로 상대가 정상적으로 송신을 종료했다는 것을 의미합니다.

다만, 애플리케이션 프로토콜상으로는 다음 두 가지를 구분할 필요가 있습니다.

상태 처리
다음 프레임을 읽기 전에 0바이트로 종료 정상 종료로 처리할 수 있는 경우가 있다
프레임의 헤더 중간, 또는 본문 중간에서 종료 어중간한 전문이므로 비정상으로 처리한다

길이 프리픽스 방식이라면, 예를 들어 다음과 같이 생각합니다.

프레임 경계에서 연결 종료:
  정상 종료로 처리해도 된다

4바이트 헤더 중 2바이트만 받고 연결 종료:
  프로토콜 오류

본문 길이 100바이트인데 60바이트만 받고 연결 종료:
  프로토콜 오류

이 구분을 넣어 두면, 로그 조사가 훨씬 편해집니다.

「상대가 연결을 끊었습니다」뿐만 아니라,

Frame payload was truncated. expected=100 actual=60

처럼 출력할 수 있으면, 상대 측의 비정상 종료, 타임아웃, 프로토콜 불일치를 의심하기 쉬워집니다.

16. 최대 크기는 반드시 정한다

길이 프리픽스 방식에서는 앞부분에 본문 길이가 들어갑니다.

여기서 위험한 것은, 상대로부터 거대한 길이가 지정된 경우입니다.

FF FF FF FF

이것을 그대로 배열 확보에 사용하면, 메모리를 대량으로 확보하려다가 애플리케이션이 불안정해집니다.

그렇기 때문에 수신 측에서는 반드시 최대 크기를 정합니다.

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

최대 크기는 업무 요건에 따라 정합니다. 커맨드라면 64 KiB로도 충분할 수 있고, 이미지나 파일을 보낸다면 다른 전송 방식이나 스트리밍을 고려해야 할 수도 있습니다. 중요한 것은 「이론상 얼마든지 받아들이는」 설계로 만들지 않는 것입니다.

17. 문자열 프로토콜에서는 「문자 수」가 아니라 「바이트 수」를 본다

TCP가 전달하는 것은 문자열이 아니라 바이트열입니다.

그렇기 때문에 길이 프리픽스 방식에서 본문 길이를 넣을 때는, 보통 「문자 수」가 아니라 「바이트 수」를 넣습니다.

예를 들어, 다음 문자열을 UTF-8로 만든다고 합시다.

안녕하세요

이것은 5글자이지만, UTF-8로는 15바이트입니다.

프로토콜상의 길이를 5로 해 버리면, 수신 측은 본문을 5바이트만 읽어버려서 문자 중간에서 끊깁니다.

송신 측은 반드시 인코딩 후의 바이트 배열을 기준으로 길이를 계산합니다.

string json = "{\"message\":\"안녕하세요\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

수신 측은 프레임 본문을 바이트로 다 읽은 뒤에 문자열로 되돌립니다.

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

이 순서로 하면, UTF-8 문자 중간에서 Read가 분할되어도 문제가 되지 않습니다.

18. 병렬 Write에 의한 애플리케이션 레벨의 혼선에도 주의한다

또 하나, 의외로 간과되기 쉬운 것이 병렬 쓰기입니다.

예를 들어, 같은 TCP 연결에 대해 여러 태스크가 동시에 프레임을 쓴다고 합시다.

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

이것을 제어하지 않으면, 애플리케이션 레벨에서 다음과 같은 혼선이 일어날 가능성이 있습니다.

A의 헤더
B의 헤더
A의 본문
B의 본문

수신 측은 A의 헤더를 읽은 뒤 A의 본문이 오는 것을 전제로 처리합니다. 거기에 B의 헤더가 끼어들면, 프로토콜이 깨집니다.

그렇기 때문에, 하나의 연결에 대한 쓰기는 직렬화하는 것이 안전합니다. 예를 들어 SemaphoreSlim이나 송신용 큐를 사용해서, 프레임 단위의 쓰기가 섞이지 않도록 합니다.

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP는 바이트의 순서를 지켜 주지만, 애플리케이션이 여러 태스크에서 바이트열을 섞어서 쓴 경우에는, 그 「섞인 순서」를 그대로 정확하게 전달해 버립니다.

19. 테스트에서는 의도적으로 분할·결합시킨다

TCP의 수신 처리는 평범하게 테스트하면 「우연히 동작하는」 상태를 놓치기 쉽습니다.

그렇기 때문에 테스트에서는 의도적으로 다음 패턴을 만듭니다.

테스트 관점
1바이트씩 도착 헤더도 본문도 1바이트 단위로 Read된다
헤더 중간에서 종료 4바이트 헤더 중 2바이트만 도착하고 종료된다
본문 중간에서 종료 본문 길이 100 중 60바이트만 도착하고 종료된다
여러 프레임이 결합 2개의 프레임이 한 번의 내부 버퍼에 들어 있다
거대한 크기 지정 최대 크기를 초과하는 본문 길이를 보낸다
0바이트 본문 본문 길이 0을 허용할지 확인한다
UTF-8 분할 한글이나 이모지의 바이트열이 중간에서 분할된다

유닛 테스트에서는 실제 TCP 소켓을 사용하지 않아도, Stream을 교체해서 「지정한 청크 크기로만 읽을 수 있는 스트림」을 만들면, 수신 처리를 검증하기 쉬워집니다.

실제로 TCP를 통해 검증하는 통합 테스트도 필요하지만, 먼저 수신 파서를 Stream에 대한 순수한 처리로 분리해 두면 테스트하기 쉬워집니다.

네트워크 처리의 품질은 「평범하게 보내면 동작한다」가 아니라, 「분할되어도, 결합되어도, 중간에 끊겨도, 예상대로 동작한다」로 판단합니다.

20. 기존 코드를 수정할 때의 체크리스트

기존 TCP 통신 코드를 확인할 때는, 다음 관점으로 보면 문제를 발견하기 쉽습니다.

관점 확인할 것
수신 단위 한 번의 Read / Receive를 1개 메시지로 취급하고 있지 않은가
반환값 Read / Receive의 반환값(바이트 수)을 반드시 사용하고 있는가
축적 메시지가 모일 때까지 바이트를 축적하고 있는가
경계 고정 길이, 구분자, 길이 프리픽스 등의 규칙이 있는가
문자 코드 메시지 완성 전에 문자열화하고 있지 않은가
최대 길이 길이나 줄 길이에 상한이 있는가
연결 종료 프레임 경계에서의 종료와 중도 종료를 구분하고 있는가
송신 Socket.Send의 반환값을 무시하고 있지 않은가
병렬성 같은 연결에 대한 여러 태스크의 쓰기가 섞이지 않는가
로그 expected / actual 바이트 수를 출력할 수 있는가
테스트 분할, 결합, 중도 종료에 대한 테스트가 있는가

특히 위험한 코드는 다음과 같은 형태입니다.

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

문제점은 여러 가지 있습니다.

  • read 값을 사용하지 않고 있다
  • 버퍼 전체를 문자열화하고 있다
  • 한 번의 Receive를 1개 메시지로 취급하고 있다
  • 메시지 경계가 없다
  • 문자 중간 분할을 고려하지 않고 있다

적어도, 다음과 같은 사고방식으로 바꿀 필요가 있습니다.

Receive로 얻은 read 바이트만큼만 수신 버퍼에 추가한다
  ↓
수신 버퍼에서, 프로토콜에 따라 1개 프레임을 잘라낼 수 있는지 확인한다
  ↓
잘라낼 수 있다면 처리한다
  ↓
남은 바이트는 다음 프레임의 시작 부분으로 남긴다
  ↓
부족하다면 다음 Receive를 기다린다

21. 정리

TCP 통신에서는 Send한 단위대로 Receive할 수 있다고는 할 수 없습니다. 이것은 예외적인 동작이 아니라, TCP를 사용하는 데 있어서의 기본입니다.

짚어 두어야 할 포인트는 다음과 같습니다.

  • TCP는 메시지가 아니라, 순서가 보장된 바이트 스트림을 제공한다
  • Send / Write 호출 단위는 수신 측의 Receive / Read 단위로 보존되지 않는다
  • 한 번의 송신이 여러 번의 수신으로 나뉘는 경우도, 여러 번의 송신이 한 번의 수신으로 합쳐지는 경우도 있다
  • 수신 측은 애플리케이션 프로토콜로서 메시지 경계를 정할 필요가 있다
  • 독자적인 프로토콜에서는 길이 프리픽스 방식이 다루기 쉬운 경우가 많다
  • 필요한 바이트 수를 다 읽는 루프, 최대 크기, 중도 종료, 문자 코드, 병렬 쓰기를 설계에 포함시킨다
  • NoDelayDataAvailable은 메시지 경계를 대신하지 못한다

네트워크 처리는 정상적인 상황만 보면 간단해 보입니다. 하지만 실제로는 「어디서 끊을지」, 「부족할 때 어떻게 기다릴지」, 「너무 많을 때 어떻게 남길지」, 「중간에 끊겼을 때 어떻게 처리할지」를 정해야 비로소 안정적인 통신이 됩니다.

TCP를 사용한다면, Receive는 메시지를 반환하는 것이 아니라, 바이트열의 일부를 반환할 뿐입니다. 메시지로 만드는 책임은 애플리케이션 측의 프로토콜 설계에 있습니다.

참고

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

TCP에서 Send한 단위대로 Receive할 수 없는 이유는 무엇인가요?
TCP가 보장하는 것은 「보낸 바이트 열이 순서를 유지한 채, 중복 없이, 빠짐없이 도착하는 것」이며, Send한 단위가 수신 측의 Receive 단위로 보존되는 것은 보장하지 않기 때문입니다. TCP는 메시지가 아니라 연속된 바이트 열을 전달하므로, 한 번의 송신이 여러 번의 수신으로 나뉘는 것도, 여러 번의 송신이 한 번의 수신으로 합쳐지는 것도 정상적인 동작입니다. 수신 측에서 메시지 경계를 판단하는 구조(프레이밍)가 필요합니다.
TCP의 프레이밍(메시지 경계를 정하는 방법)에는 어떤 방식이 있나요?
대표적인 방식은 네 가지입니다. 항상 정해진 바이트 수를 1개 메시지로 삼는 고정 길이 방식, 개행 등 특정 바이트열까지를 1개 메시지로 삼는 구분자 방식, 앞부분에 본문 길이를 두는 길이 프리픽스 방식, HTTP의 Content-Length처럼 형식 안에서 길이나 종료를 나타내는 자기 기술 형식입니다. 독자적인 프로토콜을 만든다면, 본문에 임의의 바이너리를 포함할 수 있고 최대 크기 제한도 넣기 쉬운 길이 프리픽스 방식이 먼저 검토 대상이 됩니다.
Socket.NoDelay를 true로 하면 TCP의 분할·결합 문제가 해결되나요?
해결되지 않습니다. NoDelay는 Nagle 알고리즘을 비활성화하는 설정으로, 작은 송신의 지연이나 효율에 관한 조정일 뿐이며, Send 단위를 Receive 단위로 보존하는 설정이 아닙니다. NoDelay를 true로 해도, 한 번의 Send가 여러 번의 Receive로 나뉘거나, 여러 번의 Send가 한 번의 Receive로 합쳐지거나, 문자 중간에서 분할되는 문제는 그대로 일어납니다. 프레이밍을 대신하지는 못합니다.
TCP 수신에서 데이터가 문자 깨짐이 발생하는 이유는 무엇인가요?
UTF-8에서는 한 문자가 여러 바이트가 되는 경우가 있고, Read의 경계가 문자의 경계와 일치한다는 보장이 없기 때문입니다. 받은 바이트열을 매번 바로 Encoding.UTF8.GetString으로 문자열화하면, 멀티바이트 문자 중간에서 분할된 경우 깨집니다. 대책은 메시지의 경계를 알 수 있을 때까지 바이트로 축적하고, 1개 메시지분이 모인 뒤에 디코딩하는 것입니다. 길이 프리픽스의 본문 길이도 문자 수가 아니라 인코딩 후의 바이트 수로 계산합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기