| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- C++
- PS
- c#
- BOJ
- git
- Data Structure
- multi-thread
- 독서
- System Programming
- Unity
- Toy Project
- Online Judge
- Network Programming
- Today
- Total
I'm FanJae.
[Network Programming] TCP에서 Length-Prefix 처리가 필요한 이유 본문
[Network Programming] TCP에서 Length-Prefix 처리가 필요한 이유
FanJae 2026. 5. 28. 00:131. 시작에 앞서 (회상)
- 학부 시절, 컴퓨터 통신이라는 과목을 수강한 적이 있었다. 이름만 보면 네트워크 이론을 학습할 것 같은 과목이지만, 실제로는 네트워크 프로그래밍을 배우고, 팀 프로젝트를 진행하는 과목이었다.
- 당시에는 Reliable UDP(RUDP)를 직접 구현해보겠다는 생각으로 서버를 구현했다. 그 과정에서 재전송, 순서 보장 같은 전송 신뢰성에만 집중했고, 정작 애플리케이션 레벨에서 메시지 경계를 어떻게 구분해야 하는지는 깊게 생각하지 못했다.
- 이 경험 때문인지 이후 TCP를 사용할 때도 recv() 한 번이 곧 메시지 하나라고 착각한 적이 있었다. 실제로 과거에 만들었던 일부 프로젝트를 다시 보면, 메시지 경계 처리가 명확하게 분리되어 있지 않았다.
- 이번 글에서는 이 착각을 기준으로, TCP에서 Length-Prefix 처리가 왜 필요한지 정리해보려고 한다.
2. TCP는 메시지 경계를 보존하지 않는다.
- Length-Prefix가 필요한 이유를 이해하려면 먼저 TCP의 특성을 봐야 한다.
- TCP는 신뢰성 있는 전송을 제공하지만, 애플리케이션이 보낸 메시지 단위를 그대로 보존해주지 않는다.
즉, 클라이언트에서 send()를 여러 번 호출했다고 해서 서버에서 recv()도 같은 횟수와 같은 단위로 호출된다는 보장이 없다.
- 예를 들어 클라이언트가 다음과 같이 보냈다고 가정한다.
send("HELLO")
send("WORLD")
- 개발자는 서버에서 다음처럼 받을 것이라고 기대하게 된다.
recv() -> "HELLO"
recv() -> "WORLD"
- 하지만 실제로는 어떻게 받을지 확실하지 않다.
recv() -> "HELLOWORLD"
- 이렇게 한번에 받을 수도 있고, 반대로 아래처럼 쪼개져서 받을 수도 있다.
recv() -> "HE"
recv() -> "LLOWOR"
recv() -> "LD"
- 이처럼 TCP에서는 데이터가 도착했다는 것이 하나의 메시지가 완성되었음을 의미하지 않는다.
- 따라서 애플리케이션 레벨에서 메시지의 시작과 끝을 구분할 방법이 필요하다.
① 실제 테스트
- 테스트를 위해서 간단한 형태의 예제를 만들었다.
- 아래는 테스트에 사용된 서버 코드다.
// server.cpp
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#pragma comment(lib, "ws2_32.lib")
int main()
{
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(9000);
bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr));
listen(listenSock, SOMAXCONN);
std::cout << "Server listening on port 9000...\n";
SOCKET clientSock = accept(listenSock, nullptr, nullptr);
std::cout << "Client connected.\n";
char buffer[8];
while (true)
{
int received = recv(clientSock, buffer, sizeof(buffer), 0);
if (received == 0)
{
std::cout << "Client disconnected.\n";
break;
}
if (received == SOCKET_ERROR)
{
std::cout << "recv failed.\n";
break;
}
std::cout << "recv size: " << received << " / data: ";
std::cout.write(buffer, received);
std::cout << "\n";
}
closesocket(clientSock);
closesocket(listenSock);
WSACleanup();
}
// client.cpp
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#include <thread>
#include <chrono>
#pragma comment(lib, "ws2_32.lib")
int main()
{
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr));
const char* msg1 = "HELLO";
const char* msg2 = "WORLD";
const char* msg3 = "GAME";
const char* msg4 = "SERVER";
send(sock, msg1, 5, 0);
send(sock, msg2, 5, 0);
send(sock, msg3, 4, 0);
send(sock, msg4, 6, 0);
// 현재 스레드를 1초간 멈춘다.
std::this_thread::sleep_for(std::chrono::seconds(1));
closesocket(sock);
WSACleanup();
}
- 클라이언트 쪽에서는 다음 코드에 주목할 필요가 있다.
const char* msg1 = "HELLO";
const char* msg2 = "WORLD";
const char* msg3 = "GAME";
const char* msg4 = "SERVER";
send(sock, msg1, 5, 0);
send(sock, msg2, 5, 0);
send(sock, msg3, 4, 0);
send(sock, msg4, 6, 0);
- 클라이언트는 send()를 4번 호출해서, HELLO, WORLD, GAME, SERVER를 각각 전송하지만, TCP는 이 호출 단위를 메시지 경계로 보존하지 않는다.

- 위와 같이 메시지의 순서는 보장하고 있지만, 경계는 보장하고 있지 않다.
3. 서버의 수신 버퍼 크기를 맞추면 해결 되는가?
- 수신 버퍼의 크기를 맞춘다고 하더라도 메시지 경계를 보존하지 않기 때문에 해결되지 않는다.
- 수신 버퍼의 크기는 recv()가 한 번에 읽을 수 있는 최대 크기일 뿐, 메시지의 시작과 끝은 보장하지 않는다.
- 이는 Linux의 Man page나 Microsoft Winsock TCP 서버 예제에서도 확인할 수 있다.


- 이러한 이유로, 수신 측에서는 언제 하나의 메시지가 완성되는가를 직접 판단할 필요가 있다.
4. Length-Prefix로 메시지 경계 복원하기
- TCP 수신 처리는 recv()가 몇 번 호출되었는지가 아니다. 수신 버퍼에 메시지 하나를 만들 만큼의 바이트가 누적되었는지를 기준으로 해야한다.
- Length-Prefix 방식은 메시지 앞에 본문의 길이를 먼저 붙여 보내는 방식이다.
- 예를 들어 4바이트 정수로 본문 길이를 표시한다고 하면 하나의 메시지는 다음과 같은 형태가 되는 것이다.
[Length 4Byte][PayLoad]
- 수신 측은 먼저 4바이트를 읽어서 본문 길이를 알아낸다.
- 이후, 해당 길이 만큼의 데이터가 수신 버퍼에 누적될 때 까지 기다리고, 본문 길이 만큼 데이터가 모이면 하나의 메시지가 완성된 것으로 판단하는 것이다.
- 중요한 점은 recv() 한 번으로 Length와 PayLoad 부분이 모두 도착한다고 가정하면 안 된다.
① Server
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <iostream>
#include <vector>
#include <string>
#pragma comment(lib, "ws2_32.lib")
bool RecvAll(SOCKET sock, char* buffer, int size) // 지정한 size 바이트를 전부 받을때까지 반복 수신
{
int received = 0;
while (received < size)
{
int ret = recv(sock, buffer + received, size - received, 0);
// buffer + received : 저장 시작 위치
// size - received : 앞으로 더 받아야 할 바이트 수
// ret == 0 : 연결 종료, recv < 0 : 연결 에러
if (ret <= 0)
return false;
// 바이트 수 누적
received += ret;
}
return true;
}
bool SendAll(SOCKET sock, const char* buffer, int size) // 지정한 size 바이트를 전부 보낼때까지 반복 송신
{
int sent = 0;
while (sent < size)
{
int ret = send(sock, buffer + sent, size - sent, 0);
// buffer + received : 전송 시작 위치
// size - sent : 앞으로 더 보내야할 바이트 수
if (ret == SOCKET_ERROR)
return false;
// 바이트 수 누적
sent += ret;
}
return true;
}
int main(void)
{
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData); // 소켓 초기화
SOCKET listenSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); // 소켓 연결
sockaddr_in serverAddr = {};
serverAddr.sin_family = PF_INET;
serverAddr.sin_port = htons(7777);
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenSock, reinterpret_cast<sockaddr*>(&serverAddr), sizeof(serverAddr));
listen(listenSock, SOMAXCONN);
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
std::cout << "Echo Server Start Port Info : 7777\n";
SOCKET clientSock = accept(listenSock, nullptr, nullptr);
std::cout << "Client Connected. \n";
while (true)
{
// 1. 헤더 길이 수신 처리
uint32_t headLength = 0;
if (!RecvAll(clientSock, reinterpret_cast<char*>(&headLength), sizeof(headLength)))
{
break;
}
// 네트워크 바이트 오더 -> 호스트 바이트 오더로 교체 한다.
uint32_t bodyLength = ntohl(headLength);
std::cout << "Body Length : " << bodyLength << std::endl;
if (bodyLength == 0 || bodyLength > 4096)
{
std::cout << "Invalid packet size : " << bodyLength << "\n";
break;
}
// bodyLength 만큼 본문을 수신한다.
std::vector<char> body(bodyLength);
if (!RecvAll(clientSock, body.data(), bodyLength))
{
break;
}
std::string message(body.begin(), body.end());
std::cout << "Received : " << message << "\n";
// Echo 응답 보내기
// 호스트 바이트 오더 -> 네트워크 바이트 오더.
uint32_t echoLength = htonl(bodyLength);
if (!SendAll(clientSock, reinterpret_cast<char*>(&echoLength), sizeof(echoLength)))
{
break;
}
if (!SendAll(clientSock, body.data(), bodyLength))
{
break;
}
}
std::cout << "Client disconnected.\n";
closesocket(clientSock);
closesocket(listenSock);
WSACleanup(); //윈속 라이브러리 정리
return 0;
}
② Client
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <iostream>
#include <vector>
#include <string>
#pragma comment(lib, "ws2_32.lib")
#pragma warning (disable:4996)
bool RecvAll(SOCKET sock, char* buffer, int size) // 지정한 size 바이트를 전부 받을때까지 반복 수신
{
int received = 0;
while (received < size)
{
int ret = recv(sock, buffer + received, size - received, 0);
// buffer + received : 저장 시작 위치
// size - received : 앞으로 더 받아야 할 바이트 수
// ret == 0 : 연결 종료, recv < 0 : 연결 에러
if (ret <= 0)
return false;
// 바이트 수 누적
received += ret;
}
return true;
}
bool SendAll(SOCKET sock, const char* buffer, int size) // 지정한 size 바이트를 전부 보낼때까지 반복 송신
{
int sent = 0;
while (sent < size)
{
int ret = send(sock, buffer + sent, size - sent, 0);
// buffer + received : 전송 시작 위치
// size - sent : 앞으로 더 보내야할 바이트 수
if (ret == SOCKET_ERROR)
return false;
// 바이트 수 누적
sent += ret;
}
return true;
}
int main()
{
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 소켓 연결
sockaddr_in serverAddr = {};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(7777);
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(sock, reinterpret_cast<sockaddr*>(&serverAddr), sizeof(serverAddr)) == SOCKET_ERROR)
{
std::cout << "Connect Failed.\n";
closesocket(sock);
WSACleanup();
return 1;
}
std::cout << "Connected to server.\n";
while (true)
{
std::string message;
std::cout << "> ";
std::getline(std::cin, message);
if (message == "exit")
break;
uint32_t bodyLength = static_cast<uint32_t>(message.size());
uint32_t headLength = htonl(bodyLength); // 호스트 바이트 오더에서 네트워크 바이트 오더로
// 1. 길이 헤더 전송
if (!SendAll(sock, reinterpret_cast<char*>(&headLength), sizeof(headLength)))
{
break;
}
// 2. 본문 전송
if (!SendAll(sock, message.data(), bodyLength))
{
break;
}
// 3. 에코 응답 길이 헤더 수신
uint32_t echoHeadLength = 0;
if (!RecvAll(sock, reinterpret_cast<char*>(&echoHeadLength), sizeof(echoHeadLength)))
{
break;
}
uint32_t echoBodyLength = ntohl(echoHeadLength);
if (echoBodyLength == 0 || echoBodyLength > 4096)
{
std::cout << "Invalid echo size.\n";
break;
}
// 4. 에코 본문 수신
std::vector<char> echoBody(echoBodyLength);
if (!RecvAll(sock, echoBody.data(), echoBodyLength))
{
break;
}
std::string echoMessage(echoBody.begin(), echoBody.end());
std::cout << "Echo : " << echoMessage << "\n";
}
closesocket(sock);
WSACleanup();
return 0;
}
'Network > Network Programming' 카테고리의 다른 글
| [Socket] 소켓에 대한 기본적인 내용 (0) | 2026.05.05 |
|---|---|
| [Socket] Byte order에 따른 소켓 통신 시 유의할 점 (0) | 2025.06.03 |