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

파이썬으로 터미널 상에서 돌아가는 간단한 채팅앱을 구현해보고자 한다. 채팅 앱은 서버와 클라이언트로 구성되며, 각각의 클라이언트가 보내는 메시지를 모든 클라이언트에게 되돌려주면 된다. 이 때 일반적인 에코서버 구현과 다른 점은, 대화 메시지가 오고 가는 방식은 비동기적이기 때문에 실제로는 서버와 클라이언트 모두 각각 수신용과 발신용의 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)

Read more

워드프레스에서 고스트로 이전

워드프레스에서 고스트로 이전

이 글을 쓰면서도 믿기 힘든 사실인데, 블로그라는 걸 처음 시작한지가 20년이 되었습니다. 이글루스에서 처음 시작했다가, SK컴즈가 인수한다고 발표함과 동시에 워드프레스로 플랫폼을 옮겼죠. 워드프레스오 옮긴 이후에는 호스팅 환경을 이리 저리 옮기긴 했지만 거의 18년 가까이 워드프레스를 사용해온 것 같습니다. 그 동안 워드프레스는 블로깅 툴에서 명실상부한 범용CMS로 발전했습니다. 사실 웬만한 홈페이지들은 이제

By sooop
띄어쓰기에 대한 생각

띄어쓰기에 대한 생각

업무 메일을 쓸 때 가장 많이 쓰는 말 중에 하나가 메일 말미에 ‘업무에 참고 부탁 드립니다.‘인데요, 어느 날부터 아웃룩에서 이 ‘부탁 드립니다’가 틀렸다고 맞춤법 지적을 하기 시작했습니다. 맞는 말은 ‘부탁드립니다’라고 붙여 쓰는 거라고. 사실 아래아한글 시절부터 이전의 MS워드까지, 워드프로세서들의 한국어 맞춤법 검사 실력은 거의 있으나 마나 한

By sooop

구글 포토에서 아이클라우드로 탈출한 후기

한 때 구글 포토가 백업 용량을 무제한으로 제공해 주겠다고해서, 구글 포토를 사용해서 사진을 백업해왔습니다. 물론 이 이야기의 결말은 저나 이 글을 읽고 있는 여러분이나 모두 알고 있습니다. 사실 AI에게 학습 시킬 이미지 데이터를 모으기 위한 것일 뿐이라거나 하는 이야기는 그 당시에도 있었습니다만, 에이 그래도 구글인데 용량은 넉넉하게 주겠지…하는 순진한

By sooop

Julia의 함수 사용팁

연산자의 함수적 표기 Julia의 연산자는 기본적으로 함수이며, 함수 호출 표기와 같은 방식으로 호출하는 것이 가능합니다. 또한 그 자체로 함수이기 때문에 filter(), map() 과 같이 함수를 인자로 받는 함수에도 연산자를 그대로 적용하는 것이 가능합니다. 특히 + 연산자는 sum() 함수와 같이 여러 인자를 받아 인자들의 합을 구할 수 있습니다. 2 + 3 # = 5 +(2,

By sooop