iBetter Books
수정

직접 만드는 채팅 프로토콜

PART 04에서 만든 채팅 서버는 한 사람의 말을 모두에게 전했지만, 그게 전부였습니다. 누가 보냈는지 알 수 없었고, 명령도 없었고, 긴 메시지가 쪼개져 오면 깨질 수도 있었습니다. 이 장에서 앞 장의 설계 원리를 적용해, 그 채팅을 제대로 된 프로토콜을 갖춘 채팅으로 키웁니다. 닉네임을 붙이고, 명령을 처리하고, 메시지 경계를 줄바꿈으로 분명히 긋습니다.

우리 프로토콜의 약속

먼저 약속을 정합니다. 이것이 프로토콜 설계입니다.

메시지의 경계는 줄바꿈 문자로 긋습니다. 한 줄이 한 메시지입니다. 앞 장에서 배운 구분자 방식입니다. 채팅 메시지는 사람이 입력하는 텍스트라, 줄바꿈으로 나누는 것이 자연스럽습니다.

메시지는 두 종류입니다. 빗금으로 시작하면 명령이고, 그렇지 않으면 일반 대화입니다. 명령은 두 가지를 둡니다. 닉네임을 정하는 명령과 방을 나가는 명령입니다. 이렇게 빗금 하나로 명령과 데이터를 구분하는 것도 약속의 일부입니다.

줄 단위로 받기

이 프로토콜의 핵심은 줄 단위로 메시지를 잘라 내는 것입니다. PART 02에서 배운 스트림의 본질을 떠올리세요. 데이터는 쪼개지거나 뭉쳐서 옵니다. 그래서 받은 데이터를 클라이언트마다 따로 모아 두었다가, 줄바꿈이 나타날 때마다 한 줄씩 잘라 처리해야 합니다. 이를 위해 클라이언트마다 아직 줄이 끝나지 않은 데이터를 담아 둘 버퍼가 필요합니다.

이 발상을 담아 앞 장의 채팅 서버를 확장합니다.

# 수정: chat_server.pyimport socketimport selectserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9050))server.listen(5)server.setblocking(False)clients = [server]names = {}buffers = {}print("채팅 서버 대기 중")def broadcast(text, sender=None):    for c in clients:        if c is not server and c is not sender:            try:                c.sendall((text + "\n").encode())            except OSError:                passwhile True:    readable, _, _ = select.select(clients, [], [])    for s in readable:        if s is server:            conn, addr = s.accept()            conn.setblocking(False)            clients.append(conn)            names[conn] = "익명"            buffers[conn] = ""        else:            try:                data = s.recv(1024)            except ConnectionError:                data = None            if not data:                broadcast(f"[시스템] {names[s]} 님이 나갔습니다")                clients.remove(s)                del names[s]                del buffers[s]                s.close()                continue            buffers[s] += data.decode()            while "\n" in buffers[s]:                line, buffers[s] = buffers[s].split("\n", 1)                if line.startswith("/nick "):                    names[s] = line[6:]                    s.sendall(f"[시스템] 닉네임이 {names[s]}로 설정되었습니다\n".encode())                elif line == "/quit":                    s.sendall("[시스템] 안녕히 가세요\n".encode())                    broadcast(f"[시스템] {names[s]} 님이 나갔습니다")                    clients.remove(s)                    del names[s]                    del buffers[s]                    s.close()                    break                else:                    broadcast(f"{names[s]}: {line}", sender=s)

추가된 곳을 따라가 보겠습니다.

names는 각 소켓의 닉네임을 기억하는 딕셔너리이고, buffers는 각 소켓에서 아직 줄이 끝나지 않은 데이터를 모아 두는 딕셔너리입니다. 클라이언트가 접속하면 닉네임을 익명으로 시작하고 빈 버퍼를 마련합니다.

broadcast 함수는 앞 장에서 데이터 처리 안에 흩어져 있던 전파 로직을 깔끔히 모은 것입니다. 보낸 사람을 뺀 모두에게 메시지를 전하되, 끝에 줄바꿈을 붙입니다. 우리 프로토콜이 줄 단위이기 때문입니다.

핵심은 데이터를 받은 뒤의 처리입니다. 받은 데이터를 곧바로 쓰지 않고 buffers[s]에 이어 붙입니다. 그리고 버퍼 안에 줄바꿈이 있는 동안, 한 줄씩 잘라 내 처리합니다. split("\n", 1)이 첫 줄바꿈을 기준으로 한 줄과 나머지를 나눕니다. 잘라 낸 한 줄이 비로소 완전한 한 메시지입니다. 이렇게 하면 메시지가 쪼개져 와도, 여러 개가 뭉쳐 와도 정확히 한 줄씩 처리됩니다. PART 02의 부분 수신 문제를 프로토콜로 깔끔히 해결한 것입니다.

잘라 낸 한 줄이 빗금으로 시작하면 명령으로 처리합니다. 닉네임 명령이면 이름을 바꿉니다. 나가기 명령이면 작별 인사를 보낸 뒤 그 클라이언트를 명단에서 빼고 소켓을 닫으며, 다른 사람들에게 퇴장을 알리고 break로 그 소켓 처리를 끝냅니다. 명령이 아니면 닉네임을 앞에 붙여 모두에게 전합니다. 누가 한 말인지 이제 분명해졌습니다. 클라이언트가 인사 없이 그냥 연결을 끊은 경우에도, 빈 데이터를 감지해 같은 방식으로 퇴장을 알립니다.

클라이언트 한 줄만 고치기

서버가 줄바꿈으로 메시지를 자르므로, 클라이언트는 보낼 때 줄바꿈을 꼭 붙여야 합니다. 앞 장의 클라이언트는 줄바꿈을 떼고 보냈는데, 이제는 그대로 두고 보냅니다.

# 수정: chat_client.pyimport socketimport threadingimport sysclient = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client.connect(("127.0.0.1", 9050))def receive():    while True:        try:            data = client.recv(1024)        except OSError:            break        if not data:            break        print(data.decode(), end="")threading.Thread(target=receive, daemon=True).start()for line in sys.stdin:    client.sendall(line.encode())client.close()

client.sendall(line.encode())로 바뀌었습니다. sys.stdin에서 읽은 줄에는 끝에 줄바꿈이 이미 들어 있으므로, 떼지 않고 그대로 보내면 서버가 기대하는 한 줄이 완성됩니다. 받는 쪽 출력도 end=""로 바꿔, 서버가 보낸 줄바꿈을 그대로 살려 깔끔하게 찍히도록 했습니다.

대화해 보기

서버를 띄우고 두 사람이 접속해 봅니다. 한 사람이 닉네임을 Bob으로 정하고, 다른 사람이 Alice로 정한 뒤 인사를 보냅니다. Bob의 화면에는 이렇게 나타납니다.

[시스템] 닉네임이 Bob로 설정되었습니다
Alice: 안녕 Bob!
[시스템] Alice 님이 나갔습니다

닉네임을 정하자 시스템이 확인해 주었고, Alice의 인사가 이름과 함께 도착했고, Alice가 나가자 그 사실까지 전해졌습니다. 앞 장의 밋밋한 채팅이 어엿한 프로토콜을 갖춘 채팅으로 자랐습니다.

우리가 만든 것

방금 우리가 한 일을 돌아보세요. 메시지 경계를 정하고, 명령과 데이터를 구분하고, 상태를 닉네임으로 관리하고, 줄 단위로 안전하게 잘라 받았습니다. 앞 장에서 배운 프로토콜 설계의 원리를 그대로 실천한 것입니다. 이것이 작지만 완전한 프로토콜입니다.

더 키운다면 귓속말, 채팅방 나누기, 접속자 목록 같은 기능을 명령으로 계속 더할 수 있습니다. 모두 같은 방식입니다. 새 명령을 약속에 추가하고, 서버가 그것을 처리하게 하면 됩니다. 프로토콜은 이렇게 약속을 넓혀 가며 자랍니다.

우리만의 프로토콜을 만들어 봤으니, 이제 세상에서 가장 유명한 프로토콜을 직접 구현할 차례입니다. 다음 장에서 HTTP 서버를 만들어, 우리 소켓 프로그램이 웹 브라우저와 대화하게 합니다.