TCP 소켓 프로그래밍
이전 절의 에코 서버는 한 번에 하나의 클라이언트만 처리할 수 있었습니다. accept()로 연결을 받은 후 while 루프에서 데이터를 주고받으므로, 그 동안 다른 클라이언트는 대기하게 됩니다. 실제 서비스는 수십, 수백 명의 클라이언트를 동시에 처리해야 합니다.
다중 클라이언트 처리 전략 비교
방법 동시 연결 수 자원 소비 복잡도
──────────────────────────────────────────────────────────
순차 처리 1개 최소 최소
fork (프로세스) 수백 높음 (MB/연결) 낮음
Thread (스레드) 수천 중간 (KB/연결) 중간 (동기화)
I/O 멀티플렉싱 수만 낮음 높음
async/await 수만 낮음 중간
프로세스 스레드
┌───────────────────┐ ┌──────────────────┐
│ 독립 메모리 공간 │ │ 메모리 공유 │
│ 프로세스당 수 MB │ │ 스레드당 수 KB │
│ fork() 비용 높음 │ │ 생성 비용 낮음 │
│ IPC 필요 │ │ 공유 자원 → 락 │
│ 하나 죽어도 안전 │ │ 하나 죽으면 전체 │
└───────────────────┘ └──────────────────┘다중 클라이언트 처리 — fork
가장 고전적인 방법은 프로세스 분기(fork)입니다. 새 클라이언트가 연결되면 자식 프로세스를 생성하여 해당 클라이언트를 전담하게 합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <signal.h>
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';
send(client_fd, buffer, bytes_read, 0);
}
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() 전
부모 프로세스: server_fd=3, client_fd=4
fork() 후
부모: server_fd=3, client_fd=4 → client_fd 닫음
자식: server_fd=3, client_fd=4 → server_fd 닫음
이유: fork()가 fd를 복사하므로
닫지 않으면 참조 카운트가 남아 리소스 누수 발생SIGCHLD를 SIG_IGN으로 설정하면 자식이 종료될 때 좀비 프로세스가 생기지 않습니다. 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는 바이트 스트림 프로토콜입니다. 메시지의 경계를 보장하지 않습니다.
송신 측
send("Hello") → 5 bytes
send("World") → 5 bytes
send("!") → 1 byte
수신 측에서 가능한 결과
recv() → "HelloWorld!" (전부 합쳐서 도착)
recv() → "Hel" (일부만 도착)
recv() → "loWor" (경계를 알 수 없음)
recv() → "ld!"
해결 방법
1. 고정 길이: [____Hello][____World][________!]
모든 메시지 10바이트로 패딩 → 낭비
2. 구분자: Hello\nWorld\n!\n
\n으로 메시지 구분 → 데이터에 \n 불가
3. 길이 접두사: [5]Hello[5]World[1]!
앞 4바이트 = 길이 → 가장 일반적 ✓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 datastruct.pack("!I", length)는 정수를 4바이트 빅 엔디언으로 직렬화합니다. recv_exact()는 정확히 n바이트를 받을 때까지 반복하는 헬퍼입니다.
간단한 채팅 서버
메시지 경계 처리를 적용하여 간단한 채팅 서버를 만들어 보겠습니다.
import socket
import threading
clients = []
lock = threading.Lock()
def broadcast(message, sender):
with lock:
for client in clients:
if client != sender:
try:
client.sendall(message)
except Exception:
clients.remove(client)
def handle_client(client, addr):
with lock:
clients.append(client)
print(f"Connected: {addr}")
try:
while True:
data = client.recv(1024)
if not data:
break
broadcast(data, client)
finally:
with lock:
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() 함수의 역할입니다.
Client A ── "안녕" ──→ Server ──"안녕" ──→ Client B
──"안녕" ──→ Client C
Client B ── "반가워"──→ Server ──"반가워" ──→ Client A
──"반가워" ──→ Client C
스레드 구조
메인 스레드: accept() 루프
스레드 1: Client A 전담 (recv → broadcast)
스레드 2: Client B 전담 (recv → broadcast)
스레드 3: Client C 전담 (recv → broadcast)다음 절에서는 연결이 없는 UDP 소켓 프로그래밍을 살펴보겠습니다.
이 코드는 동작하지만 확장성에 한계가 있습니다. 클라이언트가 1,000명이 되면 1,000개의 스레드가 필요합니다. 다음 절에서는 UDP 소켓의 차이를 살펴보고, 이후 I/O 멀티플렉싱으로 이 한계를 해결하겠습니다.