모놀리식 아키텍처는 모든 기능을 하나의 프로세스로 구성하기 때문에 장애 상황에 취약하고,
장애가 발생하게되면 전체 서비스에 영향을 미치기 때문에 피해 또한 심각하다.
시스템 간 네트워크를 어떻게 연결할지 먼저 생각해야한다.
토폴로지
컴퓨터 네트워크를 구성할 때 링크, 노드 등을 이용해 물리적으로 연결하는 방식.
버스형, 트리형, 링형, 성형, 망형 등이 있다.
망형
그물 모양으로 각 노드를 1:1 연결하는 구조. 장애에 가장 안정적이지만 구현이 어렵다.
망형 토폴로지를 소프트웨어적으로 구현하려면 노드들의 위치와 접속 가능 상태를 알아야 한다.
가장 간단한 방법은 모든 노드가 알고 있는 위치에 자신의 정보를 저장하는 것.
저장된 정보를 조회하면 분산 환경에서 원하는 노드에 접속할 수 있다.
안정적으로 분산 처리를 하려면 상태를 저장하는 서버인 Distributor에 3가지 상태를 고려해야한다.
Distributor가 실행되지 않았을 때도 노드들은 Distributor에 주기적으로 접속을 시도해야한다.
노드가 Distributor에 접속하거나 접속이 종료되었을 때 Distributor는 이를 인지하고, 다른 노드에 이 사실을 전파해야 한다.
Distributor가 종료되어도 각 노드는 알고 있는 정보를 이용해 노드 간 접속 상태를 유지해야하고 1.의 상태로 돌아가 Distributor에 다시 접속될 때까지 주기적으로 접속을 시도해야한다.
Distributor는 TCP 서버로 만드는 것이 여러모로 유리하다.
Distributor 입장에서 각 노드는 클라이언트이지만, 요청을 처리하는 서버가 되기도 한다.
생상선을 높일 수 있도록 Distributor와 각 노드에서 공통으로 사용할 클라이언트와 서버 클래스를 만들고, 이 클래스를 상속받아 구현해 보겠다.
클라이언트 클래스는 기본 기능으로 접속
, 데이터 수신
, 데이터 발송
세 가지 기능으로 구성된다. 자식 클래스에서는 접속(connnect)
과 데이터 발송(write)
함수에만 접근할 수 있다.
import net from 'net';
class tcpClient {
constructor(host, port, onCreate, onRead, onEnd, onError) {
this.options = {
host: host,
port: port
};
this.onCreate = onCreate;
this.onRead = onRead;
this.onEnc = onEnd;
this.onError = onError;
}
connect() {
this.client = net.connect(this.options, () => {
if (this.onCreate) this.onCreate(this.options);
});
this.client.on('data', (data) => {
var sz = this.merge ? this.merge + data.toString() : data.toString();
var arr = sz.split('¶');
for (var n in arr) {
if (sz.charAt(sz.length - 1) != '¶' && n == arr.length - 1) {
this.merge = arr[n];
break;
} else if (arr[n] == "") {
break;
} else {
this.onRead(this.options, JSON.parse(arr[n]));
}
}
});
this.client.on('close', () => {
if (this.onEnd)
this.onEnd(this.options);
});
this.client.on('error', (err) => {
if (this.onError)
this.onError(this.options, err);
});
}
write(packet) { this.client.write(JSON.stringify(packet) + '¶');
}
}
export default tcpClient;
connect 함수를 만들어 생성자에서 전달받은 접속 정보로 접속하도록 한다.
서버에 접속되면 생성자에서 전달받은 콜백 함수로 접속 완료 이벤트를 알려준다.
연결된 소켓을 이용해 데이터가 수신되면 데이터 수신을 처리한다.
이때 모든 패킷은 JSON 현태로 구성한다. 마지막에 ¶ 문자를 붙이는 이유는 패킷별로 구분해서 처리하기 위해서다.
서버의 기본 기능인 리슨
, 데이터 수신
, 클라이언트 접속 관리
에 추가로 클라이언트 클래스를 이용해서 Distributor에 주기적으로 접속을 시도하는 기능(connectToDistributor)을 만든다.
import net from 'net';
import tcpClient from './client.js';
class tcpServer {
constructor(name, port, urls) {
this.context = {
port: port,
name: name,
urls: urls
}
this.merge = {};
this.server = net.createServer((socket) => {
this.onCreate(socket);
socket.on('error', (exception) => {
this.onClose(socket);
});
socket.on('close', () => {
this.onClose(socket);
});
socket.on('data', (data) => {
var key = socket.remoteAddress + ":" + socket.remotePort;
var sz = this.merge[key] ? this.merge[key] + data.toString() :
data.toString();
var arr = sz.split('¶');
for (var n in arr) {
if (sz.charAt(sz.length - 1) != '¶' && n == arr.length - 1) {
this.merge[key] = arr[n];
break;
} else if (arr[n] == "") {
break;
} else {
this.onRead(socket, JSON.parse(arr[n]));
}
}
});
});
this.server.on('error', (err) => {
console.log(err);
});
this.server.listen(port, () => {
console.log('listen', this.server.address());
});
}
onCreate(socket) {
console.log("onCreate", socket.remoteAddress, socket.remotePort);
}
onClose(socket) {
console.log("onClose", socket.remoteAddress, socket.remotePort);
}
connectToDistributor(host, port, onNoti) {
var packet = {
uri: "/distributes",
method: "POST",
key: 0,
params: this.context
};
var isConnectedDistributor = false;
this.clientDistributor = new tcpClient(
host
, port
, (options) => { // Distributor 접속 이벤트
isConnectedDistributor = true;
this.clientDistributor.write(packet);
}
, (options, data) => { onNoti(data); }
, (options) => { isConnectedDistributor = false; }
, (options) => { isConnectedDistributor = false; }
);
// 주기적으로 재접속 시도
setInterval(() => {
if (isConnectedDistributor /= true) {
this.clientDistributor.connect();
}
}, 3000);
}
}
}
export default tcpServer;
Distributor에 접속하기 위한 함수를 선언하고, 파라미터로 접속 정보와 Distributor에 접속했을 때 콜백받을 함수를 전달받는다.
분산 아키텍처의 성능을 보장하려면 프로토콜을 통일해야 한다.
특히 마이크로서비스처럼 많은 노드 간에 통신이 필요할 때 프로토콜 통일은 필수다.
프로토콜을 정의하려면 포맷과 헤더 정보를 정의해야한다.
[
{
"port": "첫 번째 노드의 포트",
"name": "첫 번째 노드의 이름",
"urls": [
"첫 번째 노드의 첫 번째 url",
"첫 번째 노드의 두 번째 url",
......
],
"host": "첫 번째 노드의 host"
},
{
"port": "두 번째 노드의 포트",
"name": "두 번째 노드의 이름",
"urls": [
"두 번째 노드의 첫 번째 url",
"두 번째 노드의 두 번째 url",
......
],
"host": "두 번째 노드의 host"
},
......
]
Distributor에는 노드가 접속하면 접속한 노드에 현재 접속 중인 다른 노드의 정보를 제공하고, 노드 접속이 종료되면 다른 접속된 노드에 전파하는 기능을 구현한다.
Distributor는 모든 노드가 접속해 자신의 정보를 저장하므로, Distributor에는 로그 처리 모니터링 등 많은 기능을 추가할 수 있다.
이때 내부구조가 너무 복잡해지지 않도록 주의한다.
map 객체를 선언하고 서버 클래스를 상속받아 접속한 클라이언트의 정보를 저장한다.
var map = {};
class distributor extends tcpServer {
constructor() {
super("distributor", 9000, ["POST/distributes", "GET/distributes"]);
}
onCreate(socket) {
console.log("onCreate", socket.remoteAddress, socket.remotePort);
this.sendInfo(socket);
}
onClose(socket) {
var key = socket.remoteAddress + ":" + socket.remotePort;
console.log("onClose", socket.remoteAddress, socket.remotePort);
delete map[key];
this.sendInfo();
}
onRead(socket, json) {
var key = socket.remoteAddress + ":" + socket.remotePort;
console.log("onRead", socket.remoteAddress, socket.remotePort, json);
if (json.uri == "/distributes" && json.method == "POST") {
map[key] = {
socket: socket
};
map[key].info = json.params;
map[key].info.host = socket.remoteAddress;
this.sendInfo();
}
}
write(socket, packet) {
socket.write(JSON.stringify(packet) + '¶');
}
sendInfo(socket) {
var packet = {
uri: "/distributes",
method: "GET",
key: 0,
params: []
};
for (var n in map) {
packet.params.push(map[n].info);
}
if (socket) {
this.write(socket, packet);
} else {
for (var n in map) {
this.write(map[n].socket, packet);
}
}
}
}
new distributor();