TCP 서버
TCP 서버에서의 기본적인 함수 호출 순서
제일 먼저 socket 함수의 호출을 통해 소켓을 생성하고, 주소정보를 담기 위한 구조체 변수를 선언 및 초기화한 후 bind 함수를 호출해 소켓에 주소를 할당합니다. 이 두 단계는 이미 앞선 포스트에서 다뤘으니, 이후의 과정에 대해 다뤄보겠습니다.
연결요청 대기상태로의 진입
bind 함수를 통해 소켓에 주소까지 할당했다면 listen 함수호출을 통해 연결요청 대기상태로 들어갈 차례입니다. listen 함수는 다음과 같은 인자들을 전달받습니다.
- int sock: 연결요청 대기상태에 두고자 하는 소켓의 파일 디스크립터, 이 함수의 인자로 전달된 디스크립터의 소켓이 서버 소켓(리스닝 소켓)이 됩니다.
- int backlog: 연결요청 대기 큐의 크기정보, n이 전달되면 큐의 크기가 n이 되어 클라이언트의 연결요청을 n개까지 대기시킬 수 있습니다.
// 성공 시 0, 실패 시 -1 반환
#include <sys/socket.h>
int listen(int sock, int backlog);
서버가 연결요청 대기상태에 있다는 것은 클라이언트가 연결요청을 했을 때 연결이 수락될 때까지 연결요청 자체를 대기시킬 수 있는 상태에 있다는 것을 의미합니다.
위 그림을 통해 설명을 하겠습니다. 클라이언트의 연결요청도 하나의 데이터 전송이기 때문에, 이를 받아들이려면 당연히 소켓이 있어야합니다. 서버 소켓이 연결 요청을 맞이하는 일종의 문지기 또는 문의 역할을 한다고 볼 수 있습니다. 클라이언트가 "혹시 지금 연결이 가능한가요?" 하고 물어보면 문지기인 서버 소켓은 클라이언트에게 "가능합니다. 그런데 줄이 조금 기니 대기실에서 번호표를 뽑고 기다리세요."라고 얘기합니다.
listen 함수가 호출되면 이렇듯 문지기의 역할을 하는 서버 소켓이 만들어지고, listen 함수의 두 번째 인자로 전달되는 정수의 크기에 해당하는 대기실이 만들어집니다. 이 대기실을 가리켜 연결요청 대기 큐라고 하고, 서버 소켓과 연결요청 대기 큐가 완전히 준비되어서 클라이언트의 연결요청을 받아들일 수 있는 상태를 연결요청 대기상태라고 합니다.
클라이언트의 연결요청 수락
listen 함수호출 이후에 클라이언트의 연결요청이 들어왔다면, 들어온 순서대로 연결요청을 수락해야 합니다. 연결요청을 수락한다는 것은 클라이언트와 데이터를 주고받을 수 있는 상태가 된다는 것을 의미하고, 따라서 이러한 상태가 되기 위해 당연히 소켓이 필요합니다. 그런데 이미 만들어놓은 서버 소켓을 쓰면 되지 않냐는 생각이 들 수도 있습니다. 그런데 서버 소켓은 문지기 역할을 해야하는데 클라이언트와의 데이터 송수신을 위해 이걸 사용하면 문지기 역할을 할 소켓이 없습니다. 그런데 우리가 직접 소켓을 만들 필요는 없고, 다음 함수를 쓰면 소켓이 만들어지고, 만들어진 소켓은 연결요청을 한 클라이언트 소켓과 자동으로 연결됩니다. 이번에 소개할 accept 함수에는 다음과 같은 인자들을 전달해야 합니다.
- int sock: 서버 소켓의 파일 디스크립터
- struct sockaddr* addr: 연결요청 한 클라이언트의 주소정보를 담을 변수의 주소 값, 함수호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소정보가 채워집니다.
- socklen_t* addrlen: 두 번째 매개변수 addr에 전달된 주소의 변수 크기를 바이트 단위로 전달하는데, 크기정보를 변수에 저장한 후 변수의 주소 값을 전달해야 합니다. 그리고 함수호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워집니다.
// 성공 시 생성된 소켓의 파일 디스크립터, 실패 시 -1 반환
#include <sys/socket.h>
int accept(int sock, struct sockaddr* addr, socklen_t* addrlen);
이 함수는 연결요청 대기 큐에서 대기중인 클라이언트의 연결요청을 수락하는 기능의 함수입니다. 호출성공 시 내부적으로 데이터 입출력에 사용할 소켓을 생성하고 그 소켓의 파일 디스크립터를 반환합니다.
이 글에서 다뤘던 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, 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]));
// bind로 소켓에 IP 주소와 PORT 번호 할당
if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
// listen 함수를 호출해 소켓이 연결요청 대기상태로 들어가게 합니다.
// 이제 이 소켓은 서버 소켓(리스닝 소켓)이라 할 수 있습니다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
clnt_addr_size = sizeof(clnt_addr);
// 연결요청의 수락을 위한 accept 함수 호출,
// 대기 큐가 비어있다면 대기 큐가 찰 때까지 accept 함수는 반환하지 않고,
// 대기 큐가 차있다면 대기 큐에서 첫 번째로 대기 중에 있는 연결요청을 참조해
// 클라이언트와 연결을 구성하고, 이 때 생성된 소켓의 파일 디스크립터를 반환합니다.
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error");
// 클라이언트에게 write 함수로 데이터를 전송합니다.
write(clnt_sock, message, sizeof(message));
// close 함수호출을 통해 클라이언트와의 연결을 끊습니다.
close(clnt_sock);
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
여태까지의 과정을 잘 따라왔다면 이 코드를 이해하기가 어렵지 않을 것입니다. 만약 이 코드를 이해하는 데 무리가 있다면 앞선 포스트들을 다시 읽어보는 것을 추천드립니다.
TCP 클라이언트
TCP 클라이언트의 기본적인 함수 호출 순서
서버의 구현과정과 차이가 있는 부분은 연결요청 이라는 과정입니다. 서버는 listen 함수를 호출한 이후부터 연결요청 대기 큐를 만들어 놓습니다. 따라서 그 이후부터 클라이언트는 연결요청을 할 수 있습니다. 클라이언트는 connect 라는 함수를 통해서 연결요청을 하고, 이 함수는 다음과 같은 인자들을 전달받습니다.
- int sock: 클라이언트 소켓의 파일 디스크립터 전달
- struct sockaddr* servaddr: 연결요청 할 서버의 주소정보를 담은 변수의 주소 값 전달
- socklen_t addrlen: 두 번째 매개변수 servaddr에 전달된 주소의 변수 크기를 바이트 단위로 전달
// 성공 시 0, 실패 시 -1 반환
#include <sys/socket.h>
int connect(int sock, struct sockaddr* servaddr, socklen_t addrlen);
클라이언트에 의해 connect 함수가 호출되면 다음 둘 중 한 가지 상황이 되어야만 함수가 반환됩니다.
- 서버에 의해 연결요청이 접수된 경우
- 네트워크 단절 등 오류상황에 의해 연결요청이 중단된 경우
그리고 클라이언트 소켓은 IP와 PORT를 할당하지 않아도 되는 걸까 라는 생각이 들 수도 있습니다. 물론 클라이언트 소켓도 IP와 PORT를 할당해야 합니다. 서버 소켓을 다룰 때 이를 bind 함수를 이용해서 해결했습니다. 하지만 클라이언트 소켓을 다룰 때는 bind 함수를 통해 IP와 PORT를 할당해주지 않아도 connect 함수가 자동으로 소켓에 IP와 PORT를 할당해줍니다. 이 때 IP는 컴퓨터(호스트)에 할당된 IP를 사용하고, PORT는 임의로 선택해서 사용합니다.
이번에도 이 글에서 다뤘던 클라이언트 프로그램을 다시 보며 분석해보겠습니다.
#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);
}
// 서버 접속을 위한 소켓 생성, 이 때 생성하는 것은 TCP 소켓이어야 합니다.
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error");
// 서버 소켓의 IP와 PORT 정보를 이용해 구조체 변수 serv_addr를 초기화합니다.
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]));
// connect 함수호출로 서버 프로그램에 연결을 요청합니다.
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 함수호출을 통해 소켓을 닫습니다. 서버와의 연결이 종료됩니다.
close(sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
이 코드 역시 여태까지의 과정을 잘 따라왔다면 이해가 잘 돼야합니다. 그렇지 않다면 이전 포스트들을 복습하세요.
TCP 기반 서버, 클라이언트의 함수 호출 관계
TCP 서버와 클라이언트 프로그램의 구현 순서를 다뤘는데, 이 둘은 독립적 과정이 아니므로 하나의 과정으로 머릿속에 그릴 수 있어야 합니다. 아래 그림을 보며 머릿속으로 나름의 설명을 생각해보세요. 설명이 생각되지 않고 막힌다면 다시 복습하고 넘어가는 걸 추천드립니다.
Iterative 기반의 서버, 클라이언트 구현
여태 보아온 서버는 한 클라이언트의 요청에만 응답하고 바로 종료되어 버렸습니다. 때문에 연결요청 대기 큐의 크기도 사실상 의미가 없었습니다. 그런데 이는 일반적으로 생각하는 서버의 모습이 아닙니다. 그렇다면 계속해서 들어오는 클라이언트의 연결요청을 수락하기 위해서는 서버의 코드 구현을 어떻게 하면 될까요? 그냥 반복문을 삽입해서 accept 함수를 여러 번 호출하면 됩니다. 이러한 형태의 서버를 iterative 서버라고 합니다. 아래는 iterative 서버의 함수호출 순서입니다.
에코 서버 구현
다음과 같은 사항들을 만족하는 에코 서버를 만들어보겠습니다.
- 서버는 한 순간에 하나의 클라이언트와 연결되어 에코 서비스를 제공합니다.
- 서버는 총 다섯 개의 클라이언트에게 순차적으로 서비스를 제공하고 종료합니다.
- 클라이언트는 프로그램 사용자로부터 문자열 데이터를 입력 받아서 서버에 전송합니다.
- 서버는 전송 받은 문자열 데이터를 클라이언트에게 재전송합니다.
- 서버와 클라이언트 간의 문자열 에코는 클라이언트가 Q를 입력할 때까지 계속합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
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_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
clnt_adr_sz = sizeof(clnt_adr);
for (int i = 0; i < 5; i++) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if (clnt_sock == -1)
error_handling("accept() error");
else
printf("Connected client %d\n", i + 1);
while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
위에서 말했지만 앞서 다뤘던 서버 프로그램과 별로 다른 점이 없고 accept를 하고 데이터를 받는 부분에 for문이 들어갔다는 차이만 있습니다.
에코 클라이언트 구현
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
char message[BUF_SIZE];
int str_len, recv_len, recv_cnt;
struct sockaddr_in serv_adr;
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_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error");
else
puts("connected...");
while (1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
str_len = write(sock, message, strlen(message));
recv_len = 0;
// TCP는 데이터의 경계가 존재하지 않으므로 클라이언트에서 보내고자 하는 메시지를 한 번에 보냈더라도
// 서버에서 받은 메시지를 여러 패킷으로 나눠 줄 수도 있으므로 str_len에 보낸 문자열의 길이를 저장해두고
// 그에 해당하는 길이의 메시지를 모두 읽어들일 때까지 읽어들여야 합니다.
while(recv_len < str_len) {
recv_cnt = read(sock, &message[recv_len], BUF_SIZE - 1);
if (recv_cnt == -1)
error_handling("read() error");
recv_len += recv_cnt;
}
message[recv_len] = 0;
printf("Message from server: %s\n", message);
}
close(sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
에코 클라이언트 이외의 경우에는? 어플리케이션 프로토콜 정의
에코 클라이언트는 수신할 데이터의 크기를 미리 파악할 수 있지만 이것이 불가능한 경우가 훨씬 많습니다. 이렇게 수신할 데이터의 크기를 파악할 수 없을 때에는 어플리케이션 프로토콜이 필요합니다. 데이터의 송수신 과정에서 데이터의 끝을 파악할 수 있는 약속(프로토콜)을 별도로 정의해서 데이터의 끝을 표현하거나, 송수신될 데이터의 크기를 미리 알려줘서 그에 따른 대비가 가능하도록 해야 합니다. 서버, 클라이언트의 구현 과정에서 하나둘씩 만들어지는 약속을 모아 어플리케이션 프로토콜이라고 합니다.
'네트워크 프로그래밍 > C' 카테고리의 다른 글
[네트워크 프로그래밍/C] UDP에 대한 이해 (0) | 2022.02.25 |
---|---|
[네트워크 프로그래밍/C] TCP의 이론적 내용 (0) | 2022.02.25 |
[네트워크 프로그래밍/C] TCP와 UDP에 대한 이해 (0) | 2022.02.22 |
[네트워크 프로그래밍/C] 인터넷 주소의 초기화와 할당 (0) | 2022.02.22 |
[네트워크 프로그래밍/C] 네트워크 바이트 순서와 인터넷 주소 변환 (0) | 2022.02.21 |