본문 바로가기

UNIX_LINUX_C_C++

[펌] 제2장 UNIX BSD 소켓 시스템 콜

소강좌제목 : 컴퓨터 네트웍 프로그래밍이전강좌: 제1장 네트웍 프로그래밍의 이해 다음강좌: 제3장 클라이언트 서버 토크프로그램


제2장 UNIX BSD 소켓 시스템 콜

2장에서는 트랜스포트 계층의 네트웍 프로그래밍 API 가장 널리 사용되고 있는 UNIX 소켓(socket) 대하여 상세히 배운다. 먼저 2.1절에서는 소켓의 정의, 소켓번호와 소켓주소의 개념 그리고 소켓을 이용한 네트웍 프로그래밍 작성 방법을 설명한다. 2.2절에서는 IP 주소변환 소켓주소의 여러가지 처리에 대하여 설명한다. 2.3절에서 기본적인 클라이언트 프로그램 작성 방법을, 2.4절에서 서버 프로그램 작성 방법을 소개하며 끝으로 2.5절에서는 소켓 프로그래밍에 필요한 UNIX 운영체제의 시스템 signal() fork() 소개한다.

2.1 소켓의 이해

2.1.1 소켓의 정의

▶ 소켓(socket)은 1982년 BSD(Berkeley Software Distribution) UNIX 4.1에서 처음 소개되으며 현재 널리 사용되는 것은 1986년의 BSD UNIX 4.3에서 개정된 것이다.

▶ 소켓은 소프트웨어로 작성된 통신 접속점이라고 할 수 있는데 네트웍 응용 프로그램은 소켓을 통하여 통신망으로 데이터를 송수신하게 된다.

▶ 그림 2-1에 세 개의 응용 프로그램이 각각 소켓을 통하여 TCP/IP를 공유하고 있는 것을 나타냈다.

▶ 소켓은 응용 프로그램에서 TCP/IP를 이용하는 창구 역할을 하며 응용 프로그램과 소켓 사이의 인터페이스를 소켓 인터페이스라고 한다.

▶ 한 컴퓨터내에는 보통 한 세트의 TCP/IP가 수행되고 있으며, 네트웍 드라이버는 LAN 카드와 같은 네트웍 접속 장치(NIU: Network Interface Unit)를 구동하는 소프트웨어를 말한다.

그림 2-1 소켓 인터페이스의 위치

2.1.2 소켓번호

▶UNIX에서 파일을 새로 열면(open) int 타입의 정수를 리턴하는데 이와같이 open문이 리턴한 정수를 파일기술자(file descriptor)라고 하며 프로그램에서 open된 파일을 액세스할 때 이 파일기술자를 사용하게 된다.

▶ 파일기술자는 기술자 테이블(descriptor table)의 index 번호인데(그림 2-2 참조), 기술자 테이블이란 현재 open되어 있는 파일의 각종 정보를 포함하고 있는 구조체를 가리키는 포인터들로 구성된 테이블이다.

▶ 예를들어 한 응용 프로그램내에서 2개의 파일을 open하면 파일기술자는 3과 4가 배정된다

 

그림 2-2 파일 기술자와 소켓 기술자

▶ 프로그램에서 소켓을 개설하면 파일기술자와 똑같은 기능을 하는 소켓기술자(socket descriptor)가 리턴된다.

▶ 응용 프로그램에서 이 소켓을 통하여 목적지 호스트와 연결을 요구하거나 패킷을 송수신할 때 해당 소켓기술자를 사용하게 된다(이 책에서는 편의상 소켓기술자를 소켓번호라고 부르겠다).

▶ UNIX에서는 파일기술자와 소켓기술자가 같은 기술자 테이블의 index가 된다. 즉, 파일과 소켓이 기술자 테이블을 공유한다.

▶ 한 프로세스에서 파일 open시 리턴되는 파일기술자와 소켓 개설시 리턴되는 소켓기술자의 값은 서로 중복된 것이 없게 된다.

▶ 그림 2-2에는 두 개의 파일과 한 개의 소켓을 개설하였을 때의 기술자 테이블과 기술자 테이블이 가리키는 파일 또는 소켓 데이터 구조체를 나타내고 있다.

▶ 그림 2-3에 응용 프로그램과 소켓 그리고 TCP/IP의 관계를 구체적으로 나타냈다.

▶ 네 개의 응용 프로그램이 소켓번호로 각각 4, 3, 3, 3을 사용하고 있는 것을 나타냈다(응용 프로그램 1은 파일을 하나 먼저 열고 있으므로 소켓번호가 4가 된 것이다).

▶ 한편 소켓번호는 응용 프로그램내에서 순서대로 배정되며 그 프로그램내에서만 유일하게 구분되면 되는 것이므로 서로 다른 응용 프로그램에서 같은 소켓번호를 사용하는 것은 문제가 되지 않는다.

▶ 포트번호는 TCP/IP가 지원하는 상위 계층의 프로세스를 구분하기 위한 번호이므로 하나의 컴퓨터내에 있는 응용 프로세스들은 반드시 서로 다른 포트번호를 사용하여야 한다.

▶ 그림 2-3에서는 네 개의 응용 프로그램이 3000번부터 3003번의 포트번호를 사용하는 것을 가정하였다.

▶ 그림 2-3에서 연결형 서비스는 TCP가 그리고 비연결형 서비스는 UDP가 각각 처리하는 것을 보였다.

 

그림2-3 응용프로그램과 소켓 그리고 TCP/IP

▶ IP 주소 192.203.144.11은 이 컴퓨터에 배정된 IP 주소인데 예를들어 목적지 IP 주소가 192.203.144.11인 IP 패킷은 모두 그림 2-3의 컴퓨터로 전달된다.

▶ 이 패킷을 수신할 응용 프로그램은 TCP(또는 UDP) 헤더에 있는 16비트의 포트번호를 참조하여 구분된다(그림 1-12, 1-13 참조).

 

2.1.3 소켓의 이용

▶ 소켓을 이용한 네트웍 응용 프로그램에서 상대방과 IP 패킷을 주고받기 위하여는 다음의 다섯 가지 정보가 정해져야 한다.

① 통신에 사용할 프로토콜(TCP 또는 UDP)

② 자신의 IP 주소

③ 자신의 포트번호

④ 상대방의 IP 주소

⑤ 상대방의 포트번호

▶ 통신에 사용할 프로토콜은 연결형 또는 비연결형을 말하는데 인터넷 프로그램에서는 연결형 서비스를 TCP 또는 스트림(stream) 서비스라고도 부르고, 비연결형 서비스를 UDP 또는 데이터그램 서비스라고도 부른다.

▶ 자신의 IP 주소는 응용 프로그램이 수행되는 컴퓨터의 IP 주소를 말하며, 자신의 포트번호는 이 컴퓨터에서 수행되고 있는 응용 프로그램들 중 본 응용 프로그램을 구분하는 고유번호이다.

▶ 상대방의 IP 주소는 통신하고자 하는 상대방(목적지) 컴퓨터의 IP 주소를 말하며, 상대방의 포트번호는 목적지 컴퓨터내에서 수행중인 여러 응용 프로그램 중 나와 통신할 프로그램을 지정하는 번호이다.

▶ 잘 알려진(well-known) 표준 인터넷 서비스(ftp, mail, http 등)를 처리하는 서버 프로그램은 미리 지정된 포트번호를 사용하고 있다.

▶ 소켓 프로그래밍에서 첫번째로 해야 할 일은 통신 창구 역할을 하는 소켓을 만드는 것으로 이것은 서버와 클라이언트에서 모두 필요한데 이를 위하여 socket() 시스템 콜을 호출한다.

▶socket()이 성공적으로 수행되면 새로 만들어진 소켓번호(int 타입)를 리턴하고 에러가 발생하면 -1이 리턴된다.

▶ socket()의 사용 문법은 다음과 같다.

int socket (

domain, /* 프로토콜 체계 */

type, /* 서비스 타입 */

protocol; /* 소켓에 사용될 프로토콜 */

▶ 소켓은 본래 TCP/IP, 즉 인터넷만을 위하여 정의된 것이 아니며 UNIX 네트웍, XEROX 네트웍 등에서도 사용할 수 있도록 일반적으로 정의된 것이다.

▶ TCP/IP 프로토콜을 사용하려면 소켓을 개설할 때 프로토콜 체계를 인터넷으로 지정하여야 한다.

▶ 이를 위하여 domain을 PF_INET으로 선택하여야 한다.

▶ 한편 domain으로 가질 수 있는 값은 다음과 같다(PF는 Protocol Family를 나타냄).

domain : PF_INET (인터넷 프로토콜 체계 사용)

PF_UNIX (UNIX 방식의 푸로토콜 체계 사용)

PF_NS (XEROX 네트웍 시스템의 프로토콜 체계 사용)

▶ type은 서비스 타입(type of service)을 말하는데, 연결형(스트림) 서비스를 제공하는 소켓을 만들려면 SOCK_STREAM을, 비연결형(데이터그램) 서비스를 선택하려면 SOCK_ DGRAM을 선택하여야 한다.

type : SOCK_STREAM (스트림 방식의 소켓 생성)

SOCK_DGRAM (데이터그램 방식의 소켓 생성)

▶ protocol은 소켓을 지원하는 프로토콜을 지정하는데 일반적으로 0을 쓰면 시스템이 자동으로 설정해 준다.

▶ 다음은 socket() 시스템 콜을 호출하고 생성된 소켓번호를 출력하는 예제 open_socket.c이다.

▶ 이 프로그램에서는 먼저 /etc/passwd 파일을 열고 리턴된 파일기술자(file descriptor)를 출력한 후, 소켓을 두 개 열어서 소켓번호가 어떤 값인지 확인한다.

▶ 끝으로 또 다른 파일(/etc/hosts)을 열어서 파일기술자를 출력한다.

/*-----------------------------------------------------------------------------------------------

파일명 : open_socket.c

: socket() 시스템 콜을 호출하고, 생성된 소켓번호를 출력

컴파일 : cc -o open_socket open_socket.c -lsocket

실행예 : open_socket

--------------------------------------------------------------------------------------------- */

#include <stdio.h> /* 표준 입출력 함수 */

#include <sys/types.h> /* 소켓 시스템 콜에 필요한 상수 선언 */

#include <sys/stat.h> /* 파일의 상태에 대한 데이터 선언 */

#include <fcntl.h> /* 파일 제어 함수 선언 */

#include <sys/socket.h>/* 소켓 시스템콜 선언 */

int main() {

/* 파일 소켓번호 */

int fd1, fd2, sd1, sd2 ;

/* 파일 열기 */

fd1 = open("/etc/passwd", O_RDONLY, 0) ;

printf("/etc/passwd's file descriptor = %d\n", fd1) ;

/* 스트림형 소켓 열기 */

sd1 = socket(PF_INET, SOCK_STREAM, 0) ;

printf("stream socket descriptor = %d\n", sd1) ;

/* 데이터그램형 소켓 열기 */

sd2 = socket(PF_INET, SOCK_DGRAM, 0) ;

printf("datagram socket descriptor = %d\n", sd2) ;

/* 또다른 파일 열기 */

fd2 = open("/etc/hosts", O_RDONLY, 0) ;

printf("/etc/hosts's file descriptor = %d\n", fd2) ;

/* 파일 소켓 닫기 */

close(fd2) ;

close(fd1) ;

close(sd2) ;

close(sd1) ;

}

▶ 위의 예제 open_socket.c의 실행 결과는 다음과 같다.

/etc/passwd's file descriptor = 3

stream socket descriptor = 4

datagram socket descriptor = 5

/etc/hosts's file descriptor = 6

▶ socket() 시스템 콜은 트랜스포트 프로토콜(TCP 또는 UDP)을 선택하여 하나의 소켓을 만드는 함수이다.

▶ 리턴된 소켓번호는 응용 프로그램내에서 생성된 파일과 소켓을 구분하는 유일한 번호인 것을 알 수 있다.

 

2.1.4 소켓주소 구조체

▶ 소켓을 이용할 통신 객체(클라이언트 또는 서버)의 구체적인 주소를 표현하기 위해서는 주소 체계(address family), IP 주소, 포트번호 세 가지가 지정되어야 하며 이 세 가지 주소 정보를 소켓주소(socket address)라고 부른다.

▶ 소켓 프로그래밍에서는 소켓주소를 담을 구조체 sockaddr를 아래와 같이 정의하였으며 이것은 2바이트의 address family와 14바이트의 주소(IP 주소 + 포트번호)로 구성되어 있다.

struct sockaddr {

u_short sa_family; /* address family */

char sa_data[14]; /* 주소 */

};

▶ 그런데 위에 정의된 sockaddr 소켓주소 구조체에 IP 주소, 포트번호 등을 직접 쓰거나 읽기가 불편하므로 인터넷 프로그래밍에서는 sockaddr 구조체를 사용하는 대신 4바이트의 IP 주소와 2바이트의 포트번호를 구분하여 지정할 수 있는 인터넷 전용 소켓주소 구조체인 sockaddr_in을 주로 사용한다.

▶ sockaddr_in에서는 다시 32비트의 IP 주소를 저장하는 구조체 in_addr를 사용하고 있으며 sockaddr_in과 sockaddr의 호환성을 위하여 두 구조체의 전체 크기는 16바이트로 같도록 하였다.

struct in_addr {

u_long s_addr; /* 32비트의 IP 주소를 저장할 구조체 */

};

struct sockaddr_in {

short sin_family; /* 주소 체계 */

u_short sin_port; /* 16비트 포트번호 */

struct in_addr sin_addr; /* 32비트 IP 주소 */

char sin_zero[8]; /* 16바이트 크기를 맞추기 위한 dummy */

};

 

▶ 위에서 주소 체계 sin_family로 선택할 수 있는 것은 다음과 같으며 인터넷에서는 항상 인터넷 주소 체계(AF_INET)를 선택하여야 한다.

sin_familly : AF_INET (인터넷 주소 체계)

AF_UNIX (유닉스 파일 주소 체계)

AF_NS (XEROX 주소 체계)

소켓주소의 주요 내용은 IP 주소와 포트번호인데, 소켓주소는 응용 프로그램이 자신의 소켓주소(local address) 표현하는 데에도 사용되며 상대방 프로세스의 소켓주소(remote address) 표현할 때도 사용된다(, 2.1.3절의 + 또는 +⑤를 각각 나타내기 위해 소켓주소 구조체가 사용된다).

2.1.5 소켓 프로그래밍 절차

▶ 소켓 프로그래밍도 대표적인 네트웍 프로그래밍으로서 1.3절에서 설명한 클라이언트-서버 통신 모델로 구현된다.

▶ 소켓을 이용한 클라이언트와 서버의 프로그래밍 절차를 간략히 설명하고, 2.3절과 2.4절에서 클라이언트와 서버 프로그램의 작성 과정을 상세히 설명하겠다.

▶ 그림 2-4에 클라이언트와 서버가 TCP(스트림형 또는 연결형) 소켓을 만들고 서로 연결한 다음 데이터를 송수신하고 소켓을 종료하는 절차를 나타냈다.

▶ 클라이언트-서버 통신 모델에서는 항상 서버 프로그램이 먼저 수행되고 있어야 한다.

▶ 서버는 socket()을 호출하여 통신에 사용할 소켓을 하나 개설하고 이때 리턴된 소켓번호와 자신의 소켓주소 (2.1.3절의 ②+③)를 bind()를 호출하여 서로 연관시켜 둔다.

▶ 서버에서 bind()가 필요한 이유는 소켓번호는 응용 프로그램이 알고 있는 통신 창구 번호이고, 소켓주소(②+③)는 네트웍 시스템(즉, TCP/IP)이 알고 있는 주소이므로 이들의 관계를 묶어 두어야(bind) 응용 프로세스와 네트웍 시스템간의 패킷 전달이 가능하기 때문이다(bind()의 자세한 사용법은 2.4.1절에서 설명함).

▶ 다음에 서버는 listen()을 호출하여 클라이언트로부터의 연결요청을 기다리는 수동 대기모드로 들어간다.

▶ 클라이언트로부터의 연결요청이 왔을 때 이를 처리하기 위하여 accept()를 호출한다.

▶ 서버는 accept() 시스템 콜에서 기다리고 있다가 클라이언트가 connect()를 호출하여 접속요구를 해오면 이를 처리한다.

▶ 연결이 성공적으로 이루어지면 서버는 클라이언트와 데이터를 송수신할 수 있게 된다.

▶ 한편 클라이언트는 socket()을 호출하여 소켓을 만든 후 bind()를 부를 필요 없이, 서버에게 연결요청을 보내기 위하여 connect()를 호출한다.

▶이때 클라이언트는 접속할 상대방 서버의 소켓주소(④+⑤) 구조체를 만들어 connect()의 함수 인자로 주어야 한다.

▶ 서버와 연결이 이루어지면 (즉, connect()문이 성공적으로 리턴되면) 서버와 데이터를 송수신할 수 있다.

그림 2-4 TCP(연결형) 소켓 프로그래밍의 절차

▶ 클라이언트에서 bind()를 호출할 필요가 없는 이유는, 클라이언트 프로그램은 서버 프로그램과 달리 자신이 사용하는 IP 주소나 포트번호를 다른 클라이언트 또는 서버가 미리 알고 있을 필요가 없기 때문이다.

▶ 서버의 응용 프로그램은 자신이 사용하고 있는 포트번호를 통하여 클라이언트들의 서비스를 처리해야 하므로, 응용 프로그램이 소켓번호와 소켓주소를 bind()하는 것이 필수적이다.

▶ 클라이언트는 포트번호를 임의로 사용해도 되기 때문에 포트번호를 특정한 값으로 bind()시켜 두는 것이 필요 없다.

▶ 클라이언트는 오히려 bind()를 사용하는 것이 클라이언트 프로그램의 범용성을 떨어뜨리게 된다. 왜냐하면 같은 포트번호를 사용하는 클라이언트 프로그램들이 하나의 컴퓨터에서 두 개 이상 실행되면 에러가 발생하기 때문이다.

2.2 인터넷 주소변환

▶ 소켓 프로그래밍 절차를 설명하기에 앞서 본 절에서는 바이트 순서 변환과 인터넷 주소변환을 소개하겠다.

▶ 인터넷에는 여러 가지 종류의 컴퓨터들이 접속되어 있고 각 컴퓨터마다 데이터(숫자, 문자 등)를 컴퓨터 내부에서 표현하는 방식이 다르다. 따라서 데이터를 다른 기종의 컴퓨터로 그대로 전송하면 문제가 발생할 수 있다.

▶ 이러한 통신상의 문제를 피하기 위하여 데이터를 전송하기 전에 그리고 데이터를 수신한 후에 바이트 순서를 맞추는 절차가 필요하다.

▶ 한편 인터넷 주소를 표현하는 방식에는 도메인 네임, 32비트의 IP 주소, 그리고 십진수(dotted decimal) 표시법 등 세 가지 방식이 있으며 이들 주소 표현법을 자유롭게 변환할 수 있는 함수가 필요하다.

2.2.1 바이트 순서

▶ 바이트 순서(byte ordering)에는 호스트 바이트 순서와 네트웍 바이트 순서 두 가지가 있다.

▶ 호스트 바이트 순서는 컴퓨터가 내부 메모리에 숫자를 저장하는 순서를 말하는데, 이것은 컴퓨터의 CPU의 종류에 따라 다르다.

▶ PC에서 널리 사용되는 80x86계열의 CPU가 사용하는 호스트 바이트 순서는 예를들어 두 바이트로 구성된 십진수 50146의 경우 hexa로 0xC3E2이며 이것은 80x86 계열의 CPU에서는 E2, C3의 순서로(즉, 하위 바이트부터) 메모리에 저장되고 MC68000 계열의 CPU에서는 C3, E2의 순서로 메모리에 저장된다.

그림 2-5 0xC3E2(십진수 50146)의 호스트 바이트 순서 비교

▶ 한편 네트웍 바이트 순서는 네트웍에서 바이트 단위로 데이터가 전달되는 순서를 말하는데 2바이트의 수 0xC3E2의 경우 C3, E2의 순서로 즉, 상위 바이트부터 전송된다.

▶ 즉, 80x86계열의 CPU가 사용하는 호스트 바이트 순서는 네트웍 바이트 순서와 다르다. 따라서 80x86계열의 컴퓨터에서 네트웍을 통하여 전송한 데이터를 68000계열의 컴퓨터가 수신하면 바이트 순서가 바뀌게 된다.

▶ 이러한 문제를 해결하기 위하여 컴퓨터 내부에서 만들어진 호스트 바이트 순서의 데이터를 네트웍으로 전송하기 전에 htons() 함수를 사용하여 모두 네트웍 바이트 순서로 바꾸어야 한다.

▶ 반대로 네트웍에서 수신한 데이터는 ntohs() 함수를 사용하여 자신에게 맞는 호스트 바이트 순서로 항상 바꾸어야 한다.

▶ 즉, 네트웍 바이트 순서를 지켜 데이터를 전송함으로써 수신한 데이터가 어떤 종류의 컴퓨터에서 만들어진 것인지 알 필요가 없도록 하는 것이다.

▶ 위에서 MC68000 계열의 CPU에서는 호스트 바이트 순서와 네트웍 바이트 순서가 같은 것을 알 수 있는데 이러한 호스트에서의 htons()와 ntohs() 함수는 아무 일도 하지 않게 된다.

▶ 바이트 순서를 바꾸는 함수에는 변환할 바이트 길이에 따라 다음과 같이 두 가지 종류가 있다.

  • Unsigned short interger 변환 (2바이트 체계)
  • htons() : host-to-network 바이트 변환
  • ntohs() : network-to-host 바이트 변환
  • Unsigned long interger 변환 (4바이트 체계)
  • htonl() : host-to-network 바이트 변환
  • ntohl() : network-to-host 바이트 변환

 

2.2.2 바이트 순서 예제 프로그램

▶ 여기서는 현재 사용중인 컴퓨터의 호스트 바이트 순서가 네트웍 바이트 순서와 같은지를 알아보는 테스트 프로그램 byte_order.c를 소개한다.

▶ byte_order.c에서는 먼저 udp를 사용하는 echo 서비스의 포트번호를 알아내기 위하여 getservbyname() 시스템 콜을 다음과 같이 호출한다.

pmyservent = getservbyname("echo", "udp");

▶ getservbyname()은 well-known 포트를 사용하는 TCP/IP 응용 프로그램의 포트번호를 알아내는 함수이다.

▶ 서비스 이름과 프로토콜을 인자로 하여 호출하면 서비스 관련 각종 정보를 포함하고 있는 servent라는 구조체의 포인터를 리턴한다.

 

struct servent {

char *s_name; /* 서비스 이름 */

char **s_aliases; /* 별명 목록 */

int s_port; /* 포트번호 */

char *s_proto; /* 사용하는 프로토콜 */

};

▶ servent 구조체는 네트웍으로부터 얻은 정보이므로 그 내용이 네트웍 바이트 순서로 되어 있고 이를 화면에 출력해 보려면 호스트 바이트 순서로 바꾸어야 한다.

▶ 예제 byte_order.c에서는 servent에 들어 있는 포트번호(s_port)를 (정상적으로) 호스트 바이트 순서로 바꾸어 출력해 보고 또한 바이트 순서 변환 없이 그대로 출력하여 이들을 비교해 본다.

/*-----------------------------------------------------------------------------------------------

파일명 : byte_order.c

: 호스트 바이트 순서 테스트 프로그램

컴파일 : cc -o byte_order byte_order.c -lsocket

실행예 : byte_order

---------------------------------------------------------------------------------------------- */

#include <sys/types.h>

#include <sys/socket.h>

#include <netdb.h>

void main(void) {

struct servent *pmyservent;

pmyservent = getservbyname( "echo", "udp" );

if( pmyservent == NULL ) {

printf( "서비스 정보를 얻을 없음. \n\n" );

exit( 1 );

}

printf( "Port number of 'echo', 'udp' with ntohs() : %d \n",

pmyservent->s_port );

printf( "Port number of 'echo', 'udp' without ntohs() : %d \n",

ntohs(pmyservent->s_port));

exit( 0 );

}

 

▶ MC68000 계열의 CPU를 사용하는 SUN 컴퓨터에서는 byte_order.c의 실행결과가 아래와 같으며 호스트 바이트 순서가 네트웍 바이트 순서와 같다는 것을 알 수 있다.

 

# byte_order

Port number of 'echo', 'udp' with ntohs() : 7

Port number of 'echo', 'udp' without ntohs() : 7

#

 

▶ 한편, 인텔 80x86 계열의 CPU를 사용하는 IBM-PC에서 byte_order.c를 실행하면 결과가 아래와 같으며 따라서 호스트 바이트 순서가 네트웍 바이트 순서와 다르다는 것을 알 수 있다.

▶ 아래에서 정수 1792는 2바이트 숫자 0x0007의 바이트 순서가 서로 바뀐 0x0700의 십진수이다(포트번호는 2바이트로 되어 있음).

# byte_order

Port number of 'echo', 'udp' with ntohs() : 7

Port number of 'echo', 'udp' without ntohs() : 1792

#

 

2.2.3 IP 주소변환

▶ 32비트의 IP 주소를 편의에 따라 cc.kangwon.ac.kr과 같은 도메인 네임, 그리고192.203.144.11과 같은 dotted decimal 표시법 등으로 바꾸어 널리 사용하고 있다.

▶ 소켓 프로그램에서는 이들 주소 표현법을 자유롭게 변환할 수 있는 함수가 필요하며 그림 2-6에 이들 세 가지 인터넷 주소 표현 방법과 이들을 상호 변환하는 데 사용되는 네 가지 주소변환 함수를 나타냈다.

▶ 한편 IP 패킷을 네트웍으로 실제로 전송할 때에는 32비트의 (binary) IP 주소가 필요하다.

▶ 예를들어 그림 2-6에서 dotted decimal로 표현된 192. 203.144.11을 32비트의 IP 주소로 변환하려면 inet_addr() 시스템 콜을 사용하고 IP 주소를 다시 dotted decimal로 변환하려면 inet_ntoa()를 사용한다.

그림 2-6 IP 주소 표현의 세가지 방법 및 이들의 상호 변환 함수

▶ 다음은 이를 수행하는 코드인데 이의 실행 결과로 192. 203.144.11의 IP 주소인 4바이트의 hexa c0cb900b와, 이를 다시 dotted decimal 주소로 바꾼 192.203.144.11이 화면에 출력된다.

struct sockaddr_in server_addr;

server_addr.sin_addr.s_addr = inet_addr ("192.203.144.11");

printf("%x\n", server_addr.sin_addr.s_addr); /* hexa 4바이트 출력 */

printf("%s\n", inet_ntoa(server_addr.sin_addr));/* dotted decimal 출력 */

▶ 한편 도메인 네임으로부터 IP 주소를 알기 위해서는 get- hostbyname() 시스템 콜을 사용하는데 아래는 gethostby- name()의 사용 예이다.

struct hostent *gethostbyname(char *hname);

▶ 위에서 gethostbyname()은 도메인 네임 hname을 입력으로 받고(예를들면 cc.kangwon.ac.kr) 그 이름에 해당하는 호스트의 각종 정보를 가지고 있는 hostent라는 구조체의 포인터를 리턴한다.

▶ 구조체 hostent의 정의는 다음과 같다.

struct hostent {

char * h_name; /* 호스트 이름 */

char ** h_aliases; /* 호스트 별명들 */

int h_addrtype; /* 호스트 주소의 종류 */

int h_length; /* 주소의 크기(바이트 단위) */

char **h_addr_list; /* 주소 리스트 */

#define h_addr h_addr_list[0]

};

▶ 다음에는 사용자가 지정한 호스트의 hostent 구조체 정보를 gethostbyname()을 이용하여 구한 후, hostent 구조체의 내용을 모두 출력해 보는 예제 프로그램 host_ent.c를 소개한다.

▶ host_ent.c의 실행 예는 다음과 같다.

# host_ent telecom.kangwon.ac.kr

official host name : telecom.kangwon.ac.kr

host address type : 2

length of host address : 4

address for host : 203.252.65.9

#

▶ host_ent.c에서는 먼저 목적지 호스트의 도메인 네임을 프로그램 명령 인자로 받고(위에서는 telecom.kangwon. ac.kr), gethostbyname()을 호출하여 목적지 호스트의 hostent 구조체를 구한다.

▶ 다음에는 hostent내에 있는 호스트 이름, 별명(있으면), 주소 체계, dotted decimal 인터넷 주소를 화면에 출력한다.

▶ 여기서 주소 체계가 인터넷일 때는 AF_INET가 되는데 AF_INET의 값은 정수 2이다.

host_ent.c 전체리스트 받기

 

2.2.4 주소변환 예제 프로그램

(1) 프로그램 설명

▶ 여기서는 도메인 네임, dotted decimal 주소, 이진수 IP 주소를 상호 변환하는 것을 확인하는 예제 addr_conv.c를 소개한다.

▶ 그림 2-6에서 gethostbyname()은 도메인 네임으로부터 IP 주소를 얻는 함수이고 gethostbyaddr()은 반대로 IP 주소로부터 도메인 네임을 얻는 함수이다.

▶ 이들 두 함수는 모두 호스트의 각종 정보를 저장한 구조체 hostent의 포인터를 리턴한다.

▶ addr_conv.c에서는 키보드에서 목적지 호스트의 도메인 네임과 dotted decimal 주소 두 가지 주소를 입력 받고 다음의 네 가지 기능을 수행한다.

1) 입력된 목적지 도메인 네임으로부터 IP 주소를 찾아 화면에 출력 (gethostbyname() 이용)

2) 위 1)에서 구한 IP 주소를 dotted decimal 주소로 변경하여 화면에 출력 (inet_ntoa() 이용)

3) 입력된 dotted decimal 주소로부터 IP 주소를 찾아 화면에 출력 (gethostbyaddr() 이용)

4) 위 3)에서 구한 hostent 구조체내에 있는 목적지 도메인 네임을 화면에 출력

▶ 프로그램 addr_conv.c의 실행 예는 다음과 같다.

# addr_conv vcn.kangwon.ac.kr 203.252.65.3

vcn.kangwon.ac.kr's binary IP address (hexa) : cbfc4103

vcn.kangwon.ac.kr's dotted decimal IP address : 203.252.65.3

203.252.65.3's binary ip address (hexa) : cbfc4103

203.252.65.3's hostname : vcn.kangwon.ac.kr

#

 

addr_conv.c 받기

2.2.5 기타 주소 관련 함수

▶ 앞에서 소개한 주소변환 함수 이외에도 BSD 소켓은 getXbyY() 형태의 호스트 정보 검색 함수들을 제공하고 있다.

▶ 이들의 자세한 사용은 부록 A를 참조하기 바라고 여기서는 다음의 두 가지 함수만 소개한다.

(1) gethostname()

▶현재 사용중인 컴퓨터의 도메인 네임, 예를들어 'cc.kangwon. ac.kr'과 같은 이름을 알기 위하여 gethostname()을 사용한다.

▶ 아래의 예에서 문자열 변수 hname에 현재 사용중인 호스트의 도메인 네임이 저장된다.

#define HOST_NAME_LEN 50 /* 호스트 이름의 최대 길이 */

char hname[HOST_NAME_LEN + 1];/* 호스트 이름을 저장할 버퍼 */

gethostname(hname, HOST_NAME_LEN);

(2) sethostname()

▶ 사용중인 호스트의 도메인 네임을 새로운 값으로 지정할 때 사용하는데 아래의 예에서는 호스트의 이름을 new. kangwon.ac.kr로 바꾼다.

sethostname("new.kangwon.ac.kr", strlen("new.kangwon.ac.kr"));

2.3 클라이언트 프로그램 작성 절차

▶ 본 절에서는 클라이언트 프로그램의 일반적인 작성 절차를 설명하고, well-known 서비스 포트를 이용하는 클라이언트 프로그램 mydaytime.cmyecho.c를 소개한다.

2.3.1 연결형 클라이언트 프로그램 작성 절차

▶ TCP(연결형 또는 스트림형) 클라이언트 프로그램의 일반적인 작성 절차를 그림 2-7에 나타냈다.

▶ 2.1.5절에서 소개한 바와 같이 클라이언트는 먼저 socket()으로 소켓을 개설한다.

▶ 연결형 서비스를 이용하기 위하여 connect()를 호출하여 서버와 연결을 요청하고 연결이 이루어지면 send()와 recv()를 사용하여 데이터를 송수신하고 작업이 종료되면 close()로 소켓을 닫는다.

그림 2-7 TCP(연결형) 클라이언트 프로그램 작성 절차

(1) socket(), 소켓 개설

▶ 클라이언트는 먼저 socket()으로 소켓을 개설해야 하는데 이때 프로토콜 체계를 PF_INET로 선택하고, 서비스 타입은 SOCK_STREAM으로 선택한다.

▶ 소켓 프로그래밍을 하기 위하여는 다섯 가지 요소 즉, 사용할 트랜스포트 프로토콜(스트림 또는 데이터그램), 자신과 상대방의 IP 주소, 자신과 상대방의 포트번호가 지정되어야 한다고 하였다(2.1.3절 참조).

▶ socket()을 호출시에는 트랜스포트 프로토콜만을 지정하게 되는데 그림 2-8에 socket() 수행시 내부적으로 일어나는 동작을 나타냈다. 여기서 응용 프로그램이 프로토콜을 지정하여 socket()을 호출하면 소켓 인터페이스가 새로 생성된 소켓의 소켓번호를 리턴하는 것을 보여주고 있다.

▶ 한편 클라이언트는 자신이 사용할 포트번호를 명시적으로 지정할 필요가 없다. 즉, 클라이언트는 bind()를 호출할 필요가 없이 다음에 설명할 connect() 호출시 시스템(TCP/IP)이 임의의 포트번호를 지정해 준다.

그림 2-8 socket() 호출시 소켓 번호와 소켓 인터페이스의관계

(2) 연결할 서버의 소켓주소 구조체(sockaddr_in) 작성

▶ 클라이언트는 connect()를 호출하기 전에 연결하고자 하는 서버의 주소를 지정한다.

▶ 4바이트의 IP 주소와 2바이트의 포트번호를 포함하는 소켓주소 구조체 sockadr_in를 작성하여야 한다(2.1.4절 참조).

(3) connect(), 서버와 연결요청

▶ (2)에서 만든 서버의 소켓주소 구조체를 사용하여 서버에게 접속요청을 하기 위하여 connect()를 호출한다.

▶ 그림 2-9에 connect()의 수행 내용을 나타냈는데, con- nect()는 상대방 즉, 서버의 IP 주소와 포트번호를 명시하여 (1)에서 구한 소켓번호로 호출하면 된다.

▶ connect()의 사용 문법은 아래와 같다.

int connect (

int s, /* 서버와 연결시킬 소켓번호 */

const struct sockaddr *addr, /* 상대방 서버의 소켓주소 구조체 */

int addrlen); /* 구조체 *addr 크기 */

▶ connect()를 호출하면 TCP가 서버의 소켓주소 구조체 *addr에 지정된 목적지 IP 주소와 포트번호로 연결을 시도하여 연결이 성공하면 0을 리턴한다.

▶ 실패하면 -1을 리턴하며 전역변수 errno에 에러코드가 들어 있게 된다.

▶ 클라이언트가 호출한 connect()가 성공적으로 연결되려면 서버에서 accept()를 호출해 두고 있어야 한다.

▶ 한편, connect() 시스템 콜 호출 중에 에러가 발생하였을 때는 바로 다시 connect()를 호출하지 말고 해당 소켓을 close()로 닫고, 새로운 소켓을 socket()으로 만든 후 사용하는 것이 안전하다.

그림 2-9 connect() 호출시 소켓번호와 소켓 주소의 관계

(4) send(), recv(), 데이터 송수신

▶ 클라이언트가 서버와 연결되면 send(), recv() 또는 write(), read()를 사용하여 서버와 데이터를 송수신할 수 있다.

▶ 이들 데이터 송수신 함수들을 표 2-1에 정리하였으며 자세한 내용은 부록 A.2를 참조하기 바란다.

문 법

인 자

int send(int s, char* buf,

int length, int flags);

s소켓번호
buf 전송할 데이터가 저장된 버퍼
lengthbuf 버퍼의 크기
flags보통 0
int write(int s, const void*

buf, int length);

s소켓번호
buf전송할 데이터가 저장된 버퍼
lengthbuf 버퍼의 길이
int recv(int s, char* buf,

int length, int flags);

s소켓번호
buf 수신 데이터를 저장할 버퍼
lengthbuf 버퍼의 길이
flags보통 0
int read(int s, void* buf,

int length);

s소켓번호
buf수신 데이터를 저장할 버퍼
lengthbuf 버퍼의 길이

표 2-1 TCP(스트림) 소켓의 데이터 송수신 함수 비교

▶ send()와 write()는 스트림형(TCP) 소켓을 통하여 패킷을 송신하는 함수이다.

▶ 데이터를 전송할 소켓번호(s), 송신할 데이터 버퍼(buf), 전송할 데이터 크기(length)를 지정해야 하며 두 함수 모두 실제로 전송된 데이터 크기를 바이트 단위로 리턴한다.

▶ recv()와 read()는 스트림형(TCP) 소켓을 통하여 패킷을 수신하는 함수이며 데이터를 수신할 소켓번호(s), 수신 버퍼(buf), 읽을 데이터 크기(length)를 지정해야 한다. 두 함수 모두 실제로 읽은 데이터 크기를 바이트 단위로 리턴한다.

▶ 스트림 소켓에서는 IP 패킷이 한 번에 전송할 수 있는 최대 데이터 크기보다 큰 데이터를 송신 버퍼에 저장하고 write()나 send()를 호출할 수 있다.

▶ 이때에는 전체 데이터가 IP 패킷 단위로 분할되어 전송되며 수신측에서는 패킷의 순서와 분실을 검사하고 필요하면 재전송을 요구하는데 이것이 바로 스트림(stream) 소켓을 사용하는 장점이기도 하다.

▶ 그러나 다음 절에서 설명할 UDP 소켓에서는 사용자가 전송을 요구한 데이터의 크기가 데이터그램 크기보다 크면 에러가 발생하거나 데이터그램 크기만큼만 한 번 전송되고 만다.

(5) close(), 소켓 닫기

▶소켓의 사용을 마치려면 해당 소켓번호(s)를 지정하여 다음과 같이 close()를 호출하여 소켓을 종료하여야 한다.

close(s);

▶ close()를 호출한 시점에 서버나 클라이언트의 송신 버퍼에 있으나 아직 전송하지 못한, 또는 네트웍내에서 전달중인 패킷들이 있을 수 있는데 close()는 디폴트로 이러한 패킷들을 모두 처리한 후에 소켓을 닫게 되어 있다.

▶ 이러한 패킷들을 보다 구체적으로 (수동적으로) 처리하기 위하여 shutdown() 시스템 콜을 아래와 같이 사용할 수 있다.

shutdown(s, direction);

 

▶ 위에서 s는 닫을 소켓번호이고, direction은 0이면 패킷의 전송을 종료하고 1이면 수신을 종료하며 2이면 송수신을 모두 종료하게 한다.

▶ 예를들어 클라이언트가 더 이상 전송할 데이터가 없으면 direction을 0으로 하여 shutdown()을 호출하면 되는데 이를 partial close라 한다.

▶ 이때 네트웍 시스템은 상대방 서버에게 end-of-file 신호를 전송하고 이를 수신한 서버도 만일 보낼 데이터가 없으면 해당 연결을 종료하면 된다.

▶ 한편 close() 호출시 미처리된 패킷들을 처리하는 또 다른 방법으로, 일정한 시간을 지정해 주고 이 시간 동안만 미처리된 패킷들이 처리되는 것을 기다리는 방법도 있다.

▶ 이를 위하여는 close()를 호출하기 전에 setsockopt()를 호출하여 소켓 동작의 옵션을 바꾸어 주어야 한다(자세한 내용은 5.2.1절에서 설명).

2.3.2 비연결형 클라이언트 프로그램 작성 절차

▶UDP(비연결형 또는 데이터그램) 클라이언트도 앞에서 설명한 연결형 클라이언트 프로그램과 유사한 절차로 구현할 수 있다.

▶ 그러나 비연결형 서비스이므로 connect() 시스템 호출이 필요 없고 클라이언트와 서버는 소켓 개설 후 바로 데이터를 송수신할 수 있다.

그림 2-10 UDP(비연결형) 소켓 프로그래밍 절차

 

▶ 한편 비연결형 통신에서는 연결형 소켓 프로그래밍과 달리, 소켓이 목적지별로 개설되어 있는 것이 아니므로 하나의 소켓을 통하여 임의의 목적지를 향하여 IP 패킷을 보낼 수 있는데 이것이 바로 UDP를 사용하는 최대의 장점이기도 하다.

▶ 그러나 비연결형으로 만든 소켓으로 패킷을 전송할 때에는 각 패킷 전송시마다 목적지의 IP 주소와 포트번호(즉 소켓주소)를 항상 함수 인자로 주어야 한다.

▶ 표 2-2에 비연결형 소켓에서 사용하는 데이터 송수신 함수인 sendto()와 recvfrom()의 사용법을 정리하였다.

▶ 데이터 송신 함수인 sendto()에서 to 구조체는 데이터를 보낼 상대방의 소켓주소 구조체를 가리키며 sendto()를 호출하기 전에 to의 내용을 정해 주어야 한다.

▶ 한편 데이터 수신함수인 recvfrom()이 성공적으로 수행되면 from 구조체에 현재 수신된 패킷을 보낸 발신자의 소켓주소가 들어 있으며 따라서 누가 이 데이터를 전송하였는지를 알 수 있다.

▶ 이 데이터를 보내온 발신자에게 데이터를 보내려면 from에 들어 있는 소켓주소를 sendto()의 to로 복사하여 사용하면 된다.

문 법

인 자

int sendto(int s, char* buf,

int length, int flags,

sockaddr* to, int tolen);

s소켓번호
buf 전송할 데이터가 저장된 버퍼
lengthbuf 버퍼의 크기
flags보통 0
to목적지의 소켓주소 구조체
tolento 버퍼의 크기
int recvfrom(int s,

char* buf, int length,

int flags, sockaddr* from,

int* fromlen)

s소켓번호
buf 수신 데이터를 저장할 버퍼
lengthbuf 버퍼의 길이
flags보통 0
from발신자의 소켓주소 구조체
fromlenfrom 버퍼의 길이

표 2-2 sendto()와 recvfrom() 함수의 사용법

2.3.3 daytime 클라이언트 프로그램

▶ 여기서는 간단한 TCP 클라이언트 프로그램 작성 예로, 현재의 날짜와 시간을 알려주는 daytime 서비스를 이용하는 클라이언트 프로그램 mydaytime.c를 작성하겠다.

▶ 다음은 mydaytime.c를 실행한 결과인데 daytime 서비스를 제공해 줄 임의의 서버의 dotted decimal IP 주소를 명령문 인자로 주어야 한다.

 

# mydaytime 203.252.65.3

Sun May 16 05:00:21 1997

#

▶ mydaytime.c에서는 사용할 소켓을 아래와 같이 개설하는데 프로토콜 체계로는 인터넷을, 서비스는 연결형(TCP)을 지정하고 있다.

s = socket(PF_INET, SOCK_STREAM, 0);

 

▶ 다음에는 연결할 서버의 소켓주소 구조체를 만들어야 하는데 주소 체계를 인터넷(AF_INET)으로 지정한다.

▶ 서버의 IP 주소와 포트번호를 소켓주소 구조체 sever_addr에 기록하는 것을 아래에 보였다.

▶ 아래에서 inet_addr() 함수는 사용자가 명령 인자로 입력한 dotted decimal 형태의 IP 주소 문자열 haddr을 (binary) IP 주소로 변환하는 함수이다(2.2.3절 참조).

▶ 한편 표준 인터넷 서비스인 daytime 서비스를 받기 위해서는 well_known 포트번호로 13번을 지정하여야 한다.

struct sockaddr_in server_addr; /* 서버의 소켓주소 구조체 */

haddr = argv[1];

server_addr.sin_family = AF_INET; /* 주소 체계 선택 */

server_addr.sin_addr.s_addr = inet_addr(haddr); /* 32비트의 IP 주소로 변환 */

server_addr.sin_port = htonl(13); /* daytime 서비스 포트번호 */

▶ 이상과 같이 서버의 주소 정보를 모두 server_addr 구조체에 기록한 후 서버에 연결을 요청하기 위해 connect() 함수를 다음과 같이 호출하는데 연결요청이 수락된 경우에는 0이 리턴되며, 그렇지 않은 경우에는 -1이 리턴된다.

connect(s, (struct sockaddr *)&server_addr, sizeof(server_addr));

 

▶서버와 연결된 후, 서버가 보내오는 문자열(날짜와 시간)을 수신하기 위해 클라이언트는 read()를 아래와 같이 호출한다.

n = read(s, buf, BUF_LEN);

 

mydaytime.c 전체 리스트

2.3.4 echo 클라이언트 프로그램

▶ 클라이언트 프로그래밍의 두번째 예로 echo 서비스를 시험하는 클라이언트 프로그램 myecho.c를 소개한다.

▶ 표준 인터넷 서비스인 echo는 well-known 포트 7번을 통해 유닉스 컴퓨터가 제공하는 서비스로 클라이언트가 전송한 문자열을 다시 클라이언트로 전송한다.

▶ 다음은 myecho.c를 실행한 결과 예이다.

# myecho 203.252.65.3

Input any string : Unix socket programming

Echoed string : Unix socket programming

#

▶ myecho.c 프로그램도 앞에서 설명한 mydaytime.c와 거의 유사하게 작성되는데 한 가지 차이점은 mydaytime.c에서는 daytime 서비스를 이용하기 위해 포트 13번을 사용하였으나, myecho.c에서는 echo 서비스를 이용해야 하므로 서버의 소켓주소 구조체 server_addr의 포트번호로 아래와 같이 7을 선택해야 한다.

server_addr.sin_port = htonl(7);

 

▶ 한편 connect()를 호출한 후 서버와 연결이 완료되면 서버에 전송할 문자열을 키보드를 통해 아래와 같이 입력받는다.

fgets(buf, BUF_LEN, stdin);

 

▶ 위에서 fgets() 함수는 스트링 문자열을 입력받는 함수로 첫번째 인자(buf)는 문자열을 저장할 버퍼이다.

▶ 두번째 인자는 버퍼의 크기, 그리고 세번째 인자는 입력 스트림 포인터이다.

▶ 입력 스트림 포인터로 stdin을 지정하였는데 stdin은 FILE 포인터 타입의 전역변수로 표준 입력 파일인 키보드를 뜻한다.

▶ fgets()로 키보드에서 입력받은 문자열은 write() 시스템 콜을 이용해 서버로 전송하고 서버가 echo시켜준 문자열은 read() 시스템 콜로 수신한다.

myecho.c 전체 리스트

2.4 서버 프로그램 작성 절차

▶ 본 절에서는 echo 기능을 갖는 서버 프로그램 myecho_ server.c를 구축해 본다.

2.4.1 연결형 서버 프로그램 작성 절차

▶ 그림 2-11에 TCP(연결형) 서버를 iterative 형태 즉, 서비스 요구가 들어오는 순서대로 처리해 주는 형태로 구축하는 절차를 나타냈다.

▶ 서버는 socket()으로 소켓을 개설하고 bind()를 수행한 후 listen()으로 소켓을 수동 대기모드로 만든다.

▶ 다음에는 accept()를 호출하여 자신에게 연결을 요청하는 클라이언트의 연결을 처리하도록 한다.

▶ 클라이언트와 연결된 후에는 클라이언트가 요구하는 요청(request)을 처리하고 결과(response)를 전송해 주는 방식으로 서비스를 처리하고 하나의 서비스를 완료하면 다음 요청을 처리한다.

그림 2-11 Iterative 모델의 TCP(연결형) 서버 프로그램 작성 절차

(1) socket(), 소켓의 생성

▶ 서버 프로그램도 클라이언트와 마찬가지로 통신을 하기 위하여는 트랜스포트 프로토콜을 지정하여 소켓을 만들어야 하는데 이를 위해 socket() 함수를 사용한다.

▶ socket()의 사용 방법은 클라이언트의 경우와 같으며 myecho_ server.c에서도 연결형 소켓을 사용하므로 프로토콜 체계로 SOCK_STREAM을 지정한다.

socket(PF_INET, SOCK_STREAM, 0);

 

(2) bind(), 소켓번호와 소켓주소 구조체 연결

▶socket() 시스템 콜을 통해서 생성된 소켓은 그 응용 프로그램내에서 유일한 번호인 소켓번호를 하나 배정받는다.

▶ 그러나 이 번호는 응용 프로그램만 알고 사용하는 번호이므로 이 프로그램이 컴퓨터 외부와 통신하려면 이 소켓번호와 TCP/IP 시스템이 제공하는 소켓주소(IP 주소 + 포트번호)를 연결해 두어야 하며 이를 위하여 bind()를 사용한다.

▶ 그림 2-12에 bind() 호출시의 IP 주소, 포트번호, 그리고 소켓번호의 관계를 나타냈다.

▶ bind()는 응용 프로그램 자신의(local) 주소와 소켓번호를 연결하는 작업이라고 할 수 있다.

▶ 서버에서 bind()가 반드시 필요한 이유는 임의의 클라이언트가 서버의 특정 프로그램이 만든 소켓과 통신을 하려면 그 소켓을 찾을 수 있어야 하며, 따라서 서버는 소켓번호와 클라이언트가 알고 있을 서버의 IP 주소 및 포트번호(즉, 서버의 소켓주소)를 미리 서로 연결(bind)시켜 두는 것이 필요하기 때문이다.

그림 2-12 bind() 호출시 소켓 번호와 소켓 주소의 관계

▶ 아래는 bind() 시스템 콜의 사용 문법이며 bind()는 성공하면 0을, 실패하면 -1을 리턴한다.

▶ 인자 s는 bind시킬 소켓번호로서 socket() 시스템 콜이 리턴한 것이며 *addr은 소켓주소를 담고 있는 구조체이다.

int bind (

int s, /* 소켓번호 */

struct sockaddr *addr, /* 서버 자신의 소켓주소 구조체 포인터 */

int len); /* *addr 구조체의 크기 */

▶ 아래의 프로그램 코드는 소켓을 만들고 이것을 IP 주소가 203.252.65.3이고 포트번호가 3000번인 소켓주소 구조체와 bind()하는 것을 보였다.

▶아래에서 inet_addr()은 문자열로 된 dotted decimal IP 주소 203.252.65.3를 4바이트 IP 주소 1010111 10110010 00101101 00000011로 바꾸는 함수이며(2.2.3절 참조), htons()는 호스트 바이트 순서의 숫자 3000번을 네트웍 바이트 순서로 바꾸기 위하여 사용되었다.

#define SERV_IP_ADDR "203.252.65.3"

#define SERV_PORT 3000

/* 소켓 생성 */

s = socket(PF_INET, SOCK_STREAM, 0);

struct sockaddr_in server_addr;

/* 소켓주소 구조체 내용 */

server_addr.sin_family = AF_INET;

server_addr.sin_addr.s_addr = inet_addr(SERV_IP_ADDR);

server_addr.sin_port = htons(SERV_PORT);

/* 소켓번호와 소켓주소를 bind */

bind(s, (struct sockaddr *)&server_addr, sizeof(server_addr));

▶ 앞의 bind()문에서 소켓주소 구조체를 나타내는 함수 인자로 server_addr을 바로 사용하지 않고(struct sockaddr*) &server_addr을 사용한 것을 알 수 있다.

▶ 대부분의 인터넷 소켓 프로그램에서는 인터넷 주소를 편리하게 다루기 위하여(즉, IP 주소와 포트번호를 직접 기록하거나 읽을 수 있도록) sockaddr_in 구조체를 사용하고 있다.

▶ bind() 함수를 비롯한 각종 소켓 함수의 정의에서는 일반적인 소켓주소 구조체인 sockaddr를 사용하도록 정의되어 있기 때문에 구조체 타입을 바꾸는 casting이 필요한 것이다.

▶ 위에서 자신의 IP 주소로 203.252.65.3을 구체적으로 지정하였다.

▶ 응용 프로그램이 수행되는 컴퓨터 자신의 IP 주소를 자동으로 가져다 쓰려면 INADDR_ANY라는 변수를 다음과 같이 사용하면 된다.

server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

 

▶ 위에서는 포트번호로 3000번을 지정하여 사용하였지만, 포트번호를 0으로 하고 bind()를 호출하면 시스템(즉, TCP/IP)이 포트번호를 자동으로 배정해 준다.

▶ 다음은 이것을 확인하는 예제 프로그램 test_bind.c인데 소켓을 두 개 개설하고 각각 포트번호를 0으로 하여 bind()를 호출한 후 시스템이 배정한 포트번호를 화면에 출력하고 있다.

/*-----------------------------------------------------------------------------

파일명 : test_bind.c

: 시스템이 자동으로 배정한 포트번호를 출력하는 프로그램

컴파일 : cc -o test_bind test_bind.c -lsocket -lnsl

실행예 : test_bind

---------------------------------------------------------------------------- */

#include <sys/types.h>

#include <sys/socket.h>

#include <stdio.h>

#include <stdlib.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include <limits.h>

#define ADDRESS "192.203.144.11"

#define PORT 7

#define MSG "Test Message"

int main() {

int sd1, sd2 ; /* 소켓번호 */

struct sockaddr_in sin1, sin2 ; /* 소켓주소 구조체 */

int addr_len ; /* 소켓주소 구조체의 크기 */

u_short rtn1, rtn2; /* 포트번호 */

/* TCP UDP 두가지 소켓 생성 */

sd1 = socket(AF_INET, SOCK_STREAM, 0) ;

sd2 = socket(AF_INET, SOCK_DGRAM, 0) ;

sin1.sin_family = PF_INET ;

sin1.sin_addr.s_addr = inet_addr(ADDRESS) ;

sin1.sin_port = htons(PORT) ;

if(connect(sd1, (struct sockaddr*)&sin1, sizeof(sin1))<0)

{

printf("Error : Connect failed!!!\n") ;

exit(1) ;

}

addr_len = sizeof(sin2);

if (getsockname(sd1, (struct sockaddr*)&sin2, &addr_len) < 0){

printf("getsockname error\n");

}

rtn1 = sin2.sin_port ;

if(sendto(sd2, MSG, strlen(MSG), 0, (struct sockaddr*)&sin1, sizeof(sin1))<0)

{

printf("Error : sendto failed!!\n") ;

exit(1) ;

}

addr_len = sizeof(sin2);

if(getsockname(sd2, (struct sockaddr*)&sin2, &addr_len)<0)

printf("Error : getsockname error\n") ;

rtn2 = sin2.sin_port ;

/* 배정된 포트번호 출력 */

printf("stream socket's bind return = %d\n", rtn1 ) ;

printf("datagram socket's bind return = %d\n", rtn2) ;

close(sd1) ;

close(sd2) ;

}

▶ 서버에서 socket()과 bind()를 호출하여 통신을 할 준비가 된 후, 데이터를 송수신하는 절차는 소켓 개설시 지정한 트랜스포트 프로토콜의 종류에 따라 다르다.

▶ 연결형 통신(TCP)에서는 listen(), accept()의 호출이 필요하고 비연결형 통신(UDP)에서는 바로 데이터의 송수신이 가능하다.

▶ 우선 연결형 서버의 경우에 대하여 설명하겠다.

(3) listen(), 클라이언트로부터의 연결요청을 기다리기

▶ 서버는 클라이언트로부터의 연결요청을 받아들이기 위하여 이를 기다리고 있어야 하는데 이를 위하여 listen()을 호출하며 listen()의 사용문법은 아래와 같다.

 

int listen (

int s, /* 소켓번호 */

int log); /* 연결을 기다리는 클라이언트의 최대 */

▶ 위에서 인자 log는 서버에서 (다음에 설명할) accept()를 처리하는 동안 대기시킬 수 있는 connect()의 요청 수를 지정한다. 즉, 클라이언트가 요구한 연결요청을 최대 log개까지 기다리게 할 수 있다는 것이다.

▶ 예를들어 아래의 코드는 서버가 최대 2개의 connect() 요청을 대기시킬 수 있으며, 세 번째 이후의 connect() 요청은 거절하여 클라이언트가 이 사실을 바로 알 수 있도록 해준다.

listen(s, 2);

▶ 한편 listen()은 소켓을 단지 수동 대기모드로 바꾸는 것이므로 listen()의 호출은 즉시 리턴되는데 성공시에는 0, 실패시에는 -1이 리턴된다.

(4) accept(), 클라이언트로부터의 연결요청 수락

▶ 서버가 listen()을 호출한 이후에 어떤 클라이언트에서 connect()로 이 서버에 연결요청을 보내오면 이를 처리하기 위해 서버는 accept()를 호출해 두어야 한다.

▶ accept()의 수행이 성공한 경우에는 접속된 클라이언트와의 일 대 일 통신에 사용할 새로운 소켓이 만들어지고 accept()는 이 소켓번호를 리턴하며 실패시에는 -1을 리턴한다.

▶ accept()는 또한 접속된 클라이언트의 소켓주소 구조체와 구조체의 길이의 포인터를 함수인자 addr과 addrlen으로 각각 리턴한다.

▶ accept()의 사용 문법은 아래와 같고 accept() 호출시에 얻는 값들을 그림 2-13에 나타냈다.

int accept (

int s, /* 소켓번호 */

struct sockaddr *addr, /* 연결요청을 상대방의 소켓주소 구조체 */

int *addrlen); /* *addr 구조체 크기의 포인터 */

 

그림2-13 accept() 호출시 얻는 소켓 주소 정보와 새로운 소켓

(5) close(), 소켓 종료

▶ 소켓을 닫을 때 close()를 호출하는데 데이터그램(UDP) 소켓에서 close()를 호출하면 단순히 사용하던 소켓을 닫는 작업만 수행한다.

▶ 그러나 스트림(TCP)소켓은 연결형 서비스이므로 현재 미처리된 패킷들(송신 버퍼에 있으나 아직 송신이 안 된 패킷 또는 현재 송수신중에 있는 패킷)을 모두 처리한 후에 소켓을 닫게 된다.

▶ 그러나 이러한 미처리 패킷을 즉시 모두 버리게 하거나, 지정한 시간동안 처리되기를 기다릴 수 있는데 이를 위하여는 setsockopt() 시스템 콜을 사용한다(자세한 내용은 5.2.1절 참조).

2.4.2 서버 프로그램 작성

▶ 여기서는 echo 서비스를 제공하는 서버 프로그램 myecho_ server.c를 소개한다.

▶ myecho_server.c는 클라이언트의 접속요청을 수락한 후 이를 단말기에 표시한 다음 echo 서비스를 한 번 수행한다.

▶ myecho_server.c의 실행결과는 다음과 같다.

# myecho_server 2049

Server : waiting connection request.

Server : client connected.

Server : waiting connection request.

▶ 예를들어 포트번호 2049를 지정하여 myecho_server.c를 서버에서 실행한 후 어떤 클라이언트에서 이 서버로 접속을 하려면 포트번호를 2049로 하여 접속을 요구하여야 한다.

▶ 이러한 클라이언트 프로그램은 2.3.4절의 myecho.c에서 서버의 포트번호를 2049로 하여 사용하면 된다.

▶ myecho_server.c 프로그램은 두 개의 소켓번호를 필요로 하는데 하나(server_fd)는 클라이언트의 연결요청을 기다리기 위해 사용되고 다른 하나(client_fd)는 클라이언트와 데이터를 주고 받기 위해 사용된다.

▶ 서버는 accept() 시스템 콜을 호출하여 클라이언트의 접속요청을 기다리는데 프로그램의 진행은 accept() 시스템 콜에서 멈추게 된다. 이 때 어떤 클라이언트의 접속요청이 수신되면 accept()는 새로운 소켓(client_fd)을 생성하여 리턴한다.

▶ accept()는 또한 두번째 인자로 지정된 소켓주소 구조체 client_addr에, 접속된 클라이언트의 소켓주소 정보를 기록하여 리턴한다.

client_fd = accept(server_fd, (struct sockaddr *)&client_addr, int &len);

 

▶ 서버는 클라이언트가 보내오는 메시지를 read()로 읽고 write()로 echo해 준 다음 소켓 client_fd를 닫는다.

myecho_server.c 프로그램 리스트

2.5 소켓 관련 UNIX 시스템 콜

▶ 본 절에서는 소켓 프로그램 작성시 많이 사용되는 UNIX 시스템 콜을 소개한다.

▶ 먼저 UNIX에서 시그널(signal)이 발생했을 때 이를 처리하는 내용을 임의로 바꿀 때 사용되는 signal() 시스템 콜을 설명하고, 프로세스를 복제하는 데 사용되는 fork() 시스템 콜을 설명한다.

2.5.1 signal()

▶ UNIX 운영체제에서 시그널은 시스템내에서 어떤 사건(event)이 발생한 것을 프로세스 사이에 알리는 수단으로 사용되는데 UNIX에서는 31개의 사건에 대한 시그널이 정의되어 있다.

▶ 소켓 프로그램에서 주로 사용되는 시그널은 SIGFPE, SIGIO, SIGURG 세 가지이다(표 2-3 참조).

SIGFPE부동소수점 연산 에러를 나타냄.
SIGIOI/O가 가능한 상태를 나타냄.
SIGURG
Out-of-band 데이터 도착과 같은 긴급한(urgent) 소켓 상태를 나타냄.

표 2-3 소켓 관련 시그널 종류

▶ 시그널이 발생했을 때 이를 디폴트로 처리하는 내용은 UNIX 커널에 미리 정의되어 있다(예를들어 부동소수점 에러가 발생하면 프로그램이 종료된다).

▶ 그러나 프로그래머는 시그널이 발생하였을 때 이와같은 UNIX 커널이 제공하는 디폴트 처리기 대신 시그널 처리 함수를 임의로 지정할 수 있는데 이 때 signal() 시스템 콜을 사용해야 한다.

▶ 아래의 프로그램 코드에서 signal(SIGIO, sigio_func)이 호출된 이후에 시스템에서 SIGIO 시그널이 발생하면 UNIX 커널의 디폴트 처리기가 동작하지 않고 sigio_func() 함수가 수행된다.

#include <signal.h>

main() {

int sigio_func(); /* 사용자 정의 함수 선언 */

signal(SIGIO, sigio_func); /* 시그널 처리 함수 지정 */

:

}

int sigio_func() {

/* SIGIO 시그널 발생시 처리 부분 */

}

▶ 한편 응용 프로그램에서 앞으로 발생할 시그널을 단순히 무시하려면 다음과 같이 무시할 시그널의 종류(예를들면 SIGIO)를 지정하고 옵션으로 시그널 무시(SIG_IGN)를 선택하여 signal() 시스템 콜을 호출하면 된다.

signal(SIGIO, SIG_IGN);

▶ 다음에는 signal()을 사용하는 예제 프로그램으로 1초마다 카운터를 증가시키고 이를 화면에 출력하는 프로그램 signal_test.c를 소개한다.

▶ UNIX 프로그램이 실행되는 도중에 사용자가 Ctrl-C를 입력하면 시그널 SIGINT가 발생한다.

▶ 예제 프로그램 signal_test.c에서는 SIGINT 시그널이 발생하면 프로그램이 바로 종료되는 것이 아니라 my_signal ()이라는 함수가 호출되게 하였다.

▶ my_signal()내에서 Ctrl-C가 입력된 사실을 화면에 출력하고 Ctrl-C가 세 번 입력되면 그 때 프로그램을 종료하도록 하였다.

▶ signal_test.c의 실행 예는 다음과 같다.

# signal_test

0

1

^C

Ctrl-C pressed.

2

3

^C

Ctrl-C pressed.

4

^C

Ctrl-C pressed.

5

#

▶ signal_test.c에서는 먼저 시그널 SIGINT가 발생했을 때 사용자 정의함수 my _singal()가 수행되도록 다음과 같이 signal() 시스템 콜을 호출해 두어야 한다.

signal(SIGINT, my_signal);

▶ 새로 지정된 시그널 처리 함수 my_signal()에서는 SIGINT가 발생했음을 화면에 출력하고 Cntl-C가 입력된 횟수를 세기 위한 카운터 count를 1 증가시킨다.

int my_signal() {

printf("\nCtrl-C pressed.\n");

count++;

return 0;

}

signal_test.c 전체 프로그램 리스트

2.5.2 fork()

▶ UNIX에서 임의의 프로세스는 fork()를 이용해서 자신과 똑같은 기능을 수행하는 프로세스를 하나 복제할 수 있다.

▶ fork()를 호출한 프로세스를 부모(parent) 프로세스라 하고 새로 생긴 프로세스를 자식(child) 프로세스라고 한다.

▶ 자식 프로세스는 부모 프로세스의 코드, 스택, 파일기술자, 소켓번호 등을 공유하게 되어 프로세스 수행 환경이 부모 프로세스와 같게 된다.

▶ 단, 새로 생긴 자식 프로세스의 id 번호(PID: Process Identication)는 부모 프로세스의 PID와 구별되며 각종 변수들은 공유하지 않는다.

▶ fork() 시스템 콜이 성공적으로 수행되면 그 순간에 하나의 프로세스가 두 개의 프로세스로 되는데 두 프로세스는 수행할 일을 구분하기 위하여 두 프로세스의 fork() 리턴문이 서로 다르다는 것을 이용한다.

▶ 부모 프로세스에게는 fork()의 리턴값으로 새로 만들어진 자식 프로세서의 PID가 리턴되며, 자식 프로세스에게는 fork()의 리턴값이 0이 된다.

▶ 한편 fork() 문이 실패한 경우 부모 프로세스에서는 -1을 리턴하고, 자식 프로세스는 생성되지 않는다.

▶ 그림 2-14에 PID가 135인 어떤 프로세스가 fork()를 수행하여 자식 프로세스를 만드는 것을 보였다.

그림 2-14 fork()의 수행과정

▶ 예를들어 아래와 같은 프로그램 코드를 수행하면 fork() 문을 만나는 순간에 두 개의 프로세스가 생성되는데 그 중 자식 프로세스는 fork()의 리턴값이 0이 되므로 child_ work()를 수행하고, 부모 프로세스에서는 fork()의 리턴값이 자식 프로세스의 PID 값(즉 0이 아님)이 되므로 parent_work()를 수행하게 된다.

int PID;

PID = fork();

if(PID == 0) {

child_work(); /* 자식 프로세스용 코드 */

} else {

parent_work(); /* 부모 프로세스용 코드 */

}

▶ 다음은 fork()의 사용 예로 부모와 자식 프로세스가 같은 이름의 변수를 각각 증가시키고 그 결과를 확인해 보는 예제 프로그램 fork_test.c를 소개한다.

▶ 자식 프로세스는 두 개의 변수 global_var과 local_var를 각각 1씩 증가시키며, 부모 프로세서는 2초 후에 이 두 변수를 각각 5씩 증가시킨다.

/*----------------------------------------------------------------------------------------------

파일명 : fork_test.c

: fork() 시스템 사용 연습

컴파일 : cc -o fork_test fork_test.c

실행예 : fork_test

----------------------------------------------------------------------------------------------*/

#include <sys/types.h>

#include <unistd.h>

int global_var = 0; /* 전역 변수 선언 */

int main(void) {

pid_t pid;

int local_var = 0; /* 지역 변수 선언 */

if((pid = fork()) < 0) {

printf("fork error\n");

return -1;

/* 자식 프로세스 */

} else if (pid == 0) {

global_var++;

local_var++;

printf("CHILD - my pid : %d, parent's pid : %d\n", getpid(), getppid());

} else {

/* 부모 프로세스 */

sleep(2); /* 2 쉰다 */

global_var += 5;

local_var += 5;

printf("PARENT - my pid is %d, child's pid is %d\n", getpid(), pid);

}

printf("\t global var : %d\n", global_var);

printf("\t local var : %d\n", local_var);

}

▶ 이 프로그램 실행 결과는 다음과 같다.

# fork_test

CHILD - my pid is 12557 and parent's pid is 12556

global var : 1

local var : 1

PARENT - my pid is 12556 and child's pid is 12557

global var : 5

local var : 5

#

▶ 이 프로그램 실행 결과에서 2초 후에 부모 프로세스가 main() 함수 밖과 안에서 정의된 변수 global_var와 local_var를 각각 5씩 증가시켰는데 그 결과가 모두 6이 되지 않고 5가 되어 있는 것을 주목할 필요가 있다.

▶ 즉, 자식 프로세스와 부모 프로세스는 파일이나 소켓은 공유하지만 변수는 공유하지 않는다는 것을 알 수 있다.


소강좌제목 : 컴퓨터 네트웍 프로그래밍이전강좌: 제1장 네트웍 프로그래밍의 이해 다음강좌: 제3장 클라이언트 서버 토크프로그램