TCP 소켓 프로그래밍
fork와 스레드로 여러 TCP 클라이언트를 처리하고 바이트 스트림의 버퍼·메시지 경계를 관리해 채팅 서버를 구현합니다.
이전 절의 에코 서버는 한 번에 하나의 클라이언트만 처리할 수 있었습니다.
이전 원문은 accept를 한 번 호출하고 그 연결이 끝나면 서버도 종료합니다. 다른 연결을 계속 수락하려면 accept 반복과 연결별 처리 전략이 추가로 필요합니다.
실제 서비스는 수십, 수백 명의 클라이언트를 동시에 처리해야 합니다.
다중 클라이언트 처리 전략 비교
- fork: 연결마다 자식 프로세스를 만들어 메모리와 오류 영향을 분리합니다. 프로세스 생성·관리 비용이 듭니다.
- 스레드: 같은 프로세스의 메모리를 공유하며 연결별 블로킹 코드를 작성합니다. 공유 상태 동기화와 스레드 수 제한이 필요합니다.
- 이벤트 루프: 준비된 I/O를 모아 처리합니다. 부분 처리 상태와 출력 큐를 애플리케이션에서 관리합니다.
다중 클라이언트 처리 — fork
가장 고전적인 방법은 프로세스 분기(fork)입니다.
새 클라이언트가 연결되면 자식 프로세스를 생성하여 해당 클라이언트를 전담하게 합니다. 다음은 fork 성공 경로를 보여 주는 Unix 예시입니다. 초기화와 fork 실패 분류, recv 오류 및 SIGPIPE 정책은 생략되어 있습니다.
#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 직후 연결 소켓을 가리키는 두 참조
원문은 부모에서 client_fd를 닫고, 자식에서 server_fd를 닫습니다. 부모의 불필요한 연결 참조가 남으면 자식의 close만으로 연결 소켓이 닫히지 않아 상대가 EOF를 기다릴 수 있습니다.
SIGCHLD를 SIG_IGN으로 설정하면 많은 Unix 계열 시스템에서 자식 종료를 자동으로 정리해 좀비 프로세스를 줄일 수 있습니다.
더 명시적으로 제어하려면 SIGCHLD 핸들러에서 waitpid()를 반복 호출하는 방식도 사용합니다.
fork 방식은 격리에 유리하지만 연결마다 프로세스를 생성하므로 메모리와 생성 비용이 커집니다. 처리 가능한 연결 수는 환경과 작업에 따라 측정해야 합니다.
다중 클라이언트 처리 — Thread
프로세스보다 가벼운 스레드를 사용하면 자원 소비를 줄일 수 있습니다.
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 경계
수신 분할은 설명용 예시입니다. 4바이트 길이 필드도 여러 recv에 걸칠 수 있으며, 길이 값은 문자 수가 아닌 인코딩된 본문의 바이트 수입니다.
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 datastruct.pack("!I", length)는 정수를 4바이트 빅 엔디언으로 직렬화합니다.
recv_exact는 n바이트를 모으면 반환하고, 도중 EOF이면 None을 반환합니다. 빈 메시지는 길이 0의 빈 문자열로 구분됩니다. 이 예제는 최대 길이, timeout, UTF-8 오류와 중간 프레임 EOF의 별도 오류 분류를 생략했습니다.
간단한 채팅 서버
다음 채팅 예시는 길이 프레이밍과 대상 목록 복사를 결합한 교육용 코드입니다. 현재 lock은 clients 목록만 보호합니다. 여러 송신 스레드가 같은 대상 소켓에 sendall을 호출하면 프레임 바이트가 섞일 수 있으므로, 동시에 안전한 채팅 서버를 완성하려면 대상별 송신 잠금이나 단일 송신자 큐가 필요합니다.
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 멀티플렉싱으로 이 한계를 해결하겠습니다.