ZMQ 멀티파트메시지

멀티파트 메시지는 하나의 메시지 프레임 내부에 여러 개의 독립적인 메시지 프레임이 들어 있는 것을 말한다. 이는 하나의 프레임에서 처리하기 힘든 데이터 조각들을 모아서 처리할 때 유용하다. 예를 들어 바이너리 파일 데이터를 전송하려는 경우에는 보내는 쪽이나 받는 쪽이나 전송하는 데이터가 이진데이터라는 것을 알고 있다 가정하여 바이트 스트림을 전송할 수 있다. 하지만 이렇게 하면 실제 데이터 외부에 있었던 정보, 이를 테면 파일 이름이나 생성한 날짜 같은 메타 정보를 전달하기가 어려워진다. 이런 경우 여러 정보들을 멀티 파트 메시지로 묶어서 하나의 프레임으로 전송하면 필요한 모든 정보를 같이 전달해 줄 수 있다.

멀티 파트 메시지 전송은 비단 ZMQ내에서만 사용되는 개념이 아니다. HTTP 규격에서도 멀티 파트 메시지 개념이 존재하며, 실제로 HTML 폼 전송에서 멀티 파트 전송이 널리 사용된다. 여러 개의 필드를 하나의 폼에서 묶어서 submit 한다거나, 여러 개의 파일을 한 번에 업로드하는 폼 들은 모두 enctype='multpart/formdata' 라는 폼 속성을 가지며, 실제 HTTP 전송 헤더 역시 Content-Type 에서 이 값이 사용된다.

ZMQ 에서도 여러 개의 메시지 혹은 프레임을 하나의 메시지로 묶어서 멀티파트로 전송할 수 있다. pyzmq를 사용하는 경우 이는 소켓 객체의 send_multipart() / recv_multipart()  두 개의 메소드로 간단히 구현된다. 다만 문자열이 아닌 버퍼(바이트 배열)를 주고 받아야 한다는 점에 주의만 하면 그외에는 매우 간단하다. 다음은 세 개의 문자열을 하나의 멀티파트 메시지로 전송하는 REQ-REP 예제이다.

## multimessage-server.py

import zmq

ctx = zmq.Context()

def run_server(port=5555):
  sock = ctx.socket(zmq.REP)
  sock.bind(f'tcp://*:{port}')
  keys = 'abc'
  while True:
    msg = dict(zip(keys, (x.decode() for x in sock.recv_multipart())))
    for k, v in msg.items():
      print(f'{k}: {v}')
    ## 대문자화하고 하나의 문자열로 만들어서 되돌려준다.
    sock.send_string(' '.join(msg[k].upper() for k in keys))

if __name__ == '__main__':
  run_server()

recv_multipart() 에 의해 리턴되는 값이 바이트배열의 시퀀스임을 알고 있다면 위 코드는 충분히 이해되리라 본다. REQ-REP 패턴에서 클라이언트에서 서버로 멀티 파트 메시지를 전송했다 하더라도, 반드시 응답이 멀티파트여야 할 필요도 없다. 다음은 위 서버 코드와 짝을 맞출 클라이언트 코드이다. 세 번의 키보드 입력 후 해당 문자열들을 한 번에 전송한다. 그리고 서버가 보내준 단일 메시지를 출력하는 것을 반복하는 간단한 코드이다.

import zmq

ctx = zmq.Context()

def run_client(port=5555):
  sock = ctx.socket(zmq.REQ)
  sock.connect(f'tcp://localhost:{port}')
  while True:
    data = [input().encode() for _ in range(3)]
    sock.send_multipart(data)
    res = sock.recv_string()
    print(res)

if __name__ == '__main__':
  run_client()
    

 

ZMQ – Poller 사용하기

PUB-SUB 패턴이나 PUSH-PULL 패턴을 사용하면 데이터를 발생시키는 1개 (혹은 여러 개의) 노드로부터 데이터를 전달받는 N개의 노드들이 돌아가는 방식의 네트워크를 구성할 수 있음을 지난 글에서 보았다. 이 때 각각의 클라이언트 노드들은 루프를 돌면서 큐에 들어온 메시지를 순서대로 처리하게 된다.

만약 개별 클라이언트가 한 번에 처리해야 할 메시지의 최대 개수가 정해져 있다면 while 무한 루프가 아닌 for 루프를 통해서 유한한 루프를 돌 수 있을 것이다. 그 외에도 메시지의 내용으로부터 동작이나 중지를 결정할 수 있는 경우에도 그 스스로 동작을 멈출 시점을 결정할 수 있을 것이다.

여러 개의 소켓을 듣는 노드

하지만 클라이언트들의 중지를 다른 제 3의 노드가 결정해야 한다면? ‘커맨드 노드’라는 노드가 있고 이 노드가 PUB 패턴으로 모든 클라이언트들에게 중지 명령을 내리는 구조를 만든다고 가정해보자.

그렇다면 클라이언트 입장에서는 한 번에 두 개의 소켓(하나는 SUB 패턴으로 데이터를 지속적으로 수신받고, 다른 하나는 PULL 패턴으로 종료 명령을 수신받는다.) 을 사용하게 된다. 이것은 이전의 패턴들과는 사뭇다르다. 이전 예제들에서 보여왔던 다중 접속은 하나의 노드가 단일 소켓을 사용하면서, 소켓 자체가 여러 포트에 바인딩되거나, 여러 서버에 커넥트되는 것을 보였다. (그리고 ZMQ는 이 구조 내에서 메시지 전달의 교통정리를 훌륭하게 수행해준다.)

소켓으로 데이터를 반복적으로 읽어야 하는 노드의 특성상, 두 개 이상의 소켓을 하나의 노드가 갖는 것이 문제될 일은 없지만 두 노드를 한꺼번에 대기할 수는 없다. 두 개 소켓으로부터 각각 메시지를 수신하기 위해서는 각각 별도의 스레드를 통해서 처리해야 할 것이다.

POLLER

ZMQ에서는 이러한 상황을 좀 더 쉽게 해결할 수 있도록 POLLER라는 것을 제공한다. Poller는 두 개 이상의 소켓을 등록하여두면 소켓들로부터의 입력을 감지하여 (소켓, 이벤트)의 리스트를 리턴해준다. 이를 이용해서 각각의 소켓을 동시에 수신하면서 소켓별로 구분된 메시지를 얻을 수 있다.

def client(data_port=5556, com_port=5558):
  ctx = zmq.Context()

  # 1. SUB 소켓 연결
  sock_sub = ctx.socket(zmq.SUB)
  sock_sub.setsockopts_string(zmq.SUBSCRIBE, '9')
  sock_sub.connect(f'tcp://localhost:{data_port}')

  # 2. PULL 소켓 연결
  sock_pull = ctx.socket(zmq.PULL)
  sock_pull.connect(f'tcp://localhost:{com_port}')
  
  # 3. 폴러 생성 및 설정
  poller = ctx.Poller()
  poller.register(sock_sub, zmq.POLLIN)
  poller.register(sock_pull, zmq.POLLIN)

  # 폴러로부터 이벤트 수신
  should_continue = True
  while should_continue:
    # 4. 각각의 이벤트는 (소켓, 이벤트), (소켓, 이벤트) ... 의 형태로 전달
    # 이를 사전 타입으로 변환
    socks = dict(poller.poll())
    if socks.get(sock_pull, None) == zmq.POLLIN:
      command = sock_pull.recv_string()
      if command == 'EXIT':
         print('This client will be terminated...')
         should_continue = False
    if socks.get(sock_sub, None) == zmq.POLLIN:
      topic, msg = sock_sub.recv_string().split(' ', 1)
      print(f'Processing: {msg}')

  
  

poller는 복수 개의 소켓을 번갈아 수신하기 보다는 특정한 주기로 각 소켓의 메시지 큐를 검사해서 신규 메시지들을 소켓별로 하나씩 가져와서 이벤트의 리스트로 리턴해준다. 폴러에 등록되는 소켓의 타입은 서로 같은 것들이어도 무방하다. 지난 글에서 살펴본 PUSH-PULL 패턴을 사용한 분산처리는 다음과 같이 개선해볼 수 있다.

  1. Ventilator는 PUSH 패턴으로 복수의 Worker에게 데이터를 순차적으로 뿌린다.
  2. Worker는 PULL 소켓으로 데이터를 수신하여 처리한 후, 다시 PUSH 소켓으로 Sink에게 그 결과를 전달한다.
  3. Worker는 또한 SUB 소켓으로 중지명령을 기다리고 있다. 따라서 Worker에는 PULL, SUB 소켓이 Poller로 묶여있게 된다.
  4. Sink는 PULL 소켓으로 여러 Worker로부터 결과를 수신받아 취합한다.
  5. Sink는 모든 결과가 수신되면 PUB 소켓을 통해 모든 Worker들에게 kill 시그널을 전송한다.

이처럼 단순 소켓이 아니라 소켓을 결합해주는 Poller를 사용하면 복잡한 네트워크 그래프를 간단한 방법으로 구축할 수 있다. Poller외에도 ZMQ에서는 양 끝단에 소켓을 부착하여 메시지를 포워딩해주는 device라는 개념이 있는데, 다음 번에는 이 부분에 대해서 한 번 알아보는 기회를 갖도록 하자.

INSERT문 – 테이블에 값 삽입하기 – SQLite3

테이블에 값을 삽입할 때는 INSERT 구문을 사용한다. 이 구문은 크게 세가지 형태로 만들어질 수 있다.

  1. INSERT INTO table (columns...) VALUES (.... ); : 각 칼럼에 (정의하지 않으면 스키마의 모든 칼럼에) ( ... ) 의 값을 각각 적용해서 새로운 레코드를 삽입한다. 지정한 칼럼이나 혹은 전체 칼럼과 값의 개수가 맞지 않으면 에러가 발생한다.
  2. INSERT INTO table SELECT ...; : SELECT 구문을 실행해서 그 결과의 각 행을 해당 테이블의 새로운 행으로 삽입한다. SELECT 결과의 각 칼럼의 순서와 개수는 테이블의 칼럼과 일치해야 한다. 아니면 최소한 칼럼 목록을 명시한다.
  3. INSERT INTO table DEFAULT VALUES; : 모든 칼럼의 디폴트값이 정의되어 있다면 디폴트 값으로 새 레코드를 생성한다.

액션

INSERT 키워드는 REPLACE, INSERT OR REPLACE 등으로 변경될 수 있다. 이는 특정 제한에 의한 충돌 발생 시 이를 해결하기 위한 방편을 정의한다. REPLACEINSERT OR REPLACE와 같은 명령이며 정확하게는 INSERT 문 내에서 ON CONFILCT에 대한 처리로, UNIQUE 제약이나 PRIMARY KEY가 겹치는 등의 삽입 오류가 발생했을 때, 기존 레코드를 UPDATE 하는 방식으로 처리한다.

파이썬 연습문제 – 누워있는 드럼통에 들어있는 기름의 부피

반지름이 R cm이고 높이가 H cm 인 드럼통에 기름이 들어있다. 이 드럼통 내의 기름의 높이가 A라 할 때 기름의 부피를 구하는 것은 원기둥의 부피를 구하는 것이므로 그리 어렵지 않다. 그런데 드럼통이 세워져 있지 않고 수평으로 누워서 설치되었을 때, 기름의 높이가 B 이면, 이 때 드럼 통 내의 부피를 계산해보자. 간단한(?) 적분 문제인데, 여기서는 구분구적법을 적용해서 근사치를 구해볼 것이다.

파이썬 연습문제 – 누워있는 드럼통에 들어있는 기름의 부피 더보기

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를 이용하여 가장 쉽게 할 수 있는 것 (바로 두 노드 간의 통신을 구축하는 것)을 사용하여 얼마든지 다방향 네트워크를 구축할 수 있다는 점이다.