클라이언트-서버에 대해 이야기할 차례입니다. 통신망에 있는 거의 모든 것은 클라이언트 프로세스가 서버 프로세스와 이야기하거나 그 반대로 동작합니다. telnet을 예로 들어봅시다. 여러분이 텔넷(클라이언트)로 원격지 호스트의 23번 포트에 접속할 때 그 호스트의 프로그램(telnetd라고 불리는 서버)이 살아납니다. 그 프로그램이 들어오는 텔넷 요청을 처리하고 여러분에게 로그인 프롬프트를 띄워주는 등의 일을 처리합니다.
위의 도표에 클라이언트와 서버의 정보 교환이 정리되어 있습니다.
클라이언트-서버 쌍은 SOCK_STREAM이나 SOCK_DGRAM 또는 다른 어떤 방식이라도 사용할 수 있음을 기억하세요. (둘이 같은 방식으로 말하기만 한다면) 클라이언트-서버 쌍의 좋은 예시는 telnet/telnetd, ftp/ftpd 또는 Firefox/Apache 입니다. 여러분이 ftp를 쓸 때마다 여러분의 요청을 받아들이는 원격지 프로그램인 ftpd가 있습니다.
흔히 한 대의 컴퓨터에는 서버가 하나뿐이고, 그 서버는 fork()를 통해 여러 클라이언트를 처리합니다. 기본적인 과정은 아래와 같습니다. 서버가 연결을 기다리고, accept()한 후, 요청을 처리할 자식 프로세스를 fork()합니다. 이것이 다음 절에서 우리의 예제 서버가 하는 일입니다.
이 서버가 하는 일은 스트림 연결에 “Hello, world!”을 전송하는 것뿐입니다. 이 서버를 시험하기 위해서 할 일은 하나의 창에서 이것을 실행한 후 다른 창에서 텔넷에 아래 명령어로 접속하는 일뿐입니다.
$ telnet remotehostname 3490
(역자 주: 최신 macOS에는 telnet이 기본 포함되지 않습니다. Debian과 Fedora 등의 Linux 배포판에서는 패키지로 계속 제공됩니다. telnet을 쓸 수 없다면 이 책에 포함된 telnot 예제24를 컴파일해 데모에 사용할 수 있습니다.)
remotehostname은 서버를 실행하는 컴퓨터의 이름입니다.
/*
** server.c -- a stream socket server demo
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <signal.h>
#define PORT "3490" // 사용자들이 접속할 포트
#define BACKLOG 10 // 대기 중인 연결을 몇 개까지 큐에 둘 것인가
void sigchld_handler(int s)
{
(void)s; // 사용하지 않는 변수 경고를 끕니다
// waitpid()가 errno를 덮어쓸 수 있으므로 저장했다가 복원합니다.
int saved_errno = errno;
while(waitpid(-1, NULL, WNOHANG) > 0);
errno = saved_errno;
}
// IPv4 또는 IPv6 주소를 가져옵니다.
void *get_in_addr(struct sockaddr *sa)
{
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
}
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
int main(void)
{
// sockfd에서 대기하고 들어오는 연결은 new_fd에 저장
int sockfd, new_fd;
struct addrinfo hints, *servinfo, *p;
struct sockaddr_storage their_addr; // 접속한 쪽의 주소 정보
socklen_t sin_size;
struct sigaction sa;
int yes=1;
char s[INET6_ADDRSTRLEN];
int rv;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE; // bind()에 쓸 와일드카드 주소를 요청합니다
if ((rv = getaddrinfo(NULL, PORT, &hints, &servinfo)) != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 모든 결과를 순회해 사용할 수 있는 첫 번째 결과에 바인드합니다
for(p = servinfo; p != NULL; p = p->ai_next) {
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1) {
perror("server: socket");
continue;
}
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes,
sizeof(int)) == -1) {
perror("setsockopt");
exit(1);
}
if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
close(sockfd);
perror("server: bind");
continue;
}
break;
}
freeaddrinfo(servinfo); // 이 구조체는 이제 필요 없습니다
if (p == NULL) {
fprintf(stderr, "server: failed to bind\n");
exit(1);
}
if (listen(sockfd, BACKLOG) == -1) {
perror("listen");
exit(1);
}
sa.sa_handler = sigchld_handler; // 죽은 프로세스를 모두 거둡니다
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
printf("server: waiting for connections...\n");
while(1) { // 주 accept() 루프
sin_size = sizeof their_addr;
new_fd = accept(sockfd, (struct sockaddr *)&their_addr,
&sin_size);
if (new_fd == -1) {
perror("accept");
continue;
}
inet_ntop(their_addr.ss_family,
get_in_addr((struct sockaddr *)&their_addr),
s, sizeof s);
printf("server: got connection from %s\n", s);
if (!fork()) { // 자식 프로세스입니다.
close(sockfd); // 자식은 리스너가 필요 없습니다.
if (send(new_fd, "Hello, world!", 13, 0) == -1)
perror("send");
close(new_fd);
exit(0);
}
close(new_fd); // 부모는 이것이 필요 없습니다.
}
return 0;
}궁금한 독자들을 위해 덧붙이자면 구문의 명료함을 위해서 하나의 큰 main()함수 안에 모든 코드를 다 적었습니다. 원한다면 더 작은 함수들로 나누어도 좋습니다.
(아마도 이 sigaction()을 처음 볼 수도 있는데 괜찮습니다. 이 코드는 fork()된 자식 프로세스가 종료되면서 생기는 좀비 프로세스를 거둬들이는 데 사용됩니다. 좀비 프로세스를 많이 만들고 거둬들이지 않으면 시스템 관리자가 화를 낼 것입니다.)
다음 절에 나오는 클라이언트를 사용해서 이 서버로부터 데이터를 얻을 수 있습니다.
이 녀석은 서버보다도 더 쉽습니다. 이 클라이언트가 하는 일은 여러분이 명령줄에 지정한 호스트의 3490번 포트로 접속하는 것입니다. 그리고 서버가 보낸 문자열을 받습니다.
/*
** client.c -- 스트림 소켓 클라이언트의 예시
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define PORT "3490" // 클라이언트가 접속할 포트
#define MAXDATASIZE 100 // 한 번에 받을 수 있는 최대 바이트 개수
// IPv4 또는 IPv6 sockaddr을 얻습니다
void *get_in_addr(struct sockaddr *sa)
{
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
}
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
int main(int argc, char *argv[])
{
int sockfd, numbytes;
char buf[MAXDATASIZE];
struct addrinfo hints, *servinfo, *p;
int rv;
char s[INET6_ADDRSTRLEN];
if (argc != 2) {
fprintf(stderr,"usage: client hostname\n");
exit(1);
}
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if ((rv = getaddrinfo(argv[1], PORT, &hints, &servinfo)) != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 모든 결과를 순회해 연결할 수 있는 첫 번째 결과를 사용합니다
for(p = servinfo; p != NULL; p = p->ai_next) {
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1) {
perror("client: socket");
continue;
}
inet_ntop(p->ai_family,
get_in_addr((struct sockaddr *)p->ai_addr),
s, sizeof s);
printf("client: attempting connection to %s\n", s);
if (connect(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
perror("client: connect");
close(sockfd);
continue;
}
break;
}
if (p == NULL) {
fprintf(stderr, "client: failed to connect\n");
return 2;
}
inet_ntop(p->ai_family,
get_in_addr((struct sockaddr *)p->ai_addr),
s, sizeof s);
printf("client: connected to %s\n", s);
freeaddrinfo(servinfo); // 이 구조체는 이제 필요 없습니다
if ((numbytes = recv(sockfd, buf, MAXDATASIZE-1, 0)) == -1) {
perror("recv");
exit(1);
}
buf[numbytes] = '\0';
printf("client: received '%s'\n",buf);
close(sockfd);
return 0;
}클라이언트를 실행하기 전에 서버를 실행하지 않으면 connect()는 “Connection refused”를 반환한다는 점을 기억하세요. 아주 유용합니다.
위에서 sendto()와 recvfrom()에 대해 논의할 때 UDP 데이터그램 소켓의 기본에 대해서 이미 알아보았습니다. 그러므로 바로 두 개의 예제 프로그램을 제시하겠습니다. talker.c와 listener.c입니다.
listener는 한 컴퓨터에서 포트 4950으로 들어오는 패킷을 기다립니다. talker는 지정한 컴퓨터의 해당 포트로 사용자가 명령줄에 입력한 내용을 담은 패킷을 보냅니다.
데이터그램 소켓은 연결이 없고 패킷을 허공에 던진 뒤 성공 여부는 신경 쓰지 않기 때문에 클라이언트와 서버에 IPv6을 사용하도록 명시할 것입니다. 이렇게 하면 서버는 IPv6로 리스닝하는데 클라이언트는 IPv4로 전송하여, 서버가 데이터를 받지 못하는 상황을 피할 수 있을 것입니다. (우리의 TCP 스트림 소켓 세상에서도 이런 불일치가 발생할 수 있지만 connect()에서 하나의 주소 계열에 대해 오류가 발생하면 다른 주소 계열로 다시 시도하게 됩니다.)
여기에 listener.c의 소스 코드가 있습니다.27:
/*
** listener.c -- 데이터그램 소켓 "서버"의 예시
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define MYPORT "4950" // 연결할 포트
#define MAXBUFLEN 100
// IPv4 또는 IPv6 주소를 가져옵니다:
void *get_in_addr(struct sockaddr *sa)
{
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
}
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
int main(void)
{
int sockfd;
struct addrinfo hints, *servinfo, *p;
int rv;
int numbytes;
struct sockaddr_storage their_addr;
char buf[MAXBUFLEN];
socklen_t addr_len;
char s[INET6_ADDRSTRLEN];
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET6; // IPv4를 쓰려면 AF_INET으로 설정합니다
hints.ai_socktype = SOCK_DGRAM;
hints.ai_flags = AI_PASSIVE; // bind()에 쓸 와일드카드 주소를 요청합니다
if ((rv = getaddrinfo(NULL, MYPORT, &hints, &servinfo)) != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 모든 결과를 순회하면서 가능한 첫 번째 것에 바인드합니다
for(p = servinfo; p != NULL; p = p->ai_next) {
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1) {
perror("listener: socket");
continue;
}
if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
close(sockfd);
perror("listener: bind");
continue;
}
break;
}
if (p == NULL) {
fprintf(stderr, "listener: failed to bind socket\n");
return 2;
}
freeaddrinfo(servinfo);
printf("listener: waiting to recvfrom...\n");
addr_len = sizeof their_addr;
if ((numbytes = recvfrom(sockfd, buf, MAXBUFLEN-1 , 0,
(struct sockaddr *)&their_addr, &addr_len)) == -1) {
perror("recvfrom");
exit(1);
}
printf("listener: got packet from %s\n",
inet_ntop(their_addr.ss_family,
get_in_addr((struct sockaddr *)&their_addr),
s, sizeof s));
printf("listener: packet is %d bytes long\n", numbytes);
buf[numbytes] = '\0';
printf("listener: packet contains \"%s\"\n", buf);
close(sockfd);
return 0;
}getaddrinfo()에서 우리가 마침내 SOCK_DGRAM을 사용한다는 것에 주목하세요. 또한, listen()과 accept()가 필요하지 않다는 점도 기억하세요. 이것이 연결 없는 데이터그램 소켓을 사용할 때의 장점 중 하나입니다.
다음으로 talker.c의 소스 코드28입니다.
/*
** talker.c -- 데이터그램 "클라이언트"의 예시
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define SERVERPORT "4950" // 사용자들이 접속할 포트
int main(int argc, char *argv[])
{
int sockfd;
struct addrinfo hints, *servinfo, *p;
int rv;
int numbytes;
if (argc != 3) {
fprintf(stderr,"usage: talker hostname message\n");
exit(1);
}
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET6; // IPv4를 쓰려면 AF_INET으로 설정합니다
hints.ai_socktype = SOCK_DGRAM;
if ((rv = getaddrinfo(argv[1], SERVERPORT, &hints, &servinfo)) != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 모든 결과를 순회하면서 소켓을 만듭니다
for(p = servinfo; p != NULL; p = p->ai_next) {
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1) {
perror("talker: socket");
continue;
}
break;
}
if (p == NULL) {
fprintf(stderr, "talker: failed to create socket\n");
return 2;
}
if ((numbytes = sendto(sockfd, argv[2], strlen(argv[2]), 0,
p->ai_addr, p->ai_addrlen)) == -1) {
perror("talker: sendto");
exit(1);
}
freeaddrinfo(servinfo);
printf("talker: sent %d bytes to %s\n", numbytes, argv[1]);
close(sockfd);
return 0;
}이것이 전부입니다! 하나의 장치에서 listener를 실행하고 talker를 다른 장치에서 실행하세요. 그것들이 통신하는 것을 지켜보세요.
(역자 주 : 한 장치에서도 순서만 맞게 실행하면 문제없이 시험할 수 있습니다.)
온 가족이 즐길 수 있는 전연령 프로그램입니다!
이번에는 서버를 실행할 필요도 없습니다! talker를 혼자 실행시키면 패킷을 신나게 날려 보내고, 아무도 반대쪽에서 recvfrom()을 호출하지 않는다면 그저 패킷은 사라질 뿐입니다. UDP 데이터그램 소켓으로 보낸 데이터는 도착을 보장하지 않는다는 점을 기억하세요!
전에 몇 번 말한 사소한 것 한 가지를 빼면 전부입니다: 연결된 데이터그램 소켓이 그것입니다. 그것에 대해서 여기에서 말해야 하는데, 이 문서의 데이터그램에 대한 부분이 바로 여기이기 때문입니다. 위의 talker 가 listener의 주소를 지정하고 connect()를 호출한다고 합시다. 그 순간부터 talker는 connect()로 지정한 주소로만 데이터를 보내고 받을 수 있습니다. 이런 이유로 sendto()와 recvfrom()을 쓸 필요가 없습니다. 단순히 send()와 recv()를 쓰면 됩니다.