iBetter Books
수정

데이터는 한 번에 오지 않는다

지금까지 우리는 recv(1024)를 부르면 상대가 보낸 데이터가 통째로 돌아온다고 믿어 왔습니다. 짧은 인사말을 주고받을 때는 대체로 그렇게 동작합니다. 하지만 그것은 운이 좋았을 뿐입니다. 이 장에서 다루는 진실은 소켓 프로그래밍에서 가장 중요하면서도 초보자를 가장 많이 넘어뜨리는 내용입니다. 천천히 읽어 주세요.

TCP는 스트림이다

TCP는 데이터를 메시지 단위가 아니라 바이트의 흐름, 곧 스트림으로 다룹니다. 이 한 문장에 모든 것이 담겨 있습니다.

수도꼭지를 떠올려 보세요. 물을 컵으로 세 번 부어 넣어도, 수도꼭지 반대편에서는 그냥 물줄기가 흘러나올 뿐입니다. 몇 번에 나눠 부었는지는 흔적도 없이 사라집니다. TCP도 똑같습니다. 보내는 쪽이 sendall을 세 번 호출해도, 받는 쪽에서는 그것이 세 덩어리였다는 정보가 남지 않습니다. 그저 바이트가 순서대로 흘러올 뿐입니다.

그래서 다음과 같은 일이 모두 일어날 수 있습니다. 보내는 쪽이 한 번에 보낸 데이터가 받는 쪽에서는 여러 번의 recv로 쪼개져 도착할 수 있습니다. 반대로 보내는 쪽이 여러 번 나눠 보낸 데이터가 받는 쪽에서는 한 번의 recv로 뭉쳐 도착할 수도 있습니다. 무엇 하나 보장되지 않습니다. 보장되는 것은 오직 하나, 바이트의 순서뿐입니다. 보낸 순서대로 도착한다는 것만은 확실합니다.

PART 01의 짧은 인사말이 잘 동작한 이유는 데이터가 작아서 우연히 한 번에 들어왔기 때문입니다. 데이터가 커지거나 네트워크가 붐비면 곧바로 쪼개집니다.

직접 확인해 보기

말로만 들으면 실감이 나지 않으니 직접 보겠습니다. 클라이언트가 데이터를 일부러 두 번에 나눠 보내고, 서버는 정확히 10바이트를 모을 때까지 받는 프로그램입니다.

# 새 파일: recv_demo.pyimport socketimport threadingimport timedef recv_exactly(sock, n):    buf = b""    while len(buf) < n:        chunk = sock.recv(n - len(buf))        if not chunk:            raise ConnectionError("연결이 예상보다 일찍 끊겼습니다")        buf += chunk    return bufdef server():    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)    s.bind(("127.0.0.1", 9101))    s.listen(1)    conn, _ = s.accept()    with conn:        data = recv_exactly(conn, 10)        print(f"서버가 모은 10바이트: {data!r}")    s.close()t = threading.Thread(target=server)t.start()time.sleep(0.4)client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client.connect(("127.0.0.1", 9101))client.sendall(b"ABCDE")time.sleep(0.2)client.sendall(b"FGHIJ")client.close()t.join()

이 예제는 서버와 클라이언트를 한 프로그램 안에서 함께 돌립니다. 서버를 별도의 스레드로 띄우는 방식인데, 스레드 이야기는 PART 04에서 자세히 하니 지금은 서버와 클라이언트가 같이 실행된다는 정도로만 받아들이면 됩니다.

핵심은 클라이언트입니다. b"ABCDE"를 보내고 잠시 멈췄다가 b"FGHIJ"를 보냅니다. 두 번에 나눠 보낸 것입니다. 실행하면 이렇게 나옵니다.

서버가 모은 10바이트: b'ABCDEFGHIJ'

서버는 두 덩어리를 하나로 합쳐 정확히 10바이트를 받았습니다. 나눠 보낸 흔적은 사라지고 바이트만 순서대로 이어졌습니다. 스트림이 무엇인지 눈으로 확인한 셈입니다.

recv_exactly라는 안전장치

이 마법을 부린 것이 recv_exactly 함수입니다. 앞으로 이 책에서 셀 수 없이 등장할 중요한 도구이니 동작을 정확히 이해하고 넘어가겠습니다.

이 함수는 "정확히 n바이트를 받을 때까지 절대 포기하지 않는다"는 한 가지 일을 합니다. recv를 한 번 부르면 원하는 만큼 다 올지 알 수 없으니, 받은 양이 n에 못 미치는 동안 반복해서 더 받습니다. recv(n - len(buf))에서 매번 아직 부족한 만큼만 요청하는 점을 눈여겨보세요. 5바이트를 이미 받았다면 다음에는 5바이트만 더 달라고 합니다.

중간에 chunk가 빈 바이트열이면, 앞 장에서 배웠듯 상대가 연결을 끊었다는 뜻입니다. 원하는 만큼 다 받기도 전에 상대가 떠났으니, 이는 비정상 상황입니다. 그래서 예외를 던져 알립니다.

직접 짠 recv는 이렇게 항상 부분 수신을 염두에 두어야 합니다. "받고 싶은 만큼 못 받을 수 있다"는 것을 잊는 순간 버그가 시작됩니다.

send와 sendall, 그리고 보내는 쪽의 진실

받는 쪽만 조심하면 될까요. 아닙니다. 보내는 쪽에도 같은 함정이 있습니다.

send 함수는 사실 우리가 부탁한 데이터를 한 번에 다 보내지 못할 수 있습니다. 운영체제의 송신 버퍼가 가득 차 있으면, send는 보낼 수 있는 만큼만 보내고 실제로 보낸 바이트 수를 돌려줍니다. 나머지는 우리가 다시 보내야 합니다. 받는 쪽의 recv가 부분 수신이듯, 보내는 쪽의 send도 부분 송신인 셈입니다.

지금까지 우리가 send 대신 sendall을 써 온 이유가 바로 이것입니다. sendall은 데이터 전체가 다 나갈 때까지 내부에서 알아서 반복해 줍니다. 부분 송신을 우리 대신 처리해 주는 편리한 함수입니다. 그래서 Python에서는 특별한 이유가 없는 한 sendall을 쓰는 것이 안전합니다.

C에는 sendall에 해당하는 함수가 없습니다. 그래서 직접 만들어야 합니다. 다음 장의 파일 전송 실습에서 받는 쪽의 recv_exactly와 짝을 이루는 send_all 함수를 C로 직접 구현하게 됩니다.

그래서 메시지의 경계는 누가 정하는가

여기서 자연스러운 질문이 떠오릅니다. 스트림에는 메시지 경계가 없다면, 받는 쪽은 한 메시지가 어디서 끝나는지 어떻게 알까요. 답은 분명합니다. 우리가 직접 정해 줘야 합니다.

이것이 프로토콜 설계의 출발점입니다. 가장 흔한 방법은 데이터를 보내기 전에 그 길이를 먼저 알려 주는 것입니다. "이제 65바이트를 보낼게"라고 미리 말해 두면, 받는 쪽은 recv_exactly로 정확히 65바이트를 모으면 된다는 것을 압니다. 이 방법을 길이 접두어라 부르며, 바로 다음다음 장의 파일 전송 실습에서 직접 구현합니다.

스트림의 본질을 이해했으니, 다음 장에서는 그 스트림을 떠받치는 연결 자체가 어떻게 시작되고 끝나는지를 들여다보겠습니다.