소켓 프로그래밍
소켓을 파일 디스크립터로 다루는 원리부터 bind·listen·accept 흐름, I/O 멀티플렉싱과 동시 접속 모델까지 정리합니다.
Contents
소켓은 네트워크 통신을 파일 디스크립터로 추상화한 종단점이고, 서버와 클라이언트는 정해진 호출 순서로 연결을 세웁니다.
소켓과 파일 디스크립터
소켓(socket)은 네트워크 통신의 양 끝을 가리키는 종단점입니다. 운영체제는 소켓을 파일 디스크립터(file descriptor, 열린 자원을 가리키는 정수 핸들)로 돌려줍니다. 덕분에 네트워크 입출력도 파일처럼 read·write로 다룰 수 있습니다.
소켓을 만들 때는 주소 체계와 전송 방식을 함께 정합니다. IPv4는 AF_INET, 바이트 스트림 방식의 TCP는 SOCK_STREAM, 데이터그램 방식의 UDP는 SOCK_DGRAM을 씁니다.
import socket
# IPv4 + TCP 소켓 생성, 반환값은 소켓 객체(내부적으로 파일 디스크립터)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)TCP와 UDP는 같은 소켓 API 위에 있지만 성질이 다릅니다. 아래 표로 핵심 차이를 정리합니다.
| 항목 | TCP (SOCK_STREAM) | UDP (SOCK_DGRAM) |
|---|---|---|
| 연결 방식 | 연결 지향 | 비연결 |
| 신뢰성 | 재전송·ACK로 보장 | 보장 없음(best-effort) |
| 데이터 경계 | 바이트 스트림(경계 없음) | 데이터그램(경계 보존) |
| 헤더 크기 | 20~60바이트 | 8바이트 |
| 서버 소켓 수 | 리스닝 소켓 + 연결당 연결 소켓 | 단일 소켓으로 다수 처리 |
헤더 크기는 각각 RFC 9293(TCP)·RFC 768(UDP) 기준입니다. 가장 실무에 영향을 주는 차이는 데이터 경계입니다. TCP는 바이트 스트림이라 메시지 경계가 없어, 애플리케이션이 길이 접두사나 구분자로 경계를 직접 정해야 합니다.
연결을 세우는 호출 순서
TCP 서버와 클라이언트는 역할이 갈립니다. 서버는 주소를 묶고 대기 상태로 들어가 연결을 받고, 클라이언트는 그 주소로 연결을 겁니다. 아래 그림은 양쪽의 호출 순서를 보여줍니다.
connect()는 3-방향 핸드셰이크(3-way handshake)를 시작합니다. SYN, SYN+ACK, ACK 세 메시지로 양쪽이 서로의 수신 능력을 확인하는 절차입니다. 각 호출의 역할은 다음과 같습니다.
| 함수 | 역할 | 호출 주체 |
|---|---|---|
| socket() | 소켓 생성, 파일 디스크립터 반환 | 서버·클라이언트 |
| bind() | 소켓에 IP·포트 할당 | 서버 |
| listen() | 연결 대기 상태로 전환, backlog 설정 | 서버 |
| accept() | 연결 요청 수락, 새 소켓 반환 | 서버 |
| connect() | 서버에 연결 요청 | 클라이언트 |
| send()/recv() | 데이터 송수신 | 양쪽 |
| close() | 소켓 종료, 4-방향 핸드셰이크 시작 | 양쪽 |
가장 단순한 에코 서버는 이 순서를 그대로 따릅니다. 받은 바이트를 그대로 돌려주는 구조입니다.
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 9000))
server.listen(128) # backlog 128
while True:
conn, addr = server.accept() # 연결 소켓과 상대 주소 반환
data = conn.recv(1024) # 최대 1024바이트 수신
conn.sendall(data) # 받은 만큼 전부 송신
conn.close()이 코드는 한 번에 한 연결만 처리합니다. recv()와 sendall()이 끝날 때까지 다음 accept()가 막히기 때문입니다. 동시 접속을 다루려면 뒤에서 볼 모델이 필요합니다.
backlog와 연결 소켓
listen()의 backlog 인자는 대기 큐(pending connection queue)의 크기를 정합니다. accept()는 이 큐에서 완료된 연결을 하나 꺼내 새로운 소켓 디스크립터를 반환합니다. 원래의 리스닝 소켓은 종료되지 않고 계속 새 연결을 받습니다.
accept()가 매번 새 소켓을 만드는 이유가 여기 있습니다. 리스닝 소켓은 연결 접수만 담당하고, 실제 데이터 통신은 연결 소켓(connected socket)이 맡습니다. 연결 소켓은 (서버 IP, 서버 포트, 클라이언트 IP, 클라이언트 포트) 4-튜플로 식별됩니다.
이 4-튜플 덕분에 같은 서버 포트로 여러 클라이언트와 동시에 통신할 수 있습니다. 출발지 IP나 포트가 다르면 커널이 서로 다른 연결로 구분하기 때문입니다. 리눅스에서는 이 대기 단계를 미완성 연결(SYN 큐)과 완성 연결(accept 큐)로 나눠 관리합니다(리눅스 listen(2)·tcp(7) 매뉴얼 기준).
send·recv와 커널 버퍼
send()와 recv()는 요청한 바이트 수를 한 번에 보장하지 않습니다. recv(1024)는 최대 1024바이트를 반환하며, 그보다 적게 올 수 있습니다. send()도 커널 송신 버퍼 여유만큼만 쓰고 실제 전송한 바이트 수를 반환합니다.
그래서 정확한 양을 주고받으려면 반복 처리가 필요합니다. 파이썬 sendall()은 전부 보낼 때까지 내부에서 반복하고, 수신 쪽은 필요한 길이만큼 모일 때까지 recv()를 반복합니다. 커널은 소켓마다 송신·수신 버퍼를 두며, 크기는 SO_SNDBUF·SO_RCVBUF로 조정합니다.
TCP가 바이트 스트림이라는 점이 이 동작의 배경입니다. 한 번의 send()가 여러 recv()로 쪼개져 도착하거나, 여러 send()가 한 recv()로 합쳐질 수 있습니다. 메시지 단위를 보존하려면 애플리케이션이 길이 접두사나 구분자로 경계를 정해야 합니다.
블로킹과 I/O 멀티플렉싱
블로킹 I/O에서는 recv() 호출 시 데이터가 도착할 때까지 스레드가 차단됩니다. 한 스레드가 한 소켓만 감시할 수 있어 동시 연결이 늘면 그만큼 스레드가 필요합니다. 논블로킹 I/O는 데이터가 없으면 즉시 EAGAIN(또는 EWOULDBLOCK) 오류를 반환합니다.
논블로킹만으로 여러 소켓을 돌리면 빈 소켓을 계속 확인하는 폴링이 되어 CPU를 낭비합니다. 그래서 보통 I/O 멀티플렉싱과 결합합니다. 한 스레드가 select·poll·epoll로 여러 소켓을 동시에 감시하고, 준비된 소켓만 처리하는 방식입니다.
| 항목 | select / poll | epoll / kqueue |
|---|---|---|
| fd 전달 | 매 호출마다 전체 집합 전달 | epoll_ctl로 1회 등록 |
| 커널 처리 | 전체 순회 O(n) | 준비된 fd만 반환, O(1)에 근접 |
| fd 수 제한 | select는 FD_SETSIZE(보통 1024) | 사실상 제한 없음 |
| 트리거 | 레벨 트리거 | 레벨·엣지 트리거 |
| 플랫폼 | POSIX 공통 | epoll=Linux, kqueue=BSD·macOS |
FD_SETSIZE 기본값은 리눅스 select(2) 매뉴얼 기준 보통 1024입니다. epoll은 fd를 커널에 한 번 등록한 뒤, epoll_wait가 이벤트가 발생한 fd만 돌려줍니다. 다음은 리눅스 epoll의 이벤트 루프 골격입니다(일부 인자 생략).
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN; // 읽기 이벤트 감시
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
ev.events = EPOLLIN | EPOLLET; // 엣지 트리거
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
} else {
handle_client(events[i].data.fd);
}
}
}엣지 트리거(edge-triggered, EPOLLET)는 상태가 바뀌는 순간에만 알리므로 불필요한 통지를 줄입니다. macOS·BSD는 같은 역할을 kqueue가 맡습니다. 파이썬 asyncio, Node.js 이벤트 루프, Nginx 모두 내부적으로 epoll이나 kqueue를 씁니다.
동시 접속을 처리하는 세 가지 모델
동시 연결을 다루는 방식은 크게 세 가지로 갈립니다. 연결마다 자원을 새로 만드는 모델과, 단일 스레드로 이벤트만 분배하는 모델로 나뉩니다.
| 모델 | 동시성 단위 | 메모리 | 한계 |
|---|---|---|---|
| 멀티프로세스 | 연결당 fork된 프로세스 | 독립 공간 | 프로세스 생성 비용 큼 |
| 멀티스레드 | 연결당 스레드 | 공유, 동기화 필요 | 스레드 수에 비례한 자원 |
| 이벤트 기반 | 단일 스레드 이벤트 루프 | 공유 | 콜백 분기로 코드 복잡 |
멀티프로세스·멀티스레드는 연결당 프로세스나 스레드를 만들어 자원 소모가 큽니다. 이벤트 기반 모델은 단일 스레드에서 논블로킹 I/O와 멀티플렉싱을 결합해 수만 연결을 처리합니다. 동시 1만 연결을 감당하는 과제를 C10K 문제라 부릅니다(Dan Kegel이 정리한 용어).
이벤트 기반 모델의 바탕은 리액터(Reactor) 패턴입니다. 단일 스레드의 이벤트 루프가 준비된 이벤트를 받아 등록된 핸들러로 분배하는 구조입니다. 파이썬 표준 라이브러리 selectors로 이 골격을 짧게 표현할 수 있습니다.
import selectors
import socket
sel = selectors.DefaultSelector()
def accept_handler(server):
conn, addr = server.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read_handler)
def read_handler(conn):
data = conn.recv(1024)
if data:
conn.sendall(data)
else: # 빈 바이트는 상대가 close()했다는 뜻
sel.unregister(conn)
conn.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 9000))
server.listen(100)
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, accept_handler)
while True:
for key, mask in sel.select(timeout=None): # 준비된 fd만 반환
callback = key.data
callback(key.fileobj)여기서 sel.select()는 내부적으로 epoll·kqueue를 골라 씁니다. 리스닝 소켓과 연결 소켓을 같은 셀렉터에 등록하고, 이벤트가 온 소켓만 콜백으로 처리합니다. 한 스레드가 수많은 소켓을 돌리므로 연결당 스레드를 만드는 비용이 사라집니다.
운영에서 만지는 소켓 옵션
소켓 옵션은 setsockopt()로 켜고 끕니다. 운영 중 자주 손대는 옵션을 정리합니다.
| 옵션 | 레벨 | 용도 |
|---|---|---|
| SO_REUSEADDR | SOL_SOCKET | TIME_WAIT 상태 포트 재사용 허용 |
| SO_KEEPALIVE | SOL_SOCKET | 유휴 연결에 주기적 프로브 전송 |
| TCP_NODELAY | IPPROTO_TCP | Nagle 알고리즘 비활성화 |
| SO_RCVBUF / SO_SNDBUF | SOL_SOCKET | 수신·송신 버퍼 크기 조정 |
| SO_REUSEPORT | SOL_SOCKET | 동일 포트에 여러 소켓 바인딩 |
가장 자주 만나는 옵션은 SO_REUSEADDR입니다. TCP에서 능동적으로 연결을 닫은 쪽은 TIME_WAIT 상태에 들어가, 지연된 패킷이 새 연결에 섞이는 것을 막습니다. 이 시간은 최대 세그먼트 수명(Maximum Segment Lifetime, MSL)의 2배이며, 리눅스 구현은 약 60초로 고정합니다(추정).
이 상태의 포트로 bind()를 다시 시도하면 "Address already in use" 오류가 납니다. SO_REUSEADDR을 켜면 TIME_WAIT 포트를 재사용해 서버를 즉시 재시작할 수 있습니다. TCP_NODELAY는 작은 패킷을 모아 보내는 Nagle 알고리즘을 꺼서, 지연에 민감한 통신의 응답성을 높입니다.
정리
소켓은 네트워크 통신을 파일 디스크립터로 추상화하고, 서버는 bind·listen·accept, 클라이언트는 connect로 연결을 세웁니다. accept()가 반환하는 연결 소켓은 4-튜플로 식별되므로, 같은 포트로 여러 클라이언트를 동시에 처리할 수 있습니다. send·recv는 요청한 바이트를 한 번에 보장하지 않으므로, 바이트 스트림 위에서 메시지 경계를 직접 다뤄야 합니다. 동시 접속은 멀티프로세스·멀티스레드·이벤트 기반 모델로 풀 수 있고, 대규모 연결에서는 논블로킹 I/O와 epoll·kqueue를 결합한 이벤트 모델이 자원 면에서 유리합니다. 마지막으로 SO_REUSEADDR 같은 옵션은 TIME_WAIT 같은 TCP의 정상 동작을 이해한 뒤 켜야 의도대로 작동합니다.