소켓 API 기본
지금까지 우리는 TCP가 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 연결을 종료하는 과정을 이론적으로 살펴보았습니다. 이제 그 이론을 코드로 직접 구현해 보겠습니다. 네트워크 프로그래밍의 출발점은 소켓(Socket)입니다.
소켓이란 무엇인가
소켓은 네트워크 통신의 끝점(endpoint)입니다. 프로세스가 네트워크를 통해 다른 프로세스와 데이터를 주고받으려면, 반드시 소켓을 통해야 합니다.
6장에서 소켓을 IP 주소 + 포트 번호 + 프로토콜의 조합으로 설명했습니다. 조금 더 정확히 말하면, TCP 연결은 로컬 IP/포트 + 원격 IP/포트 + 프로토콜의 5-tuple로 구분됩니다. 서버의 listening socket은 (TCP, 192.0.2.10, 80)처럼 로컬 수신 지점을 열어 두고, accept()가 반환한 connected socket은 클라이언트의 에페메럴 포트까지 포함해 개별 연결을 나타냅니다.
유형 상수 프로토콜 특징
──────────────────────────────────────────────────
STREAM SOCK_STREAM TCP 연결 지향, 바이트 스트림
DATAGRAM SOCK_DGRAM UDP 비연결, 데이터그램
RAW SOCK_RAW IP 직접 프로토콜 직접 구현 (ping 등)TCP 소켓의 생명주기
TCP 소켓 프로그래밍은 서버와 클라이언트가 서로 다른 흐름을 따릅니다.
여기서 핵심은 accept()의 동작입니다. 서버가 listen()으로 대기하다가 클라이언트의 connect()를 받으면, 커널은 완료된 연결을 큐에 넣고 accept()는 그 연결을 위한 새로운 connected socket을 반환합니다. 원래의 listening socket은 여전히 다른 클라이언트의 연결을 기다리고, 새 소켓으로 해당 클라이언트와 통신합니다. listen(backlog)의 값은 “동시 접속자 수”라기보다, 아직 애플리케이션이 accept()하지 않은 완료 연결 큐의 크기에 가깝습니다.
| API 함수 | 역할 | TCP 동작 |
|---|---|---|
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로 구현하는 에코 서버
에코 서버는 클라이언트가 보낸 데이터를 그대로 다시 돌려보내는 서버입니다. 소켓 프로그래밍의 Hello World라고 할 수 있습니다.
#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()은 모든 데이터가 전송될 때까지 반복합니다.
또 하나 중요한 점은 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()가 필요 없습니다. connect()를 호출하면 OS가 자동으로 사용 가능한 포트(에페메럴 포트)를 할당합니다. connect() 내부에서 TCP 3-way handshake가 완료된 후 반환됩니다.
1. "Address already in use"
원인: TIME_WAIT 상태의 포트
해결: SO_REUSEADDR 설정
2. "Connection refused"
원인: 서버가 listen 상태가 아님, 포트가 다름
해결: 서버 실행 확인, 포트 번호 확인
3. recv()가 0 반환
의미: 상대방이 close()를 호출함 (FIN 수신)
처리: 연결 종료 처리 필요
4. send() 후 데이터 유실
원인: sendall() 대신 send() 사용
해결: sendall() 사용 또는 반환값 확인
5. "Broken pipe"
원인: 이미 닫힌 소켓에 쓰기
해결: recv()로 상대방 상태 확인 후 전송
6. 메시지가 중간에서 잘림
원인: TCP를 메시지 단위로 착각
해결: 길이 prefix, 구분자 등 프레이밍 규칙 추가다음 절에서는 여러 클라이언트를 동시에 처리하는 TCP 소켓 프로그래밍을 다루겠습니다.