소켓 API 기본
TCP 소켓의 생성·bind·listen·accept·송수신·종료 생명주기를 익히고 C와 Python으로 에코 통신을 구현합니다.
지금까지 우리는 TCP가 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 연결을 종료하는 과정을 이론적으로 살펴보았습니다.
이제 그 이론을 코드로 직접 구현해 보겠습니다.
네트워크 프로그래밍의 출발점은 소켓(Socket)입니다.
소켓이란 무엇인가
소켓은 네트워크 통신의 끝점(endpoint)입니다.
이 절에서는 운영체제의 소켓 API로 IPv4 TCP·UDP 통신을 다룹니다. 소켓은 Unix domain처럼 IP 네트워크 밖의 통신에도 사용되므로 소켓 전체를 IP·포트로만 정의하지는 않습니다.
6장에서 소켓을 IP 주소 + 포트 번호 + 프로토콜의 조합으로 설명했습니다.
조금 더 정확히 말하면, TCP 연결은 로컬 IP/포트 + 원격 IP/포트 + 프로토콜의 5-tuple로 구분됩니다.
서버의 listening socket은 (TCP, 192.0.2.10, 80)처럼 로컬 수신 지점을 열어 두고, accept()가 반환한 connected socket은 클라이언트의 에페메럴 포트까지 포함해 개별 연결을 나타냅니다.
STREAM / SOCK_STREAM
IPv4에서의 대표 프로토콜: TCP
특징: 연결 지향, 바이트 스트림
DATAGRAM / SOCK_DGRAM
IPv4에서의 대표 프로토콜: UDP
특징: 비연결, 데이터그램
RAW / SOCK_RAW
대상: IP 프로토콜 직접 접근
특징: ICMP 등 프로토콜 처리에 사용 가능TCP 소켓의 생명주기
TCP 소켓 프로그래밍은 서버와 클라이언트가 서로 다른 흐름을 따릅니다.
TCP 소켓의 역할
accept는 연결별 소켓을 반환하고, listen 소켓은 유지합니다
같은 server_fd에서 accept를 반복하면 client_fd A·B처럼 연결별 소켓을 얻습니다. 분기선은 API 관계이며, 아래 에코 예제 자체는 accept를 한 번만 호출합니다.
두 연결은 API 관계를 설명하는 예시입니다. 아래 에코 서버 원문은 accept를 한 번만 호출하므로 실제로 두 클라이언트를 처리하는 구현은 아닙니다.
여기서 핵심은 accept()의 동작입니다.
서버가 listen 상태인 동안 커널이 TCP 연결을 처리합니다. accept는 완료된 연결을 꺼내 새 connected socket을 반환하며, listen 호출 자체가 연결이 올 때까지 대기하는 것은 아닙니다.
Linux에서 listen(backlog)는 아직 애플리케이션이 accept하지 않은 완료 연결 큐의 한도이며 커널 설정에 의해 제한될 수 있습니다. 전체 동시 접속자 수를 직접 제한하는 값은 아닙니다.
socket(): 소켓 생성; 파일 디스크립터 할당.bind(): 주소/포트 바인딩; 서버의 수신 주소 설정.listen(): 연결 대기 시작; backlog 큐 생성.accept(): 연결 수락; 3-way handshake 완료된 연결 꺼냄.connect(): 연결 요청; SYN → SYN-ACK → ACK.send(): 데이터 전송; TCP 세그먼트로 분할/전송.recv(): 데이터 수신; 수신 버퍼에서 데이터 복사.close(): 연결 종료; FIN 기반 정상 종료 또는 RST.
정상 종료는 보통 FIN 교환으로 설명하지만, 실제 프로그램에서는 shutdown()을 통한 half-close, 이미 닫힌 연결에 쓰기, RST, 동시 종료 같은 변형도 만납니다.
예시: 같은 수신 포트로 들어온 두 연결 서버 주소가 127.0.0.1:8080이고 A의 로컬 포트는 50001, B는 50002라고 합시다. server_fd는 새 연결을 꺼내는 listener로 남고, accept가 돌려준 client_fd A와 client_fd B는 각각 다른 상대와 송수신합니다. 그림의 두 화살표는 API 역할 관계이며, 아래의 한 번 accept하는 서버가 두 연결을 동시에 처리한다는 실행 결과는 아닙니다. accept 뒤 데이터를 listener에서 recv하려 하지 않는 이유도 연결별 소켓이 따로 있기 때문입니다. Linux accept의 새 connected socket 설명을 함께 확인하세요.
C로 구현하는 에코 서버
에코 서버는 클라이언트가 보낸 데이터를 그대로 다시 돌려보내는 서버입니다.
다음 C 코드는 Linux/Unix 계열의 한 연결 에코 예시입니다. 부분 송신과 send의 EINTR 오류는 처리하지만, recv 오류 분류·일부 초기화 반환값·SIGPIPE 정책은 생략했습니다. 기본 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>
static void 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");
exit(EXIT_FAILURE);
}
sent += n;
}
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[1024];
char client_ip[INET_ADDRSTRLEN];
// 1. 소켓 생성
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// SO_REUSEADDR: 서버 재시작 시 주소 재사용 규칙 완화
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 주소 바인딩
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *)&server_addr,
sizeof(server_addr)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 3. 연결 대기
if (listen(server_fd, 5) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port 8080\n");
// 4. 클라이언트 연결 수락
client_fd = accept(server_fd, (struct sockaddr *)&client_addr,
&client_len);
if (client_fd < 0) {
perror("accept failed");
close(server_fd);
exit(EXIT_FAILURE);
}
inet_ntop(AF_INET, &client_addr.sin_addr,
client_ip, sizeof(client_ip));
printf("Client connected: %s:%d\n",
client_ip,
ntohs(client_addr.sin_port));
// 5. 데이터 수신 및 에코
ssize_t bytes_read;
while ((bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0)) > 0) {
buffer[bytes_read] = '\0';
send_all(client_fd, buffer, bytes_read);
}
// 6. 종료
close(client_fd);
close(server_fd);
return 0;
}부분 송신을 독립 추적하기 원 C 함수 send_all에 7바이트가 주어졌습니다. 첫 send는 3을 반환하고, 둘째는 -1과 EINTR를 반환하며, 재시도한 셋째는 남은 4바이트를 모두 반환한다고 가정합니다. 각 호출 뒤 sent는 얼마이며 둘째 호출 뒤 무엇부터 다시 보내나요?
반환값과 누적값의 풀이
sent는 0에서 3, EINTR 뒤에도 3, 마지막에는 7이 됩니다. 인터럽트 경로는 sent를 늘리지 않고 남은 위치 buf+3부터 재시도합니다. 첫 3바이트를 다시 보내는 것이 아닙니다. 이 수치는 함수 흐름을 읽는 모델이며 send의 호출 수나 반환값이 TCP 패킷 수를 나타내지는 않습니다. Python의 sendall도 모두 보내거나 예외를 내지만, 예외가 났을 때 어디까지 보냈는지는 그 API의 반환값으로 알 수 없습니다. 다시 전체 요청을 보내기 전에 애플리케이션의 중복 처리 조건을 따져야 합니다.
Python으로 구현하는 에코 서버
같은 로직을 Python으로 작성하면 훨씬 간결해집니다.
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", 8080))
server.listen(5)
print("Server listening on port 8080")
client, addr = server.accept()
print(f"Connected by {addr}")
while True:
data = client.recv(1024)
if not data:
break
client.sendall(data)
client.close()
server.close()SO_REUSEADDR 옵션은 서버 재시작 시 Address already in use 상황을 줄여 줍니다.
다만 정확한 의미는 OS마다 조금 다르고, 이미 같은 주소/포트에 active listening socket이 있으면 새로 바인딩할 수 없습니다.
여러 프로세스가 같은 포트를 나눠 받는 구조는 보통 SO_REUSEPORT나 로드 밸런싱 설정까지 함께 봐야 합니다.
send()와 sendall()의 차이도 중요합니다.
send는 일부만 처리할 수 있고, sendall은 요청한 바이트를 모두 처리하거나 오류가 날 때까지 반복합니다. 성공 반환은 상대 애플리케이션의 수신·처리 완료 확인이 아닙니다. Python 예제도 오류 시 소켓을 정리하는 try/finally는 생략했습니다.
또 하나 중요한 점은 TCP가 바이트 스트림이라는 사실입니다.
한 번 sendall()한 데이터가 한 번의 recv(1024)로 정확히 도착한다는 보장은 없습니다.
에코 서버처럼 받은 만큼 바로 돌려주는 예제는 단순하지만, 실제 프로토콜은 길이 필드, 구분자, 고정 크기 헤더 같은 프레이밍 규칙을 둬야 합니다.
클라이언트 구현
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 8080))
client.sendall(b"Hello, Socket!")
data = client.recv(1024)
print(f"Received: {data.decode()}")
client.close()이 클라이언트는 bind를 생략합니다. 특정 로컬 주소나 포트를 선택해야 하는 클라이언트는 직접 bind할 수도 있습니다.
미리 bind하지 않은 이 소켓은 connect 때 OS가 로컬 주소와 에페메럴 포트를 선택합니다.
이처럼 블로킹 TCP 소켓의 connect가 성공하면 연결이 성립한 상태입니다. 원문은 recv를 한 번만 호출하므로 출력이 Hello, Socket! 전체라는 보장은 없습니다.
한 번의 recv 출력만 보면 생기는 오해 원 클라이언트가 보낸 Hello, Socket!은 ASCII 14바이트입니다. 서버가 14바이트 모두 돌려줬어도 첫 recv가 Hello 다섯 바이트만 반환할 수 있습니다. 이것은 에코 서버가 나머지를 잃었다는 증거가 아니라 클라이언트가 한 번의 수신 뒤 닫는다는 코드 조건입니다. 다음 절에서는 전체 메시지를 읽는 종료 조건을 길이 헤더로 정합니다. 양수 크기의 recv가 빈 바이트열을 반환하는 수신 EOF는 상대의 송신 방향 종료를 뜻하며, 반대 방향으로 남은 응답을 보내는 판단과 나눕니다. Python recv·send·sendall의 반환 규칙을 코드의 반복 조건에 연결해 보세요.
1. "Address already in use"
원인: 기존 바인딩 또는 TIME_WAIT 관련 재사용 제약 등
확인: 기존 listener와 OS의 주소 재사용 규칙
2. "Connection refused"
원인: 서버가 listen 상태가 아님, 포트가 다름
해결: 서버 실행 확인, 포트 번호 확인
3. TCP에서 양수 크기로 요청한 recv()가 0 반환
의미: 남은 수신 데이터를 읽은 뒤 상대 송신 방향의 EOF
처리: 수신 종료와 반대 방향 송신을 구분
4. 요청한 바이트 일부가 송신되지 않음
가능한 원인: 부분 송신 반환값을 무시함
처리: 남은 바이트 재시도 또는 sendall과 오류 처리
5. "Broken pipe"
원인: 이미 닫힌 소켓에 쓰기
처리: 쓰기 오류와 C의 SIGPIPE 정책 처리
주의: 미리 recv해도 이후 쓰기 성공을 보장하지 않음
6. 메시지가 중간에서 잘림
원인: TCP를 메시지 단위로 착각
해결: 길이 prefix, 구분자 등 프레이밍 규칙 추가다음 절에서는 여러 클라이언트를 동시에 처리하는 TCP 소켓 프로그래밍을 다루겠습니다.