실습 — 멀티 클라이언트 채팅 서버
이 PART에서 배운 동시성을 실전으로 마무리합니다. 여러 사람이 동시에 접속해 서로 대화하는 채팅 서버를 만듭니다. 한 사람이 보낸 말이 다른 모든 사람에게 전해지는, 단체 대화방입니다. 앞 장에서 만든 select 서버에 한 가지 발상만 더하면 완성됩니다.
에코에서 채팅으로
채팅 서버는 select 에코 서버와 뼈대가 거의 같습니다. 차이는 단 하나입니다. 에코 서버는 받은 데이터를 보낸 사람에게만 돌려줬지만, 채팅 서버는 보낸 사람을 뺀 나머지 모두에게 전합니다. 한 사람의 말을 방 안 모두가 듣게 하는 것입니다. 이렇게 여럿에게 한 번에 전하는 것을 브로드캐스트라 부릅니다.
채팅 서버
# 새 파일: 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]print("채팅 서버 대기 중")while True: readable, _, _ = select.select(clients, [], []) for s in readable: if s is server: conn, addr = s.accept() conn.setblocking(False) clients.append(conn) print(f"{addr} 입장") else: try: data = s.recv(1024) except ConnectionError: data = None if data: for c in clients: if c is not server and c is not s: try: c.sendall(data) except OSError: pass else: clients.remove(s) s.close()
앞 장의 select 에코 서버와 비교하며 달라진 곳을 보세요. 새 연결을 받고 명단에 추가하는 부분은 똑같습니다. 결정적 차이는 데이터를 받았을 때입니다.
에코 서버는 s.sendall(data)로 보낸 사람에게만 돌려줬습니다. 채팅 서버는 명단을 돌며, 듣기 소켓도 아니고 보낸 사람 자신도 아닌 모든 클라이언트에게 c.sendall(data)로 전합니다. if c is not server and c is not s라는 조건이 바로 "보낸 사람을 뺀 나머지 모두"를 골라냅니다. 이 한 줄의 발상이 에코를 채팅으로 바꿉니다.
브로드캐스트할 때 각 sendall을 try로 감싼 것도 눈여겨보세요. 여러 명에게 보내는 도중 누군가의 연결이 마침 끊겨 있을 수 있는데, 그 한 명 때문에 전체가 멈추면 안 됩니다. 그래서 한 명에게 보내다 에러가 나도 조용히 넘기고 나머지에게 계속 전합니다. 견고함을 위한 작은 배려입니다.
채팅 클라이언트
채팅 클라이언트는 지금까지의 클라이언트와 다른 점이 하나 있습니다. 내가 말을 입력하는 동시에, 다른 사람의 말도 받아야 합니다. 보내기와 받기가 동시에 일어나야 하는 것입니다. 그래서 받는 일을 별도의 스레드에 맡깁니다. PART 04의 첫 발상, 흐름을 나누는 방법을 여기서 다시 씁니다.
# 새 파일: 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())threading.Thread(target=receive, daemon=True).start()for line in sys.stdin: client.sendall(line.rstrip("\n").encode())client.close()
receive 함수는 서버가 전해 주는 다른 사람의 말을 계속 받아 화면에 출력합니다. 이 함수를 별도 스레드에서 돌립니다. 그래야 주 흐름이 내 입력을 기다리는 동안에도, 받는 스레드가 다른 사람의 말을 놓치지 않고 출력할 수 있습니다.
주 흐름은 sys.stdin에서 한 줄씩 읽어 서버로 보냅니다. 내가 키보드로 입력하는 부분입니다. 받기는 스레드가, 보내기는 주 흐름이 맡아 둘이 동시에 굴러갑니다.
함께 대화해 보기
서버를 띄우고 터미널 여러 개에서 클라이언트를 실행해 봅니다. 각 터미널이 한 사람입니다.
# 파일: 터미널 1 (서버)python3 chat_server.py
# 파일: 터미널 2, 3, 4 (각각 다른 사람)python3 chat_client.py
한 클라이언트에서 메시지를 입력하면, 그 말이 다른 모든 클라이언트의 화면에 나타납니다. 예를 들어 한 사람이 인사를 보내면, 다른 사람의 터미널에 이렇게 찍힙니다.
A: 안녕 모두
서버 쪽에는 누가 입장했는지 기록이 남습니다.
채팅 서버 대기 중
('127.0.0.1', 54925) 입장
('127.0.0.1', 54927) 입장
단 하나의 흐름으로 돌아가는 서버가, 여러 사람의 대화를 동시에 중계하고 있습니다. 스레드를 손님 수만큼 만들지 않고도 말입니다. select의 힘입니다.
더 나아가기
이 채팅 서버는 더 다듬을 곳이 많습니다. 지금은 누가 보낸 메시지인지 구별되지 않으니, 입장할 때 닉네임을 받아 메시지 앞에 붙이면 누구의 말인지 알 수 있습니다. 입장과 퇴장을 모두에게 알리거나, 특정 사람에게만 보내는 귓속말을 더할 수도 있습니다. 이런 기능들은 결국 우리만의 채팅 프로토콜을 설계하는 일로 이어집니다. 바로 다음 PART의 주제입니다.
또 한 가지, 이 서버는 메시지를 한 번의 recv로 통째로 받는다고 가정합니다. PART 02에서 배운 스트림의 본질을 떠올리면, 긴 메시지는 쪼개져 올 수 있습니다. 다음 PART에서 길이 접두어 같은 프로토콜로 이 문제까지 제대로 다루겠습니다.
이로써 동시성의 핵심을 모두 익혔습니다. 여러 클라이언트를 동시에 다루는 세 가지 길을 배웠고, 그중 select로 실전 채팅 서버까지 만들었습니다. 다음 PART에서는 지금까지 주먹구구로 주고받던 메시지에 제대로 된 형식을 입혀, 진짜 프로토콜을 설계하고 구현합니다.