오늘은 서버에 대한 정확히 이해하는 시간을 가져보겠습니다!
1. 시스템콜
-정의: OS의 커널이 제공하는 서비스에 대해, 응용 프로그램(ex)사용자 프로그램)의 요청에 대해 커널에 접근하기 위한 인터페이스.
-시스템콜을 사용하는 이유: 유저모드(일반 명령)만으로는 구현하기 힘든 기능을 커널에서 커널모드(특권 명령)로 해결할 수 있기 때문.
-시스템콜의 유형: process control/file manangement/device management/정보 관리/통신/보안
2. 프로세스 & 스레드
-프로세스: 운영체제로부터 자원을 할당받은 자원의 단위
"Process is a program in execution"
-스레드: 프로세스가 할당받은 자원을 이용하는 실행 흐름의 단위
-스레드끼리 공유하는 부분: PC, register, stack space (stack 부분을 공유한다고 보면 될 듯 싶다)
-스레드끼리 공유하지 않는 부분: code, data, OS resources
3. TCP/IP 계층
-NW Interface, Internet, Transport, Application 계층으로 구성
-Internent Layer(L2): 네트워크 계층으로, 컴퓨터 간 라우팅 담당 ex)IP
-Transport Layer(L3): 전송 계층으로, IP에 의해 전달되는 패킷의 오류를 검사하고 재전송 요구 등의 제어 담당 ex) TCP
4. TCP vs. UDP
-공통점:
TCP/IP 전송계층에서 사용되는 프로토콜로, IP프로토콜 기반으로 구현됨. 체크섬(for 데이터 오류 검사) 존재
-TCP: 응답을 받기 때문에 신뢰성이 요구되는 앱에서 사용
-UDP: 간단한 데이터를 빠른 속도로 전송하는 앱에서 사용
서버는 OS에 의해 동작하는 프로세스인 동시에 클라이언트의 역할을 하는 프로세스와 소켓을 통해 IPC를 수행하는 것입니다.
cf)IPC(Inter Process Communication)는 shared memory, message passing로 구성.
이제 프로세스 식별 과정을 알아보겠습니다!
컴퓨터 네트워크 상에서 수많은 컴퓨터들을 식별하기 위한 수단
-> IP주소
IP주소는 식별을 목적으로 사용하기 때문에 유일해야 함! 이를 위해 NAT, 서브넷팅, IPv6 등이 사용됨.
컴퓨터가 직접 네트워크에서 통신하는 게 아니라 컴퓨터 내 프로세스가 다른 컴퓨터의 프로세스와 통신하는 것
=>각자의 컴퓨터의 프로세스에서 IPC수행
위에서 설명한 IP주소를 통해 컴퓨터를 식별한 이후 해당 컴퓨터의 어떤 프로세스로 데이터를 보낼지 결정할 때 사용하는 수단
->port 번호
이러한 IP주소와 port번호를 합쳐서 클라이언트가 서버에게 데이터를 보내는 형태는
[서버 프로세스가 동작 중인 컴퓨터의 아이피 주소]:[서버 프로세스가 부여받은 포트번호]
ex)[203.201.7.3:80]는 203.201.7.3의 ip주소를 가진 컴퓨터의 80번 포트의 프로세스 의미.

위의 계층 그림에서
1->2->3->4 순서로 송신이 이루어집니다. 그림에서 왼쪽은 계층, 오른쪽은 송신 시 보내는 패킷(header+payload)을 나타냅니다.
4->3->2->1 순서로 진행됨.
4: NIC에서 데이터 수신하고 인터럽트를 통해 driver로 데이터 옮김
3,2: NW 스택에서 데이터 이동하며 소켓에 데이터가 담김
1: 프로세스에 데이터 도착!
소켓은 종류에 따라 TCP와 UDP에서 사용하는 걸로 나뉩니다.
연결지향(connected-oriented)
비연결지향

TCP 소켓의 시스템콜을 다뤄보겠습니다.
1. socket() 시스템 콜
통신을 위한 틀을 만드는 과정으로, 아래와 같은 형태이다.
socket(domain, type, protocol);
domain:IPv4 vs. IPv6 중 뭘 사용할지 결정
type: stream vs. datagram
protocol: 0/6/7 //0: 프로토콜 선택, 6: TCP, 17: UDP
리턴값: 파일 디스크립터
int socket_descriptor;
socket_descriptor=socket(AF_INET, SOCK_STREAM, 0);
리눅스에서는 모든 것을 파일로 취급하기에 소켓 역시 파일로 취급한다.
따라서 서버 프로세스가 write(), read() 시스템 콜을 할 때 파일 디스크리터를 파라미터로 사용해야한다.
2. bind() 시스템 콜
생성한 소켓에 실제 아이피 주소와 포트번호를 부여하는 시스템 콜로, 서버에서만 사용한다. (클라이언트는 통신 시 포트번호가 자동으로 부여되기 때문.)
bind(sockfd, sockaddr, socklen_t);
sockfd: 바인딩을 할 소켓의 파일 디스크립터
sockaddr: 소켓에 바인딩 할 아이피 주소, 포트번호를 담은 구조체
socklen_t: 위 구조체의 메모리 크기
#include <sys/socket.h>
#include <netinet/in.h>
int main(){
int sockfd=socket(AF_INET, SOCK_STREAM, 0);
if(sockfd==-1){
perror("Socket creation failed");
return 1;
}
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;
}
3. listen() 시스템 콜
TCP 에서만 사용되기 때문에 무척이나 중요하다!
파일 디스크립터에 해당하는 소켓이 클라이언트의 요청을 받아들이도록 하며, 최대로 받아주는 크기를 backlog로 설정한다.
*backlog == TCP에서의 backlog queue의 크기
->listen() 시스템 콜은 파라미터로 받은 backlog 크기만큼 backlog queue를 만드는 시스템 콜.
listen(sofkfd, backlog);
sockfd: 소켓의 파일 디스크립터
backlog: 연결요청을 받아줄 크기=TCP 백로그 큐의 크기
#include <sys/socket.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0); //소켓 생성.
if (sockfd == -1) {
perror("Socket creation failed");
return 1;
}
// ... 서버 소켓의 주소와 바인딩 설정 ...
int backlog = 10; // 최대 대기열 크기
if (listen(sockfd, backlog) == -1) {
perror("Listen failed");
return 1;
}
// 리스닝 성공 처리 및 연결 요청 처리
return 0;
}'''

클라이언트가 클라이언트 소켓을 통해 처음으로 서버에 요청해 backlog queue에 들어갈 때 syn 요청을 보냄.
4.accept() 시스템 콜
accept() 시스템 콜은 backlog queue에서 syn을 보내서 대기 중인 요청을 FIFO 방식으로 하나씩 연결에 대해 수립시킨다.
이제 코드가 슬슬 길어질텐데 중요한 부분이니 꼬옥 집중해서 봐주세요!
int accept(sockfd, sockaddr, socklen_t);
sockfd: 백로그 큐의 요청을 받아들이기 위한 소켓의 파일 디스크립터
sockaddr: 선입선출로 빼온 연결 요청에서 알아낸 클라이언트의 주소 정보.
socklen_t: 위 ㅣ구조체의 메모리 크기.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
//서버 프로세스
struct sockaddr_in server_address;
server_address.sin_family = AF_INET; //소켓의 도메인(IPv4 or IPv6)
server_address.sin_addr.s_addr = INADDR_ANY; //소켓의 IP주소
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");
}
4.1 TCP 3-way handshake
신뢰성을 위해 클라이언트와 서버가 서로 준비되었다는 걸 확인하는 과정!

4.2 accept() 시스템 콜 이후 멀티 프로세스/멀티 스레드
서버는 병목 현상을 방지하기 위해 연결요청과 응답요청을 분리!
아래는 전체적인 코드이며, 이후 위의 fork()문 이후 부모 프로세스 코드와 자식 프로세스 코드가 나옵니다.
#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) { //while문을 돌며 백로그 큐에 있는 클라이언트 프로세스들 처리.
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;
}
<부모 프로세스>
#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;
}
fork()를 통해 생성된 자식 프로세스의 코드입니다.
자식 프로세스는 부모 프로세스가 가진 데이터들을 똑같지만 새로운 프로세스입니다. 그래서 부모 프로세스의 기억을 가지고 자신의 프로세스를 진행할 수 있다는 장점이 있습니다.
<자식 프로세스>
#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;
}
마지막으로 위에서 설명한 시스템 콜들을 합친 아파치(HTTP 웹 서버)의 전체 코드입니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
const char* server_ip = "127.0.0.1";
int server_port = 8080;
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
perror("Socket creation failed");
return 1;
}
struct sockaddr_in server_addr, client_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
server_addr.sin_addr.s_addr = inet_addr(server_ip);
if (bind(server_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("Binding failed");
return 1;
}
if (listen(server_socket, 5) == -1) {
perror("Listening failed");
return 1;
}
printf("Server listening on %s:%d\n", server_ip, server_port);
//클라이언트 프로세스
while (1) {
socklen_t client_addr_len = sizeof(client_addr);
int client_socket = accept(server_socket, (struct sockaddr*)&client_addr, &client_addr_len);
if (client_socket == -1) {
perror("Accepting client failed");
continue;
}
printf("Accepted connection from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
char request[1024];
//receive
recv(client_socket, request, sizeof(request), 0);
printf("Received request:\n%s\n", request);
//send
char response[] = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nHello, World!";
send(client_socket, response, sizeof(response), 0);
close(client_socket);
}
close(server_socket);
return 0;
}