안동민 개발노트

본문 시작

소켓 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가 반환한 소켓

listen 소켓과 accept가 반환한 소켓

서버의 세 소켓 역할accept는 listen 소켓을 바꾸지 않고 각 연결의 새 파일 디스크립터를 반환한다.server_fd새 연결을 수락하는 소켓client_fd A클라이언트 A와 송수신client_fd B클라이언트 B와 송수신accept Aaccept B
서버의 세 소켓 역할같은 listen 소켓에서 두 번 accept하면 연결별 디스크립터가 생긴다.server_fd새 연결을 수락client_fd AA와 송수신client_fd BB와 송수신server_fd는 그대로 유지

두 연결은 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 동작 때문에 끊긴 연결에 쓰면 오류 출력 전에 프로세스가 종료될 수도 있습니다.

echo_server.c
#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으로 작성하면 훨씬 간결해집니다.

echo_server.py
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)로 정확히 도착한다는 보장은 없습니다.

에코 서버처럼 받은 만큼 바로 돌려주는 예제는 단순하지만, 실제 프로토콜은 길이 필드, 구분자, 고정 크기 헤더 같은 프레이밍 규칙을 둬야 합니다.


클라이언트 구현

echo_client.py
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 소켓 프로그래밍을 다루겠습니다.