참고
https://oliveyoung.tech/2023-10-02/c10-problem/ - 올리브영 기술 문서 - C10K 문제
https://www.youtube.com/watch?v=mb-QHxVfmcs - 쉬운코드 youtube
https://blog.naver.com/n_cloudplatform/222189669084 - 네이버 클라우드 기술 블로그 I/O Multiplexing
시작하며
서버는 클라이언트가 요청을 하면 이를 감지하여 요청을 이해하고, 이에 적절한 응답을 만들어 반환한다.
근데 이러한 요청은 OS에서 어떻게 처리 되는걸까? 그동안 API 서버는 많이도 만들어 봤지만, 여기에 대해서는 확신이 없었다.
Network에서 배운 소켓 통신, TCP/IP, HTTP 프로토콜 등등... 뭔가 많이 배웠는데 정확하게 클라이언트가 요청을 보내면 서버 PC의 OS가 어떻게 이 요청을 받아들이고 웹서버 한테 전달하는지 완벽하게 완성해서 대답 할 수 있는가?
일단 나는 못했다. 그래서 CS공부를 다시 진행하며 내 나름대로 정리한 내용을 가볍게 써보고자 한다.
다만, 학부 전공 교재에서 다루는 만큼 상세히 다루지는 않으니 디테일한 내용은 알아서 전공 교재나 어려운 책 보고 공부하길 바란다.
소켓
유저 모드 / 커널 모드
우리가 사용하는 OS에는 Kernel이라는 녀석이 존재한다.
이녀석은 잘 알다싶이, 운영체제가 해야하는 잡다한 일을 모두 도맡아 해주는 잡부다.
이에 따라 OS내에서 실행되는 작업은 유저 영역에서 실행하는 작업과, 커널 영역에서 실행 되는 영역으로 나뉜다.
User Mode : 커널에 의해 제한된 환경. 한정적인 범위의 자원에만 접근이 가능하고, 그 이상의 작업은 시스템 콜(추상화 된 인터페이스)를 통해서 수행 해야 함.
Kernel Mode : OS의 핵심 기능이 동작하는 모드. 하드웨어 및 시스템 자원에 대한 전면적인 접근 제한을 가짐.
Kernel은 User가 마음대로 메모리 공간이나, HW를 헤집고 다니지 못하게 철저히 제한하여 안전하게 OS를 실행 시킨다.
이렇게 프로세스는 혼자 할 수 있는게 별로 없다. 대표적으로 뭔가 Disk에서 읽어오고 싶다면(Disk I/O) 조금 디테일한 동작 원리는 아래와 같다.
Process는 하드웨어에 직접 접근할 권한이 없기에, 디스크에서 어떠한 데이터를 읽어오고 싶으면 이러한 동작들이 추상화 된 System Call 을 호출하여 커널에게 이러한 동작을 해달라고 요청한다.
그럼 커널은 요청받은 System Call(여기서는 open() 혹은 read())을 확인하고 그에 해당하는 동작을 수행 후 파일을 조회한 결과를 Process한테 반환해 줄 것이다.
File I/O는 대표적인 Kernel Mode 영역의 작업이고, 다양한 작업들을 Kernel이 수행한다. (프로세스 & 스레드의 생성, 가상 메모리의 관리, 인터럽트 처리, 다양한 하드웨어의 제어 등등....)
그럼 소켓은 뭐임?
Socket이란, 통신을 수행 할 때 필요한 동작들을 OS가 추상화하여 제공하는 인터페이스이다.
위에서 File I/O를 하기 위해서 read() 라는 System Call을 호출하지 않았는가? Socket도 동일한 시스템 콜 중 하나일 뿐이다. OS가 내부적으로 무슨 짓을 하는지에 대해서 프로세스는 관심이 없다.
프로세스는 통신을 하기 위해 필요한 시스템 콜들을 호출 할 뿐이고, 커널은 이러한 시스템 콜 중 하나로 소통 창구를 제공하는데, 이것이 바로 소켓이다.
소켓 시스템 콜 종류들
서버 사이드에서 통신을 수행하기 위해 소켓을 열고 대기하는 과정은 다음과 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 12345 /* 서버가 바인드할 포트 */
#define BACKLOG 10 /* 대기 큐 크기 */
/* 소켓이 수행할 작업을 포함하는 함수 (본 예제에서는 작성 X) */
void work(int client_fd);
int main(void) {
int sd, new_fd;
struct sockaddr_in addr, client;
socklen_t client_len;
int opt = 1;
/* 1. 소켓 생성 */
sd = socket(AF_INET, SOCK_STREAM, 0);
if (sd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
/* SO_REUSEADDR 옵션 설정 (재시작 시 바인드 에러 방지) */
if (setsockopt(sd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
close(sd);
exit(EXIT_FAILURE);
}
/* 2. 주소 설정 및 바인드 */
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 모든 인터페이스에서 수신 */
addr.sin_port = htons(PORT);
if (bind(sd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(sd);
exit(EXIT_FAILURE);
}
/* 3. 리슨(수신 대기) */
if (listen(sd, BACKLOG) < 0) {
perror("listen");
close(sd);
exit(EXIT_FAILURE);
}
/* SIGCHLD 무시하여 좀비 프로세스 방지 */
signal(SIGCHLD, SIG_IGN);
printf("서버가 포트 %d 에서 연결 대기 중...\n", PORT);
/* 4. 메인 루프: accept → fork → work */
for (;;) {
client_len = sizeof(client);
new_fd = accept(sd, (struct sockaddr *)&client, &client_len);
if (new_fd < 0) {
perror("accept");
continue;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(new_fd);
continue;
}
if (pid == 0) {
/* 자식 프로세스 */
close(sd); // 자식은 듣기 소켓 닫기
work(new_fd); // 연결 처리
close(new_fd);
exit(EXIT_SUCCESS);
} else {
/* 부모 프로세스 */
close(new_fd); // 부모는 클라이언트 소켓 닫기
}
}
/* (도달하지 않음) */
close(sd);
return 0;
}
- socket() 시스템 콜을 통해 소켓을 생성한다.
- bind() 시스템 콜을 통해 생성된 소켓에 사용할 포트번호 (여기서는 12345)를 매핑한다. 클라이언트는 본 서버로 접속 할 때 12345번 포트를 사용 해야 한다.
- listen() 시스템 콜을 통해 클라이언트들의 요청을 수신할 준비를 한다. (그 후 내부에서 accept() 시스템 콜의 반환값이 올 때 까지 기다린다.)
- accept() 시스템 콜을 통해 요청이 들어온 클라이언트의 소켓(파일 디스크립터)을 반환한다.
- 새롭게 생성된 소켓에 대한 처리를 자식 프로세스를 생성해 처리하도록 한다.
음... 근데 요청이 들어올 때 마다 프로세스를 찍어내면 요청이 1만개 들어오면 그땐 프로세스 혹은 스레드를 1만개 만들어야 하나...?
Everything is a File
UNIX 계열 OS에서 사용되는 인페이스 디자인 원칙으로 "모든 것은 파일이다." 라는 말이 있다.
위에서 어떤 요청이 들어오면 accept() 시스템 콜이 실행되고, 해당 시스템 콜은 소켓 (파일 디스크립터) 를 반환한다고 했는데, 이 말은 즉슨 "소켓 역시도 일종의 파일" 이라는 것이다.
📌 파일 디스크립터 (File Descriptor)란?
프로세스가 커널에 열려 있는 자원(파일)을 가리킬 때 사용하는 정수 인덱스
int fd_file = open("foo.txt", O_RDONLY);
int fd_socket = socket(AF_INET, SOCK_STREAM, 0);
read(fd_file, buf, len); // 디스크 파일 읽기
read(fd_socket, buf, len); // 네트워크에서 들어오는 데이터 읽기
와 같이, 특정 자원을 가르키기 위한 식별자를 커널이 제공하고, 우리는 그 식별자를 사용해 시스템콜을 호출하면 그 자원에 접근할 수 있다.
그리하여 소켓을 통해 어떠한 데이터를 전송하는 send 시스템 콜은 실은 해당 소켓 파일에 write 하는 것과 동일하고
마찬가지로 소켓에 들어온 데이터를 읽어오는 recv 시스템 콜은 해당 소켓 파일에 read 하는 것과 동일하다.
결국 소켓은 하나의 파일이기 때문에, 서버는 요청이 많이 들어온다 하더라도 매번 스레드나 프로세스를 새롭게 찍어낼 필요 없이 반복문으로 본인이 가지고 있는 소켓(파일)들을 돌아가면서 한번씩 확인하면 되는 것이다.
Socket을 1번~5번 까지 돌면서 매번 "새롭게 들어온 데이터 있니?" 라고 read() 하는 동작을 무한루프 돌려주기만 한다면 프로세스나 스레드의 낭비 없이 여러개의 소켓을 핸들링 할 수 있을 것이다.
이렇게 하나의 프로세스가 여러 파일(I/O)을 관리한다 라고 해서 이것을 I/O 다중화 즉, I/O Multiplexing 이라고 한다.
I/O Multiplexing
근데 위 방법에서도 문제가 있다. 바로 아무런 소통도 안한 소켓까지 다 반복문 돌면서 까 봐야 한다는 것.
예를들어 소켓이 1,000개 정도 있는데, 여기서 실제로 새로운 요청이 발생한 소켓은 10개 미만이지만 위 방법은 1,000개의 소켓(파일)들을 매번 헤집고 다녀야 한다.
뭔가 내가 매번 소켓을 확인하는게 아니라, 특정 이벤트(새로운 데이터)가 발생한 소켓만 딱 알려주면, 그것만 깔끔하게 까보면 안될까?
결론부터 이야기하자면 된다. (우리가 흔히 사용하는 Node.js 에서도 이러한 이벤트 기반의 시스템콜(epoll)을 활용해 비동기 / 논블로킹 형태로 네트워크 I/O를 효율적으로 처리한다.)
- select, poll : 위에서 언급한 가장 단순한 방식 (여러개의 소켓들을 지정 해두고, 이 소켓들 전체를 순회하며 어떤 소켓들에 새로운 데이터가 있는지 확인하는 방법으로 동작함.)
- epoll, kqueue, IOCP : 각각 리눅스, Mac, Windows OS들 에서 사용하는 매커니즘들로, 소켓을 매번 확인 할 필요 없이 FD의 상태 자체를 kernel에서 관리해 상태가 바뀐것을 사용자에게 통지 해주는 방식이다.
리눅스 환경에서는 epoll 과 같은 매커니즘을 활용하면, 메인 스레드는 매번 모든 소켓들을 모두 read 할 필요 없이 epoll 방식에 의해 커널에서 이벤트를 발생시켜 주면 그 소켓들에 대한 처리만 해주면 된다.