Tech </> tech.log
· - ·
PythonNetworkWebNormal

[Python][중급] Socket 라이브러리

Socket 라이브러리 완전해체분석

소켓이 대체 뭔데?

Python 이든, Java,NodeJS 이든 뭐든 웹 개발을 하면서 공통적으로 사용되는 “소켓”이라고 하는게 있다. 이것에 대한 기본적인 개념도 모르면서 웹 프로그래밍을 한다고?

넌 소켓으로 좀 맞자 오늘 우리는 소켓에 대해서 기본적인 개념을, 그리고 애플리케이션 레이어인 파이썬을 예시로 알아보자.


이 글을 왜 써야 했나

import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Python 문서를 보면 "소켓을 생성한다"고 설명합니다. 근데 소켓이 뭔데?

네트워크 통신의 끝점이라고요? 그래서 그게 뭐냐니까???

이 글은 이러한 질문들에 답하기 위해 씁니다. 소켓이 실제로 무엇인지, 컴퓨터 내부에서 어떤 일이 일어나는지, 비유 없이 있는 그대로 설명합니다.


데이터는 어떻게 이동하나

1. 보내는 쪽 (송신)

Python에서 send()를 호출하면 데이터는 애플리케이션에서 인터넷으로 내려가게 됩니다.

2. 받는 쪽 (수신)

반대로, recv() 를 호출하면 데이터가 인터넷으로부터 애플리케이션 영역으로 올라가게 됩니다.

핵심 포인트

여기서 중요한 건 Python은 맨 위에서 "보내줘", "받아줘"만 요청한다는 겁니다.

실제로 데이터를 자르고, 번호 붙이고, 주소 붙이고, 전기 신호로 바꾸는 건 전부 운영체제(커널)와 하드웨어가 합니다.

그리고 Python과 커널 사이의 접점이 바로 소켓입니다.


아오!!! 그래서 소켓의 정체가 뭐냐니까?

어어…진정해 진정해. 천천히 알아보자고.

소켓은 그냥 단순 "번호표"

소켓을 만들면 운영체제가 번호 하나를 주는데, 이걸 파일 디스크립터(File Descriptor)라고 부릅니다.

import socket
sock = socket.socket()
print(sock.fileno())  # 예: 3

3이라는 숫자가 소켓의 본체입니다. Python의 socket 객체는 이 번호를 예쁘게 감싸놓은 것뿐..이랄까?

뭐…? 단순 "번호"라고?

운영체제는 프로그램이 여는 모든 것에 번호를 붙여.

번호 대상
0 키보드 입력 (stdin)
1 화면 출력 (stdout)
2 에러 출력 (stderr)
3 내가 만든 소켓
4 내가 연 파일
5 또 다른 소켓

프로그램 입장에서는 "3번에 데이터 써줘"라고 하면 되. 그게 소켓이든 파일이든 운영체제가 알아서 처리하니까.

이게 Unix의 유명한 철학인 "모든 것은 파일이다" 그렇기에 네트워크 연결도 파일처럼 읽고 쓸 수 있게 만든 거죠.

소켓 뒤에 숨겨진 것들

근데 번호 하나 뒤에 실제로는 엄청난 게 숨어있습니다..!

socket.socket() 한 줄에 커널 내부에서 이 모든 게 할당됩니다.


데이터는 어떻게 전달되나

소켓에서 또 중요한 개념 중 하나가 버퍼가 있지 이것도 알아보자.

송신 버퍼

send()를 호출한다고 데이터가 바로 네트워크로 나가지 않아요

send()가 반환됐다 = 송신 버퍼에 복사됐다

실제로 상대방에게 도착했다는 뜻이 아닙니다!

수신 버퍼

recv()는 수신 버퍼에서 데이터를 가져오는 것

네트워크에서 직접 읽는 게 아닙니다!

자.. 여기서 만약 버퍼가 꽉 차면?

송신 버퍼가 꽉 찬 경우

  • send()가 블로킹됨 (공간이 생길 때까지 대기)
    • 진짜 그 작업 스레드가 OS 스케줄러에 의해 대기(Waiting) 상태가 되버려! 걸린 함수에서 제어권을 파이썬 인터프리터에게 돌려주지 않는다!
  • 논블로킹 모드면 일부만 보내고 반환

수신 버퍼가 꽉 찬 경우:

  • 커널이 상대방에게 "그만 보내!"라고 알림
  • 상대방은 전송을 멈춤 (흐름 제어)

그래서 흐름 제어(Flow Control) 라고 하는 메커니즘이 TCP 안에 들어가 있어요. 수신측이 송신측에 “나 지금 이만큼은 더 받을 수 있어” 라고 수신 윈도우(Receive Window) 버퍼를 이용하게 된답니다.

바로 블로킹 현상이 이 흐름 제어 때문에 발생하는 거랍니다.

그래서, 보통 OS 레벨의 TCP 스택이 알아서 다 해주지만, 프로그래머는 다음 사항을 고려해야 합니다.

  1. 적절한 버퍼 사이즈: recv(1024)처럼 너무 작게 잡으면 시스템 콜이 잦아져 성능이 떨어질 수 있습니다.
  2. 수신 지연 금지: 수신측에서 데이터를 받아놓고 무거운 연산을 하느라 recv()를 안 부르면, 결국 송신측까지 다 멈춰버립니다. (데이터 수신과 로직 처리를 분리하는 이유입니다.)
  3. 윈도우 제로(Window Zero): 상대방 윈도우가 0이 되면 내 소켓은 아무것도 못 보냅니다. 이때 타임아웃 처리를 어떻게 할지 고민해야 합니다.

흐름 제어는 TCP 소켓이 데이터의 무결성과 신뢰성을 보장하기 위해 브레이크를 거는 장치입니다.


난 동시에 여러 소켓을 처리하고 싶은데?

블로킹

기본적으로 accept()recv()블로킹입니다:

client, addr = server.accept()  # 연결 올 때까지 멈춤
data = client.recv(1024)        # 데이터 올 때까지 멈춤

하나의 클라이언트를 처리하는 동안 다른 클라이언트는 기다려야 합니다.

해결책들

방법 설명 장단점
멀티스레딩 연결마다 스레드 생성 간단하지만 많은 연결에 비효율
멀티프로세싱 연결마다 프로세스 생성 안정적이지만 무거움
I/O 멀티플렉싱 하나의 스레드에서 여러 소켓 감시 효율적이지만 복잡

어 근데 멀티스레딩.. 멀티프로세싱은 감이 잡히는데 멀티플렉싱?? 이건 뭐지..? 바로 알아보자.

I/O 멀티플렉싱: select vs epoll

여러 소켓을 동시에 감시하는 방법:

비교 select epoll
소켓 등록 매번 전체 복사 한 번만 등록
확인 방식 전체 순회 O(n) 이벤트 기반 O(1)
소켓 수 제한 1024개 사실상 무제한

10,000개 연결이 있고 그중 10개에 이벤트가 발생했다면:

  • select: 10,000개 다 확인
  • epoll: 10개만 반환

이게 바로 Node.js나 nginx 같은 고성능 서버의 비결입니다.


Python socket 라이브러리와 커널의 연결

Python의 socket 라이브러리는 결국 운영체제의 시스템 콜을 호출하는 얇은 래퍼입니다. 각 메서드가 어떤 시스템 콜로 연결되는지 봅시다.

메서드 → 시스템 콜 매핑

Python socket 객체는 이 시스템 콜들을 호출하고, 에러를 Python 예외로 바꿔주는 역할만 합니다.

CPython 내부 구조

CPython 소스코드에서 socket 모듈은 Modules/socketmodule.c에 구현되어 있습니다

실제 CPython 코드 일부를 보면

// Modules/socketmodule.c (단순화)

static PyObject *
sock_connect(PySocketSockObject *s, PyObject *addro)
{
    sock_addr_t addrbuf;
    int res;
    
    // Python 튜플 ('127.0.0.1', 8080)을 sockaddr 구조체로 변환
    getsockaddrarg(s, addro, &addrbuf, &addrlen);
    
    // 시스템 콜 호출
    res = connect(s->sock_fd, &addrbuf, addrlen);
    
    // 에러 처리
    if (res < 0)
        return PyErr_SetFromErrno(PyExc_OSError);
    
    Py_RETURN_NONE;
}

핵심은 connect(s->sock_fd, ...) - 결국 C의 connect() 함수를 호출하고, 이게 시스템 콜로 이어집니다.

strace로 실제 시스템 콜 확인하기

Python이 정말로 시스템 콜을 호출하는지 직접 확인할 수 있습니다

# test_socket.py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('93.184.216.34', 80))  # example.com
sock.send(b'GET / HTTP/1.0\r\n\r\n')
print(sock.recv(1024))
sock.close()
$ strace -e trace=network python test_socket.py
#출력
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = 0
sendto(3, "GET / HTTP/1.0\r\n\r\n", 18, 0, NULL, 0) = 18
recvfrom(3, "HTTP/1.0 200 OK\r\n...", 1024, 0, NULL, NULL) = 1024
close(3)                                = 0

Python 코드와 시스템 콜이 1:1로 대응되는 걸 볼 수 있습니다:

Python strace 출력
socket.socket() socket(AF_INET, SOCK_STREAM, ...) = 3
sock.connect() connect(3, {sin_port=80, ...})
sock.send() sendto(3, "GET / ...", 18, ...)
sock.recv() recvfrom(3, ..., 1024, ...)
sock.close() close(3)

파일 디스크립터의 실체

socket() 시스템 콜이 반환한 3이라는 숫자. 이게 프로세스 내에서 어떻게 관리되는지

/proc에서 직접 확인:

# Python 프로세스 PID가 12345라면
$ ls -la /proc/12345/fd/
lrwx------ 1 user user 0 Jan 1 00:00 0 -> /dev/pts/0
lrwx------ 1 user user 0 Jan 1 00:00 1 -> /dev/pts/0
lrwx------ 1 user user 0 Jan 1 00:00 2 -> /dev/pts/0
lrwx------ 1 user user 0 Jan 1 00:00 3 -> socket:[123456]

$ cat /proc/12345/fdinfo/3
pos:    0
flags:  02
mnt_id: 9

socket:[123456]에서 123456은 커널 내부의 소켓 inode 번호입니다.

소켓 옵션도 … select/epoll도 모두 시스템 콜이다!

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)

이것도 결국..

setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
getsockopt(3, SOL_SOCKET, SO_SNDBUF, [131072], [4]) = 0

커널의 소켓 구조체 내부 값을 직접 읽고 쓰는 시스템 콜입니다.

import select
readable, _, _ = select.select([sock1, sock2], [], [], timeout)
select(4, [3], [], [], {tv_sec=5, tv_usec=0}) = 1
import selectors
sel = selectors.DefaultSelector()  # Linux에서는 epoll 사용
sel.register(sock, selectors.EVENT_READ)
events = sel.select(timeout=5)
epoll_create1(EPOLL_CLOEXEC) = 4
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN, {u32=3, u64=3}}) = 0
epoll_wait(4, [{EPOLLIN, {u32=3, u64=3}}], 1, 5000) = 1

Python의 selectors 모듈은 OS에 따라 가장 효율적인 방식(Linux면 epoll, macOS면 kqueue)을 자동 선택합니다.

결론..

소켓은 번호 하나(파일 디스크립터) + 그 뒤의 커널 자료구조라고 부를 수 있겠다!