Wireframe

파이썬으로 구현하는 채팅앱

파이썬으로 터미널 상에서 돌아가는 간단한 채팅앱을 구현해보고자 한다. 채팅 앱은 서버와 클라이언트로 구성되며, 각각의 클라이언트가 보내는 메시지를 모든 클라이언트에게 되돌려주면 된다. 이 때 일반적인 에코서버 구현과 다른 점은, 대화 메시지가 오고 가는 방식은 비동기적이기 때문에 실제로는 서버와 클라이언트 모두 각각 수신용과 발신용의 2개의 소켓을 준비해야 한다는 점이다. 어쨌든 이것은 ZMQ를 사용하면 손쉽게 해결할 수 있다. 다음으로 채팅앱에서 어려운 점은 일반적인 input() 함수에 관한 것이다. 채팅앱은 키보드를 통해 메시지를 입력하는 중간에도 수신한 메시지를 출력할 수 있어야 한다. 그런데 input() 함수는 블록킹함수이기 때문에 이 부분의 처리가 쉽지 않을 수 있다. 스레드로 처리한다 하더라도 입력과 동시에 출력이 발생하면 콘솔의 모양이 깨진다. 그런데 이 문제에 대한 해결책도 사실 이전에 이 블로그에서 제시한 바 있다. 바로 prompt_toolkit을 사용하는 것이다.

구현

서버와 클라이언트를 편의상 각각 구현해보도록 하겠다. 두 코드를 합쳐서 하나의 예제에서 돌려보는 것은 그다지 어렵지 않으니 생략하도록 하겠다.

서버

서버는 두 개의 접점으로 구성된다. frontend에는 각각의 클라이언트가 보내는 메시지가 도착한다. backend로는 각 메시지를 다시 모든 클라이언트에게 전송한다. 이는 단방향이기는 하나 두 개의 소켓에서 한쪽으로 수신한 내용을 다른쪽으로 그대로 전달하기만 하면 된다는 측면에서 프록시와 동일한 동작이다. frontend 는 비동기 수신이 가능한 소켓이면 되니 DEALER를 써도 되고 ROUTER를 써도 된다. backend는 여러 클라이언트에게 같은 메시지를 브로드캐스팅해야 하니, PUB 소켓을 사용하겠다.

# server.py
import zmq

def run_server():
    port_in = 5555
    port_out = 5556
    ctx = zmq.Context.instance()
    sock_in = ctx.socket(zmq.ROUTER)
    sock_in.bind(f"tcp://*:{port_in}")
    sock_out = ctx.socket(zmq.PUB)
    sock_out.bind(f"tcp://*:{port_out}")
    zmq.proxy(sock_in, sock_out)

클라이언트

클라이언트의 동작은 두 가지 루프가 동시에 도는 것이다. 사용자가 입력한 텍스트를 서버로 전달하고, 다시 서버로부터 수신한 내용을 화면에 출력한다. 여기에는 각각의 커넥션이 필요하다. 입력 부분을 조금 편하게 처리하기 위해서는 각각의 루프를 비동기 코루틴 내에서 돌면 되겠다.

zmq를 비동기로 사용하기 위해서는 zmq.asyncio.Context 클래스로 컨텍스트 객체를 만들면 된다. 이후 만들어지는 소켓은 비동기 소켓으로 send*(), recv*(), poll() 등의 메소드가 모두 awaitable 하다는 차이만 있다.

클라이언트 – 수신부

client_reader() 는 서버로부터 수신된 메시지를 화면에 출력한다. 루프를 돌면서 수신된 메시지를 출력해주면 된다. 참고로 asyncio 를 사용하는 경우에는 완전히 동시에 두 개의 문구가 출력되는 경우는 없기 때문에 print() 함수를 사용하여 출력해도 간섭이 생기지 않는다.

클라이언트 – 송신부

송신부쪽은 루프를 돌면서 입력받은 내용을 전송하면 된다. 이전에 소개한 적 있는 비동기 입력 프롬프트를 사용하면 메시지 출력 부분과 충돌하지 않고 작동할 수 있다. 사실 이 부분이 따로 구현하려면 상당히 번거로운데, prompt_toolkit의 도움을 받으면 상당히 간단하게 정리할 수 있다.

# client.py 

import asyncio
import zmq

from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout
from zmq.asyncio import Context

async def client_reader(ctx: Context):
    sock = ctx.socket(zmq.SUB)
    sock.setsockopt(zmq.SUBSCRIBE, b'')
    sock.connect("tcp://localhost:5556")
    while True:
        addr, message = await sock.recv_multipart()
        print(message.decode())


async def client_sender(ctx: Context):
    sock = ctx.socket(zmq.DEALER)
    sock.connect("tcp://localhost:5555")
    # 수신부를 따로 스케줄링하여 실행
    asyncio.create_task(client_reader(ctx))
    sess = PromptSession(message=":> ")
    with patch_stdout():
        while True:
            line = await sess.prompt_async()
            await sock.send_string(line)

if __name__ == '__main__':
    ctx = Context.instance()
    asyncio.run(client_sender(ctx))

서버 – 메시지의 발신자 표시

위에서 구현한 서버는 메시지를 모든 클라이언트에게 전송해주기는 하지만, 메시지가 누구의 것인지를 알지 못한다. 이를 구분해주는 기능을 추가하기 위해서 서버를 직접 작성해보자. ROUTER 소켓은 피어가 접속할 때 해당 피어를 구분해주는 식별자값을 할당하는데, 이는 임의의 정수값이다. 이를 바탕으로 SHA256 해시를 만들고 해시 끝 6자리를 취해서 해당 발신자의 식별자로 삼겠다.

# server2.py
from hashlib import sha256
from zmq.asyncio import Context
import zmq

async def server():
  ctx = Context.instance()
  front = ctx.socket(zmq.ROUTER)
  front.bind('tcp://*:5555')
  back = ctx.socket(zmq.PUB)
  back.bind('tcp://*:5556')
  while True:
    addr, data = await front.recv_multipart()
    pid = sha256(addr).hexdigest()[-6:]
    message = f"#{pid}: {data.decode()}"
    await sock.send_string(message)
Exit mobile version