TCP 소켓의 입출력 버퍼
위키백과: 슬라이딩 윈도
binary package
Little Endian이란? Big Endian이란?
ChatGPT
프로젝트에서 tcp socket을 활용하여 데이터를 송수신하는 과정을 진행하고 있는다.
그런데 의문스러웠던 점이, 수신 측에서는 송신 측에서 보낼 데이터의 크기를 모른다는 건데,
어떻게 수신 측의 버퍼 사이즈를 설정해야하나 라는 점이었다.
그래서 혼자 생각해본 결과는 아래와 같다.
매번 송신 측에서 보내는 데이터의 크기가 다를 것이기에 (같을 수도 있겠지만)
유동적으로 버퍼 사이즈를 설정해야할 것이라는 필요성을 느꼈고
수신 측에서는 송신 측을 감시할 수는 없기에
송신 측에서 이를 알려주면 수신 측에서도 이를 반영해 사이즈를 설정하면
데이터 전체를 정상적으로 수신할 수 있지 않을까 하는 생각이 들었다.
그래서 관련된 자료를 찾으며 내용을 정리해보고자 한다.
TCP 소켓의 데이터 송수신에는 경계가 없다.
송신 측에서 write()
로 여러 데이터를 보내도, 수신 측에서는 read()
로 한 번에 데이터를 수신할 수 있다.
그리고, 송신 측에서 용량이 큰 데이터를 보내도, 수신 측에서는 그 데이터를 여러 번에 나누어서 수신할 수 있다.
→ TCP 소켓 생성시 입력버퍼, 출력버퍼를 둔다.
TCP는 스트림 기반 프로토콜로, 데이터를 연속적인 바이트 스트림으로 처리
- 즉,
write()
를 호출할 때의 데이터 경계는 유지되지 않으며,
수신 측에서는 데이터의 조합이나 나누어짐에 관계없이 바이트 단위로 데이터를 처리- 송신 측에서 여러 번
write()
를 호출하여 데이터를 보낸 경우,
수신 측에서는 그 데이터를 한 번의read()
호출로 받거나, 여러 번에 나누어 받는 일이 발생할 수 있다.- 반대로 송신 측에서 한 번의
write()
로 큰 데이터를 보낸 경우, 수신 측에서는 그 데이터를 여러 번 나누어 받아야 할 수도 있다.
write()
를 호출하면 데이터는 송신 측의 출력 버퍼에 전달되고,
수신 측에서는 read()
를 호출하여 자신의 입력 버퍼에서 데이터를 읽는다.
즉, 수신자가 데이터를 수신하는 시점은 송신자가 write()
를 호출했을 때가 아닌, 수신자가 read()
를 호출했을 때다.
이러한 입출력버퍼의 특성 몇가지를 정리해보자.
입출력 버퍼는 TCP 소켓 각각에 별도로 존재
: 송신과 수신을 위한 출력 버퍼와 입력 버퍼가 독립적으로 생성
입출력 버퍼는 소켓 생성 시 자동으로 생성
: 커널에서 소켓이 생성될 때 기본적으로 입출력 버퍼가 할당
소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송 이루어짐
: 송신 측에서 close()
를 호출하면, 커널은 출력 버퍼에 남아있는 데이터를 정상적으로 전송한 후 연결을 종료
소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸
: 소켓이 닫히면 수신 측의 입력 버퍼에 남아있는 데이터는 커널에 의해 제거
→ 애플리케이션이 데이터를 read()
하기 전에 소켓이 닫히면 해당 데이터를 읽을 수 없다.
TCP는 출력 버퍼와 입력 버퍼를 사용하여 데이터를 관리
write()
호출 → 데이터는 송신 측 커널의 출력 버퍼로 들어감 → 네트워크를 통해 수신 측 커널의 입력 버퍼로 전송- 수신 측에서
read()
를 호출 → 입력 버퍼의 데이터를 애플리케이션에 전달
즉, 데이터의 전송 자체는 송신 측의
write()
로 시작되지만, 수신 측 애플리케이션이read()
를 호출해야 데이터를 읽을 수 있다.
그런데 위 가정을 생각해보면,
수신자 측의 입력 버퍼의 크기가 50bytes일때
송신자 측에서 100bytes를 보낼 경우
버퍼가 넘쳐서 문제가 발생한다고 생각할 수 있다!
하지만 TCP에서는 '슬라이딩 윈도우(Sliding Window)`라는 프로토콜이 존재하여 위 문제가 발생하지 않는다.
위 이미지는 WireShark에서 수신자가 자신의 WIndow Size Value를 클라이언트에게 전달하는 화면이다.
이렇듯 수신자가 수신가능한 만큼만 데이터를 전송하므로 버퍼가 넘치는 일은 없다.
슬라이딩 윈도우와 흐름 제어 덕분에,
TCP에서는 송신 측이 수신 측의 입력 버퍼 크기를 초과해 데이터를 전송하지 않으므로 버퍼 오버플로 문제는 발생하지 않는다!
- 만약 수신 측의 윈도우 크기가 0이 되는 상황(버퍼가 가득 참)이 발생하면,
송신 측은 데이터 전송을 일시적으로 중단하고, 수신 측에서 윈도우 크기를 늘리기를 기다린다.
TCP는 스트림 기반으로 데이터의 경계가 없으며,
송신 측이 데이터를 어떻게 write()
하든 수신 측에서는 데이터가 여러 번에 나뉘거나 합쳐져서 도착할 수 있다.
TCP 소켓에는 송신 측 출력 버퍼와 수신 측 입력 버퍼가 존재하며,
소켓이 닫힐 때 데이터의 처리 방식이 다르다.
슬라이딩 윈도우와 흐름 제어 덕분에 수신 측 버퍼가 초과하지 않도록 송신 측이 전송량을 조절하며,
이는 TCP의 안정성을 보장하는 중요한 메커니즘이다.
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 데이터를 언마샬링하려다 실패했음을 의미한다.
Tx server sent 112731 bytes
지만, Rx server received 4096 bytes
인 이뉴는 TCP가 데이터를 분할하여 전송했기 때문이다.protobuf data: proto: cannot parse invalid wire-format data
와 같은 에러가 발생한다.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
}
Read
나 ReadFull
을 사용하여 설정된 길이만큼 데이터가 올 때까지 기다리도록 설계io. ReadFull
은 지정한 바이트만큼 데이터를 모두 읽을 때까지 기다리므로, 데이터를 조립하기에 적합기존의 코드를 일부 수정하였다.
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)
}
데이터를 전송하기 전에 데이터의 길이 정보를 먼저 전송하여 수신 측이 데이터를 정확히 처리할 수 있도록 함
data
는 proto.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
에 더함 → totalRead
가 dataLength
에 도달할 때까지 반복.
→ 큰 데이터를 나눠 읽어도 끝까지 읽음.
수신 측의 버퍼를 데이터 길이로 설정했다고 해서 Read를 반복 호출할 필요가 없는 것은 아니다.
TCP는 데이터를 송신한 대로 한 번에 도착하지 않을 수 있기 때문에, 데이터가 완전히 수신될 때까지 반복적으로 Read를 호출해야 한다.
이는 TCP 프로토콜의 특성에 따른 안전한 설계 방식이다.
그럼 이와 같이 전에는 오류가 나던 메서드 작업들도, 정상적으로 동작하는 것을 볼 수 있다!