본문 바로가기
CS/컴퓨터네트워크

[열혈] 단순한 서버-클라이언트 코드 구조의 이해

by sun__ 2020. 10. 19.

서버, 클라이언트 코드의 가장 단순한 버전을 이해하기 위한 글

 


 

1. 네트워크 프로그래밍과 소켓의 이해

 

네트워크 프로그래밍 : 네트워크로 연결된 서로 다른 컴퓨터가 서로 데이터를 주고받을 수 있도록 하는 것

 

소켓 : 물리적으로 연결된(ex. 인터넷) 네트워크 상에서의 데이터 송수신에 사용하는 운영체제가 제공하는 sw장치. 네트워크 망의 연결에 사용되는 도구. 네트워크를 통한 두 컴퓨터의 연결을 의미하기도 한다.

 

네트워크 프로그래밍을 소켓 프로그래밍이라고도 한다.

 

 

서버의 소켓 생성과정

1. socket() : 소켓 생성

2. bind() : IP주소와 PORT번호 할당

3. listen() : 연결요청 가능 상태로 변경

4. accept() : 연결요청 수락

 

클라이언트의 소켓 생성 과정

1. socket() : 소켓 생성

2. connect() : 연결요청

 

socket, bind, listen, accept, connect 함수는 <sys/socket.h> 헤더에 있음.


 

클라이언트-서버의 아주 단순한 코드

 

hello_server.c

#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, 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);
	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);
}

 

 

hello_client.c

#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, 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");

	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);
}

 


 

 

2. 소켓의 타입과 프로토콜 설정

 

프로토콜 : 컴퓨터 상호간의 대화에 필요한 통신규약

 

데이터 전송방식

 - SOCK_STREAM : 연결지향형 소켓. TCP에서 사용

  • 중간에 데이터가 소멸되지 않는다. 버퍼가 꽉차거 하는 특별한 경우 데이터를 못받을 수 있다. 이땐 재요청 해야 한다.

  • 전송 순서대로 데이터가 수신된다.

  • 데이터의 경계가 존재하지 않는다.

  • 소켓대 소켓 연결은 반드시 1:1구조

 - SOCK_DGRAM : 비연결지향형 소켓, UDP에서 사용

  • 전송순서와 상관없이 빠른 속도의 전송

  • 데이터 손실 가능

  • 데이터의 경계존재

  • 한 번에 전송할 수 있는 데이터의 크기 제한

 

int socket(int domain, int type, int protocol);

/*
domain : 소켓이 사용할 protocol family. IPv4를 의미하는 PF_INET 고정
type : 소켓의 데이터 전송방식. 연결지향을 의미하는 SOCK_STREAM 고정
protocol : IPPROTO_TCP 또는 0 고정. 
*/

//domain과 type이 정해져도 여러가지 프로토콜의 경우의 수가 나타나는 경우 protocol도 설정을 잘 해줘야 한다.
//우리는 그냥 0 고정임

 


 

3 주소체계와 데이터 정렬

 

3.1 소켓에 할당되는 IP와 PORT

 

IP : 인터넷 상에서 데이터를 송수신할 목적으로 컴퓨터에 부여되는 번호

PORT : 소켓(어플리케이션)에 부여되는 번호

 

IPv4 : 4바이트 주소체계

IPv6 : 16바이트 주소체계

 

IPv4는 클래스가 A,B,C,D,E로 나눠진다. 첫번째 바이트가 0으로 시작하면 A, 10으로시작하면 B, 110으로 시작하면 C이다. 네트워크 주소를 이용해서 네트워크(라우터)를 찾고, 호스트 주소를 이용해서 호스트를 구분한다.

그림에서 네트워크 ID는 라우터의 ID를 말한다.

 

 


 

 

3.2 주소정보의 표현

 

<sockaddr_in> : IPv4 주소표현을 위한 구조체

struct sockaddr_in{
    sa_family_t		sin_family;		//주소체계
    uint16_t		sin_port;		//16비트 PORT번호
    struct in_addr	sin_addr;		//32비트(4바이트) IP주소
    char			sin_zero[8];	//사용되지 않음
}
    
struct in_addr{
	in_addr_t		s_addr;			//32비트(4바이트) 인터넷 주소
}

/*  자료형 정리
sa_family_t : 주소체계
uint16_t : unsigned 16bit int
in_addr_t : uint32_t로 정의됨. unsigned 32bit int	*/

sin_family

  • AF_INET : IPv4
  • AF_INET6 : IPv6
  • AF_LOCAL : 로컬통신

sin_port : 16비트 PORT번호 저장. 네트워크 바이트 순서

 

sin_addr : 32비트 IP주소정보 저장. 네트워크 바이트 순서. 구조체지만 32비트 정수자료형이라고 간주해도 됨.

 

sin_zero : sockaddr_in과 sockaddr 자료형의 크기를 일치시키기위 해 삽입됨. 반드시 0으로 채워야 한다.

 

 

<sockaddr> : 주소체계, port, ip번호 포함한 구조체. bind의 두번째 인자의 자료형

struct sockaddr{
    sa_family_t		sin_family;	//주소체계
    char			sa_data[14];	//주소정보
};

sa_data에 저장되는 주소정보에 IP주소와 PORT번호가 모두 포함돼야 하고 남은 부분은 0으로 채울 것을 bind함수는 요구한다. 이를 프로그래밍하기엔 매우 불편하므로 sockaddr_in이 등장했다. 

 

sockaddr_in은 IPv4를 위해 정의된 구조체인데 굳이 sin_family를 지정해줘야 한다. sockaddr의 sin_family를 채워주기 위함이다.

 


3.3 네트워크 바이트 순서와 인터넷 주소 변환

컴퓨터에서 데이터를 처리할 때 빅 엔디안이나 리틀 엔디안을 사용한다. 서로 다른 방법을 사용하는 컴퓨터(호스트)끼리 통신할 때 원치않는 결과가 나올 수 있다. 따라서 네트워크 바이트 순서는 빅 엔디안으로 통일하였다. 참고로 인텔계열, amd계열 cpu는 리틀 엔디안 방식이다.

 

0x12345678

-> big endian : 0x12345678

-> little endian : 0x78563412

 

네트워크 바이트 순서로 데이터를 바꿔줘야 하는 경우는 sockaddr_in 구조체 변수에 데이터를 채울 때 뿐이다. 그 외에 데이터는 알아서 다 됨. 본인 시스템이 빅 엔디안이라도 hton으로 바꿔주는 것이 좋다. (실제론 아무일도 안일어난다.)

 

hton : host 바이트 순서 -> network 바이트 순서

 

 


 

3.4 인터넷 주소의 초기화와 할당

bind함수를 비롯해서 앞서 소개한 구조체의 활용에 대한 설명

 

sockaddr_in 안에 ip주소정보를 위한 멤버는 32비트 정수형이다. ip주소 201.211.214.36과 같은 정보를 32비트 정수형으로 변환할 수 있어야 한다. inet_addr, inet_aton함수가 이를 수행한다.

 

#include <arpa/inet.h>

in_addr_t inet_addr(const char* str);
//성공 시 빅 엔디안 32비트 정수값, 실패시 INADDR_NONE 반환

int inet_aton(const char* str, struct in_addr * addr);
//성공 시 1, 실패시 0 반환. 보통 이 함수 쓰게 됨
//str : 문자열 형 ip주소
//addr엔 정수로 변환된 ip주소를 저장할 in_addr 구조체 변수의 주소 전달 (보통 sockaddr_in 타입의 멤버)

 

 

inet_ntoa : inet_aton과 반대 기능을 하는 함수.

이 함수의 반환형이 char형 포인터라는 것은 이미 문자열이 메모리공간에 저장됐다는 뜻이다. 그런데 이 함수는 프로그래머에게 메모리공간의 할당을 요구하지 않는다. 대신 함수 내부적으로 메모리 공간을 할당해서 변환된 문자열 정보를 저장한다. 따라서 이 함수호출 후에는 가급적 반환된 문자열 정보를 다른 메모리 공간에 복사해 두는 것이 좋다. 다시 한번 inet_ntoa가 호출되면 전에 저장된 문자열 정보가 지워질 수 있기 때문이다. 정리하면 inet_ntoa함수가 재호출되기 전까지만 반환된 문자열의 주소값이 유효하니 별도의 메모리 공간에 복사를 해둬야 한다.

#include <arpa/inet.h>

char * inet_ntoa(struct in_addr adr);
//성공 시 변환된 문자열의 주소 값, 실패 시 -1 반환

 

 


4. TCP

인터넷을 통한 효율적인 데이터의 송수신이란 문제의 해결을 위해선 많은 분야의 전문가가 필요했고 그들의 상호 논의로 다양한 약속이 필요했다. 결국 문제를 영역별로 나눠서 해결하다보니 프로토콜이 여러 개 만들어졌다.

 

link 계층 : 물리적인 영역의 표준화. LAN, WAN, MAN과 같은 네트워크 표준에 해당

IP 계층 : internet protocol. 경로의 설정

TCP/UDP 계층 : 데이터의 전송. 이론 중간고사 범위

application 계층 : 프로그램의 성격에 따라 클라이언트와 서버 간의 데이터 송수신에 대한 약속들이 정해지기 마련이다. 이를 가르켜 application 프로토콜이라 한다. 대부분의 네트워크 프로그래밍은 application 프로토콜의 설계 및 구현이다.

 


@ bind, listen, accept, connect 설명

 

서버에서 listen함수가 호출돼야 클라이언트의 connect함수가 성공할 수 있다.

listen의 backlog 크기는 실험적 결과에 의해 결정하게 된다. (웹 서버 등은 최소 15)

 

서버소켓은 문지기 역할을 하며 연결 요청 대기 큐의 상태를 보면서 클라이언트들의 연결요청 처리를 한다.

accept로 어떤 클라이언트의 connect를 허용하면 새로운 accept에 의해 내부적으로 데이터 입출력에 사용할새로운 소켓이 생긴다.

#include <sys/socket.h>

int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);
//성공시 0, 실패시 -1 반환
/*
sockfd : 소켓의 파일 디스크립터
*myaddr : sockaddr_in의 주소를 sockaddr*으로 형변환하여 넘기는 것이 보통
addrlen : sizeof(myaddr)
*/

int listen(int sockfd, int backlog);
//성공시 0, 실패시 -1 반환
/*
sockfd : 소켓의 파일 디스크립터
backlog : 연결요청 대기 큐의 크기정보 전달.
*/

int accept(int sockfd, struct sockaddr * addr, socklen_t * addrlen);
//성공시 새로 생성된 소켓의 파일 디스크립터, 실패시 -1 반환
/*
sockfd : 서버 소켓의 파일 디스크립터
addr : 연결요청을 한 클라이언트의 주소정보를 담을 변수의 주소.
addrlen : addr에 전달된 주소의 변수 크기를 전달받을 변수의 주소
*/

int connect(int sockfd, struct sockaddr * servaddr, socklen_t addrlen);
//성공시 0 실패시 -1 반환
/*
sockfd : 클라이언트 소켓의 파일 디스크립터
servaddr : 연결요청할 서버의 주소정보를 담은 변수의 주소 값
*/

 

 

<hello_server.c>

//command line에 [  ./hserver 9190 	] 꼴로 입력. 서버의 포트번호를 9190으로 할당하라는 소리.
//ip는 INADDR_ANY 소켓이 돌아가는 컴퓨터의 IP주소가 자동으로 할당됨. 서버에선 많이 쓰나 클라이언트에선 쓸일이 적다.
    
    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, SOCK_STREAM, 0); //파일 디스크립터 할당
	if (serv_sock == -1) error_handling("socket() error");

	memset(&serv_addr, 0, sizeof(serv_addr));	//일단 sockaddr_in은 0으로 초기화해줘야함
	serv_addr.sin_family = AF_INET;				//고정
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);	//ip주소
	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_addr에 받고, 그 크기는 clnt_addr_size에 받는다.
    //클라이언트와 데이터를 주고받을 소켓을 새로 만들어 clnt_sock에 그 파일디스크립터를 넣는다.
	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;

 

<hello_client.c>

//command line에 [  ./hclient 127.0.0.1 9190  ] 꼴로 입력
//127.0.0.1은 루프백 주소(본인 컴퓨터 주소)를 의미함. 두 대의 컴퓨터로 실행하려면 ip주소 적절히 넘겨야함.

	int sock;		//파일 디스크립터
	struct sockaddr_in serv_addr;		//ip, port번호
	char message[30];		//받을 메세지
	int str_len;

	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));		//일단 0으로 초기화
	serv_addr.sin_family = AF_INET;			//IPv4 고정
	serv_addr.sin_addr.s_addr = inet_addr(argv[1]);	//ip주소
	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;

 

서버 입장에선 IP주소는 당연히 본인 컴퓨터일텐데 왜 IP를 지정해줘야 할까? NIC(랜카드)가 여러 대 있는 컴퓨터는 IP를 여러 개 갖기 때문이다. NIC가 한 대라면 주저없이 INADDR_ANY를 사용하면 된다.

'CS > 컴퓨터네트워크' 카테고리의 다른 글

[열혈] 멀티쓰레드 기반 서버구현  (0) 2020.12.22
[열혈] 멀티플렉싱 기반 서버구현  (0) 2020.11.24
[열혈] 멀티 프로세스 기반 서버구현  (2) 2020.10.25
[열혈] dns  (0) 2020.10.24
[열혈] half close  (0) 2020.10.24