본문 바로가기

소켓 API 기본

운영체제가 프로그램에 주는 통신의 끝점인 소켓의 종류와 주소 체계를 정리하고, 서버와 클라이언트가 부르는 함수의 흐름과 리스닝 소켓, 연결 소켓의 관계를 봅니다. C로 쓴 에코 서버에서 부분 송신과 SIGPIPE를 다루는 방법, Python으로 같은 일을 하는 코드, 자주 만나는 소켓 오류를 함께 정리합니다.

지금까지 TCP와 UDP가 연결을 맺고, 데이터를 나르고, 연결을 닫는 과정을 보았습니다. 이 장에서는 그 동작을 프로그램에서 직접 다룹니다. 애플리케이션은 TCP 세그먼트를 직접 만들지 않습니다. 운영체제의 커널이 TCP와 UDP를 처리하고, 애플리케이션은 커널이 내주는 소켓(socket)이라는 창구로 데이터를 넣고 꺼낼 뿐입니다. 이 창구를 다루는 함수들이 소켓 API이며, 1980년대 BSD Unix에서 시작된 이 API는 지금도 거의 모든 운영체제와 언어의 네트워크 기능의 바탕입니다.

소켓은 커널이 주는 통신의 끝점

소켓은 프로그램이 통신에 쓰는 끝점입니다. Unix 계열 운영체제에서 소켓은 파일 디스크립터(file descriptor)라는 작은 정수로 나타나서, 파일처럼 read()와 write()로도 읽고 쓸 수 있습니다. 소켓을 만들 때는 주소 체계와 소켓의 종류를 고릅니다.

소켓의 종류IPv4와 IPv6에서 쓰는 프로토콜성질
SOCK_STREAMTCP연결이 있고, 바이트 스트림을 순서대로 빠짐없이 전한다
SOCK_DGRAMUDP연결이 없고, 데이터그램 단위로 보낸다
SOCK_RAWIP 위의 아무 프로토콜IP 헤더 바로 위를 직접 다룬다. ping이나 traceroute 같은 도구가 쓰고, 관리자 권한이 필요하다

주소 체계는 IPv4의 AF_INET, IPv6의 AF_INET6, 한 컴퓨터 안의 프로세스끼리 파일 경로로 통신하는 AF_UNIX 등이 있습니다. 소켓을 늘 IP 주소와 포트로 설명하지만, AF_UNIX 소켓처럼 네트워크를 거치지 않는 소켓도 같은 API를 씁니다. TCP 연결은 6장 1편에서 본 것처럼 로컬과 원격의 IP와 포트, 그리고 프로토콜로 구별됩니다.

서버와 클라이언트의 함수 흐름

TCP 서버와 클라이언트는 서로 다른 순서로 함수를 부릅니다.

함수부르는 쪽하는 일
socket()둘 다소켓을 만들고 파일 디스크립터를 돌려준다
bind()주로 서버소켓에 로컬 주소와 포트를 붙인다
listen()서버연결을 받을 수 있는 리스닝 소켓으로 바꾼다
accept()서버핸드셰이크를 마친 연결 하나를 꺼내 새 소켓으로 돌려준다
connect()클라이언트상대에게 연결을 맺는다. TCP라면 3-way 핸드셰이크가 일어난다
send(), recv()둘 다커널의 송신 버퍼에 데이터를 넣고, 수신 버퍼에서 꺼낸다
shutdown(), close()둘 다한 방향이나 연결 전체를 닫는다

TCP 소켓의 역할

accept는 연결별 소켓을 반환하고, listen 소켓은 유지합니다

같은 server_fd에서 accept를 반복하면 client_fd A·B처럼 연결별 소켓을 얻습니다. 분기선은 API 관계이며, 아래 에코 예제 자체는 accept를 한 번만 호출합니다.

서버의 세 소켓 역할accept는 listen 소켓을 바꾸지 않고 각 연결의 새 파일 디스크립터를 반환한다.server_fd새 연결을 수락하는 소켓client_fd A클라이언트 A와 송수신client_fd B클라이언트 B와 송수신accept Aaccept B
서버의 세 소켓 역할같은 listen 소켓에서 두 번 accept하면 A와 B의 연결별 디스크립터가 생깁니다. 두 화살표는 listener에서 각 connected socket을 얻는 API 관계이며 A가 B를 만드는 순서가 아닙니다. 아래 한 연결 에코 서버의 실행 관측도 아닙니다.server_fd새 연결을 수락client_fd AA와 송수신client_fd BB와 송수신accept Aaccept B같은 listener 유지두 API 관계를 비교

두 연결은 API 관계를 설명하는 예시입니다. 아래 에코 서버 원문은 accept를 한 번만 호출하므로 실제로 두 클라이언트를 처리하는 구현은 아닙니다.

핵심은 accept()입니다. 서버의 리스닝 소켓은 연결을 받는 창구로 남아 있고, accept()는 부를 때마다 연결 하나를 위한 새 소켓을 돌려줍니다. 클라이언트 A와 B가 접속하면 서버에는 리스닝 소켓 하나와 연결 소켓 둘이 생기고, A와 주고받는 데이터는 A의 연결 소켓으로만 오갑니다. 리스닝 소켓에 recv()를 부르는 실수를 하지 않으려면 이 구분을 기억해 둡니다.

listen()을 부른 뒤 핸드셰이크를 처리하는 것은 커널입니다. 클라이언트의 SYN에 SYN+ACK로 답하고 마지막 ACK를 받으면, 커널은 그 연결을 대기열에 넣어 두고, 애플리케이션이 accept()를 부를 때 하나씩 내줍니다. listen()의 두 번째 인자인 backlog는 이 대기열의 길이입니다. listen(2)에 따르면 Linux에서 이 값은 핸드셰이크를 마치고 accept()를 기다리는 연결의 수이며, net.core.somaxconn보다 크면 그 값으로 잘립니다. 동시에 처리할 수 있는 연결의 수가 아니라는 점을 기억해야 합니다.

C로 쓰는 에코 서버

에코 서버는 받은 데이터를 그대로 돌려보내는 서버로, 소켓 API의 흐름을 보기에 가장 단순한 예입니다. 다음은 Linux를 기준으로 한 연결 하나만 처리하는 에코 서버입니다.

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>

static void die(const char *what) {
    perror(what);
    exit(EXIT_FAILURE);
}

/* len바이트를 모두 보낼 때까지 send를 되풀이한다. */
static int send_all(int fd, const char *buf, size_t len) {
    size_t sent = 0;
    while (sent < len) {
        ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL);
        if (n < 0) {
            if (errno == EINTR) {
                continue;               /* 시그널로 끊겼을 뿐이니 남은 부분부터 다시 보낸다 */
            }
            return -1;                  /* EPIPE(상대가 닫음) 같은 오류 */
        }
        sent += (size_t)n;
    }
    return 0;
}

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) die("socket");

    int opt = 1;
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) die("setsockopt");

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   /* 모든 로컬 주소에서 받는다 */
    addr.sin_port = htons(8080);                /* 포트는 네트워크 바이트 순서로 */
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) die("bind");
    if (listen(server_fd, 128) < 0) die("listen");

    struct sockaddr_in peer;
    socklen_t peer_len = sizeof(peer);
    int client_fd = accept(server_fd, (struct sockaddr *)&peer, &peer_len);
    if (client_fd < 0) die("accept");

    char ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
    printf("connected: %s:%u\n", ip, (unsigned)ntohs(peer.sin_port));

    char buf[4096];
    for (;;) {
        ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
        if (n == 0) break;                      /* 상대가 보내기를 마쳤다(EOF) */
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("recv");
            break;
        }
        if (send_all(client_fd, buf, (size_t)n) < 0) {
            perror("send");
            break;
        }
    }
    close(client_fd);
    close(server_fd);
    return 0;
}

주소와 포트를 구조체에 넣을 때 htons()와 htonl()을 쓰는 것은, 네트워크는 큰 자리부터 보내는 빅 엔디언을 쓰는데 많은 CPU는 작은 자리부터 저장하는 리틀 엔디언이기 때문입니다. 8080을 그대로 넣으면 리틀 엔디언 기계에서는 다른 포트에 바인드됩니다. INADDR_ANY는 이 컴퓨터의 모든 주소로 오는 연결을 받겠다는 뜻이고, 특정 주소에서만 받으려면 그 주소를 inet_pton()으로 바꿔 넣습니다.

send_all()이 필요한 이유는 send()가 요청한 바이트를 다 보내지 못하고 일부만 커널에 넣은 채 돌아올 수 있기 때문입니다. 돌려준 값만큼 위치를 옮겨 남은 부분을 다시 보내고, 시그널 때문에 끊긴 EINTR은 오류가 아니므로 같은 위치부터 다시 시도합니다. MSG_NOSIGNAL은 상대가 이미 닫은 연결에 쓸 때 프로세스를 끝내 버리는 SIGPIPE 시그널을 보내지 말고 EPIPE 오류만 돌려 달라는 플래그입니다(send(2)). 이 플래그나 SIGPIPE를 무시하는 설정이 없으면, 클라이언트가 먼저 끊었을 때 서버가 오류 메시지 하나 남기지 않고 죽습니다. recv()가 0을 돌려주면 상대가 송신 방향을 닫았다는 뜻이므로 반복을 끝냅니다.

SO_REUSEADDR은 서버를 껐다 바로 다시 켤 때 6장 2편의 TIME-WAIT에 남은 예전 연결 때문에 bind()가 EADDRINUSE로 실패하는 일을 막습니다. 다른 프로세스가 같은 포트에서 이미 듣고 있다면 이 옵션이 있어도 바인드할 수 없으며, 여러 프로세스가 한 포트를 나누어 받으려면 Linux의 SO_REUSEPORT 같은 다른 옵션이 필요합니다.

더 깊이: 운영체제마다 뜻이 다른 SO_REUSEADDR와 SO_REUSEPORT

같은 이름의 옵션이 운영체제마다 다른 일을 합니다. socket(7)에 따르면 Linux의 SO_REUSEADDR는 그 주소에서 이미 듣고 있는 소켓이 있을 때를 빼고 바인드를 허락하는 옵션이라, 서버를 다시 켜는 용도로 써도 다른 서버의 포트를 빼앗지 않습니다. 그런데 Microsoft의 Winsock 문서에 따르면 Windows의 SO_REUSEADDR는 다른 소켓이 쓰고 있는 포트에도 억지로 바인드하게 하는 옵션이어서, 그 뒤로는 들어오는 연결을 어느 소켓이 받을지 정해지지 않으며 특별한 권한도 필요하지 않습니다.

그래서 Microsoft는 Windows의 서버 프로그램에 다른 프로그램이 포트를 가로채지 못하게 하는 SO_EXCLUSIVEADDRUSE를 켜라고 권합니다. Windows Server 2003부터는 처음 바인드한 소켓이 공유를 허락하지 않으면 다른 사용자 계정의 프로그램이 포트를 가로챌 수 없게 바뀌었지만, 하위 호환을 위해 같은 계정에는 예외를 남겨 두었습니다. 크로스 플랫폼 코드에서 이 옵션을 무심코 켜면 Linux와 Windows에서 전혀 다른 일이 일어날 수 있다는 뜻입니다.

Linux 3.9부터 있는 SO_REUSEPORT는 여러 소켓이 같은 주소와 포트에 함께 바인드하게 하는 옵션입니다. 첫 소켓을 포함한 모든 소켓이 바인드하기 전에 이 옵션을 켜야 하고, 포트를 가로채지 못하도록 유효 사용자 ID가 같은 프로세스끼리만 함께 쓸 수 있습니다. TCP라면 스레드마다 리스닝 소켓을 따로 두어 accept()를 고르게 나누고, UDP라면 들어오는 데이터그램을 여러 프로세스에 나누어 주는 데 씁니다.

Python으로 같은 일을

Python의 socket 모듈은 같은 함수들을 거의 그대로 감싸고 있어서 코드가 훨씬 짧아집니다. 다음 코드는 한 프로세스 안에서 에코 서버를 스레드로 띄우고, 클라이언트가 보낸 데이터를 모두 돌려받습니다.

import socket
import threading

def echo_once(listener):
    conn, _ = listener.accept()
    with conn:
        while data := conn.recv(1024):              # 빈 바이트열이 오면 상대가 보내기를 마쳤다
            conn.sendall(data)

listener = socket.create_server(("127.0.0.1", 0))   # socket, bind, listen을 한 번에
threading.Thread(target=echo_once, args=(listener,), daemon=True).start()

with socket.create_connection(listener.getsockname()) as client:
    client.sendall(b"Hello, Socket!")
    client.shutdown(socket.SHUT_WR)                 # 다 보냈다고 알린다
    received = b""
    while chunk := client.recv(1024):               # 서버가 닫을 때까지 모두 읽는다
        received += chunk
print("Received:", received)
listener.close()
Received: b'Hello, Socket!'

socket.create_server()는 소켓을 만들고 바인드하고 듣는 일을 한 번에 하며, POSIX에서는 SO_REUSEADDR도 켜 줍니다. 포트 0을 주면 운영체제가 빈 포트를 고릅니다. sendall()은 C의 send_all()처럼 모든 바이트를 보낼 때까지 되풀이하고, 실패하면 예외를 냅니다. 다만 예외가 났을 때 어디까지 보냈는지는 알 수 없으므로, 다시 보내도 되는지는 7장 2편에서 본 것처럼 애플리케이션이 판단해야 합니다.

클라이언트가 recv()를 한 번만 부르지 않고 빈 바이트열이 올 때까지 되풀이하는 것도 중요합니다. TCP는 바이트 스트림이므로, 서버가 14바이트를 한 번에 돌려보냈더라도 첫 recv()가 Hello 다섯 바이트만 돌려줄 수 있습니다. 여기서는 클라이언트가 shutdown()으로 다 보냈다고 알리고 서버가 연결을 닫는 것을 메시지의 끝으로 삼았지만, 연결 하나로 여러 메시지를 주고받는 실제 프로토콜은 다음 글에서 보는 길이 헤더 같은 규칙이 필요합니다. daemon=True는 스레드가 남아 있어도 프로그램이 끝날 수 있게 하는 설정입니다.

자주 만나는 소켓 오류

오류Python의 예외흔한 원인볼 것
EADDRINUSEOSError같은 포트를 이미 다른 소켓이 쓰고 있다ss -tlnp로 그 포트의 프로세스, SO_REUSEADDR
ECONNREFUSEDConnectionRefusedError그 포트에서 듣는 서버가 없어 RST가 왔다서버가 떠 있는지, 주소와 포트가 맞는지
ETIMEDOUTTimeoutError아무 답이 없다방화벽이 조용히 버리는지, 경로
ECONNRESETConnectionResetError상대가 RST로 연결을 끊었다상대가 비정상 종료했는지, 중간 장비의 유휴 시간
EPIPEBrokenPipeError이미 닫힌 연결에 썼다쓰기 오류의 처리, C라면 SIGPIPE
recv()가 0을 돌려줌빈 바이트열상대가 송신 방향을 닫았다(정상)오류가 아니라 EOF로 처리
메시지가 중간에서 잘림없음TCP를 메시지 단위로 착각했다길이 헤더나 구분자로 메시지를 나눈다

recv()가 0을 돌려주는 것은 오류가 아니라 상대가 "더 보낼 것이 없다"고 FIN을 보낸 것입니다. 이것을 오류로 다루어 연결을 갑자기 끊으면, 상대가 아직 기다리는 응답을 보내지 못하게 됩니다. 반대로 EPIPE와 ECONNRESET은 상대가 이미 사라졌다는 뜻이므로, 연결을 닫고 필요하면 다시 연결해야 합니다.

정리

  • 소켓은 커널이 프로그램에 주는 통신의 끝점이며, Unix 계열에서는 파일 디스크립터로 나타나고, 종류와 주소 체계를 골라 만듭니다.
  • 서버는 socket, bind, listen, accept의 순서로, 클라이언트는 socket, connect의 순서로 함수를 부르고, 둘 다 send와 recv로 주고받은 뒤 닫습니다.
  • accept()는 리스닝 소켓을 그대로 두고 연결마다 새 소켓을 돌려주며, listen()의 backlog는 accept()를 기다리는 연결의 수입니다.
  • send()는 일부만 보낼 수 있으므로 남은 부분을 되풀이해 보내고, EINTR은 다시 시도하며, MSG_NOSIGNAL로 SIGPIPE에 프로세스가 죽지 않게 합니다.
  • 주소와 포트는 htons(), htonl()로 네트워크 바이트 순서로 바꿔 넣습니다.
  • recv()의 0은 정상적인 EOF이고, ECONNREFUSED, ETIMEDOUT, ECONNRESET, EPIPE는 각각 다른 상황을 알려 주므로 나누어 처리합니다.

연습 문제

1. 부분 송신을 따라가기

본문의 send_all()에 7바이트를 넘겼습니다. 첫 send()는 3을 돌려주고, 둘째는 -1과 EINTR을, 다시 시도한 셋째는 남은 4를 돌려주었습니다. 각 호출 뒤 sent는 얼마이고, 둘째 호출 뒤에는 어느 위치부터 다시 보내나요?

해설 보기

sent는 처음 0에서 첫 호출 뒤 3이 되고, EINTR인 둘째 호출 뒤에도 3으로 그대로이며, 셋째 호출 뒤 7이 되어 반복이 끝납니다. 둘째 호출 뒤에는 buf + 3부터, 곧 아직 보내지 않은 네 번째 바이트부터 다시 보냅니다. 처음 3바이트를 다시 보내면 상대는 같은 데이터를 두 번 받게 됩니다. send()를 부른 횟수나 돌려준 값은 커널의 송신 버퍼에 들어간 양일 뿐, 네트워크로 나간 세그먼트의 수와는 관계가 없습니다.

2. recv를 한 번만 부르는 클라이언트

어느 에코 클라이언트가 sendall(b"Hello, Socket!") 뒤에 recv(1024)를 한 번만 부르고 그 결과를 출력한 뒤 연결을 닫습니다. 서버가 14바이트를 모두 돌려보냈는데 화면에 Hello만 찍혔다면, 서버가 나머지를 잃어버린 것인가요? 어떻게 고쳐야 하나요?

해설 보기

서버가 잃어버린 것이 아닙니다. TCP는 바이트 스트림이어서 recv()는 그 순간 수신 버퍼에 도착해 있던 만큼만 돌려줄 수 있고, 나머지 9바이트는 조금 늦게 도착했을 뿐입니다. 클라이언트가 한 번만 읽고 닫았기 때문에 나머지를 읽지 못했습니다. 기대하는 길이만큼 모일 때까지 recv()를 되풀이하거나, 본문처럼 상대가 닫을 때까지 읽거나, 메시지 앞에 길이를 붙여 그 길이만큼 읽도록 프로토콜을 정해야 합니다.

3. backlog의 뜻

Linux에서 net.core.somaxconn이 4096일 때, 서버가 listen(fd, 1024)를 부르면 대기열의 길이는 얼마인가요? listen(fd, 8192)라면 어떻게 되나요? backlog가 1024인 서버는 동시에 1024개의 연결만 처리할 수 있나요?

해설 보기

1024는 4096보다 작으므로 대기열의 길이는 1024이고, 8192는 4096으로 잘려 4096이 됩니다. backlog는 핸드셰이크를 마쳤지만 아직 accept()로 꺼내 가지 않은 연결의 수이므로, 동시에 처리하는 연결의 수와는 다릅니다. 이미 accept()로 꺼낸 연결은 이 대기열에 들어 있지 않으므로, 서버는 메모리와 파일 디스크립터가 허락하는 한 훨씬 많은 연결을 동시에 가질 수 있습니다. 대기열이 넘친다는 것은 애플리케이션이 accept()를 충분히 빨리 부르지 못한다는 신호입니다.

다음 글 TCP 소켓 프로그래밍에서는 길이 헤더로 메시지를 주고받는 프로토콜을 만들고, 여러 클라이언트를 프로세스와 스레드로 함께 처리하는 방법을 살펴봅니다.