콘텐츠로 건너뛰기
Home » ZMQ의 기본 개념들

ZMQ의 기본 개념들

일전에 간단하게 ZMQ에 대한 기본적인 내용을 살짝 정리한 글이 있는데, 사실 ZMQ 자체를 알게 된 게 소켓 통신에 대한 내용을 알아보려 검색하다가 흘러흘러 닿은 것이었더 관계로 글 자체가 공식 문서에서 예제 몇 개를 따온 수준이었다. 이후에 조금 더 알아보니 ZMQ는 단순한 소켓 프로그래밍 API를 대체하는 것 외에 거의 같은 코드베이스로 분산처리라든지 네트워크 소켓외에도 프로세스간 스레드간 통신에 사용될 수 있는 정말 활용폭이 넓고 괜찮은 라이브러리라고 생각됐다. ZMQ 자체는 간단한 개념이며, 기본적으로 사용하는 방식이 소켓과 크게 다르지 않아서 학습 곡성도 매우 낮은데, 오늘은 이 글에서 ZMQ가 무엇이며 전통 적인 소켓 통신과는 어떤 차이가 있는지를 좀 살펴보도록 하겠다.

ZMQ란?

ZMQ는 Zero Message Queue 의 약어로, 분산 메시징 플랫폼 구현을 위한 라이브러리이다. 간단하게는 소켓통신을 쉽고 간편하게 구현하는 것 외에도 소켓을 직접 다루는 것 대비하여 더 좋은 기능들을 특별한 비용 없이 제공해준다는 장점이 있다. 다음은 공홈에서 자랑하는 ZMQ의 장점들이다.

  • TCP, IPC, inproc, TIPC, Multicast 같은 다양한 프로토콜 및 연결을 사용하여 메시지를 전달할 수 있음
  • PUB-SUB, ROUTER-DEALER 와 같은 스마트한 메시징 패턴
  • 비동기 I/O를 지원
  • 모든 현대 언어와 플랫폼을 지원
  • 대규모의 활동적인 오픈소스 커뮤니티 지원
  • 중앙 집중 방식 및 분산 처리 방식, 소규모 및 대규모 등 거의 모든 아키텍쳐에 적용하여 구성 가능
  • 완전한 상업적 지원을 동반하는 무료 소프트웨어
  1. ZMQ는 단순히 소켓 사용을 고수준화한 라이브러리가 아닌 커뮤니케이션 플랫폼을 구축하는데 사용되는 것을 목표로 했다. 커뮤니케이션 그래프 내의 각 노드는 단순한 소켓외에 스레드나 프로세스가 될 수 있는데, 즉 노드에 어디에 있는지에 대해서는 신경쓰지 않아도 되도록 메시징의 끝단을 추상화한다.
  2. 노드와 노드간에 주고 받는 내용은 모두 ‘메시지’라고 불린다. 메시지는 담고있는 내용에 대해 관여하지 않는 대신에 언어나 플랫폼 간에 호환이 가능하도록 길이값과 데이터의 묶음으로만 구성된다.
  3. 이름에서 ‘Q’자는 Queue를 의미한다. ZMQ 위에 구축된 플랫폼 내에서 모든 메시지 전달은 자동으로 각 소켓 앞에서 큐를 거쳐 전달된다. 이러한 특성은 플랫폼을 유연하고 안정적으로 만들어준다. 예를 들어 TCP 소켓을 구현하는 서버-클라이언트에서는 서버가 리스닝하기 전에 클라이언트가 connect 하는 상황은 에러가 되지만, ZMQ에서는 연결의 순서가 중요하지 않다. 발신자 소켓에서도 수신자가 없는 메시지는 일단 대기열에 보관하며, 수신 소켓 쪽에서도 일단 받은 메시지는 큐에 보관된 후 처리된다. 이 때 큐에 관한 모든 처리는 라이브러리에 의해 자동으로 관리된다. ZMQ에서 Zero는 특히 이러한 큐를 관리하는데 들어가는 비용이 없다는 것에 포커스를 두고 있다.

컨텍스트와 소켓

컨텍스트는 하나의 프로세스에서 사용되는 소켓과 소켓에 대한 입출력을 관리하는 객체이다. 사실 소켓을 생성하는 용도 외에는 실제로 코드 상에서는 다룰 일이 없지만, 모든 ZMQ 코드는 컨텍스트를 만드는 것으로 시작하게 된다. 컨텍스트는 스레드 안전하며, 프로세스 내에서 하나만 존재해야 한다. 컨텍스트가 만드는 소켓은 추상화된 ZMQ 소켓이며, 실제 소켓은 그 시점에 생성되지 않는다. 소켓이 실제로 생성되고, 네트워크에 연결되고 메시지를 주고 받는 것은 실제 보이는 코드와 다른 시점에 일어날 수도 있지만, 컨텍스트에 의해서 적절하게 관리된다.

일반적으로 우리가 말하는 ‘소켓’은 전통적인 UNIX 소켓을 말하는 것으로, 특정한 네트워크 포트를 추상화한 것이다. ZMQ에서 말하는 소켓은 메시지를 전달하고 받는 창구의 개념으로, UNIX 소켓을 한 층 더 고수준화한 것으로 볼 수 있다. 우리(애플리케이션)가 바라보는 ZMQ 소켓은 어떤 큐의 입구에 해당한다. 따라서 네트워크 소켓과 달리 애플리케이션은 연결 여부도 알 필요가 없다. 우리가 보내는 메시지는 일단 큐에 들어갈 것이며, 메시지를 읽으려할 때에도 적절한 시점에 받아둔 메시지를 큐로부터 가져올 것이다. 심지어 ZMQ 소켓은 하나의 소켓이 여러 엔드포인트에 동시에 접속해 있는 것도 가능하다. 따라서 메시지를 수/발신할 필요가 있는 곳에 ZMQ 소켓을 만들고 이를 네트워크의 하나의 노드로 취급하면 된다.

따라서 ZMQ 내에서 여러 소켓들은 하나의 네트워크 노드가 되어 특정한 역할을 수행할 수 있다. 메시지를 발송만 하는 소켓이 있을 수 있고, 반대로 수신만하는 소켓이 있을 수 있다. 그리고 수신과 발신을 번갈아 하는 소켓과, 순서에 상관없이 메시지를 주고받으려는 소켓도 있을 것이다. 메시지를 사용한 커뮤니케이션의 방식에 따라서 그에 맞는 메시징 패턴이 있을 수 있으며, 메시징 패턴은 양 끝단 간의 소켓 형태의 조합에 의해서 결정된다.

가장 기본적으로 서버와 클라이언트가 번갈아 메시지를 주고 받는 에코서버-클라이언트를 생각해보자. 여기에는 가장 기초적인 메시징 패턴인 REQ-REP 패턴이 사용되며, 각각 zmq.REQ, zmq.REP 유형의 소켓이 사용된다.

# echo-srv-client-async.py
import asyncio
from zmq.asyncio import Context
import zmq

ctx = Context.instance()

async def server():
  sock = ctx.socket(zmq.REP)
  sock.bind('tcp://*:5555')
  while True:
    msg = await sock.recv_string()
    print(f"Received: {msg}")
    await sock.send_string(msg)

async def client():
  sock = ctx.socket(zmq.REQ)
  sock.connect('tcp://localhost:5555')
  while True:
    line = input().strip()
    if not line:
      break
    await sock.send_string(line)
    reply = await sock.recv_string()
    print(reply)

실행을 좀 쉽게 해보기 위해서 asyncio 를 사용하는 코드로 작성해보았다. 전통적인 소켓을 사용한 에코서버 구현과 다른 점들에 대해서 살펴보자.

  1. 먼저 소켓을 생성하는데에는 소켓의 패턴외에는 어떤 정보도 필요하지 않았다. 또한 한쪽이 bind()하고 다른 한쪽이 connect() 하는 것은 맞지만, listen()/accept() 은 인터페이스는 없다. 대신에 접속하는 엔드포인트에 대해서 “프로토콜”이 지정되었다.
  2. send() 자체는 특정한 바이트 만큼만 보내지 않는다. 모든 메시지는 마치 전체가 한 덩어리로 전송되는 것처럼 보인다.

아래와 같은 간단한 코드로 실행하여 실제 동작을 확인해 볼 수 있다.

# run server - client
async def test():
  asyncio.create_task(server())
  await client()

asyncio.run(test())

실행 하는 코드를 아래와 같이 변경해보자. 흥미롭게도 서버나 클라이언트에 아무런 추가적인 조치가 없었지만, 에코서버는 여러 클라이언트와 동시에 연결되며, 순서대로 올바르게 메시지를 처리해준다.

async def test():
  asynctio.create_task(server())
  await asyncio.wait([client() for  _ in range(6)])

asyncio.run(test())

이처럼 ZMQ은 전통적인 소켓 통신 관련 코드를 간단하게 대체하는데 사용될 수 있다.

메시지

ZMQ 소켓을 통해서 주고 받는 데이터는 이진 raw 데이터 스트림이 아니다. ZMQ는 ‘메시지’라 불리는 길이값과 데이터를 결합한 단위의 데이터 타입을 주고 받는다. 따라서 recv()send()에서 별다른 인자 없이 데이터 전체를 하나의 메소드 호출로 주고 받을 수 있게 한다. 또한 이는 문자열을 다루는 매커니즘이 서로 다른 언어 구현간에 발생할 수 있는 문자열 데이터 전달을 처리하는 좋은 돌파구가 된다. 적어도 파이썬을 쓰는 한, 메시지에 대해서는 별다른 처리가 필요하지 않을 것이기 때문에 일단은 넘어가자.

메시지 패턴

컨텍스트가 어떻게 소켓과 각 소켓의 입출력 동작을 마법처럼 관리할 수 있을까? 사실 컨텍스트는 메시지 패턴에 따라서 사용되어야 할 소켓이 어떻게 동작해야 할 것인지를 판단할 수 있다. 메시지 패턴은 두 노드 혹은 여러 노드간에서 메시지를 주고 받는 방향성과 흐름을 정의하는 방법이다. 네트워크 구성 노드들의 연결 방식에 따라서 전체 네트워크의 위상이 달라질 수 있겠지만, ZMQ는 하나의 소켓이 다른 소켓 혹은 소켓들과 어떻게 동작해야 하는지를 몇 개의 패턴으로 구분하고, 그에 맞게 최적화된다. 실질적으로는 매우 많은 패턴들이 있을 수 있겠지만, ZMQ는 몇 개의 기본적인 패턴들을 정의하고 있다.

  1. REQuest – REPly 패턴 : 서버-클라이언트가 각각 한 번씩의 요청과 응답을 주고 받는다.
  2. PUBlisher – SUBscriber 패턴 : 서버가 발행하는 메시지가 각각의 클라이언트로 분산되어 전파된다.
  3. Pipleline : PUSHPULL 패턴의 소켓이 연결되어 단일 방향으로 메시지를 개별전송한다.

이외에도 메시징 네트워크를 확장하기 위한 프록시나 Poller와 같은 디바이스 몇 가지가 정의되어 있다. 분명한 것은 ZMQ는 그렇게 많지 않은 패턴을 제공하고 있음에도 불구하고 매우 다양한 구조를 손쉽게 만들 수 있다는 점이다. 그것은 네트워크 내의 각 노드사이에서 데이터나 신호가 흘러가는 흐름을 디자인하기에 달려 있으며, 우리는 ZMQ를 이용하여 가장 쉽게 할 수 있는 것 (바로 두 노드 간의 통신을 구축하는 것)을 사용하여 얼마든지 다방향 네트워크를 구축할 수 있다는 점이다.