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의 순서 보장과 응용 메시지의 경계는 다른 문제입니다. 필요한 길이를 누적해서 읽고, 길이 상한과 실패 계약을 함께 검증하세요.
'개발 > 트러블슈팅' 카테고리의 다른 글
| AI가 만든 문의 처리 코드 검수: 빈 입력·실패·모의 성공 (0) | 2026.10.31 |
|---|---|
| Codex 스킬 실습: 실행 근거와 결과물 검증을 따로 남기기 (0) | 2026.10.30 |
| AI 텍스트 분류 확률이 모두 0일 때: 나이브베이즈 로그 점수로 비교하기 (0) | 2026.10.27 |
| RAG 답변 캐시가 적중해도 다시 검사할 것: 인용 문서의 권한 회수 (0) | 2026.10.26 |
| 두 사람이 수정한 내용이 덮일 때: HTTP ETag·If-Match와 412 실습 (0) | 2026.10.26 |
댓글