본문 바로가기
개발/트러블슈팅

TCP recv 한 번에 메시지가 다 올까? 길이 접두사와 분할 수신 실습

by char_lie 2026. 10. 29.
반응형

TCP에서 recv(1024)를 한 번 호출했다고 메시지 하나를 전부 읽은 것은 아닙니다. TCP는 순서 있는 바이트 스트림을 제공하므로 송신 호출과 수신 호출의 경계가 대응하지 않습니다. 메시지 길이를 먼저 읽고, 그 길이만큼 본문을 반복 수신하는 프레이밍 규칙이 필요합니다.

이번 실습은 4바이트 길이 접두사와 UTF-8 본문을 사용합니다. 헤더가 쪼개지거나 두 메시지가 연속으로 도착해도 같은 두 문자열을 복원합니다. 중간 종료와 길이 상한 초과는 오류로 남깁니다.

원래 메시지 경계와 다르게 나뉘어 들어오는 바이트 스트림 개념 그림

원래 메시지 경계와 다르게 나뉘어 들어오는 바이트 스트림 개념 그림 · 실제 화면이나 수치 그래프가 아닌 개념 그림입니다.

1024는 메시지 길이가 아니라 이번에 받을 최대 바이트 수입니다

recv의 반환 길이는 요청한 크기보다 작을 수 있습니다. 두 번 send한 내용을 한 번에 받을 수도 있습니다. 버퍼 크기를 크게 늘리거나 잠깐 sleep하는 방식은 메시지 경계 규칙을 대신하지 못합니다. sendall도 수신 측의 한 번 읽기나 업무 처리 완료를 보장하지 않습니다.

본문은 먼저 UTF-8 바이트로 바꾼 뒤 길이를 계산합니다. “안녕”은 글자 두 개지만 이 인코딩에서는 6바이트입니다. 문자열 길이를 헤더에 쓰면 멀티바이트 문자에서 다음 메시지의 경계가 어긋날 수 있습니다.

헤더 4바이트, 본문 n바이트를 각각 완성합니다

struct의 !I는 네트워크 바이트 순서의 4바이트 부호 없는 정수입니다. 양쪽이 같은 계약을 사용해야 합니다. 이 예제는 본문 상한을 1024바이트로 정해 헤더 검사 직후 지나치게 큰 프레임을 거부합니다. 이 상한은 실습 정책이며 TCP의 고정 제한이 아닙니다.

헤더와 본문을 필요한 바이트 수까지 여러 조각으로 모으는 개념 그림

헤더와 본문을 필요한 바이트 수까지 여러 조각으로 모으는 개념 그림 · 실제 화면이나 수치 그래프가 아닌 개념 그림입니다.

Python 3.14.7 표준 라이브러리만 사용합니다. tcp_frame_demo.py로 저장해 python3 tcp_frame_demo.py로 실행하세요. Fragmented는 분할 상황을 일정하게 만드는 테스트 대역이며 실제 패킷 수를 흉내 낸 측정값은 아닙니다. 마지막 부분은 127.0.0.1의 실제 TCP 소켓으로 같은 파서를 검사합니다. 외부 서버에는 접속하지 않습니다.

import socket
import struct
import threading

MAX_BODY = 1024

def recv_exact(sock, length):
    result = bytearray()
    while len(result) < length:
        chunk = sock.recv(length - len(result))
        if not chunk:
            raise EOFError("incomplete frame")
        result.extend(chunk)
    return bytes(result)

def recv_message(sock):
    length = struct.unpack("!I", recv_exact(sock, 4))[0]
    if length > MAX_BODY:
        raise ValueError("frame too large")
    return recv_exact(sock, length)

def frame(text):
    body = text.encode("utf-8")
    if len(body) > MAX_BODY:
        raise ValueError("frame too large")
    return struct.pack("!I", len(body)) + body

# recv() is allowed to return fewer bytes: deterministic test double.
class Fragmented:
    def __init__(self, data, step):
        self.data, self.step = data, step
    def recv(self, size):
        n = min(size, self.step, len(self.data))
        result, self.data = self.data[:n], self.data[n:]
        return result

wire = frame("안녕") + frame("OK")
for step in (1, 3, len(wire)):
    receiver = Fragmented(wire, step)
    answers = [recv_message(receiver).decode("utf-8") for _ in range(2)]
    assert answers == ["안녕", "OK"]
    print("fragment =", step, "messages =", answers)

try:
    recv_message(Fragmented(frame("OK")[:-1], 1))
except EOFError as error:
    print(type(error).__name__, str(error))
try:
    recv_message(Fragmented(struct.pack("!I", MAX_BODY + 1), 1))
except ValueError as error:
    print(type(error).__name__, str(error))

# Real TCP on loopback only. recv boundaries are intentionally not asserted.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
    listener.bind(("127.0.0.1", 0))
    listener.listen(1)
    listener.settimeout(2)
    def send_two():
        with socket.create_connection(listener.getsockname(), timeout=2) as sender:
            sender.sendall(wire)
    worker = threading.Thread(target=send_two)
    worker.start()
    with listener.accept()[0] as receiver:
        receiver.settimeout(2)
        result = [recv_message(receiver).decode("utf-8") for _ in range(2)]
    worker.join()
assert result == ["안녕", "OK"]
print("loopback TCP =", result)

실제 결과: 분할 방법과 관계없이 같은 두 메시지

fragment = 1 messages = ['안녕', 'OK']
fragment = 3 messages = ['안녕', 'OK']
fragment = 16 messages = ['안녕', 'OK']
EOFError incomplete frame
ValueError frame too large
loopback TCP = ['안녕', 'OK']

1·3·16은 테스트 대역이 한 번에 반환하도록 허용한 최대 조각 길이입니다. 실제 TCP의 패킷 크기나 횟수라는 뜻이 아닙니다. 마지막 loopback 결과는 실제 로컬 TCP 전달을 확인한 것이며, 운영체제가 recv를 몇 번으로 나누는지는 검증 조건에 넣지 않았습니다.

recv_exact는 아직 필요한 크기만 요청하므로 첫 프레임을 읽다가 다음 프레임의 바이트를 소비하지 않습니다. 빈 본문은 헤더 길이 0으로 표현되며 recv_exact(0)는 바로 빈 바이트를 반환합니다. recv가 빈 바이트를 반환한 시점에 아직 필요한 데이터가 있으면 불완전 프레임으로 거부합니다.

중간에 끊긴 프레임과 길이 상한을 넘는 프레임을 구분해 거부하는 개념 그림

중간에 끊긴 프레임과 길이 상한을 넘는 프레임을 구분해 거부하는 개념 그림 · 실제 화면이나 수치 그래프가 아닌 개념 그림입니다.

프로토콜 경계와 연결 장애 처리를 나눕니다

본문 중간 종료를 정상 메시지로 처리하면 잘린 JSON이나 파일 조각을 정상 입력으로 넘길 수 있습니다. 반대로 메시지 사이의 정상 EOF를 지원하려면 “새 헤더를 읽기 전 종료”와 “일부 헤더·본문 뒤 종료”를 따로 정의해야 합니다. 이 작은 예제는 예정한 두 프레임을 읽는 계약이므로 부족하면 모두 오류입니다.

실제 서비스에는 연결·읽기 타임아웃, 전체 프레임의 총 기한, 최대 누적 버퍼, 인증·TLS와 잘못된 UTF-8 처리 정책도 필요합니다. 여기서 소켓의 2초 타임아웃은 테스트가 무기한 멈추는 것을 막는 장치이며 전체 업무의 기한을 보장하는 구현은 아닙니다. 느리게 조금씩 보내는 상대에 대한 총 기한도 별도로 설계합니다.

여러 스레드가 같은 소켓에서 동시에 프레임을 읽거나 헤더·본문을 따로 쓰면 서로 섞일 수 있습니다. 연결별 읽기 소유자와 쓰기 직렬화 기준을 정하세요. HTTP나 WebSocket을 쓴다면 이미 정의된 프레이밍을 라이브러리에 맡기고 이 예제를 그 프로토콜 파서로 그대로 사용하지 않습니다.

바로 남길 결과물

프레이밍 계약에는 헤더 크기·바이트 순서·바이트 길이·최대 크기·빈 본문·중간 EOF·시간 제한을 적습니다. 정상·헤더 분할·본문 분할·두 프레임 연속·중간 종료·과대 길이 사례의 결과를 함께 보관하세요.

이 실습의 배경은 TCP·UDP와 소켓 흐름, 계층별 위치는 네트워크·프로토콜 정리에서 이어 볼 수 있습니다.

TCP의 순서 보장과 응용 메시지의 경계는 다른 문제입니다. 필요한 길이를 누적해서 읽고, 길이 상한과 실패 계약을 함께 검증하세요.

반응형

댓글