int socket_descriptor;
// domain, type, protocol
// IPv4, TCP, 시스템이 프로토콜 선택
socket_descriptor = socket(AF_INET, SOCK_STREAM, 0);
[ 참고 ] 파일 디스크립터(FD) 란?
- 리눅스에서는 소켓 조작이 파일 조작과 동일하게 간주
- FD는 시스템으로부터 할당 받은 파일, 소켓에 부여된 정수
- FD는 0이 아닌 정수 값을 가짐 ( 0:표준입력 / 1:표준출력 / 2:표준에러 )
- 파일 디스크립터를 파라미터로 전달해서,OS의 어떤 파일에 데이터를 쓸지, 요청할지 결정
2) bind() 시스템 콜
- socket() 을 통해 생성한 소켓에 IP주소와 Port 번호를 바인딩
- 어떤 소켓에 바인딩할지 OS에 알려주기 위해 파일 디스크립터 포함
- 클라이언트는 Port 번호가 자동으로 부여되므로, 서버에서만 사용 ( * 아래 참고 )
[ bind 시스템 콜 형식 : bind( sockfd, sockaddr, socklen_t ); ]
- sockfd : 바인딩 할 소켓의 파일 디스크립터
- sockaddr : 소켓에 바인딩할 IP 주소, Port 번호를 구조체로 담음
- socklen_t : sockaddr 구조체의 메모리 크기
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
// 1. socket() 시스템 콜
// IPv4, TCP, 시스템 프로토콜 선택
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 파일 디스크립터 값이 -1인 경우 -> socket 생성 실패
if (sockfd == -1) {
perror("Socket creation failed");
}
// 소켓 바인딩할 IP, Port 담은 구조체
struct sockaddr_in server_address;
server_address.sin_family = AF_INET // IPv4
server_address.sin_addr.s_addr = INADDR_ANY; // 모든 가능한 IP 주소
server_address.sin_port = htons(80); // 포트 번호 80
if (bind(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) == -1) {
perror("Bind Failed");
return 1;
}
return 0;
}
[ 참고 ] 클라이언트는 통신 시 Port 번호가 자동으로 부여되는 이유!
- 서버 프로그램의 경우, 보통 지정된 포트 번호를 가지고 있음
- 하지만 클라이언트 프로그램은 자신의 IP 주소와 port 번호를 다른 클라이언트 / 서버가 미리 알고 있을 필요 X
- 또한 클라이언트 프로그램이 여러 컴퓨터와 병렬적으로 연결되는 경우, 하나의 포트 번호를 사용하면 에러 발생
- 따라서 클라이언트는 bind()를 사용하는 것이 더 범용성을 떨어뜨리므로 임의로 부여된 포트 번호 사용
3) listen() 시스템 콜
- 서버가 클라이언트에서 연결요청이 오고 있는지 듣고 있는 함수
- 연결 지향인 TCP 에서만 사용
- 최대로 받아주는 크기인 backlog를 설정하여, 클라이언트의 연결 요청을 받아들임
- 이때 backlog는 TCP의 backlog queue ( *아래 참고 )의 크기
[ listen 시스템 콜 형식 : listen( sockfd, backlog ); ]
- sockfd : 소켓의 파일 디스크립터
- backlog : 연결 요청을 받아줄 크기 ( = TCP의 백로그 큐 크기 )
#include <sys/socket.h>
int main() {
// 1. socket() 시스템 콜
// IPv4, TCP, 시스템 프로토콜 선택
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 파일 디스크립터 값이 -1인 경우 -> socket 생성 실패
if (sockfd == -1) {
perror("Socket creation failed");
}
// 앞서 작성한 서버 소켓 주소, 바인딩 설정 코드 생략---->
int backlog = 10; // 최대 대기열 크기
if (listen(sockfd, backlog) == -1) {
perror("Listen failed");
return 1;
}
return 0;
}
[ 참고 ] TCP backlog queue 알아보기
- listen() 시스템 콜은 파라미터의 backlog 크기만큼 backlog queue를 생성!
- 서버는 backlog queue를 가진 채로 클라이언트의 연결 요청을 받기 위해 대기 상태 유지
- 수많은 클라이언트가 요청을 보내면 이 요청들은 모두 backlog queue에 저장
- 커널은 listen() 소켓에 대해 2개의 큐를 가지고 있음 (complted connection queue, incompete connection queue)
- backlog는 이 두가지 큐의 합에 대한 최대값을 규정 ( 가득 찬 경우 요청 거절 )
1) 완전 연결 큐 : 3HSK가 맺어진 클라이언트 항목 저장 (established 상태)
2) 불완전 연결 큐 : 3HSK를 맺기 위해 보낸 SYN* 패킷을 저장하는 큐 (SYN_RCVD 상태)
* SYN : Client가 소켓을 통해 서버에 요청을 하여 backlog queue에 들어갈 때 보내는 요청
- backlog queue에서 SYN을 보내 대기 중인 요청을, 큐에서 하나씩 연결 수립
- 리턴 값은 새로운 소켓의 파일 디스크립터 (* 아래 내용 참조)
[ accept 시스템 콜 형식 : accept( sockfd, sockaddr, socklen_t ); ]
- sockfd : 백로그 큐의 요청을 받아들이기 위한 소켓의 파일 디스크립터
- backlog : 백로그 큐에서 뺀 연결 요청을 통해 확인한 클라이언트 주소 정보 (IP주소, port 번호)
- socklen_t : backlog 구조체의 메모리 크기
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
// 1. socket() 시스템 콜
// IPv4, TCP, 시스템이 프로토콜 선택
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
// 2. bind() 시스템 콜
// 소켓에 바인딩할 IP주소, Port 번호를 담은 구조체
struct sockaddr_in server_address;
// IPv4 주소 체계 사용
server_address.sin_family = AF_INET;
// 모든 가능한 IP 주소
server_address.sin_addr.s_addr = INADDR_ANY;
// 포트 번호 80
server_address.sin_port = htons(80);
bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));
// 3. listen() 시스템 콜
// TCP 백로그 큐의 크기 = 5
listen(server_socket, 5);
print("Server: Waiting for client's connection...\n");
// 4. accept() 시스템 콜
// 백로그 큐에서 빼온 클라이언트의 주소 정보를 담은 구조체
struct sockaddr_in client_address;
socklen_t slient_addrlen = sizeof(client_address);
int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);
print("Server: Accepted connection from $s:%d\n",
inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
// 5.3-way handshake의 나머지 두 단게 수행
// 아래의 1.5장에서 이에 대해 자세히 설명
char buffer[1024];
// Client ACK 받기
ssize_t bytes_received = recv(client_socket, buffer, sizeof(buffer), 0);
if (bytes_received > 0) {
printf("Server: Received ACK from client.\n");
}
}
[ * 코드 함수 ]
- inet_nota() 함수 : 네트워크 바이트 순서의 32bit 를 Dotted-Decimal Notation 주소값으로 변환
- ntohs() 함수 : 네트워크 바이트 순서를 IP 포트 번호로 변환
1.5 TCP 3-way handshake
앞서 살펴보았듯이 TCP는 연결지향성, 신뢰성을 보장한다.
이는 TCP 3-way handshake 과정을 통해
클라이언트와 서버가 준비되었음을 확인하는 과정이 있기 때문이다.
위의 TCP 3-way handshake 과정을 하나씩 살펴보자.
1) SYN : 서버가 listen() 상태일 때 소켓 연결 요청을 보내는 것 ( = connect() system call )
2) SYN / ACK : accept() 시스템콜 이후 클라이언트에 요청이 잘 수행되었음을 반환
3) ACK : 서버의 SYN/ACK에 대한 반환
여기서 2와 3번 과정은 OS의 system call을 통해 이루어지며,
accept() 콜 이후에 established 상태를 수립하는 과정이다.
해당 과정에 대한 자세한 그림은 다음과 같다.
하지만 accept() 시스템 콜이 수행되더라도,
곧바로 3WHS를 통해 데이터 송수신이 이루어지는 것은 아니다.
서버는 하나의 프로세스이므로,
수많은 클라이언트의 요청이 들어있는 백로그 큐의 가장 앞에 있던 요청의
수행과 응답을 모두 끝낸 후, 다음 요청을 수행하려면 병목 현상이 발생할 것이다.
따라서 서버는 연결 요청을 받는 부분과, 응답을 보내는 부분을 구분한다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_address;
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
server_address.sin_port = htons(80); // 웹 서버 포트인 80
bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));
listen(server_socket, 5);
printf("Server: Listening on port 80...\n");
while (1) {
struct sockaddr_in client_address;
socklen_t client_addrlen = sizeof(client_address);
// accept() 시스템 콜
int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);
// fork() 시스템 콜
// 자식 프로세스인 경우
// exec() 시스템 콜은 생략
if (fork() == 0)
printf("Server: Accepted connection from %s:%d\n",
inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
// 3-way handshake의 나머지 두 단계 수행
// 여기서는 ACK를 보내는 과정만 간단히 보여줍니다.
sleep(1); // 실제로는 필요한 로직 수행
// 서버의 응답 전송
char response[] = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!";
send(client_socket, response, strlen(response), 0);
printf("Server: Sent response to client.\n");
close(client_socket);
exit(0);
}
close(client_socket);
}
close(server_socket);
return 0;
}
위의 코드를통해 accept() 시스템 콜에 대한 리턴을 받은 것은
SYN 요청을 보낸 클라이언트가 백로그 큐에 있은 후, 이를 위한 응답을 위해 새로운 소켓을 만드는 것임을 알 수 있다.
그렇다면, 여기서 fork() 시스템 콜에 대해 조금 더 자세히 알아보자.
[ fork() 시스템 콜 : 자식 프로세스 생성 ]
- return값 0 : 자식 프로세스
- return 값 0이 아님 : 부모 프로세스
앞선 코드를 부모 프로세스와 자식 프로세스의 입장으로 나누어보면, 다음과 같다.
1) 부모 프로세스의 입장
부모 프로세스의 경우 accept() 시스템 콜로 연결 요청을 받아준 후,
나머지 작업은 자식 프로세스에게 맡기는 것을 확인할 수 있다.
부모 프로세스는 새로운 클라이언트의 연결 요청을 받아주는 역할!
#include <stdio.h#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_address;
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
server_address.sin_port = htons(80); // 웹 서버 포트인 80
bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));
listen(server_socket, 5);
printf("Server: Listening on port 80...\n");
while (1) {
struct sockaddr_in client_address;
socklen_t client_addrlen = sizeof(client_address);
int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);
if (fork() == 0 -> false ) {
실행안함
}
}
close(server_socket);
return 0;
}
2) 자식 프로세스의 입장
자식 프로세스의 경우 부모 프로세스가 새롭게 만든 소켓을 받아서,
나머지 작업 (3WHS의 2,3단계) 을 수행한 후, 클라이언트와 데이터 통신을 하는 것을 확인할 수 있다.
이때 해당 작업을 모두 수행하면 exit() 시스템 콜을 호출하여,
새로운 연결 요청을 받지 않고 종료한다.
자식 프로세스는 3WHS의 나머지 두 단계를 수행한 후 ACK를 보내는 역할!
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_address;
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
server_address.sin_port = htons(80); // 웹 서버 포트인 80
bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));
listen(server_socket, 5);
printf("Server: Listening on port 80...\n");
while (1) {
struct sockaddr_in client_address;
socklen_t client_addrlen = sizeof(client_address);
int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);
if (fork() == 0 -> true) { // 자식 프로세스
printf("Server: Accepted connection from %s:%d\n",
inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
// 3-way handshake의 나머지 두 단계 수행
// 여기서는 ACK를 보내는 과정만 간단히 보여줍니다.
sleep(1); // 실제로는 필요한 로직 수행
// 서버의 응답 전송
char response[] = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!";
send(client_socket, response, strlen(response), 0);
printf("Server: Sent response to client.\n");
close(client_socket);
// 자식 프로세스 종료
// 새로운 연결 요청 받지 않고, 응답만 준 후 종료
exit(0);
}
close(client_socket);
}
close(server_socket);
return 0;
}
[ 정리 ]
서버는 연결을 받는 부분 (부모 프로세스) 과 응답을 주는 부분 (자식 프로세스) 이 병렬적으로 이루어져 있다!