본문 바로가기
Programming/열혈 TCP, IP 소켓 프로그래밍(저자 윤성우)

Ch 06. UDP 기반 서버/클라이언트

by minjunkim.dev 2020. 8. 8.

    모든 내용은 [윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!


# UDP 소켓의 특성

1) 장점

- TCP보다 간결한 구조로 설계됨 => 상황에 따라 좋은 성능 발휘

- 프로그래밍 관점에서 구현이 용이함

- 생각만큼 데이터 손실이 자주 발생하지 않음 => 신뢰성보다 성능이 중시되는 상황에서 좋은 선택이 될 수 있음

2) 단점

- 상대방의 수신여부를 알 수 없음

- 전송 도중에 분실될 확률이 있음

- 신뢰할 수 없는 전송방법을 제공함

- 데이터를 전송할 때마다 반드시 목적지의 주소정보를 별도로 추가해야 함

(TCP처럼 연결된 상태가 아니기 때문)

 

# TCP는 신뢰성 없는 IP를 기반으로 신뢰성 있는 데이터 송수신을 위해

흐름제어(Flow Control)를 하지만, UDP에는 이 흐름제어가 존재하지 않음.

=> "흐름제어 존재의 유무"가 TCP와 UDP의 가장 큰 차이점

 

# 데이터 송수신 속도

- 일반적으로는 UDP >> TCP

- 하지만 송수신하는 데이터 성격에 따라 TCP는 UDP와 비슷한 속도를 내기도 함

- 한번에 송수신하는 데이터의 양이 크면 클수록 TCP는 UDP 못지않은 전송속도를 냄

 

# UDP 내부 동작원리

- UDP의 역할중 가장 중요한 것은, 호스트로 수신된 UDP 패킷(호스트까지의 데이터 전달은 IP의 역할)을

PORT정보를 참조하여 최종목적지인 UDP 소켓에 전달하는 것

 

# UDP의 효율적 사용
- UDP도 나름대로 상당히 신뢰할만함
- 압축파일의 경우에는 그 특성상 반드시 TCP 기반으로 송수신이 이루어져야 하나,
실시간 멀티미디어(영상 및 음성 등) 전송의 경우에는 속도가 상당히 중요한 요소이므로

UDP 기반의 구현을 고려할만함

# TCP가 UDP보다 느린 이유
1) 데이터 송수신 이전, 이후에 거치는 연결설정 및 해제과정이 존재
2) 데이터 송수신과정에서 거치는 신뢰성 보장을 위한 흐름제어가 존재

=> 따라서 송수신하는 데이터의 양이 작으면서, 잦은 연결이 필요한 경우에는 UDP가 훨씬 효율적이고 빠르게 동작


# UDP 서버/클라이언트는 TCP처럼 연결된 상태로 데이터 송수신을 하는 것이 아니기 때문에
연결 설정, 해제과정이 필요가 없다. => listen, accept 함수호출이 불필요

=> 즉, UDP 소켓 생성과 데이터 송수신 과정만 필요함

# TCP에서는 소켓과 소켓의 관계가 일대일 대응(연결되어 있음)

=> 서버가 10개의 클라이언트에 서비스를 제공하려면
서버에 "서버소켓 1개" + 클라이언트와의 연결을 위한 "10개의 추가소켓"이 필요했음.
그러나 UDP에서는 서버건 클라이언트건 하나의 소켓만 있으면 됨.

 

# UDP 소켓은 "우체통" 역할과 유사하다.

(우체통이 있으면 어디로든 편지를 보낼 수 있는 것처럼 UDP 소켓이 있으면 어디로든 데이터를 보낼 수 있다.

"데이터 수신지의 주소"만 안다면 말이다.)


# UDP 기반의 데이터 입출력 함수
1. 출력함수

#include <sys/socket.h>

ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);
// -> 성공시 전송된 바이트수, 실패시 -1반환

- sock : 데이터 전송에 사용될 UDP 소켓의 파일 디스크립터

- buff : 전송할 데이터를 저장하고 있는 버퍼의 주소값

- nbytes : 전송할 데이터 크기를 바이트 단위로 전달

- flags : 옵션 지정에 사용되는 매개변수, 지정할 옵션이 없다면 0

- to : 목적지 주소정보를 담고 있는 sockaddr 구조체 변수의 주소값

- addrlen : 매개변수 to로 전달된 주소값의 구조체 변수 크기

- TCP 기반의 출력함수와 가장 비교되는 것은 "목적지 주소정보를 요구"한다는 것



2. 입력함수

ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
// -> 성공시 수신한 바이트수, 실패시 -1 반환

- sock : 데이터 수신에 사용될 UDP 소켓의 파일 디스크립터

- buff : 데이터 수신에 사용될 버퍼의 주소값

- nbytes : 수신할 최대 바이트 수 전달, 때문에 매개변수 buff가 가리키는 버퍼의 크기를 넘을 수 없음

- flags : 옵션 지정에 사용되는 매개변수, 지정할 옵션이 없다면 0

- from : 발신지 정보를 채워 넣을 sockaddr 구조체 변수의 주소값

- 매개변수 from으로 전달된 주소에 해당하는 구조체 변수의 크기정보를 담고있는 변수의 주소값

- 이 함수는 UDP 패킷에 담겨있는 발신지 정보를 함께 반환함(from, addrlen)

# UDP 소켓은 TCP 소켓과 달리
listen(), accept() 함수를 호출하지 않는다. (연결설정이 필요하지 않으므로)

# TCP 클라이언트 소켓의 주소는 connect 함수가 호출될때 IP(호스트IP)와 PORT번호(임의)가 자동으로 할당됨.
그렇다면 UDP 클라이언트 소켓의 주소는 언제 할당될까?
=>
    UDP 에서는 sendto 함수호출 이전에 해당 소켓(데이터 발신지)의 주소정보가 할당되어 있어야 함
첫번째 방법 - sendto 함수호출 이전에 bind 함수호출하여 명시적으로 주소정보 할당
두번째 방법 - 첫번째 방법을 사용하지 않았다면, sendto 함수가 처음 호출되는 시점에

해당 소켓(발신지)에 IP와 PORT번호가 자동으로 할당됨 => 보다 일반적 구현방법
(IP는 호스트 IP로, PORT번호는 현재 사용하지 않고있는 번호 하나를 임의로 할당,

프로그램 종료까지 주소정보는 유지)


# 데이터의 경계가 존재하는 UDP 소켓

- 따라서, 데이터 송수신과정에서 호출하는 입출력함수의 호출횟수가 의미를 가짐
- 입력함수의 호출횟수와 출력함수의 호출횟수가 "완벽히 일치"해야 송신된 데이터 전부를 수신할 수 있음

# UDP 데이터그램
- UDP 소켓이 전송하는 패킷을 가리킴
- 데이터그램도 패킷의 일종이나, TCP 패킷과 달리 데이터의 일부가 아닌

그 자체가 하나의 데이터로 의미를 가질 때 이렇게 표현함.
- UDP는 데이터의 경계가 존재하기 때문에 하나의 패킷 = 하나의 데이터로 간주됨

=> 그래서 데이터그램이라고 표현하는 것

# TCP 소켓에는 데이터를 전송할 목적지(수신지)의 IP와 PORT번호를 등록하는 반면,
UDP 소켓에는 데이터를 전송할 목적지(수신지)를 등록하지 않고,
sendto 함수호출을 할 때마다
1) UDP 소켓에 목적지(수신지)의 IP와 PORT번호 등록
2) 해당 목적지(수신지)로 데이터 전송
3) UDP 소켓에 등록된 목적지(수신지) 정보 삭제
위의 과정을 거친다.(단, unconnected 소켓 기준) => 목적지(수신지)의 주소정보가 계속 변경될 수 있기에

하나의 UDP 소켓을 통해 다양한 목적지로 데이터 전송이 가능한 것

# unconnected 소켓(목적지 정보가 등록되어있지 않은 소켓) - UDP 소켓의 DEFAULT
# connected 소켓(목적지 정보가 등록되어 있는 소켓)

- 하나의 호스트와 오랜 시간 데이터 송수신시 이것이 보다 효율적
- UDP 소켓을 대상으로 connect 함수를 호출해주면 됨

- unconnected와 달리 송수신의 대상이 정해졌기 때문에,

sento 대신 write / recvfrom 대신 read 함수를 사용가능하기에 이를 사용하여 이점을 챙겨올 수 있음


# UDP 기반의 에코서버와 에코 클라이언트

uecho_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc,char *argv[])
{
    int serv_sock, clnt_sock;
    char message[BUF_SIZE];
    int str_len;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;

    if(argc!=2) // 실행파일 경로/PORT번호 를 입력으로 받아야 함
    {
        printf("Usage : %s <port>\n",argv[0]);
        exit(EXIT_FAILURE);
    }

    serv_sock=socket(PF_INET,SOCK_DGRAM,0); // UDP 소켓 생성
    if(serv_sock==-1)
        error_handling("socket() error");

    /* 서버 주소정보 초기화 */
    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_adr.sin_port=htons(atoi(argv[1]));

    /* 서버 주소정보 할당 */
    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
        error_handling("bind() error");

    while(true)
    {
        clnt_adr_sz=sizeof(clnt_adr);

        /* 클라이언트로부터 널문자를 제외하고 문자열을 수신 */
        str_len=recvfrom(serv_sock,message,BUF_SIZE,0,(struct sockaddr*)&clnt_adr,&clnt_adr_sz);

        /* 수신받은 데이터를 다시 클라이언트로 송신 */
        sendto(serv_sock,message,str_len,0,(struct sockaddr*)&clnt_adr,clnt_adr_sz);
    }

    close(serv_sock); // UDP 소켓 종료
    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

 

uecho_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc,char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;
    socklen_t adr_sz;

    struct sockaddr_in serv_adr, from_adr;

    if(argc!=3) // 실행파일 경로/IP/PORT번호 입력 받아야 함
    {
        printf("Usage : %s <IP> <port>\n",argv[0]);
        exit(EXIT_FAILURE);
    }

    sock=socket(PF_INET,SOCK_DGRAM,0); // UDP 소켓 생성
    if(sock==-1)
        error_handling("socket() error");

    /* 서버 주소정보 초기화 */
    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_adr.sin_port=htons(atoi(argv[2]));

    while(true)
    {
        fputs("Input message(Q to exit): ",stdout);
        fgets(message,BUF_SIZE,stdin);

        if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
            break;
        
        /*
        이때, 클라이언트의 주소가 자동으로 할당됨
        입력받은 문자열을 서버로 널문자를 제외하고 송신
        */
        sendto(sock,message,strlen(message),0,(struct sockaddr*)&serv_adr,sizeof(serv_adr));

        /*
        서버로부터 에코받은 문자열을 다시 수신하여 출력
        */
        adr_sz=sizeof(from_adr);
        str_len=recvfrom(sock,message,BUF_SIZE,0,(struct sockaddr*)&from_adr,&adr_sz);
        message[str_len]='\0'; // 수신한 문자열 맨뒤에 널문자 추가
        printf("Message from server: %s",message);
    }
    close(sock); // UDP 소켓 종료
    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

# connected UDP 소켓 생성(UDP의 default는 unconnected)

- 클라이언트 역할의 호스트에서 connect 함수를 호출하여 "목적지 주소를 등록함"을 유의해서 보자.

- sendto 대신 write, recvfrom 대신 read 함수를 사용할 수 있음을 유의해서 보자.

- 아래의 코드는 위에서의 uecho.server.c 와 같이 실행이 가능하다.

 

uecho_con_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc,char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;

    struct sockaddr_in serv_adr;

    if(argc!=3) // 실행파일 경로/IP/PORT번호 입력 받아야 함
    {
        printf("Usage : %s <IP> <port>\n",argv[0]);
        exit(EXIT_FAILURE);
    }

    sock=socket(PF_INET,SOCK_DGRAM,0); // UDP 소켓 생성
    if(sock==-1)
        error_handling("socket() error");

    /* 서버 주소정보 초기화 */
    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_adr.sin_port=htons(atoi(argv[2]));

    connect(sock,(struct sockaddr*)&serv_adr,sizeof(serv_adr));
    // connected UDP이므로 connect 함수를 호출하여 목적지 주소정보를 등록했음을 보자!

    while(true)
    {
        fputs("Input message(Q to exit): ",stdout);
        fgets(message,BUF_SIZE,stdin);

        if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
            break;
        
        /*
        입력받은 문자열을 서버로 널문자를 제외하고 송신
        */

        write(sock,message,strlen(message));

        /*
        서버로부터 에코받은 문자열을 다시 수신하여 출력
        */
    
        str_len=read(sock,message,sizeof(message)-1);
        message[str_len]='\0'; // 널문자를 수신한 문자열 맨 뒤에 추가
        printf("Message from server: %s",message);
    }
    close(sock); // UDP 소켓 종료
    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

[출처] : 윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어