목표
이번 글에서는 c++로 UDP 소켓 생성과 이를 이용해 데이터를 보내는 코드를 작성해 보면서 UDP 통신 방법을 알아보도록 하겠습니다.
Opaque Hadle로서의 Socket Descriptor
socket() 함수로 UDP 소켓을 만들어보겠습니다.
#include <arpa/inet.h> // the Internet 관련 함수들
#include <sys/socket.h> // socket 관련 기본 함수들
#include <unistd.h> // Unix 계열에서 표준 기능 함수들
#include <iostream>
using namespace std;
int main(){
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
cout << "Socket ID:" << s << endl;
close(s);
return 0;
}
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
SOCK_DGRAM 안에 세부 프로토콜 IP위의 DGRAM은 UDP 밖에 없으므로 IPPROTO_UDP 대신 0을 써도 됩니다.
cout << "Socket ID:" << s << endl;
이렇게 만들어진 s 는 Socket Descriptor로 숫자입니다. 이게 어떤 의미인지는 모르지만, 각 소켓을 구분할 목적으로 이후 소켓 함수를 쓸 때 같이 전달합니다.
참고: Linux C++ 에서 필요한 헤더 파일 찾기
$ man 2 함수이름 // 시스템 함수 중에 찾아라
$ man 3 함수이름 // 라이브러리 함수 중에 찾아라
재사용되는 Socket Descriptor 값
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main() {
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
cout << "Socket ID:" << s << endl;
close(s);
s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
cout << "Socket ID:" << s << endl;
close(s);
return 0;
}
/**
* 출력
* Socket ID: 3
* Socket ID: 3
*/
- Socket Descriptor 값은 재사용됩니다.
- 즉, 100번 소켓을 닫고, 다음에 다른 소켓을 열면 이전에 사용했던 100번이 다시 쓰일 수 있습니다.
- 이때 값이 재사용된다고 해서 "같은 소켓"이라는 뜻은 아닙니다. 우연이 값이 같아도 둘은 "완전히 다른 소켓"입니다.
- 따라서 socket() 으로 얻어낸 것은 오직 1회만 close() 해야 합니다.
- close()를 안하면 소켓 자원이 누수됩니다.
- close()를 2회 이상 하면 -> 우연히 같은 번호를 쓰게 된 소켓을 닫아버릴 수 있습니다.(예: 다른 스레드에서 얻은 소켓을 닫아버림)
- 메모리 alloc/free와 똑같습니다.
참고: Socket Descriptor 관련 정보
- Socket Descriptor 는 process 별로 구분됩니다.
- 즉 같은 프로그램을 2개 동시에 실행했을 때 같은 숫자 3이 나와도 이는 같은 소켓이라는 의미가 아닙니다.
- 각각의 프로세스 안에서 3이라는 숫자는 다른 소켓을 의미합니다.
- (Linux, mac 한정) Socket Descriptor는 File Descriptor와 섞여서 쓰입니다.
- 열린 파일을 식별하는 식별자가 file descriptor 입니다.
- Linux와 mac 같은 Unix 계열에서는 file, socket 같은 I/O 를 동등하게 취급합니다. (즉, I/O 다루는 read() 같은 함수에 file descriptor, socket descriptor 둘 다 전달 가능)
- 따라서 프로그램이 뜨자마자 파일 1000개를 열었다면, 다음 소켓 번호는 1001이 되고, 다시 파일을 하나 더 열면 1002번 그 뒤에 열리는 소켓은 1003번이 됩니다.
- 참고: 앞의 예에서 결괏값으로 3이 나온 이유
- 표준 입력(stdin) = 0, 표준 출력(stdout) = 1, 표준 에러(stderr) = 2
UDP 소켓으로 데이터 보내기
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <iostream>
#include <string>
using namespace std;
int main(){
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); // Socket 열기
if (s < 0) return 1; // 오류가 있으면 s는 음수
string buf = "Hello World";
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(10000);
sin.sin_addr.s_addr = inet_addr("127.0.0.1");
int numBytes = sendto(s, buf.c_str(), buf.length(),
0, (struct sockaddr *) &sin, sizeof(sin));
cout << "Sent: " << numBytes << endl;
close(s);
return 0;
}
/**
* 출력
* Sent: 11
*/
struct sockaddr과 struct sockaddr_in
- Socket API는 여러 주소 체계의 지원을 가정하고 있습니다.
- 주소 요소 중 공통의 부분을 struct sockaddr에 넣고, 각 주소 체계 별로 별도 struct를 지정하게 하고 있습니다.
- 예: IPv4 -> struct sockaddr_in, IPv6 -> struct sockaddr_in6
- 이는 상속 형태로 구현했다면 깔끔했을 것입니다. 그러나 당시에는 OOP가 일반적이지 않았으므로 상속 개념이 없었습니다.
- 대안으로 C에서 struct 가 메모리에 잡히는 방법을 이용했습니다.
- 필드 순으로 메모리에 잡힙니다.
- 어떤 struct 가 이 공통 필드를 다 포함하고 있다면 (= 자식 클래스) 사이즈가 작은 것 (= 부모 클래스)으로 강제 형 변환을 해도 안전합니다.
struct sockaddr_in sin; // 원래 코드
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(10000);
sin.sin_addr.s_addr = inet_addr("127.0.0.1");
int numBytes = sendto(s, buf.c_str(), bug.length(),
0, (struct sockaddr *) &sin, sizeof(sin)); // 강제 형변환 후 확보된 바이트까지 넘기기
- 위 코드에서 sendto() 함수는 struct sockaddr * 을 매개변수로 받습니다.
- 그런데 IPv4 용인 struct sockaddr_in을 사용하므로 강제로 형변환을 하고, 전체 몇 바이트까지 확보된 것인지를 같이 넘겨줍니다.
sendto()에 대해서
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
- sockfd: 데이터를 보낼 소켓의 파일 디스크립터입니다.
- buf: 전송할 데이터가 저장된 버퍼의 포인터입니다.
- len: 전송할 데이터의 바이트 크기입니다.
- flags: 일반적으로 0으로 설정하며, 특별한 동작을 제어하는 데 사용됩니다. 예를 들어 MSG_DONTWAIT는 소켓이 블록 되지 않도록 설정하는 플래그입니다.
- dest_addr: 목적지 주소 정보를 담고 있는 sockaddr 구조체의 포인터입니다. 이 구조체에는 IP 주소와 포트 번호 등이 포함됩니다.
- addrlen: dest_addr에 대한 길이 정보입니다.
sendto() 함수는 성공 시 전송된 바이트 수를 반환하고, 실패 시 -1을 반환합니다. 실패 원인은 errno 변수를 통해 확인할 수 있습니다.
int numBytes = sendto(s, buf.c_str(), buf.length(),
0, (struct sockaddr *) &sin, sizeof(sin));
위 코드는 "Hello World" 문자열(11바이트)을 로컬 호스트(127.0.0.1)의 10000번 포트로 전송하고 있으며, 그 결과로 전송된 바이트 수가 출력됩니다.
UDP의 Connectionless와 Best-Effort
- 앞에서 127.0.0.1에 10000 포트에 아무것도 떠 있지 않다고 하겠습니다.
- 터미널에서 $ netstat -an | grep 10000으로 확인할 수 있습니다.
- 그럼에도 UDP 소켓은 해당 IP, 포트에 데이터를 전송했다고 반환합니다.
- 이는 UDP는 연결 개념이 없고 (connectionless), 전송을 보장하는 것이 아닌 "최대한 노력(best-effort)"만 하기 때문입니다.
- 따라서 sendto()가 반환값을 내놨다고 해서 상대가 받았음을 가정해서는 안됩니다.
sendto() 함수 및 recvform() 함수
- UDP는 연결 개념이 없습니다. (connectionless)
- 따라서 매번 데이터를 보낼 때마다 어디로 보내야 하는지 알려줘야 합니다.
- sendto() :
- 네트워크로 보냈으면 보낸 바이트 수 반환 (네트워크로 보냈다는 뜻이지 상대가 받았다는 뜻이 아니니 주의)
- 보내지 못했으면 -1을 반환합니다.
- sendto() :
- 유사하게 매번 데이터를 받을 때마다 어디서 받았는지 같이 반환합니다.
- recvfrom() :
- 데이터를 읽었으면 읽은 바이트 수 반환
- 읽지 못했으면 -1 반환
- recvfrom() :
- 이는 N 개의 클라이언트를 서비스하더라도 서버 쪽 UDP 소켓은 단 1개만 사용됨을 의미합니다.
- 서버에서 관리해야 되는 state 도 없고 (stateless) 그때그때 들어오는 UDP 발신자만 알려주면 되므로 부하가 적습니다.
'CS' 카테고리의 다른 글
[Network] TCP Socket Programming - 데이터 전송하기 (1) | 2023.10.22 |
---|---|
[Network] Protobuf, JSON (0) | 2023.10.22 |
[Network] Socket Programming 메모리 관리 (1) | 2023.10.21 |
[Network] UDP Socket 만들어보기 - 데이터 받기 (1) | 2023.10.20 |
[Network] Socket Programming (TCP, UDP) (0) | 2023.10.13 |