icon

안동민 개발노트

11장 : 소켓 프로그래밍

소켓 API 기본


지금까지 우리는 TCP가 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 연결을 종료하는 과정을 이론적으로 살펴보았습니다. 이제 그 이론을 코드로 직접 구현해 보겠습니다. 네트워크 프로그래밍의 출발점은 소켓(Socket)입니다.


소켓이란 무엇인가

소켓은 네트워크 통신의 끝점(endpoint)입니다. 프로세스가 네트워크를 통해 다른 프로세스와 데이터를 주고받으려면, 반드시 소켓을 통해야 합니다.

소켓의 위치
애플리케이션 (웹 서버, 채팅 앱 등)

    │ socket(), bind(), listen(), accept()
    │ connect(), send(), recv()

┌──────────────┐
│   소켓 API   │  ← 유저 공간과 커널의 경계
└──────┬───────┘

┌──────────────┐
│  TCP / UDP   │  전송 계층
├──────────────┤
│     IP       │  네트워크 계층
├──────────────┤
│  이더넷/WiFi │  데이터 링크 계층
└──────────────┘

소켓 = 파일 디스크립터
  → 유닉스의 "모든 것은 파일이다" 철학
  → 파일처럼 열고(open), 읽고(recv), 쓰고(send), 닫을(close) 수 있음

6장에서 소켓을 IP 주소 + 포트 번호 + 프로토콜의 조합으로 정의했습니다. 서버 소켓 (TCP, 192.168.1.10, 80)과 클라이언트 소켓 (TCP, 10.0.0.5, 52431)이 쌍을 이루어 하나의 연결을 식별합니다.

소켓 유형
유형           상수            프로토콜   특징
──────────────────────────────────────────────────
STREAM        SOCK_STREAM     TCP       연결 지향, 바이트 스트림
DATAGRAM      SOCK_DGRAM      UDP       비연결, 데이터그램
RAW           SOCK_RAW        IP 직접   프로토콜 직접 구현 (ping 등)

TCP 소켓의 생명주기

TCP 소켓 프로그래밍은 서버와 클라이언트가 서로 다른 흐름을 따릅니다.

TCP 소켓 흐름 (서버 vs 클라이언트)
서버                                     클라이언트
  │                                         │
  │ socket(AF_INET, SOCK_STREAM, 0)         │ socket()
  ▼                                         │
  │ bind(IP, Port)                          │
  ▼                                         │
  │ listen(backlog)                         │
  ▼                                         │
  │ accept() ←──── 3-way handshake ────── connect()
  ▼          → 새로운 소켓 반환             ▼
  │                                         │
  │ recv() ←───────── 데이터 ──────────── send()
  │ send() ────────── 데이터 ───────────→ recv()
  │                (양방향 반복)            │
  │                                         │
  │ close() ←──── 4-way handshake ────── close()
  ▼                                         ▼

핵심: accept()는 새로운 소켓을 반환!
  리스닝 소켓 (server_fd): 포트 8080에서 대기 지속
  연결 소켓 (client_fd):   해당 클라이언트 전용 통신

여기서 핵심은 accept()의 동작입니다. 서버가 listen()으로 대기하다가 클라이언트의 connect()를 받으면, accept()새로운 소켓을 반환합니다. 원래의 소켓은 여전히 다른 클라이언트의 연결을 기다리고, 새 소켓으로 해당 클라이언트와 통신합니다.

API 함수역할TCP 동작
socket()소켓 생성파일 디스크립터 할당
bind()주소/포트 바인딩서버의 수신 주소 설정
listen()연결 대기 시작backlog 큐 생성
accept()연결 수락3-way handshake 완료된 연결 꺼냄
connect()연결 요청SYN → SYN-ACK → ACK
send()데이터 전송TCP 세그먼트로 분할/전송
recv()데이터 수신수신 버퍼에서 데이터 복사
close()연결 종료FIN → ACK → FIN → ACK

C로 구현하는 에코 서버

에코 서버는 클라이언트가 보낸 데이터를 그대로 다시 돌려보내는 서버입니다. 소켓 프로그래밍의 Hello World라고 할 수 있습니다.

echo_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[1024];

    // 1. 소켓 생성
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // SO_REUSEADDR: TIME_WAIT 포트 재사용
    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);
    }
    printf("Client connected: %s:%d\n",
           inet_ntoa(client_addr.sin_addr),
           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(client_fd, buffer, bytes_read, 0);
    }

    // 6. 종료
    close(client_fd);
    close(server_fd);
    return 0;
}
주요 상수와 함수 정리
AF_INET        → IPv4 주소 체계
AF_INET6       → IPv6 주소 체계
SOCK_STREAM    → TCP (바이트 스트림)
SOCK_DGRAM     → UDP (데이터그램)

htons()  → Host TO Network Short (16비트, 포트)
htonl()  → Host TO Network Long  (32비트, IP)
ntohs()  → Network TO Host Short
ntohl()  → Network TO Host Long

INADDR_ANY     → 0.0.0.0 (모든 인터페이스)
inet_ntoa()    → 정수 IP → 문자열 ("192.168.1.1")
inet_addr()    → 문자열 → 정수 IP
바이트 순서 (Endianness)
숫자 0x1234를 메모리에 저장

리틀 엔디언 (x86, ARM):     빅 엔디언 (네트워크 표준):
  주소 0: 0x34 (하위)         주소 0: 0x12 (상위)
  주소 1: 0x12 (상위)         주소 1: 0x34 (하위)

포트 8080 = 0x1F90
  htons(0x1F90) → 0x901F (리틀 → 빅 변환)
  네트워크로 전송할 때는 반드시 빅 엔디언으로!

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 에러를 방지합니다. 이전 연결의 TIME_WAIT 상태 때문에 같은 포트를 바로 재사용할 수 없는 문제를 해결합니다.

send()sendall()의 차이도 중요합니다. send()는 일부만 보낼 수 있지만, sendall()은 모든 데이터가 전송될 때까지 반복합니다.


클라이언트 구현

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()가 필요 없습니다. 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()로 상대방 상태 확인 후 전송

다음 절에서는 여러 클라이언트를 동시에 처리하는 TCP 소켓 프로그래밍을 다루겠습니다.

목차