CS(Computer Science)/UNIX

UNIX - [소켓 프로그래밍 기초 - 1]

seongmik 2022. 12. 26. 23:58
728x90
반응형

TCP/IP 프로토콜을 이용한 소켓 프로그래밍

 👉 학습목표
TCP/IP 프로토콜의 기본 개념을 이해한다.
IP 주소와 포트 번호의 개념을 이해한다.
소켓 관련 구조체와 함수를 이해한다.
소켓을 이용한 통신 프로그램을 작성할 수 있다.

 

TCP/IP 프로토콜

TCP/IP 프로토콜은 계층 구조를 구성하는 다양한 프로토콜 중에서 전송 계층의 대표적인 프로토콜인 “TCP 프로토콜”과 네트워크 계층의 대표적인 프로토콜인 “IP 프로토콜”을 묶어서 부르는 말이다.

 👉 프로토콜(Protocol) 통신 프로토콜 또는 통신 규약은 컴퓨터나 원거리 통신 장비 사이에서 메시지를 주고 받는 양식과 규칙의 체계이다. 통신 프로토콜은 신호 체계, 인증, 그리고 오류 감지 및 수정 기능을 포함할 수 있다.

TCP/IP 프로토콜은 계층 구조로 구성되어 있다.

 👉 TCP/IP 프로토콜은 총 5개의 계층으로 이루어져 있고 이 5개의 계층은 다음과 같다.
[응용 계층] : SMTP, Telnet, FTP, HTTP, …
[전송 계층] : TCP, UDP
[네트워크 계층] : IP, ARP, ICMP
[네트워크 접속 계층] : 이더넷, FDDI, …
[하드웨어 계층]
  • 응용 계층 (application layer) : 사용자에게 서비스를 제공하기 위한 계층이다. 응용 계층의 대표적인 프로토콜은 Telnet, FTP, HTTP, SMTP, DNS 등이다.
  • 전송 계층 (transport layer) : 패킷의 전송을 담당하는 계층이다. 전송 계층에는 TCP (Transmission Control Protocol)와 UDP(User Datagram Protocol) 프로토콜이 있다.
  • 네트워크 계층 (network layer) : 인터넷 계층이라고도 하며, 패킷이 전달되는 경로를 담당한다. 통상 TCP/IP라고 부르는 프로토콜 중에서 IP (Internet Protocol)가 이 계층에 속한 프로토콜이다. 이 계층에는 IP 외에도 ICMP, ARP 등이 있다.
  • 네트워크 접속 계층 (network access layer)과 하드웨어 계층 (hardware layer) : 물리적인 네트워크와의 연결을 담당한다. 일반적으로 이더넷 카드나 랜 카드라고 하는 부분이 이 계층에 해당한다.

TCP/IP 중 전송 계층에서는 TCP와 UDP 프로토콜을 제공한다.

TCP와 UDP의 차이점은 다음과 같다.

TCP는 전화를 걸 때처럼 데이터를 주고받기 전에 송신측과 수신측이 연결되어 있어야 한다. 그리고 송신한 데이터가 수신측에 도착했는지 확인하는 과정을 거친다. 또한 데이터를 주고받는 속도를 조절해 통신 효율이 떨어지거나 데이터가 손실되는 일을 방지할 수도 있다.

UDP는 TCP와 달리 사전에 목적지와 연결을 설정하지도 않고, 상대방이 데이터를 제대로 수신했는지도 확인하지 않는다. 단순히 목적지 주소를 지정해 네트워크로 전송하는 것이다. 중도에 데이터가 분실되어도 신경 쓰지 않는다. 따라서 신뢰성보다는 속도가 중요한 서비스에 주로 이용한다. 예를 들어, ping 서비스의 경우 대상 호스트가 동작하고 있는지를 빨리 알아내는 것이 중요하므로 UDP 프로토콜을 이용한다.

TCP/IP 프로토콜을 이용해 데이터를 주고받으려면 주소가 있어야 한다. 일반 우편으로 편지를 보낼 때 주소를 적는 것과 마찬가지다. TCP/IP 프로토콜에서 주소 관련 프로토콜은 IP다. TCP/IP 프로토콜을 이용하는 네트워크에서 주소는 IP 주소를 의미하며 점(.)으로 구분된 4바이트 정수로 표시한다. 예를 들어, 192.168.100.1과 같이 쓴다.

IP 주소는 데이터가 전송될 목적지 호스트(destination host)를 알려주는 역할을 한다. 그런데 목적지 호스트에는 여러 가지 기능을 수행하는 프로세스들이 동시에 동작하고 있을 수 있다. 따라서 전송되어 오는 데이터를 어느 프로세스가 수신해 서비스를 제공할 것인지를 알려줘야 한다. 이때 사용하는 것이 포트 번호(port number)다. 포트 번호는 2바이트 정수로 되어 있으므로, 0~65535까지 사용할 수 있다. 이를 잘 알려진 포트(well-known port)라고 하며, 0~1023까지 사용한다. 대표적인 포트 번호는 텔넷(Telnet) 프로토콜이 23, FTP가 21, HTTP가 80 등이다. 일반 프로그램에서는 0~1023을 제외한 1024~65535를 사용하면 된다.

TCP/IP 프로토콜을 이용해 응용 프로그램을 작성할 때 TCP 계층에서 제공하는 인터페이스 함수를 직접 사용해 프로그래밍하면 매우 복잡할 뿐만 아니라 관련 프로토콜의 내부구조를 잘 알고 있어야 한다. 이런 복잡한 작업을 간편하게 해주는 것이 소켓(socket) 인터페이스다. 소켓 인터페이스는 응용 계층에서 전송 계층의 기능을 사용할 수 있도록 제공하는 응용 프로그래밍 인터페이스(API, Application Programming Interface)다.

소켓 인터페이스는 응용 프로그램과 TCP 계층을 연결하는 역할을 한다. 소켓 인터페이스를 이용하면 전송 계층이나 네트워크 계층의 복잡한 구조를 몰라도 쉽게 네트워크 프로그램을 작성할 수 있다. 이 장에서는 소켓을 이용해 프로그래밍 하는 데 필요한 구조체와 함수를 살펴본다.

 👉 TLI를 이용한 네트워킹 전송 계층 인터페이스(TLI, Transport Layer Interface)는 소켓 인터페이스처럼 응용 계층과 전송 계층을 연결해주는 역할을 한다. TLI는 TCP/IP 프로토콜을 포함해 노벨 IPX 프로토콜 등 다양한 하부 프로토콜을 지원한다. TLI는 시스템 V 계열에서 개발되었으나 소켓 인터페이스에 밀려 현재는 유닉스 시스템 V의 소스 코드에서만 사용할 뿐 다른 분야에서는 사용하지 않는다. (사장된 기술)

 

IP 주소와 포트 번호

TCP/IP 프로토콜을 이용해 통신하려면 IP 주소가 있어야 한다. 또한 인터넷에서 동작하는 각종 서비스를 구분하기 위한 포트 번호를 지정해야 한다.

 👉 이 절에서의 목표
IP 주소와 포트 번호의 기본 개념 이해
관련 함수의 사용법을 배운다

 

IP 주소와 호스트명

IP 주소(IP address)는 인터넷을 이용할 때 사용하는 주소로, 점(.)으로 구분된 32비트 숫자로 표시한다(e.g. 192.168.100.51). IP 주소를 네트워크 주소(인터넷 주소)(network address(internet address))라고도 한다. IP 주소는 A~C 클래스로 구분된다.

시스템은 주소를 숫자로 구분하는 것이 효율적이지만, 사람은 주소를 이름으로 구분하는 것이 더 편하다. 따라서 시스템에는 IP 주소 외에 호스트명(hostname)을 지정한다. 예를 들어, 호스트명이 www.hanb.co.kr인 시스템의 IP 주소는 218.237.65.4다. 인터넷에서 사용하는 호스트명은 ‘호스트명+도메인명’ 형태로 구성된다. www.hanb.co.kr의 경우 www는 호스트명, hanb.co.kr은 도메인명이 된다. 도메인명(domain name)은 도메인을 관리하는 기관에 등록하고 사용해야 한다. 국내에서는 한국인터넷진흥원에서 kr 도메인을 관리하고 있다. 호스트명은 같은 도메인 안에서 중복되지 않게 시스템 관리자가가 정해 사용하면 된다. 호스트명과 도메인명을 관리하는 시스템을 DNS(Domain Name System)라고 한다.

호스트명과 IP 주소 변환

호스트명과 IP 주소를 등록해놓은 파일이나 데이터베이스를 검색해 호스트명이나 IP 주소를 찾을 수 있다. 이와 관련된 파일은 /etc/hosts며, 데이터베이스로는 제공하는 서비스에 따라 DNS일 수도 있고, NIS(Network Information Service)일 수도 있다. /etc/nsswitch.conf 파일에 어떤 데이터베이스를 어떤 순서로 활용하는지 지정하고 있다. 예를 들어, /etc/nsswitch.conf 에 지정된 값이 다음과 같다고 하자

hosts: files dns

이 설정의 의미는 호스트명과 IP 주소를 먼저 파일에서 찾고, 파일에서 찾지 못하면 DNS 서비스를 이용한다는 의미다.

여기서 파일이란 /etc/hosts 파일을 의미한다. 유닉스에서는 호스트명과 IP 주소를 변환하는 함수를 여러 가지 형태로 제공한다.

 

호스트명과 IP 주소 읽어오기 : gethostent(3), sethostent(3), endhostent(3)

#include <netdb.h>

struct hostent *gethostent(void);
int sethostent(int stayopen);
int endhostent(void);
/*
* stayopen : IP 주소 데이터베이스를 열어둘지 여부를 나타내는 값
*/

gethostent, sethostent, endhostent 함수는 호스트명과 IP 주소를 차례로 읽어온다.

gethostent 함수는 호스트명과 IP 주소를 읽어서 hosting 구조체에 저장하고 그 주소를 리턴한다.

sethostent 함수는 데이터베이스의 현재 읽기 위치를 시작 부분으로 재설정한다.

sethostent 함수는 gethostent 함수를 처음 사용하기 전에 호출해야 한다.

sethostent 함수의 인자인 stayopen 값이 0이 아니면 데이터베이스가 열린 채로 둔다.

endhostent 함수는 데이터베이스를 닫는다.

gethostent 함수는 데이터베이스의 끝을 만나면 널을 리턴한다.

sethostent 함수와 endhostent 함수는 수행을 성공하면 0을 리턴한다.

hostent 구조체는 다음과 같으며 <netdb.h>에 정의되어 있다.

struct hostent{
		char *h_name;
		char **h_aliases;
		int h_addrtype;
		int h_length;
		char **h_addr_list;
};

/*
* h_name : 호스트명을 저장한다.
* h_aliases : 호스트를 가리키는 다른 이름들을 저장한다.
* h_addrtype : 호스트 주소의 형식을 지정한다.
* h_length : 주소의 길이를 저장한다.
* h_addr_list : 해당 호스트의 주소 목록을 저장한다. 이 항목의 값을 해석하려면 2절에서 배우는 함수들이 필요하다.
*/

gethostent 함수를 사용해 /etc/hosts 파일의 내용을 읽어 출력하는 예제

#include <netdb.h>
#include <stdio.h>

int main(void) {
		struct hostent *hent;

		// 호스트 파일의 처음으로 읽기 위치를 설정한다.
		sethostent(0);

		// 호스트 파일에서 차례로 읽어 호스트명(h_name)을 출력한다.
		while ((hent = gethostent()) != NULL)
				printf("Name=%s\\n", hent-h_name);

		// 호스트 파일을 닫는다.
		endhostent();

		return 0;
}

⚡네트워크 프로그램은 그냥 컴파일할 경우 다음과 같이 오류가 발생한다.

# gcc ex11_1.c
정의되지 않음                   첫번째 참조된
기호                          파일:
endhostent                       /var/tmp/ccwQu9hN.o
gethostent                       /var/tmp/ccwQu9hN.o
sethostent                       /var/tmp/ccwQu9hN.o
ld:  치명적: 기호 참조 오류. a.out에 출력이 기록되지않음
collect2: ld returned 1 exit status

오류가 발생하는 이유는 endhostent, gethostent, sethostent 같은 함수가 표준 C 라이브러리에 정의되어 있지 않기 때문이다. 이 함수들은 nsl 라이브러리에 정의되어 있다. nsl 라이브러리는 /usl/lib 디렉토리에 위치하고 있으며, 파일명은 libnsl.so다. 유닉스 시스템에서 표준 C 라이브러리 외의 라이브러리를 지정할 때는 -l 뒤에 라이브러리명을 붙인다. 따라서 위의 예제의 경우 컴파일할 때 다음과 같이 -lnsl로 지정하면 된다

# gcc -o ex11_1.out ex11_1.c -lnsl

/etc/hosts 파일의 내용이 다음과 같다고 하자.

# cat /etc/hosts
# 
# Internet host table
# 
127.0.0.1         localhost
218.237.65.4      www.hanb.co.kr
192.168.162.133   hanbit

이 상태에서 위의 예제를 실행하면 다음과 같이 호스트명 부분을 출력한다.

[결과]
# ex11_1.out
Name=localhost
Name=www.hanb.co.kr
Name=hanbit

 

호스트명으로 정보 검색 : gethostbyname(3)

#include <netdb.h>

struct hostent *gethostbyname(const char *name);

/*
* name : 검색하려는 호스트명
*/

gethostbyname 함수는 호스트명을 인자로 받아 데이터베이스에서 해당 항목을 검색해 hostent 구조체에 저장하고 그 주소를 리턴한다.

 

IP 주소로 정보 검색 : gethostbyaddr(3)

#include <netdb.h>

struct hostent *gethostbyaddr(const char *addr, int len, int type);

/*
* addr : 검색하려는 IP 주소
* len : addr 길이
* type : IP 주소 형식
*/

gethostbyaddr 함수는 IP 주소를 인자로 받아 데이터베이스에서 해당 항목을 검색해 hostent 구조체에 저장하고 그 주소를 리턴한다. 첫 번째 인자인 addr에는 IP 주소가 저장된다. addr에 저장되는 주소는 변환을 수행한 것이다. 두 번째 인자인 len은 addr의 길이다. 세 번째 인자인 type에는 주소의 형식으로 <sys/socket.h> 파일에 정의된 주소 형식 중 하나를 지정해야 한다. <sys/socket.h> 파일에 정의된 주소 형식은 다음과 같으며. 이중 AF_UNIX와 AF_INET을 주로 사용한다.

 👉
AF_UNSPEC    0      / 미지정 /
AF_UNIX          1      / 호스트 내부 통신 /
AF_INET           2      / 인터네트워크 통신: UDP, TCP 등 /
AF_IMPLINK    3      / Arpanet의 IMP 주소 /
AF_PUP           4      / PUP 프로토콜 : BSP 등 /
AF_CHAOS      5      / MIT의 CHAOS 프로토콜 /
AF_NS              6      / XEROX의 NS 프로토콜 /

~ 29번째 까지 있다.

gethostbyname 함수와 gethostbyaddr 함수의 사용 예는 다음 절에서 살펴보자.

 

포트번호

IP 주소는 데이터가 전송될 목적지 호스트를 알려주는 역할을 한다. 그런데 목적지 호스트에는 여러 가지 기능을 수행하는 서비스 프로세스들이 동시에 동작하고 있을 수 있다. 예를 들면, 웹 서비스, 메일 서비스, FTP 서비스, 텔넷 서비스 등을 수행하는 프로세스들이 동작하고 있는 것이다. 따라서 전송되어 오는 데이터를 어느 서비스 프로세스에 전달할 것인지 구분할 수 있어야 한다. 마치 회사 주소로 배달된 우편물을 전산실 김대리에게 전달하려면 회사 주소뿐만 아니라 수신자명이 정확하게 있어야 하는 것과 마찬가지다. 인터넷에서도 IP 주소 외에 서비스를 구분하는 다른 정보가 필요하다. 이때 사용하는 것이 포트 번호다. 포트 번호(port number)는 2바이트 정수로 되어 있으므로 0~65545까지 사용할 수 있다. 인터넷에서 자주 사용하는 서비스는 이미 포트 번호가 지정되어 있다. 이를 잘 알려진 포트(well-known port)라고 하며, 0~1023까지 사용한다. 대표적인 포트 번호는 텔넷 프로토콜이 23, FTP가 21, HTTP가 80 등이다. 일반 프로그램에서는 0~1023을 제외한 1024~65535를 사용하면 된다. 이미 정해진 포트 번호는 /etc/services 파일에 등록되어 있다. 물론 유닉스는 /etc/services 파일에서 정보를 검색하는 함수를 제공한다.

 

포트 정보 읽어오기 : getservent(3), setservent(3), endservent(3)

#include <netdb.h>

struct servent *getservent(void);
int setservent(int stayopen);
int endservent(void);
/*
* stayopen : 포트 정보 데이터베이스를 열어둘지 여부를 나타내는 값
*/

getservent, setservent, endservent 함수는 포트 정보를 차례로 읽어온다. 이 함수들은 gethostent, sethostent, endhostent 함수와 같은 형태로 동작한다. getservent 함수는 포트 정보를 읽어서 servent 구조체에 저장하고 그 주소를 리턴한다. serservent 함수는 getservent 함수를 처음 사용하기 전에 호출해야 한다. setservent 함수의 인자인 stayopen 값이 0이 아니면 데이터베이스를 연 채로 둔다. endservent 함수는 데이터베이스를 닫는다. getservent 함수는 데이터베이스의 끝을 만나면 널을 리턴한다. setservent와 endservent함수는 성공하면 0을 리턴한다.

servent 구조체는 다음과 같으며 <netdb.h>에 정의되어 있다.

struct servent {
		char *s_name;
		char **s_aliases;
		int s_port;
		char *s_proto;
};

/*
* s_name : 포트명을 저장한다.
* s_aliases : 해당 서비스를 가리키는 다른 이름들을 저장한다.
* s_port : 포트 번호를 저장한다.
* s_proto : 서비스에 사용하는 프로토콜의 종류를 나타낸다.
*/

getservent 함수를 사용해 /etc/services 파일의 내용을 읽어 출력하는 프로그램을 작성해보자.

gerservent 관련 함수는 socket 라이브러리에 정의되어 있다. socket 라이브러리도 /usr/lib 디렉토리에 위치하고 있으며, 파일명은 libsocket.so다. 따라서 이번 예제는 컴파일할 때 다음과 같이 -lsocket으로 지정하면 된다.

# gcc -o ex11_2.out ex11_2.c -lsocket

 

getservent 함수로 포트 정보 읽어오기

#include <netdb.h>
#include <stdio.h>

int main(void) {
		struct servent *port;
		int n;

		// 포트 정보 데이터베이스에서 현재 읽기 위치를 시작으로 이동시킨다.
		setservent(0);

		// 처음 5개의 포트 정보를 차례로 읽어서 출력한다.
		for(n = 0; n < 5; n++) {
				port = getservent();
				printf("Name=%s, Port=%d\\n", port->s_name, port->s_port);
		}

		// 포트 정보 데이터베이스를 닫는다.
		endservent();

		return 0;
}

→ 실행 결과와 /etc/services의 파일의 내용을 비교해보면 포트 번호가 다를 것이다.

이는 servent 구조체에 저장되는 포트 번호의 바이트 순서가 다르기 때문이다.

바이트 순서 관련 내용은 다음 절에서 다루므로 여기서는 위 코드의 결과가 조금 이상하더라도 일단 넘어가자.

 

서비스명으로 정보 검색 : getservbyname(3)

#include <netbd.h>

struct servent *getservbyname(const char *name, const char *proto);

/*
* name : 검색할 포트명
* proto : "tcp" 또는 "udp"
*/

getservbyname 함수는 포트명을 인자로 받아 데이터베이스에서 해당 항목을 검색해 servent 구조체에 저장하고 그 주소를 리턴한다. 두 번째 인자인 proto에는 “tcp”나 “udp” 또는 NULL을 지정한다. 같은 서비스 포트가 TCP 서비스를 위한 번호와 UDP 서비스를 위한 번호로 구분되기 때문이다.

 

포트 번호로 정보 검색 : getservbyport(3)

#include <netdb.h>

struct servent *getservbyport(int port, const char *proto);

/*
* port : 검색할 포트 번호
* proto : "tcp" 또는 "udp"
*/

getservbyport 함수는 포트 번호를 인자로 받아 데이터베이스에서 해당 항목을 검색해 servent 구조체에 저장하고 그 주소를 리턴한다. 두 번째 인자인 proto에는 “tcp”나 “udp” 또는 NULL을 지정한다. 같은 서비스 포트가 TCP 서비스를 위한 번호와 UDP 서비스를 위한 번호로 구분되기 때문이다.

 

포트 번호로 정보 검색 : getservbyport(3)

#include <netdb.h>

struct servent *getservbyport(int port, const char *proto);

/*
* port : 검색할 포트 번호
* proto : "tcp" 또는 "udp"
*/

getservbyport 함수는 포트 번호를 인자로 받아 데이터베이스에서 해당 항목을 검색해 servent 구조체에 저장하고 그 주소를 리턴한다. 두 번째 인자인 proto에는 “tcp”나 “udp” 또는 NULL을 지정한다.

 

소켓 프로그래밍 기초

 👉 소켓은 응용 계층과 전송 계층을 연결하는 기능을 제공하는 프로그래밍 인터페이스다. 소켓을 이용해 TCP/IP 환경에서 프로그래밍할 경우 TCP나 UDP 프로토콜의 세부적인 내용에 대한 지식이 없어도 통신 프로그램을 작성할 수 있다. 이 절에서는 소켓 프로그래밍을 할 때 알아야 할 구조체와 함수 등 기본적인 사항을 살펴본다.

 

소켓의 종류

소켓(socket)은 크게 두 가지로 구분하는데, 같은 호스트에서 프로세스 사이에 통신할 때 사용하는 유닉스 도메인 소켓(unix domain socket)인터넷을 통해 다른 호스트와 통신할 때 사용하는 인터넷 소켓(internet socket)이 있다. 이 소켓들을 표시하는 이름으로는 <sys/socket.h>에 정의되어 있는 주소 패밀리명을 사용한다.

  • AF_UNIX : 유닉스 도메인 소켓
  • AF_INET : 인터넷 소켓

 

소켓의 통신 방식

TCP/IP 프로토콜에서 전송 계층에서 사용하는 프로토콜로는 TCP와 UDP가 있다.

소켓을 이용할 때도 하부 프로토콜로 TCP를 사용할 것인지, UDP를 사용할 것인지 지정해야 한다. 이는 미리 정의되어 있는 상수를 사용해 지정한다.

  • SOCK_STREAM : TCP 프로토콜 사용
  • SOCK_DGRAM : UDP 프로토콜 사용

따라서 소켓을 이용할 때는 소켓의 종류와 통신 방식에 따라 4가지 통신 유형이 나타난다.

  • AF_UNIX - SOCK_STREAM
  • AF_UNIX - SOCK_DGRAM
  • AF_INET - SOCK_STREAM
  • AF_INET - SOCK_DGRAM

 

소켓 주소 구조체

소켓을 이용한 프로그래밍에서는 소켓의 종류와 IP 주소, 포트 번호 등을 지정하기 위한 구조체를 사용한다. 소켓 구조체는 유닉스 도메인 소켓과 인터넷 소켓에서 각기 다른 형태를 사용한다.

유닉스 도메인 소켓의 주소 구조체

유닉스 도메인 소켓에 사용하는 주소 구조체는 다음과 같다. sockaddr_un 구조체는 <sys/un.h>에 정의되어 있다. sockaddr_un 구조체에는 주소 패밀리명과 경로명이 들어 있다. sun_family에는 AF_UNIX를 지정한다.

struct sockadr_un {
		sa_family_t sun_family;
		char        sun_path[108];
};

/*
* sun_family : 주소 패밀리명
* sun_path : 경로명
*/

인터넷 소켓의 주소 구조체

인터넷 소켓에 사용하는 주소 구조체는 다음과 같다. 주소 패밀리명과 포트 번호, IP 주소가 구조체 항목으로 들어 있다.

struct sockaddr_in {
		sa_family_t    sin_family;
		in_port_t      sin_port;
		struct in_addr sin_addr;
};

struct in_addr {
		in_addr_t s_addr;    /* 32비트 IP 주소(long) */
};

/*
* sin_family : 주소 패밀리명
* sin_port : 포트 번호
*/

 

바이트 순서 함수

컴퓨터에서 정수를 저장하는 방식(바이트 순서 (byte ordering))은 두 가지로, 각각 바이트를 순서대로 저장하는 빅 엔디언(big endian)과 거꾸로 저장하는 리틀 엔디언(little endian)이다.

빅 엔디언 방식은 메모리의 낮은 주소에 정수의 첫 바이트를 위치시킨다. 예를 들어, 0x1234를 저장할 경우

빅 엔디언은 0x12, 0x34의 순서대로 저장한다(최상위 바이트 우선 (most signficant byte first)).

반면 리틀 엔디언의 경우 메모리의 높은 주소에 정수의 첫 바이트를 위치시킨다. 따라서 0x1234를 저장하면

리틀 엔디언은 0x34. 0x12의 순서와 같이 거꾸로 저장한다(최하위 바이트 우선 (least significant byte first)).

인텔 계열(펜티엄)은 리틀 엔디언 방식을, 모토롤라(680x0)와 썬 SPARC은 빅 엔디언 방식을 사용한다.

컴퓨터마다 바이트를 저장하는 순서가 다르기 때문에 네트워크를 이용한 통신에서 바이트 순서는 주요 문제가 된다. 데이터를 보내는 컴퓨터와 받는 컴퓨터의 정수 저장 방식이 다르면 같은 값을 서로 다르게 해석하기 때문이다. 따라서 TCP/IP 에서는 데이터를 전송할 때 무조건 빅 엔디언을 사용해 데이터를 전송하기로 결정했다. 이를 네트워크 바이트 순서(NBO, Network Byte Order)라고 한다.

반면 호스트에서 사용하는 바이트 순서는 호스트 바이트 순서(HBO, Host Byte Order)라고 한다.

시스템에서 통신을 통해 데이터를 내보낼 때는 HBO에서 NBO로 순서를 바꿔서 전송하고, 데이터를 받으면 NBO에서 HBO로 데이터 순서를 변환한 후 처리해야 한다. NBO와 HBO 간에 바이트 순서를 변환해주는 함수를 사용해 이 작업을 수행할 수 있다.

#include <sys/types.h>
#include <netinet/in.h>
#include <inttypes.h>

uint32_t htonl(unit32_t hostlong);
uint16_t htons(unit16_t hostshort);
uint32_t ntohl(unit32_t hostlong);
uint16_t ntohs(unit16_t hostshort);

/*
* hostlong, hostshort : 호스트 바이트 순서로 저장된 값
* netlong, netshort : 네트워크 바이트 순서로 저장된 값
*/
  • hotel 함수는 32비트 HBO를 32비트 NBO로 변환한다.
  • htons 함수는 16비트 HBO를 16비트 NBO로 변환한다.
  • ntohl 함수는 32비트 NBO를 32비트 HBO로 변환한다.
  • ntohs 함수는 16비트 NBO를 16비트 HBO로 변환한다.

이 함수들은 socket 라이브러리에 정의되어 있다.

가장 최근의 예제를 살펴보면, servent 구조체는 통신과 관련된 정보를 저장하는 구조체므로 포트 번호를 NBO 형태로 저장한다. 따라서 호스트에서 이 값을 출력하려면 ntohs 함수로 변환해야 한다. ntohs를 사용하는 이유는 포트 번호가 16비트 정수(short)기 때문이다. 아래 예제에서 이를 확인해보자.

#include <netdb.h>
#include <stdio.h>

int main(void) {
		struct servent *port;
		int n;

		setservent(0);

		for (n = 0; n < 5; n++) {
				port = getservent();
				// s_port의 값을 ntohs 함수로 변환해 출력하도록 수정한다.
				printf("Name:%s, Port=%d\\n", port->s_name, ntohs(port->s_port));
		}
	
		endservent();

		return 0;
}

→ 실행 결과를 보면 /etc/services 파일에 정의되어 있는 값과 동일함을 알 수 있다.

getservbyname 과 getservbyport 함수를 사용해 포트 정보를 검색하는 예제를 살펴보자. 포트 번호를 정확하게 처리하려면 NBO와 HBO 사이의 변환이 필요하다.

#include <netdb.h>
#include <stdio.h>

int main(void) {
		struct servent *port;

		// 포트명을 검색하려면 getservbyname 함수에 서비스명을 인자로 지정한다.
		port = getservbyname("telnet", "tcp");
		printf("Name=%s, Port=%d\\n", port->s_name, ntohs(port->s_port));

		// 포트 번호로 검색하려면 포트 번호를 NBO로 지정해야 한다.
		// getservbyport 함수의 첫 번째 인자는 htons 함수를 사용해 포트 번호를 HBO에서 NBO로 변환해 지정한다.
		port = getservbyport(htons(21), "tcp");
		printf("Name=%s, Port=%d\\n", port->s_name, ntohs(port->s_port));

		return 0;
}

→ 실행 결과를 보면 텔넷 서비스는 23번 포트를, FTP 서비스는 21번 포트를 사용하고 있음을 알 수 있다.

 

IP 주소 변환 함수

IP 주소는 192.168.10.1 과 같이 점(.)으로 구분되는 형태다. 이 주소를 저장할 때 두 가지 방법이 있다. 시스템 내부적으로는 앞서와 같은 형태의 주소를 이진값으로 바꿔서 저장한다. 외부적으로 사용할 때는 문자열로 사용한다. 따라서 이진값과 문자열로 표시되는 IP 주소를 서로로 변환할 수 있는 함수를 제공한다.

문자열 형태의 IP 주소를 숫자 형태로 변환 : inet_addr(3)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

in_addr_t inet_addr(const char *cp);

/*
* cp : 문자열 형태 IP 주소
*/

inet_addr 함수는 IP 주소를 문자열로 받아 이를 이진값으로 바꿔서 리턴한다.

in_addr_t는 long 형이다.

구조체 형태의 IP주소를 문자열 형태로 반환 : inet_ntoa(3)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

char *inet_ntoa(const struct in_addr in);

/*
* in : in_addr 구조체 형태 IP 주소
*/

inet_ntoa 함수는 IP 주소를 in_addr 구조체 형태로 받아 점으로 구분된 문자열로 리턴한다.

IP 주소 변환 함수인 gethostbyaddr 함수를 사용해 호스트 정보를 검색하는 예제를 살펴보자.

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

int main(void) {
		in_addr_t addr;
		struct hostent *hp;
		struct in_addr in;
		
		// 문자열로 된 IP 주소를 이진 형태로 변환한다.
		if ((addr = inet_addr("218.237.65.4")) == (in_addr_t)-1) {
				printf("Error : inet_addr(218.237.65.4)\\n");
				exit(1);
		}

		// gethostbyaddr 함수에 정수형으로 변환된 IP 주소를 인자로 지정하고 호스트 정보를 검색한다.
		// gethostbyaddr 함수의 첫 번째 인자는 char* 이므로 형변환을 수행해 지정한다.
		// 주소는 4바이트 크기고, 인터넷 주소이므로 AF_INET을 지정한다.
		hp = gethostbyaddr((char *)&addr, 4, AF_INET);
		if (hp == NULL) {
				(void) printf("Host information not found\\n");
				exit(2);
		}

		// 검색된 호스트명을 출력한다.
		printf("Name=%s\\n", hp->h_name);

		// hostent 구조체의 항목으로 리턴된 IP 주소를 출력한다.
		// 이 IP 주소는 이진값으로 hostent 구조체의 항목에서 in_addr 구조체로 복사한 후 문자열 형태로 변환해야 한다.
		// memcpy에서 in_addr 구조체에 값을 지정하고, 다음 행에서 inet_ntoa 함수로 in_addr 구조체를 인자로 받아 문자열로 변환하고 출력한다.
		(void) memcpy(&in.s_addr, *hp->h_addr_list, sizeof(in.s_addr));
		printf("IP=%s\\n", inet_ntoa(in));

		return 0;
}

→ 실행 결과를 보면 호스트명과 IP 주소가 출력됨을 확인할 수 있다.

 

소켓 인터페이스 함수

 👉 소켓(socket)도 특수 파일(special file)의 하나다. 따라서 소켓을 이용해 네트워크 프로그래밍을 할 때는 소켓을 생성해 IP 주소와 연결한 후 서버와 클라이언트가 연결되면 소켓을 통해 읽고 쓰면 된다. 소켓을 이용해 데이터를 주고받으려면 다양한 소켓 관련 함수가 필요하며, 이 함수들을 순서에 맞게 호출해야 한다. 이 절에서는 소켓 인터페이스 함수의 종류와 호출 순서를 살펴본다.

 

소켓 인터페이스 함수

소켓을 이용해 네트워크 프로그래밍을 할 때 필요한 함수는 다음과 같다.

  • socket : 소켓 파일 기술자 생성
  • bind : 소켓 파일 기술자를 지정된 IP 주소/포트 번호와 결합(bind)
  • listen : 클라이언트의 접속 요청 대기
  • connect : 클라이언트가 서버에 접속 요청
  • accept : 클라이언트의 접속 허용
  • recv : 데이터 수신(SOCK_STREAM)
  • send : 데이터 송신(SOCK_STREAM)
  • recvfrom : 데이터 수신(SOCK_DGRAM)
  • sendto : 데이터 송신(SOCK_DGRAM)
  • close : 소켓 파일 기술자 종료

이들 함수 중 bind, listen, accept는 서버측에서만 사용한다.

반면 connect 함수는 클라이언트측에서만 사용한다.

나머지 socket, recv, recvfrom, sendto, close 함수는 서버와 클라이언트 모두 사용한다.

각 함수의 형식과 사용 방법을 알아보자.

 

소켓 생성하기 : socket(3)

#include <sys/types.h>
#include <sys/socket.h>

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

/*
* domain : 소켓 종류(유닉스 도메인 또는 인터넷 소켓)
* type : 통신 방식(TCP 또는 UDP)
* protocol : 소켓에 이용할 프로토콜
*/

socket 함수는 domain에 지정한 소켓의 형식과 type에 지정한 통신 방식을 지원하는 소켓을 생성한다.

protocol은 소켓에서 이용할 프로토콜로, 보통은 0을 지정한다. 이 경우 시스템이 protocol 값을 결정한다.

domain에는 도메인 또는 주소 패밀리를 지정한다. 유닉스 도메인 소켓을 생성할 경우 AF_UNIX를 지정하고, 인터넷 소켓을 생성할 경우 AF_INET을 지정한다. type에는 통신 방식에 따라 SOCK_STREAM이나 SOCK_DGRAM을 지정한다. socket 함수는 성공하면 소켓 기술자를, 실패하면 -1을 리턴한다.

다음은 인터넷 소켓을 생성하는 예다.

int sd;
sd = socket(AF_INET, SOCK_STREAM, 0);

 

소켓에 이름 지정하기 : bind(3)

#include <sys/types.h>
#include <sys/socket.h>

int bind(int s, const struct sockaddr *name, int namelen);

/*
* s : socket 함수가 생설한 소켓 기술자
* name : 소켓의 이름을 표현하는 구조체
* namelen : name의 크기
*/

socket 함수로 생성한 소켓을 사용하려면 소켓을 특정 IP 및 포트 번호와 연결해야 한다.

bind 함수는 socket 함수가 생성한 소켓 기술자 s에 sockaddr 구조체인 name에 지정한 정보를 연결한다.

sockaddr 구조체에 지정하는 정보 소켓의 종류, IP 주소, 포트 번호다. bind 함수는 수행을 성공하면 0을, 실패하면 -1을 리턴한다.

다음은 bind 함수로 소켓에 이름을 붙이는 예다.

IP 주소를 192.168.100.1로 지정하고 포트트 번호를 9000번으로 지정했다.

int sd;
struct sockaddr_in sin;

memset((char *)&sin, '\\0', sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(9000);
sin.sin_addr.s_addr = inet_addr("192.168.100.1");
memset(&(sin.sin_zero), 0, 8);

bind(sd, (struct sockaddr *)&sin, sizeof(struct sockaddr));

 

클라이언트 연결 기다리기 : listen(3)

#include <sys/types.h>
#include <sys/socket.h>

int listen(int s, int backlog);

/*
* s : socket 함수가 생성한 소켓 기술자
* backlog : 최대 허용 클라이언트 수
*/

listen 함수는 소켓 s에서 클라이언트의 연결을 받을 준비를 마쳤음을 알린다. 접속이 가능한 클라이언트 수는 backlog에 지정한다. listen 함수는 소켓이 SOCK_STREAM 방식으로 통신할 때만 필요하다.

다음은 클라이언트의 연결 요청을 받아들일 준비를 마쳤고, 최대 10개까지만 연결을 받겠다고 표현한 예다.

listen(sd, 10);

 

연결 요청 수락하기 : accept(3)

#include <sys/types.h>
#include <sys/socket.h>

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

/*
* s : socket 함수가 생성한 소켓 기술자
* addr : 접속을 수락한 클라이언트의 IP 정보
* addrlen : addr 크기
*/

accept 함수는 클라이언트의 연결 요청을 수락한다. 서버는 accept 함수를 사용해 소켓 s를 통해 요청한 클라이언트와의 연결을 수락한다. 이떄 addr에 클라이언트의 주소가 저장된다. addrlen에는 addr의 크기가 저장된다. 클라이언트의 연결 요청이 오면 새로운 소켓 기술자를 리턴한다. 서버는 이 새로운 소켓 기술자를 사용해 클라이언트와 데이터를 주고받을 수 있다. s가 가리키는 소켓 기술자는 추가 연결 요청을 기다리는 데 사용한다.

다음은 accept 함수를 사용하는 예다.

기존 소켓 기술자 sd를 통해 연결이 수락되면 새로운 기술자 new_sd가 리턴된다. clisin에는 클라이언트 주소가 저장된다.

int sd, new_sd;
struct sockaddr_in sin, clisin;

new_sd = accept(sd, &clisin, &sizeof(struct sockaddr_in));

 

서버와 연결하기 : connect(3)

#include <sys/types.h>
#include <sys/socket.h>

int connect(int s, const struct sockaddr *name, int namelen);

/*
* s : socket 함수가 생성한 소켓 기술자
* name : 접속하려는 서버의 IP 정보
* namelen : name의 크기
*/

connect 함수는 클라이언트가 서버에 연결을 요청할 때 사용한다.

connect 함수는 소켓 s를 통해 name에 지정한 서버에 연결을 요청한다. SOCK_STREAM 방식으로 통신할 때만 필요하다. connect 함수는 첫 번째 인자인 s가 가리키는 소켓을 두 번째 인자인 name이 가리키는 주소로 연결한다. 연결에 성공하면 0을, 실패하면 -1을 리턴한다.

다음은 IP 주소가 192.168.100.1인 서버에 9000번 포트로 연결하는 예다.

int sd;
struct sockaddr_in sin;

memset((char *)&sin, '\\0', sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(9000);
sin.sin_addr.s_addr = inet_addr("192.168.100.1");
memset(&(sin.sin_zero), 0, 8);

connect(sd, (struct sockaddr *)&sin, sizeof(struct sockaddr));

 

데이터 보내기 : send(3)

#include <sys/types.h>
#include <sys/socket.h>

ssize_t send(int s, const void *msg, size_t len, int flags);

/*
* s : socket 함수가 생성한 소켓 기술자
* msg : 전송할 메시지를 저장한 메모리 주소
* len : 메시지의 크기
* flags : 데이터를 주고받는 방법을 지정한 플래그
*/

send 함수는 소켓 s를 통해 크기가 len인 메시지 msg를 flags에 지정한 방법으로 전송한다.

마지막 인자인 flags에는 데이터를 주고받는 방법을 지정한다. 이 플래그에 지정할 수 있는 값은 다음과 같다.

  • MSG_OOB : 영역 밖의 데이터(out-of-band data)로 처리한다. 이는 SOCK_STREAM에서만 사용할 수 있다. 영역 밖의 데이터란 중요한 메시지가 아니라는 의미다. 이 플래그를 설정한 메시지를 보내고 수신 확인을 받지 않아도 다른 메시지를 계속 전송한다.
  • MSG_DONTROUTE : 데이터의 라우팅 설정을 해제한다. 이 플래그는 진단 프로그램이나 라우팅 프로그램에서 사용한다.

send 함수는 실제로 전송한 데이터의 바이트 수를 리턴한다. 리턴값이 지정한 크기보다 작으면 데이터를 모두 보내지 못했음을 의미한다. send 함수의 리턴값이 -1이면 데이터 전송 자체를 실패했다는 의미다.

다음은 간단한 메시지를 전송하는 예다.

char *msg = "Send Test\\n";
int len = strlen(msg) + 1;

if(send(sd, msg, len, 0) == -1) {
		perror("send");
		exit(1);
}

 

데이터 받기 : recv(3)

#include <sys/types.h>
#include <sys/socket.h>

ssize_t recv(int s, void *buf, size_t len, int flags);

/*
* s : socket 함수가 생성한 소켓 기술자
* buf : 전송받은 메시지를 저장할 메모리 주소
* len : buf의 크기
* flags : 데이터를 주고받는 방법을 지정한 플래그
*/

recv 함수는 소켓 s를 통해 전송받은 메시지를 크기가 len인 버퍼 buf에 저장한다. 마지막 인자인 flags는 send 함수에서 사용하는 플래그와 같다. recv 함수는 실제로 수신한 데이터의 바이트 수를 리턴한다.

다음은 버퍼의 크기를 80으로 설정하고 데이터를 수신하는 예다.

실제 수신한 데이터의 크기는 rlen 변수에 저장된다.

char buf[80];
int len, rlen;

if ((rlen = recv(sd, buf, len, 0)) == -1) {
		perror("recv");
		exit(1);
}

 

데이터 보내기 : sendto(3)

#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int s, const void *msg, size_t len, int flags, const struct sockaddr *to, int tolen);

/*
* s : socket 함수가 생성한 소켓 기술자
* msg : 전송할 메시지를 저장한 메모리 주소
* len : 메시지의 크기
* flags : 데이터를 주고받는 방법을 지정한 플래그
* to : 메시지를 받을 호스트의 주소
* tolen : to의 크기
*/

sendto 함수는 UDP 프로토콜로 데이터를 전송하는 함수다.

따라서 목적지까지 미리 경로를 설정하지 않고 데이터를 전송한다. 데이터그램 기반 소켓(SOCK_DGRAM)으로 통신을 할 때는 listen이나 accept 함수를 호출하지 않는다. 첫 번째 인자인 s로 지정한 소켓을 통해 msg가 가리키는 데이터를 to가 가리키는 목적지의 주소로 전송한다. send 함수와 달리 매번 목적지 주소를 지정해야 한다. sendto 함수는 실제로 전송한 데이터의 바이트 수를 리턴한다.

다음은 IP 주소가 192.168.10.1인 서버로 데이터를 전송하는 예다.

char *msg = "Send Test\\n";
int len = strlen(msg) + 1;
struct sockaddr_in sin;
int size = sizeof(struct sockaddr_in);

memset((char *)&sin, '\\0', sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(9000);
sin.sin_addr.s_addr = inet_addr("192.168.10.1");
memset(&(sin.sin_zero), 0, 8);

if (sendto(sd, msg, len, 0, (struct sockaddr *)&sin, size) == -1) {
		perror("sendto");
		exit(1);
}

 

데이터 받기 : recvfrom(3)

#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int s, void *buf, size_t len, int flags, struct sockaddr *from, int *fromlen);

/*
* s : socket 함수가 생성한 소켓 기술자
* buf : 전송받은 메시지를 저장할 메모리 주소
* len : 메시지의 크기
* flags : 데이터를 주고받는 방법을 지정한 플래그
* from : 메시지를 보내는 호스트의 주소
* fromlen : from의 크기
*/

recvfrom 함수는 UDP 프로토콜로 전달된 데이터를 수신하는 데 사용한다.

따라서 어디에서 메시지를 보내온 것인지 주소 정보도 함께 전달받는다. 다섯 번째 인자인 from에는 메시지를 발신한 시스템의 주소 정보가 저장된다. recvfrom 함수는 실제로 읽어온 데이터의 바이트 수를 리턴한다.

다음은 서버에서 전송한 데이터를 읽어오는 예다.

char buf[80];
int len, size;
struct sockaddr_in sin;

if (recvfrom(sd, buf, len, 0, (struct sockaddr *)&sin, &size) == -1) {
		perror("recvfrom");
		exit(1);
}

 

소켓 함수의 호출 순서

소켓 관련 함수를 호출하는 일반적인 순서는 아래 그림과 같다.

[서버]

  1. 서버 측에서는 socket 함수로 먼저 소켓을 생성한 후 bind 함수를 사용해 특정 포트와 연결한다.
  2. 그 후 클라이언트에서 오는 요청을 받을 준비를 마쳤다는 사실을 listen 함수를 통해 운영체제에 알리고, 요청이 들어오기를 기다린다.
  3. 클라이언트의 요청이 오면 accept 함수로 요청을 받고, send와 recv 함수를 통해 데이터를 주고받는다.
  4. 서버와 클라이언트 모두 close 함수를 사용해 통신을 종료한다.

[클라이언트]

  1. 클라이언트의 경우 socket 함수로 소켓을 만든 뒤 connect 함수로 서버와 연결을 요청한다.
  2. 서버에서 연결 요청을 받아들이면 send와 recv 함수로 데이터를 주고받는다.
  3. 서버와 클라이언트 모두 close 함수를 사용해 통신을 종료한다.

 

다음 글에서는 지금까지 공부한 함수들을 이용해서 소켓 프로그래밍을 실제로 어떻게 하는지 예제를 작성해볼 것입니다.

** 위 글은 한빛아카데미의 "유닉스 시스템 프로그래밍"의 436page 이후를 참고해서 작성했습니다. **

728x90
반응형

'CS(Computer Science) > UNIX' 카테고리의 다른 글

UNIX - [PIPE - 2]  (0) 2022.12.31
UNIX - [PIPE]  (3) 2022.12.30
UNIX - [MEMORY MAPPING]  (0) 2022.12.30
UNIX - [SIGNAL]  (1) 2022.12.29
UNIX - [소켓 프로그래밍 예제 - 2]  (1) 2022.12.27