[UMC Server] Chapter 01. 서버란 무엇인가(소켓&멀티 프로세스)

hh·2024년 4월 3일

UMC Server

목록 보기
1/6
post-thumbnail

01. 서버란 무엇인가

💡 학습 목표
 1️⃣ 서버의 정의와 역할 이해
 2️⃣ 서버의 구축 과정 이해

백엔드 개발자라면 서버 의 개념을 정확히 아는 것이 중요하다!

이번 스터디 이후에 '서버가 정확히 무엇인가?'라는 질문에 명확한 답을 할 수 있었으면 한다.

우선 서버는 OS에 의해 동작하는 프로세스로, Client 역할을 수행하는 프로세스와 소켓을 통해 Inter Process Communication (IPC)를 수행한다.

이때 IPC는 프로세스 간 통신 방법으로 메시지를 전달하는 방법인 message passing 과 주소 공간을 공유하는 방법인 shared memory 등이 있다.

IP 주소와 포트번호

IP 주소

IP 주소는 컴퓨터가 네트워크 상에서 통신하기 위해 존재하는 유일한 식별 수단이다.
그러나 32-bit IP 주소인 IPv4는 42억개(2^32)의 주소를 만들어낼 수 있음에도 이는 2011년에 고갈이 되었다.

이를 해결하기 위해 private IP 주소를 public IP 주소로 바꾸는 NAT, IPv6 등 여러 기술이 등장하게 된다.

포트번호

네트워크 통신에 컴퓨터가 직접 참여한다고 생각할 수 있지만, 사실 컴퓨터에서 동작하는 프로세스가 또 다른 컴퓨터에서 동작 중인 프로세스와 통신한다.

즉, 다른 시스템의 프로세스와 IPC를 하는 것이다.

그렇다면 프로세스를 어떻게 식별할 수 있을까?

IP주소를 통해 컴퓨터를 식별했다면, 포트번호 를 이용해서 어떤 프로세스에게 데이터를 보내야 하는지 식별할 수 있다.

Client A가 Server B로 데이터를 보낼 때 아래와 같이 대상을 식별할 수 있다.

[서버 프로세스 B가 동작 중인 컴퓨터의 IP 주소] : [서버 프로세스 B가 할당받은 포트번호]

예를 들어, 110.110.23.2:80 는 110.110.23.2 라는 IP 주소를 가진 컴퓨터의 HTTP 프로세스를 의미한다.
(* HTTP 프로토콜의 포트 번호는 80!)

데이터 송신 과정

데이터가 어떻게 송신되고, 수신되는지 과정을 이해하기 위해 위의 계층 구조를 참고하자!

간단하게 각 레이어를 L1 - L5로 표현하려 한다. 🙃

데이터 송신 과정

데이터 송신 과정에서 데이터는 L5 에서 L1으로 이동한다.

L5에서 서버 프로세스가 OS의 write() 시스템콜을 통해 L4에 존재하는 소켓에 데이터를 보낸다. 그 이후 L4-L3 레이어에서 흐름제어 및 라우팅 작업을 하고, NIC을 통해 외부로 데이터를 보내게 된다.

그리고 이 과정을 Multiplexing 이라고 한다.

데이터 수신 과정

데이터 수신 과정은 송신 과정의 반대라고 이해하면 쉽다.

NIC에서 데이터를 수신한 후, 인터럽트를 발생시켜 Driver로 데이터를 옮긴다.

그 이후 L3에서 L4로 데이터가 이동하며 소켓에 데이터를 담아 L5 계층에 전달한다.

그리고 수신 과정은 Demultiplexing 이라고 한다.

소켓

이번에는 Transport Layer에 존재하는 소켓에 대해 알아보려 한다.

소켓은 Application Layer와 Transport Layer 간 데이터를 주고받기 위한 인터페이스(API) 역할을 한다.

또한, TCP 소켓과 UDP 소켓이 존재한다.

TCP 소켓

stream 소켓 이라고도 하며, 신뢰성 있는 데이터 송수신을 보장한다.

UDP 소켓

datagram 소켓 이라고도 하며, TCP와 달리 비연결지향이기 때문에 신뢰성을 보장하지 않는다.

비연결지향은 call set-up을 하지 않아 delay가 없어 데이터 전송 속도가 빠르다는 장점이 있다.

신뢰성 때문에 TCP가 더 좋아보일 수는 있지만, rate sensitive & throughput sensitive한 실시간 스트리밍 서비스에서는 UDP가 더 효과적이다! 👍🏻

4가지 시스템콜

TCP 소켓을 사용하는 흐름을 살펴보기 위해 아래 이미지를 참고하면 좋다.

1️⃣   socket()

socket() 시스템 콜은 이름 그대로 소켓을 만드는 시스템콜을 의미한다.

domain, type 등을 미리 결정해 틀을 만들어둔다 생각하면 쉽다.

int socket_descriptor;

socket_descriptor = socket(AF_INET, SOCK_STREAM, 0); // IPv4, TCP, 시스템콜이 프로토콜 선택

각 매개변수에 어떤 값을 전달해야 하는지는 아래와 같다.

socket(domain, type, protocol);
(1) domain : IPv4, IPv6 결정
(2) type : Stream, Datagram 소켓 선택
(3) protocol : 0(시스템이 프로토콜 선택) / 6(TCP) / 17(UDP)

위 코드에서도 알 수 있듯이 socket() 의 반환값은 파일 디스크립터이다.
(💡 리눅스에서는 모든 것이 파일로 취급된다!)

2️⃣   bind()

bind() 시스템콜은 socket() 으로 생성된 실제 IP주소와 포트번호를 부여하는 시스템콜을 의미한다.

#include <sys/socket.h>
#include <netinet/in.h>

int main() {

	// 1. socket 생성
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    
    // socket 생성에 실패한 경우
    if (sockfd == -1) {
        perror("Socket creation failed");
        return 1;
    }

    // 2. (IP 주소, 포트번호) 바인딩
    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;
}

각 매개변수에 어떤 값을 전달해야 하는지는 아래와 같다.

bind(sockfd, sockaddr, socklen_t);
(1) sockfd : 바인딩 할 소켓의 파일 디스크립터
(2) sockaddr : 소켓에 바인딩 할 (IP 주소, 포트번호)를 담은 구조체
(3) socklen_t : sockaddr 구조체의 메모리 크기

Client 포트번호는 통신 시 자동으로 부여되기 때문에, 실제 IP 주소와 포트번호를 부여하는 bind() 시스템콜은 Server 프로세스에서만 사용된다.

3️⃣   listen() 🌟

listen() 시스템콜은 연결지향인 TCP에만 존재한다.
즉, 연결 요청을 받아들이기 위해 필요하다.

#include <sys/socket.h>

int main() {

	// 1. socket 생성
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    
    // socket 생성에 실패한 경우
    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(sockfd, backlog);
(1) sockfd : 소켓의 파일 디스크립터
(2) backlog : 연결 요청을 받아줄 크기 (= TCP의 backlog queue 크기)

이때 backlog 란 무엇이며, 연결 요청을 어떻게 받아들이는 걸까?

listen() 시스템콜은 Client의 연결 요청을 받아주기 위한 backlog queue를 생성하는 작업을 수행한다.

파라미터로 유한한 값인 backlog 를 지정했기 때문에 서버에 엄청 많은 클라이언트가 연결 요청을 보낸다면 overflow가 생길까봐 우려될 수 있다.

그러나 이러한 문제는 TCP 3-way handshake 과정에서 어느정도 해결된다.

4️⃣   accept() 🌟

accept() 시스템콜은 클라이언트의 연결 요청을 받아주는 시스템콜이다.

backlog queue에서 syn을 보내 대기 중인 클라이언트의 요청을 받아 하나씩 연결을 수립한다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {

	// 1. server socket 생성 ====================
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);

	// 2. (IP 주소, 포트번호) 바인딩 ====================
    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)

    bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));

	// 3. listen() ====================
    listen(server_socket, 5); // 최대 대기열 크기 : 5

    printf("Server: Waiting for client's connection...\n");

    /*
    	4. accept() ====================
        backlog queue에서 선입선출로 뺴온 Client의 주소 정보 사용
    */
    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); // Client ACK 받기
    if (bytes_received > 0) {
        printf("Server: Received ACK from client.\n");
    }

각 매개변수에 어떤 값을 전달해야 하는지는 아래와 같다.

accept(sockfd, sockaddr, socklen_t);
(1) sockfd : backlog queue의 요청을 받을 소켓의 파일 디스크립터
(2) sockaddr : 연결 요청에서 알아낸 Client의 주소 정보 구조체
(3) socklen_t : sockaddr 구조체의 메모리 크기

이때 코드의 마지막 부분은 TCP 3-way handshake 와 관련된 부분이다.

🤝 TCP 3-way handshake

TCP 3-way handshake은 연결지향인 TCP의 신뢰성 있는 통신을 위해 필요한 과정이다.

이때 Client에서 Server 쪽으로 SYN 을 보내는 과정이 연결 요청을 보내는 것을 의미하고, 이후 나머지 두 과정은 accept() 이후 연결 수립 상태를 의미한다.

accept() 이후의 과정

서버와 클라이언트가 연결을 수립한 이후 바로 데이터 송수신이 이루어진다면 병목 현상이 생길 수 있기 때문에, 서버는 연결 요청을 받는 부분응답을 주는 부분 을 구분한다.

이를 위해 멀티 프로세스(멀티 스레드) 기술이 사용된다.

본격적으로 코드를 살펴보기 전에,

fork() 시스템콜로 자식 프로세스를 생성하여 부모 프로세스와 자식 프로세스가 서로 다른 작업을 수행한다고 이해할 수 있다.

fork() 시스템콜의 기본적인 개념은 다음과 같다.

int result = fork()
result == 0 : 자식 프로세스
result != 0 : 부모 프로세스 (자식 프로세스 ID 값)

#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() {
    
    // 1. server socket 생성 ====================
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);

	// 2. (IP 주소, 포트번호) 바인딩 ====================
    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)
    
    bind(server_socket, (struct sockaddr *)&server_address, sizeof(server_address));

    listen(server_socket, 5);

    printf("Server: Listening on port 80...\n");

    while (1) {
    	/*
        	==============  부모 프로세스 시작 ==============
            accept() 시스템콜로 연결 요청을 받아주는 역할
            (연결 요청을 받는 일만 수행)
        */
        struct sockaddr_in client_address;
        socklen_t client_addrlen = sizeof(client_address);

        int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_addrlen);

		/*
        	==============  자식 프로세스 시작 ==============
            부모 프로세스가 생성한 client_socket을 이어받아
            잔여 (SYN, ACK / ACK) 3-way handshake 수행 후, 데이터 통신
        */        
        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;
}

이후 recv(), send() 시스템콜을 통해 데이터 송수신 작업을 진행한다.

위에서 작성한 내용을 간단하게 정리해 보면,
서버는 연결을 받는 부분 (부모 프로세스)과 응답을 주는 부분(자식 프로세스)가 병렬적으로 이루어져 있다는 것이다!


최용욱님의 [UMC Server Workbook]을 기반으로 작성했습니다.

0개의 댓글