TCP 택배가 잘려 도착했다 · 사라진 메시지 경계를 찾아라 · 이론
사라진 메시지 경계를 찾아라
한 줄 요약
TCP는 바이트 순서를 지키지만 여러분이 정한 메시지의 경계까지 운반하지는 않습니다.
왜 이게 필요했나
택배 서버에 “고양이 간식”과 “충전 케이블” 두 주문을 보냈는데, 하나는 반만 보이고 둘은 붙어서 보입니다. 네트워크가 바이트를 잃었다고 단정하기 쉽습니다. 그러나 송신 측의 send 두 번과 수신 측의 recv 두 번은 일대일 대응이 아닙니다. 운영체제 버퍼의 그 순간 상태에 따라 같은 데이터가 다른 덩어리로 전달될 수 있습니다. 짧은 로컬 시험에서 우연히 한 번에 받았다는 경험은 메시지 계약의 증거가 아닙니다.
이 코스의 택배는 교육용 바이트 메시지입니다. 실제 주문·결제 시스템이나 인증된 서비스가 아니며 바깥 네트워크를 열 필요도 없습니다. 우리가 만들 것은 길이 헤더가 있는 프레임과 그 프레임을 되살리는 작은 프로그램입니다. 문자열·클래스·반복문·예외를 아는 Python 학습자에게 맞습니다.
어떻게 동작하나
봉투 앞의 4바이트가 본문 길이를 unsigned big-endian으로 표시합니다. b"cat"은 00 00 00 03 다음에 63 61 74가 옵니다. 전체 프레임은 7바이트지만 헤더 값은 3입니다. 한국어 문자열은 먼저 UTF-8로 바꾼 뒤 len을 계산합니다. 문자가 2개라는 것과 전송할 바이트가 2개라는 것은 같지 않습니다. 수신 파서는 문자를 너무 일찍 해석하지 않고 bytes를 보존합니다. UTF-8 문자 한가운데에서 chunk가 끊겨도 전체 본문을 모은 뒤 해석하면 됩니다.
Decoder는 세 가지 질문을 반복합니다. 헤더 4바이트가 있는가? 그 헤더가 요구한 본문까지 있는가? 완성본을 꺼낸 뒤 다음 프레임도 있는가? 첫 질문이 아니면 잔여를 보관하고 돌아갑니다. 둘째도 마찬가지입니다. 셋째에서는 한 번만 검사하는 if가 아니라 반복이 필요합니다. 한 chunk에 완성 프레임 둘과 세 번째 헤더 절반이 있을 수도 있습니다.
feed 1: [길이 헤더 앞 2바이트] → []feed 2: [헤더 뒤 2바이트][본문 앞부분] → []feed 3: [나머지 본문][다음 프레임 전체] → [본문1, 본문2]빈 목록은 아직 완성된 메시지가 없다는 뜻입니다. [b""]는 길이가 0인 메시지 하나를 완성했다는 뜻입니다. 이 둘을 같은 것으로 처리하면 하트비트 같은 빈 메시지를 잃습니다. feed(b"")도 “이번 입력 없음”일 뿐 연결 종료로 정의하지 않습니다. 네트워크에서 recv가 b""를 반환했을 때 비로소 EOF를 안 것이고, 그 사건을 finish로 전달합니다.
헤더가 절반 남은 채 연결이 끝났다면 다음 주문이 없는 정상 종료가 아닙니다. 상대가 한 주문을 시작하고 완성하지 못했습니다. 본문 8바이트를 약속하고 5바이트만 보낸 경우도 같습니다. 잔여가 없는 종료와 미완성 종료를 구분하면 애플리케이션이 불완전한 주문을 처리하는 일을 막습니다. 다만 프로토콜 파싱의 성공이 주문 자체의 유효성 검증을 대신하지는 않습니다.
파서는 연결마다 하나입니다. 서로 다른 클라이언트의 잔여를 전역 buffer에 모으면 A의 헤더 뒤에 B의 본문이 붙습니다. 파일의 전역 변수보다 인스턴스의 속성으로 두는 이유입니다. 반대로 같은 연결에서 매번 새 인스턴스를 만들면 아직 완성되지 않은 헤더가 매 호출마다 사라집니다. 상태를 어디에 두느냐가 곧 데이터의 소유권입니다.
현장에서 만나는 모습
RPC·장비 통신·게임 서버는 서로 다른 프레임 형식을 쓰지만 “일부만 받았다”는 문제는 공유합니다. HTTP와 WebSocket은 이미 각자의 프레이밍 규칙을 가지므로 이 코스의 4바이트 헤더를 무조건 덧붙여야 하는 것은 아닙니다. 라이브러리가 메시지 단위 API를 준다면 어느 계층이 경계를 처리하는지 먼저 확인하세요. TCP에 신뢰성이 있다는 이유로 애플리케이션 메시지 경계까지 자동으로 생긴다고 생각하는 것이 함정입니다.
길이 1인 A와 길이 2인 BC를 연결한 바이트열은 00 00 00 01 41 00 00 00 02 42 43입니다. 첫 feed에 앞 6바이트만 주면 A 하나를 반환하고 다음 헤더의 첫 바이트 00을 보관합니다. 뒤 5바이트를 주면 남은 헤더 3바이트와 본문 BC가 완성됩니다. 같은 바이트열을 한 번에 주면 [b"A", b"BC"]여야 합니다. 나누는 위치만 바꿨는데 결과가 달라지면 네트워크보다 파서의 상태 전이를 먼저 의심하세요.
연결 A와 B의 Decoder 두 개를 만들고 A에는 헤더 앞부분만, B에는 완전한 프레임을 공급하는 시험도 유용합니다. B의 완료가 A의 잔여를 바꾸면 상태가 공유되고 있는 것입니다. 이런 교차 입력은 동시 실행이 없어도 소유권 오류를 드러냅니다. 시험의 목적은 운영체제 스케줄러의 우연을 재현하는 것이 아니라 코드가 허용한 입력 순서를 체계적으로 확인하는 것입니다.
이 교육용 구현은 bytearray 앞부분을 지우는 단순한 방식입니다. 큰 트래픽에서는 복사와 이동 비용이 중요해져 읽기 커서·링 버퍼 같은 대안을 검토할 수 있습니다. 먼저 정확한 메시지 복원을 증명하고 그다음 프로파일링으로 최적화하세요. 길이 상한 하나가 전체 연결 수나 한 번에 공급되는 chunk의 크기까지 제한하지는 않는다는 점도 구분해야 합니다.
다음 확인에서 할 것
바로 뒤 퀴즈에서 경계·빈 메시지·EOF를 구분합니다. 마지막 모듈의 실습에서는 한 바이트씩 끊어 넣는 결정적 시험과 여러 프레임을 묶어 넣는 시험을 통과시킵니다. 실제 TCP에서 특정 분할이 우연히 나타나기를 기다리지 않습니다. 같은 원문이라면 어떻게 나눠 공급하든 복원한 메시지 목록이 같아야 합니다.