I'm FanJae.

[Network Programming] TCP에서 Length-Prefix 처리가 필요한 이유 본문

Network/Network Programming

[Network Programming] TCP에서 Length-Prefix 처리가 필요한 이유

FanJae 2026. 5. 28. 00:13

1. 시작에 앞서 (회상)

- 학부 시절, 컴퓨터 통신이라는 과목을 수강한 적이 있었다. 이름만 보면 네트워크 이론을 학습할 것 같은 과목이지만, 실제로는 네트워크 프로그래밍을 배우고, 팀 프로젝트를 진행하는 과목이었다.

- 당시에는 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;
}

 

Comments