방법

서비스간 혹은 서비스와 브라우저간 서로 통신을 하려면 어떻게 하는 것이 좋을까? 내 컴퓨터에서 실행되는 Java와 C++ 프로그램 사이의 통신처럼, 동일 머신내 서로 다른 프로세스 사이의 통신을 IPC라고 한다. IPC에는 여러 가지 방법이 있는데, 이들을 응용 및 확장하여 네트워크 환경에서도 적용하는 방향으로 발전해 온 것 같다. 대강 검색해보니 아래와 같은 방법들이 보였다.

  • Socket : TCP Socket
  • Message Queue : Mosquitto, RabbitMQ, HiveMQ, Kafka, ZeroMQ
  • Remote Procedure Call : zeroRPC, gRPC, Thrift
  • python-shell
  • 이 밖에 Shared Memory, Pipe, Memory Map 등이 있으나 웹 개발과 관련이 적어 패스

여기서는 Socket, Message Queue, RPC를 주제로 간단한 실험을 해 본다. 우선 간단히 개념을 정리해보면 다음과 같다.

  • Socket : 네트워크 통신을 위해 데이터가 드나들 수 있도록 특정 포트에 할당된 일종의 창구
  • Message Queue: 큐를 사용한 전송 기법. 네트워크에서는 프로토콜이 필요한데 MQTT(Application Layer, TCP/IP 기반)가 대표적
  • RPC : 원격 프로시저 호출을 통해 데이터를 얻는 기법. 자세한 원리는 대학 교재에 나올 듯

그러면 바로 비교 실험으로 들어가 보자.

TCP Socket

Node 클라이언트에서 파이썬 서버로 TCP 스트림 소켓을 통해 10바이트 패킷을 30회 전송하고 응답을 받았을 때 소요된 시간은 80ms, 회당 2.66ms 정도가 된다. 또, Node에서 파이썬으로 패킷 전송을 8ms 간격으로 30회 수행했을 때 총 소요 시간은 270ms였다.

import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 5555))
sock.listen()
while True:
	client, address = sock.accept()		
	client.settimeout(60)
	buffsize = 10	
	while True:
		try:
			data = client.recv(buffsize)
			if data:
				response = data
				print(response)
				client.send(response)
		except:
			print('--- disconnected ---')
			break
var net = require('net');
const client = new net.Socket();
client.connect({ port: 5555, host: '' }, function() {
	console.log('connected...');
	let count = 0;
	while(true) {		
		let d = new Date()
		let c = 
		let m = 
		client.write('NODE:'+("00"+count).slice(-2)+("000"+d.getMilliseconds()).slice(-3));
		count++;
		if(count > 30) 
			break;
	}
});
client.on('data', function(chunk) {
	let d = new Date()
	console.log('RECV : ', chunk.toString(), d.getSeconds(), d.getMilliseconds());
});

Socket.io, Engine.io

웹소켓을 사용하면 브라우저에서 TCP 통신을 수행할 수 있다. 브라우저와 Node 서버간 30회의 전송에 소요된 시간은 70~100ms, 회당 2.33~3.33ms로 다른 방법들에 비해 들쭉날쭉한 편이었다. 또, 8ms 간격으로 30회 수행했을 때 총 소요 시간은 250~300ms 정도였다. 다른 방법들에 비해 안정적이지 않은 느낌.

const engine = require('engine.io');
const server = engine.listen(5555, {cors:true});
server.on('connection', socket => {
	socket.on('message', msg => {
		console.log(msg);
		let d = new Date()
		socket.send(msg + " - " +("000"+d.getMilliseconds()).slice(-3));
	});
  // socket.send(Buffer.from([0, 1, 2, 3, 4, 5])); // binary data
});
<!DOCTYPE html>
<html>
  <head>
		<script type="text/javascript" src="/engine.io.min.js"></script>
		<script>
			const socket = eio('ws://localhost:5555');
			socket.on('open', () => {
				let count = 0;
				while(true) {		
					let d = new Date()
					socket.send(("00"+count).slice(-2)+("000"+d.getMilliseconds()).slice(-3));
					count++;
					if(count > 30) 
						break;
				}
				socket.on('message', (data) => { console.log(data) });
			});
		</script>	
  </head>
<body>
</body>
</html>

Mosquitto

유명한 MQTT 브로커 중 하나이다. MQTT는 사물인터넷과 같은 적은 리소스 환경에서 다대다의 신뢰성 있는 통신을 위해 설계된 경량의 프로토콜이며 센서 네트워크 등에 적합하다고 한다. Mosquitto는 계층구조의 Topic, 영구 세션 옵션, QoS 0/1/2, Authentication, MQTT over WebSocket 등을 지원한다.

MQTT는 Broker, Publisher, Subscriber 세 가지로 구성된다. 유튜브를 예로 들면 유튜브=Broker, 유튜버=Publisher, 구독자=Subscriber가 될 것이다. 유튜버 A가 동영상을 유튜브에 올리면 A의 구독자들이 알림을 받는 것과 비슷한 방식이다.

우선 브로커(Broker)를 실행해보자. 윈도우에서는 mosquitto 인스톨러(mosquitto-2.0.2-install-windows-x64)를 실행하면 설치된다. 설치된 폴더에서 mosquitto.exe를 실행하면 검은 창이 그대로 멈춰있는데 이 상태가 브로커가 구동된 것이다.

다음은 구독자(Subscriber)를 Node 스크립트로 만들어보자. mqtt 라는 패키지를 사용한다. 실행해보면 메시지 수신 대기 상태가 될 것이다.

npm install --save mqtt
var mqtt = require('mqtt')
var client  = mqtt.connect('mqtt://localhost')
client.on('connect', function () {
	console.log('connected...')
	client.subscribe('/TOPIC/1', function (err) { })
})
client.on('message', function (topic, message) {
	let d = new Date()
	console.log(message.toString(), d.getSeconds(), d.getMilliseconds())
	// client.end()
})

이번엔 발행자(Publisher)를 파이썬으로 만들어보자. paho-mqtt 패키지를 사용한다. 실행하면 메시지를 발행한다.

pip37 install paho-mqtt
import paho.mqtt.client as mqtt
import time
client = mqtt.Client()
client.connect('localhost')
client.loop_start()
count = 0
while True:
	start = datetime.datetime.now()
	client.publish('/TOPIC/1', "Hello World..." + str(count+1) + " " +  str(start.microsecond / 1000.0), 1)
	count += 1
	if count == 30:
		break	
for i in range(30):
	client.publish('/TOPIC/1', "Hello World...", 1)
	time.sleep(0.008)
client.loop_stop()
client.disconnect()

참고로 mosquitto 설치 폴더에서 명령어를 사용해 발행을 할 수도 있다.

mosquitto_pub -t MY_TOPIC -m "HELLO WORLD"

새 메시지가 발행되면 구독자(Node) 쪽에 메시지가 도착하는 것을 볼 수 있다. 로컬호스트 환경에서 파이썬에서 Node로 발행과 수신을 30회 수행했을 때 총 소요 시간은 70ms, 회당 2.33ms였다. 또, 파이썬에서 Node로 발행과 수신을 8ms 간격으로 30회 수행했을 때 총 소요 시간은 250ms였다.

Mosquitto 양방향통신

양방향 통신을 위해서는 다음과 같이 생각해볼 수 있다. 구독자도 유튜버가 되어 동영상을 업로드하기 시작했고, 유튜버A는 구독자의 동영상을 구독하기 시작했다. 즉, 파이썬 쪽과 Node 모두 발행자이자 구독자가 되는 것이다. 이렇게 테스트했을 때의 소요 시간은 2배로 늘어날 줄 알았는데 신기하게도 70ms 거의 그대로였다.

import paho.mqtt.client as mqtt
import datetime
import time
import math
def on_message(client, userdata, message):
	msg=str(message.payload.decode("utf-8"))
	client.publish('/FROMPY', "PY:" + msg, 1)
client = mqtt.Client()
client.connect('localhost')
client.on_message=on_message
client.loop_start()	
client.subscribe("/FROMNODE")
while True:
	time.sleep(0.001)	
#client.loop_stop()
#client.disconnect()
var mqtt = require('mqtt')
var client  = mqtt.connect('mqtt://localhost')
client.on('connect', function () {
	console.log('connected...')
	client.subscribe('/FROMPY', function (err) { })
	let count = 0;
	while(true) {		
		let d = new Date()
		let msg = 'NODE:'+("00"+count).slice(-2)+("000"+d.getMilliseconds()).slice(-3)
		client.publish('/FROMNODE', msg)		
		count++;
		if(count > 30) 
			break;
	}	
})
client.on('message', function (topic, message) {
	let d = new Date()
	console.log('ONMESSAGE: '+ message.toString() + ' NODE:', d.getMilliseconds())
	// client.end()
})

Browser - Mosquitto

mosquitto 브로커에서 웹소켓을 사용하도록 설정해 브라우저에서 바로 접근할 수 있다. 그러나 윈도우 버전에서는 인스톨러로 설치했을 때 바로 웹소켓을 사용할 수는 없었다. libwebsockets 등을 빌드하고 어쩌고 하는 과정이 필요한 것 같다. 빌드 등을 끝내면 mosquitto.conf를 아래처럼 수정하면 된다고 한다. 여기서는 일단 테스트를 위해 브로커는 원격지의 broker.hivemq.com를 대신 사용하기로 했다.

listener 9001
protocol websockets

Browser - MQTT.js

위에서 사용한 mqtt.js는 브라우저에서도 사용할 수 있다. 아래와 같이 browser-build를 실행하면 mqtt.min.js를 생성할 수 있다. 브라우저에서 이를 불러오면 Node에서와 같이 mqtt 브로커에 접속할 수 있다. 이 때 접속 주소에 웹소켓 프로토콜을 지정해주는 것을 유의하자. 웹브라우저에서 broker.hivemq.com로 발행과 수신을 30회 수행하였는데, 30~40ms 간격으로 통신할 수 있었다.

cd node_modules/mqtt
npm install . // install dev dependencies
npm run browser-build
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>gRPC-Web Example</title>
<script src="./browser/mqtt.min.js"></script>
</head>
<body>
  <p>Open up the developer console and see the logs for the output.</p>
	<script>
		var client = mqtt.connect('ws://broker.hivemq.com:8000/mqtt')
		client.on('connect', function () {
			console.log('connected...')
			client.subscribe('/TEST/1', function (err) { 
				if (!err) {
					let count = 0;
					let pub = setInterval(()=> {
						client.publish('/TEST/1', 'Hello mqtt')
						count++;
						if(count > 30) clearInterval(pub);
					}, 30)					
				}			
			})
		})
		client.on('message', function (topic, message) {
			let d = new Date()
			console.log(message.toString(), d.getSeconds(), d.getMilliseconds())
			// client.end()
		})		
	</script>
</body>
</html>

Browser - Eclipse Paho Javascript Client

Eclipse Paho Javascript Client를 사용해보았다. 마찬가지로 웹브라우저에서 broker.hivemq.com로 발행과 수신을 30회 수행하였는데, 30~40ms 간격으로 통신할 수 있었다. Paho의 경우 코드는 조금 길어지지만 라이브러리 용량이 34KB로 mqtt.js(191KB)에 비해 훨씬 작다.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>gRPC-Web Example</title>
<script src="./browser/paho-mqtt-min.js"></script>
</head>
<body>
  <p>Open up the developer console and see the logs for the output.</p>
	<script>
		client = new Paho.MQTT.Client('broker.hivemq.com', 8000, 'test');
		client.onMessageArrived = onMessageArrived;
		client.connect({onSuccess:onConnect});
		function onConnect() {
			console.log("connected...");
			client.subscribe("/TEST/1");			
			let count = 0;
			let pub = setInterval(()=> {
				message = new Paho.MQTT.Message("Hello MQTT over Websockets");
				message.destinationName = "/TEST/1";
				client.send(message);				
				count++;
				if(count > 30) clearInterval(pub);
			}, 30)
		}
		function onMessageArrived(message) {
			let d = new Date()
			console.log(message.payloadString, d.getSeconds(), d.getMilliseconds())			
		}		
	</script>
</body>
</html>

이것을 웹서버 같은 서비스에 어떻게 적용할 것인지는 일단 미루어두고, 이번에는 RPC로 넘어가보자.

zeroMQ / zeroRPC

zeroMQ는 Mosquitto 등의 MQTT 브로커들과 달리 브로커 없이 동작하는 메시징 라이브러리이다. 여러가지 패턴(pub/sub, request/reply, client/server and others)과 전송(TCP, in-process, inter-process, multicast, WebSocket and more)을 지원한다고 한다. 정체가 뭔지 잘 모르겠다.

zeroRPC는 zeroMQ기반의 메시징/RPC 프레임워크다. 도커 개발자가 만들었다고 하며, 사용법이 직관적이고 스트리밍도 가능하다. zeroRPC를 기반으로 한 node-python-collaboration 이라는 Node 패키지를 사용하면 좀 더 간편하게 통신할 수 있다. mqtt.js나 gRPC-Web처럼 브라우저에서 직접 zeroRPC 서버로 접근하는 방법은 좀 더 찾아봐야겠다.

Node에서 zeroRPC 설치가 잘 되지 않은 관계로 우선 파이썬으로 해보았다. 파이썬에서 파이썬의 hello 메소드를 30회 호출하고 결과를 전달받기까지 총 소요 시간은 150ms, 회당 5ms였다.

pip37 install zerorpc
import zerorpc
class HelloRPC(object):
    def hello(self, name):
        return "Hello, %s" % name
s = zerorpc.Server(HelloRPC())
s.bind("tcp://0.0.0.0:4242")
s.run()
import zerorpc
import datetime
import time
c = zerorpc.Client()
c.connect("tcp://127.0.0.1:4242")
count = 0
while True:
	start = datetime.datetime.now()
	print(c.hello("RPC"), count+1, start.microsecond / 1000.0)
	count += 1
	if count == 30:
		break

추가 : Node를 14버전에서 10버전으로 다운그레이드하면 npm install zerorpc로 잘 설치가 된다. (windows-build-tools –vs2017 설치) Node에서 파이썬의 hello 메소드를 30회 호출하고 결과를 전달받기까지 총 소요 시간은 60ms, 회당 2ms로 파이썬보다 훨씬 빨랐다. 스트림을 사용하면 더 빠를 것 같다. 참고로 Node 14버전에서는 실행이 안 된다.

var zerorpc = require("zerorpc");
var client = new zerorpc.Client();
client.connect("tcp://127.0.0.1:4242");
let count = 0;
while(true) {		
	let d = new Date()
	let msg = 'NODE:'+("00"+count).slice(-2)+("000"+d.getMilliseconds()).slice(-3)
	client.invoke("hello", msg, function(error, res, more) {
		console.log(res);
	});		
	count++;
	if(count > 30) 
		break;
}

gRPC

경량화/푸시/스트리밍 등을 지원하는 HTTP/2 기반에, 작은 메시지 페이로드를 갖는 ProtoBuf 직렬화 포맷 이진 데이터를 사용하는 RPC 프레임워크다. HTTP/1.1+JSON 방식에 비해 상당히 효율적이라고 한다. 특히 gRPC-Web을 사용하면 브라우저에서 gRPC 서비스에 접근할 수 있다. 작은 서비스들을 유기적으로 결합한 마이크로 서비스, 백엔드 간 실시간 양방향 스트리밍 통신, 다중 언어를 사용하거나 동일한 머신에 있는 앱 간의 통신, 네트워크가 제한된 환경 등에 유용하다고 한다. 구글에서 만들었다.

Node에서 파이썬의 sayHello 함수를 30회 호출하고 결과를 전달받기까지 총 소요 시간은 100ms, 회당 3.33ms였다.

python37 -m pip install --upgrade pip
python37 -m pip install grpcio
python37 -m pip install grpcio-tools
cd grpc/examples/python/helloworld
python37 greeter_server.py
git clone -b v1.34.0 https://github.com/grpc/grpc
cd grpc/examples/node/dynamic_codegen
npm install
node greeter_client.js
// grpc/examples/protos/helloworld.proto - service Greeter 부분에 추가
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}
# .proto 파일을 수정하면 컴파일해야 한다. node-watch 등으로 자동 컴파일이 되려나?
python37 -m grpc_tools.protoc -I../../protos --python_out=. --grpc_python_out=. ../../protos/helloworld.proto
# grpc/examples/python/helloworld/greeter_server.py - class Greeter 부분에 추가
def SayHelloAgain(self, request, context):
	return helloworld_pb2.HelloReply(message='Hello again, %s!' % request.name)
// grpc/examples/node/dynamic_codegen/greeter_client.js - main() 부분에 추가
client.sayHelloAgain({name: 'you'}, function(err, response) {
  console.log('Greeting:', response.message);
});	

gRPC-Web

윈도우 환경에서 퀵스타트 문서를 따라 실행해보았으나 docker-compose up 15단계에서 실패…

https://docs.docker.com/docker-for-windows/install/ # docker, docker-compose for windows
git clone https://github.com/grpc/grpc-web
cd grpc-web
docker-compose pull
docker-compose up -d node-server envoy commonjs-client # 15단계에서 bash 어쩌구 뜨면서 실패
http://localhost:8081/echotest.html
docker-compose down

그래서 gRPC-Web Hello World Guide를 따라 해보았다.

protoc-gen-grpc-webprotoc를 사용해 helloworld_pb.jshelloworld_grpc_web_pb.js를 생성한다.

protoc -I=. helloworld.proto --js_out=import_style=commonjs:. --grpc-web_out=import_style=commonjs,mode=grpcwebtext:.

Node 서버를 실행한다.

npm install
npx webpack client.js
node server.js

윈도우라서 envoy.yaml을 약간 수정해주고 도커로 envoy를 실행한다. 실행명령어도 윈도우는 조금 다르다.

hosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}]
# linux
docker run -d -v "$(pwd)"/envoy.yaml:/etc/envoy/envoy.yaml:ro --network=host envoyproxy/envoy:v1.16.1

# windows
docker run -d -v D:/grpc-web-hw/envoy.yaml:/etc/envoy/envoy.yaml:ro -p 8080:8080 -p 9901:9901 envoyproxy/envoy:v1.16.1

파이썬 심플 서버도 띄워준다.

python37 -m http.server 8081

브라우저로 localhost:8081로 들어가 콘솔을 열어 보면 Hello! World가 찍혀 있는 것을 볼 수 있다.

샘플에 있는 스트림 예제인 sayRepeatHello를 사용해 Hey! World를 브라우저로 30회(매회 10ms의 delay) 전달하는 데 소요된 시간은 로컬호스트 환경에서 평균 350ms 정도였다.

마지막으로 gRPC 서버를 Node가 아닌 파이썬으로 대체해보았다. 파이썬 예제의 HelloWorld 서버와 브라우저 gRPC-Web이 서로 잘 통신하여 Hello, World!가 찍히는 것을 확인했다.

순위

서비스 대 서비스 / 1회 통신 소요 시간

  1. zeroRPC : 파이썬↔Node / 2ms

  2. Mosquitto : 파이썬↔Node / 2.33ms

  3. TCP Socket : 파이썬↔Node / 2.66ms

  4. gRPC : 파이썬↔Node / 3.33ms

  5. zeroRPC : 파이썬↔파이썬 / 5ms

브라우저 대 서비스 / 1회 통신 소요 시간

  1. gRPC-Web : 크롬↔Node / 0.5ms

  2. socket.io, engine.io : 크롬 ↔ Node / 2.33~3.33ms

  3. mqtt.js, paho js : 크롬↔원격지브로커(broker.hivemq.com) / 30~40ms

결과

서비스 간 통신에서는 Socket, Message Queue, RPC 모두 좋은 성능을 보여주었으며, 파이썬에 비해 Node일 때 성능이 좀 더 좋았다. 브라우저와 서비스 간의 통신에서는 아쉽게도 정확한 비교가 어려웠지만, gRPC-Web 의 성능이 엄청난 것을 확인할 수 있었다.

참고