UMC 6기에 합격하고 난뒤 첫 시작인 1주차 워크북을 진행하면서 얻은 지식을 정리 및 전달을 위해 블로그를 작성합니다. 아마 연차를 거듭하면서 대대적인 블로그 리펙토링이 있을 예정이므로? 미흡한 정보전달이더라도 양해를 부탁드립니다. 🙇🏻♂️
게임 좀 해보신 분들은 서버란 단어가 매우 익숙하실 겁니다. 서버 점검이라던지, 서버를 열어서 멀티플레이를 즐긴다던지 아주 흔하게 접할 수 있습니다. 그럼 서버는 뭔가 여러 개의 접속이 가능한 뭔가인가? 하실 수 있습니다. 혹은 클라이언트의 요청에 대한 대답을 주는 것. 이라고도 할 수 있습니다. 사실 굉장히 광범위하게 사용되는 단어 혹은 개념이기 때문에 뭔가 하나가 정답이야! 라고 하기엔 애매한 감이 있습니다. 그렇기에 저는 웹서비스에서 사용되는 서버의 개념으로 접해서 워크북을 진행 했습니다. 웹에서의 서버는 클라이언트의 요청을 받아서 그에 대응되는 대답을 반환하는 것입니다.
서버는 OS에 의해 동작되는 프로세스입니다. 어떤 서버용 컴퓨터에서 작동하는 프로세스가 서버인것 이죠. 클라이언트 컴퓨터의 프로세스와 통신을 수행합니다. 때문에 이번 포스팅에선 어떻게 서버와 클라이언트의 데이터가 서로에게 도착하는지에 대해 이해하는것이 목표입니다.
우리의 수많은 컴퓨터들이 각자를 구분하기 위해서는 고유한 수단이 필요합니다. 이때 사용되는것이 IP주소입니다. 그러나 IPv4방식에는 한계가 이미 찾아왔고 사설 아이피의 개념을 사용하고 있지만 그래도 부족합니다. 여러 해결 방안은 따로 포스팅 해두었으니 " IP와 포트번호 "읽어보시면 대략적으로 파악이 되실 겁니다.
중요한 점은 서비스를 이용하면서 보통의 경우는 컴퓨터가 직접 네트워크 통신을 하는게 아닙니다. 컴퓨터에서 동작하는 프로세스가 또다른 프로세스와 통신을 하는 것 입니다. 카카오톡을 예시로 들면 카카오톡을 실행하는 컴퓨터가 메세지를 보내는게 아닌 카카오톡 이라는 프로세스가 메세지를 보내는 것 입니다. 여기서 호스트를 구분하기 위해 IP주소를 사용하고 프로세스를 식별하기 위해서 포트번호를 사용합니다.
서버가 운영체제의 write 시스템 콜을 통해 소켓에 데이터를 보내고 이후 TCP/UDP 계층과 IP 계층 그리고Ethernet을 거쳐 흐름제어, 라우팅 등의 작업을 하게 됩니다. 마지막으로 NIC(랜 카드)를 통해 외부로 데이터를 보냅니다.
반대로 NIC에서 데이터를 수신 하고, 인터럽트를 통해 Driver로 데이터를 옮기고 네트워크 스택에서 데이터가 이동하며 소켓에 데이터가 담기고, 수신 대상이 되는 프로세스에 데이터가 도달하게 됩니다.
소켓이란 네트워크 관점에서 프로그램 혹은 프로세스가 데이터를 송수신 할 수 있도록 네트워크 환경에 연결할 수 있게 만들어진 연결부 입니다. 말이 어렵다면 소켓이라는 명세서=메뉴판이 있고 네트워크상에서는 이 소켓이라는 메뉴판으로만 소통을 할 수 있는겁니다. "네트워크상에서 데이터 통신을 하기 위한 명세서가 소켓이다."라고 알고 계시면 됩니다.
TCP는 UDP와 달리 신뢰성을 가집니다. 대표적으로 Three hand shaking과 같이 데이터의 전송을 보장해준다는 신뢰라고 이해 하시면 됩니다. 정확하게는 두 호스트의 전송충 사이에서 논리적인 연결을 설정하는 연결형(connection- oriented)프로토콜입니다. 때문에 흐름을 제어하고 오류를 제어하고 혼잡을 제어합니다.
논리적인 연결을 설정하지 않고 사용자 데이터그램을 전송하는 비연결형 (connectionless) 프로토콜 입니다. 데이터를 보내는데 있어서 수신에 대한 확인을 하지 않기 때문에 TCP보단 비교적 심플합니다.
일반적인 웹서버에서는 TCP소켓을 사용하기 때문에 TCP소켓에 대해서 얘기 해보겠습니다.
위의 그림은 클라이언트와 서버의 통신과정을 보여줍니다. 각 함수를 하나씩 알아보도록 합시다.
socket()
함수는 네트워크 통신을 위한 소켓을 생성하는 시스템 콜입니다. 이 함수는 세 가지 인자를 받습니다.
다음은 socket()
함수의 사용 예시입니다.
int socket_descriptor;
socket_descriptor = socket(AF_INET, SOCK_STREAM, 0);
socket()
함수는 파일 디스크립터를 반환합니다. 파일 디스크립터는 리눅스 시스템에서 모든 것을 파일로 취급하기 때문에 소켓도 파일로 취급됩니다. 이 파일 디스크립터를 통해 소켓과 관련된 작업을 수행합니다.
예를 들어, 웹 서버 프로세스가 데이터를 전송하기 위해 write()
와 read()
시스템 콜을 사용할 때, 소켓 파일 디스크립터를 전달하여 데이터 작성과 요청을 수행합니다.
socket() 시스템 콜은 네트워크 통신을 위한 소켓을 생성하고 초기화하는 작업을 수행합니다. 이는 프로그래머가 특정 프로토콜과 소켓 유형을 선택하여 효율적인 네트워크 통신을 가능하게 합니다.
네트워크 프로그래밍에서 bind()
시스템 콜은 중요한 역할을 수행합니다. 이를 통해 서버는 클라이언트의 연결을 수신할 수 있는 주소를 설정합니다.
bind()
함수의 기본 구조는 다음과 같습니다.
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
socket()
함수로 생성된 소켓을 가리킵니다.sockaddr
구조체의 포인터입니다. 주로 sockaddr_in
(IPv4) 또는 sockaddr_in6
(IPv6) 구조체를 사용합니다.아래의 예시 코드는 bind()
함수를 사용하여 서버 소켓에 IP 주소와 포트 번호를 할당하는 방법을 보여줍니다.
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
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("바인딩 실패");
return 1;
}
// 바인딩 성공 후의 처리 및 작업 수행
return 0;
}
bind()
를 하기 전, 소켓 구조체를 생성합니다. socket()
으로 만들어진 빈 껍데기인 소켓에 주소를 묶는 = binding 동작을 합니다. 따라서 서버의 소켓은 모든 주소로부터 통신을 받아들이고 80포트를 열어두는 것 입니다.
listen()
함수의 기본 구조는 다음과 같습니다.
listen(sockfd, backlog)
sockfd : 소켓의 파일 디스크립터
backlog: : 연결요청을 받아줄 크기 = TCP의 백로그 큐의 크기
listen()
은 연결지향인 TCP에서만 사용합니다. 파라미터로 받은 파일 디스크립터에 클라이언트의 연결요청을 받도록 설정하며 backlog의 크기를 설정합니다. 여기서 말하는 backlog는 TCP의 backlog queue입니다. 즉 backlog가 3으로 설정되었다면, 큐의 최대 크기는 3이됩니다. 그럼 연결요청이 큐에 가득 차게 되면, 추가적인 연결요청은 거부되거나 무시되는것 입니다.
Backlog queue란?
TCP의 백로그 큐(backlog queue)는 서버 소켓이 클라이언트의 연결 요청을 대기열에 저장하는 역할입니다. 이는 서버가 한 번에 여러 클라이언트의 연결 요청을 처리할 수 있도록 도와주는 메커니즘입니다. 백로그 큐는 두 가지 큐가 있습니다. 간단하게 미완성된 연결요청을 저장하는 SYN Queue, 3-way handshake가 완료된 요청을 저장하는 Accept Queue가 있습니다.
accept()
함수의 기본 구조는 다음과 같습니다.
int accept(sockfd, sockaddr , socklen_t);
sockfd : 백로그 큐의 요청을 받아들이기 위한 소켓의 파일 디스크립터
sockaddr : 선입선출로 빼온 연결 요청에서 알아낸 클라이언트의 주소 정보
socklen_t : 위 구조체의 메모리 크기
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);
bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));
listen(server_socket, 5);
printf("Server: Waiting for client's connection...\n");
struct sockaddr_in client_address;
socklen_t client_addrlen = sizeof(client_address);
int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);
printf("Server: Accepted connection from %s:%d\n",
inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
// 3-way handshake의 나머지 두 단계 수행
char buffer[1024];
ssize_t bytes_received = recv(client_socket, buffer, sizeof(buffer), 0); // 클라이언트의 ACK 받기
if (bytes_received > 0) {
printf("Server: Received ACK from client.\n");
}
accept()
시스템 콜은 backlog queue중 SYN queue에서 syn을 보내와 대기 중인 클라이언트의 요청을 자료구조 큐로 하나씩 연결에 대한 수립을 해줍니다.
파라미터에서 클라이언트의 아이피 주소와 포트번호(socketaddr)를 받는데 이 값은 백로그 큐에서 가장 앞에 있는 연결요청 구조체에서 가져옵니다.
accept() 시스템 콜의 리턴 값은 새로운 소켓의 파일 디스크립터입니다만, 왜 새로운 소켓을 만들까요?
accept() 시스템 콜의 목적은 대기 중인 연결 요청을 받아들이고, 새로운 연결을 처리할 수 있는 소켓을 생성하는 것입니다. 여기서 새로운이라는 의미만 알고 넘어갑시다.
앞서 TCP는 UDP에 비해 신뢰성을 보장한다고 했습니다. 바로 신뢰성을 검증하는 로직이 3-way handshake입니다.
위의 3가지 과정 중에 클라이언트가 보내는 SYN패킷이 listen 상태의 서버소켓에 연결 요청을 보내는 것입니다. 나머지 밑의 과정에서 서버가 SYN-ACK패킷을 보내고 클라이언트도 확인했다는 ACK패킷을 보내 최종적으로 Established 상태가 되어 본격적인 데이터가 송/수신 됩니다.
위 그림은 listen이후의 동작을 표현합니다. 클라이언트의 connect()
가 SYN패킷으로서 서버에게 연결 요청을 보냅니다. 연결요청은 백로그 큐중 하나에 도착하는데, 여기서 백로그 큐는 SYN queue입니다. 그 후에 서로 ACK패킷을 교환합니다.
클라이언트의 connect()
호출 (SYN 패킷 전송):
connect()
함수를 호출하여 서버에 연결 요청을 보냅니다. 이 요청은 TCP SYN 패킷으로 이루어집니다.서버의 SYN-ACK 응답:
클라이언트의 ACK 응답:
accept()
호출을 통해 처리할 준비가 된 상태입니다.여기서 영화 티겟 발급 웹서비스가 있다고 가정합시다. 티켓카운터는 1개뿐이며 고객들은 줄을 지어서 순서대로 차례를 기다려야 합니다(큐 자료구조).
그럼 순서대로 티켓을 발급하는데 중간 고객이 단체주문으로 100건의 발급을 요청하면, 그 요청을 처리하기 위해서 뒤의 고객들은 그 요청 시간을 온전히 기다려야 합니다.
1개의 카운터 = 싱글 프로세스 or 싱글 쓰레드 입니다. 때문에 서버의 백로그 큐에는 엄청난 병목현상이 생길 것이기 때문에 추가적인 테크닉이 필요합니다.
위에서 제기한 문제를 해결하기 위해 도입한 테크닉이 바로 멀티 프로세스, 혹은 멀티 쓰레드 입니다.
연결 요청을 받는 부분따로, 응답을 주는 부분을 나누는것 입니다.
이 부분을 이해하기 위해서는 멀티 프로세스 코드를 알아야 합니다....만 이걸 다루기에는 포스팅이 너무 길어지기 때문에 좋은 블로그를 추천하겠습니다.
//socket(),bind() 하는 과정 생략
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) { // 자식 프로세스 <- 이 부분에 집중!
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 시스템콜에 대한 리턴을 클라이언트 소켓 변수에 받습니다. 즉, accept콜의 응답을 받았다면 새로운 소켓을 생성해서 그 뒤의 클라이언트 요청에 대한 이후의 응답을 새로운 소켓으로 나머지 통신을 수행합니다.
간단 설명
fork()
=> 자식 프로세스 생성 함수
return == 0
자식 프로세스 ,return != 0
부모 프로세스
//소켓생성,바인딩,리스닝 생략
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;
}
fork의 리턴값이 0이기 때문에 자식 프로세스가 생성되어 accept()
이후의 작업은 새로운 클라이언트 연결을 처리하는 자식 프로세스에서 이루어집니다. 따라서 부모 프로세스는 다른 클라이언트의 연결을 기다리거나 종료 작업을 수행하게 됩니다.
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;
}
코드를 보시면 fork의 리턴이 0이므로 자식 프로세스가 생성됩니다. 부모 프로세스가 accept()
를 통해 생성한 소켓을 이어받아 나머지 3-way handshake를 수행하고 종료됩니다.
종료되는 부분을 보시면 자식 프로세스는 새로운 연결 요청을 받지 않고 응답만을 수행한뒤 종료됩니다.
멀티 쓰레드에 대해서는 추후에 다루겠습니다. 멀티 쓰레드는 멀티 프로세스의 단점을 보완하기 위한 테크닉입니다.
이렇게 간단한(?) 소켓 프로그래밍에 대해서 알아봤습니다. 최대한 쉽게 풀어 쓰도록 노력했는데 아직 미흡한것 같네요😥
흐름을 기억하시고 이해하시면 코드부분이 어렵지 이론은 어렵지 않을거라고 생각합니다.
클라이언트와 서버의 연결 요청/응답 부분이 병렬적으로 작동된다.
이것만 기억하셔도 나머지는 부딪히면서 배우시면 됩니다.👍