나의 브을로오그으

#4. TCP기반 서버/클라이언트 1 본문

네트워크/열혈 TCP_IP 소켓프로그래밍

#4. TCP기반 서버/클라이언트 1

__jhp_+ 2022. 7. 24. 09:54

#1. TCP와 UDP에 대한 이해

인터넷 프로토콜 기반 소켓의 경우, 데이터 전송 방식에 따라 TCP 소켓(스트림 기반 소켓)과 UDP 소켓으로 나뉜다.

TCP(Transmission Control Protocol)의 약자로써 데이터 전송과정의 컨트롤 이라는 뜻을 담고 있다.

 

 

TCP/IP 프로토콜 스택

APPLICATION Layer > TCP Layer, UDP Layer > IP Layer > LINK Layer

이렇게 인터넷 기반의 효율적인 데이터 전송이라는 커다란 하나의 문제를 하나의 덩치 큰 프로토콜을 4개의 계층으로 나누어서 설계. (참고로 각 Layer는 물리적인 장치일수도 있고 소프트웨어이기도 하다.)

 

 

OSI 7Layer

데이터 통신에 사용되는 프로토콜 스택은 7계층으로 세분화된다. 그러나 앞서 4Layer와 같이 구분 짓기도 한다. 하지만 프로그래머 관점에서 4Layer로도 충분하다.

 

 

TCP/IP 탄생 배경

'인터넷을 통한 효율적인 데이터의 송수신'이라는 과제를 해결하기 위해 많은 전문가들이 모였었다. 하드웨어 전문가, 시스템 전문가, 라우팅 알고리즘 전문가 등 지금까지 우리는 소켓 생성과 활용에만 관심을 둬서 생각하지 못했지만 네트워크는 소프트웨어 가지고만 해결 할 수 있는 문제가 아니다. 이 문제는 많은 분야의 전문가가 필요하고, 상호 논의, 약속을 통해 해결되어야 하는 이슈들이다. 따라서 이 문제를 작게 나눠서 해결하는 것이 효율적이며, 이러다 보니 여러개의 프로토콜이 생겨났다.

 

 

개방형 시스템(Open System)

프로토콜의 계층화로 얻게되는 장점이 무엇일까? 여러가지가 있겠지만 그 중 가장 큰 장점은 표준화 작업을 통한 '개방형 시스템(Open System)'의 설계이다. 표준이라는 것은 여러 사람들에게 알려서 많은 사람들이 따르도록 유도하는 것이다. 따라서 여러 개의 표준을 근거로 설계된 시스템을 가리켜 '개방형 시스템'이라 하며, 우리가 지금 공부하고 있는 TCP/IP 프로토콜 스택 역시 개방형 시스템의 하나이다.

그러면 개방형 시스템의 장점은 무엇일까?

예를 들어 IP 계층을 담당하는 라우터라는 장비가 있다. 회사에서 A사의 라우터 장비를 사용하고 있는데, 이를 B사의 라우터 장비로 교체하려고 한다. 교체가 가능할까? 물론 가능하다. 이게 가능한 이유가 바로 IP 계층의 표준에 맞춰서 라우터 제작 회사들이 라우터를 제작하기 때문이다.

한가지 예를 더 들어보면, 우리의 컴퓨터에 네트워크 카드, 소위 랜카드가 달려있다. 그리고 이것 역시 랜카드 생산 회사들이 저마다 LINK 계층의 표준에 따라 만들기 때문에 꼭 같은 회사에서 생산한 제품이 아니더라도 쉽게 선택하고, 교체 할 수 있다. 이것이 개방형 시스템의 장점이다.

이렇듯 표준이 존재한다는 것은 그만틈 빠른 속도의 기술발전이 가능하다는 것을 의미한다. 그리고 이것이 시스템을 개방형으로 설계하는 가장 큰 이유이기도 하다. 사실 소프트웨어 공학에서의 '객체지향(Object Oriented)'의 탄생배경에도 소프트웨어의 발전을 위해서는 표준이 필요하다는 생각이 큰 몫을 차지했다. 그만큼 표준이라는 것은 기술의 발전에 있어서 중요한 요소이다.

 

 

LINK 계층

LINK 계층은 물리적인 영역의 표준화에 대한 결과이다. 이는 가장 기본이 되는 영역으로 LAN, WAN, MAN과 같은 네트워크 표준과 관련된 프로토콜을 정의하는 영역이다. 두 호스트가 인터넷을 통해 데이터를 주고받으려면 물리적으로 연결되어 있어야 한다.

 

 

IP 계층

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

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

 

 

TCP/UDP 계층

데이터의 전송을 위한 경로의 검색을 IP계층에서 해결해주니, 그 경로를 기준으로 데이터를 전송만하면 된다.

TCP와 UDP 계층은 이렇듯 IP계층에서 알려준 경로정보를 바탕으로 데이터의 실제 송수신을 담당한다. 때문에 이 계층을 가리켜 '전송(Transport)계층'이라 한다. 전송 계층에 존재하는 UDP는 TCP에 비해 상대적으로 간단하므로 별도로 빼겠다. TCP는 신뢰성 있는 데이터의 전송을 담당한다. 그런데 TCP가 데이터를 보낼 때 기반이 되는 프로토콜이 IP이다. 

(이것이 프로토콜이 스택의 구조로 계층화되어 있는 이유이다).

그럼 이 둘의 관계를 어떻게 이해해야 할까?

IP는 오로지 하나의 데이터 패킷(데이터 전송의 기본단위)이 전송되는 과정에만 중심을 두고 설계되었다.따라서 여러 개의 데이터 패킷을 전송한다 하더라도 각각의 패킷이 전송되는 과정은 IP에 의해서 진행되므로 전송의 순서, 전송 그 자체 역시 신뢰 할 수 없다. 만약 IP만을 이용해서 데이터 패킷을 전송한다면, 전송한 A 패킷보다 뒤에 전송한 B 패킷이 먼저 도달할 수도 있다. 그리고 이어서 전송한 A, B, C 패킷 중에서 A와 C 패킷만 전송될 수 있으며, 심지어 C 패킷은 손상된 상태로 전송 될 수도 있다. 반면에 TCP가 추가되어 데이터를 송수신하면 다음과 같은 대화를 주고받게 된다.

 

호스트 A : 두 번째 패킷까지 잘 받았다!

호스트 B : OK

 

호스트 A : 세 번째 패킷까지 잘 받았다!

호스트 B : 네 번째 패킷까지 보냈는데? 못 받았나 보네. 재전송 할게.

 

결론적으로 IP의 상위계층에서 호스트 대 호스트의 데이터 송수신 방식을 약속하는 것이 TCP 그리고 UDP이며, TCP는 확인절차를 걸쳐서 신뢰성 없는 IP에 신뢰성을 부여한 프로토콜이라 할 수 있다.

 

 

APPLICATION 계층

지금까지의 내용은 소켓 생성 시 데이터 송수신과정에서 자동으로 처리되는 것들이다. 데이터의 전송경로를 확인하는 과정이라든가 데이터 수신에 대한 응답의 과정이 소켓이라는 것 하나에 감춰져 있기 때문이다. 그러나 감춰져 있다는 표현보다는 이러한 일들을 프로그래머가 다 할 필요 없다는 의미로 해석하는 것이 정확하다. (몰라도 된다는 의미는 아니다.)

최종적으로 소켓이라는 도구가 우리에게 주어졌고, 우리는 이것을 이용하여 무엇인가를 만드는 과정에서 프로그램의 성격에 따라 클라이언트와 서버간의 데이터 송수신에 대한 약속(규칙)들이 정해지기 마련이다. 이를 가리켜 APPLICATION 프로토콜이라고 한다. 그리고 대부분의 네트워크 프로그래밍은 APPLICATION 프로토콜의 설계 및 구현이 상당부분을 차지한다.

 

 

 

#2. TCP기반 서버, 클라이언트 구현

 

TCP 서버에서의 기본적인 함수호출 순서

socket() 소켓생성 -> bind() IP,PORT 할당 -> listen() 연결요청 대기상태 -> accept() 연결 수락 -> read()/write() 데이터 송수신 -> close()  소켓소멸

 

 

연결요청 대기상태로의 진입

bind() 함수 호출로 소켓에 주소가 할당되었다면, 이번에는 listen() 함수호출을 통해서 '연결요청대기상태'로 들어갈 차례이다. 그리고 listen() 함수가 호출되어야 클라이언트가 연결요청을 할 수 있는 상태가 된다. 즉, listen() 함수가 호출되어야 클라이언트는 연결요청을 위해서 connect() 함수를 호출할 수 있다.(이전에 connect() 함수가 호출되면 error)

#include <sys/socket.h>
int listen(int sock, int backlog);
-> 성공 시 0, 실패 시 -1 반환

sock : 연결요청 대기상태에 두고자 하는 소켓의 파일 디스크립터 전달, 이 함수의 인자로 전달된 디스크립터의 소켓이 서버 소켓(리스닝 소켓)이 된다.

backlog : 연결요청 대기 큐(Queue)의 크기정보 전달, 5가 전달되면 큐의 크기가 5가 되어 클라이언트의 연결요청을 5개까지 대기시킬 수 있다.

 

여기서 '연결요청 대기상태'의 의미와 '연결요청 대기 큐'라는 것에 대해서 알아보자. 서버가 '연결요청 대기상태'에 있다는 것은 클라이언트가 연결요청을 했을 때 연결이 수락될 때까지 연결요청 자체를 대기시킬 수 있는 상태에 있다는 것을 의미한다. 이것을 받아들이려면 당연히 소켓이 하나 있어야 한다. 서버 소켓의 역할이 바로 이것이다. 즉, 연결 요청을 맞이하는, 일종의 문지기 또는 문의 역할을 한다고 볼 수 있다.

클라이언트가 "저기여 혹시 제가 감히 연결될 수 있나요?"라고 서버 소켓에게 물어보면, 서버 소켓은 아주 친절한 문지기이기 때문에 "아 물론이죠. 그런데 지금 시스템이 조금 바쁘니, 대기실에서 번호표 뽑고 기다리시면 준비되는 대로 바로 연결해 드리겠습니다."라고 말하며,  클라이언트의 연결요청을 대기실로 안내한다. listen함수가 호출되면, 이렇듯 문지기의 역할을 하는 서버 소켓이 만들어지고, listen함수의 두 번째 인자로 전달되는 정수의 크기에 해당하는 대기실이 만들어진다. 이 대기실을 가리켜 "연결 요청 대기 큐"라 하며, 서버 소켓과 연결요청 대기 큐가 완전히 준비되어서 클라이언트의 연결요청을 받아들일 수 있는 상태를 가리켜 '연결요청 대기상태'라 한다.

listen 함수의 두 번째 인자로 전달될 적절한 인자의 값은 서버의 성격마다 다르지만, 웹 서버와 같이 잦은 연결요청을 받는 서버의 경우에는 최소 15 이상을 전달해야 한다. 참고로 연결요청 대기 큐의 크기는 어디까지나 실험적 결과에 의존해서 결정하게 된다.

 

 

클라이언트의 연결요청 수락

listen 함수호출 이후에 클라이언트의 연결요청이 들어왔다면, 들어온 순서대로 연결요청을 수락해야 한다. 연결요청을 수락한다는 것은 클라이언트와 데이터를 주고받을 수 있는 상태가 됨을 의미한다. 따라서 이러한 상태가 되기 위해 무엇이 필요한지 짐작할 수 있을 것이다. 당연히 소켓이 필요하다! 전혀 이상할 것 없다. 데이터를 주고받으려면 소켓이 있어야 하지 않는가? 물론 우리가 서버 소켓을 생각하면서 이것을 사용하면 되지 않냐고 할 수 있지만, 서버 소켓은 문지기이다. 클라이언트와 데이터 송수신을 위해 이것을 사용하면 문은 누가 지킬까? 때문에 소켓을 하나 더 만들어야 한다. 하지만 우리가 소켓을 직접 만들 필요는 없다. 다음 함수의 호출결과로 소켓이 만들어지고, 이 소켓은 연결요청을 한 클라이언트 소켓과 자동으로 연결된다.

#include <sys/socket.h>

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

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

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

addrlen : 두 번째 매개변수 addr에 전달된 주소의 변수 크기를 바이트 단위로 전달, 단 크기정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다. 그리고 함수호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워진다.

 

accept() 함수는 '연결요청 대기 큐'에서 대기중인 클라이언트의 연결요청을 수락하는 기능의 함수이다.

따라서 accept 함수는 호출성공 시 내부적으로 데이터 입출력에 사용할 소켓을 생성하고, 그 소켓의 파일 디스크립터를 반환한다. 중요한 점은 소켓이 자동으로 생성되어, 연결요청을 한 클라이언트 소켓에 연결까지 이뤄진다는 점이다.

 

대기 큐(Queue)에 존재하던 연결요청 하나를 꺼내서 새로운 소켓을 생성한 후에 연결요청을 한다. 이렇듯 서버에서 별도로 생성한 소켓과 클라이언트 소켓이 직접 연결되었으니, 이제는 데이터를 주고받는 일만 남았다.

 

 

[hello_server.c]

/* Linux */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char* message);

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

	struct sockaddr_in serv_addr;
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_size;

	char message[] = "Hello World!";

	if (argc != 2) 
	{
		printf("Usage : %s <port> \n", argv[0]);
		exit(1);
	}

	serv_sock = socket(PF_INET, SOCKET_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);
	clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, clnt_addr_size);
	if (clnt_sock == -1)
		error_handling("accept() error");

	write(clnt_sock, message, sizeof(message));
	close(clnt_sock);
	close(serv_sock);

	return 0;
}

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

1. 서버 프로그램의 구현과정에서 제일 먼저 소켓을 생성한다.(socket()) 아직은 서버 소켓이라고 부르기 이른 상태이다.

2. 소켓의 주소할당을 위해 구조체 변수를 초기화하고(memset) bind() 함수를 호출한다.

3. 연결요청 대기상태로 들어가기 위해서 listen() 함수를 호출하고 있다. 연결요청 대기 큐의 크기도 5로 설정하고 있다. 이제야 비로서 아까 생성한 소켓을 서버 소켓이라 할 수 있다.

4. accept() 함수가 호출되어, 대기 큐에서 첫 번째로 대기 중에 있는 연결요청을 참조하여, 클라이언트와의 연결을 구성하고, 이 때 생성된 소켓의 파일 디스크립터를 반환한다. 참고로 이 함수가 호출될 때 연결요청 대기 큐가 비어있는 상태라면, 대기 큐가 찰 때까지, 다시 말해서 클라이언트의 연결요청이 들어올 때까지 accept() 함수는 반환되지 않는다.

5. write() 함수 호출을 통해서 클라이어늩에게 데이터를 전송하고 있다. 그리고는 close 함수호출을 통해서 연결을 끊고 있다.

 

 

TCP 클라이언트의 기본적인 함수호출 순서

클라이언트의 구현과정은 서버에 비해 매우 간단하다. 소켓 생성 -> 연결 요청이 전부이다.

socket() > connect() > read() / write() > close()

 

서버는 listen() 함수를 호출한 이후부터 연결요청 대기 큐를 만들어 놓는다. 따라서 그 이후부터 클라이언트는 연결요청을 할 수 있다.

#include <sys/socket.h>

int connect(int sock, struct sockaddr* servaddr, socklen_t addrlen);
-> 성공 시 0, 실패 시 -1 반환

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

servaddr : 연결요청 할 서버의 주소정보를 담은 변수의 주소 값 전달.

addrlen : 두 번째 매개변수 servaddr에 전달된 주소의 변수 크기를 바이트 단위로 전달

 

connet() 함수 호출 이후 함수가 반환되려면 다음 두 가지 중 한가지 이상 만족해야 한다.

- 서버에 의해 연결요청이 접수되었다.

- 네트워크 단절 등 오류상황이 발생해서 연결요청이 중단되었다.

 

※ 여기서 주의할 점은 연결 요청이 접수된 상태는 accept() 함수가 호출된 상태가 아니고, 연결요청 대기 큐에 등록된 상황을 의미한다. 때문에 connect() 함수가 반환했더라도 당장에 서비스가 이뤄지지 않을 수도 있음을 기억해야 한다.

 

 

클라이언트 소켓의 주소정보는 어디에?

서버를 구현하면서 반드시 거쳤던 과정 중 하나가 서버 소켓에 IP와 PORT를 할당하는 것이었다. 그런데 생각해보면 클라이언트 프로그램의 구현순서에는 소켓의 주소할당 과정이 없었다. 그저 소켓을 생성하고 서버로의 연결을 위해서 connect() 함수를 호출하는 것이 전부였다. 그렇다면 클아이언트 소켓은 IP와 PORT 할당이 불필요한 것일까? 아니다! 네트워크를 통해서 데이터를 송수신하려면 IP와 PORT번호를 반드시 할당해야 한다. 그러면 대체 어디서 할당하길래 이것이 가능할까? 그 비밀은 바로 connect() 함수 호출에 있다.

언제? connect() 함수가 호출 될 떄

어디서? 운영체제에서, 보다 정확하게는 커널에서

어떻게? IP는 컴퓨터에 할당된 IP로, PORT는 임의 선택하여

즉, bind() 함수를 통해서 소켓에 IP와 PORT를 직접 할당하지 않아도 connect() 함수호출 시 자동으로 소켓에 IP와 PORT가 할당된다. 따라서 클라이언트 프로그램을 구현할 때에는 bind() 함수를 명시적으로 호출할 필요가 없다.

 

 

[hello_clinet.c]

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

void error_handling(char* message);

int main(int argc, char* argv[]) 
{
	int sock;
	struct sockaddr_in serv_addr;
	char message[30];
	int str_len;

	if (argc != 3)
	{
		printf("Usage : %s <IP> <port> \n", argv[0]);
		exit(1);
	}

	sock = socket(PF_INET, SOCKET_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 = htonl(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!");

	str_len = read(sock, message, sizeof(message) - 1);
	if (str_len == -1)
		error_handling("read() error!");

	printf("Message from server : %s \n", message);
	close(sock);

	return 0;
}

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

1. 서버 접속을 위한 소켓을 생성한다. TCP 소켓이여야 한다.

2. 구조체 변수 serv_addr에 IP와 PORT정보를 초기화한다. 초기화된느 값은 연결을 목적으로 하는 서버 소켓의 IP와 PORT정보이다.

3. connect() 함수호출을 통해 서버로 연결요청을 보낸다.

4. 연결요청을 성공한 후 서버로부터 전송되는 데이터를 수신하고 있다.

5. 데이터 수신 후 close() 함수를 호출하여 소켓을 닫고 있다. 따라서 서버와의 연결은 종료된다.

 

 

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

이모든 과정은 독립적인것이 아닌 서로 상호작용을 하는 과정이다.

 

서버

socket() > bind() > listen() > accept() > read()/write() > close()

 

클라이언트

socket() > connect() > read()/write() > close()

 

정리

전체적인 흐름은, 서버는 소켓 생성 이후 bind, listen 함수의 연이은 호출을 통해 대기상태에 들어가고, 클라이언트는 connect 함수호출을 통해서 연결요청을 하게 된다. 특히 클라이언트는 서버 소켓의 listen 함수호출 이후에 connect 함수호출이 가능하다는 사실을 기억해야한다. 뿐만아니라 클라이언트가 connect 함수를 호출하기에 앞서 서버가 accept 함수를 먼조 호출할 수 있다는 것도 같이 기억해야한다. 물론 이때는 클라이언트가 connect 함수를 호출할 때까지 서버는 accept 함수가 호출된 위치에서 블로킹 상태에 놓인다.

 

 

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

에코서버는 클라이언트가 전송하는 문자열 데이터를 그대로 재전송하는, 말 그대로 문자열 데이터를 echo(에코)시키는 서버이다.

 

 

Iterative 서버의 구현

socket() > bind() > listen() > accept() > read()/write() > close(client) > close(server)

앞서 보인 hello 예제는 단일 클라이언트 요청 시 write()하고 그대로 끝나기 때문에 연결요청 대기 큐가 무색했다. 실제 서버에는 여러 클라이언트의 연결 요청을 받기에 이것을 어떻게 구현할까? 그냥 반복하면 된다. (accept()함수를 반복)

 

close(client)는 참고로 accept() 함수 호출 과정에서 생성된 소켓을 close하는 것이다. close() 함수까지 호출되었다면 한 클라이언트에 대한 서비스가 완료된 것이다. 그럼 이어서 또 다른 클라이언트에게 서비스하기 위해서 무엇을 해야할까? 또 그냥 accept() 함수를 호출하면 된다.

"그렇다면 결국 이거다! 서버에서는 한 순간에 하나의 클라이언트에게만 서비스를 제공한다는 사실!(은행창구마냥)"

(이후 프로세스, 쓰레드를 활용하면 동시에 둘 이상의 클라이언트에게 서비스 제공이 가능하다.)

 

 

Iterative 에코 서버, 에코 클라이언트

앞서 설명한 서버의 형태를 가리켜 Iterative 서버라고 부른다. 그리고 서버가 Iterative 형태로 동작한다 해도 클라이언트 코드에는 차이가 없다. Iterative 형태로 동작하는 에코 서버, 그리고 이와 함께 동작하는 에코 클라이언트를 작성해 보자.

[기본동작방식]

- 서버는 한 순간에 하나의 클라이언트와 연결되어 에코 서비스를 제공한다.

- 서버는 총 다섯 개의 클라이언트에게 순차적으로 서비스를 제공하고 종료한다.

- 클라이언트는 프로그램 사용자로부터 문자열 데이터를 입력 받아서 서버에 전송한다.

- 서버는 전송 받은 문자열 데이터를 클라이언트에게 전송한다. 즉, 에코 시킨다.

- 서버와 클라이언트간의 문자열 에코는 클라이언트가 Q를 입력할 때까지 계속한다.

 

 

[echo_server.c]

/* Linux */
#include <stdio.h>
#include <stdlib.h>
#include <string.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, i;

	struct sockaddr_in serv_adr, clnt_adr;
	socklen_t clnt_adr_sz;

	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_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");

	if (listen(serv_sock, 5) == -1)
		error_handling("listen() error");

	clnt_adr_sz = sizeof(clnt_adr);

	for (i = 0; i < 5; ++i)
	{
		clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
		if (clnt_sock == -1)
			error_handling("accept() error");
		else
			printf("Connect client %d \n", i + 1);

		while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
			write(clnt_sock, message, str_len);

		close(clnt_sock);
	}
	close(serv_sock);
	return 0;
}

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

- 총 5개의 클라이언트에게 서비스를 제공하기 위한 반복문이다. (결과적으론 accept() 를 5번 호출함)

- 에코 서비스이기 때문에 읽어 들인 문자열을 그대로 전송한다.

- 소켓을 대상으로 close() 함수가 호출되면, 연결되어있던 상대방 소켓에게 EOF가 전달된다. 즉, 클라이언트 소켓이 close() 함수를 호출하면 read()를 하는 서버쪽에서 false가 되어, close(clnt_sock)이 호출된다.

(결국 클라이언트쪽에서 먼저 소켓을 닫으면, EOF가 서버쪽으로 오고, 서버쪽에서 생성한 소켓이 read()를 하려고 block 되어있는데 0을 읽게되어서 빠져나오고 close() 된다.)

- 5개의 클라이언트에게 서비스를 제공하고 나면, 마지막으로 서버 소켓을 종료하면서 프로그램이 종료한다.

root@my_linux:/tcpip# gcc echo_server.c -o eserver
root@my_linux:/tcpip# ./eserver 9190
Connected client 1
Connected client 2
Connected client 3
...

 

 

[echo_client.c]

/* Linux */
#include <stdio.h>
#include <stdlib.h>
#include <string.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) 
	{
		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_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family = AF_INET;
	serv_adr.sin_addr.s_addr = htonl(argv[1]);
	serv_adr.sin_port = htons(atoi(argv[2]));

	if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
		error_handling("connect() error");
	else
		printf("Connected..........");

	while (1)
	{
		fputs("Input message(Q to q): ", 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", message);
	}
	close(sock);
	return 0;
}

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

- connect() 함수가 호출되고, 앞서 말한것처럼 함수호출 시 연결요청 정보가 서버의 대기 큐에 등록이 되면 connect() 함수는 정상적으로 호출을 완료한다. 때문에 Connect.... 라는 문자열이 출력되더라도 서버에서 accept() 함수를 호출하지 않은 상황이라면 실제 서비스가 이뤄지지 않음에 주의해야 한다.

- 이렇게 close() 함수가 호출되면 상대 소켓으로는 EOF가 전송된다. EOF는 연결의 끝을 의미한다.

root@my_linux:/tcpip# gcc echo_client.c -o eclient
root@my_linux:/tcpip# ./esclient 127.0.0.1 9190
Connected..........
Input Message(Q to quit): Good Morning
Message from server: Good Morning
Input Message(Q to quit): Hi
Message from server: Hi
Input Message(Q to quit): q
root@my_linux:/tcpip#

잘 동작하는것 같지만, 여기서 문제가 있다.

TCP는 전송되는 데이터의 경계가 없다.

 

에코 클라이언트의 문제점

write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1 );
message[str_len] = 0;
printf("Message from server: %s \n", message);

이 코드에는 잘못된 가정이 들어가 있다. "read, write 함수가 호출될 때마다 문자열 단위로 실제 입출력이 이루어진다."

TCP 클라이언트이기 때문에 둘 이상의 write 함수호출로 전달된 문자열 정보가 묶여서 한번에 서버로 전송될 수 있다. 그리고 그러한 상황이 발생하면 클라이언트는 한번에 둘 이상의 문자열 정보를 서버로부터 되돌려 받아서, 원하는 결과를 얻지 못할 수 있다. 그리고 서버가 다음과 같이 판단하는 상황도 생각해봐야 한다.

"문자열의 길이가 제법 긴 편이니, 문자열을 두 개의 패킷에 나눠서 보내야겠군!"

 

서버는 한번의 write 함수호출로 데이터 전송을 명령했지만, 전송할 데이터의 크기가 크다면, 운영체제는 내부적으로 이를 여러 개의 조각으로 나눠서 클라이언트에게 전송할 수 있다. 그리고 이 과정에서 데이터의 모든 조각이 클라이언트에게 전송되지 않았음에도 불구하고, 클라이언트는 read 함수를 호출할지도 모른다.

이 모든 문제는 TCP의 데이터 전송특성에서 비롯된 것이다. 그렇다면 이 문제를 어떻게 해결해야 할까?

물론 우리가 구현한 에코 서비스는 잘 동작한다. 그런데 이것은 순전히 운이 좋았던 것이다.! 송수신 데이터가 크기가 작어서 그런거고, 실제 실행환경이 하나의 컨퓨터 또는 근거리에 놓여있느 두 개의 컴퓨터이다 보니 오류가 발생하지 않은 것 뿐이다. 오류가 발생 할 확률은 아직 존재한다.

 

 

#4. 윈도우 기반으로 구현하기

 

[echo_server_win.c]

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")

#define BUF_SIZE 1024
void ErrorHandling(const char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET hServSock, hClntSock;
	char message[BUF_SIZE];
	int strlen, i;

	SOCKADDR_IN servAddr, clntAddr;
	int clntAdrSize;

	if (argc != 2)
	{
		printf("Usage : %s <port> \n", argv[0]);
		exit(1);
	}

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
	{
		ErrorHandling("WSAStartup() error");
	}

	hServSock = socket(PF_INET, SOCK_STREAM, 0);
	if (hServSock == INVALID_SOCKET)
	{
		ErrorHandling("socket() error");
	}

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

	if (bind(hServSock, (struct sockaddr*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
	{
		ErrorHandling("bind() error");
	}

	if (listen(hServSock, 5) == SOCKET_ERROR)
	{
		ErrorHandling("listen() error");
	}

	clntAdrSize = sizeof(clntAddr);

	for (i = 0; i < 5; ++i)
	{
		hClntSock = accept(hServSock, (struct sockaddr*)&hClntSock, &clntAdrSize);
		if (hClntSock == -1)
		{
			ErrorHandling("accept() error");
		}
		else
		{
			printf("Connected client %d \n", hClntSock);
		}

		while ((strlen = recv(hClntSock, message, BUF_SIZE, 0)) != 0)
		{
			send(hClntSock, message, strlen, 0);
		}
		closesocket(hClntSock);
	}
	closesocket(hServSock);
	WSACleanup();
	return 0;
}

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

 

 

윈도우 기반 에코 클라이언트

[echo_client_win.c]

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")

#define BUF_SIZE		1024

void ErrorHandling(const char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET hSocket;
	char message[BUF_SIZE];
	int strLen;
	SOCKADDR_IN servAdr;

	if (argc != 3)
	{
		printf("Usage : %s <IP> <port> \n", argv[0]);
		exit(1);
	}

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
	{
		ErrorHandling("WSAStartup() error");
	}

	hSocket = socket(PF_INET, SOCK_STREAM, 0);
	if (hSocket == INVALID_SOCKET)
	{
		ErrorHandling("socket() error");
	}

	memset(&servAdr, 0, sizeof(servAdr));
	servAdr.sin_family = AF_INET;
	servAdr.sin_addr.S_un.S_addr = inet_addr(argv[1]);
	servAdr.sin_port = htons(atoi(argv[2]));

	if (connect(hSocket, (struct sockaddr*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
	{
		ErrorHandling("connect() error");
	}
	else
	{
		printf("Connected..........\n");
	}

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

		if (!strcmp(message, "Q\n") || !strcmp(message, "q\n"))
		{
			break;
		}

		send(hSocket, message, strlen(message), 0);
		strLen = recv(hSocket, message, BUF_SIZE - 1, 0);
		message[strLen] = 0;
		printf("Message from server: %s \n", message);
	}
	closesocket(hSocket);
	WSACleanup();
	return 0;
}

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

 

실행

// echo_server_win.c
C:tcpip\> server 9190
Connected client 1
Connected client 2


// echo_client_win.c
C:tcpip\> client 127.0.0.1 9190
Connected...........
Input message(Q to q): I really
Message from server: I really
Input Message(Q to q): Q

 

 

[내용 확인문제]

01. TCP/IP 프로토콜 스택을 4개의 계층으로 구분해 보자. 그리고 TCP 소켓이 거치는 계층구조와 UDP 소켓이 거치는 계층구조의 차이점을 설명해보자.

답)

LINK Layer > IP Layer > TCP/UDP Layer > APPCLIATION Layer

IP계층에서 알려준 경로정보를 바탕으로 데이터의 실제 송수신을 담당한다. 때문에 이 계층을 '전송(Transport)계층'이라고 하는데, UDP는 전송 계층이 존재하고, TCP는 신뢰성 있는 데이터의 전송을 담당한다.

 

02. TCP/IP 프로토콜 스택 중에서 LINK 계층과 IP 계층이 담당하는 역할이 무엇인지 설명해보자. 그리고 이 둘의 관계도 함께 설명해보자.

답)

LINK계층은 물리 계층을 표준화한 계층으로 가장 기본이 되는 LAN, MAN, WAN과 같은 네트워크 표준과 관련된 프로토콜을 정의하는 영역이다. 두개의 호스트가 서로 데이터를 주고받으려면 물리적으로 연결이 되어있어야 하는데 이 부분에 대한 표준을 LINK 계층에서 담당한다. IP계층은 데이터 전송을 위해 어떤 경로를 통해 목적지로 갈건지 경로 검색을 담당하는 프로토콜이다. 추가적으로 IP는 비 연결지향적이며 신뢰할 수 없는 프로토콜이다.

두 프로토콜은 상,하위계층으로 붙어있는데 물리적인 연결을 담당하는 LINK계층에서 표준에 따라 연결하면 연결된 표준을 보고 IP계층에서 경로를 검색한다.

 

03. TCP/IP 프로토콜 스택을 4개의 계층(또는 7개의 계층)으로 나누는 이유는 무엇인가? 이를 개방형 시스템에 대한 설명과 함께 답해보자.

답)

개방형 시스템(Open System)은 '표준'을 근거로 설계된 시스템을 의미한다. '표준'은 어떤 기준을 널리 알려서 많은 사람들로 하여금 따르도록 유도하는 것을 의미한다. 프로토콜 스택을 계층별로 나누는 이유는 '인터넷 기반의 효율적인 데이터 전송'이라는 커다란 하나의 문제를 하나의 덩치 큰 프로토콜 설계로 해결하는 것이 아닌, 그 문제를 작게 나누어 계층화함으로써 각 계층을 담당하는 역할에 집중 할 수 있도록 하기 위함이다.

 

04. 클라이언트 connect 함수호출을 통해서 서버로의 연결을 요청한다. 그렇다면 클라이언트는 서버가 어떠한 함수를 호출한 이후부터 connect 함수를 호출할 수 있는가?

답)

listen() 함수 호출 이후부터 connect() 함수를 호출 할 수 있다.

 

05. 연결요청 대기 큐라는 것이 생성되는 순간은 언제이며, 이것이 어떠한 역할을 하는지 설명해 보자. 그리고 accept() 함수와의 관계도 함께 설명해보자.

답)

서버측에서 listen() 함수가 호출 된 이후 연결요청 대기 큐가 생성된다. 이곳에서는 클라이언트의 연결 요청을 대기시켜 놓는 장소이다. 서버측에서 accept() 함수가 호출되면 연결요청 대기 큐에 있는 연결요청을 처리하게 된다.

 

06. 클라이언트 프로그램에서 소켓에 주소정보를 할당하는 bind 함수호출이 불필요한 이유는 무엇인가? 그리고 bind 함수를 호출하지 않았을 경우, 언제 어떠한 방식으로 IP주소와 PORT번호가 할당되는가?

답)

connet() 함수를 호출하면 OS에서 내부적으로 호스트 IP와 PORT번호를 임의로 할당해주므로 bind() 함수가 불필요하다.

 

07. Chapter 01에서 구현한 예제 hello_server.c, hello_server_win.c를 iterative 모델로 변경하고, 제대로 변경이 되었는지 클라이언트와 함께 테스트해보자.

답)

Iterative 모델로 변경하는 것이 같기 때문에 Window에서 테스트만 함.

[hello_server_win_4.c]

/* Iterative */
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")


void ErrorHandling(const char* message);

int main(int argc, char* argv[]) {
	WSADATA wsaData;
	SOCKET hServSock, hClntSock;
	SOCKADDR_IN servAddr, clntAddr;

	int szClntAddr;
	char message[] = "Hello World!";
	if (argc != 2)
	{
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
	{
		ErrorHandling("WSAStartup() error!");
	}

	hServSock = socket(PF_INET, SOCK_STREAM, 0);
	if (hServSock == INVALID_SOCKET)
	{
		ErrorHandling("socket() error!");
	}

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

	if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
	{
		ErrorHandling("bind() error!");
	}

	if (listen(hServSock, 5) == SOCKET_ERROR)
	{
		ErrorHandling("listen() error!");
	}

	szClntAddr = sizeof(clntAddr);
	for (int i = 0; i < 5; ++i) 
	{
		hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr);
		if (hClntSock == INVALID_SOCKET)
		{
			ErrorHandling("accept() error!");
		}
		else
		{
			printf("Connected client %d \n", hClntSock);
		}
		send(hClntSock, message, sizeof(message), 0);
		closesocket(hClntSock);
	}

	closesocket(hServSock);
	WSACleanup();
	return 0;
}

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

 

[hello_client_win_4.c]

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")


void ErrorHandling(const char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET hSocket;
	SOCKADDR_IN servAddr;

	char message[30] = { 0 };
	int strLen;
	if (argc != 3)
	{
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
	{
		ErrorHandling("WSAStartup() error!");
	}

	hSocket = socket(PF_INET, SOCK_STREAM, 0);
	if (hSocket == INVALID_SOCKET)
	{
		ErrorHandling("socket() error!");
	}

	memset(&servAddr, 0, sizeof(servAddr));
	servAddr.sin_family = AF_INET;
	servAddr.sin_addr.S_un.S_addr = inet_addr(argv[1]);
	servAddr.sin_port = htons(atoi(argv[2]));

	if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
	{
		ErrorHandling("connect() error!");
	}

	strLen = recv(hSocket, message, sizeof(message) - 1, 0);
	if (strLen == -1)
	{
		ErrorHandling("recv() error!");
	}
	printf("Message from server: %s \n", message);

	closesocket(hSocket);
	WSACleanup();
	return 0;
}

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