서버란?
서버는 OS에 의해 동작하는 프로세스이며 클라이언트의 역할을 하는 프로세스와 소켓을 통해 IPC(Inter-Process Coummunication)를 수행하는 것이라고 표현할 수 있다.
학습목표
1. 서버의 정의와 역할 이해하기
2. 서버가 어떻게 구축되는지 이해하기 이것을 기억하는 것이 목표 🥸
이제까지 개발을 위해서 프레임워크만 공부했다면, 이제 본격적으로 원리를 깊이 이해할 차례이다. 오늘은 데이터의 송수신 과정에 대해 깊이 알아보자.
서버와 클라이언트가 각각 하나의 프로세스로써 어떻게 데이터를 주고받는지 이해하기 위해서는 IP주소와 포트번호를 알아야 한다.
IP주소와 포트번호
(OSI 7계층과 TCP/IP계층을 대략 알아야 한다. 모른다면 이것부터 알아두기)
IP주소는 컴퓨터가 네트워크 상에서 통신을 하기 위해서 유일하게 식별이 가능한 수단이다.
따라서 아이피 주소는 절대로 겹치면 안 되고, 고유해야 한다.
하지만 세상에 컴퓨터가 매우 많아지며 아이피 주소의 부족 현상이 자연스레 발생하게 되었고 이를 해결하기 위해 사설 IP주고응 사용하여 이를 공인 IP 주소로 바꾸는 NAT, 서브네팅, IPv6등 여러 기술이 나오게 된다. ( 이내용은 다음 챕터에서 )
컴퓨터는 직접 네트워크에서 통신을 하는 것이 아니고, 컴퓨터에서 동작하는 프로세스가 또 다른 컴퓨터의 프로세스와 통신을 하는 것이다. (프로세스 간의 통신이라구요!!)
IP주소를 통해 컴퓨터를 식별했다면, 해당 컴퓨터에서 어떤 프로세스에게 데이터를 보내야 하는지 알아야 하는데 이때 사용되는 식별 값이 포트 번호이다. 포트번호는 컴퓨터 네트워크에서 장치 간 서로를 인식하고 통신을 하기 위한 특수한 번호이다. ex) HTTP: 80
즉, [203.230.7.2:80]의 뜻은 [203.230.7.2의 아이피 주소를 가진 컴퓨터의 80번 포트의 프로세스]를 말한다
데이터 송신 과정
앞에서 프로세스를 식별하는 것을 알았다. 이제 데이터를 어떻게 송신하는지 보자.
데이터 송신의 과정
1. Appication (데이터를 송신하려는 서버 프로세스)
2. Sockets
3. 네트워크 스택
4. NIC
데이터를 송신할 때
1. 서버 프로세스(Application)가 운영체제의 write 시스템 콜을 통해 소켓에 데이터를 보낸다.
2. TCP/UDP 계층과 IP 계층 그리고 대표적으로 Ethernet을 거쳐 흐름제어, 라우팅 등의 작업을 하게 된다.
3. 이후 마지막으로 NIC(랜 카드)를 통해 외부로 데이터를 보낸다
데이터를 수신할 때
1. NIC에서 데이터를 수신한다.
2. 인터럽트를 통해 Driver로 데이터를 옮기고 네트워크 스택에서 데이터가 이동하여 소켓에 데이터가 담긴다
3. 최종적으로 수신 대상이 되는 프로세스에 데이터가 도달한다.
그렇다면 계속 소켓!! 이 나오는데 소켓이란 뭔지 알아보자
소켓: OSI 7 계층에서 응용 프로그램과 전송 계층 사이의 인터페이스
두 가지 종류가 있으며 주로 사용되는 TCP 소켓에 초점을 맞출 것이다.
- TCP 소켓(Stream): 신뢰성 있는 데이터를 송수신
- UDP 소켓(datagram): TCP와 달리 비연결지향
클라이언트 소켓
1) create: 맨 처음에 소켓을 생성
2) connect: 서버 소켓에 연결을 요청
3) send / receive: 서버가 연결 요청을 받으면, 데이터를 송수신
4) close: 모든 데이터 처리가 완료되면 소켓을 닫는다
서버소켓
1) create: 맨 처음 소켓을 생성
2) bind: 소켓에 서버가 사용할 IP주소/Port 번호를 결합
3) Listen: 클라이언트에서 연결 요청이 오는지 계속 듣고 있는다
4) Accept: 클라이언트에서 연결 요청이 오면 이를 받아들인다
5) send / receive: 클라이언트에 데이터를 송수신
6) close: 모든 데이터 처리가 완료되면 소켓을 닫는다
그럼 각자의 소켓의 시스템 콜을 자세히 살펴보도록 하자
[참고] 시스템 콜이란?
사용자 애플리케이션이 운영체제의 커널 기능을 사용할 수 있도록 하는 인터페이스이다.
프로그램이 하드웨어 자원에 직접 접근(커널모드)하는 것은 보안과 안정성 문제를 일으킬 수 있기 때문에
운영체제는 이러한 자원에 대한 접근을 관리하고 사용자 프로그램과 하드웨어 사이의 중재자 역할을 한다.
시스템 콜은 이런 접근을 가능하게 해주는 메커니즘 중 하나이다. (사용자 모드 -> 커널 모드 전환)
주요 기능
1. 프로세스 관리: fork() 시스템 콜은 새 프로세스를 생성하고, exit() 시스템 콜은 프로세스를 종료한다.
2. 파일 조작: 파일 생성, 삭제, 읽기, 쓰기 등
3. 장치 관리: 하드웨어 장치에 대한 접근 및 제어를 관리
4. 정보 유지: getid(), getuid() 시스템 콜 등 프로세스 id, 사용자 id 시스템 정보의 조회 및 변경을 위해 사용된다.
5. 통신: 프로세스 간 통신을 위한 다양한 IPC 기법을 사용하는 시스템 콜
Socket() 시스템 콜
소켓을 만드는 시스템 콜, 미리 형태를 잡아두는 것이라고 이해하자
미리 ipv4, ipv6을 사용할지 TCP, UDP를 사용할지 틀을 만들어두는 것이라고 생각하면 된다.
socket(domain, type, protocal);
domain : IPv4, IPv6중 무엇을 사용할지 결정
type : stream, datagram 소켓 중 선택
protocol : 0, 6, 17 중 0을 넣으면 시스템이 프로토콜을 선택하며, 6이면 tcp, 17이면 udp
socket()의 리턴 값은 파일 디스크립터이다.
파일 디스크립터는 운영 체제가 파일이나 다른 입출력 자원에 대한 참조를 관리하는 데 사용하는 정수 값이다.
소켓도 입출력 자원의 한 종류로 간주되기 때문에, 운영체제는 소켓을 다룰 때도 파일 디스크립터를 사용한다.
bind() 시스템 콜
생성한 소켓에 실제 아이피 주소와 포트번호를 부여하는 시스템 콜이다.
OS에게 어떤 소켓에 아이피 주소와 포트번호를 부여할지 알려주기 위해 파라미터에 소켓의 파일 디스크립터를 포함한다.
bind(sockfd, sockaddr, socklen_t
sockfd: 바인딩을 할 소켓의 파일 디스크립터
sockaddr: 소켓에 바인딩 할 아이피 주소, 포트번호를 담은 구조체
socklen_t: 위 구조체의 메모리 크기
서버에만 bind() 시스템 콜이 있는 이유는 클라이언트는 통신 시 포트 번호가 자동으로 부여되기 때문이다. 서버는 특정 포트에서 클라이언트의 연결 요청을 기다리고 있어야 한다. 클라이언트는 보통 요청을 하기 위해 서버에 임시적으로 연결하며 임시 포트 번호를 사용함으로써 다수의 연결을 유연하게 관리할 수 있다.
listen() 시스템 콜 (only for TCP)
파라미터로 받은 파일 디스크립터에 해당하는 소켓을 클라이언트의 연결요청을 받아들이도록 하며 최대로 받아주는 크기를 backlog로 설정한다. listen() 시스템 콜에서 설정하는 backlog가 TCP에서의 backlog queue의 크기이다.
listen(sockfd, backlog)
sockfd : 소켓의 파일 디스크립터
backlog : 연결요청을 받아줄 크기 = TCP의 백로그 큐의 크기
accept() 시스템 콜
backlog queue에서 syn을 보내와 대기 중인 요청을 선입선출(큐)로 하나씩 연결에 대한 수립을 해준다. 파라미터를 보면 클라이언트의 아이피 주소, 포트번호를 받는데 백로그 큐에서 가장 앞에 있는 연결요청 구조체에서 알아내서 가져온다.
int accept(sockfd, sockaddr , socklen_t);
sockfd : 백로그 큐의 요청을 받아들이기 위한 소켓의 파일 디스크립터
sockaddr: 선입선출로 빼온 연결 요청에서 알아낸 클라이언트의 주소 정보
socklen_t : 위 구조체의 메모리 크기
accept() 시스템 콜에서 아래 과정을 수행하게 된다. 이 과정이 핵심!
⭐️⭐️ TCP 3-way handshake
클라이언트와 서버 간에 서로 신뢰성 있는 통신을 위해 서로 준비가 되었다는 것을 확인하는 과정이다.
위의 3 과정 중 client가 보내는 SYN이 listen 상태인 서버의 소켓에 연결 요청을 보내는 것, 이후 2 과정은 accept 시스템 콜 이후 진행하여 최종적으로 established 상태를 수립하고 본격적인 데이터의 송/수신이 이루어진다.
3-way handshake 이후 데이터의 송수신이 바로 이루어지는 것이 아니다.
서버의 성능을 위해 또 하나의 테크닉이 들어가게 된다.
각각의 시스템 콜을 알아봤으니 이제 accept() 시스템 콜 이후 멀티 프로세스를 알아보자
멀티 프로세스 / 멀티 스레드
프로세스: "실행 중인 프로그램"
프로그램이 주기억장치에 적재되어 처리를 시작할 때, 비로소 프로세스라는 활동적인 개체가 되는 것이다.
데이터 송 수신에서의 멀티 프로세스란: 연결 요청을 받는 부분 따로, 이후 응답까지 주는 부분을 따로 나누는 것
하나의 프로세스인 서버가 클라이언트의 수많은 요청을 받는 상황에서, 백로그 큐의 가장 앞에 있던 클라이언트의 요청을 받고 응답까지 다 주고 다시 다음 요청을 받아준다면 엄청난 병목이 생길 것이다. 그래서 받는 부분, 주는 부분을 따로 나누는 것이다.
accept 시스템 콜의 응답을 받았다
= SYN 요청을 보낸 클라이언트가 적어도 하나 있어서 백로그 큐에 있었고 해당 클라이언트의 요청에 대한
이후 응답을 위해 새로운 소켓을 만들었다
코드로 확인해 볼게요
#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;
}
참 복잡하다. 멀티 프로세스를 알기 위해서 fork() 시스템 콜을 자세히 알아야 한다.
fork() 시스템 콜: 자식 프로세스 생성
fork()는 os에게 자신과 똑같은 코드를 담은 프로세스를 복제 생성 하기를 요청하는 시스템콜 함수이다.
fork()는 단순히 자신과 똑같은 코드를 복제할 뿐 아니라. 현재의 프로그램카운터의 값도 그대로 복제를 한다.
- return 값 0 : 자식 프로세스
- return 값 0 아닌 것: 부모 프로세스
위의 코드를 보면 사실상 부모 프로세는 연결 요청을 받아주고 자식 프로세스에게 나머지 일을 맡기고 다시 새로운 연결요청을 받아주는 형태인 것이다.
자식 프로세스는 반면, 부모 프로세스가 새로 만들어준 소켓을 이어받아 이후 남은 잔여 3-way handshake를 수행 후 데이터 통신을 수행하는 것을 확인할 수 있다. 곧, 자식 프로세는 새로운 연결요청을 받지 않고 그저 응답을 준 후 exit(0)를 통해 종료된다. 이후 recv(), send()는 말 그래도 데이터를 수신, 송신할 때 사용되는 시스템 콜로 데이터의 수신과 송신이 끝이 난다.
지금까지의 내용을 요약하자면 아래와 같다.
데이터의 통신은 프로세스끼리 이루어지는 것,
소켓을 통해 데이터를 전달하는데 서버 소켓의 이동은
socket() -> bind() -> listen() -> accept() -> send()/recv() -> close()라는 것,
accept()에서 병목 현상이 발생하지 않도록 fork() 시스템 콜을 이용한다는 것,
🚩🚩 서버는 연결을 받는 부분과 응답을 주는 부분이 병렬적으로 이루어져 있다는 것
그리고 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];
recv(client_socket, request, sizeof(request), 0);
printf("Received request:\n%s\n", request);
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;
}
📢 학습 후기
[스터디 1주 차의 주제:서버란 무엇인가] 개발을 하면서 깊이 생각해 본 적은 없었다.
그냥 값만 잘 주면 되지! 이런 생각만 급급했는데 서버에 대해 구축하는 법부터 다시 알아보는 계기가 되었다.
심지어 운영체제, 네트워크, 시스템소프트웨어 모두 강의를 이수했는데 잊고 있었던 내용들이었다.
개발은 코드를 깊이 이해하고 치는 것이다. 내용을 알고 치는 것과 모르고 치는 것은 분명 차이가 있을 것이다.
공부하자 기록하자^_^
'IT 동아리 > UMC' 카테고리의 다른 글
[Chapter 7] JPA를 통한 엔티티 설계, 매핑 & 프로젝트 파일 구조 이해 (0) | 2024.05.24 |
---|---|
[Chapter 6] API URL의 설계, REST API, 프로젝트 세팅 (0) | 2024.05.17 |
[Chapter 5] 실전 SQL - Query를 작성하는 방법 (1) | 2024.05.10 |
[Chapter 3] Web Server & Web Application Server(WAS), Reverse Proxy (1) | 2024.05.02 |
[Chapter 2] AWS (VPC & Internet & Gateway & EC2) (2) | 2024.04.10 |