TCP기반 서버, 클라이언트 구현
TCP기반 서버, 클라이언트 구현
TCP 서버에서의 기본적인 함수호출 순서
제일 먼저 socket 함수의 호출을 통해서 소켓을 생성한다. 그리고 주소정보를 담기 위한 구조체 변수를 선언 및 초기화해서 bind함수를 호출하여 소켓에 주소를 할당한다.
이 두 단계는 이미 여러분에게 설명한 내용이니, 이제 그 이후의 과정에 대해서 설명하겠다.
연결요청 대기상태로의 진입(listen)
bind 함수호출을 통해서 소켓에 주소까지 할당했다면, 이번에는 listen 함수호출을 통해서 ‘연결요청 대기상태’로 들어갈 차례이다. 그리고 listen 함수가 호출되어야 클라이언트가 연결요청을 할 수 있는 상태가 된다. 즉, listen 함수가 호출되어야 클라이언트는 연결요청을 위해서 connect 함수를 호출할 수 있다.
#include <sys.socket.h>
int listen(int sock, int backlog);
// 성공 시 0, 실패 시 -1 반환
sock
: 연결요청 대기상태에 두고자 하는 소켓의 파일 디스크립터 전달, 이 함수의 인자로 전달된 디스크립터의 소켓이 서버 소켓(리스닝 소켓)이 된다.backlog
: 연결요청 대기 큐(Queue)의 크기정보 전달, 5가 전달되면 큐의 크기가 5가 되어 클라이언트의 연결요청을 5개까지 대기시킬 수 있다.
클라이언트의 연결요청 수락(accept)
listen 함수호출 이후에 클라이언트의 연결요청이 들어왔다면, 들어온 순서대로 연결요청을 수락해야 한다. 연결요청을 수락한다는 것은 클라이언트와 데이터를 주고받을 수 있는 상태가 됨을 의미한다. 따라서 이러한 상태가 되기 위해 무엇이 필요한지 짐작할 수 있을 것이다. 당연히 소켓이 필요하다! 전혀 이상할 것 없다. 데이터를 주고받으려면 소켓이 있어야 하지 않는가?
하지만 우리가 소켓을 직접 만들 필요는 없다. 다음 함수의 호출 결과로 소켓이 만들어지고, 이 소켓은 연결요청을 한 클라이언트 소켓과 자동으로 연결되니 말이다.
#include <sys/socket.h>
int accept(int sock, struct sockaddr * addr, socklen_t * addrlen);
// 성공 시 생성된 소켓의 파일 디스크립터, 실패 시 -1 반환
sock
: 서버 소켓의 파일 디스크립터 전달.addr
: 연결요청 한 클라이언트의 주소정보를 담을 변수의 주소 값 전달, 함수호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소정보가 채워진다.addrlen
: 두 번째 매개변수 addr에 전달된 주소의 변수 크기를 바이트 단위로 전달, 단 크기정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다. 그리고 함수호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워진다.
accept 함수는 ‘연결요청 대기 큐’에서 대기중인 클라이언트의 연결요청을 수락하는 기능의 함수이다. 따라서 accept 함수는 호출성공 시 내부적으로 데이터 입출력에 사용할 소켓을 생성하고, 그 소켓의 파일 디스크립터를 반환한다. 중요한 점은 소켓이 자동으로 생성되어, 연결요청을 한 클라이언트 소켓에 연결까지 이뤄진다는 점이다.
Hello world 서버 프로그램 리뷰
이전 포스팅에서 소개한 Hello world 서버 프로그램을 다시 한번 분석해보자.
#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);
}
/* --1-- */
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
/* ----- */
if(serv_sock == -1)
error_handling("socket() error");
/* --2-- */
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");
/* ----- */
/* --3-- */
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
/* ----- */
clnt_addr_size = sizeof(clnt_addr);
/* --4-- */
clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if(clnt_sock == -1)
error_handling("accept() error");
/* ----- */
/* --5-- */
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);
}
- 소켓을 생성하고 있다. 단, 이 때 생성되는 소켓은 아직 서버 소켓이라 부르기 이른 상태이다.
- 소켓의 주소할당을 위해 구조체 변수를 초기화하고 bind 함수를 호출하고 있다.
- 연결요청 대기상태로 등러가기 위해서 listen 함수를 호출하고 있다. 연결요청 대기 큐의 크기도 5로 설정하고 있다. 이제야 비로소 1번에서 생성한 소켓을 가리켜 서버 소켓이라 할 수 있다.
- accept 함수가 호출되었으니,대기 큐에서 첫 번째로 대기 중에 있는 연결요청을 참조하여 클라이언트와의 연결을 구성하고, 이 때 생성된 소켓의 파일 디스크립터를 반환한다. 참고로 이 함수가 호출되었을 때 대기 큐가 비어있는 상태라면, 대기 큐가 창 때까지, 다시 말해서 클라이언트의 연결요청이 들어올 때까지 accept 함수는 반환하지 않는다.
- write 함수호출을 통해서 클라이언트에게 데이터를 전송하고 있다. 그리고는 close 함수호출을 통해서 연결을 끊고 있다.
TCP 클라이언트의 기본적인 함수호출 순서
연결요청이라는 과정은 클라이언트 소켓을 생성한 후에 서버로 연결을 요청하는 과정이다. 서버는 listen 함수를 호출한 이후부터 연결요청 대기 큐를 만들어 놓는다. 따라서 그 이후부터 클라이언트는 연결요청을 할 수 있다. 그렇다면 클라이언트는 어떻게 연결요청을 할까? 다음 함수호출을 통해서 연결요청을 한다.
#include <sys/socket.h>
int connect(int sock, struct sockaddr * servaddr, socklen_t addrlen);
// 성공시 0, 실패 시 -1 반환
sock
: 클라이언트 소켓의 파일 디스크립터 전달.servaddr
: 연결요청 할 서버의 주소정보를 담은 변수의 주소 값 전달addrlen
: 두 번째 매개변수 servaddr에 전달된 주소의 변수 크기를 바이트 단위로 전달
connect가 0을 반환하여 대기 큐 등록이 성공적으로 완료되어도 이것이 서버와 통신이 가능한 상태라는 것을 의미하지는 않는다. 결국 서버에서 accept를 해줘야 한다.
클라이언트의 소켓 주소 정보는?
클라이언트의 소켓 주소정보는 connect 함수 호출시에 할당된다.
커널에서 컴퓨터에 할당된 IP, 임의의 사용 가능한 PORT번호를 선택하여 소켓에 할당한다.
Hello world 클라이언트 프로그램 리뷰
이전 포스팅의 클라이언트 프로그램을 다시 한번 관찰해보자.
#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);
}
/* --1-- */
sock = socket(PF_INET, SOCK_STREAM, 0);
/* ----- */
if(sock == -1)
error_handling("socket() error");
/* --2-- */
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]));
/* ----- */
/* --3-- */
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
/* ----- */
/* --4-- */
str_len = read(sock, message, sizeof(message) - 1);
/* ----- */
if(str_len == -1)
error_handling("read() error!");
printf("Message from server : %s \n", message);
/* --5-- */
close(sock);
/* ----- */
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
- 서버 접속을 위한 소켓을 생성하고 있다. 이 때 생성하는 것은 TCP 소켓이어야 한다.
- 구조체 변수 serv_addr에 IP와 PORT정보를 초기화하고 있다. 초기화되는 값은 연결을 목적으로 하는 서버 소켓의 IP와 PORT정보이다.
- connect 함수호출을 통해서 서버로 연결요청을 하고 있다.
- 연결요청을 성공한 후에 서버로부터 전송되는 데이터를 수신하고 있다.
- 데이터 수신 이후에 close 함수호출을 통해서 소켓을 닫고 있다. 따라서 서버와의 연결은 종료가 된다.
클라이언트는 서버가 accept 함수를 호출한 뒤에도 connect될 수 있다는 사실을 기억하자.
다만 이때 서버는 accept함수 위치에서 blocking된 상태이다.
TCP 서버 - 클라이언트 간 write, read 시 문제
TCP 프로토콜에서는 데이터가 바이트 스트림으로 전송되므로, write에 쓰인 데이터 크기만큼 정확히 read에서 읽는것이 보장되지 않는다. 한 마디로, 버퍼에서 데이터를 읽을 때 여러 바이트를 읽어들이는 경우, 아직 write측에서 보낸 데이터가 여러 패킷에 나뉘어 다 도착하지 않았음에도 불구하고 read에서 일부만을 읽는 현상이 발생할 수 있다.
이를 해결하기 위해서는 명시적으로 데이터의 끝 표시를 나타내거나 정해진 크기만큼 read를 반복하거나 다른 수신 방식을 고려해야 한다.