서버의 동작원리

2024. 8. 29. 14:07·Network

옛날에 교수님이 수업시간에 공개적으로 이렇게 물어본적이 있었다. 

"애플리케이션 만들 때 무슨 서버 쓰니?"

그 때 SpringBoot? 라고 대답했다. 반쪽짜리 답변이였다..

내가 만든게 WebServer 아니야?

정확히 말하면 SpringBoot은 WAS이고 SpringBoot에 내장 서버가 있다.

 

그러면 서버란 무엇인가? 웹서버.. DB서버..?

서버는 프로세스이다! 자세히는 데몬 프로세스(백그라운드에서 계속 실행되는 프로세스)

 

기본적으로 한대의 컴퓨터에서는 여러개의 프로세스가 돌아간다. 

그리고 그 프로세스들은 서로 0과 1의 전기신호를 주고받는다. (근본적인 컴퓨터의 동작..) 

이거를 있어보이게 IPC라고 한다. ( IPC에는 여러 방법이 있는데... )

예를 들어 python에서 print("hi")를 하면 콘솔에 'hi'가 찍히는것도

파이썬 프로세스가 콘솔 프로세스에 IPC를 통해 정보(전기신호)를 준 것이라고 할 수 있다. 

 

IPC를 하는 과정은 기본적으로 Computer의 resource(하드웨어)를 사용할 수 밖에 없고

이를 보호하기 위해 OS가 관여한다. 따라서 IPC를 하기 위해서 시스템 콜을 해줘야한다.

open() 시스템콜을 실행했을 때

 

만약 같은 컴퓨터의 프로세스 말고 지구촌 건너편 컴퓨터의 프로세스에 정보를 전달하고 싶으면 어떻게 할까?

그러기 위해서는 아래의 네트워크 스택을 반드시 거쳐야 한다. (그래야지 컴퓨터하고 프로세스들을 식별할 수 있다)

기본적으로 3,4계층은 OS의 커널에 1,2은 하드웨어로 구현되어있다.

이 때 application에서 OS의 네트워크 스택 즉 TCP쪽에 데이터를 전달하기 위해서는 소켓을 사용한다.

즉 소켓은 프로세스가 네트워크의 세계로 데이터를 내보내거나 받기 위한 창구 역할이다.

소켓은 인터페이스로 구현되어있다. (네트워크 프로그래밍을 위한 API)

이로써 개발자가 소켓을 통해 네트워크 통신을 쉽게 구현할 수 있게 해준다. 

소켓 인터페이스에는 socket(), bind(), listen(), accept()같은 여러 소켓 시스템 콜을 포함하고 있다. 

 

Linux에서는 모든 것을 파일로 취급한다. Application에서 TCP로 보내기 위해서는write() 시스템콜 사용

 

 결론적으로 소켓은 IPC의 방법중 하나라고 생각할 수 있겠다. (서버-클라이언트 모델을 위한)

* 헷갈렸던것: IPC(Inter-Process Communication)는 주로 사용자 모드의 프로세스들 간에 데이터를 주고받기 위해 사용된다. 네트워크 스택은 커널 내에 존재하기 때문에, 응용 프로그램이 네트워크 스택에 데이터를 전달할 때는 IPC가 아닌 시스템 콜을 통해 이루어진다.

소켓을 사용할 때 서버와 클라이언트의 세부적인 흐름이 다르다

 

그럼 간략화된 appach(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. socket() 

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

 

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

 

domain, type 등을 미리 결정해 소켓의 틀을 만들어둔다.

socket() 의 반환값은 파일 디스크립터이다. (리눅스에서는 모든 것이 파일로 취급되기에)

 

2. bind() 

socket()으로 생성된 소켓에 실제 IP주소와 포트번호를 부여해준다. (클라이언트X 통신 시 자동으로 부여된다.)

서버 같은 경우에는 아이피주소랑 포트번호 바뀌면 대혼란 올 것이므로 고정하는 작업

 

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

 

3. listen() (Only TCP)

listen() 시스템 콜은 파라미터로 받은 backlog 크기만큼 backlog queue를 만든다.

 

listen(sockfd, backlog);
sockfd : 소켓의 파일 디스크립터
backlog : 연결 요청을 받아줄 크기 (= TCP의 backlog queue 크기)

 

서버 측의 소켓은 listen()이후 대기 상태에서 클라이언트의 연결 요청을 받아주기 위해

backlog queue를 가진 채로 기다리게 된다. 실제로는 서버에 셀 수 없이 많은 클라이언트가 요청을 보내게 되고

이 요청들은 모두 backlog queue에 저장이 된다.

소켓을 통해 TCP listening(대기)상태로

 

위 그림에서 client가 소켓을 통해 처음으로 서버에 요청을 하고 백로그 큐에 들어갈 때 syn 요청을 보낸다.

 

4. accept()

accept 시스템 콜은 backlog queue에서 syn을 보내와 대기 중인 요청을

선입선출(자료구조 큐)로 하나씩 연결에 대한 수립을 해준다.

 

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

 

파라미터를 보면 클라이언트의 아이피 주소, 포트번호를 받는데

이 값은 백로그 큐에서 가장 앞에 있는 연결요청 구조체에서 알아내서 가져온다.

즉 accept는 3-way-hanshake중 SYN 요청 이후의 작업을 한다.(established가 되면 통신 가능)

 

 

이때 서버가 다음 클라이언트의 요청을 받을 때

그전 클라이언트의 요청에 대한 응답을 줄 때 까지 기다린다면 엄청 느려질 것이다.

따라서 연결 요청을 받는 부분 따로, 통신에 대한 응답 주는 부분을 분리해준다!

여기서 멀티 프로세스(or 쓰레드)의 개념이 사용된다.

#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;
}

 

먼저 fork()는 자식 프로세스를 만들기 위한 시스템 콜이다.

 

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

 

원래 실행중인 프로그램이 부모 새로 생성된 프로그램이 자식이다.

 

연결 요청을 받아준 후 실제 응답을 주기 위한 프로세스를 만든다. 

여기서 주의 깊게 볼점은 accept의 반환값이 새로운 소켓의 파일 디스크립터라는 것이다.

기존 소켓은 연결을 받는 역할을 계속하게 하고, 새로 만들어진 프로세스에 대해서

데이터를 전달할 새로운 소켓을 만들어 준다.

 

정리하자면 fork를 통해 실행중인 프로그램이 2개로 나뉘고

연결 요청 받는 부분 -> accept

응답 주는 부분 -> fork, pthread_create 사용

 

부모 프로세스 입장

#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;
	}

 

 

부모 프로세스 입장에서는 socket() -> bind() -> listen()상태로 만들고 ->  accept()로 큐에 있는거 하나하나 처리한다.

그 이후의 과정은 나몰라! 하고 자식 프로세스에게 시킨다. 

그리고 while문을 통해 다시 돌아오면서 accept()를 다시 실행

 

자식 프로세스 입장

if (fork() == 0 -> true) { // 자식 프로세스
            close(server_socket);

            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); <-여기서 자식 프로세스가 종료됨
        }

 

 

자식 프로세스는 반면, 부모 프로세스가 새로 만들어준 소켓을 이어받아

이후 남은 잔여 3-way handshake를 수행 후 데이터 통신을 수행한다.

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

그리고 새로운 연결요청을 받지 않고 그저 응답을 준 후 exit(0)를 통해 종료된다

 

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

 

PS. UMC 워크북 서버파트의 도움을 많이 받았습니다.😊

'Network' 카테고리의 다른 글

네트워크 스택  (0) 2024.08.31
'Network' 카테고리의 다른 글
  • 네트워크 스택
dev.di
dev.di
devdi 님의 블로그 입니다.
  • dev.di
    개발 블로그
    dev.di
  • 전체
    오늘
    어제
    • 분류 전체보기 (28)
      • Algorithm (9)
        • Basics (9)
      • AWS (0)
        • AWS (0)
        • SAA (0)
      • Computer Science (1)
        • OS 벼락치기 (1)
        • DB 벼락치기 (0)
      • Data Engineer (8)
        • Airflow (0)
        • Data Warehouse (0)
        • Kafka (0)
        • Spark (0)
        • 데브코스 (8)
      • Docker (0)
      • Interviews (1)
      • Network (2)
        • Physical Layer (0)
        • Data Link Layer (0)
      • OOP (3)
        • GoF (3)
      • Python (4)
        • Django (3)
        • Scraping (1)
      • Software Engineering (0)
      • Spring (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    IPv4
    sql
    데이터 웨어하우스
    포트포워딩
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
dev.di
서버의 동작원리
상단으로

티스토리툴바