TCP 와 UDP 기반의 server/client 구현하기

evelyn82ny·2022년 6월 17일
0

Network

목록 보기
4/4

Transport 계층은 출발지와 최종 목적지까지의 데이터를 송수신하는 계층이며 TCPUDP 방식이 있다.

TCP (Transmission Control Protocol)

  • 분실, 중복, 순서 등 데이터의 전송을 보장하는 신뢰성있는 프로토콜
  • 데이터의 전송을 보장하기 때문에 UDP 보다 복잡한 프로토콜
  • 소켓끼리 일대일 연결
  • 일대일 연결이므로 연결 후 서로의 목적지를 설정하지 않아도 됨
  • 소켓 전달시 SEQ, ACK 같은 신호로 데이터의 전송을 보장
  • receiver 에게 패킷을 전송했을 때 receiver 로부터 전송한 패킷에 대한 응답을 받지 못하면 time out 으로 처리해 다시 보내 데이터의 전송을 보장

UDP (User Datagram Protocol)

  • 데이터의 전송을 보장하지 않아 TCP 보다 신뢰도가 떨어짐
  • 데이터의 전송을 보장하지 않기 때문에 3-way handshake 과정을 거치지 않고 SEQ, ACK 같은 신호도 없음
  • 데이터의 전송을 보장하지 않기 때문에 TCP 보다 구조가 단순해 용량이 가벼움
  • 전송을 보장하지 않아 전송 체크를 할 필요가 없어 TCP 보다 상대적으로 속도가 빠름
  • 실시간 방송 같이 안전성보다 성능이 중요하다면 UDP 를 사용
  • 비연결형 통신으로 여러 소켓과 일대다 통신이 가능 (broadcast, multicast)
  • 비연결형 특성이므로 connection 이라는 개념이 적용되지 않아 통신할 때마다 수신자를 지정해야함

DNS (Domain name service) 는 UDP 사용

  • DNS Request에 사용되는 용량을 UDP segment가 처리 가능
  • UDP 제한 크기인 512를 넘어가면 TCP 사용해야함
  • DNS도 connection을 유지할 필요없기 때문에 UDP 사용이 적합
  • 하지만 DNS 서버 간의 요청을 주고 받을 때 사용하는 zone transfer에서는 TCP를 사용한다.

🛠 TCP: 1byte씩 읽기

TCP 통신으로 Server가 전달한 데이터를 Client가 제대로 받는지 확인해봤다.

Server

socket

socket() 으로 소켓을 생성한다.

#include <sys/socket.h>

int socket(int domain, int type, int protocol);
// 성공 시 파일 디스크립터를, 실패 시 -1 리턴
  • domain : socket이 사용할 프로토콜의 체계(protocol family) 정보
    • PF_INET : IPv4 인터넷 프로토콜 체계
    • PF_INET6 : IPv6 인터넷 프로토콜 체계
  • type : socket의 데이터 전송방식으로 tcp, udp 인지 작성
    • SOCK_STREAM : TCP (연결 지향형 소켓 타입)
    • SOCK_DGRAM : UDP (비연결 지향형 소켓 타입)
  • protocol : 컴퓨터간 통신에 사용되는 프로토콜 정보
int server_sock;
server_sock = socket(PF_INET, SOCK_STREAM, 0);

IPv4 인터넷 프로토콜 체계이면서 TCP 통신을 하는 서버 소켓을 생성한다.


bind

socket() 으로 생성한 소켓에 bind() 를 사용해 주소를 할당한다.

#include <sys/socket.h>

int bind(int sockfd, (struct sockaddr*) &myaddr, socklen_t addrlen);
// 성공 시 0, 실패 시 -1 리턴
  • sockfd : 소켓의 파일 디스크립터
  • myaddr : 설정할 주소가 담긴 구조체를 sockaddr 구조체로 casting

struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(atoi(argv[1]));

생성한 소켓에 주소를 할당하기 위해 server_addr 에 주소 체계, IP 주소 그리고 Port 번호를 설정한다.

  • sin_family: IPv4 인터넷 프로토콜 체계를 사용하기 위해 AF_INET
  • sin_addr.s_addr: Host PC 와 같은 IP 주소를 설정하기 위해 INADDR_ANY
  • sin_port: 개발자에게 입력받는 번호를 Port 번호로 설정

sockaddr_in 구조체에 대한 설명: https://velog.io/@evelyn82ny/struct-sockaddr-in-for-IPv4


if(bind(server_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1)
	error_handling("bind() error");

bind() 를 사용해 생성한 서버 소켓에 주소를 할당한다.


listen

#include <sys/type.h>

int listen(int sock, int backlog);
// 성공 시 0, 실패 시 -1 리턴
  • sock : listen 소켓으로 설정할 소켓의 파일 디스크립터
  • backlog : 연결요청 대기 큐의 크기 정보 전달 (5 작성시 클라이언트 연결 요청을 5개까지 대기시킬 수 있음)

accept

#include <sys/socket.h>

int accept(int sockfd, (struct sockaddr*) &clnt_addr, socklen_t *addrlen);
// 성공 시 파일 디스크립터, 실패 시 -1 리턴
  • 클라이언트가 연결을 요청하면 수락
  • accept() 는 새로운 소켓을 생성해 해당 소켓의 파일 디스크립터 리턴
  • 클라이언트와 서버 소켓이 직접적인 데이터 송수신을 하는 것이 아니고 accept() 에서 생성한 새로운 소켓이 데이터 송수신용 소켓
  • 서버 소켓은 클라이언트의 요청에 따라 데이터 송수신용 소켓을 새로 생성 (서버 소켓 == 리스닝 소켓)
client_sock = accept(server_sock, (struct sockaddr*)&client_addr, &client_addr_size);

char message[] = "Hello world!";
write(client_sock, message, sizeof(message));

클라이언트의 연결을 수락하면 해당 클라이언트와 데이터를 송수신하는 소켓이 생성된다. 해당 소켓을 이용해 클라이언트에게 데이터를 보낸다.


Client

struct sockaddr_in server_addr;

server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(argv[1]);
server_addr.sin_port = htons(atoi(argv[2]));

Client는 Server에게 연결을 요청하기 위해 Server 주소가 필요하다. 이 때문에 Server는 자신의 주소를 명시해야 한다.

connect

#include <sys/socket.h>

int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);
// 성공 시 0, 실패 시 -1 리턴

Client는 Server와 연결하기 위해 connect()를 호출한다. Server가 해당 Client에게 데이터를 전달하기 위해 Server도 Client의 주소를 알아야 하지만 개발자가 직접 지정해 줄 필요없다. connect()를 호출 시 커널이 Client에게 적절한 주소를 할당한다.

connect() 는 2가지 Return 이 존재한다.

  1. read, write 가능한 상태로 return
    연결하고자 하는 서버의 backlog가 모두 채워져 있지 않으면 서버의 accpet()을 호출할 수 있는 상태이다. 서버의 accept() 까지 호출해 데이터 송수신용 소켓을 생성해 클라이언트와의 연결까지 처리한 다음 return 한다. 이 경우는 바로 read, write 수행이 가능한 상태이다.
  2. read, write 불가능한 상태로 return
    연결하고자 하는 서버의 연결요청 대기큐가 모두 차있으면 서버의 backlog에 기록만하고 되돌아 오기 때문에 read, write 를 할 수 없는 상태이다

Blocking 모드에서 read

while(read_len = read(sock, &message[index++], 1)) {
	if(read_len == -1)
		error_handling("read() error");
	str_len += read_len;
}

위 방식은 1byte씩 읽는다. 모든 데이터를 다 읽어 Read Pointer가 EOF에 있는 경우 0을 리턴해 while 문을 탈출한다.

공부를 하면서 소켓은 기본적으로 Blocking 모드로 작동하는 것을 알게되었는데 BlockingNon-blocking 모드에서 read() 를 사용했을 때 발생되는 여러가지 경우를 아는 것도 좋을 것 같아 아래 링크를 추가합니다.

Blocking 모드에 대한 설명: https://github.com/evelyn82ny/Computer-science/blob/master/Network/theory/blocking-vs-non-blocking.md


Server가 보낸 데이터와 읽은 Byte 수를 출력한 결과이다. Hello world!는 12Bytes 인데 13 을 출력한 이유는 null 까지 읽기 때문이다.


🛠 TCP: Buffer 사이즈만큼 읽기

str_len = read(sock, message, sizeof(message) - 1);

이번엔 Buffer 사이즈보다 1 작은 크기로 한번에 읽는다. 1 작은 크기로 읽는 이유는 null 을 처리하기 위함이다.

Buffer 사이즈가 30이므로 Server가 보낸 Hello world! 데이터를 모두 받았다. 그럼 Server가 보낸 데이터보다 작은 Buffer 사이즈를 가지고 있을 경우 어떻게 될까?

받은 데이터보다 Read buffer 사이즈가 작은 경우

Buffer 사이즈는 8로 할당했고 Server가 보낸 Hello world! 데이터를 모두 받지 못했다. null 을 처리해야 하므로 w 까지만 읽은 것을 확인할 수 있다.


🛠 UDP: 일대일 통신

UDP 는 일대다 연결이 가능하다. 그렇기 때문에 서버 소켓, 클라이언트 소켓이라는 개념이 없다.

하지만 쉬운 이해를 위해 데이터를 발신하는 쪽을 클라이언트로, 데이터를 수신하는 쪽을 서버라고 설명하겠다. 1개의 클라이언트가 1개의 서버에게 데이터를 전달하면 서버는 받은 데이터를 다시 클라이언트에 보내는 과정을 구현했다.

위 GIF에서 왼쪽이 서버이고 오른쪽이 클라이언트이다. 클라이언트가 데이터를 입력하면 서버에게 제대로 전달되고 서버는 데이터를 클라이언트에게 제대로 전달하는 것을 확인할 수 있다.

Server

int server_sock;
struct sockaddr_in server_addr, server_my_addr;

server_sock = socket(AF_INET, SOCK_DGRAM, 0);

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(9190);

if(bind(server_sock, (struct sockaddr*) &server_addr, sizeof(server_addr)) == -1)
	error_handling("bind() error");

if(getsockname(server_sock, (struct sockaddr*) &server_my_addr, &server_addr_len) == -1)
	error_handling("getsockname() error");

bind

서버의 IP 주소는 Host PC와 동일하게 설정하기 위해 INADDR_ANY를, Port 번호는 9190으로 설정한다. 해당 정보를 sockaddr_in구조체에 담아 bind() 호출하면 해당 소켓에 주소가 설정된다.

getsockname

여러 클라이언트가 서버에 접속하기 위해선 서버의 주소를 알아야 한다. 이를 명시하기 위해 getsockname() 을 호출해 서버 주소를 출력한다.


struct sockaddr_in client_addr;

for(int i = 0; i < 3; ++i) {
	memset(&client_addr, 0, sizeof(client_addr));
	client_addr_len = sizeof(client_addr);

	recv_len = recvfrom(server_sock, buf, BUF_SIZE, 0, (struct sockaddr*) &client_addr, &client_addr_len);
	if(recv_len == -1)
		error_handling("recvfrom() error");
	else {
		printf("connection successful...\n");
		printf("Peer's IP : %u\n", ntohl(client_addr.sin_addr.s_addr));
		printf("Peer's Port : %d\n", ntohs(client_addr.sin_port));
	}

	printf("\nread result: %s\n", buf);
	send_len = sendto(server_sock, buf, sizeof(buf), 0, (struct sockaddr*) &client_addr, client_addr_len);
	if(send_len == -1)
		error_handling("sendto() error");
	printf("Disconnected...\n\n");
}

해당 서버는 클라이언트와 3번만 연결 후 종료하기 위해 for문을 사용했다. UDP는 비연결성이므로 for문 시작과 동시에 클라이언트의 주소가 저장될 client_addr 를 초기화 한다.

recvfrom

클라이언트에게 데이터를 받기 위해 recvfrom() 을 사용한다.

#include <sys/socket.h>

ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
// 성공 시 받은 문자 수(byte), 실패 시 -1 return <br>
  • sock : 데이터 수신에 사용될 UDP 소켓의 파일 디스크립터
  • buff : 데이터 수신에 사용될 버퍼 주소 값
  • nbytes : 수신할 최대 바이트 수 전달 (buff가 가리키는 버퍼의 크기를 넘을 수 없음)
  • flags : 옵션 지정에 사용되는 매개변수로 지정할 옵션 없으면 0 전달
  • from : 발신자(해당 포스트에서는 클라이언트로 지칭) 정보를 채워 넣을 sockaddr 구조체 변수의 주소 값
  • addrlen : sockaddr 구조체의 크기를 할당한 pointer

from에 비어있는 sockaddr 구조체를 넘겨주면 커널에서 발신자(해당 포스트에서는 클라이언트로 지칭) 주소를 알아서 채워준다.

sendto

클라이언트에게 받은 데이터를 다시 전송하기 위해 sendto() 를 사용한다.

#include <sys/socket.h>

ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);
// 성공 시 보낸 문자 수(byte), 실패 시 -1 return            
  • sock : 데이터 전송에 사용될 UDP 소켓의 파일 디스크립터
  • buff : 전송할 데이터를 저장하고 있는 버퍼 주소 값
  • nbytes : 전송할 데이터 크기(바이트 단위)
  • flags : 옵션 지정에 사용되는 매개변수로 지정할 옵션 없으면 0 전달
  • to : 목적지 주소정보를 담고 있는 sockaddr 구조체 변수의 주소 값
  • addrlen : 매개변수 to로 전달된 주소 값의 구조체 변수 크기(value)

UDP는 여러 소켓과 연결이 가능하므로 데이터를 전송할 때마다 to 매개변수에 목적지에 대한 정보를 전달해야한다.


Client

int client_sock;
struct sockaddr_in client_addr, server_addr;

client_sock = socket(AF_INET, SOCK_DGRAM, 0);
memset(&client_addr, 0, sizeof(client_addr));

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(atoi(argv[1])); // 0.0.0.0
server_addr.sin_port = htons(atoi(argv[2]));

클라이언트는 서버와 연결하기 위해 server_addr 에 서버 주소를 저장한다. 클라이언트가 이 부분에서 서버 주소가 무엇인지 파악해야 하므로 서버 쪽에서 자신의 주소를 명시해야 한다.

while(1) {
	fputs("Insert message(1 to quit): ", stdout);
	fgets(buf, BUF_SIZE, stdin);
	if(!strcmp(buf, "q\n") || !strcmp(buf, "Q\n"))
		break;

	send_len = sendto(client_sock, buf, sizeof(buf), 0, (struct sockaddr*) &server_addr, sizeof(server_addr));
	if(send_len == -1)
		error_handling("sendto() error");

	client_addr_len = sizeof(client_addr_len);
	recv_len = recvfrom(client_sock, buf, sizeof(buf), 0, (struct sockaddr*) &client_addr, &client_addr_len);
	if(recv_len == -1)
		error_handling("recvfrom() error");

	printf("message from server : %s\n", buf);
}

사용자가 qQ 를 입력하기 전까지 계속해서 통신을 시도하기 위해 while 문으로 작성했다. 클라이언트도 sendto() 로 데이터를 보내고 recvfrom() 으로 데이터를 받는다.

제대로 전달되는 것을 확인할 수 있다. 현재 서버는 3번만 통신하고 해당 소켓을 종료한다. 만약 서버가 종료되었는데 클라이언트가 특정 데이터를 다시 보냈다면 Block 모드로 변하기 때문에 ctrl + c 로 강제 종료한다.


🛠 UDP: 일대다 통신

일대일 통신에서 사용했던 코드를 그대로 사용해 일대다 통신을 테스트할 수 있다.

1개의 서버를 실행하고 여러개의 터미널에서 클라이언트 코드를 실행하면 위와 같이 일대다 통신도 정상적으로 실행된다. 위 이미지를 잘보면 데이터를 보낸 소켓의 Port 번호가 다른 것을 확인할 수 있다.

profile
🌈 즐겨보자고 🙌🏻

0개의 댓글