← 기술 지식

기술 지식 / 컴퓨터 과학 / 네트워크 / 전송 계층

TCP 메시지 프레이밍

길이 헤더가 쪼개지거나 여러 메시지가 한 번에 도착하는 예를 따라가며 증분 파서와 버퍼 수명을 설계합니다.

네트워크 · 원리·구현·운영
기술 지식 트리

TCP는 신뢰할 수 있는 바이트 스트림을 제공하지만 애플리케이션 메시지의 경계를 보존하지 않습니다. 이 노트는 길이 헤더가 조각나거나 여러 프레임이 합쳐지는 실제 상태를 증분 파서와 버퍼 소유권으로 처리하고, 입력·처리·응답 완료를 분리합니다.

TCP 쓰기 호출과 읽기 경계의 비일치

채팅 프로그램이 HELLO와 OK를 두 번의 쓰기 호출로 보냈다고 하겠습니다. 수신자가 반드시 두 번 읽는 것은 아닙니다. HELLOOK를 한 번에 받을 수도 있고, HE, LLOO, K로 나누어 받을 수도 있습니다. TCP는 연결 안에서 바이트의 순서를 제공하지만, 애플리케이션의 쓰기 호출 경계는 전달하지 않습니다.

따라서 “한 번 읽었으니 메시지 하나”라고 처리하면 네트워크 상태에 따라 같은 요청이 다르게 해석됩니다. 메시지의 시작과 끝을 정하는 규칙을 양쪽 애플리케이션이 합의해야 합니다. 이를 메시지 프레이밍(framing)이라고 부릅니다. 이 노트에서는 4바이트 길이 헤더 방식 하나를 끝까지 따라갑니다. 연결 수립, 혼잡 제어, TIME_WAIT는 별도 주제입니다.

길이 헤더의 범위·단위·바이트 순서

메시지는 [4바이트 길이][본문]으로 보냅니다. 길이는 부호 없는 32비트 정수이며 네트워크 바이트 순서인 big-endian으로 기록합니다. 여기서 길이는 헤더를 제외한 본문 바이트 수입니다. 문자열 글자 수가 아닙니다. UTF-8 한글은 글자 수와 바이트 수가 다를 수 있으므로 먼저 인코딩한 뒤 길이를 계산합니다.

항목 이 예제의 규칙 합의하지 않으면 생기는 문제
길이에 포함되는 범위 본문만 헤더 4바이트만큼 다음 메시지가 밀림
길이의 단위 바이트 멀티바이트 문자가 잘림
바이트 순서 big-endian 작은 메시지를 거대한 길이로 오해
0 길이 빈 메시지로 허용 서로 다른 파서의 진행 규칙
최대 본문 예제에서는 1 MiB 외부 입력이 메모리 할당을 지배

HELLO는 00 00 00 05 뒤에 5바이트, OK는 00 00 00 02 뒤에 2바이트를 붙입니다. 수신자는 TCP가 어떻게 나누어 주든 이 규칙으로 같은 두 메시지를 복원해야 합니다.

바이트 수신과 메시지 처리는 다른 단계소켓 읽기: 매번 다른 크기의 바이트 조각. 연결별 프레임 파서: 미완성 헤더와 본문 보관, 길이 확인 후 메시지 복원. 크기가 제한된 처리 큐: 완성 메시지의 소유권 인수. 메시지 처리기: 본문 해석과 실제 상태 변경바이트 조각완성 프레임처리할 차례소켓 읽기매번 다른 크기의 바이트 조각연결별 프레임 파서미완성 헤더와 본문 보관길이 확인 후 메시지 복원크기가 제한된 처리 큐완성 메시지의 소유권 인수메시지 처리기본문 해석과 실제 상태 변경
바이트 수신과 메시지 처리는 다른 단계 화살표는 바이트 또는 완성 메시지의 전달입니다. 파서는 헤더와 본문이 모일 때까지 기다리고, 처리 큐에는 완성된 메시지만 넘깁니다.
그림을 글로 읽기
  • 소켓 읽기: 매번 다른 크기의 바이트 조각
  • 연결별 프레임 파서: 미완성 헤더와 본문 보관 · 길이 확인 후 메시지 복원
  • 크기가 제한된 처리 큐: 완성 메시지의 소유권 인수
  • 메시지 처리기: 본문 해석과 실제 상태 변경
  1. 소켓 읽기 → 연결별 프레임 파서: 바이트 조각
  2. 연결별 프레임 파서 → 크기가 제한된 처리 큐: 완성 프레임
  3. 크기가 제한된 처리 큐 → 메시지 처리기: 처리할 차례

그림에서 소켓 읽기가 끝난 것은 파싱 완료가 아니고, 파싱 완료는 실제 요청 처리 완료가 아닙니다. 이 경계를 분리하면 버퍼가 부족한지, 처리 큐가 밀리는지, 요청 실행이 느린지를 따로 측정할 수 있습니다.

세 번의 읽기와 증분 파싱 과정

아래 입력 조각은 예제를 위한 고정된 분할입니다. 실제 OS가 이 크기로 전달한다는 뜻은 아닙니다. 00 같은 표기는 16진 바이트, 글자는 ASCII 바이트를 뜻합니다.

읽기 새로 도착한 바이트 파서의 행동 남겨 둘 바이트
1 00 00 헤더 4바이트가 안 모여 기다림 00 00
2 00 05 H E 길이 5 확인, 본문은 2바이트라 기다림 길이 헤더와 H E
3 L L O 00 00 00 02 O K HELLO 복원 후 다시 반복해 OK도 복원 없음

세 번째 읽기에서 메시지 하나만 처리하고 반환하면 OK는 버퍼에 남습니다. 새로운 바이트가 더 오지 않을 수도 있으므로, 현재 버퍼에서 완성된 메시지를 모두 꺼내는 반복문이 필요합니다. 반대로 본문이 아직 모이지 않았을 때 같은 버퍼를 계속 검사하면 진행 없는 무한 반복이 되므로 반환하고 다음 입력을 기다려야 합니다.

상태를 유지하는 증분 파서

파서의 진행 상태는 최소한 “헤더 대기”, “본문 길이 확정 후 본문 대기”, “완성 프레임 배출”로 나누어 관찰합니다. 각 상태에서 입력을 다시 넣었을 때 커서가 전진했는지와 버퍼가 상한을 넘지 않았는지를 기록하면, 조각 입력에서 멈추는 문제와 프레임을 중복 배출하는 문제를 구별할 수 있습니다.

다음은 원리를 보여 주는 슈도코드입니다. append는 연결별 상한 안에서만 수행하고, 입력 조각이 상한보다 크면 조각을 나누어 파싱하거나 일시 중지하는 정책이 필요합니다. 이 단순 예제에서는 초과 연결을 닫습니다.

MAX_MESSAGE = 1 MiB
MAX_BUFFER = 2 MiB

on_bytes(data):
    if size(data) > MAX_BUFFER - available(buffer):
        reject_and_close("receive buffer limit")
        return
    append(buffer, data)

    while true:
        if available(buffer) < 4:
            return                         // 미완성 헤더
        length = decode_u32_big_endian(first_4_bytes(buffer))
        if length > MAX_MESSAGE:
            reject_and_close("message too large")
            return
        if available(buffer) - 4 < length:
            return                         // 미완성 본문
        if not processing_queue.has_capacity():
            pause_reading_and_resume_when_queue_drains()
            return                         // 이 프레임을 아직 소비하지 않음
        frame = copy_body(buffer, length)  // 독립된 소유 버퍼
        processing_queue.push(frame)
        consume_prefix(buffer, 4 + length)

길이를 검사할 때 available >= 4 + length 대신 헤더가 있는지 먼저 검사하고 뺄셈으로 비교하면, 제한되지 않은 길이 덧셈에서 생길 수 있는 정수 넘침을 피하기 쉽습니다. 실제 구현에서는 정수 타입과 변환도 확인해야 합니다.

이 슈도코드는 같은 연결의 파서와 큐 용량 확보가 직렬로 실행된다고 가정합니다. 여러 생산자가 같은 큐에 넣는 구조라면 “자리 확인”과 “삽입” 사이에 경쟁이 생기므로, 원자적 용량 예약이나 실패 가능한 삽입을 사용해야 합니다. 읽기를 멈췄다가 큐에 자리가 생기면 기존 버퍼부터 다시 파싱해야 합니다. 다음 소켓 이벤트만 기다리면 이미 완성된 메시지가 멈춰 있을 수 있습니다.

무복사 view와 버퍼 수명

위 코드는 본문을 복사해 큐에 넘기므로 파서가 원래 수신 버퍼를 재사용해도 안전합니다. 복사 비용을 줄이려고 본문 위치를 가리키는 뷰만 넘길 수도 있지만, 처리기가 읽는 동안 그 메모리를 덮어쓰거나 확장해 주소를 바꾸면 안 됩니다.

파서와 메시지 버퍼의 책임FrameParser: buffer: 수신 중 바이트, feed(bytes), drainFrames(). Message: body: 소유 바이트 배열. MessageHandler: handle(message)복사해 생성소유권 전달FrameParserbuffer: 수신 중 바이트feed(bytes)drainFrames()Messagebody: 소유 바이트 배열MessageHandlerhandle(message)
파서와 메시지 버퍼의 책임 화살표는 사용·소유 관계입니다. 여기서는 Message가 본문 복사본을 소유하므로 파서의 입력 버퍼와 수명이 분리됩니다.
그림을 글로 읽기
  • FrameParser: buffer: 수신 중 바이트 · feed(bytes) · drainFrames()
  • Message: body: 소유 바이트 배열
  • MessageHandler: handle(message)
  1. FrameParser → Message: 복사해 생성
  2. Message → MessageHandler: 소유권 전달

무복사 설계는 이 소유권을 없애는 것이 아니라, 고정된 메모리 블록의 참조 수·완료 확인 등 다른 방법으로 보장하는 설계입니다. 작은 메시지에서 복사 한 번을 줄이려고 수명 관리가 훨씬 복잡해지는지도 측정해 보고 결정하는 편이 좋습니다.

원형 버퍼에서 프레임이 끝과 처음에 걸치면 논리적으로 연속이어도 물리적으로 두 구간입니다. 하나의 연속 view를 만들려면 복사하거나, 두 span을 처리하는 소비자 계약이 필요합니다. 단순 시작 포인터와 전체 길이만 넘기면 배열 끝을 넘어 읽을 수 있습니다.

소비 커서를 전진시키는 것과 그 메모리를 새 입력으로 덮어도 되는 시점도 다르므로, view를 가진 소비자가 끝날 때까지 해당 블록을 고정하거나 소유 복사본으로 넘깁니다. 확장 시 옛 배열 주소가 바뀌면 기존 view가 무효화될 수 있어 새 배열 공개와 옛 독자 종료를 분리해야 합니다.

EOF 시 잔여 바이트와 잘린 프레임

읽기 API가 정상 EOF를 알렸는데 버퍼에 헤더 일부나 본문 일부가 남아 있다면 이 프로토콜에서는 잘린 메시지입니다. TCP 연결 종료를 본문 완료로 대신 해석하면 안 됩니다. 버퍼가 비어 있는 정상 종료와, 완성되지 않은 요청이 있는 종료를 로그와 복구 정책에서 구분합니다.

송신도 한 번의 호출로 모든 바이트를 넘겼다고 가정하지 않습니다. API가 일부만 처리했다고 보고하면 나머지 범위를 이어 보내고, 같은 연결에 쓰는 여러 작업은 프레임 바이트가 섞이지 않도록 직렬화합니다. 로컬 송신 완료나 TCP ACK는 상대 애플리케이션의 처리 커밋을 뜻하지 않습니다. 처리 성공을 알아야 하는 프로토콜에는 요청 ID와 애플리케이션 응답이 필요합니다.

입력 분할과 복원 메시지 불변식

실무 선택에서는 복사 기반 프레임을 먼저 사용해 소유권 계약을 단순하게 만들고, 측정된 복사 비용이 병목일 때만 view·span으로 바꿉니다. 변경 뒤에도 같은 분할 입력의 메시지 목록, EOF 오류 종류, 큐 포화 후 재개 결과가 유지되어야 하며, 속도 향상만으로 프레이밍 정확성의 회귀를 허용하지 않습니다.

검사의 기준은 읽기 횟수가 아니라 복원한 메시지 목록입니다. HELLO, OK를 인코딩한 같은 바이트열을 한 번에, 한 바이트씩, 가능한 각 경계에서 두 조각으로 나누어 입력합니다. 모두 정확히 [HELLO, OK]가 나와야 합니다.

입력 조건 기대 결과
빈 본문 두 개 연속 빈 메시지 두 개, 무한 반복 없음
헤더 3바이트 뒤 EOF 잘린 헤더 오류
본문 길이 5인데 4바이트 뒤 EOF 잘린 본문 오류
최대 길이를 넘는 헤더만 입력 본문 할당 전에 거절
처리 큐가 꽉 찼다가 비워짐 같은 프레임을 한 번만 전달하고 진행 재개

실제 네트워크 부하 시험에 앞서 파서를 순수 바이트 입력 함수로 시험하면 오류를 재현하기 쉽습니다. 큰 정상 메시지도 연결별 상한과 전체 연결 수를 곱하면 메모리 부담이 되므로, 최대 메시지 크기뿐 아니라 전체 수신 버퍼와 처리 큐의 한도를 함께 정해야 합니다.

후속·관련 개념

이 개념의 확장

함께 읽을 문서

전체 기술 지식
선택 연습 · 관련 질문 2개

질문은 이해를 확인하는 보조 자료입니다. 학습 경로와 문서의 범위는 질문 수와 독립적입니다.