TCP socket & Sliding Window & Endian & Setting Socket buffer size

wldbs._.·2024년 12월 18일
1

HTTP & Go

목록 보기
4/5
post-thumbnail

TCP 소켓의 입출력 버퍼
위키백과: 슬라이딩 윈도
binary package
Little Endian이란? Big Endian이란?
ChatGPT

프로젝트에서 tcp socket을 활용하여 데이터를 송수신하는 과정을 진행하고 있는다.
그런데 의문스러웠던 점이, 수신 측에서는 송신 측에서 보낼 데이터의 크기를 모른다는 건데,
어떻게 수신 측의 버퍼 사이즈를 설정해야하나 라는 점이었다.

그래서 혼자 생각해본 결과는 아래와 같다.

  • 송신 측에서 자신의 데이터 크기를 따로 알려주고, 그 크기를 기반으로 유동적으로 버퍼 사이즈 설정

매번 송신 측에서 보내는 데이터의 크기가 다를 것이기에 (같을 수도 있겠지만)
유동적으로 버퍼 사이즈를 설정해야할 것이라는 필요성을 느꼈고
수신 측에서는 송신 측을 감시할 수는 없기에

송신 측에서 이를 알려주면 수신 측에서도 이를 반영해 사이즈를 설정하면
데이터 전체를 정상적으로 수신할 수 있지 않을까 하는 생각이 들었다.

그래서 관련된 자료를 찾으며 내용을 정리해보고자 한다.


1. TCP socket

TCP 소켓의 데이터 송수신에는 경계가 없다.
송신 측에서 write() 로 여러 데이터를 보내도, 수신 측에서는 read()로 한 번에 데이터를 수신할 수 있다.
그리고, 송신 측에서 용량이 큰 데이터를 보내도, 수신 측에서는 그 데이터를 여러 번에 나누어서 수신할 수 있다.

  • 예: 송신 측에서 100bytes의 데이터를 보냈을 경우, 수신자는 20bytes씩 다섯번 나누어 수신할 수 있다.
    • 이때 20bytes의 데이터를 수신하는 동안 80bytes의 데이터는 어디에 있을까?
    • 100bytes를 모아서 한번에 수신하고 싶을 때 이 데이터들은 어디에서 모아서 수신하는 걸까?

→ TCP 소켓 생성시 입력버퍼, 출력버퍼를 둔다.

TCP는 스트림 기반 프로토콜로, 데이터를 연속적인 바이트 스트림으로 처리

  • 즉, write()를 호출할 때의 데이터 경계는 유지되지 않으며,
    수신 측에서는 데이터의 조합이나 나누어짐에 관계없이 바이트 단위로 데이터를 처리
  • 송신 측에서 여러 번 write()를 호출하여 데이터를 보낸 경우,
    수신 측에서는 그 데이터를 한 번의 read() 호출로 받거나, 여러 번에 나누어 받는 일이 발생할 수 있다.
  • 반대로 송신 측에서 한 번의 write()로 큰 데이터를 보낸 경우, 수신 측에서는 그 데이터를 여러 번 나누어 받아야 할 수도 있다.

1-1. Input/Output Buffer

write()를 호출하면 데이터는 송신 측의 출력 버퍼에 전달되고,
수신 측에서는 read()를 호출하여 자신의 입력 버퍼에서 데이터를 읽는다.

즉, 수신자가 데이터를 수신하는 시점은 송신자가 write()를 호출했을 때가 아닌, 수신자가 read()를 호출했을 때다.

이러한 입출력버퍼의 특성 몇가지를 정리해보자.

  1. 입출력 버퍼는 TCP 소켓 각각에 별도로 존재
    : 송신과 수신을 위한 출력 버퍼와 입력 버퍼가 독립적으로 생성

  2. 입출력 버퍼는 소켓 생성 시 자동으로 생성
    : 커널에서 소켓이 생성될 때 기본적으로 입출력 버퍼가 할당

  3. 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송 이루어짐
    : 송신 측에서 close()를 호출하면, 커널은 출력 버퍼에 남아있는 데이터를 정상적으로 전송한 후 연결을 종료

  4. 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸
    : 소켓이 닫히면 수신 측의 입력 버퍼에 남아있는 데이터는 커널에 의해 제거
    → 애플리케이션이 데이터를 read()하기 전에 소켓이 닫히면 해당 데이터를 읽을 수 없다.

TCP는 출력 버퍼와 입력 버퍼를 사용하여 데이터를 관리

  • write() 호출 → 데이터는 송신 측 커널의 출력 버퍼로 들어감 → 네트워크를 통해 수신 측 커널의 입력 버퍼로 전송
  • 수신 측에서 read()를 호출 → 입력 버퍼의 데이터를 애플리케이션에 전달

즉, 데이터의 전송 자체는 송신 측의 write()로 시작되지만, 수신 측 애플리케이션이 read()를 호출해야 데이터를 읽을 수 있다.


1-2. Sliding Window

그런데 위 가정을 생각해보면,
수신자 측의 입력 버퍼의 크기가 50bytes일때
송신자 측에서 100bytes를 보낼 경우
버퍼가 넘쳐서 문제가 발생한다고 생각할 수 있다!

하지만 TCP에서는 '슬라이딩 윈도우(Sliding Window)`라는 프로토콜이 존재하여 위 문제가 발생하지 않는다.

  • 송수신 간의 데이터 흐름을 제어, 송신자가 수신자의 버퍼를 초과하지 않도록 보장

위 이미지는 WireShark에서 수신자가 자신의 WIndow Size Value를 클라이언트에게 전달하는 화면이다.

  • 슬라이딩 윈도우: 수신 측은 자신의 남아있는 입력 버퍼 크기(=Window Size) 값을 송신자에게 주기적으로 알려주고,
    송신 측은 수신 측이 보고한 윈도우 크기를 참고하여, 해당 크기를 초과하지 않도록 데이터를 전송
    • 송신 측은 데이터를 전송한 후, 수신 측의 ACK 응답을 기다림
    • 수신 측의 윈도우 크기가 충분하지 않으면, 송신 측은 데이터를 전송하지 않고 기다림
      → 이러한 TCP 특성을 "흐름 제어 (Flow Control)": 송수신 간의 데이터량을 조절하여 수신자의 입력 버퍼 초과를 방지

이렇듯 수신자가 수신가능한 만큼만 데이터를 전송하므로 버퍼가 넘치는 일은 없다.

슬라이딩 윈도우와 흐름 제어 덕분에,
TCP에서는 송신 측이 수신 측의 입력 버퍼 크기를 초과해 데이터를 전송하지 않으므로 버퍼 오버플로 문제는 발생하지 않는다!

  • 만약 수신 측의 윈도우 크기가 0이 되는 상황(버퍼가 가득 참)이 발생하면,
    송신 측은 데이터 전송을 일시적으로 중단하고, 수신 측에서 윈도우 크기를 늘리기를 기다린다.

1-3. 정리

  1. TCP는 스트림 기반으로 데이터의 경계가 없으며,
    송신 측이 데이터를 어떻게 write()하든 수신 측에서는 데이터가 여러 번에 나뉘거나 합쳐져서 도착할 수 있다.

  2. TCP 소켓에는 송신 측 출력 버퍼와 수신 측 입력 버퍼가 존재하며,
    소켓이 닫힐 때 데이터의 처리 방식이 다르다.

  3. 슬라이딩 윈도우와 흐름 제어 덕분에 수신 측 버퍼가 초과하지 않도록 송신 측이 전송량을 조절하며,
    이는 TCP의 안정성을 보장하는 중요한 메커니즘이다.



2. Project Log

2024/12/18 11:35:30 Tx server sent 112731 bytes to Rx server.
2024/12/18 11:35:30 Rx server received 4096 bytes from Tx server.
2024/12/18 11:35:30 Error unmarshaling protobuf data: proto: cannot parse invalid wire-format data

위 로그는 내가 프로젝트를 진행하면서 발견한 오류 로그이다.
Rx 서버가 Tx 서버로부터 전송된 모든 데이터를 제대로 수신하지 못한 상태에서 Protocol Buffers 데이터를 언마샬링하려다 실패했음을 의미한다.


2-1. 원인 분석

  1. TCP는 스트림 기반
  • TCP는 데이터를 패킷 단위가 아닌, 스트림 단위로 처리한다. (송신된 데이터가 패킷 단위로 전송)
    → 즉, 송신 측에서 보낸 데이터가 수신 측에서 한 번에 다 도착하지 않을 수 있다.
  • Tx server sent 112731 bytes지만, Rx server received 4096 bytes인 이뉴는 TCP가 데이터를 분할하여 전송했기 때문이다.
    이 데이터는 여러 번 나누어 도착할 수 있다.
  1. 언마샬링 시점의 문제
  • Protocol Buffers 데이터는 완전한 메시지가 수신되어야만 올바르게 언마샬링할 수 있다.
  • 데이터가 일부만 도착했거나, 조합이 완료되지 않은 상태에서 언마샬링을 시도하면
    protobuf data: proto: cannot parse invalid wire-format data와 같은 에러가 발생한다.
  1. Rx 서버의 수신 로직
  • 수신 로직에서 데이터를 완전히 조합하기 전에 언마샬링을 시도했을 가능성이 존재한다.
  • TCP로 받은 데이터는 반드시 완전한 Protocol Buffers 메시지로 조립된 후에 언마샬링해야 한다.

2-2. 대책

Rx 서버가 데이터를 완전히 수신하고 언마샬링해야한다.
이를 위해서는, 데이터의 길이 정보를 포함하는 프로토콜 설계가 필요하다.

  1. Protocol Buffers 메시지 길이 정보 포함하기
  • Tx 서버(송신 측)에서 데이터를 보낼 때, 메시지의 길이 정보를 앞에 추가한다.
    → Rx 서버(수신 측)은 길이 정보를 먼저 읽어, 완전한 메시지가 도착했는지 확인한 후 언마샬링을 시도한다.
 // 2. Tx: 데이터 길이를 포함한 버퍼 생성
    var buffer bytes.Buffer
    length := uint32(len(data)) // 데이터 길이를 4바이트로 지정
    if err := binary.Write(&buffer, binary.BigEndian, length); err != nil {
        log.Fatalf("Error writing length to buffer: %v", err)
    }
 // 1. Rx: 길이(4바이트) 읽기
    var length uint32
    if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
        log.Printf("Error reading length: %v", err)
        return
    }
  1. 메시지 조립을 위한 수신 루프 설계
  • 수신 측이 데이터를 받을 때, ReadReadFull을 사용하여 설정된 길이만큼 데이터가 올 때까지 기다리도록 설계
  • io. ReadFull은 지정한 바이트만큼 데이터를 모두 읽을 때까지 기다리므로, 데이터를 조립하기에 적합
  • 데이터가 조립되지 않은 상태에서 언마샬링 시도하지 않도록 해야함
  1. TCP 메시지 분할 처리
  • TCP는 스트림 기반이므로, 수신 측에서 읽은 데이터가 정확히 한 메시지일 보장이 없다.
  • 따라서, 송신 측에서 길이를 앞에 붙이는 설계가 중요하다.
  • 길이 정보(4byte) 읽기 → 길이 정보에 따라 정확히 해당 길이만큼 데이터 읽기 → 읽은 데이터가 완전하면 언마샬링 진행

2-3. 코드 수정

기존의 코드를 일부 수정하였다.
Tx 서버가 데이터를 전송하기 전에 길이를 먼저 전송하고, Rx 서버에서 해당 길이에 맞는 크기로 데이터를 정확히 읽도록 처리했다.

tx에는 아래 코드를 추가했다.

// 데이터 길이 정보 전송 -> socket buffer size
	length := int32(len(data))
	lengthBuf := make([]byte, 4)
	binary.BigEndian.PutUint32(lengthBuf, uint32(length))
	if _, err := conn.Write(lengthBuf); err != nil {
		return fmt.Errorf("failed to send data length to Rx server: %w", err)
	}
  • 데이터를 전송하기 전에 데이터의 길이 정보를 먼저 전송하여 수신 측이 데이터를 정확히 처리할 수 있도록 함

  • dataproto.Marshal을 통해 직렬화된 바이트 배열

len(data): (송신할) 바이트 배열의 총 길이
→ 길이 정보를 저장할 바이트 배열 lengthBuf 생성
length 값을 Big Endian 방식으로 4바이트 바이트 배열(lengthBuf)에 저장
→ 생성된 바이트 배열(lengthBuf)을 소켓을 통해 수신 측으로 전송

  • length 값은 int32 타입이지만, 네트워크 전송 시에는 바이트 배열 형식으로 변환!

TX에서 데이터 전송이 두 번 이루어진다. 첫 번째는 데이터 길이를 보내고, 두 번째는 실제 데이터를 보낸다.

  • TX에서 두 번 write를 호출한다고 해서, 두 번의 데이터를 동시에 보내는 것이 아니라,
    길이 정보와 실제 데이터를 순차적으로 보낸다.
  • 따라서 RX에서는 길이를 먼저 읽고, 그 길이만큼 데이터를 읽으면 된다.
    → RX는 lengthBuf로 길이를 읽고 나서, 그 길이만큼 buf를 생성하여 데이터를 읽고, 남은 데이터가 있을 경우 계속 읽어들이면 된다!

rx는 아래와 같이 코드를 수정하였다.

// 데이터 길이 수신
	lengthBuf := make([]byte, 4)
	if _, err := conn.Read(lengthBuf); err != nil {
		log.Printf("Error reading data length from connection: %v", err)
		return
	}
	dataLength := binary.BigEndian.Uint32(lengthBuf)

	// 데이터 수신
	buf := make([]byte, dataLength)
	totalRead := 0
	for totalRead < int(dataLength) {
		n, err := conn.Read(buf[totalRead:])
		if err != nil {
			log.Printf("Error reading from connection: %v", err)
			return
		}
		totalRead += n
	}

	// 수신된 데이터 바이트 수 출력
	log.Printf("Rx server received %d bytes from Tx server.\n", totalRead)
  • tcp 소켓으로 데이터를 수신할 때, 송신자가 보낸 데이터의 정확한 길이를 먼저 읽고, 그 길이만큼 데이터를 반복적으로 수신

  • lengthBuf: 데이터를 읽을 길이를 저장할 4바이트 크기의 버퍼 생성
    → 빅엔디안 방식으로 4바이트를 정수형(uint32)으로 변환 → 수신해야 할 데이터 크기를 파악.

  • buf: 실제 데이터를 담을 버퍼. 크기는 dataLength로 설정, totalRead: 수신된 데이터 크기를 누적.
    → 읽은 데이터 크기를 totalRead에 더함 → totalReaddataLength에 도달할 때까지 반복.
    → 큰 데이터를 나눠 읽어도 끝까지 읽음.

    수신 측의 버퍼를 데이터 길이로 설정했다고 해서 Read를 반복 호출할 필요가 없는 것은 아니다.
    TCP는 데이터를 송신한 대로 한 번에 도착하지 않을 수 있기 때문에, 데이터가 완전히 수신될 때까지 반복적으로 Read를 호출해야 한다.
    이는 TCP 프로토콜의 특성에 따른 안전한 설계 방식이다.


2-4. 그외


그럼 이와 같이 전에는 오류가 나던 메서드 작업들도, 정상적으로 동작하는 것을 볼 수 있다!

profile
공부 기록용 24.08.05~ #LLM #RAG

0개의 댓글