소켓 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 소켓 프로그래밍은 서버와 클라이언트가 서로 다른 흐름을 따릅니다.
listen 소켓과 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, 동시 종료 같은 변형도 만납니다.
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;
}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! 전체라는 보장은 없습니다.
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 소켓 프로그래밍을 다루겠습니다.