본문 바로가기

TCP 소켓 프로그래밍

여러 클라이언트를 함께 처리하는 세 가지 방법을 비교하고, fork로 연결마다 프로세스를 나눌 때 소켓을 닫는 위치와 스레드 서버의 구조를 봅니다. 길이 헤더로 메시지를 나누어 주고받는 코드를 일부러 쪼갠 쓰기로 확인하고, 채팅 서버의 브로드캐스트에서 공유 목록과 동시 송신을 다루는 방법을 정리합니다.

앞 글의 에코 서버는 accept()를 한 번만 부르고, 그 연결이 끝나면 서버도 끝났습니다. 실제 서버는 연결을 끝없이 받아야 하고, 수많은 클라이언트와 동시에 주고받아야 합니다. 이 글에서는 여러 클라이언트를 함께 처리하는 방법과, TCP의 바이트 스트림 위에서 메시지를 정확히 나누어 주고받는 방법을 다룹니다. 이 두 가지가 거의 모든 TCP 서버의 뼈대입니다.

여러 클라이언트를 처리하는 세 가지 방법

recv()는 데이터가 올 때까지 기다리므로, 한 실행 흐름이 한 클라이언트를 기다리는 동안에는 다른 클라이언트를 돌볼 수 없습니다. 이를 피하는 방법은 크게 셋입니다.

방법하는 일장점부담
프로세스(fork)연결마다 자식 프로세스를 만든다한 연결의 오류가 다른 연결에 번지지 않는다프로세스를 만드는 비용과 메모리
스레드연결마다 스레드를 만든다프로세스보다 가볍고 메모리를 함께 쓴다공유 데이터의 동기화, 스레드 수의 한계
이벤트 루프스레드 하나가 준비된 소켓만 골라 처리한다연결이 아주 많아도 버틴다처리 중인 상태를 직접 관리해야 한다

이 글에서는 앞의 둘을 보고, 이벤트 루프는 11장 4편에서 다룹니다.

연결마다 프로세스를 나누는 fork

가장 오래된 방법은 연결을 받을 때마다 fork()로 자식 프로세스를 만들어 그 연결을 맡기는 것입니다. fork()가 프로세스를 복제하는 방식과 끝난 자식을 거두는 방법은 운영체제 교재의 프로세스 생성과 종료에서 자세히 다룹니다. 다음은 Linux를 기준으로 한 예입니다.

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

static int send_all(int fd, const char *buf, size_t len) {
    size_t sent = 0;
    while (sent < len) {
        ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        sent += (size_t)n;
    }
    return 0;
}

static void serve_client(int fd) {
    char buf[4096];
    for (;;) {
        ssize_t n = recv(fd, buf, sizeof(buf), 0);
        if (n < 0 && errno == EINTR) continue;
        if (n <= 0) break;                          /* EOF나 오류 */
        if (send_all(fd, buf, (size_t)n) < 0) break;
    }
}

int main(void) {
    signal(SIGCHLD, SIG_IGN);                       /* 끝난 자식이 좀비로 남지 않게 한다 */

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return 1; }
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(8080);
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
    if (listen(server_fd, 128) < 0) { perror("listen"); return 1; }

    for (;;) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) {
            if (errno == EINTR) continue;
            perror("accept");
            continue;
        }
        pid_t pid = fork();
        if (pid < 0) {                              /* 자식을 만들지 못했다 */
            perror("fork");
            close(client_fd);
        } else if (pid == 0) {                      /* 자식 */
            close(server_fd);                       /* 자식에게 리스닝 소켓은 필요 없다 */
            serve_client(client_fd);
            close(client_fd);
            _exit(0);
        } else {                                    /* 부모 */
            close(client_fd);                       /* 부모에게 이 연결은 필요 없다 */
        }
    }
}

fork()는 프로세스를 통째로 복제하므로, 자식도 부모가 열어 둔 파일 디스크립터를 모두 물려받습니다. 그래서 fork() 직후에는 하나의 연결을 부모와 자식의 디스크립터 두 개가 함께 가리킵니다. 새 TCP 연결이 생긴 것이 아니라, 같은 연결을 가리키는 참조가 둘이 된 것입니다.

fork 직후 연결 소켓을 가리키는 두 참조

fork 뒤 두 fd가 같은 커널 연결을 가리킵니다. 다른 참조가 없다는 조건에서 자식도 닫습니다. 각 단계의 값과 전환 이유를 비교합니다.

fork와 파일 디스크립터

프로세스가 둘이어도, 참조하는 연결 소켓은 하나입니다

fork 직후 부모와 자식의 client_fd는 같은 열린 커널 소켓을 참조합니다. 원문의 부모는 client_fd를, 자식은 server_fd를 닫아 불필요한 참조를 정리합니다.

fork 뒤 연결 소켓 공유fork가 복제하는 것은 같은 소켓에 대한 프로세스별 참조이다.부모 프로세스client_fd같은 연결 소켓커널 객체자식 프로세스client_fd서로 다른 프로세스의 fd가 같은 열린 소켓을 참조
fork 뒤 연결 소켓 공유부모와 자식의 client_fd는 하나의 커널 소켓을 가리킵니다. 각 화살표는 프로세스의 참조 방향입니다. fork 직후 정리하기 전 상태이며, 새 TCP 연결이 생긴다는 뜻이 아닙니다.부모프로세스client_fd자식프로세스client_fd같은 연결 소켓커널 객체참조참조연결 소켓 하나프로세스별 참조 둘

원문은 부모에서 client_fd를 닫고, 자식에서 server_fd를 닫습니다. 부모의 불필요한 연결 참조가 남으면 자식의 close만으로 연결 소켓이 닫히지 않아 상대가 EOF를 기다릴 수 있습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 fork 직후부터 자식 close까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목fork 직후 연결 소켓을 가리키는 두 참조
예의 값과 변화
관찰·상황값·보충 정보변화·이유
fork 직후

fork 뒤 두 fd가 같은 커널 연결을 가리킵니다.

  • 소켓·열린 참조 1: 연결 1
  • 소켓·열린 참조 2: client_fd 참조 2
  • 열린 참조가 두 개로 증가; 새 TCP 연결 생성은 아님
불필요 참조 정리

부모는 client_fd, 자식은 server_fd를 닫습니다.

  • 소켓·열린 참조 1: 연결 참조 1
  • 소켓·열린 참조 2: 자식 client_fd 유지
  • 부모 listener와 자식 연결 참조 하나를 유지
자식 close

다른 참조가 없다는 조건에서 자식도 닫습니다.

  • 소켓·열린 참조 1: 연결 참조 0
  • 마지막 열린 참조 제거; 상대 EOF가 즉시 보인다는 보장은 없음

fork 직후 연결 소켓을 가리키는 두 참조

fork 뒤 두 fd가 같은 커널 연결을 가리킵니다. 다른 참조가 없다는 조건에서 자식도 닫습니다. 각 단계의 값과 전환 이유를 비교합니다.

fork와 파일 디스크립터

프로세스가 둘이어도, 참조하는 연결 소켓은 하나입니다

fork 직후 부모와 자식의 client_fd는 같은 열린 커널 소켓을 참조합니다. 원문의 부모는 client_fd를, 자식은 server_fd를 닫아 불필요한 참조를 정리합니다.

fork 뒤 연결 소켓 공유fork가 복제하는 것은 같은 소켓에 대한 프로세스별 참조이다.부모 프로세스client_fd같은 연결 소켓커널 객체자식 프로세스client_fd서로 다른 프로세스의 fd가 같은 열린 소켓을 참조
fork 뒤 연결 소켓 공유부모와 자식의 client_fd는 하나의 커널 소켓을 가리킵니다. 각 화살표는 프로세스의 참조 방향입니다. fork 직후 정리하기 전 상태이며, 새 TCP 연결이 생긴다는 뜻이 아닙니다.부모프로세스client_fd자식프로세스client_fd같은 연결 소켓커널 객체참조참조연결 소켓 하나프로세스별 참조 둘

원문은 부모에서 client_fd를 닫고, 자식에서 server_fd를 닫습니다. 부모의 불필요한 연결 참조가 남으면 자식의 close만으로 연결 소켓이 닫히지 않아 상대가 EOF를 기다릴 수 있습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 fork 직후부터 자식 close까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목fork 직후 연결 소켓을 가리키는 두 참조
예의 값과 변화
관찰·상황값·보충 정보변화·이유
fork 직후

fork 뒤 두 fd가 같은 커널 연결을 가리킵니다.

  • 소켓·열린 참조 1: 연결 1
  • 소켓·열린 참조 2: client_fd 참조 2
  • 열린 참조가 두 개로 증가; 새 TCP 연결 생성은 아님
불필요 참조 정리

부모는 client_fd, 자식은 server_fd를 닫습니다.

  • 소켓·열린 참조 1: 연결 참조 1
  • 소켓·열린 참조 2: 자식 client_fd 유지
  • 부모 listener와 자식 연결 참조 하나를 유지
자식 close

다른 참조가 없다는 조건에서 자식도 닫습니다.

  • 소켓·열린 참조 1: 연결 참조 0
  • 마지막 열린 참조 제거; 상대 EOF가 즉시 보인다는 보장은 없음

커널은 그 연결을 가리키는 마지막 참조가 닫힐 때 연결을 닫고 상대에게 FIN을 보냅니다. 그래서 부모는 연결 소켓을, 자식은 리스닝 소켓을 곧바로 닫아야 합니다. 부모가 연결 소켓을 닫지 않으면 자식이 일을 마치고 닫아도 참조가 하나 남아 FIN이 나가지 않으므로, 클라이언트는 응답이 끝났다는 것을 알지 못한 채 기다립니다. 부모의 디스크립터도 연결마다 하나씩 새어 나가 결국 열 수 있는 수의 한도에 걸립니다.

SIGCHLD를 SIG_IGN으로 두면 끝난 자식이 종료 상태를 남긴 좀비 프로세스로 쌓이지 않고 바로 정리됩니다(sigaction(2)). 자식의 종료 상태를 확인해야 한다면 SIGCHLD의 핸들러에서 waitpid()를 되풀이해 부르는 방법을 씁니다. 자식이 exit() 대신 _exit()로 끝나는 것은, 부모에게서 물려받은 stdio 버퍼를 자식이 한 번 더 비워 같은 출력이 두 번 찍히는 일을 막기 위해서입니다.

더 깊이: exec로 넘어가는 소켓과 close-on-exec

fork()로 만든 자식이 exec()로 다른 프로그램을 실행하면, 물려받은 파일 디스크립터는 기본적으로 새 프로그램에서도 열린 채로 남습니다. 서버가 외부 명령을 실행하려고 만든 자식에게 리스닝 소켓이나 다른 클라이언트의 연결 소켓까지 넘어가면, 앞에서 본 것처럼 마지막 참조가 닫히지 않아 FIN이 나가지 않거나, 서버를 다시 켤 때 그 명령이 아직 포트를 쥐고 있어 바인드가 실패할 수 있습니다. 이를 막는 것이 디스크립터의 close-on-exec 표시(FD_CLOEXEC)로, 이 표시가 있는 디스크립터는 exec()할 때 자동으로 닫힙니다.

Linux에서는 소켓을 만들 때부터 이 표시를 붙일 수 있습니다. socket()의 종류에 SOCK_CLOEXEC를 더하거나, Linux 2.6.28부터 있는 accept4()에 SOCK_CLOEXEC를 주면 됩니다. accept(2)에 따르면 Linux의 accept()가 돌려준 소켓은 BSD와 달리 리스닝 소켓의 O_NONBLOCK 같은 상태 표시를 물려받지 않으므로, accept4()의 SOCK_NONBLOCK처럼 필요한 표시를 직접 정하는 편이 안전합니다. Python은 3.4부터 자기가 만드는 디스크립터를 기본으로 상속되지 않게 만들어 exec()할 때 닫히게 하지만, 문서가 밝히듯 os.fork()에는 이 표시와 상관없이 그대로 복제됩니다.

연결마다 스레드를 만드는 서버

스레드는 프로세스보다 가볍고 같은 메모리를 함께 씁니다. Python의 socketserver 모듈은 연결마다 스레드를 하나씩 만들어 처리기를 돌리는 서버를 제공합니다. 다음 코드는 그 서버를 띄우고, 세 클라이언트가 연결을 동시에 열어 둔 채로 주고받습니다.

import socket
import socketserver
import threading

class UpperHandler(socketserver.StreamRequestHandler):
    def handle(self):
        for line in self.rfile:                              # 한 줄씩 읽어
            self.wfile.write(line.upper())                   # 대문자로 돌려준다

server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), UpperHandler)   # 연결마다 스레드 하나
threading.Thread(target=server.serve_forever, daemon=True).start()

clients = [socket.create_connection(server.server_address) for _ in range(3)]  # 세 연결을 함께 열어 둔다
for i, c in enumerate(clients):
    c.sendall(f"client {i}\n".encode())
for c in clients:
    with c, c.makefile("rb") as reader:
        print(reader.readline())
server.shutdown()
server.server_close()
b'CLIENT 0\n'
b'CLIENT 1\n'
b'CLIENT 2\n'

세 연결이 모두 열려 있는 동안 각각의 스레드가 자기 연결의 줄을 읽고 답했으므로, 클라이언트마다 자기 응답을 받았습니다. 한 스레드가 recv()에서 기다려도 다른 스레드는 계속 일합니다. 대신 스레드 하나마다 수 MB의 스택과 운영체제의 자원을 쓰므로, 연결이 수천 개로 늘면 스레드의 수와 전환 비용이 한계가 됩니다. 여러 스레드가 함께 쓰는 데이터는 아래의 채팅 서버처럼 잠금으로 보호해야 합니다.

길이 헤더로 메시지를 나눈다

연결 하나로 여러 메시지를 주고받으려면 메시지의 끝을 정하는 규칙이 필요합니다. 6장 1편에서 본 것처럼 TCP는 send()를 몇 번 불렀는지 기억하지 않으므로, 보낸 쪽이 두 번 쓴 데이터가 한 번에 오거나, 한 번 쓴 데이터가 여러 번에 나뉘어 옵니다. 여기서는 메시지 앞에 본문의 길이를 4바이트로 붙이는 방식을 씁니다.

애플리케이션 프레임과 recv 경계

첫 2바이트만 도착해 길이 헤더도 불완전합니다. 둘째 길이와 본문까지 6바이트를 더 모읍니다. 각 단계의 값과 전환 이유를 비교합니다.

바이트 스트림 프레이밍

길이 필드도 한 번의 recv로 온전히 도착하지 않습니다

4바이트 길이와 본문으로 HELLO·OK를 프레이밍하면 총 15 B입니다. 아래의 2·7·6 B는 임의 recv의 설명용 분할이며, recv_exact는 요청한 길이만큼 반복해서 모읍니다.

프레임과 수신 분할길이 5의 HELLO와 길이 2의 OK 두 프레임 15바이트를 2, 7, 6바이트로 나누어 받는 가상 예이다.프레임: 4바이트 길이 + 본문길이 5HELLO길이 2OK각 recv가 읽은 바이트 수2 B7 B6 B
프레임과 수신 분할첫 프레임은 길이 필드4바이트와 HELLO5바이트로9바이트, 두 번째는 길이 필드4와 OK2로6바이트입니다. 임의 recv가2,7,6바이트를 받는 가상 분할입니다. 모든 데이터 상자 폭은 바이트 수에 비례하며, 원 recv_exact의 요청 크기와는 구별합니다.프레임 1길이 5 + HELLO길이 5HELLO임의 recv 분할먼저 2 B, 다음 7 B2 B7 B프레임 2길이 2 + OK길이 2OK다음 수신: 6 B6 B폭은 바이트 수에 비례전체: 9 B + 6 B = 15 B

수신 분할은 설명용 예시입니다. 4바이트 길이 필드도 여러 recv에 걸칠 수 있으며, 길이 값은 문자 수가 아닌 인코딩된 본문의 바이트 수입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 첫 2B부터 마지막 6B까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목애플리케이션 프레임과 recv 경계
관계 설명첫 프레임은 길이 필드 4바이트와 HELLO 5바이트로 9바이트, 두 번째는 길이 필드 4와 OK 2로 6바이트입니다. 임의 recv가 2, 7, 6바이트를 받는 가상 분할입니다. 모든 데이터 상자 폭은 바이트 수에 비례하며, 원 recv_exact의 요청 크기와는 구별합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 2B

첫 2바이트만 도착해 길이 헤더도 불완전합니다.

  • 수신·프레임 1: 누적 2/15B
  • 수신·프레임 2: 첫 헤더 2/4B
  • 누적 버퍼 유지; 완성한 메시지는 0개
다음 7B

7바이트가 더 와 첫 9바이트 프레임이 모입니다.

  • 수신·프레임 1: 누적 9B
  • 수신·프레임 2: length5+HELLO
  • 길이 5와 HELLO를 분리; 완성한 메시지는 1개
마지막 6B

둘째 길이와 본문까지 6바이트를 더 모읍니다.

  • 수신·프레임 1: 누적 15B
  • 수신·프레임 2: length2+OK
  • 수신·프레임 3: 완성 2
  • OK도 분리해 총 2개; 임의 recv 분할이며 recv_exact 요청 크기와 구별

애플리케이션 프레임과 recv 경계

첫 2바이트만 도착해 길이 헤더도 불완전합니다. 둘째 길이와 본문까지 6바이트를 더 모읍니다. 각 단계의 값과 전환 이유를 비교합니다.

바이트 스트림 프레이밍

길이 필드도 한 번의 recv로 온전히 도착하지 않습니다

4바이트 길이와 본문으로 HELLO·OK를 프레이밍하면 총 15 B입니다. 아래의 2·7·6 B는 임의 recv의 설명용 분할이며, recv_exact는 요청한 길이만큼 반복해서 모읍니다.

프레임과 수신 분할길이 5의 HELLO와 길이 2의 OK 두 프레임 15바이트를 2, 7, 6바이트로 나누어 받는 가상 예이다.프레임: 4바이트 길이 + 본문길이 5HELLO길이 2OK각 recv가 읽은 바이트 수2 B7 B6 B
프레임과 수신 분할첫 프레임은 길이 필드4바이트와 HELLO5바이트로9바이트, 두 번째는 길이 필드4와 OK2로6바이트입니다. 임의 recv가2,7,6바이트를 받는 가상 분할입니다. 모든 데이터 상자 폭은 바이트 수에 비례하며, 원 recv_exact의 요청 크기와는 구별합니다.프레임 1길이 5 + HELLO길이 5HELLO임의 recv 분할먼저 2 B, 다음 7 B2 B7 B프레임 2길이 2 + OK길이 2OK다음 수신: 6 B6 B폭은 바이트 수에 비례전체: 9 B + 6 B = 15 B

수신 분할은 설명용 예시입니다. 4바이트 길이 필드도 여러 recv에 걸칠 수 있으며, 길이 값은 문자 수가 아닌 인코딩된 본문의 바이트 수입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 첫 2B부터 마지막 6B까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목애플리케이션 프레임과 recv 경계
관계 설명첫 프레임은 길이 필드 4바이트와 HELLO 5바이트로 9바이트, 두 번째는 길이 필드 4와 OK 2로 6바이트입니다. 임의 recv가 2, 7, 6바이트를 받는 가상 분할입니다. 모든 데이터 상자 폭은 바이트 수에 비례하며, 원 recv_exact의 요청 크기와는 구별합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 2B

첫 2바이트만 도착해 길이 헤더도 불완전합니다.

  • 수신·프레임 1: 누적 2/15B
  • 수신·프레임 2: 첫 헤더 2/4B
  • 누적 버퍼 유지; 완성한 메시지는 0개
다음 7B

7바이트가 더 와 첫 9바이트 프레임이 모입니다.

  • 수신·프레임 1: 누적 9B
  • 수신·프레임 2: length5+HELLO
  • 길이 5와 HELLO를 분리; 완성한 메시지는 1개
마지막 6B

둘째 길이와 본문까지 6바이트를 더 모읍니다.

  • 수신·프레임 1: 누적 15B
  • 수신·프레임 2: length2+OK
  • 수신·프레임 3: 완성 2
  • OK도 분리해 총 2개; 임의 recv 분할이며 recv_exact 요청 크기와 구별

그림은 HELLO의 9바이트 프레임(길이 4바이트와 본문 5바이트)과 OK의 6바이트 프레임을 이어 보낸 15바이트가 2, 7, 6바이트로 나뉘어 도착한 경우입니다. 받는 쪽은 처음 2바이트로는 길이조차 알 수 없으므로 기다렸다가, 길이 4바이트를 다 모아 5를 읽은 뒤 본문 5바이트를 모읍니다. 다음 코드는 이 규칙으로 서버와 클라이언트를 만들고, 마지막 메시지는 일부러 세 번에 나누어 써서 받는 쪽이 제대로 다시 모으는지 확인합니다.

import socket
import struct
import threading

def send_msg(sock, text):
    data = text.encode("utf-8")
    sock.sendall(struct.pack("!I", len(data)) + data)          # 4바이트 길이(빅 엔디언) + 본문

def recv_exact(sock, n):
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))                       # 아직 모자란 만큼만 요청한다
        if not chunk:
            return None                                        # 다 모으기 전에 EOF
        data += chunk
    return data

def recv_msg(sock):
    header = recv_exact(sock, 4)
    if header is None:
        return None
    (length,) = struct.unpack("!I", header)
    body = recv_exact(sock, length)
    return None if body is None else body.decode("utf-8")

def serve(listener):
    conn, _ = listener.accept()
    with conn:
        while (message := recv_msg(conn)) is not None:
            send_msg(conn, message)

listener = socket.create_server(("127.0.0.1", 0))
threading.Thread(target=serve, args=(listener,), daemon=True).start()

with socket.create_connection(listener.getsockname(), timeout=5) as client:
    for text in ["Hello, Socket!", "", "한글🙂"]:
        if text == "한글🙂":
            payload = text.encode("utf-8")
            frame = struct.pack("!I", len(payload)) + payload
            for part in (frame[:2], frame[2:5], frame[5:]):     # 일부러 세 번에 나누어 쓴다
                client.sendall(part)
        else:
            send_msg(client, text)
        print(f"{recv_msg(client)!r}, 본문 {len(text.encode('utf-8'))}바이트")
    client.shutdown(socket.SHUT_WR)
    print("서버가 닫은 뒤:", recv_msg(client))
listener.close()
'Hello, Socket!', 본문 14바이트
'', 본문 0바이트
'한글🙂', 본문 10바이트
서버가 닫은 뒤: None

한글🙂는 세 글자이지만 UTF-8로 10바이트이므로, 길이 헤더까지 14바이트의 프레임을 2, 3, 9바이트로 나누어 썼습니다. 길이 헤더가 두 번에 걸쳐 오고 이모지의 바이트가 중간에서 잘려도, 받는 쪽은 길이만큼 모은 뒤에 디코딩하므로 같은 문자열을 되돌려 줍니다. 세 번의 쓰기가 받는 쪽의 세 번의 recv()와 짝을 이룬다는 보장은 없다는 점도 기억해 둡니다. 데이터는 합쳐지거나 더 잘게 나뉘어 도착할 수 있고, 코드는 어느 경우든 같은 결과를 내도록 짜여 있습니다.

빈 문자열은 길이가 0인 정상 메시지입니다. recv_exact(sock, 0)은 반복을 한 번도 돌지 않고 빈 바이트열을 돌려주므로 빈 문자열이 되고, 다음 헤더를 기다리다 상대가 연결을 닫은 경우만 None이 됩니다. 빈 메시지와 연결의 끝을 구별할 수 있어야 하는 이유입니다.

이 코드는 규칙을 보여 주는 예이므로 운영에 쓰려면 더 필요한 것이 있습니다. 길이 헤더에 아주 큰 값이 오면 그만큼 메모리를 잡으려 하므로 메시지의 최대 크기를 정해 거부해야 하고, 데이터를 아주 천천히 보내는 상대에 대비해 서버 쪽에도 읽기 타임아웃을 두어야 합니다. 헤더나 본문의 중간에서 연결이 끊긴 경우를 정상 종료와 구별해 기록하고, UTF-8로 풀 수 없는 본문을 받았을 때의 처리도 정해야 합니다.

채팅 서버의 브로드캐스트

채팅 서버는 한 클라이언트가 보낸 메시지를 나머지 모두에게 전합니다. 연결마다 스레드를 두는 구조라면, 모든 스레드가 접속한 클라이언트의 목록을 함께 쓰므로 목록을 바꾸거나 읽을 때 잠금이 필요합니다. 다음 코드는 그 핵심인 브로드캐스트 함수를, 실제 소켓 대신 받은 데이터를 기록하는 가짜 소켓으로 확인합니다.

import threading

clients, lock = [], threading.Lock()

class FakeSocket:
    def __init__(self, name, broken=False):
        self.name, self.broken, self.received = name, broken, []

    def sendall(self, data):
        if self.broken:
            raise BrokenPipeError                            # 이미 끊긴 연결
        self.received.append(data)

def broadcast(message, sender):
    with lock:
        targets = [c for c in clients if c is not sender]    # 잠금 안에서는 목록만 복사한다
    dead = []
    for c in targets:                                        # 보내기는 잠금 밖에서 한다
        try:
            c.sendall(message)
        except OSError:
            dead.append(c)
    with lock:
        for c in dead:
            if c in clients:
                clients.remove(c)

a, b, gone = FakeSocket("a"), FakeSocket("b"), FakeSocket("gone", broken=True)
clients.extend([a, b, gone])
broadcast(b"hi", sender=a)
print(a.received, b.received, [c.name for c in clients])
[] [b'hi'] ['a', 'b']

보낸 a는 자기 메시지를 받지 않고, b만 받았으며, 끊긴 연결은 목록에서 빠졌습니다. 잠금을 쥔 채로 보내지 않고 목록만 복사해 잠금 밖에서 보내는 것이 중요합니다. 느린 클라이언트 하나에 sendall()이 오래 걸리면, 잠금을 쥐고 있는 동안 다른 모든 스레드가 새 클라이언트를 추가하거나 메시지를 보내지 못하고 멈추기 때문입니다.

남은 문제도 있습니다. 두 스레드가 같은 클라이언트에게 동시에 sendall()을 부르면 두 메시지의 바이트가 섞여, 받는 쪽이 길이 헤더를 엉뚱한 위치에서 읽게 될 수 있습니다. 클라이언트마다 송신용 잠금을 따로 두거나, 클라이언트마다 보낼 메시지의 큐와 그 큐를 비우는 송신 스레드 하나를 두어 한 연결에는 한 곳에서만 쓰게 해야 합니다. 또 느린 클라이언트 하나가 브로드캐스트 전체를 늦추지 않도록 송신에 타임아웃을 두고, 큐가 너무 길어지면 그 연결을 끊는 정책도 필요합니다. 클라이언트가 1000명이면 스레드도 1000개가 되는 이 구조의 한계는 11장 4편의 I/O 멀티플렉싱으로 넘어섭니다.

정리

  • 여러 클라이언트를 함께 처리하는 방법에는 연결마다 프로세스, 연결마다 스레드, 준비된 소켓만 처리하는 이벤트 루프가 있습니다.
  • fork() 뒤에는 한 연결을 부모와 자식의 디스크립터가 함께 가리키므로, 부모는 연결 소켓을, 자식은 리스닝 소켓을 바로 닫아야 마지막 참조가 닫힐 때 FIN이 나갑니다.
  • SIGCHLD를 무시하거나 waitpid()를 불러 끝난 자식이 좀비로 쌓이지 않게 합니다.
  • 스레드 서버는 가볍지만 연결 수만큼 스레드가 생기고, 함께 쓰는 데이터는 잠금으로 보호해야 합니다.
  • 길이 헤더 방식은 모자란 만큼만 recv()를 되풀이해 헤더와 본문을 모으며, 쓰기가 어떻게 나뉘어 도착해도 같은 메시지를 되돌립니다. 빈 메시지와 연결의 끝은 구별해야 합니다.
  • 브로드캐스트는 잠금 안에서 목록만 복사하고 잠금 밖에서 보내며, 한 연결에 여러 스레드가 동시에 쓰지 않도록 연결마다 송신 잠금이나 큐를 둡니다.

연습 문제

1. 부모가 닫지 않은 연결

본문의 fork 서버에서 부모가 close(client_fd)를 빠뜨렸습니다. 자식이 응답을 다 보내고 close(client_fd)와 _exit(0)을 했다면, 클라이언트는 연결이 끝났다는 것을 알 수 있나요? 서버를 오래 돌리면 어떤 일이 생기나요?

해설 보기

알 수 없습니다. 부모의 디스크립터가 같은 연결을 여전히 가리키고 있으므로, 자식이 닫아도 마지막 참조가 남아 커널이 FIN을 보내지 않습니다. 클라이언트가 응답의 끝을 EOF로 판단하는 프로토콜이라면 recv()에서 끝없이 기다리게 됩니다. 또 연결을 받을 때마다 부모의 디스크립터가 하나씩 쌓여, 결국 열 수 있는 파일 디스크립터의 한도에 걸려 accept()가 EMFILE로 실패하기 시작합니다.

2. 프레임의 크기

본문의 send_msg로 빈 문자열과 한글🙂를 차례로 보내면, 각 프레임의 길이 헤더 값과 프레임의 전체 바이트 수는 얼마인가요? 받는 쪽에서 길이 0을 읽었을 때와, 새 헤더를 읽기도 전에 연결이 닫혔을 때 recv_msg는 각각 무엇을 돌려주나요?

해설 보기

빈 문자열은 본문이 0바이트이므로 길이 헤더는 0이고 프레임은 헤더만 있는 4바이트입니다. 한글🙂는 UTF-8로 3 + 3 + 4 = 10바이트이므로 헤더는 10이고 프레임은 14바이트입니다. 둘을 합치면 18바이트입니다. 길이 0을 읽으면 recv_exact(sock, 0)이 빈 바이트열을 돌려주어 recv_msg는 빈 문자열 ''을 돌려줍니다. 새 헤더를 읽기 전에 연결이 닫히면 recv_exact(sock, 4)가 None을 돌려주므로 recv_msg도 None을 돌려줍니다. 앞의 것은 정상 메시지이고 뒤의 것은 연결의 끝입니다.

3. 섞여 버린 메시지

채팅 서버에서 클라이언트 X와 Y가 거의 동시에 메시지를 보내, 두 스레드가 클라이언트 Z에게 동시에 send_msg()를 불렀습니다. sendall()이 각각 길이 헤더와 본문을 한 번에 넘기는데도 Z가 받은 데이터가 망가질 수 있나요? 어떻게 막을 수 있나요?

해설 보기

망가질 수 있습니다. sendall()은 한 번의 send()로 다 보내지 못하면 남은 부분을 여러 번에 나누어 보내는데, 그 사이에 다른 스레드의 send()가 끼어들면 X의 프레임 일부 뒤에 Y의 프레임이 섞여 들어갑니다. Z는 X의 본문 중간에서 Y의 길이 헤더를 본문으로 읽거나, 엉뚱한 바이트를 길이로 읽어 거대한 메시지를 기다리게 됩니다. 클라이언트마다 송신 잠금을 두어 한 번에 한 스레드만 그 소켓에 쓰게 하거나, 클라이언트마다 메시지 큐를 두고 그 큐를 비우는 송신자를 하나만 두면 막을 수 있습니다.

다음 글 UDP 소켓 프로그래밍에서는 연결 없이 데이터그램을 주고받는 UDP 소켓의 코드와, 손실과 타임아웃을 다루는 방법, DNS 질의를 직접 만들어 보내는 예를 살펴봅니다.