ZMQ의 기본 개념들

일전에 간단하게 ZMQ(Zero MQ)에 대한 내용을 간단히 정리해본 바 있는데, 이 때는 소켓에 대한 내용을 살펴보다가 흘러흘러 닿은 부분이라 제대로 설명하지 못하고 공식문서에 나오는 예제를 그대로 옮기는 수준이었다.  ZMQ는 소켓 프로그래밍 API를 대체할 수 있는 정말 괜찮은 라이브러리라는 생각이 들어서 활용할 폭이 넓다고 판단됐다. 다만 용어나 개념에 대한 약간의 선행지식이 필요한 부분이 있다. 오늘은 ZMQ에서 사용되는 기본적인 개념에 대해서 알아보고, ZMQ를 통해서 간단한 에코서버와 클라이언트로 소켓통신을 구현하는 방법에 대해 살펴보도록 하겠다. 그리고 ZMQ를 사용하면 전통적인 소켓 접속을 구현하는 것보다 얼마나 편하며 또 멋지게 돌아가는지도 살펴보도록 하겠다.

ZMQ란?

  • 코드를 모든 언어, 모든 플랫폼 상에서 연결
  • inproc, IPC, TCP, TIPC, 멀티캐스트를 통해 메시지들을 전달
  • pub-sub, push-pull 그리고 router-dealer와 같은 스마트한 패턴
  • I/O 비동기 엔진을 지원하는 소형 라이브러리
  • 대규모의 활동적인 오픈소스 커뮤니티 지원
  • 모든 현대 언어와 플랫폼을 지원
  • 중앙집중, 분산, 소규모 및 대규모의 모든 아키텍처를 구성 가능
  • 완전한 상업적 지원을 동반하는 무료 소프트웨어

위는 ZMQ 홈페이지에서 소개하는 ZMQ의 특징이다. ZMQ는 제로엠큐라 불리는 분산 메시징 플랫폼을 구현하기 위한 라이브러리이다. 흔히 소켓 통신 구현을 간단히 할 수 있는 라이브러리로 많이 소개되는데, ZMQ는 사실 훨씬 더 많은 것을 간편하게 구축할 수 있으며, 더군다나 빠르게 처리되도록 할 수 있다. ZMQ의 컨셉은 여러 가지 측면을 가지고 있지만, 그중에 가장 근간이 되는 키워드라 함은 ‘여러 노드를 편리하게 이어주는 분산형 메시징 플랫폼’이다.  위의 특징 소개글에서도 약간 엿볼 수 있지만, 다음과 같은 특징을 가진다.

  1. ZMQ는 단순히 소켓과 소켓을 연결하는 라이브러리가 아닌, 노드와 노드를 연결하여 메시지를 주고받을 수 있는 플랫폼을 구축하는데 쓰인다. 이 때 노드는 소켓외 스레드, 프로세스, TCP로 연결된 다른 머신이 될 수 있다. 즉 노드가 어디에 있든지에 대해서 상관하지 않도록 메시징의 양 끝단이 추상화되어 있다.
  2. 전달되는 데이터는 모두 ‘메시지’라는 형태로 불린다. 메시지는 그 속에 무엇이 들어있는지를 신경쓰지 않으며, 여러 언어/플랫폼간에 호환이 가능하도록 길이값+데이터의 형태로 묶여 있다.
  3. ZMQ의 Q는 Queue를 의미한다. 이는 노드들이 연결되어 만들어진 플랫폼 내에서의 모든 메시지 전달은 자동으로 큐를 거치게 된다. 소켓의 경우 서버가 소켓을 열기 전에 클라이언트가 접속하는 상황은 에러가 되지만, ZMQ에서 이러한 순서는 중요하지 않다. 수신측이 없거나 유효하지 않은 메시지는 모두 큐잉되며, 나중에 소비되거나 혹은 버려질 수 있다. 반대로 메시지를 받아들이는 부분에서도 큐가 사용된다. 모든 큐에 관한 관리는 라이브러리가 자동으로 관리하며, 큐 관리에 들어가는 비용은 없다. (그래서 Zero MQ이다.)

컨텍스트 (context)

모든 ZMQ 관련 코드는 컨텍스트 객체를 만드는 것으로 시작하고, 모든 ZMQ소켓은 컨텍스트를 통해서 생성된다. 소켓을 생성하고 관리하는 컨테이너이자 관리자이다. 컨텍스트는 스레드 안전하며, 프로세스 내에서 하나만 생성하고 사용하여야 한다.

컨텍스트를 통해서 생성되는 소켓에서 발생하는 입출력은 실제로는 백그라운드 스레드에서 비동기로 일어나며, 이 과정은 전부 컨텍스트에 의해서 처리된다. 컨텍스트로부터 명시적으로 생성되는 소켓은 실제로는 소켓이 아니며, 즉시 생성되지도 않는다. 모든 것은 내부에서 자동으로 관리된다.

소켓

이 글에서 아무런 단서 없이 언급되는 ‘소켓’이라는 명칭은 ZMQ 내에서의 소켓을 말한다. 전통적인 네트워킹에 사용되는 UNIX 소켓은 “UNIX 소켓” 혹은 “전통적인 소켓”이라고 언급할 것이다. 이와 같은 명명 관습은 ZMQ를 다루는 이 글과 그 후속글에서 공통적으로 적용할 예정이다.

전통적인 UNIX의 소켓은 네트워크 포트를 추상화한 것이다. 이에 빈해 ZMQ의 소켓은 메시지를 전달하고 받는 창구의 개념이다. UNIX 소켓은 수신측인 상대편 노드가 열려있는 소켓이거나 혹은 상대방 소켓이 이쪽으로 붙어서 연결되었음을 상정한다. (예를 들어 클라이언트쪽에서 열려있지 않은 서버의 소켓에 접속하려 시도하면 강제로 연결이 끊기면서 에러가 발생한다.) 이에 반해 ZMQ의 소켓은, 컨텍스트 내에서 생성되는 소켓의 재추상화된 버전이다. 소켓은 TCP등의 프로토콜을 사용하는 전통적인 UNIX 소켓일 수도 있으며, 상대방 노드의 종류에 따라 inproc(동일 프로세스 내에서 스레드간 통신을 위한 규격) 혹은 IPC(동일 시스템 내에서 프로세스간 통신을 위한 규격)일 수 있다. 실제 물리적인 소켓은 필요에 따라 생성된다.

실제로 소켓은 UNIX 소켓과 1:1로 대응하지 않는다. 우리가 바라볼 때 소켓의 뒤에는 수신자가 아닌 메시지를 주고 받기 위한 큐가 있을 뿐이다. 기술적으로 ZMQ에서는 하나의 소켓을 여러 개의 포트에 바인딩하거나, 여러 노드에 connect 할 수 있다. (이 경우에 송신할 때나 수신할 때, 여러 포트/노드로 번갈아가며 공평하게 하나씩 전송하거나 전송받는다.)

다음은 간단한 ZMQ에서의 소켓을 이용한 에코서버/클라이언트 구현이다. 컨텍스트가 어떤 소켓을 사용하고 어떤 세팅을 사용할 것인지에 대해서 우리는 큰 고민을 할 필요가 없으며, 몇 가지 핵심적인 코어 메시지 패턴 중에서 하나를 선택하면 된다. 우리가 만들려고 하는 에코 서버/클라이언트는 클라이언트가 서버에 요청을 보내고, 그 요청에 대한 응답을 받는 식으로 서로 한 걸음씩 짝을 맞추어 커뮤니케이션하는 패턴을 갖는다. 이를 ZMQ에서는 REQ-REP 패턴이라고 한다. 이 패턴에 기초해서 소켓을 만들고, 사용한다.

## echo-server.py
## 서버측 코드
import zmq, time

## 컨텍스트를 생성한다.
ctx = zmq.Context()

def run_server():
  ## zmq 소켓을 만들고 포트에 바인딩한다.
  sock = zmq.socket(zmq.REP)
  sock.bind('tcp://*:5555')
  while True:
    ## 소켓을 읽고 그 내용을 출력한 후 다시 되돌려 준다.
    msg = sock.recv()
    print(f'Recieved: {msg.decode()}')
    time.sleep(1)
    sock.send(msg)

run_server()

----
## echo-client.py
## 클라이언트 코드

import zmq, sys

## 동일하게 컨텍스트를 만들고 
ctx = zmq.Context()

def run_client(port=7777):
  ## 컨텍스트로부터 소켓을 만들고 서버에 연결한다.
  sock = ctx.socket(zmq.REQ)
  sock.connect(f'tcp://localhost:{port}')
  while True:
    ## 키보드로부터 입력받은 내용을 서버로 전송하고,
    ## 다시 서버의 응답을 출력한다.
    ## bye를 메시지로 보내고 받으면 소켓을 닫는다.
    line = input()
    sock.send(line.encode())
    rep = sock.recv()
    print(f'Reply: {rep.decode()}')
    if rep.decode() == 'bye':
      sock.close()
      break

port = sys.argv[1] if len(sys.argv) > 1 else 7777
run_client(port)

이 코드들은 전통적인 소켓을 이용한 에코서버 구현보다 훨씬 간단하다.

  1. 소켓을 생성하는데에는 어떤 패밀리나 타입 정보도 필요하지 않다. 단지 컨텍스트에게 메시지 패턴 정보만 알려줄 뿐이다.
  2. 바인드하는 액션외에 listen()이나 accept() 같은 작업은 필요하지 않다. 실제로 포트를 듣고, 연결을 수락하는 물리적인 포트에 대한 추상화된 객체가 존재할 것이나, 이 모든것은 컨텍스트 내부에 있으며, 자동으로 관리된다.
  3. 파이썬의 표준 소켓 api와 비교했을 때, sendall()은 존재하지 않는다. 모든 메시지는 마치 전체가 한 덩어리로 보내지는 것처럼 보인다.

실제 실행에서도 몇 가지 차이를 보이는데, 우선 여느 소켓 서버-클라이언트 예제와 달리 이 파일들에 대해서는 클라이언트를 먼저 실행해도 아무런 에러 없이 실행된다. 또한 클라이언트를 2개 이상 실행했을 때 동시 접속이 되는 것처럼 동작한다. 이는 소켓이 상대방 노드가 아니라 메시지 큐에 맞닿아있고, 큐를 통해 들어오는 메시지를 처리하기 때문에 가능한 일이다.

ZMQ의 소켓은 물리적인 포트에 묶이는 UNIX 소켓이 아니라고 했다. 따라서 메시징 타입이 같다면, 하나의 소켓이 (이미 바인딩되지 않은) 다른 포트들에 멀티로 바인딩되는 것도 가능하다.

sock.bind('tcp://*:5555')
sock.bind('tcp://*:7777')

이렇게 동일한 소켓 하나가 두 개 이상의 포트에 묶이도록 호출하는 것이 아무런 문제가 되지 않으며, 클라이언트를 쪼개서 각각 다른 포트들에 접속하도록 했을 때에도 서버 하나로 동작하는게 가능하다.

메시지

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

메시지 패턴

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

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

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