소켓 API 기본
TCP 소켓의 생성·bind·listen·accept·송수신·종료 생명주기를 익히고 C와 Python으로 에코 통신을 구현합니다.
지금까지 우리는 TCP가 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 연결을 종료하는 과정을 이론적으로 살펴보았습니다.
이제 그 이론을 코드로 직접 구현해 보겠습니다.
네트워크 프로그래밍의 출발점은 소켓(Socket)입니다.
소켓이란 무엇인가
소켓은 네트워크 통신의 끝점(endpoint)입니다.
프로세스가 네트워크를 통해 다른 프로세스와 데이터를 주고받으려면, 반드시 소켓을 통해야 합니다.
애플리케이션은 fd로 읽고 쓰지만 연결 상태와 버퍼는 커널 socket에 있다.
- ProcessProcess file descriptor
read , write , send , recv 호출
- Socket APISocket API 주소 체계, type
protocol을 선택하는 경계
- Kernel bufferKernel buffer 송수신 큐, 연결
상태, timeout 관리
- TCP/UDP/IPTCP/UDP/IP 전송
라우팅 프로토콜 처리
- NetworkNetwork 패킷
NIC와 외부망으로 나감
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 소켓 프로그래밍은 서버와 클라이언트가 서로 다른 흐름을 따릅니다.
서버는 요청을 받는 listen socket과 클라이언트별 connected socket을 분리해 수명과 close 책임을 다르게 관리한다.
- 1 socket
socket TCP fd 생성 아직 주소와 역할이 없다
- 2 bind
bind IP와 port 고정 서버가 받을 주소를 점유
- 3 listen
listen 대기열 생성 연결 요청을 queue에 둔다
- 4 accept
accept connected fd 반환 클라이언트별 통신 대상 생성
- 5 close
close 역할별 정리 listen과 connected fd를 따로 닫음
여기서 핵심은 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;
}같은 실패처럼 보여도 EOF, reset, timeout, 부분 전송은 버퍼 위치와 재시도 기준이 다르다.
- EOF 상대
정상 종료를 시작 남은 버퍼 처리 후 close
- RST 중간 또
상대가 연결을 리셋 요청 단위 재시도 가능성 확인
- Timeout 응답 없
경로 또는 상대 지연 backoff와 timeout 값을 분리
- EAGAIN non-blocking
지금은 읽을 수 없음 ready 이벤트까지 대기
- Partial I/O read/write
요청한 바이트 일부만 처리 offset을 보존하고 이어서 처리
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 소켓 프로그래밍을 다루겠습니다.
TCP 소켓은 생성하고 연결한 뒤 read/write를 반복하다가 half-close, reset, timeout까지 포함해 닫힌다.
- 11 Create
Create socket, bind 주소 체계와 port를 고정
- 22 Connect / Accept
Connect / Accept 상대와 연결 client connect 또는 server accept
- 33 Transfer
Transfer read/write loop 부분 전송과 backpressure 처리
- 44 Shutdown
Shutdown half/full close 읽기/쓰기 방향을 구분
- 55 Recover
Recover timeout/reset 재시도 또는 fd 정리
소켓 API 기본에서는 소켓 상태, 송수신 경계, 오류 반환, 재시도 조건을 확인합니다.
소켓 오류는 함수 이름만 외우면 놓치기 쉽습니다. listening socket과 connected socket을 분리하고, `send()`/`recv()` 반환값이 상태 신호라는 점을 함께 봐야 합니다.
- 동시 접속자 수가 아니라 accept 대기 큐다
backlog 동시 접속자 수가 아니라 accept 대기 큐다 완료된 연결이 애플리케이션에 수락되기 전 쌓이는 큐로 이해합니다.
- TCP에는 메시지 경계가 없다
framing TCP에는 메시지 경계가 없다 길이 prefix, 구분자, 고정 길이 같은 애플리케이션 규칙이 필요합니다.
- EINTR과 EAGAIN은 실패 의미가 다르다
retry EINTR과 EAGAIN은 실패 의미가 다르다 블로킹/논블로킹 모드에 따라 재시도 조건을 분리해야 합니다.