본문으로 건너뛰기

안동민 개발노트

본문 시작

TCP 소켓 프로그래밍

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

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

accept()로 연결을 받은 후 while 루프에서 데이터를 주고받으므로, 그 동안 다른 클라이언트는 대기하게 됩니다.

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


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

동시 접속 처리는 연결당 실행 단위를 어떻게 둘지의 선택이다

`accept()`는 연결을 하나씩 꺼내지만, 그 뒤 처리 모델은 여러 가지입니다. 핵심 비교축은 격리, 메모리 비용, 컨텍스트 스위칭, 공유 자원 관리입니다.

  1. 연산이 길면 I/O 모델만으로 해결되지 않는다

    CPU 작업 연산이 길면 I/O 모델만으로 해결되지 않는다 별도 worker pool이나 큐로 무거운 작업을 분리합니다.

  2. 스레드는 공유가 쉬운 만큼 동기화가 필요하다

    공유 상태 스레드는 공유가 쉬운 만큼 동기화가 필요하다 클라이언트 목록, 통계, 캐시 접근은 락 범위를 짧게 둡니다.

  3. 대기 시간이 길수록 event loop가 유리하다

    idle 연결 대기 시간이 길수록 event loop가 유리하다 대부분 기다리는 연결은 스레드를 하나씩 붙이는 방식이 비싸집니다.


다중 클라이언트 처리 — fork

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

새 클라이언트가 연결되면 자식 프로세스를 생성하여 해당 클라이언트를 전담하게 합니다.

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 서버는 fd 소유권을 나눠야 연결 누수가 없다

fork 후 부모와 자식은 같은 fd를 참조하므로, 각 프로세스가 쓰지 않는 fd를 바로 닫아야 한다.

  1. 1 parent

    parent accept connected fd를 얻음

  2. 2 fork

    fork fd 복사 부모와 자식이 같은 open file description 참조

  3. 3 parent

    parent connected fd close 다음 accept를 계속 처리

  4. 4 child

    child listen fd close 자신의 클라이언트만 담당

  5. 5 child

    child serve and close read/write 뒤 connected fd 정리

SIGCHLDSIG_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는 바이트 스트림 프로토콜입니다.

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

TCP는 메시지가 아니라 바이트 흐름이므로 프레임을 직접 잘라야 한다

recv() 한 번은 메시지 하나가 아니다. 애플리케이션이 길이, 구분자, 상태를 기준으로 메시지 경계를 복원한다.

  1. 1 bytes

    bytes 붙거나 쪼개진 바이트 여러 메시지가 한 번에 올 수 있음

  2. 2 buffer

    buffer 누적 버퍼 모자란 바이트를 보관

  3. 3 parse

    parse 길이 또는 구분자 확인 완성 여부 판정

  4. 4 emit

    emit 메시지 하나 반환 남은 바이트는 다음 파싱으로

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]
    return recv_exact(sock, length).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바이트를 받을 때까지 반복하는 헬퍼입니다.


간단한 채팅 서버

메시지 경계 처리를 적용하여 간단한 채팅 서버를 만들어 보겠습니다.

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() 함수의 역할입니다.

단, 잠금을 잡은 채 네트워크 전송을 오래 수행하면 다른 연결 처리까지 막을 수 있으므로, 위 예제처럼 대상 목록만 복사한 뒤 전송하는 편이 더 안전합니다.

채팅 서버의 lock은 목록 복사까지만 잡고 전송은 밖에서 한다

공유 clients 목록은 짧게 보호하되, 느린 네트워크 send를 lock 안에 넣으면 전체 broadcast가 막힌다.

  1. 1 lock

    lock clients 목록 잠금 구조 변경을 막음

  2. 2 copy

    copy 수신자 스냅샷 보낼 대상만 짧게 복사

  3. 3 unlock

    unlock 공유 상태 해제 다른 join/leave 허용

  4. 4 send

    send 잠금 밖 전송 느린 클라이언트가 전체를 막지 않음

  5. 5 cleanup

    cleanup 실패 연결 제거 다음 tick에서 목록 갱신

이 코드는 동작하지만 확장성에 한계가 있습니다.

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

thread-per-client는 연결 수가 늘수록 대기 비용이 먼저 커진다

연결이 적을 때는 단순하지만 idle 연결이 많아지면 스택 메모리, 스케줄링, lock 경쟁이 병목이 된다.

  1. 소수 연결 코드 흐름

    단순 상태 관리가 더 번거로움

  2. idle 다수 스레드

    스택이 계속 점유 ready fd만 처리

  3. 느린 클라이언트 스레드

    대기 상태로 묶임 queue와 backpressure로 제어

  4. 장애 지점 context switch

    lock 경쟁 핸들러가 오래 돌면 전체 지연

TCP 소켓 프로그래밍에서는 소켓 상태, 송수신 경계, 오류 반환, 재시도 조건을 확인합니다.

다중 클라이언트 TCP 서버는 연결, 버퍼, 공유 상태를 분리해야 한다

채팅 서버는 동작만 보면 단순하지만, 연결 수가 늘어나면 스레드 수, 공유 목록 락, TCP 메시지 경계, 느린 클라이언트 전송이 곧 병목이 됩니다.

  1. 연결당 스레드는 빨리 비싸진다

    1,000 clients 연결당 스레드는 빨리 비싸진다 대기 시간이 긴 연결은 I/O 멀티플렉싱으로 옮기는 편이 보통 유리합니다.

  2. 락은 데이터 구조 보호에만 짧게 쓴다

    shared list 락은 데이터 구조 보호에만 짧게 쓴다 네트워크 전송처럼 오래 걸리는 작업은 락 밖으로 빼야 합니다.

  3. 서버 안정성은 애플리케이션 프로토콜 설계에 달린다

    protocol 서버 안정성은 애플리케이션 프로토콜 설계에 달린다 메시지 크기 제한, ping/pong, timeout이 없으면 연결이 쌓입니다.

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