안동민 개발노트

본문 시작

TCP 소켓 프로그래밍

fork와 스레드로 여러 TCP 클라이언트를 처리하고 바이트 스트림의 버퍼·메시지 경계를 관리해 채팅 서버를 구현합니다.

이전 절의 에코 서버는 한 번에 하나의 클라이언트만 처리할 수 있었습니다.

이전 원문은 accept를 한 번 호출하고 그 연결이 끝나면 서버도 종료합니다. 다른 연결을 계속 수락하려면 accept 반복과 연결별 처리 전략이 추가로 필요합니다.

실제 서비스는 수십, 수백 명의 클라이언트를 동시에 처리해야 합니다.


다중 클라이언트 처리 전략 비교

  • fork: 연결마다 자식 프로세스를 만들어 메모리와 오류 영향을 분리합니다. 프로세스 생성·관리 비용이 듭니다.
  • 스레드: 같은 프로세스의 메모리를 공유하며 연결별 블로킹 코드를 작성합니다. 공유 상태 동기화와 스레드 수 제한이 필요합니다.
  • 이벤트 루프: 준비된 I/O를 모아 처리합니다. 부분 처리 상태와 출력 큐를 애플리케이션에서 관리합니다.

다중 클라이언트 처리 — fork

가장 고전적인 방법은 프로세스 분기(fork)입니다.

새 클라이언트가 연결되면 자식 프로세스를 생성하여 해당 클라이언트를 전담하게 합니다. 다음은 fork 성공 경로를 보여 주는 Unix 예시입니다. 초기화와 fork 실패 분류, recv 오류 및 SIGPIPE 정책은 생략되어 있습니다.

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

static int send_all(int fd, const char *buf, ssize_t len) {
    ssize_t sent = 0;

    while (sent < len) {
        ssize_t n = send(fd, buf + sent, len - sent, 0);
        if (n < 0 && errno == EINTR) {
            continue;
        }
        if (n <= 0) {
            perror("send failed");
            return -1;
        }
        sent += n;
    }
    return 0;
}

void handle_client(int client_fd) {
    char buffer[1024];
    ssize_t bytes_read;
    while ((bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0)) > 0) {
        buffer[bytes_read] = '\0';
        if (send_all(client_fd, buffer, bytes_read) < 0) {
            break;
        }
    }
    close(client_fd);
    exit(0);
}

int main() {
    int server_fd;
    struct sockaddr_in addr;

    signal(SIGCHLD, SIG_IGN); // 좀비 프로세스 방지

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

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8080);

    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(server_fd, 128);
    printf("Server listening on port 8080\n");

    while (1) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) continue;

        pid_t pid = fork();
        if (pid == 0) {
            close(server_fd);  // 자식은 서버 소켓 불필요
            handle_client(client_fd);
        } else {
            close(client_fd);  // 부모는 클라이언트 소켓 불필요
        }
    }
}
fork 직후 연결 소켓을 가리키는 두 참조

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

fork 뒤 연결 소켓 공유fork가 복제하는 것은 같은 소켓에 대한 프로세스별 참조이다.부모 프로세스client_fd같은 연결 소켓커널 객체자식 프로세스client_fd서로 다른 프로세스의 fd가 같은 열린 소켓을 참조
fork 뒤 연결 소켓 공유부모와 자식의 client_fd가 하나의 커널 소켓을 가리킨다.부모 프로세스client_fd자식 프로세스client_fd같은 연결 소켓커널 객체

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

SIGCHLD를 SIG_IGN으로 설정하면 많은 Unix 계열 시스템에서 자식 종료를 자동으로 정리해 좀비 프로세스를 줄일 수 있습니다.

더 명시적으로 제어하려면 SIGCHLD 핸들러에서 waitpid()를 반복 호출하는 방식도 사용합니다.

fork 방식은 격리에 유리하지만 연결마다 프로세스를 생성하므로 메모리와 생성 비용이 커집니다. 처리 가능한 연결 수는 환경과 작업에 따라 측정해야 합니다.


다중 클라이언트 처리 — Thread

프로세스보다 가벼운 스레드를 사용하면 자원 소비를 줄일 수 있습니다.

multi_server_thread.py
import socket
import threading

def handle_client(client, addr):
    print(f"Connected: {addr}")
    while True:
        data = client.recv(1024)
        if not data:
            break
        client.sendall(data)
    client.close()
    print(f"Disconnected: {addr}")

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8080))
server.listen(128)
print("Server listening on port 8080")

while True:
    client, addr = server.accept()
    t = threading.Thread(target=handle_client, args=(client, addr))
    t.daemon = True
    t.start()

스레드 방식은 fork보다 가볍지만, 여전히 동시 연결 수가 많아지면 컨텍스트 스위칭 비용이 증가합니다.

또한 공유 자원에 대한 동기화 문제(락, 레이스 컨디션)를 신경 써야 합니다.


버퍼 관리와 메시지 경계

TCP는 바이트 스트림 프로토콜입니다.

메시지의 경계를 보장하지 않습니다.

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

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

프레임과 수신 분할길이 5의 HELLO와 길이 2의 OK 두 프레임 15바이트를 2, 7, 6바이트로 나누어 받는 가상 예이다.프레임: 4바이트 길이 + 본문길이 5HELLO길이 2OK각 recv가 읽은 바이트 수2 B7 B6 B
프레임과 수신 분할위의 첫 프레임은 2바이트와 7바이트로 나뉘어 수신되고, 아래 두 번째 프레임은 6바이트로 수신되는 가상 예이다. 모든 상자의 폭은 바이트 수에 비례한다.프레임 1 · 길이 5 + HELLO길이 5HELLO수신: 먼저 2 B, 이어서 7 B2 B7 B프레임 2 · 길이 2 + OK길이 2OK다음 수신: 6 B6 B

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

length_prefix.py
import struct

def send_msg(sock, msg):
    data = msg.encode()
    length = struct.pack("!I", len(data))  # 4바이트 빅엔디언
    sock.sendall(length + data)

def recv_msg(sock):
    raw_length = recv_exact(sock, 4)
    if not raw_length:
        return None
    length = struct.unpack("!I", raw_length)[0]
    data = recv_exact(sock, length)
    return None if data is None else data.decode()

def recv_exact(sock, n):
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            return None
        data += chunk
    return data

struct.pack("!I", length)는 정수를 4바이트 빅 엔디언으로 직렬화합니다.

recv_exact는 n바이트를 모으면 반환하고, 도중 EOF이면 None을 반환합니다. 빈 메시지는 길이 0의 빈 문자열로 구분됩니다. 이 예제는 최대 길이, timeout, UTF-8 오류와 중간 프레임 EOF의 별도 오류 분류를 생략했습니다.


간단한 채팅 서버

다음 채팅 예시는 길이 프레이밍과 대상 목록 복사를 결합한 교육용 코드입니다. 현재 lock은 clients 목록만 보호합니다. 여러 송신 스레드가 같은 대상 소켓에 sendall을 호출하면 프레임 바이트가 섞일 수 있으므로, 동시에 안전한 채팅 서버를 완성하려면 대상별 송신 잠금이나 단일 송신자 큐가 필요합니다.

chat_server.py
import socket
import struct
import threading

clients = []
lock = threading.Lock()

def send_msg(sock, msg):
    data = msg.encode()
    header = struct.pack("!I", len(data))
    sock.sendall(header + data)

def recv_exact(sock, n):
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            return None
        data += chunk
    return data

def recv_msg(sock):
    raw_length = recv_exact(sock, 4)
    if raw_length is None:
        return None
    length = struct.unpack("!I", raw_length)[0]
    data = recv_exact(sock, length)
    return None if data is None else data.decode()

def broadcast(message, sender):
    with lock:
        targets = [client for client in clients if client != sender]

    dead = []
    for client in targets:
        try:
            send_msg(client, message)
        except OSError:
            dead.append(client)

    if dead:
        with lock:
            for client in dead:
                if client in clients:
                    clients.remove(client)

def handle_client(client, addr):
    with lock:
        clients.append(client)
    print(f"Connected: {addr}")
    try:
        while True:
            message = recv_msg(client)
            if message is None:
                break
            broadcast(message, client)
    finally:
        with lock:
            if client in clients:
                clients.remove(client)
        client.close()
        print(f"Disconnected: {addr}")

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8080))
server.listen(128)
print("Chat server on port 8080")

while True:
    client, addr = server.accept()
    threading.Thread(target=handle_client, args=(client, addr), daemon=True).start()

clients 리스트를 여러 스레드가 공유하므로, threading.Lock()으로 접근을 동기화합니다.

한 클라이언트가 보낸 메시지를 나머지 모든 클라이언트에게 전달하는 것이 broadcast() 함수의 역할입니다.

목록 잠금 밖에서 보내면 느린 송신 때문에 목록 변경까지 막히는 일은 줄어듭니다. 다만 대상의 종료와 전송은 경합할 수 있고, 느린 대상은 그 broadcast 호출을 지연시킵니다.

현재 코드에는 프레임 크기·연결 수 제한, 출력 큐와 송신 timeout도 없습니다.

클라이언트가 1,000명이 되면 1,000개의 스레드가 필요합니다.

다음 절에서는 UDP 소켓의 차이를 살펴보고, 이후 I/O 멀티플렉싱으로 이 한계를 해결하겠습니다.