iBetter Books
수정

프로세스와 스레드로 나누기

앞 장에서 본 식당의 첫 번째 길, 손님마다 종업원을 붙이는 방법을 만들어 봅니다. 손님이 올 때마다 새로운 일꾼을 만들어 그 손님을 전담시키는 것입니다. 그 일꾼이 프로세스일 수도, 스레드일 수도 있습니다. 둘의 차이와 각각의 비용까지 함께 살펴봅니다.

스레드로 손님 맞이하기

Python에서는 스레드가 가장 손쉬운 길입니다. 스레드는 한 프로그램 안에서 동시에 흐르는 여러 가닥의 실행 흐름입니다. 클라이언트가 연결될 때마다 새 스레드를 만들어 그 클라이언트를 전담시키면, 한 스레드가 한 클라이언트의 recv에서 멈춰 있어도 다른 스레드가 다른 클라이언트를 상대할 수 있습니다.

# 새 파일: thread_server.pyimport socketimport threadingdef handle(conn, addr):    with conn:        while True:            data = conn.recv(1024)            if not data:                break            conn.sendall(data)    print(f"{addr} 종료")server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9001))server.listen(5)print("스레드 에코 서버 대기 중")while True:    conn, addr = server.accept()    threading.Thread(target=handle, args=(conn, addr), daemon=True).start()

핵심은 마지막 세 줄입니다. accept로 클라이언트를 받자마자, 그 클라이언트를 상대하는 일을 새 스레드에게 맡기고, 주 흐름은 곧바로 다시 accept로 돌아갑니다. handle 함수는 PART 02의 에코 처리와 똑같지만, 이제는 각 클라이언트마다 별도의 스레드에서 돌아갑니다.

daemon=True는 주 프로그램이 끝나면 이 스레드도 함께 정리하라는 표시입니다. 이렇게 해 두면 서버를 종료할 때 스레드들이 발목을 잡지 않습니다.

동시에 동작하는지 확인하기

서버를 띄우고 클라이언트 세 개를 동시에 붙여 봅니다. 이제는 셋이 한 줄로 기다리지 않고 함께 처리됩니다.

[B] 받음: B 인사
[A] 받음: A 인사
[C] 받음: C 인사

출력 순서가 A, B, C가 아닌 것을 눈여겨보세요. 세 클라이언트가 동시에 처리되다 보니 누가 먼저 끝날지는 그때그때 다릅니다. 한 줄로 기다리던 앞 장과 분명히 달라진 모습입니다.

C에서는 프로세스로

C에서는 전통적으로 프로세스를 나누는 fork를 자주 씁니다. fork는 현재 프로세스를 통째로 복제해 똑같은 프로그램을 둘로 만듭니다. 복제된 자식 프로세스가 클라이언트를 전담하고, 부모는 다시 accept로 돌아갑니다.

// 새 파일: fork_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>

int main(void) {
    signal(SIGCHLD, SIG_IGN);

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int yes = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9030);
    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(server_fd, 5);
    printf("fork 에코 서버 대기 중\n");

    while (1) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (fork() == 0) {
            close(server_fd);
            char buf[1024];
            ssize_t n;
            while ((n = recv(client_fd, buf, sizeof(buf), 0)) > 0)
                send(client_fd, buf, n, 0);
            close(client_fd);
            exit(0);
        }
        close(client_fd);
    }
    return 0;
}

흐름을 따라가 봅시다. accept로 클라이언트를 받은 뒤 fork를 호출합니다. fork는 신기한 함수입니다. 한 번 부르면 두 번 돌려줍니다. 부모 프로세스에게는 자식의 번호를 돌려주고, 새로 생긴 자식 프로세스에게는 0을 돌려줍니다. 그래서 if (fork() == 0)로 자식만 골라 클라이언트를 전담시킬 수 있습니다.

자식은 더는 필요 없는 server_fd를 닫고 클라이언트와 대화한 뒤, 끝나면 exit(0)으로 사라집니다. 부모는 if 블록을 건너뛰고 자기 몫인 client_fd를 닫은 뒤 다시 accept로 돌아갑니다. 부모와 자식이 같은 클라이언트 소켓을 각자 들고 있게 되므로, 자기에게 필요 없는 쪽을 닫아 주는 것이 중요합니다.

맨 위의 signal(SIGCHLD, SIG_IGN)은 자식이 사라질 때 남기는 흔적을 운영체제가 알아서 치우게 하는 설정입니다. 이것을 빼먹으면 죽은 자식 프로세스가 좀비처럼 쌓이는데, 이 한 줄로 깔끔히 막습니다.

직관적이지만 공짜는 아니다

프로세스와 스레드 방식은 직관적이고 강력합니다. 각 클라이언트가 독립된 흐름에서 처리되니 코드도 단순합니다. 하지만 공짜는 아닙니다. 그 비용을 분명히 알아야 합니다.

프로세스든 스레드든 만드는 데 비용이 듭니다. 프로세스는 통째로 복제해야 하니 특히 무겁고, 스레드는 그보다 가볍지만 역시 각자 메모리를 차지합니다. 클라이언트가 수천, 수만 명이 되면 그만큼의 흐름을 만들어야 하는데, 이는 곧 막대한 메모리 부담이 됩니다.

또 하나, 운영체제가 수많은 흐름을 번갈아 실행시키느라 치르는 비용도 있습니다. 흐름을 바꿀 때마다 어디까지 했는지 저장하고 불러오는 일이 반복되는데, 흐름이 많아질수록 이 전환 비용이 눈덩이처럼 불어납니다. 정작 일은 안 하고 일꾼을 바꾸는 데 시간을 다 쓰는 상황이 올 수 있습니다.

그래서 이 방식은 동시 접속이 수백 명 수준일 때는 훌륭하지만, 수만 명을 감당해야 하는 서버에는 한계가 있습니다. 식당에 종업원을 무한정 고용할 수 없는 것과 같습니다. 바로 이 한계를 넘기 위해 전혀 다른 발상이 등장합니다. 다음 장에서 그 발상, 한 흐름으로 여러 소켓을 지켜보는 I/O 멀티플렉싱을 만나 봅니다.