UDP 서버-클라이언트 분석

bolee·2022년 4월 25일
0

https://github.com/LEEBONGHAK/TCP-IP_window_socket/tree/main/Chapter07/UDPIPv4

여기에서는 위에 작성된 UDP 서버-클라이언트의 구조와 관련 소켓 함수를 살펴볼 것이다. 먼저 UDP 소켓과 연관된 데이터 구조체를 살펴보자.

(7-11 그림)

위 그림은 UDP 소켓과 연관된 운영체제의 데이터 구조체이다.
TCP 소켓과 달리 송신버퍼가 없는데, UDP는 TCP와 달리 데이터 재전송과 흐름 제어를 하지 않기 때문이다. 따라서 UDP는 TCP와 같은 개념의 송신 버퍼(데이터 재전송용)는 존재하지 않는다.

응용 프로그램에서 통신을 수행하려면 다음과 같은 요소를 결정해야 한다.

  • 프로토콜: 통신 규약으로, 소켓을 생성할 때 결정
  • 지역(local) IP 주소와 지역 포트 번호: 서버 또는 클라이언트 자신의 주소
  • 원격(remote) IP 주소와 원격 포트 번호: 서버 또는 클라이언트가 통신하는 상대의 주소

UDP 서버-클라이언트 모델

일반적으로 서버와 클라이언트는 아래 그림의 왼쪽과 오른쪽에 각각 나열된 순서로 소켓 함수를 호출한다.

(7-12 그림)

UDP 서버

  1. socket() 함수로 소켓을 생성함으로써 사용할 프로토콜 결정
  2. bind() 함수로 지역 IP 주소와 지역 포트 번호 결정
  3. 클라이언트가 보낸 데이터를 recvfrom() 함수로 받는다. 이때 원격 IP 주소와 원격 포트 번호, 즉 클라이언트의 주소를 알 수 있다.
  4. 받은 데이터를 처리한 결과를 sendto() 함수로 보낸다.
  5. 모든 작업을 마치면 closesocket() 함수로 소켓을 닫는다.

UDP 클라이언트

  1. socket() 함수로 소켓을 생성함으로써 사용할 프로토콜 결정
  2. sendto() 함수로 서버에 데이터를 보낸다. 이때 원격 IP 주소와 원격 포트 번호는 물론, 지역 IP 주소와 지역 포트 번호도 결정된다.
  3. 서버가 처리해 보낸 데이터를 recvfrom() 함수로 받는다.
  4. 모든 작업을 마치면 closesocket() 함수로 소켓을 닫는다.

UDP 서버-클라이언트 모델을 사용할 때 주의할 점은 다음과 같다.

  • 블로킹 소켓(blocking socket)을 사용할 경우, 송수신 함수의 호출 순서가 맞지 않으면 교착 상태(deadlock)가 발생할 수 있다.
  • 클라이언트는 데이터를 받은 후 송신자의 주소(IP 주소, 포트 번호)를 확인해야 한다. recvfrom() 함수는 UDP 서버가 보낸 데이터뿐만 아니라 전혀 다른 UDP 응용 프로그램이 보낸 데이터로 수신할 수 있기 때문이다.

UDP 클라이언트를 아래 그림의 오른쪽처럼 작성할 수도 있다. UDP 소켓에 대해 connect() 함수를 호출하면 통신할 상대의 주소 정보가 내부적으로 기억되므로, sendto()/recvfrom() 함수 대신 send()/recv() 함수를 사용해 특정 UDP 서버와 통신할 수 있다.

(7-13 그림)

connect() 함수를 사용한다는 것은 특정 IP 주소와 포트 번호로 나타낼 수 있는 고정된 대상과 통신함을 의미한다. 통신 대상을 변경하려면 언제든지 connect() 함수를 다시 호출하면 된다.

위 그림에서 나타낸 UDP 서버-클라이언트 모델의 장점은 다음과 같다.

  • sendto() 함수를 사용한 경우보다 효율적이다. connect() 함수로 서버 주소를 한 번만 설정해두면 send()함수가 이 정보를 재사용하기 때문이다.
  • 데이터를 받은 후 송신자의 주소(IP 주소, 포트 번호)를 확인하지 않아도 된다. recvfrom() 함수와 달리 recv() 함수는 connect() 함수로 설정한 대상을 제외한 다른 UDP 응용 프로그램이 보낸 데이터는 수신하지 않기 때문이다.

하지만 이 모델도 아래 사항을 주의해야 한다.

  • 블로킹 소켓을 사용할 경우, 송수신 함수의 호출 순서가 맞지 않으면 교착 상태가 발생할 수 있다.

UDP 데이터 전송 함수

데이터 전송 함수는 크게 데이터를 보내는 함수와 데이터를 받는 함수로 구분할 수 있다. 가장 기본이 되는 함수는 sendto()recvfrom()이며, WSASend*()/WSARecv*() 형태의 확장 함수 또한 존재한다.

sendto() 함수

sendto() 함수는 응용 프로그램 데이터를 운영체제의 송신 버퍼에 복사함으로써 데이터를 전송한다.
UDP의 경우, sendto() 함수를 호출할 때 소켓의 지역 IP 주소와 지역 포트 번호가 아직 결정되지 않은 상태라면 운영체제가 자동으로 결정해준다. 즉, sendto() 함수가 TCP의 bind() 함수 역할을 대신한다.

// 성공: 보낸 바이트 수, 실패: SOCKET_ERROR
int sendto(
	SOCKET	s,
    const char	*buf,
    int	len,
    int	flags,
    const struct sockaddr	*to,
    int	tolen
);
  • s: 통신에 사용할 소켓
  • buf: 보낼 데이터를 담고 있는 응용 프로그램 버퍼의 주소
  • len: 보낸 데이터 크기(바이트 단위)
  • flags: sendto() 함수의 동작을 바꾸는 옵션으로 대부분 0을 사용하면 된다. 사용 가능한 값으로 MSG_DONTROUTE(윈속에서는 사용하더라도 무시됨)와 MSG_OOB(UDP에서는 의미 없음)가 있다.
  • to: 목적지 주소를 담고 있는 소켓 주소 구조체다. UDP의 경우, 특정 호스트나 라우터 주소는 물론이고 브로드캐스트(broadcast)나 멀티캐스트(multicast) 주솔르 사요할 수도 있다.
  • tolen: 목적지 주소를 담고 있는 소켓 주소 구조체의 크기(바이트 단위)다.

sendto() 함수 사용 예는 다음과 같다.

// 소켓 주소 구조체를 수신자의 IP 주소와 포트 번호로 초기화
SOCKADDR_IN serveraddr;
...
// 송신용 버퍼를 선언하고 데이터를 넣는다.
char buf[BUFSIZE];
...
// sendto() 함수로 데이터를 보낸다.
retval = sendto(sock, buf, strlen(buf), 0, (SOCKADDR *) &serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) 오류처리;
printf("%d 바이트를 보냈습니다.\n", retval);

sendto() 함수의 주의사항을 아래와 같다.

  • sendto() 함수는 UDP 소켓은 물론이고, TCP 소켓에서도 사용할 수 있으며, 이 경우 to 와 tolen 인자는 무시된다. TCP 소켓에 사용할 때만 flags 인자에 MSG_OOB를 사용할 수 있다.
  • sendto() 함수로 보낸 데이터는 독립적인 UDP 데이터그램(=패킷)으로 만들어져 전송되며, 수신 측에서는 recvfrom() 함수 호출 한 번으로 이 데이터를 읽을 수 있다. 따라서 UDP를 사용할 경우에는 TCP와 달리 응용 프로그램 수준에서 메세지 경계를 구분하는 작업을 할 필요가 없다.
  • UDP 소켓에 대해 sendto() 함수를 호출할 경우 한 번에 보낼 수 있는 데이터의 크기에 제한이 있다. 최솟값은 0, 최댓값은 65507(65535-20(IP 헤더 크기)-8(UDP 헤더 크기))바이트 이다. 실제로는 최댓값보다 훨씬 작은 크기를 사용하는 것이 바람직하다. 특히 UDP를 이용해 브로드캐스트 패킷을 보낼 경우 512바이트보다 작은 크기를 사용할 것을 권고한다.(비주얼 스튜디오 설명서)
  • sendto() 함수로 보낸 응용 프로그램 데이터는 커널(=운영체제) 영역에 복사되어 전송된 후 곧바로 버려진다. sendto() 함수가 리턴했다고 실제 데이터 전송이 완료된 것은 아니며, 데이터 전송이 끝났어도 상대방이 받았는지 확인할 수는 없다.
  • 블로킹 소켓을 사용할 경우, 커널 영역에 데이터를 복사할 공간이 부족하면 sendto() 함수는 호출 시 블록된다.

recvfrom() 함수

recvfrom() 함수는 운영체제의 수신 버퍼에 도착한 데이터를 응용 프로그램 버퍼에 복사한다.
TCP의 recv() 함수와 다른 점은, UDP 패킷 데이터를 한 번에 하나만 읽을 수 있다는 점이다. 즉, 응용 프로그램 버퍼를 크게 잡는다고 많은 데이터를 한꺼번에 읽을 수 없다.

// 성공: 받은 바이트 수, 실패: SOCKET_ERROR
int recvfrom(
	SOCKET s,
    char *buf,
    int len,
    int flags,
    struct sockaddr *from,
    int *fromlen
);
  • s: 통신에 사용할 소켓이다. sendto() 함수에 사용하는 소켓과 달리, 이 소켓은 받느시 지역 주소(IP 주소, 포트 번호)가 미리 결정되어 있어야 한다.
  • buf: 받은 데이터를 저장할 응용 프로그램 버퍼의 주소
  • len: 응용 프로그램 버퍼의 크기(바이트 단위)다. 도착한 UDP 패킷 데이터가 len 보다 크면 len 만큼만 복하다고 나머지는 버린다. 이때 recvfrom() 함수는 SOCKET_ERROR를 리턴한다. 따라서 예상되는 UDP 패킷 데이터의 최대 크기를 감안해 응용 프로그램 버퍼를 준비해두어야 한다.
  • flags: recvfrom() 함수의 동작을 바꾸는 옵션으로, 대부분은 0을 사용하면 된다. 사용 가능한 값으로 MSG_PEEKMSG_OOB(UDP에서는 의미 없음)가 있다. recvfrom() 함수의 기본 동작은 수신 버퍼의 데이터를 응용 프로그램 버퍼에 복사한 후 해당 데이터를 수신 버퍼를 삭제하는 것이지만 MSG_PEEK 옵션을 사용하면 수신 버퍼에 데이터가 계속 남는다.
  • from: 소켓 주소 구조체를 전달하면 송신자의 주소 정보(IP 주소와 포트 번호)로 채워진다.
  • fromlen: 정수형 변수를 from 이 가리키는 소켓 주소 구조체의 크기로 초기화한 후 전달한다. recvfrom() 함수가 리턴하면 fromlen 변수는 recvfrom() 함수가 채워넣은 주소 정보의 크기(바이트 단위)를 갖게 된다.

recvfrom() 함수 사용 예는 다음과 같다.

// 통신 상대의 주소를 저장할 변수를 선언한다.
SOCKADDR_IN peeraddr;
int addrlen;
// 수신용 버퍼를 선언한다.
char buf[BUFSIZE];
// recvfrom() 함수로 데이터를 받는다.
addrlen = sizeof(peeraddr);
retval = recvfrom(sock, buf, BUFSIZE, 0, (SOCKADDR *) &peeraddr, &addrlen);
if (retval == SOCKET_ERROR) 오류처리;
printf("%d 바이트를 받았습니다.\n", retval);

recvfrom() 함수의 주의사항을 아래와 같다.

  • recvfrom() 함수는 UDP 소켓은 물론이고 TCP 소켓에도 사용할 수 있으며, 이 경우 from 과 fromlen 인자는 무시된다. TCP 소켓에 사용할 때만 flags 인자에 MSG_OOB를 사용할 수 있다.
  • sendto() 함수로 보낸 데이터는 독립적인 UDP 데이터그램(=패킷)으로 만들어져 전송되며, 수신 측에서는 recvfrom() 함수 호출 한 번으로 이 데이터를 읽을 수 있다. 따라서 UDP를 사용할 경우에는 TCP와 달리 응용 프로그램 수준에서 메세지 경계를 구분하는 작업을 할 필요가 없다.
  • UDP 소켓에 대해 recvfrom() 함수를 호출할 경우 리턴 값이 0이 될 수 있는데, 이는 상대방이 sendto() 함수 호출 시 데이터 크기를 최솟값인 0으로 설정했다는 뜻이다. UDP 프로토콜에는 연결 설정과 종료 개념이 없으므로 recvfrom() 함수의 리턴 값이 0이라고 해서 특별한 의미가 있는 것은 아니다. 반면, TCP 소켓에 대해 recvfrom() 함수를 호출할 경우 리턴 값이 0이면 정상 종료(normal close 또는 graceful close)를 의미한다.
  • 블로킹 소켓을 사용할 경우, 소켓 수신 버퍼에 도착한 데이터가 없으면 recvfrom() 함수는 호출 시 블록된다.

recvfrom() 함수와 소켓 지역 주소 바인딩
recvfrom() 함수에 사용하는 소켓은 반드시 지역 주소(IP 주소, 포트 번호)가 미리 결정되어 있어야 한다. 즉, recvfrom() 함수를 호출하기 전에 어디선가 bind() 함수 또는 이에 상응하는 함수를 사용해 지역 주소를 설정하는 과정이 필요한다.
1. bind() + recvfrom()

    bind(sock, ...);
    ...
    recvfrom(sock, ...);
  1. sendto() + recvfrom()
	sendto(sock, ...);
    ...
    recvfrom(sock, ...);

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

0개의 댓글