목표
메모리를 다루지 않는 프로그래밍 언어는 없습니다. 단지 C/C++처럼 그걸 명시적인 포인터로 노출하지 않을 뿐입니다. 그리고 그 언어들이 제공하는 것은 단지 "메모리 할당/해제" 일 뿐 메모리 복사에 대한 의사 결정은 해주지 않습니다. 즉, 불필요한 메모리 복사인지 아닌지. 복사를 감수할 정도인지 아닌지는 여전히 프로그래머가 결정해야 되고, 이때 메모리 구조에 대한 이해 없이는 불가능합니다.
이 글에서는 Socket Programming 에서 메모리 관리에 대해 알아보겠습니다.
sendto() 함수의 동작
Q) 지금까지 문자열 전송에 대해서만 다뤘는데 정수는 어떻게 보내야 하나요?
sendto 함수의 인자는 다음과 같습니다.
int numBytes = sendto(s, buf.c_str(), buf.length(), 0,
(struct sockaddr *) &sin, sizeof(sin));
sendto(소켓 디스크립터, 보낼 데이터의 메모리 시작 주소, 보낼 데이터 길이, 플래그(보통 0),
struct sockaddr * 로 강제 형변환 된 상대방 IP/Port의 메모리 주소, sizeof(sin))
위 그림은 sendto(s, X, 11, 0, (struct sockaddr *) &sin, sizeof(sin)); 의 메모리 할당을 나타낸 것입니다.
sendto()는 위 그림처럼 보낼 데이터의 메모리 주소와 길이를 넘겨 주면 여기에 ①UDP Header ②IP Header ③Ethernet Header를 붙여서 랜선 상의 전기 신호 혹은 무선 주파수 신호로 보내는 것입니다.
Ethernet Frame Header에는 DST MAC, SRC MAC 이 포함됩니다.
sendto() 함수가 전송하는 대상
전송할 데이터의 메모리 주소와 길이를 넘겨주면 됩니다.
처음에 질문을 던졌던 정수 전송 관련해서 답변하겠습니다.
A) 결론적으로 정수가 저장된 메모리 주소와 길이를 넘겨 주면 됩니다.
- 이 말은 정수 숫자를 바로 넘기는 것은 안됩니다.
- 정수를 변수에 저장하고, 그 변수의 주소를 넘겨야 된다는 뜻이 됩니다.
정수값을 전달하는 코드
(오류 있는 버전)
앞에서 정수를 변수에 저장하고, 변수의 주소값을 넘기면 된다고 했었습니다.
따라서 아래와 같이 정수를 전달해보겠습니다.
int var = 0x12345678;
sendto(s, &var, sizeof(var), 0, (struct sockaddr *) &sin, sizeof(sin));
Q) 그런데 이 코드에는 문제가 있습니다. 어떤 문제일까요?
위 코드는 Intel CPU에서 메모리 구조를 그려보면 다음과 같습니다.
따라서 Socket API 가 각 레이어별로 헤더를 채워주면 다음과 같습니다.
이는 Network Byte Order 인 Big Endian 이 아니라 Little Endian입니다. 이는 Little Endian을 쓰는 Intel CPU가 저장한 메모리를 그대로 보냈으니 생기는 문제입니다.
Bing Endian과 Little Endian에 대한 설명은
2023.10.13 - [CS] - [Network] Socket Programming (TCP, UDP)
에서 확인할 수 있습니다.
(제대로 된 버전)
코드를 수정해서 이를 해결해 보겠습니다.
int var = htonl(0x12345678);
sendto(s, &var, sizeof(var), 0, (struct sockaddr *) &sin, sizeof(sin));
이제 메모리 구조를 다시 그려보겠습니다.
따라서 Socket API 가 각 레이어별로 헤더를 채워주면 다음처럼 Network Byte Order 인 Big Endian으로 잘 전송됩니다.
숫자로 보낼 때 VS 문자열로 보낼 때
앞의 예들은 정수를 전송할 때 htonl() 혹은 htons()를 해야 됨을 보여줍니다.
그러나 정수를 숫자가 아니라 문자열 형태로 보낼 때에는 해당 사항이 없습니다.
- 예: 정수 1000을 숫자로 보내기 위해서는 htonl(1000) 필요합니다. 그러나 "1000"이라는 문자열을 보낼 때에는
1, 0, 0, 0이 각각 한 바이트 문자이기 때문에 endian 문제가 생기지 않습니다. - 이는 우리가 JSON 메시지를 다룰 때 숫자에 대해서 endian을 고려하지 않아도 된다는 의미가 됩니다.
- JSON 메시지는 모든 것을 문자열로 변환해서 처리합니다.
- 대신 그에 대한 trade-off로 길이를 희생합니다.
예: 정수 65535는 숫자로 보내면 2바이트만 필요하지만, 문자열 "65535"로 전송하게 되면 5바이트가 필요합니다.
여러 데이터를 보내야 할 때
(오류 있는 버전)
Q) 그렇다면 "Hello World"와 0x12345678 을 같이 보내려면 어떻게 해야 할까요?
아래 코드처럼 하면 전송됩니다.
const char *str = "Hello World"
int var = htonl(0x12345678);
sendto(s, str, strlen(str), 0, (struct sockaddr *) &sin, sizeof(sin));
sendto(s, &var, sizeof(var), 0, (struct sockaddr *) &sin, sizeof(sin));
Q) 그러나 위의 코드도 문제가 있습니다. 무엇일까요?
각 sendto()는 한 개의 UDP datagram을 생성합니다.
따라서 다음 코드는 "Hello World" 와 0x12345678을 같이 보내는 것이 아니라 2개의 UDP datagram을 보내게 됩니다.
Q) 두 개의 UDP Datagram으로 가는 것이 무엇이 문제인가요?
문제점 #1: 필요한 헤더 오버헤드로 인한 더 많은 트래픽이 사용됩니다.
- ETH, IP, UDP 헤더가 불필요하게 한 번씩 더 발생했습니다.
- 전송 데이터가 크다면 헤더에 의한 낭비는 무시할 수 있을지도 모릅니다.
하지만 위와 같은 경우 적은 데이터를 보내는데 훨씬 더 큰 헤더로 트래픽을 낭비합니다. - 참고: 클라우드 비용 중 제일 큰 요소 2개는 ①가상 서버, ②트래픽입니다.
문제점 #2: 두 데이터 중 하나만 유실되는 상황이 발생할 수 있습니다.
- UDP는 전송 보장을 하지 않습니다.
- 2개의 datagram 은 독립적으로 전송이 될 수도 있고 안될 수도 있습니다.
- 만일 2개의 데이터가 항상 같이 쓰여야 하는데 하나가 유실된다면,
수신 측에서는 유실된 데이터를 계속 기다리는 상황이 발생할 수 있습니다.
(제대로 된 버전)
길이 제한을 넘지 않는다면 하나의 sendto()로 전송하는 것이 바람직합니다.
(참고: UDP 최대 길이 = 65535 - 8(=UDP 헤더 길이) - 20(=IP 헤더 최소 길이))
Q) 그럼 어떻게 하면 될까요?
A) sendto()는 전송할 데이터의 메모리 주소와 길이를 전달하면 됩니다.
즉, 거기에 뭐가 있든 그 주소에서부터 주어진 길이만큼을 그대로 전송합니다.
다시 말해 그 주소에 뭐가 있든지, 그게 한 종류의 데이터인지, 여러 데이터라면 그것들이 같은 데이터 타입인지 등등을 전혀 신경 쓰지 않습니다.
const char *str = "Hello World"
int var = 0x12345678;
char buf[1024];
int offset = 0; // buf 내에서 index 같은 역할
strcpy(buf + offset, str); // 참고: offset += sprinf(buf + offset, "%s", str) 동일
offset += strlen(str); // "Hello World"의 길이를 offset에 더해 다음에 추가할 데이터 위치 업데이트
*(int *)(buf + offset) = htonl(var);
offset += 4;
sendto(s, buf, offset, 0, (struct sockaddr *) &sin, sizeof(sin));
위 코드는 "Hello World" 문자열과 'var' 값을 'buf'에 저장한 후, 이를 소켓을 통해 전송하는 작업을 수행합니다.
위 코드에 따른 메모리 구조
1. 초기 상태
2. str을 buf에 복사합니다.
strcpy(buf + offset, str);
offset += strlen(str);
3. var을 buf에 복사합니다.
*(int *)(buf + offset) = htonl(var); // 정수는 Big Endian으로 바꿔야 합니다.
offset += 4;
4. sendto()로 네트워크에 전송될 때
메모리 복사 오버헤드
네트워크 동작 관점 → 하나의 sendto()로 보내는 것이 바람직합니다.
프로그램 동작 관점 → 복사 오버헤드를 줄이는 것이 바람직합니다.
- 앞의 코드는 str을 buf로 복사하는 오버헤드가 존재합니다.
- 앞에서는 str이 짧은 문자열이기 때문에 괜찮지만,
만일 긴 문자열인 경우 이 오버헤드는 무시하지 못합니다. - 그리고 이 코드가 빈번하게 호출된다면 (예: 모든 유저들의 요청이 다 걸리는 경우)
이는 프로그램 관점에서 상당히 비효율적이게 됩니다. - 참고: 이를 피하기 위해서 하나의 연속된 메모리 주소가 아니라 분리된 메모리 조각들을 주소 배열로 전달할 수 있는 sendmsg() 같은 함수가 있습니다.
- $ man sendmsg로 도움 받을 수 있음
- 성능을 위해 본인이 어떤 메모리 복사를 유발하고 있는지 그것이 불가피하게 필요한지를 파악하고 있어야 합니다.
메모리 이야기는 C/C++ 한정 이야기?
Python의 Socket API 역시 C의 것과 사용 방법이 동일합니다.
따라서 Python에서 sendto() 역시 하나의 메모리 주소를 넘겨야 합니다.
아래는 Python으로 "Hello World"와 0x12345678을 하나의 UDP Datagram으로 서버에 전송하는 코드입니다.
import socket
str = 'Hello World'
var = 0x12345678
buf = bytes(str, 'utf-8') + var.to_bytes(4, byteorder='big')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(buf, ('127.0.0.1', 10001))
'CS' 카테고리의 다른 글
[Network] TCP Socket Programming - 데이터 전송하기 (1) | 2023.10.22 |
---|---|
[Network] Protobuf, JSON (0) | 2023.10.22 |
[Network] UDP Socket 만들어보기 - 데이터 받기 (1) | 2023.10.20 |
[Network] UDP Socket 만들어보기 - 데이터 보내기 (3) | 2023.10.15 |
[Network] Socket Programming (TCP, UDP) (0) | 2023.10.13 |