📝 학습 목표
- 서버의 정의와 역할을 이해한다.
- 서버가 어떻게 구축되는지 이해한다.
🤔 서버란 무엇일까?
우리는 아주 흔히 "SERVER" 라는 단어를 쓴다.
그러나 우리는 아주 익숙하게 사용하는 단어에 대해 정의를 물어보면 갑자기 머리가 하얗게 되어버린다..
너무 당연해서 깊게 생각한 적이 없는 것이다.
"서버가 정확히 무엇이고, 어떤 형태로 존재해?"
이러한 물음에 대답할 수 있는가?
그렇다면 서버가 무엇일까???
결론부터 말하자면,
- OS에 의해 동작하는 프로세스 이며,
- 클라이언트의 역할을 하는 프로세스와 소켓을 통해 IPC를 수행하는 것
정도로 정리할 수 있겠다.
✒️ 0. 들어가기 전
이번 포스팅의 목표는 서버와 클라이언트가 각각 하나의 프로세스로써
어떻게 DATA를 주고받는지 이해하는 것!
✒️ 1. IP 주소와 Port 번호
기본적으로 OSI 7계층과 TCP/IP 계층이 어떤 것인지 알고 있다고 가정하고 설명한다.
국제표준화기구(ISO)가 정의한 개념인데,
네트워크 통신을 일곱 개의 계층으로 분할한 것이다.
<사람이 정의한 "개념"일 뿐이고, S/W에서 H/W를 통해 데이터를 전송하는 과정의 아키텍쳐를 정의한 것이다.>
<정의하기에 따라 5계층이 될 수도, 3계층이 될 수도 있다.>
그냥 쉽게 간단히 정리만 해보겠다.
중요한 데이터를 A 어플리케이션에서 보내서 (우리가 흔히 컴퓨터에서 사용하는 서비스)
다른 B 어플리케이션으로 도착할 건데,
이때 보낼 데이터를 하위 단계로 쭉쭉 보내준다.
각 계층간에는 약속! (프로토콜)이 있고,
이를 지키고 통신하기 위해, 여러가지 부가 데이터들이 붙으며 내려간다.
그렇게 전기 신호로 전송할 수 있는 물리 계층 까지 내려간다.
그렇게 받은 덕지덕지 + 데이터를 다시 풀어서 위로 쭉쭉 보내준다.
그럼 B 어플리케이션은 원하는 데이터를 받게 된다.
다시 본론으로 돌아와, IP와 포트 번호란 무엇일까?
우선 지금 계속 "통신"에 관한 이야기를 하고 있다.
그럼 네트워크 상에서 수많은 컴퓨터 중에 특정 컴퓨터와 통신하고 싶을 때는 어떻게 식별할까??
마치 주민등록번호 와 같이 "식별 가능한 수단" 이 있어야 한다.
이때, 사용되는 것이 IP 주소이다.
따라서, IP 주소는 절대적으로 유일해야 하고, 고유해야 한다.
초기에 IP주소를 처음 적용 했을떄 IPv4를 사용했다.
32비트 주소 체계를 사용하여 약 43억 개의 고유 IP 주소를 제공하는 IPv4는 당시에 충분할 것이라고 생각했지만...
대 인터넷 시대가 열리며 그 숫자가 부족하게 되었다..
사설 아이피 주소를 사용하며 이를 공인 아이피 주소로 바꾸는 NAT, 서브네팅(Subnet), 그리고 IPv6 등
여러 기술이 나오게 된다.
조금 더 디테일하게 정의하면 "통신"이란
컴퓨터가 통채로 통신하는 것이 아닌, 컴퓨터에서 동작하는 프로세스가 다른 프로세스와 통신하는 것이다.
프로세스간 통신, 즉 IPC (Inter-Process Communication)
다른 시스템의 프로레스와 IPC한다고 생각하면 편하다!
그럼 우리가 컴퓨터 (집)을 알아냈다면,
둘째 아이들인지 아버지인지, 그 집에 사는 어떤 사람에게 편지가 도착할 건지도 알아야 한다!
그때, 해당 컴퓨터에서 어떤 프로세스에게 데이터를 보내야 할 지 식별할 때 사용되는 값이
포트 번호 이다.
예시를 들어보면,
[서버 프로세스 B가 동작 중인 컴퓨터의 아이피 주소]:[서버 프로세스가 부여받은 포트번호]
[203.230.7.2:80]의 뜻은
203.230.7.2의 아이피 주소를 가진 컴퓨터의 80번 포트의 프로세스 를 말한다.
✒️ 2. 데이터 송수신 과정
"어떤 놈에게 보낼지!" 식별하는 방식을 알게 되었다.
그렇다면, 이제 실제로 데이터를 어떻게 송신하는 지 알아보자.
(엄청 깊게 파고들기보다, 그저 "이런 느낌으로 통신이 되는구나~" 하고 알고 넘어가면 좋다.)
💡데이터 송신 과정
1. Application: 데이터를 송신하는 서버 프로세스가 있다.
2. Sockets: 서버 프로세스가 운영체제의 write 시스템 콜을 통해 소켓에 데이터를 전달한다.
3. 네트워크 스택: TCP/UDP 및 IP 계층을 통해 데이터가 처리되고, 흐름 제어 및 라우팅 등의 작업이 수행된다.
4. NIC: 데이터가 NIC(랜 카드)를 통해 외부로 전송된다.
NIC는 실제 통신할 수 있도록 해주는, 물리적 레이어 단의 하드웨어!
💡데이터 수신 과정
1. NIC: 데이터 수신 시 NIC에서 데이터를 받는다.
2. 인터럽트 및 Driver: NIC에서 인터럽트를 발생시켜 데이터를 네트워크 드라이버로 전달한다.
3. 네트워크 스택: 데이터가 네트워크 스택을 통해 처리되며, 소켓에 데이터가 전달된다.
4. Sockets: 데이터가 소켓에 도달하고, 해당 소켓을 통해 데이터가 수신 대상 프로세스에 전달된다.
✒️ 3. 소켓
💡1. 소켓 (SOCKET) 이란?
참 말이 어렵지만, 정의는 이렇다.
네트워크 통신에서 프로세스 간 데이터 송수신을 가능하게 하는 소프트웨어적인 엔드포인트
소프트웨어적인 용어이긴 하지만, 손에 잡히질 않으니 이해가 잘 가지 않는다...
엄청 많이 들어본 용어인데... 또 정확히 설명하라고 하면 어렵다...ㅠㅠ
"소켓(Socket)"은 사전적으로 "구멍", "연결", "콘센트" 등의 의미를 가진다.
가정에서 흔히 볼 수 있는 콘센트 구멍을 떠올리면 쉽게 이해할 수 있을 것이다.
전기를 필요로하는 부품들이 전기를 공급받을 수 있도록,
전기 공급 인프라 환경에 연결할 수 있게 만들어진 연결부가 콘센트라고 하면,
컴퓨터의 통신을 가능하도록 해주는 인터페이스가 "소켓(Socket)"인 것!!!
다른 비유로는
"전화기"를 생각해보자.
전화기를 사용하면 다른 사람과 대화할 수 있다! 소켓도 마찬가지다!
컴퓨터끼리 네트워크를 통해 대화할 때 사용하는 도구라고 생각하면 편하다.
하나의 컴퓨터가 소켓을 열어두면, 다른 컴퓨터는 그 소켓을 통해 데이터를 보내고 받을 수 있다!! (정도만 익혀두고 들어가자)
그럼, 소켓의 종류는 무엇이 있을까?
TCP를 사용하는 Stream Socket,
UDP를 사용하는 Datagram Socket이 있지만,
우리는 웹 서버에서 사용하는 TCP 소켓에 더 초점을 맞출 것이다.
💡+ TCP vs UDP (모르면 읽어봐요!)
1. TCP
- 연결 지향형 프로토콜!
- 데이터를 주고받기 전에 연결을 설정해야 한다!
- Three-way Handshake!!! ( → ← → )
- 장점 :
- 신뢰성 보장
- 데이터가 손실되거나 손상되는 경우 재전송을 통해 신뢰도 UP!
- 순서 보장
- 흐름 제어 및 혼잡 제어 메커니즘 제공
- 단점 :
- 오버헤드가 크다 ( 추가적인 헤더 및 제어 정보 + 연결 설정 / 해제 )
- 속도가 UDP에 비해 느리다.
- ⇒ 속도보다 신뢰성을 중요시 하는 웹 브라우징, 파일 전송, 이메일 등에 적합!
2. UDP
- 비연결성 프로토콜!
- 데이터를 주고받기 위한 연결 설정 과정이 없다.
- 장점 :
- 낮은 오버헤드 : 헤더 및 제어 정보의 오버헤드 적다.
- 빠른 전송 속도 : TCP에 비해 빠르다!
- 비연결성 : 연결 설정 등의 시간 적다
- 단점 :
- 신뢰성 부족 : 데이터 전송의 신뢰성 보장 X, 손실 가능성은 있다.
- 순서 보장 부족 : 순서 보장 X
- 네트워크 상황에 따라 전송 속도가 제어되지 않는다.
- ⇒ 신뢰성보다 속도를 중요시 하는 실시간 스트리밍, DNS 조회, 온라인 게임 등에 적합!
💡+ What Is System Call(모르면 읽어봐요!)
아래 사진을 보면, 여러 가지 System Call 이 나온다.
만약 "시스템 콜"이 생소하다면 한 번 읽어보자.
쉽게 말하면, OS 커널에 어떤 동작을 요청하는 함수이다!
- 이 함수를 호출함으로써 사용자 프로그램은 OS의 기능을 활용할 수 있게 된다.
- 이러한 이유로 “운영 체제와 사용자 프로그램 간의 인터페이스” 라고도 말한다.
System Call은 프로그래머가 직접 호출할 일은 적다.대표적으로
- 파일을 여/닫는 작업
- 프로세스를 생성하거나 종료하는 작업
- 메모리를 할당하거나 해제하는 작업
등이 시스템 콜을 통해 이루어진다.
- ⇒ 개발자가 운영 체제와 상호 작용하여 시스템 리소스에 접근하고 제어하는 방법 중 하나이다.
✒️ 4. TCP SOCKET PROGRAMMING
출처 : https://recipes4dev.tistory.com/153 참고
💡1. socket() 시스템 콜
socket(domain, type, protocol );
_____________________________________________________________________
domain : IPv4, IPv6중 무엇을 사용할지 결정
type : stream, datagram 소켓 중 선택
protocol : 0, 6, 17 중 0을 넣으면 시스템이 프로토콜을 선택하며, 6이면 tcp, 17이면 udp
int socket_descriptor;
socket_descriptor = socket(AF_INET, SOCK_STREAM, 0);
웹 서버의 프로세스가 데이터를 전송하기 위해 write(), read() 등의 System Call을 사용 할 때,
대상 파일의 fd를 파라미터로 전송하여 OS에게 어떤 파일에 데이터를 작성할지, 혹은 어떤 파일의 데이터를 요청할지 결정한다.
이때, 파라미터 fd가 소켓의 파일 디스크립터인 경우, 소켓에 데이터를 작성(데이터 송신) 혹은 소켓의 데이터를 읽어들이는(데이터 수신) 동작을 하게 되는 것!
socket() 시스템 콜은 소켓을 생성하기 위해 사용된다.
- 소켓을 생성할 때는 소켓의 유형 : IPv4 (AF_INET) / IPv6 (AF_INET6)
- 소켓타입 : 스트림 (SOCK_STREAM) / 데이터그램 (SOCK_DGRAM)
- 프로토콜 : TCP (6) / UDP (17) 을 지정
-> socket()의 리턴 값은 파일 디스크립터 이다.
1. 리눅스 에서의 파일!
- 디렉토리, 특수 장치 파일, 소켓, 파이프 등 모든 것이 파일 형태로 취급된다!
2. 파일 디스크립터
- Process가 파일이나 다른 I/O 장치와 상호 작용할 때 사용되는 정수[fd]이다.
- 리눅스에서 모든 입출력 작업은 fd를 통해 이루어진다.
- fd는 파일 시스템 내의 파일을 식별하고, 파일에 대한 입출력 작업을 수행한다.
- 고로 다음과 같은 특징을 갖는다~
- 고유한 식별자, 양의 정수로 표현 (0부터 순차적으로 할당)
- 표준 I/O 디스크립터 :
- 표준 입력(stdin) : fd 1
- 표준 출력(stdout) : fd 2
- 표준 오류(stderr) : fd 3
💡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;
}
생성한 소켓에 실제 IP 주소와 포트 번호를 할당한다!
bind 시스템 콜은 서버에서만 사용!
💡3. listen() 시스템 콜
TCP에서만 사용되는 System Call, 매우 중요하다!
listen(sockfd, 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;
}
이 listen() 시스템 콜에서 설정하는 backlog가 TCP에서의 backlog queue의 크기이다!
listen() 시스템 콜은 파라미터로 받은 backlog 크기만큼 backlog queue를 만드는 시스템 콜!
그 크기 만큼, 큐를 만들어서 대기 하고 있는 것이다!!!!!!
💡4. accept() 시스템 콜
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;
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");
}
매우 중요한, 시스템콜!
backlog queue에서 syn을 보내와
대기 중인 요청을 FIFO로 하나씩 연결에 대한 수립을 해준다.
서버 소켓에서 클라이언트의 연결 요청을 수락하고,
(3-way handshake)
새로운 소켓을 생성하여 클라이언트와의 통신에 사용한다!
fork() 시스템 콜을 이용하여!
요약 하자면,
"accept 시스템 콜 -> 곧바로 3-way handshake 이후 데이터 송/수신"
하지만, 곧바로 데이터 송수신이 이루어지지 않고,
한 가지 기술이 들어간다!!!
바로, 멀티프로세스 or 멀티쓰레드 기술
본래 Computer Science에서 멀티 프로세스는 ” 하나 이상의 프로세스가 동시에 실행되는 컴퓨터 시스템 “ 이다.
문맥상 “멀티 프로세스 소켓 통신” 이란
여러 프로세스가 동시에 소켓 통신을 수행하는 방식을 말한다!
이는 클라이언트의 연결 요청을 받을 때마다 새로운 프로세스를 생성하고,
이 프로세스가 해당 클라이언트와의 통신을 담당하는 방식으로 이루어진다.
즉 서버는 연결 요청을 받는 부분 따로, 이후 응답까지 주는 부분을 따로 나누게 됩니다.
주로 fork() 시스템 콜을 사용하여 새로운 프로세스를 생성하며,
각 프로세스는 클라이언트와의 통신을 독립적으로 처리할 수 있게 된다.
- 고려해봐야 할 점 :
- 멀티 스레드 소켓 통신과 비교하여
프로세스 간의 Context 전환 비용이 크고, 메모리 사용량이 더 많을 수 있다는 단점
- 고로, 멀티 스레드 방식을 많이 쓰지만... TOO MUCH 내용. . . (구글링!)
#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) { // 자식 프로세스 <- 이 부분에 집중!
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;
}
🤔 왜 새로운 소켓을 만들까? (WHY?)
- 다중 연결 처리 : 서버가 여러 클라이언트와 동시에 통신해야 하는 경우를 위해!
- 원본 소켓 유지 : accept()를 통해 만들어진 새로운 소켓<자식 소켓>은 클라이언트와의 통신을 담당하고, 원본 서버 소켓<부모 소켓>은 계속해서 다른 클라이언트의 연결 요청을 받을 수 있다!
- → 이를 통해 서버는 여러 클라이언트와의 통신을 동시에 병렬적으로 처리할 수 있다!
💎 결론
소켓 통신에서의 병렬 처리는 여러 클라이언트와의 통신을 동시에 처리하는 것을 의미한다.
서버는 클라이언트의 연결 요청을 받으면,
각 클라이언트에 대해 별도의 프로세스나 스레드를 생성하여 통신을 처리한다!
이를 통해 여러 클라이언트와 동시에 통신을 수행하여 전체적인 처리량을 향상시킬 수 있다.
마지막으로
우리에게 익숙한 아파치 서버 (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;
}