수정 이력(4건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대한 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 송신 구분과 수신 구분이 어긋나는 모습을 그림으로 그렸습니다. 더불어 전제 환경 표, 분할 수신을 의도적으로 재현하는 테스트 코드, 프로덕션에서 깨졌을 때의 관측 수단(로그·Wireshark·pktmon) 장을 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635304)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「TCP에서 Send한 단위마다 Receive할 수 있다는 오해 ── 바이트 스트림으로 다루기 위한 수신 설계」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635304 https://comcomponent.com/ko/blog/tcp-send-receive-message-framing/
- DOI(최신 버전)
- 10.5281/zenodo.21635304
- DOI(이 버전)
- 10.5281/zenodo.21635305
1. 먼저 짚어 둘 것
TCP 통신을 구현할 때 꽤 흔한 오해가 있습니다.
그것은,
송신 측이
Send/Write한 단위마다, 수신 측에서도Receive/Read할 수 있다
는 것입니다.
예를 들어 송신 측이 다음과 같이 보냈다고 합시다.
Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")
이때 수신 측에서도 다음과 같이 세 번에 나눠 받을 수 있다고 생각하기 쉽습니다.
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를 쓰는 애플리케이션에서는, 수신 측이 “지금 받은 바이트열이 어디부터 어디까지가 메시지 하나인지”를 판단하는 구조가 필요합니다.
이것을 애플리케이션 프로토콜의 프레이밍이라고 부릅니다.
이 글에서는 TCP의 Send와 Receive 관계에서 일어나기 쉬운 오해와, .NET / C#에서의 올바른 처리 방법을 정리합니다.
또한 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 일체(라이브러리, 루프백 TCP 데모, 분할·결합·중도 절단을 재현하는 유닛 테스트)로 GitHub에 공개되어 있습니다.
tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)
이 글의 전제
| 항목 | 전제 |
|---|---|
| 언어·런타임 | C# 8 이후 구문(범위 연산자, nullable 참조 형식)과 Memory<byte>를 받는 Stream.ReadAsync를 사용합니다. .NET Core 3.1 이후 / .NET 5 이후를 가정하세요 |
| 통신 API | 수신은 TcpClient에서 얻은 NetworkStream, 즉 Stream에 대한 async/await로 작성합니다 |
Socket을 직접 쓰는 경우 |
수신의 사고방식은 같습니다. Socket.Receive / ReceiveAsync도 “반환값의 바이트 수만 신뢰한다”, “필요한 만큼 다 읽을 때까지 루프한다”로 다룹니다. 차이가 나는 것은 송신 측으로, Socket.Send는 반환값을 봐야 합니다. 10장에서 다룹니다 |
| .NET Framework | 설계의 사고방식은 같지만, 범위 연산자나 Memory<byte>를 받는 오버로드를 쓸 수 없으므로 Read(byte[], int, int)를 쓴 루프로 바꿔 써야 합니다 |
Stream.ReadExactly |
8장에서 다루는 Stream.ReadExactly / ReadExactlyAsync는 .NET 7 이후에서 사용할 수 있습니다 |
이 글은 애플리케이션 프로토콜 설계 이야기이므로, NoDelay를 설정할지, TLS를 적용할지에 따라 결론이 바뀌지 않습니다. 각각 13장과 14장에서 이유를 설명합니다.
이 글의 지식 맵
TCP 통신에서 송신 측이 Send/Write한 단위마다 수신 측에서 Receive/Read할 수 있다는 착각이 분할·결합·문자 깨짐·프로토콜 손상을 일으킨다는 점을 다룹니다. TCP가 전달하는 것은 순서가 있는 바이트 스트림이며 메시지 경계는 보장되지 않으므로, 수신 측은 애플리케이션 프로토콜로서 프레이밍(고정 길이·구분자·길이 프리픽스·자기 기술 형식)으로 메시지 경계를 정해야 합니다. Socket.NoDelay나 DataAvailable, TLS의 레코드 단위는 어느 것도 메시지 경계를 대신하지 않습니다. 구현에서는 Read/Receive 반환값의 바이트 수만 신뢰하고 필요한 바이트 수를 다 읽을 때까지 루프하는 설계와, 프로덕션에서만 깨지는 장애를 Wireshark나 pktmon으로 관측하는 절차가 실무의 핵심이 됩니다.
flowchart LR
accTitle: TCP의 바이트 스트림과 프레이밍 설계의 지식 맵
accDescr: TCP가 바이트 스트림이며 Send 단위를 보장하지 않는다는 점, 프레이밍 방식의 종류, NoDelay나 DataAvailable, TLS 레코드가 경계를 대신하지 않는다는 점, Wireshark나 pktmon으로 하는 Audit 절차와의 관계를 보여주는 그림입니다.
tcp_byte_stream["TCP의 바이트 스트림 특성"]
application_framing["애플리케이션 프로토콜 프레이밍"]
length_prefix_framing["길이 접두사 방식"]
message_boundary_mismatch["송신 구분과 수신 구분의 어긋남(분할·결합)"]
fixed_length_framing["고정 길이 방식"]
delimiter_framing["구분 문자 방식"]
self_describing_framing["자기 기술 형식"]
nagle_algorithm["Nagle 알고리즘(Socket.NoDelay)"]
socket_dataavailable["NetworkStream.DataAvailable"]
tls_record["TLS 레코드"]
concurrent_write_interleaving["병렬 Write로 인한 애플리케이션 혼선"]
io_loop_until_complete["필요 바이트 수까지 읽고/쓰는 루프"]
asynchronous_io["비동기 I/O"]
wireshark["Wireshark"]
pktmon["Packet Monitor(pktmon)"]
npcap["Npcap"]
tcp_byte_stream -->|"원인이 될 수 있다"| message_boundary_mismatch
application_framing -->|"방지한다"| message_boundary_mismatch
fixed_length_framing -->|"구현을 담당한다"| application_framing
delimiter_framing -->|"구현을 담당한다"| application_framing
length_prefix_framing -->|"구현을 담당한다"| application_framing
self_describing_framing -->|"구현을 담당한다"| application_framing
length_prefix_framing -->|"권장되는 대응"| tcp_byte_stream
nagle_algorithm -->|"사용은 비권장"| application_framing
socket_dataavailable -->|"사용은 비권장"| application_framing
tls_record -->|"사용은 비권장"| application_framing
concurrent_write_interleaving -->|"원인이 될 수 있다"| message_boundary_mismatch
io_loop_until_complete -->|"방지한다"| message_boundary_mismatch
io_loop_until_complete -.->|"이용한다"| asynchronous_io
message_boundary_mismatch -->|"에서 확인할 수 있다"| wireshark
message_boundary_mismatch -->|"에서 확인할 수 있다"| pktmon
length_prefix_framing -->|"전제로 한다"| io_loop_until_complete
fixed_length_framing -->|"전제로 한다"| io_loop_until_complete
delimiter_framing -->|"전제로 한다"| io_loop_until_complete
wireshark -.->|"전제로 한다"| npcap
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 19건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
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로 메시지 하나를 얻는다”를 전제로 하지만, TCP에서는 그 전제가 성립하지 않습니다.
문제는 크게 세 가지입니다.
첫째는 메시지 하나가 분할되는 것입니다.
송신: {"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
이 경우 JSON 하나로 파싱하려고 하면 실패합니다.
셋째는 문자 코드 경계에서 분할되는 것입니다.
UTF-8에서는 한 문자가 여러 바이트가 될 수 있습니다. ReadAsync 경계가 문자 경계와 일치한다는 보장은 없습니다.
그래서 받은 바이트열을 매번 바로 Encoding.UTF8.GetString으로 문자열로 바꾸면, 멀티바이트 문자 중간에서 분할된 경우 깨질 수 있습니다.
“받자마자 문자열로 바꾼다”가 아니라, “메시지 경계를 알 수 있을 때까지 바이트로 쌓아 두고, 메시지 하나가 모인 뒤에 디코딩한다”가 기본입니다.
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이 나타내는 것은 “그 순간에 읽을 수 있는 데이터가 로컬 수신 버퍼에 있는지”이지, 애플리케이션상 메시지 하나가 끝났다는 뜻이 아닙니다.
예를 들어 메시지 하나가 100바이트일 때, 처음 40바이트만 도착한 순간에 DataAvailable이 true가 되고, 그 40바이트를 읽은 직후 잠시 false가 될 수 있습니다. 나머지 60바이트는 조금 뒤에 도착할 수 있습니다.
이때 DataAvailable == false를 “메시지 끝”으로 해석하면, 중간까지의 데이터를 메시지 하나로 처리해 버립니다.
DataAvailable은 읽기 루프 최적화나 논블로킹에 가까운 확인에 쓸 수는 있어도, 프로토콜 경계 판정에는 쓰지 않는 편이 안전합니다.
6. 올바른 사고방식은 “수신”과 “해석”을 나누는 것
TCP 수신 처리에서는 다음 둘을 나눠 생각하면 설계하기 쉬워집니다.
수신: TCP에서 도착한 바이트열을 읽어 버퍼에 쌓는다
해석: 버퍼에서 애플리케이션상 메시지 하나를 잘라 낸다
Receive / Read는 어디까지나 “바이트를 읽는” 처리입니다. 그에 대해 “메시지 하나가 어디서 끝나는지”는 애플리케이션 프로토콜로 정해야 합니다.
대표적인 방법은 다음 네 가지입니다.
| 방식 | 내용 | 잘 맞는 용도 |
|---|---|---|
| 고정 길이 | 항상 정해진 바이트 수를 메시지 하나로 삼는다 | 레거시 장비, 바이너리 전문, 제어 계통 |
| 구분자 | \n 등 특정 바이트열까지를 메시지 하나로 삼는다 |
커맨드, 로그, 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 ...
^---------^ ^------------------------------^
본문 길이 본문
송신 3회가 수신 측에서 어떻게 보이고, 거기서 프레임을 어떻게 복원하는지를 한 장으로 그리면 다음과 같습니다.
flowchart TD
M1["첫 번째 송신<br/>본문 HELLO<br/>00 00 00 05 48 45 4C 4C 4F"]
M2["두 번째 송신<br/>본문 ABC<br/>00 00 00 03 41 42 43"]
M3["세 번째 송신<br/>본문 QUIT<br/>00 00 00 04 51 55 49 54"]
WIRE["TCP는 순서가 있는 바이트 스트림<br/>송신 구분은 전달되지 않는다<br/>합계 24바이트가 순서대로 도착할 뿐"]
R1["첫 번째 Read = 6바이트<br/>00 00 00 05 48 45"]
R2["두 번째 Read = 11바이트<br/>4C 4C 4F 00 00 00 03 41 42 43 00"]
R3["세 번째 Read = 7바이트<br/>00 00 04 51 55 49 54"]
BUF["수신 버퍼<br/>Read로 얻은 바이트를 차례로 더한다"]
P1["앞 4바이트를 다 읽는다<br/>본문 길이 = 5"]
P2["본문을 5바이트 다 읽는다<br/>메시지 1개 완성 = HELLO"]
P3["남은 바이트는 버리지 않고<br/>다음 프레임의 앞으로 남긴다"]
M1 --> WIRE
M2 --> WIRE
M3 --> WIRE
WIRE --> R1 --> BUF
WIRE --> R2 --> BUF
WIRE --> R3 --> BUF
BUF --> P1 --> P2 --> P3
P3 -.->|"다음 프레임으로"| P1
그림 1: 송신 3회가 수신 측에서는 3회의 Read에 대응하지 않고, 수신 버퍼를 거쳐 프레임으로 복원되는 흐름
그림의 첫 번째 Read는 본문 길이 중간까지밖에 도착하지 않았고, 두 번째 Read에는 첫 번째 본문의 나머지와 두 번째 프레임 전체, 나아가 세 번째 헤더의 첫 바이트까지가 섞여 있습니다. 송신 구분과 수신 구분이 일치하지 않는다는 것을 알 수 있습니다.
수신 측은 다음 순서로 처리합니다.
- 먼저 4바이트를 다 읽는다
- 그 4바이트에서 본문 길이를 꺼낸다
- 본문 길이가 잘못된 값이 아닌지 검증한다
- 본문 길이만큼 다 읽는다
- 다 읽은 본문을 메시지 하나로 처리한다
- 다음 프레임을 읽는다
여기서 중요한 것은 “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합니다. 여기서 또 오해가 생기기 쉬운데, 송신 측에서 헤더와 본문을 두 번으로 나눠 WriteAsync해도 수신 측에서 두 번으로 나뉘어 읽힌다고 할 수 없습니다.
수신 측에서는 다음과 같이 보일 수 있습니다.
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의 처리를 정한다- 한 줄의 최대 길이를 정한다
- 구분자가 올 때까지 제한 없이 메모리를 쌓지 않는다
- 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 문자 중간에서 분할될 수 있다는 점도 고려하지 않습니다.
개행 구분을 쓴다면, 적어도 “바이트를 쌓아 두고 개행 바이트를 찾아, 한 줄이 모인 뒤에 디코딩한다”거나, StreamReader.ReadLineAsync처럼 스트림에서 줄을 읽는 API를 사용합니다.
다만 StreamReader.ReadLineAsync를 쓰는 경우에도 최대 줄 길이, 타임아웃, 취소, 연결 종료 시의 처리는 설계해 두어야 합니다.
12. 고정 길이 방식을 쓸 때의 주의점
고정 길이 전문에서는 “반드시 128바이트가 메시지 하나”처럼 정합니다. 오래된 업무 시스템, 제어 계통, 장비 연동에서 보이는 방식입니다.
고정 길이여도 사고방식은 같습니다.
메시지 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에서도 애플리케이션이 기대하는 메시지 하나가 한 번에 돌아온다고 할 수 없습니다.
따라서 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을 바꿔 끼워 “지정한 청크 크기로만 읽을 수 있는 스트림”을 만들면 수신 처리를 검증하기 쉬워집니다.
분할을 재현한다: Stream을 씌운다
전용 라이브러리를 넣지 않아도, Stream을 상속해 Read가 반환하는 바이트 수에 상한을 거는 것만으로 재현할 수 있습니다.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
// 1회 Read에서 최대 maxChunkSize 바이트만 반환하는 스트림.
// 안쪽 스트림을 감쌀 뿐이므로, 테스트 대상인 수신 처리에는 손을 대지 않는다.
public sealed class ChunkedReadStream : Stream
{
private readonly Stream _inner;
private readonly int _maxChunkSize;
public ChunkedReadStream(Stream inner, int maxChunkSize)
{
if (inner is null) throw new ArgumentNullException(nameof(inner));
if (maxChunkSize < 1) throw new ArgumentOutOfRangeException(nameof(maxChunkSize));
_inner = inner;
_maxChunkSize = maxChunkSize;
}
public override int Read(byte[] buffer, int offset, int count)
=> _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize));
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
=> _inner.ReadAsync(
buffer[..Math.Min(buffer.Length, _maxChunkSize)],
cancellationToken);
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
이것을 쓰면 “1바이트씩만 도착한다”도 “프레임 2개가 한 번에 도착한다”도, 같은 테스트 코드의 인자 차이로 쓸 수 있습니다.
using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
public class LengthPrefixedProtocolTests
{
// 8장의 LengthPrefixedProtocol.ReadFrameAsync를 테스트한다
[Theory]
[InlineData(1)] // 헤더도 본문도 1바이트씩 도착한다
[InlineData(3)] // 헤더 중간에서 끊긴다
[InlineData(1024)] // 프레임 2개가 한꺼번에 도착한다
public async Task チャンクサイズが変わっても2フレームを復元できる(int chunkSize)
{
using var source = new MemoryStream();
WriteFrame(source, "HELLO");
WriteFrame(source, "ABC");
source.Position = 0;
using var stream = new ChunkedReadStream(source, chunkSize);
byte[]? first = await LengthPrefixedProtocol.ReadFrameAsync(
stream, CancellationToken.None);
byte[]? second = await LengthPrefixedProtocol.ReadFrameAsync(
stream, CancellationToken.None);
byte[]? afterLast = await LengthPrefixedProtocol.ReadFrameAsync(
stream, CancellationToken.None);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal("HELLO", Encoding.UTF8.GetString(first!));
Assert.Equal("ABC", Encoding.UTF8.GetString(second!));
Assert.Null(afterLast); // 프레임 경계에서의 정상 종료
}
private static void WriteFrame(Stream destination, string text)
{
byte[] payload = Encoding.UTF8.GetBytes(text);
byte[] header = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);
destination.Write(header, 0, header.Length);
destination.Write(payload, 0, payload.Length);
}
}
중도 절단 테스트도 MemoryStream에 중간까지만 쓰면 재현할 수 있습니다. 예를 들어 WriteFrame 대신 헤더 4바이트 중 2바이트만 쓰면 “헤더 중간에서 연결 종료”가 되고, ReadFrameAsync가 EndOfStreamException을 던지는 것을 확인할 수 있습니다.
루프백으로 분할을 일으킨다
통합 테스트에서 실제 TCP 너머로 확인하고 싶다면, 송신 측에서 프레임을 일부러 두 번에 나눠 쓰고 사이에 대기를 넣습니다.
using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
public static class SplitSender
{
// frame을 firstChunkSize 바이트로 나눠 보낸다.
// NoDelay를 설정하지 않으면 Nagle 알고리즘으로 전반과 후반이
// 하나의 세그먼트로 묶여, 분할이 일어나지 않을 수 있다.
public static async Task SendSplitAsync(
TcpClient client,
byte[] frame,
int firstChunkSize,
CancellationToken cancellationToken)
{
if (client is null) throw new ArgumentNullException(nameof(client));
if (frame is null) throw new ArgumentNullException(nameof(frame));
if (firstChunkSize < 1 || firstChunkSize >= frame.Length)
{
throw new ArgumentOutOfRangeException(nameof(firstChunkSize));
}
client.NoDelay = true;
NetworkStream stream = client.GetStream();
await stream.WriteAsync(frame.AsMemory(0, firstChunkSize), cancellationToken);
await Task.Delay(50, cancellationToken);
await stream.WriteAsync(frame.AsMemory(firstChunkSize), cancellationToken);
}
}
firstChunkSize에 2를 넘기면 “4바이트 헤더 중간에서 끊긴다”, 본문 길이 + 2를 넘기면 “본문 중간에서 끊긴다”를 시험할 수 있습니다.
다만 이것은 TCP 사양으로서 분할을 보장하는 것이 아닙니다. 어디까지나 “실무상 거의 확실하게 경계가 어긋나는” 조건을 만드는 것일 뿐입니다. 확실성이 필요한 테스트는 소켓이 아니라 Stream 바꿔 끼우기 유닛 테스트 쪽에서 확보하세요.
실제로 TCP 너머로 검증하는 통합 테스트도 필요하지만, 우선 수신 파서를 Stream에 대한 순수한 처리로 잘라 두면 테스트하기 쉬워집니다.
네트워크 처리의 품질은 “평범하게 보내면 된다”가 아니라, “분할되어도, 결합되어도, 중간에 끊겨도 예상대로 동작한다”로 판단합니다.
20. 프로덕션에서 가끔 깨질 때의 관측 수단
프레이밍 문제는 개발 환경에서는 나오지 않고 프로덕션에서만, 그것도 부정기적으로 나옵니다. 데이터 양이 늘었을 때, 회선이 느려졌을 때, 상대 측 구현이 바뀌었을 때 비로소 분할 방식이 바뀌기 때문입니다.
이때 먼저 정하고 싶은 것은, 깨진 것이 송신 측 바이트열인지, 수신 측 복원 처리인지입니다. 여기만 갈라져도 조사 범위가 절반이 됩니다.
관측 수단은 다음 셋을 나눠 씁니다.
| 수단 | 알 수 있는 것 | 주의점 |
|---|---|---|
| 애플리케이션 로그 | 기대 바이트 수와 실제로 읽은 바이트 수, 잘라 낸 프레임 길이, 연결 종료 타이밍 | 먼저 넣어야 할 수단. expected와 actual을 반드시 둘 다 낸다. 한쪽만으로는 “부족했다”는 것만 알 수 있다 |
| Wireshark | 실제로 흐른 바이트열, TCP 세그먼트 경계, 재전송, RST 유무 | Windows에서 루프백(127.0.0.1)을 보려면 Npcap이 필요하다. Wireshark 3.0.0 이후라면 인터페이스 목록의 “Adapter for loopback traffic capture”를 고른다 |
| pktmon | Windows 표준만으로 캡처할 수 있다. 얻은 ETL은 pcapng로 변환해 Wireshark에서 열 수 있다 | pktmon.exe가 표준으로 들어 있는 것은 Windows 10 build 19041 이후 |
캡처를 열면 다음 순서로 봅니다.
- Follow > TCP Stream으로 복원된 바이트열을 본다. 여기서 길이 프리픽스 4바이트가 기대한 값인지 확인한다
- 기대대로라면 송신 측은 프레임을 올바르게 만들고 있다. 문제는 수신 측 복원 처리에 있다
- 기대대로가 아니라면 송신 측 프레임 조립, 또는 18장의 병렬 Write에 의한 혼선을 의심한다
- RST나 중도 절단이 있으면, 프레임 중간 절단을 수신 측이 어떻게 다루는지 확인한다
여기서 하나, 혼동하기 쉬운 점이 있습니다.
TCP 세그먼트 경계도 애플리케이션의 메시지 경계가 아닙니다. Wireshark에서 세그먼트 하나에 메시지 하나가 들어가 보여도 그것은 우연입니다. 수신 측 애플리케이션이 Read로 받는 단위는 세그먼트 구분과도 일치하지 않습니다. 캡처는 “실제로 흐른 바이트열”을 확인하려고 쓰는 것이지, “여기가 메시지 하나다”라고 읽으려고 쓰는 것이 아닙니다.
게다가 송신 측 호스트에서 캡처하면, 세그먼테이션 오프로드(LSO / TSO) 영향으로 MTU를 넘는 크기의 패킷으로 기록되는 경우가 있습니다. 실제로 네트워크를 흐른 세그먼트와는 다른 모습입니다. 세그먼트 크기 자체를 문제로 삼고 싶다면 수신 측이나 중간 호스트에서 캡처하거나, 일시적으로 오프로드를 끄고 캡처합니다.
21. 기존 코드를 고칠 때의 체크리스트
기존 TCP 통신 코드를 확인할 때는 다음 관점으로 보면 문제를 찾기 쉽습니다.
| 관점 | 확인할 것 |
|---|---|
| 수신 단위 | 한 번의 Read / Receive를 메시지 하나로 다루고 있지 않은가 |
| 반환값 | Read / Receive 반환값의 바이트 수를 반드시 쓰고 있는가 |
| 축적 | 메시지가 모일 때까지 바이트를 쌓고 있는가 |
| 경계 | 고정 길이, 구분자, 길이 프리픽스 등의 규칙이 있는가 |
| 문자 코드 | 메시지가 완성되기 전에 문자열로 바꾸고 있지 않은가 |
| 최대 길이 | 길이나 줄 길이에 상한이 있는가 |
| 연결 종료 | 프레임 경계에서의 연결 종료와 중도 절단을 구분하고 있는가 |
| 송신 | Socket.Send 반환값을 무시하고 있지 않은가 |
| 병렬성 | 같은 연결에 대한 여러 태스크의 쓰기가 섞이지 않는가 |
| 로그 | expected / actual 바이트 수를 낼 수 있는가 |
| 테스트 | 분할, 결합, 중도 절단 테스트가 있는가 |
특히 위험한 코드는 다음과 같은 형태입니다.
int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);
문제점은 여러 가지입니다.
read값을 쓰지 않는다- 버퍼 전체를 문자열로 바꾼다
- 한 번의
Receive를 메시지 하나로 다룬다 - 메시지 경계가 없다
- 문자 중간 분할을 고려하지 않는다
최소한 다음 사고방식으로 바꿔야 합니다.
Receive로 얻은 read 바이트만 수신 버퍼에 추가한다
↓
수신 버퍼에서, 프로토콜에 따라 프레임 하나를 잘라 낼 수 있는지 확인한다
↓
잘라 낼 수 있으면 처리한다
↓
남은 바이트는 다음 프레임의 앞으로 남긴다
↓
부족하면 다음 Receive를 기다린다
22. 정리
TCP 통신에서는 Send한 단위마다 Receive할 수 있다고 할 수 없습니다. 이것은 예외적인 동작이 아니라, TCP를 쓸 때의 기본입니다.
짚어 둘 포인트는 다음과 같습니다.
- TCP는 메시지가 아니라, 순서가 있는 바이트 스트림을 제공한다
Send/Write호출 단위는 수신 측Receive/Read단위로 보존되지 않는다- 한 번의 송신이 여러 번의 수신으로 나뉘기도 하고, 여러 번의 송신이 한 번의 수신으로 합쳐지기도 한다
- 수신 측은 애플리케이션 프로토콜로서 메시지 경계를 정해야 한다
- 독자 프로토콜에서는 길이 프리픽스 방식이 다루기 쉬운 경우가 많다
- 필요한 바이트 수를 다 읽는 루프, 최대 크기, 중도 절단, 문자 코드, 병렬 쓰기를 설계에 넣는다
NoDelay나DataAvailable은 메시지 경계를 대신하지 못한다- 프로덕션에서만 깨질 때는, 캡처로 “송신 측 바이트열”과 “수신 측 복원” 중 어느 쪽이 깨졌는지를 먼저 가른다
네트워크 처리는 정상일 때만 보면 쉬워 보입니다. 그러나 실제로는 “어디서 자를지”, “부족할 때 어떻게 기다릴지”, “너무 많을 때 어떻게 남길지”, “중간에 끊겼을 때 어떻게 다룰지”를 정해야 비로소 안정된 통신이 됩니다.
TCP를 쓴다면 Receive는 메시지를 반환하는 것이 아니라, 바이트열의 일부만 반환할 뿐입니다. 메시지로 만드는 책임은 애플리케이션 쪽 프로토콜 설계에 있습니다.
참고
- 이 글의 샘플 코드 일체(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/tcp-send-receive-message-framing
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0 - Microsoft Learn: Packet Monitor (Pktmon) https://learn.microsoft.com/en-us/windows-server/networking/technologies/pktmon/pktmon
- Wireshark Wiki: CaptureSetup/Loopback https://wiki.wireshark.org/CaptureSetup/Loopback
- Wireshark Wiki: CaptureSetup/Offloading https://wiki.wireshark.org/CaptureSetup/Offloading
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows impersonation token을 올바르게 다루기 ── 스레드 단위 권한 차용과 안전한 되돌리기
Windows impersonation token에 대해 access token, primary token, thread token, impersonation level, RevertToSelf, .NET의 WindowsIdentity.RunIm...
.NET에서 GC 미회수와 메모리 누수를 가려내기 ── 늘어나는 메모리를 관측·비교·증명하는 실무 절차
.NET 애플리케이션에서 메모리가 늘어나는 이유가 아직 GC가 회수하지 않아서인지, 실제 메모리 누수인지를 dotnet-counters, dotnet-gcdump, dotnet-dump로 가려내는 절차를 정리합니다.
OSI 참조 모델을 선명하게 그려 보기 ── HTTP 요청 1개를 7계층으로 해부한다
OSI 참조 모델을 실물로 이해합니다. HTTP GET 요청을 실어 나르는 Ethernet 프레임을 C#으로 조립해 해부하고, 7계층이 중첩된 모습을 Wireshark로 확인합니다. 각 계층과 .NET API의 대응, 장애 원인 분리까지 정리합니다.
PDB(프로그램 데이터베이스)란 무엇인가 - 디버그 정보·심볼·Source Link 이해하기
PDB(Program Database / 프로그램 데이터베이스)란 무엇인지, 무엇이 들어 있고 무엇이 들어 있지 않은지, Debug / Release, Portable PDB, Source Link, 심볼 서버, 덤프 분석과의 관계까지 실무 관점...
Roslyn이란 무엇인가 ── 컴파일러의 시각으로 C# 코드를 읽고 고치고 생성하기
Roslyn(.NET Compiler Platform)의 개요, Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, 실무에서의 활용처와 주의점을 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
장애 조사 & 장기 가동 장애
간헐적 장애, 통신 진단, 장기 가동 크래시, 실패 경로 테스트 기반을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기존 자산 활용 & 이관 지원
COM / ActiveX / OCX 자산, 네이티브 코드, 32비트 의존성을 유지하면서 단계적인 이관 계획을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- TCP에서 Send한 단위마다 Receive할 수 없는 이유는 무엇인가요?
- TCP가 보장하는 것은 보낸 바이트열이 순서를 유지한 채 중복 없이, 빠짐없이 도착하는 것이며, Send한 단위가 수신 측 Receive 단위로 보존되는 것은 보장하지 않기 때문입니다. TCP는 메시지가 아니라 연속된 바이트열을 나르므로, 한 번의 송신이 여러 번의 수신으로 나뉘는 것도, 여러 번의 송신이 한 번의 수신으로 합쳐지는 것도 정상 동작입니다. 수신 측에서 메시지 경계를 판단하는 구조(프레이밍)가 필요합니다.
- TCP 프레이밍(메시지 경계를 정하는 방법)에는 어떤 방식이 있나요?
- 대표적인 방식은 네 가지입니다. 항상 정해진 바이트 수를 메시지 하나로 삼는 고정 길이 방식, 개행 등 특정 바이트열까지를 메시지 하나로 삼는 구분자 방식, 앞에 본문 길이를 두는 길이 프리픽스 방식, HTTP의 Content-Length처럼 형식 안에서 길이나 종료를 나타내는 자기 기술 형식입니다. 독자 프로토콜을 만든다면, 본문에 임의의 바이너리를 넣을 수 있고 최대 크기 제한도 넣기 쉬운 길이 프리픽스 방식이 우선 검토 대상이 됩니다.
- Socket.NoDelay를 true로 하면 TCP의 분할·결합 문제가 해결되나요?
- 해결되지 않습니다. NoDelay는 Nagle 알고리즘을 끄는 설정으로, 작은 송신의 지연이나 효율을 조정할 뿐이며 Send 단위를 Receive 단위로 보존하는 설정이 아닙니다. NoDelay를 true로 해도, 한 번의 Send가 여러 번의 Receive로 나뉘거나, 여러 번의 Send가 한 번의 Receive로 합쳐지거나, 문자 중간에서 분할되는 문제는 그대로 일어납니다. 프레이밍을 대신하지는 못합니다.
- TCP 수신에서 데이터가 깨지는 이유는 무엇인가요?
- UTF-8에서는 한 문자가 여러 바이트가 되는 경우가 있고, Read 경계가 문자 경계와 일치한다는 보장이 없기 때문입니다. 받은 바이트열을 매번 바로 Encoding.UTF8.GetString으로 문자열로 바꾸면, 멀티바이트 문자 중간에서 분할된 경우 깨집니다. 대책은 메시지 경계를 알 수 있을 때까지 바이트로 쌓아 두고, 메시지 하나가 모인 뒤에 디코딩하는 것입니다. 길이 프리픽스의 본문 길이도 문자 수가 아니라 인코딩 후의 바이트 수로 계산합니다.