소켓 프로그래밍 HOWTO
소켓 프로그래밍 HOWTO
이 문서에서는 INET(즉 IPv4) 소켓만 다룰 거예요. 실제로 쓰이는 소켓의 99% 이상이 INET 소켓이니까요. 그리고 STREAM(즉 TCP) 소켓만 다룰게요. 정말로 무엇을 하고 있는 게 아니라면, STREAM 소켓이 다른 어떤 것보다 더 나은 동작과 성능을 주거든요. 소켓이 정확히 무엇인지에 대한 수수께끼를 풀어 드리고, 블로킹·논블로킹 소켓을 다루는 몇 가지 힌트도 드릴게요. 다만 블로킹 소켓부터 시작할게요. 논블로킹 소켓을 다루려면 먼저 블로킹 소켓이 어떻게 동작하는지 알아야 하니까요.
이걸 이해하기 어려운 이유 중 하나는 '소켓'이라는 말이 문맥에 따라 미묘하게 다른 여러 가지를 뜻할 수 있기 때문이에요. 그래서 먼저 '클라이언트' 소켓 — 대화의 한쪽 끝점 — 과 '서버' 소켓 — 마치 교환대의 교환원처럼 동작하는 것 — 을 구분해 볼게요. 클라이언트 애플리케이션(예를 들면 브라우저)은 '클라이언트' 소켓만 사용해요. 그 브라우저와 대화하는 웹 서버는 '서버' 소켓과 '클라이언트' 소켓을 둘 다 사용하죠.
출처: Python 공식 문서
역사
여러 형태의 IPC 중에서 소켓은 단연 가장 인기가 많아요. 어떤 플랫폼에서든 더 빠른 IPC 형태가 있을 수 있지만, 크로스 플랫폼 통신에 있어 소켓만큼은 거의 유일한 선택지예요.
소켓은 Berkeley에서 Unix의 BSD 계열의 일부로 발명됐어요. 인터넷과 함께 불처럼 퍼져 나갔죠. 이유가 있었어요. 소켓과 INET의 조합은 세계 어느 머신과든 대화하는 것을 믿을 수 없을 만큼 쉽게 만들어 주거든요(적어도 다른 방식에 비해서는요).
소켓 만들기
대략적으로 말하면, 이 페이지로 이끈 링크를 클릭했을 때 브라우저는 다음과 같은 일을 했어요.
# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))
connect가 완료되면 소켓 s를 써서 페이지 텍스트에 대한 요청을 보낼 수 있어요. 같은 소켓이 응답을 읽은 다음 파괴되죠. 맞아요, 파괴됩니다. 클라이언트 소켓은 보통 한 번의 교환(또는 작은 일련의 순차 교환)에만 쓰여요.
웹 서버 쪽에서 일어나는 일은 조금 더 복잡해요. 먼저 웹 서버가 '서버 소켓'을 만들어요.
# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)
몇 가지 알아둘 점이 있어요. 우리는 socket.gethostname()을 썼어요. 그래야 소켓이 외부 세계에 보이거든요. s.bind(('localhost', 80))이나 s.bind(('127.0.0.1', 80))을 썼다면 여전히 '서버' 소켓이겠지만, 같은 머신 안에서만 보이는 소켓이 됐을 거예요. s.bind(('', 80))은 그 머신이 가진 어떤 주소로든 도달할 수 있도록 지정해요.
두 번째로 알아둘 점은, 낮은 번호의 포트는 보통 '잘 알려진' 서비스(HTTP, SNMP 등)를 위해 예약돼 있다는 거예요. 실험해 본다면 꽤 큰 숫자(4자리)를 쓰세요.
마지막으로, listen의 인자는 소켓 라이브러리에 외부 연결을 거절하기 전에 connect 요청을 최대 5개(일반 최댓값)까지 대기열에 세우라고 알려줘요. 나머지 코드를 제대로 작성했다면 그것으로 충분해요.
이제 80번 포트에서 듣고 있는 '서버' 소켓이 있으니, 웹 서버의 메인루프에 들어갈 수 있어요.
while True:
# accept connections from outside
(clientsocket, address) = serversocket.accept()
# now do something with the clientsocket
# in this case, we'll pretend this is a threaded server
ct = make_client_thread(clientsocket)
ct.start()
사실 이 루프가 동작하는 방식은 세 가지가 있어요. clientsocket을 처리할 스레드를 파견하거나, clientsocket을 처리할 새 프로세스를 만들거나, 아니면 이 앱을 논블로킹 소켓으로 재구성해서 select로 '서버' 소켓과 활성 clientsocket 사이를 멀티플렉싱 하는 거죠. 이에 대해선 나중에 더 다룰게요. 지금 이해해야 할 핵심은 이거예요. 이 모든 것이 '서버' 소켓이 하는 전부라는 것. 데이터를 보내지도, 받지도 않아요. 그냥 '클라이언트' 소켓을 만들어 내죠. 각 clientsocket은 우리가 바인딩한 호스트와 포트에 어떤 다른 '클라이언트' 소켓이 connect()하는 것에 대한 응답으로 만들어져요. clientsocket을 만들자마자 다시 연결을 기다리러 돌아가요. 두 '클라이언트'는 자유롭게 대화할 수 있어요. 그들은 동적으로 할당된 포트를 사용하고 있는데, 그 포트는 대화가 끝나면 재활용돼요.
IPC
한 머신 위의 두 프로세스 사이에 빠른 IPC가 필요하다면 파이프나 공유 메모리를 알아보는 게 좋아요. AF_INET 소켓을 쓰기로 정했다면, '서버' 소켓을 'localhost'에 바인딩하세요. 대부분의 플랫폼에서 이렇게 하면 몇 겹의 네트워크 코드를 건너뛰는 지름길을 타서 훨씬 빨라져요.
참고
multiprocessing모듈은 크로스 플랫폼 IPC를 더 높은 수준의 API로 통합해요.
소켓 사용하기
먼저 알아둘 점은, 웹 브라우저의 '클라이언트' 소켓과 웹 서버의 '클라이언트' 소켓이 똑같은 존재라는 거예요. 즉 이건 '피어 투 피어'(peer to peer) 대화예요. 다른 말로 하면, 설계자로서 당신이 대화의 예절 규칙이 무엇인지 정해야 해요. 보통 연결하는 소켓이 요청이나 로그온을 보내면서 대화를 시작해요. 하지만 그건 설계 결정이지, 소켓의 규칙은 아니에요.
이제 통신에 쓰는 동사 두 가지 세트가 있어요. send와 recv를 쓸 수도 있고, 클라이언트 소켓을 파일과 같은 존재로 변환해서 read와 write를 쓸 수도 있어요. 후자는 Java가 소켓을 제공하는 방식이에요. 여기서 자세히 다루진 않을게요. 다만 소켓에서 flush를 써야 한다는 것만 경고할게요. 이것들은 버퍼링된 '파일'이라서, 뭔가를 쓰고 나서 응답을 읽는 흔한 실수를 하면 영원히 응답을 기다릴 수 있어요. flush가 없다면 요청이 여전히 출력 버퍼에 남아 있을 수 있으니까요.
이제 소켓의 가장 큰 걸림돌에 도달했어요. send와 recv는 네트워크 버퍼에서 동작해요. 그것들은 당신이 건네준(또는 기대한) 모든 바이트를 반드시 처리하지는 않아요. 주된 관심사가 네트워크 버퍼를 처리하는 것이기 때문이죠. 일반적으로 관련된 네트워크 버퍼가 채워지면(send) 또는 비워지면(recv) 반환해요. 그리고 처리한 바이트 수를 알려줘요. 메시지를 완전히 처리할 때까지 다시 호출하는 건 당신 책임이에요.
recv가 0바이트를 반환하면, 상대방이 연결을 닫았거나(또는 닫는 중)이라는 뜻이에요. 이 연결에서는 더 이상 데이터를 받지 못해요. 절대로. 데이터를 성공적으로 보낼 수도 있는데, 이에 대해선 나중에 더 이야기할게요.
HTTP 같은 프로토콜은 소켓을 한 번의 전송에만 사용해요. 클라이언트가 요청을 보내고 응답을 읽죠. 그게 전부예요. 소켓은 폐기돼요. 이 말은 클라이언트가 0바이트를 받아서 응답의 끝을 감지할 수 있다는 뜻이에요.
하지만 소켓을 추가 전송에 재사용할 계획이라면, 소켓에는 EOT(문서의 끝)가 없다는 점을 깨달아야 해요. 반복할게요. 소켓 send나 recv가 0바이트를 처리한 뒤 반환하면 연결이 끊어진 거예요. 연결이 끊어지지 않았다면 recv에서 영원히 기다릴 수 있어요. 소켓은 더 읽을 것이 없다는 걸 (당분간) 알려주지 않거든요. 이걸 좀 생각해 보면 소켓의 근본적인 진리를 깨닫게 돼요. 메시지는 (으, 싫지만) 고정 길이여야 하거나, 구분 기호로 구분되거나, 길이를 알려주거나(훨씬 나음), 연결을 끊어서 끝내야 해요. 선택은 전적으로 당신 몫이에요(단, 어떤 방식이 다른 것보다 더 옳긴 해요).
연결을 끝내고 싶지 않다고 가정하면, 가장 간단한 해법은 고정 길이 메시지예요.
class MySocket:
"""demonstration class only
- coded for clarity, not efficiency
"""
def __init__(self, sock=None):
if sock is None:
self.sock = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
else:
self.sock = sock
def connect(self, host, port):
self.sock.connect((host, port))
def mysend(self, msg):
totalsent = 0
while totalsent < MSGLEN:
sent = self.sock.send(msg[totalsent:])
if sent == 0:
raise RuntimeError("socket connection broken")
totalsent = totalsent + sent
def myreceive(self):
chunks = []
bytes_recd = 0
while bytes_recd < MSGLEN:
chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
if chunk == b'':
raise RuntimeError("socket connection broken")
chunks.append(chunk)
bytes_recd = bytes_recd + len(chunk)
return b''.join(chunks)
여기 보내는 코드는 거의 모든 메시징 방식에 쓸 수 있어요. Python에서는 문자열을 보내고, len()으로 그 길이를 알 수 있으니까요(임베디드 \0 문자가 있어도요). 주로 받는 쪽 코드가 더 복잡해져요. (C에서도 별반 다르지 않아요. 다만 메시지에 임베디드 \0이 있으면 strlen을 쓸 수 없을 뿐이에요.)
가장 쉬운 개선은 메시지의 첫 번째 문자를 메시지 타입의 표시자로 만들고, 그 타입이 길이를 결정하게 하는 거예요. 이제 recv가 두 개 생겼어요. 첫 번째는 (최소한) 그 첫 문자를 받아 길이를 조회하고, 두 번째는 나머지를 받는 루프예요. 구분 기호 방식을 쓰기로 했다면, 임의의 청크 크기(4096이나 8192가 네트워크 버퍼 크기와 자주 잘 맞아요)로 받으면서 받은 것에서 구분 기호를 찾아야 해요.
알아둬야 할 합병증 하나. 대화 프로토콜이 (어떤 응답도 없이) 메시지를 연속으로 여러 개 보내는 걸 허용하고, recv에 임의의 청크 크기를 넘긴다면, 다음 메시지의 시작 부분까지 읽어 버릴 수 있어요. 그걸 옆에 치워 두고 필요할 때까지 들고 있어야 해요.
메시지 앞에 길이를 붙이는 것(예: 5개의 숫자 문자로)은 더 복잡해져요. 왜냐하면 — 믿기 어렵겠지만 — 한 번의 recv로 5개 문자를 전부 못 받을 수도 있기 때문이에요. 실험하다 보면 그냥 넘어갈 수 있지만, 네트워크 부하가 높으면 두 개의 recv 루프를 쓰지 않는 한 코드가 아주 빨리 깨져요. 첫 번째는 길이를 결정하고 두 번째는 메시지의 데이터 부분을 받는 거죠. 골치 아파요. 그리고 이쯤에서 send도 한 번에 항상 모든 것을 처리하지 못한다는 걸 발견하게 돼요. 그리고 이걸 읽었음에도, 결국 덜컥 걸릴 거예요!
지면을 고려해서, (그리고 제 경쟁적 위치를 지키려고) 이런 개선들은 독자의 연습 문제로 남겨둘게요. 이제 정리하는 것으로 넘어가죠.
이진 데이터
소켓을 통해 이진 데이터를 보내는 것은 완전히 가능해요. 가장 큰 문제는 모든 머신이 이진 데이터에 같은 포맷을 쓰지 않는다는 거예요. 예를 들어 네트워크 바이트 순서는 빅 엔디언이라 가장 큰 바이트가 먼저 와요. 그래서 값이 1인 16비트 정수는 두 개의 16진 바이트 00 01이 돼요. 하지만 대부분의 흔한 프로세서(x86/AMD64, ARM, RISC-V)는 리틀 엔디언이라 가장 작은 바이트가 먼저 와요. 같은 1이 01 00이 되죠.
소켓 라이브러리는 16·32비트 정수를 변환하는 호출이 있어요. ntohl, htonl, ntohs, htons인데, 'n'은 네트워크, 'h'는 호스트, 's'는 short, 'l'은 long을 뜻해요. 네트워크 순서가 호스트 순서와 같으면 이들은 아무 것도 안 하지만, 머신이 바이트가 뒤집힌 경우엔 바이트를 적절히 맞바꿔요.
64비트 머신 시대가 되면서, 이진 데이터의 ASCII 표현이 이진 표현보다 자주 더 작아졌어요. 그 이유는 놀라울 만큼 자주 대부분의 정수가 값 0이나 어쩌면 1이기 때문이에요. 문자열 "0"은 2바이트인 반면, 완전한 64비트 정수는 8바이트예요. 물론 이건 고정 길이 메시지와 잘 맞지 않아요. 결정, 결정.
연결 끊기
엄밀히 말하면, 소켓을 close하기 전에 shutdown을 써야 해요. shutdown은 상대쪽 소켓에 대한 권고예요. 넘기는 인자에 따라 "더 보내지 않지만 계속 들을게" 또는 "난 듣지 않을 거야, 잘 가!"를 뜻할 수 있어요. 하지만 대부분의 소켓 라이브러리는 프로그래머가 이 예절을 무시하는 데 너무 익숙해서, 보통 close가 shutdown(); close()와 같아요. 그래서 대부분의 상황에서 명시적인 shutdown은 필요 없어요.
shutdown을 효과적으로 쓰는 한 가지 방법은 HTTP 같은 교환에서예요. 클라이언트가 요청을 보낸 다음 shutdown(1)을 해요. 이것은 서버에 "이 클라이언트는 보내기를 끝냈지만 받을 수는 있어요"라고 알려줘요. 서버는 0바이트를 받아서 'EOF'를 감지할 수 있어요. 요청을 완전히 받았다고 가정할 수 있죠. 서버가 응답을 보내요. send가 성공적으로 완료되면, 실제로 클라이언트가 여전히 받고 있었던 거예요.
Python은 자동 shutdown을 한 단계 더 발전시켜, 소켓이 garbage collect 되면 필요하다면 자동으로 close를 한다고 말해요. 하지만 이에 의존하는 건 아주 나쁜 습관이에요. 소켓이 close 없이 그냥 사라지면 상대쪽 소켓은 당신이 그저 느리다고 생각하고 무한정 멈춰 있을 수 있어요. 끝났으면 소켓을 close 하세요.
소켓이 죽을 때
블로킹 소켓을 쓰는 것에서 아마 가장 최악인 것은, 상대방이 (close도 없이) 냅다 내려왔을 때 벌어지는 일이에요. 당신의 소켓은 아마 멈춰 버릴 거예요. TCP는 신뢰성 있는 프로토콜이라, 연결을 포기하기 전에 아주 아주 오래 기다려요. 스레드를 쓰고 있다면, 스레드 전체가 사실상 죽은 것이나 마찬가지예요. 이에 대해 할 수 있는 건 많지 않아요. 블로킹 읽기를 하는 동안 락을 잡는 것 같은 어리석은 짓만 하지 않는다면, 그 스레드는 자원을 거의 소비하지 않아요. 스레드를 죽이려 들지 마세요. 스레드가 프로세스보다 효율적인 이유 중 하나는 자원의 자동 재활용과 관련된 오버헤드를 피하기 때문이에요. 다른 말로, 스레드를 죽이는 데 성공한다면 전체 프로세스가 망가질 가능성이 커요.
논블로킹 소켓
앞을 이해했다면, 소켓을 쓰는 대부분의 역학을 이미 알게 된 거예요. 여전히 같은 호출을, 거의 같은 방식으로 쓰게 될 거예요. 다만 제대로 한다면 앱이 거의 뒤집힌 모양이 될 뿐이에요.
Python에서는 socket.setblocking(False)로 논블로킹으로 만들어요. C에서는 더 복잡해요(우선 BSD 계열의 O_NONBLOCK과 거의 구분할 수 없는 POSIX 계열의 O_NDELAY 중에서 골라야 하죠. TCP_NODELAY와는 완전히 다른 거예요). 하지만 아이디어는 똑같아요. 소켓을 만든 뒤, 사용하기 전에 이걸 해요. (사실, 미친 짓을 하고 싶다면 왔다 갔다 바꿀 수도 있어요.)
주된 기계적 차이는 send, recv, connect, accept가 아무 것도 하지 않고 반환할 수 있다는 거예요. 물론 선택지는 많아요. 반환 코드와 오류 코드를 확인해서 일반적으로 스스로 미치게 만들 수 있어요. 믿기지 않으면 언젠가 해보세요. 앱이 커지고, 버그투성이가 되고, CPU를 빨아들일 거예요. 그러니 멍청한 해법은 건너뛰고 제대로 하죠.
select를 쓰세요.
C에서 select를 코딩하는 건 꽤 복잡해요. Python에서는 식은 죽 먹기지만, C 버전과 충분히 가까워서 Python에서 select를 이해하면 C에서도 어려움이 거의 없어요.
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
select에 세 개의 리스트를 넘겨요. 첫 번째는 읽기를 시도하고 싶을 수 있는 모든 소켓, 두 번째는 쓰기를 시도하고 싶을 수 있는 모든 소켓, 마지막(보통 빈 채로 둠)은 오류를 확인하고 싶은 것들이에요. 소켓이 둘 이상의 리스트에 들어갈 수 있다는 점을 알아두세요. select 호출은 블로킹이지만 타임아웃을 줄 수 있어요. 보통 이건 합리적인 일이에요. 특별한 이유가 없다면 꽤 긴 타임아웃(예: 1분)을 주세요.
그 대가로 세 개의 리스트를 받아요. 실제로 읽을 수 있고, 쓸 수 있고, 오류가 있는 소켓들이죠. 각 리스트는 넘겨준 대응 리스트의 부분 집합(어쩌면 빈)이에요.
소켓이 출력의 읽기 가능 리스트에 있다면, 그 소켓의 recv가 뭔가를 반환할 것이라는 점을 이 분야에서 우리가 얻을 수 있는 만큼 확신할 수 있어요. 쓰기 가능 리스트도 같은 아이디어예요. 뭔가 보낼 수 있을 거예요. 아마 원하는 전부는 아닐 수 있지만, 없는 것보단 뭔가가 나아요. (사실, 합리적으로 건강한 소켓이라면 쓰기 가능으로 반환돼요. 그건 그냥 나가는 네트워크 버퍼 공간이 있다는 뜻이에요.)
'서버' 소켓이 있다면 potential_readers 리스트에 넣으세요. 읽기 가능 리스트로 나오면 accept가 (거의 확실히) 동작해요. 다른 누군가에게 연결하기 위해 새 소켓을 만들었다면 potential_writers 리스트에 넣으세요. 쓰기 가능 리스트에 나타나면 연결됐을 가능성이 꽤 커요.
사실 select는 블로킹 소켓에서도 유용할 수 있어요. 블로킹할지 결정하는 한 가지 방법이거든요. 버퍼에 뭔가가 있으면 소켓이 읽기 가능으로 반환되죠. 하지만 이것도 상대방이 끝났는지 아니면 그냥 다른 일에 바쁜 건지 결정하는 문제에는 도움이 되지 않아요.
호환성 경고: Unix에서는 select가 소켓과 파일 모두에서 동작해요. Windows에서는 시도하지 마세요. Windows에서 select는 소켓에서만 동작해요. 또한 C에서는 더 고급 소켓 옵션 중 많은 것들이 Windows에서 다르게 처리돼요. 사실 Windows에서는 보통 소켓과 함께 스레드를 사용해요(매우 잘 동작하죠).
더 알아보기 (Learn more)
- 표준 라이브러리의
socket,select,socketserver,asyncio모듈 문서를 함께 읽어 보세요. - 소켓의 근본 개념이 궁금하다면 Python 공식 문서의 socket 모듈 레퍼런스와 네트워킹 관련 튜토리얼을 참고하세요.