본문 바로가기

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 뒤 두 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가 즉시 보인다는 보장은 없음

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

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

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


닫는 위치를 독립 판단하기 fork가 성공한 직후 같은 연결 소켓을 가리키는 참조가 부모에 하나, 자식에 하나 있고, 다른 복제 참조는 없다고 합시다. 부모가 자기 client_fd를 닫으면 두 참조 중 하나가 남습니다. 자식이 나중에 자기 client_fd까지 닫아야 마지막 참조도 없어집니다. 부모가 실수로 연결 참조를 유지했다면 자식의 close만 보고 상대가 수신 EOF를 곧 받는다고 결론 내릴 수 있을까요?

프로세스 수와 소켓 수의 풀이

부모의 참조가 남아 있으므로 마지막 참조 종료 조건이 아닙니다. fork가 새 TCP 연결을 하나 더 만드는 것도 아닙니다. 각 close는 해당 프로세스의 fd 참조를 정리하며, shutdown으로 같은 소켓의 송수신 방향을 닫는 동작과 구별합니다. 위 원 코드의 pid < 0은 성공한 자식 분기가 아니고 부모 쪽 정리 분기로 들어가므로, 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 경계

첫 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 요청 크기와 구별
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의 별도 오류 분류를 생략했습니다.


예시: 15바이트를 헤더와 본문으로 다시 조립하기 HELLO의 ASCII 본문은 5바이트이므로 프레임은 4+5=9바이트, OK의 프레임은 4+2=6바이트입니다. 두 프레임을 이어 보내면 15바이트입니다. 그림의 2·7·6바이트는 충분한 크기를 요청한 임의 recv의 분할입니다. 원 recv_exact는 매번 아직 필요한 크기만 요청하므로, 헤더를 2바이트 받은 직후 다음 요청은 2바이트이며 7바이트를 요청하는 것이 아닙니다. 헤더의 값 5를 읽은 다음에 본문 5바이트를 따로 모읍니다.

다른 프레임을 독립적으로 읽기 같은 규칙으로 빈 문자열과 한글🙂을 차례로 보내면 각 헤더의 길이 값과 두 프레임의 전체 바이트 수는 얼마일까요? 길이 0을 정상 헤더로 받은 경우와, 새 헤더를 읽기 전에 recv가 EOF를 돌려준 경우도 구별하세요.

문자 수 대신 인코딩된 바이트를 세는 풀이

빈 문자열의 본문은 0바이트이고 프레임은 헤더만 있는 4바이트입니다. 한글🙂은 문자 세 개지만 UTF-8 본문은 3+3+4=10바이트이므로 프레임은 14바이트입니다. 합계는 18바이트입니다. 길이 0을 읽었다면 recv_exact(sock, 0)은 빈 bytes를 반환하고 recv_msg는 빈 문자열을 반환합니다. 새 헤더를 얻지 못한 EOF는 None이며 빈 메시지와 다릅니다. 프레임 중간 EOF를 구분해 복구하는 정책은 위 축약 함수에서 생략한 조건입니다.

길이 프레이밍을 확인하는 에코 실습

앞의 length_prefix.py를 서버와 클라이언트 양쪽에서 사용해 메시지를 끝까지 확인합니다. 이 실습은 프레이밍에 집중하므로 연결 하나에서 여러 메시지를 처리한 뒤 서버도 종료합니다.

첫 절의 에코 클라이언트는 본문 바이트만 보냅니다. 이번 서버는 4바이트 길이를 먼저 읽으므로 아래의 대응 클라이언트를 함께 사용합니다. 위의 fork·스레드 에코 서버와 이번 파일을 같은 포트에서 동시에 실행하지 않습니다.

세 파일 준비

새 실습 폴더에 앞의 함수를 length_prefix.py로 저장하고, 다음 서버와 클라이언트도 같은 폴더에 저장합니다. 외부 패키지 없이 Python 3의 표준 라이브러리만 사용합니다.

서버 주소는 127.0.0.1, 포트는 18080입니다. 포트가 사용 중이면 두 파일의 포트를 같은 빈 번호로 바꿉니다. 기존 프로세스를 종료할 필요는 없습니다.

framed_echo_server.py
import socket

from length_prefix import recv_msg, send_msg

HOST = "127.0.0.1"
PORT = 18080

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen(1)
    print(f"Framed echo ready: {HOST}:{PORT}", flush=True)
    client, _ = server.accept()
    with client:
        while True:
            message = recv_msg(client)
            if message is None:
                break
            print(f"Message: {message!r}", flush=True)
            send_msg(client, message)
print("Receive EOF; server stopped", flush=True)
framed_echo_client.py
import socket
import struct

from length_prefix import recv_msg, send_msg

with socket.create_connection(("127.0.0.1", 18080), timeout=5) as client:
    for index, message in enumerate(["Hello, Socket!", "", "한글🙂"]):
        if index == 2:
            payload = message.encode("utf-8")
            frame = struct.pack("!I", len(payload)) + payload
            parts = (frame[:2], frame[2:5], frame[5:])
            for part in parts:
                client.sendall(part)
            print("Split writes:", [len(part) for part in parts])
        else:
            send_msg(client, message)
        echoed = recv_msg(client)
        if echoed != message:
            raise RuntimeError(f"Echo mismatch: expected {message!r}, got {echoed!r}")
        print(f"Verified: {echoed!r}")
    client.shutdown(socket.SHUT_WR)
    if recv_msg(client) is not None:
        raise RuntimeError("Unexpected response after the final message")
print("Receive EOF; client stopped")

클라이언트는 일반 문자열과 빈 문자열을 send_msg로 보냅니다. 마지막 한글🙂는 3문자지만 UTF-8로 10바이트이므로, 길이 필드 4바이트까지 합친 14바이트 프레임을 2·3·9바이트로 나누어 보냅니다.

세 번의 sendall이 상대의 세 번의 recv와 대응한다는 뜻은 아닙니다. 수신 데이터는 합쳐지거나 더 잘게 나뉠 수 있습니다. 서버는 본문을 전부 모은 뒤 디코딩하므로 UTF-8 문자 중간에서 전송을 나누어도 같은 메시지를 복원합니다.

실행과 완료 확인

터미널 A에서 실습 폴더로 이동해 서버를 먼저 실행합니다. -X utf8은 출력과 입력의 기본 인코딩을 UTF-8로 맞춥니다.

터미널 A: 서버 시작
python -X utf8 framed_echo_server.py

Framed echo ready: 127.0.0.1:18080을 확인한 뒤, 터미널 B의 같은 폴더에서 클라이언트를 실행합니다.

터미널 B: 대응 클라이언트 실행
python -X utf8 framed_echo_client.py
클라이언트 예상 결과
Verified: 'Hello, Socket!'
Verified: ''
Split writes: [2, 3, 9]
Verified: '한글🙂'
Receive EOF; client stopped
서버 예상 결과
Framed echo ready: 127.0.0.1:18080
Message: 'Hello, Socket!'
Message: ''
Message: '한글🙂'
Receive EOF; server stopped

Split writes는 클라이언트가 각 sendall에 넘긴 크기입니다. 패킷 크기나 실제 recv 반환 크기를 측정한 출력이 아닙니다.

클라이언트는 에코 내용이 원문과 다르면 오류를 냅니다. 모든 응답을 확인한 뒤 shutdown(SHUT_WR)로 송신 방향을 닫고 수신은 계속합니다. 서버는 다음 길이 필드를 기다리다가 EOF를 확인하고 연결을 닫습니다. 클라이언트도 수신 EOF를 확인해야 실습이 완료됩니다.

빈 메시지 ''는 유효한 응답이고 None은 수신 종료입니다. 서버가 준비됐는지, 두 파일이 같은 포트를 쓰는지, length_prefix.py가 같은 폴더에 있는지 확인하면 연결 거부나 import 실패를 좁힐 수 있습니다. 다시 실습하려면 서버도 다시 시작합니다.

짧은 수신과 실패 경계 확인

짧은 수신을 직접 강제하려면 실습용 length_prefix.py의 sock.recv(n - len(data))를 sock.recv(min(2, n - len(data)))로 바꾸고 서버·클라이언트를 다시 실행해 봅니다. 한 번에 최대 2바이트만 요청해도 같은 세 메시지가 복원되어야 합니다. 이 변경은 TCP 패킷 분할을 제어하는 것이 아니라 수신 함수에 작은 읽기를 요청하는 실험입니다.

확인 상황현재 함수의 결과해석
길이 0인 완전한 프레임''빈 메시지이며 EOF가 아닙니다.
다음 헤더 전에 송신 EOFNone앞의 프레임을 끝까지 받은 뒤 수신이 종료됩니다.
헤더나 본문 중간에 송신 EOFNone미완성 프레임을 메시지로 반환하지 않습니다. 정상 EOF와 별도 분류하지는 않습니다.
UTF-8로 해석할 수 없는 본문UnicodeDecodeError디코딩 오류의 복구 정책은 구현하지 않았습니다.

현재 서버는 길이 상한과 수신 timeout을 추가하지 않은 교육용 실습입니다. 클라이언트의 5초 timeout은 응답 없이 계속 기다리는 상황을 드러내지만 서버의 자원 제한을 대신하지 않습니다. 운영 프로토콜에는 프레임 크기·대기 시간 제한과 미완성 프레임의 오류 정책이 더 필요합니다.


간단한 채팅 서버

다음 채팅 예시는 길이 프레이밍과 대상 목록 복사를 결합한 교육용 코드입니다. 현재 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 멀티플렉싱으로 이 한계를 해결하겠습니다.