본문 바로가기

개발

Node.js 클러스터링과 로드 밸런싱을 통한 확장성 개선

Node.js의 싱글 스레드와 멀티 코어 문제 소개

Node.js는 JavaScript를 사용하여 서버 사이드 애플리케이션을 개발할 수 있는 플랫폼입니다. Node.js는 논블로킹(non-blocking) I/O와 이벤트 루프(event loop)를 통해 높은 처리 성능을 제공합니다. 하지만 Node.js는 기본적으로 싱글 스레드(single thread)로 동작하기 때문에, CPU가 멀티 코어인 경우에는 하나의 코어만 사용하고 나머지 코어는 활용하지 못합니다. 이는 컴퓨터의 성능을 충분히 발휘하지 못하고, 애플리케이션의 확장성(scalability)을 저하시킬 수 있습니다.

 

확장성이란 애플리케이션이 점점 많은 요청을 처리하거나 데이터를 저장하거나 기능을 추가할 때 성능이 저하되지 않고 잘 동작할 수 있는 능력을 의미합니다. 확장성을 높이기 위한 방법에는 크게 두 가지가 있습니다.

 

수직 확장(vertical scaling): 하나의 서버에 더 많은 CPU나 메모리 등의 자원을 추가하는 방법입니다. 이 방법은 비용이 많이 들고 한계가 있습니다.

수평 확장(horizontal scaling): 여러 개의 서버에 동일한 애플리케이션을 분산하여 실행하는 방법입니다. 이 방법은 비용이 적게 들고 무한대로 확장할 수 있습니다.

Node.js에서는 수평 확장을 위해 클러스터링(clustering)과 로드 밸런싱(load balancing)이라는 기술을 사용할 수 있습니다.

 

Node.js의 cluster 모듈 소개와 사용 방법

클러스터링이란 하나의 애플리케이션을 여러 개의 프로세스(process)로 복제하여 실행하는 것입니다. 프로세스란 운영체제에서 실행 중인 프로그램의 인스턴스를 의미합니다. Node.js에서는 cluster 모듈이라는 코어 모듈을 제공하여 클러스터링을 구현할 수 있습니다.

 

cluster 모듈은 하나의 마스터 프로세스(master process)와 여러 개의 워커 프로세스(worker process)로 구성됩니다. 마스터 프로세스는 워커 프로세스를 생성하고 관리하는 역할을 하고, 워커 프로세스는 실제 애플리케이션 로직을 실행하는 역할을 합니다. 마스터 프로세스와 워커 프로세스는 IPC(inter-process communication)라는 방식으로 서로 통신할 수 있습니다.

 

cluster 모듈을 사용하는 방법은 다음과 같습니다.

cluster 모듈을 불러옵니다.

const cluster = require("cluster");

cluster.isPrimary 속성을 사용하여 현재 프로세스가 마스터인지 워커인지 구분합니다. 

if (cluster.isPrimary) { ... } else { ... }

마스터 프로세스에서는 cluster.fork() 메서드를 사용하여 워커 프로세스를 생성합니다. 보통 CPU의 코어 수만큼 생성합니다. 

for (let i = 0; i < numCPUs; i++) { cluster.fork(); }

워커 프로세스에서는 실제 애플리케이션 코드를 작성합니다. 예를 들어, 서버를 생성하고 요청을 처리합니다. 

http.createServer((req, res) => { ... }).listen(8000);

마스터 프로세스와 워커 프로세스에서는 cluster.on() 메서드를 사용하여 각종 이벤트를 감지하고 처리할 수 있습니다. 예를 들어, 워커 프로세스가 종료되면 다시 생성하거나, 로그를 출력하거나, IPC를 통해 메시지를 주고받을 수 있습니다. 

cluster.on("exit", (worker, code, signal) => { ... });

Node.js의 pm2 모듈 소개와 사용 방법

pm2는 Node.js 애플리케이션을 위한 고급 프로세스 관리자(process manager)입니다. pm2는 다음과 같은 기능을 제공합니다.

클러스터 모드(cluster mode): CPU의 코어 수만큼 애플리케이션 인스턴스를 생성하고 로드 밸런싱을 자동으로 수행합니다.

데몬(daemon): 백그라운드에서 애플리케이션을 실행하고 관리합니다.

모니터링(monitoring): 애플리케이션의 CPU와 메모리 사용량, 요청 수, 에러 수 등의 성능 지표를 실시간으로 확인할 수 있습니다.

로깅(logging): 애플리케이션의 표준 출력과 에러 출력을 파일로 저장하고 관리합니다.

재시작(restart): 애플리케이션에 변경 사항이 있거나 에러가 발생하면 자동으로 재시작합니다.

 

pm2 모듈을 사용하는 방법은 다음과 같습니다.

pm2 모듈을 설치합니다. 

npm install pm2 -g

pm2 start 명령어를 사용하여 애플리케이션을 실행합니다. 클러스터 모드를 사용하려면 -i 옵션에 인스턴스 수를 지정합니다. 

pm2 start app.js -i max

pm2 stop 명령어를 사용하여 애플리케이션을 중지합니다. 

pm2 stop app.js

pm2 restart 명령어를 사용하여 애플리케이션을 재시작합니다. 

pm2 restart app.js

pm2 logs 명령어를 사용하여 애플리케이션의 로그를 확인합니다.

 pm2 logs

pm2 monit 명령어를 사용하여 애플리케이션의 모니터링 정보를 확인합니다. 

pm2 monit

Nginx를 이용한 로드밸런싱과 역방향 프록시 설정방법

로드 밸런싱이란 여러 개의 서버에 클라이언트의 요청을 균등하게 분산시켜 처리하는 방법입니다. 로드 밸런싱을 통해 서버의 부하를 줄이고, 가용성과 성능을 높일 수 있습니다. 로드 밸런싱을 수행하는 서버를 로드 밸런서(load balancer)라고 합니다.

 

역방향 프록시란 클라이언트와 서버 사이에 위치하여 클라이언트의 요청을 대신 받아 서버로 전달하고, 서버의 응답을 받아 클라이언트로 전달하는 서버입니다. 역방향 프록시를 통해 보안과 캐싱 등의 기능을 제공할 수 있습니다. 역방향 프록시를 수행하는 서버를 리버스 프록시(reverse proxy)라고 합니다.

 

Nginx는 웹 서버로서 로드 밸런싱과 역방향 프록시 기능을 제공합니다. Nginx를 이용하여 Node.js 애플리케이션의 로드 밸런싱과 역방향 프록시를 설정하는 방법은 다음과 같습니다.

Nginx를 설치하고 실행합니다. 

sudo apt install nginx 
sudo systemctl start nginx

Nginx의 기본 설정 파일인 /etc/nginx/nginx.conf 를 열고 http 블록 안에 server 블록과 upstream 블록을 작성합니다.

server 블록에서는 다음과 같은 설정을 합니다.

- listen: Nginx가 수신할 포트 번호를 지정합니다. 보통 80번 포트를 사용합니다.

- server_name: Nginx가 응답할 도메인 이름을 지정합니다. 예를 들어, example.com 이라고 합니다.

- location: 요청 URL의 경로에 따라 다른 설정을 적용할 수 있습니다. 예를 들어, / 는 루트 경로를 의미합니다.

- proxy_set_header: 요청 헤더에 추가할 값을 지정합니다. 예를 들어, X-Forwarded-For 헤더에 클라이언트의 IP 주소를 추가하거나, Host 헤더에 도메인 이름을 추가하거나, Upgrade 헤더와 Connection 헤더에 WebSocket 연결을 위한 값을 추가할 수 있습니다.

- proxy_pass: 요청을 전달할 대상 서버의 URL을 지정합니다. 예를 들어, upstream 블록에서 정의한 nodes 라는 이름을 사용할 수 있습니다.

 

upstream 블록에서는 다음과 같은 설정을 합니다.

- upstream: 로드 밸런싱할 서버 그룹의 이름을 지정합니다. 예를 들어, nodes 라고 합니다.

- server: 서버 그룹에 속한 각 서버의 도메인 이름과 포트 번호를 지정합니다. 예를 들어, example.com:8000 과 같이 작성합니다.

- hash: 로드 밸런싱 알고리즘 중 하나인 해시(hash) 방식을 사용하도록 지정합니다. 해시 방식은 클라이언트의 IP 주소나 쿠키 값 등을 해시 함수에 넣어서 얻은 값에 따라 서버를 선택하는 방식입니다. 이 방식은 고정 세션(sticky session)을 구현하기 위해 사용할 수 있습니다. 고정 세션이란 클라이언트가 처음 연결된 서버와 계속 연결되도록 하는 기능입니다. Socket.IO와 같은 웹 소켓 애플리케이션에서는 고정 세션을 사용해야 합니다.

 

예를 들어, 다음과 같이 작성할 수 있습니다.

http {
  server {
    listen 80;
    server_name example.com;

    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $host;
      proxy_pass http://nodes;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }
  }

  upstream nodes {
    hash $remote_addr consistent;
    server example.com:8000;
    server example.com:8001;
    server example.com:8002;
    server example.com:8003;
  }
}

 

Nginx의 설정 파일을 저장하고, Nginx를 재시작합니다.

sudo systemctl restart nginx

이제 Nginx가 80번 포트에서 요청을 받아 nodes 서버 그룹에 로드 밸런싱하고 역방향 프록시를 수행하게 됩니다.

 

Socket.IO를 사용하는 경우의 클러스터링과 로드 밸런싱 고려 사항

Socket.IO는 Node.js에서 웹 소켓(web socket)을 쉽게 구현할 수 있는 모듈입니다. 웹 소켓은 클라이언트와 서버 간에 양방향 통신을 가능하게 하는 프로토콜입니다. Socket.IO를 사용하는 경우에는 클러스터링과 로드 밸런싱을 할 때 몇 가지 고려 사항이 있습니다.

Socket.IO는 기본적으로 HTTP 업그레이드(upgrade)를 통해 웹 소켓 연결을 시도합니다. 만약 HTTP 업그레이드가 실패하면 폴링(polling) 방식으로 대체합니다. 따라서 Nginx의 설정에서 proxy_http_version, proxy_set_header Upgrade, proxy_set_header Connection 등의 옵션을 추가해야 합니다.

Socket.IO는 각 클라이언트와 서버 간에 고유한 식별자인 소켓 ID(socket ID)를 부여합니다. 이 소켓 ID는 메모리에 저장되므로, 여러 개의 서버 인스턴스가 있는 경우에는 인스턴스 간에 데이터를 공유할 수 있는 어댑터(adapter)를 사용해야 합니다. 예를 들어, Redis를 사용하는 socket.io-redis 어댑터를 사용할 수 있습니다.

Socket.IO는 고정 세션을 필요로 합니다. 즉, 클라이언트가 처음 연결된 서버와 계속 연결되도록 해야 합니다. 그렇지 않으면, 다른 서버로 전환될 때마다 새로운 소켓 ID가 부여되고, 연결이 끊어지고, 데이터가 유실될 수 있습니다. 따라서 Nginx의 설정에서 해시(hash) 방식이나 ip_hash 방식 등의 로드 밸런싱 알고리즘을 사용해야 합니다.

 

 

Node.js 클러스터링과 로드 밸런싱을 통한 확장성 개선에 대해 알아보았습니다. Node.js는 싱글 스레드로 동작하기 때문에 멀티 코어 CPU의 성능을 충분히 활용하지 못합니다. 이를 해결하기 위해 cluster 모듈과 pm2 모듈을 사용하여 애플리케이션을 여러 개의 프로세스로 복제하고 실행할 수 있습니다. 또한 Nginx를 사용하여 클라이언트의 요청을 여러 개의 서버에 균등하게 분산시키고 역방향 프록시를 수행할 수 있습니다. 이렇게 하면 서버의 부하를 줄이고, 가용성과 성능을 높일 수 있습니다. Socket.IO와 같은 웹 소켓 애플리케이션을 사용하는 경우에는 HTTP 업그레이드, 어댑터, 고정 세션 등의 고려 사항이 있으므로 주의해야 합니다. Node.js 애플리케이션에서 클러스터링과 로드 밸런싱을 적절하게 활용하여 확장성을 개선해보세요.