[소켓 #04] [실습] TCP 기반 Server/Client

이석환·2023년 4월 16일

Socket Programming

목록 보기
5/18

1. TCP/IP 프로토콜 스택

  • TCP/IP 프로토콜 스택이란 ?
    인터넷 기반의 데이터 송수신을 목적으로 설계된 스택
    큰 문제를 작게 나눠서 계층화한 결과
    데이터 송수신의 과정을 네 개의 영역으로 계층화한 결과
    각 스택 별 영역을 전문화하고 표준화함
    7계층으로 세분화가 되며, 4계층으로도 표현함

  • 즉, '인터넷 기반의 효율적인 데이터 전송'이라는 커다란 하나의 문제를 하나의 프로토콜 설계로 해결한 것이 아니라 그 문제를 작게 나눠서 계층화하려는 노력이 시도 되었고 그 결과로 탄생한 것이 TCP/IP 프로토콜 스택이며 TCP/UDP 소켓을 생성해서 데이터를 송수신할 경우에는 위의 FLOW를 따라 데이터를 송수신하게 된다.
  • 개방형 시스템
    프로토콜을 계층화해서 얻게 되는 장점은 어떤 것이 있을까 ?
    표준화 작업을 통한 "개방형 시스템 (Open System)"의 설계이다.
    표준이라는 것은 감추는 것이 아니라 활짝 열고 널리 알려서 많은 사람이 따르도록 유도하는 것이다.
    따라서 여러 개의 표준을 근거로 설계된 시스템을 가리켜 "개방형 시스템"이라 하며, TCP/IP 프로토콜 역시 개방형 시스템이다.

    예를 들어 IP 계층을 담당하는 라우터가 있다.
    A사의 라우터를 B사의 라우터로 교체가 가능하겠는가 ?
    당연히 가능하다. 반드시 같은 회사의 같은 모델로 교체해야 하는 것이 아니기 때문이다.
    모든 라우터 제조사들이 IP 계층의 표준에 맞춰서 라우터를 제작하기 때문이다.

    이와 같이 표준이 존재한다는 것은 그만큼 빠른 기술발전이 가능하도록 할 수 있게 한다.
    표준이라는 것은 기술의 발전에 있어서 중요한 요소이다.
  • LINK 계층
    물리적인 영역의 표준화 결과
    LAN, WAN, MAN과 같은 물리적인 네트워크 표준 관련 프로토콜이 정의된 영역
    물리적인 연결의 표준이 된다.

  • IP 계층
    IP는 Internet protocol을 의미
    경로의 설정과 관련이 있는 프로토콜

두 호스트가 인터넷을 통해 데이터를 주고 받으려면 물리적인 연결이 존재해야 한다.
해당 부분 표준을 LINK 계층에서 담당하고 있다.

물리적인 연결이 형성된 후에 데이터를 보낼 기본 준비가 되었다.
복잡하게 연결되어 있는 인터넷을 통한 데이터의 전송을 위해 선행되어야 할 일은 경로의 선택이다.
목적지로 데이터를 전송하기 위해서 중간에 어떤 경로를 거쳐갈 것인가를 해결하는 것이 IP 계층이다.
이 계층에서 사용하는 프로토콜이 IP(Internet Protocol)이다.

IP 자체는 비연결향적이며 신뢰할 수 없는 프로토콜이다.
데이터를 전송할 때마다 거쳐야 할 경로를 선택해 주지만, 그 경로는 일정치 않다.
특히 데이터 전송 도중에 경로상에 문제가 발생하면 다른 경로를 선택해 주는데, 이 과정에서 데이터가 손실되거나 오류가 발생하는 등의 문제가 발생한다고 해서 이를 해결해주지 않는다.
즉, 오류 발생에 대한 대비가 되어있지 않는 프로토콜이 IP이다.

1-2. TCP/ UDP 계층

  • TCP/UDP 계층
    실제 데이터의 송수신과 관련 있는 계층
    전송(Transport) 계층이라고도 한다.
    TCP는 데이터의 전송을 보장하는 프로토콜(신뢰성 있는 프로토콜)
    UDP는 데이터의 전송을 보장하지 않는 프로토콜(신뢰성이 없는 프로토콜)
    TCP는 신뢰성을 보장하기 때문에 UDP에 비해 복잡한 프로토콜이다.

IP 계층에서 경로의 검색을 해결해주니 그 경로로 데이터를 전송해주면 된다.
해당 계층에서는 그 역할을 담당한다.
데이터를 보낼 때 기반이 되는 프로토콜이 IP이다.
이것이 프로토콜이 스택의 구조로 계층화되어 있는 이유이다.
IP는 오로지 하나의 데이터 패킷(데이터 전송의 기본 단위)이 전송되는 과정에만 중심을 두고 설계되었다.
따라서 여러 개의 데이터 패킷을 전송해도 각 패킷이 전송되는 과정은 IP에 의해서 진행되므로 전송의 순서는 물론이고 전송 그 자체를 신뢰할 수 없다.
만약에 IP만을 이용해서 데이터를 전송한다면 패킷의 도착 순서도 보장할 수 없다.
전송 도중에 패킷의 손상이 일어날 수도 있다.
반면에 TCP 프로콜이 추가되어 데이터를 송수신한다면 데이터를 주고 받는 과정에서 서로 데이터의 주고 받음을 확인하며 분실된 데이터에 대해 재전송을 하게 만들 수 있다.
이것으로 IP가 데이터의 전송을 보장하지 않더라도 데이터의 전송을 신뢰할 수 있게 한다.

  • 즉, IP의 상위계층에서 호스트 대 호스트의 데이터 송수신 방식을 약속하는 것이 TCP/UDP이며, TCP는 확인 절차를 걸쳐서 신뢰성 없는 IP에 신뢰성을 부여한 프로토콜이다.

1-3. APPLICATION 계층

  • APPLICATION 계층
    응용프로그램의 프로토콜을 구성하는 계층
    소켓을 기반으로 완성하는 프로토콜을 의미
    소켓을 생성하면 위에 서술한 계층에 대한 내용은 감춰진다.
    프로그래머는 APPLICATION 계층의 완성에 집중하면 된다.

위에 서술한 계층에 대한 내용은 소켓을 생성하면 데이터 송수신과정에서 자동으로 처리되는 것들이다.
무엇인가를 만드는 과정에서 프로그램의 성격에 따라 서버와 클라이언트간의 데이터 송수신에 대한 약속들이 정해지는데 이를 가리켜 APPLICATION 프로토콜이라고 한다.
대부분의 네트워크 프로그래밍은 APPLICATION 프로토콜 설계 및 구현이 상당부분을 차지한다.

2. TCP 기반 Server의 구현


제일 먼저 socket 함수의 호출을 통해서 소켓을 생성한다.
주소 정보를 담기 위한 구조체 변수를 선언 및 초기화해서 bind 함수를 호출하여 소켓에 주소를 할당한다.
이후의 과정은 밑에서 서술하겠다.

2-1. 연결요청 대기 상태로의 진입 (Listen)

#include <sys/typeh.h>

int listen(int sock, int backlog);
// 성공시 0, 실패 시 -1 반환
/*
sock : 연결요청 대기상태에 두고자 하는 소켓의 파일 디스크립터 전달, 이 함수의 인자로 전달된 디스크립터의 소켓이 서버 소켓(리스닝 소켓)이 된다.
backlog : 연결요청 대기 큐(Queue)의 크기정보 전달, 5가 전달되면 큐의 크기가 5가 되어 클라이언트의 연결요청을 5개까지 대기시킬 수 있다.

bind 함수호출을 통해서 소켓에 주소까지 할당했다면 listen 함수를 통해 연결요청 대기상태로 들어가야 한다.
listen 함수가 호출되어야 클라이언트가 연결요청을 할 수 있는 상태가 된다.
즉, listen 함수가 호출되어야 클라이언트는 연결요청을 위해서 connect 함수를 호출할 수 있다.
(이 전에 connect 함수가 호출되면 error)

  • 클라이언트의 연결요청도 일종의 데이터 전송이기 때문에 이것을 받으려면 소켓이 있어야 한다.
    서버 소켓의 역할이 이것이며 연결 요청을 맞이하는 문지기라고 생각하면 편하다.
    클라이언트가 연결요청을 요구하면 서버 소켓에서 알려준다. 만약 현재 연결중인 클라이언트가 있으면
    대기실(연결요청 대기 큐)에 넣어준다.
    즉, 매개 변수 sock는 문지기, backlog는 대기실이다.

2-2. 클라이언트의 연결요청 수락 (accept)

#include <sys/socket.h>

int accept(int sock, struct sockaddr * addr, socklen_t * addlen);
//성공 시 생성된 소켓의 파일 디스크립터, 실패시 -1 반환

/*
sock: 서버 소켓의 파일 디스크립터 전달

addr: 연결요청 한 클라이언트의 주소정보를 담을 변수의 주소 값 전달, 
함수 호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소정보가 채워진다.

addrlen: 두 번째 매개변수 addr에 전달된 주소의 변수 크기를 바이트 단위로 전달,
단 크기 정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다.
그리고 함수호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워진다.
*/
  • accept 함수는 대기실(연결요청 대기 큐)에서 대기중인 클라이언트의 연결요청을 수락하는 기능의 함수이다.
    accept 함수는 호출 성공 시 내부적으로 데이터 입출력에 사용할 소켓을 생성하고, 그 소켓의 파일 디스크립터를 반환한다.
    중요한 점은 소켓이 자동으로 생성되어, 연결요청을 한 클라이언트 소켓에 연결까지 이뤄진다는 점이다.

3. TCP 기반 Client의 구현


클라이언트의 경우 소켓을 생성하고, 이 소켓을 대상으로 연결의 요청을 위해서 connect 함수를 호출하는 것이 전부이다.
connect 함수를 호출할 때 연결할 서버의 주소 정보도 함께 전달한다.

#include <sys/socket.h>

int connect(int sock, const struct sockaddr * servaddr, socklen_t addrlen);
// 성공 시 생성된 소켓의 파일 디스크립터, 실패시 -1 반환

/*
sock : 클라이언트 소켓의 파일 디스크립터 전달

servaddr : 연결요청한 클라이언트의 주소정보를 담을 변수의 주소 값 전달,
함수 호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소 정보가 채워진다.

addrlen : 두 번째 매개변수 servaddr에 전달된 주소의 변수 크기를 바이트 단위로 전달,
크기 정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다.
그리고 함수 호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워진다.
*/
  • 클라이언트에 의해서 connect 함수가 호출되면 둘 중 한가지 상황이 되어야 함수가 반환된다.
  1. 서버에 의해 연결요청이 접수되었다.
  2. 네트워크 단절 등 오류상황이 발생해서 연결요청이 중단되었다.

여기서 연결요청이 접수되었다는 뜻은 클라이언트의 연결요청 정보가 서버의 연결요청 대기 큐에 등록된 상황을 의미하는 것이다.
때문에 connect 함수가 반환되어도 서비스가 당장 이루어지지 않을 수 있다.

  • 서버를 구현하면서 거쳤던 과정은 bind를 통해서 서버 소켓에 IP와 PORT를 할당하는 것이였다.
    클라이언트는 소켓의 주소할당 과정이 없었다.
    그저 소켓을 생성하고 서버로의 연결을 위해서 connect 함수를 호출한 것이 전부였다.
    네트워크를 통해 데이터를 송수신하려면 IP와 PORT는 반드시 할당되어야 한다.
  • Client 소켓의 IP, PORT 할당
    언제 ? connect 함수가 호출될 때
    어디서 ? 운영체제에서, 정확히 커널에서
    어떻게 ? IP는 컴퓨터(호스트)에 할당된 IP, PORT는 임의로 선택
  • 즉, bind 함수를 통해서 소켓에 IP와 PORT를 직접 할당하지 않아도 connect 함수호출 시 자동으로 소켓에 IP와 PORT가 할당된다. 따라서 클라이언트 프로그램을 구현할 때에는 bind 함수를 명시적으로 호출할 필요가 없다.

4. TCP 기반 서버, 클라이언트의 함수호출 관계

전체적인 흐름을 정리하자면
서버는 socket()을 통해 소켓을 만들고 bind로 주소 정보를 소켓에 저장한다.
그리고 listen을 통해 대기 상태로 만든다.
클라이언트는 connect 함수 호출을 통해 연결요청을 한다.
여기서 connect는 서버에서 listen 이후에 가능하다는 사실을 기억하자.
추가로 클라이언트가 connect 함수를 호출하기에 앞서 서버가 accept 함수를 먼저 호출할 수 있다.
물론 이 때는 클라이언트가 connect 함수를 호출하기 전까지 서버는 accecpt 함수가 호출된 위치에서 블로킹 상태에 놓이게 된다.

  • listen 함수 호출 이후에 클라이언트의 connect 함수 호출이 유효한 이유
    listen 함수 호출 이후로 대기실이 생성되야 클라이언트에서 connect에서 연결요청을 보낼 수 있고 대기실에 등록할 수 있기 때문에 정확히는 listen 함수가 실행되어야 connect 함수의 반환조건이 성립한다.

5. Iterative 기반의 서버, 클라이언트 구현

  • 에코 서버
    에코 서버는 클라이언트가 전송하는 문자열 데이터를 그대로 재전송하는, 말 그대로 문자열 데이터를 에코 (echo) 시키는 서버이다.
  • Iterative 서버
    이 전까지 진행했던 실습 코드는 한 클라이언트의 요청에만 응답하고 바로 종료가 되었다.
    때문에 연결요청 대기 큐의 크기는 의미가 없었다.
    큐의 크기까지 설정해 놓았다면, 연결요청을 하는 모든 클라이언트에게 약속되어 있는 서비스를 제공해야 한다.
    계속해서 들어오는 클라이언트의 연결 요청을 수락하기 위해서 어떻게 해야할까 ?
    반복문을 삽입해서 accept 함수를 반복호출하면 된다.

위에 Flow에서 accept 함수 호출 후 read/write를 호출하고 있다.
여기서 close는 서버소켓을 닫는 것이 아니라 accept 함수의 호출과정에서 생성된 소켓을 대상이다.
close 함수까지 호출되었다면 한 클라이언트에 대한 서비스가 완료된 것이다.
그리고 다시 반복문을 통해 accept 함수를 호출하는 것이다.

요구조건
1. 서버는 한 순간에 하나의 클라이언트와 연결되어 에코 서비스를 제공한다.
2. 서버는총 다섯 개의 클라이언트에게 순차적으로 서비스를 제공하고 종료한다.
3. 클라이언트는 프로그램 사용자로부터 문자열 데이터를 입력 받아서 서버에 전송한다.
4. 서버는 전송 받은 문자열 데이터를 클라이언트에게 재전송한다. 즉, 에코 시킨다.
5. 서버와 클라이언트간의 문자열 에코는 클라이언트가 Q를 입력할 때까지 계속한다.

5-1. Iterative Server

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

void error_handling(char *message);

#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
	int serv_sock;
	int clnt_sock;
	char message[1024];
	int str_len = 0;
	int idx = 0, read_len = 0;

	struct sockaddr_in serv_addr;
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_size;
	
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_STREAM, 0);
	if(serv_sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
	serv_addr.sin_port=htons(atoi(argv[1]));
	
	if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1)
		error_handling("bind() error"); 
	
	if(listen(serv_sock, 5)==-1)
		error_handling("listen() error");
	
	clnt_addr_size=sizeof(clnt_addr); 

	for(int i = 0; i < 5; i++){
		clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
		if(clnt_sock==-1)
			error_handling("accept() error");  
		else
			printf("Connected client %d, client_sock: %d \n", i+1, clnt_sock);
		while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
			write(clnt_sock, message, str_len);
		
		close(clnt_sock);
		printf("close client socket: %d\n", clnt_sock);
	}
	close(serv_sock);
	return 0;
}

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

5-2. Iterative Client

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

#define BUF_SIZE 1024

void error_handling(char *message);

int main(int argc, char* argv[])
{
	int sock;
	struct sockaddr_in serv_addr;
	char message[BUF_SIZE];
	int str_len=0;
	int idx=0, read_len=0;
	
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_STREAM, 0);
	if(sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_addr.sin_port=htons(atoi(argv[2]));
		
	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
		error_handling("connect() error!");

	while(1){
		fputs("Input message(Q to quit): ", 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, BUF_SIZE-1);
		message[str_len] = 0;
		printf("Message from server: %s \n", message);
	}
	close(sock);
	return 0;
}

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

5-3. 에코 클라이언트의 문제점

위에 서술한 코드는 동작은 하지만 문제가 발생할 수 있다.

위의 코드에서는 "read, write 함수가 호출될 때마다 문자열 단위로 실제 입출력이 이뤄진다."라는 가정이 존재한다.
[#02]에서 언급했듯이 TCP는 데이터의 경계가 존재하지 않는다.

첫 번째 문제는 위에서 구현한 클라이언트는 TCP 클라이언트이기 때문에 둘 이상의 write 함수 호출이 이루어진 뒤 문자열이 묶여서 한 번에 서버로 전송될 수 있다.
그러한 상황이 발생하면 클라이언트는 한 번에 둘 이상의 문자열을 서버로부터 받아야 하기 때문에 원하는 결과를 받지 못한다.

두 번째 문제는 버퍼의 크기이다.
문자열의 길이가 긴 편이라면 OS는 두 개의 패킷, 혹은 그 이상의 패킷으로 나눠서 보낸다.
서버는 한 번의 write 함수호출로 데이터 전송을 명령했지만, 전송할 데이터의 크기가 크다면
OS는 내부적으로 이를 여러 개의 조각으로 나눠서 클라이언트에게 전송할 수 있다.
그리고 이 과정에서 데이터의 모든 조각이 클라이언트에게 전송되지 않았음에도 불구하고,
클라이언트는 read 함수를 호출할 지도 모른다.

이와 같은 문제들의 해결 방법은 [#05]에서 다루도록 하겠다.

참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab03

profile
반갑습니다.

0개의 댓글