최근에 'styleu'라는 프로젝트를 시작하기 전에, Node.js에 대해 깊이 있게 학습할 기회를 가졌다. 이 과정에서 Node.js의 중요한 구성 요소 중 하나인 이벤트 루프(Event Loop)에 대한 이해를 심화시켰고, 특히 이벤트 루프를 차단하는 행위를 왜 하지 말아야 하는지에 대한 분석에 주력했다. 이에 대한 통찰을 공유하기 위해, 해당 내용을 담은 블로그를 작성하게 되었다.
요약
- '이벤트 루프(Event Loop)'와 '워커 풀(Worker Pool)'은 주로 노드JS(Node.js)와 같은 비동기 프로그래밍 환경에서 중요한 역할을 한다.
- 자바스크립트는 Event Loop가 있는 단일 스레드 환경에서 실행된다. 이벤트 루프는 들어오는 작업을 순차적으로 처리하는 연속 루프로, 더 많은 작업을 수행하도록 스케줄링할 수 있다는 장점을 가진다.
- Node.js는 Event Loop에서 JavaScript 코드를 실행하고 파일 I/O와 같은 고비용 작업을 처리하기 위한 워커 풀을 제공한다.
- Node.js 확장성의 비결은 적은 수의 스레드를 사용하여 많은 클라이언트를 처리하기 때문이다.
- 따라서 주어진 시간에 각 클라이언트와 관련된 작업이 "작을" 때 Node.js가 빠르다. 이는 Event Loop의 콜백과 워커 풀 작업에 적용된다.
Event Loop와 워커 풀을 차단하지 말아야 하는 이유는 무엇일까?
- Event Loop와 워커 풀을 차단하지 않는 것이 중요한 이유는 서버의 성능과 보안에 직접적인 영향을 미치기 때문이다. Node.js의 효율적인 운영을 위해서는, 이 두 요소가 원활하게 작동해야 한다. 차단되는 상황을 방지함으로써, 서버는 더 많은 클라이언트의 요청을 신속하게 처리할 수 있고, 악의적인 공격으로부터 보호될 수 있다.
- Node.js에는 두 가지 유형의 스레드가 있다.
- Event Loop(메인 루프, 메인 스레드, 이벤트 스레드 등)
- 워커 풀에 있는 k개의 워커 풀(스레드 풀이라고도 함)
- 스레드가 콜백(Event Loop) 또는 작업(워커)을 실행하는 데 시간이 오래 걸리는 경우 이를 “차단됨”이라고 한다.
- 즉, 한 클라이언트를 대신하여 작업 중인 스레드가 “차단된” 상태에서는 다른 클라이언트의 요청을 처리할 수 없다.
- Event Loop나 워커 풀을 차단하는 것이 좋지 않은 이유 두 가지
- 성능: 두 가지 유형의 스레드에서 정기적으로 무거운 작업을 수행하면 서버의 처리량(초당 요청 수)이 저하된다. 즉, 초당 처리할 수 있는 요청의 수가 감소한다
- 보안: 스레드 차단이 악의적으로 유발될 경우, 이는 서비스 거부 공격(Dos)의 형태가 될 수 있다. 악의적인 클라이언트가 '악의적인 입력'을 제출하여 스레드를 차단하고 다른 클라이언트들에 대한 작업을 수행하지 못하게 할 수 있다.
Node에 대한 간략한 특징
- Single Thread 기반
- Event Driven 아키텍처 사용
- 오케스트레이션을 위한 Event Loop와 고비용 작업을 위한 워커 풀이 있다.
Event Loop에서는 어떤 코드가 실행되는가?
- Event Loop는 이벤트에 등록된 자바스크립트 콜백을 실행하고 네트워크 I/O와 같은 non-blocking 비동기 요청을 처리하는 역할도 담당한다.
- Node.js 애플리케이션이 시작되면
- 애플리케이션 초기화: require모듈을 요청하고 이벤트에 대한 콜백을 등록한다.
- Event Loop 진입: 그다음 Node.js 애플리케이션은 Event Loop에 진입한다. 등록된 이벤트 콜백을 순차적으로 실행하여 들어오는 클라이언트 요청에 대응한다.
- 동기적 콜백 실행: Event Loop 내에서 콜백은 동기적으로 실행된다. 하나의 콜백 처리가 완료되면, Event Loop는 다음 콜백으로 넘어간다. 이 과정에서, 처리 중인 콜백은 필요에 따라 비동기 요청을 등록할 수 있으며, 이러한 비동기 요청의 콜백 역시 Event Loop에서 실행된다.
- non-blocking 비동기 요청 처리: Event Loop는 또한 콜백에 의해 생성된 non-blocking 비동기 요청(예: 네트워크 I/O)도 처리한다.
워커 풀에서는 어떤 코드가 실행되는가?
- 워커풀의 역할: Node.js의 워커 풀은 libuv(docs)에서 구현되며 "비용이 많이 드는" 작업을 처리한다. 여기에는 운영체제가 non-blocking 버전을 제공하지 않는 I/O와 CPU 집약적인 작업이 포함된다.
- 워커 풀에서 처리되는 작업 유형(Node.js 모듈 API)
- I/O 집약적
- DNS: dns.lookup(), dns.lookupService()
- 파일 시스템: fs.FSWatcher()와 명시적으로 동기적인 것들을 제외한 모든 파일 시스템 API는 libuv의 Thread Pool을 사용
- CPU 집약적
- 암호화폐: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair()
- Zlib: 명시적으로 동기적인 것을 제외한 모든 zlib API는 libuv의 Thread Pool을 사용한다.
- I/O 집약적
- C++ 추가 기능과의 상호 작용: Node.js 애플리케이션과 모듈이 C++ 추가 기능을 사용할 경우, 이를 통해 워커 풀에 추가적인 작업을 제출할 수 있다.
- Event Loop와의 상호 작용: Event Loop 콜백에서 에서 워커 풀을 사용하는 API를 호출할 때, Node.js는 약간의 초기 설정 비용을 지불하고 해당 작업을 워커 풀에 제출한다. 이 초기 비용은 전체 작업 비용에 비하면 무시할 정도이며 이것이 Event Loop가 오프로딩(offloading)하는 이유이다.
Node.js는 어떻게 다음에 실행할 코드를 결정할까?
Node.js에서 다음에 실행할 코드를 결정하는 메커니즘은 이벤트 루프와 워커 풀 사이에서 다르게 작동한다.
추상적으로 말하자면, Event Loop와 워커 풀은 각각 대기 중인 이벤트와 대기 중인 작업을 위한 큐를 유지한다.
실제로 이벤트루프는
- 실제로 큐를 유지하지 않는다.
- 대신 운영 체제의 File descriptor 모니터링 기능을 활용한다.
- 운영체제가 File descriptor 중 하나가 준비 되었다고 알려주면, Event Loop는 이를 적절한 이벤트로 변환하고 해당 이벤트와 연결된 콜백을 호출한다.
- 이 프로세스는 Node.js Event Loop From the Inside Out이라는 영상에서 확인할 수 있다.
반면에 워커 풀은
- 실제로 작업을 관리하기 위한 큐를 가지고 있다.
- 작업자는 이 큐에서 작업을 가져와 작업하고, 완료되면 Event Loop에게 “최소한 하나의 작업이 완료되었다”는 이벤트를 발생시킨다.
이것이 애플리케이션 아키텍처에서 무엇을 의미할까?
- 애플리케이션 아키텍처에서 Node.js와 Apache와 같은 전통적인 멀티스레드 서버 모델의 차이점을 이해하는 것이 매우 중요하다. 간략히 말하자면, 주된 차이점은 자원 활용과 처리량 최적화 방식에 있다.
Apache와 같은 전통적인 멀티 스레드 모델
- 스레드 할당: 각 클라이언트 요청을 개별 스레드로 처리한다. 이는 동시에 많은 클라이언트 요청을 처리할 수 있게 해준다.
- 공정성: 각 클라이언트 요청이 개별 스레드에 할당되므로, 스레드 하나가 차단되어도 다른 클라이언트 요청 처리에 영향을 주지 않는다. 따라서 운영체제는 더 많은 작업이 필요한 클라이언트에 의해 적은 양의 작업이 필요한 클라이언트가 불이익을 받지 않도록 보장한다.
Node.js의 이벤트 기반 모델
- 스레드 활용: Node.js는 소수의 스레드로 많은 클라이언트 요청을 처리할 수 있다.
- 공정성 및 확장성: 비록 Node.js에서는 하나의 스레드가 차단되면 전체 시스템의 처리량이 영향을 받을 수 있지만, 이벤트 기반 모델은 효율적인 자원 사용을 통해 높은 확장성을 제공한다. 따라서 애플리케이션 설계 시 비동기 패턴을 적절히 사용하여 차단을 최소화해야 한다.
Event Loop 차단하지 않기
- Event Loop를 차단하지 않는 것은 Node.js 애플리케이션의 성능을 유지하기 위한 핵심적인 요소이다.
- 모든 I/O 요청이 Event Loop를 통과하기 때문에, 이벤트 루프의 효율적인 관리는 애플리케이션의 전반적인 성능에 직접적인 영향을 미친다. 이는 이벤트 루프가 지나치게 오랜 시간 동안 하나의 작업에 머물러 있다면, 그것은 다른 모든 대기 중인 클라이언트의 처리를 지연시키며, 새로운 클라이언트 연결의 응답 시간도 늘어나게 한다는 뜻이다.
- Event Loop를 차단하지 않기 위해 Javascript 콜백과 await 와 Promise.then 은 빠르게 완료되어야 한다.
- 콜백의 “시간 복잡도”를 고려하는 것이 이러한 문제를 관리하는 하나의 방법이다. 콜백이 입력과 관계없이 일정한 실행 시간을 가진다면, 이는 모든 사용자에게 공정한 처리 시간을 보장하는 데 도움이 된다.
예시1. 상수 시간 콜백
app.get('/constant-time', (req, res) => {
res.sendStatus(200);
});
예시 2: O(n)콜백
app.get('/countToN', (req, res) => {
let n = req.query.n;
for (let i = 0; i < n; i++) {
console.log(`Iter ${i}`);
}
res.sendStatus(200);
});
예시 3: O(n^2)콜백.
app.get('/countToN2', (req, res) => {
let n = req.query.n;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}.${j}`);
}
}
res.sendStatus(200);
});
얼마나 주의해야 할까?
Node.js는 JavaScript용 Google V8 엔진을 사용하는데, 이는 대부분의 작업에 매우 빠르다. V8 엔진은 대부분의 JavaScript 작업을 매우 빠르게 처리할 수 있으며, 이는 Node.js 애플리케이션의 전반적인 성능에 긍정적인 영향을 미친다. (복잡한 정규 표현식 사용과 대량의 JSON 데이터 처리는 성능에 부정적인 영향을 미칠 수 있다. - 아래에서 더 자세히 설명)
그러나 복잡한 작업의 경우 처리하는 입력 데이터의 크기를 제한하는 것을 고려해야 한다. 너무 긴 입력 데이터를 거부하거나, 데이터의 크기를 사전에 제한함으로써, 콜백이나 비동기 작업의 실행 시간을 예측 가능한 범위 내로 유지할 수 있다. 이는 최악의 경우에도 애플리케이션의 반응성을 보장하는 데 도움이 된다.
1. Event Loop 차단: REDOS
- Event Loop를 차단하는 가장 흔한 방법은 "취약한" 정규 표현식을 사용하는 것이다.
- REDOS(정규 표현식 서비스 거부)는 정규 표현식의 취약한 패턴과 악의적인 입력이 결합될 때 발생할 수 있는 문제로, 애플리케이션의 성능을 심각하게 저하시킬 수 있다.
취약한 정규 표현식을 피할 것
- 정규 표현식은 입력 문자열을 패턴과 비교한다. 우리는 보통 정규 표현식 매칭이 입력 문자열을 한 번만 통과할 것으로 생각한다. - O(n) (n은 문자열의 길이) 대부분의 경우는 정말 한 번의 통과로도 충분하다.
- 하지만 어떤 정규 표현식 매칭의 경우 지수적인 증가(O(2^n))로 이어질 수 있다.
- 지수적으로 증가한다는 것은 x 번만으로 끝날 수 있는 요청이 입력 문자열에 단 하나의 문자를 더하는 것만으로 2*x 번의 요청으로 늘어날 수 있음을 의미한다.
- 요청의 숫자는 소요되는 시간과 선형적인 관계가 있기 때문에 이는 Event Loop를 차단하는 결과로 이어질 것이다.
어떤 언어(Perl, Python, Ruby, Java, JavaScript 등)에서든 적용되는 REDOS 방지 규칙
- 중첩된 한정자 피하기: (a+)*와 같은 패턴은, V8 엔진을 포함하여 많은 정규 표현식 엔진(regexp)에서 처리 시간이 급격하게 증가할 수 있다.
- 공통 부분을 포함하는 OR절 피하기: (a|a)*와 같은 패턴도, 처리 시간을 예측하기 어렵게 만들 수 있으므로 피하는 것이 좋다.
- 역참조 피하기: (a.*) \1와 같은 역참조를 포함하는 패턴은 많은 정규 표현식 엔진(regexp)에서 선형 시간 내에 완료를 보장할 수 없다.
- 간단한 문자열 비교에는 indexOf나 local equivalent를 사용해라: 이 방법은 O(n) 보다 많은 시간을 소모하지 않는다.
- 자신의 정규 표현식이 취약한지 여부를 판단하기 어렵다면, Node.js는 취약한 정규 표현식이나 긴 문자열 입력에 대한 문제를 항상 명확하게 보고하지 않는다는 것을 기억하는 것이 좋다.
REDOS 예시
app.get('/redos-me', (req, res) => {
let filePath = req.query.filePath;
// REDOS
if (filePath.match(/(\\/.+)+$/)) {
console.log('valid path');
}
else {
console.log('invalid path');
}
res.sendStatus(200);
});
- 이 취약한 정규 표현식 예시는 리눅스에서 파일의 경로가 유효한 경로인지 체크하는 방법이다. 이는 "/a/b/c"와 같이 "/"로 시작하는 모든 문자열에 대해 패턴을 체크하게 되는데 이는 규칙 1(중첩된 한정자를 사용하는 것을 피하라)을 위반하였으므로 위험하다.
- 만약 클라이언트가 파일경로 ///.../\\n (100개의 /와 \n으로 끝나 정규 표현식의 "."에 매치되지 않게 됨)를 요청하게 되면, Event Loop는 무한 루프에 빠지게 되면서 “막히게” 된다. 이러한 클라이언트의 REDOS 공격은 대기 중인 모든 클라이언트가 정규 표현식 처리가 끝날 때까지 기다리게 한다.
- 이러한 이유로, 유저의 입력에 대해 유효성 검사할 때 복잡한 정규 표현식을 조심히 사용하는 것이 좋다.
REDOS를 방어하는 방법
- 정규 표현식이 안전한지 확인할 수 있는 몇 가지 도구
- safe-regex
- rxxr2
- 하지만 모든 취약한 정규 표현식을 막아주는 것은 아니다.
- 다른 정규식 표현 엔진을 사용
- URL이나 파일 경로 같은 ”명백한” 것들에 정규 표현식을 사용하려고 한다면
- regexp library에서 예제를 찾거나 ip-regex 같은 npm 모듈을 사용해라.
2. Event Loop 차단: Node.js 코어 모듈
- Node.js 애플리케이션에서 Event Loop의 차단을 피하기 위해 코어 모듈의 동기식 API 사용을 자제하는 것이 중요하다.
- Node.js 코어 모듈 중 사용을 피해야 하는 동기적 API
- 암호화(Encryption):
- crypto.randomBytes, crypto.randomFillSync, crypto.pbkdf2Sync 등의 동기적 암호화
- 이외에도 암호화 및 복호화 루틴에 긴 입력을 넣는 것에 대해 조심하는 것이 좋다.
- 압축(Compression):
- zlib.inflateSync , zlib.deflateSync
- 파일 시스템(File system):
- 동기적 파일 시스템 API는 파일 읽기/쓰기 등의 작업을 처리하며, 특히 네트워크 파일 시스템(NFS)과 같은 분산 파일 시스템을 사용할 경우 응답 시간의 편차가 클 수 있다.
- 자식 프로세스(Child Process):
- child_process.spawnSync , child_process.execSync , child_process.execFileSync
- 암호화(Encryption):
- 이 API는 상당한 계산(암호화, 압축)을 포함하거나, I/O(파일 I/O)를 요구하거나, 혹은 둘 다(자식 프로세스)가 필요하기 때문에 비용이 많이 든다.
- 만약 이 API들을 Event Loop에서 실행한다면, 자바스크립트 명령어를 완료하는 데 보다 많은 시간이 소요되게 되고, Event Loop가 차단된다.
3. Event Loop 차단: JSON DOS
JSON.parse와 JSON.stringify 또한 잠재적으로 비싼 연산이다. 입력의 길이에 따른 O(n) 시간복잡도를 가지지만, n이 큰 경우에는 의외로 오랜 시간이 걸릴 수 있다.
예: JSON 차단
- 크기가 2^21인 객체 obj를 만들고 JSON.stringify하여, 문자열에서 **indexOf**를 실행하고 그것을 JSON.parse 한다. **JSON.stringify**된 문자열은 50MB이다. 객체를 JSON.stringfy 하는 데 0.7초, 50MB 문자열에서 indexOf를 실행하는 데 0.03초, 문자열을 JSON.parse하는데 1.3초가 걸린다.
let obj = { a: 1 };
let niter = 20;
let before, str, pos, res, took;
for (let i = 0; i < niter; i++) {
obj = { obj1: obj, obj2: obj };
}
before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);
before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);
before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);
JSON 처리에서의 이벤트 루프 차단 방지
- 크기 제한
- 클라이언트로부터 받는 JSON 데이터의 크기를 제한한다. 이는 서버가 과도한 양의 데이터를 처리하려고 시도하는 것을 방지할 수 있다.
- npm 모듈이 제공하는 비동기적 JSON API 사용
- JSONStream : 스트림 API
- Big-Friendly JSON : 아래에 설명된 partitioning-on-the-Event-Loop 패러다임을 이용한 비동기 버전의 표준 JSON API와 스트림 API
Event Loop를 차단 하지 않고 복잡한 계산을 하는 방법
Event Loop를 차단하지 않으면서 복잡한 계산을 하고 싶다면, 두 가지 옵션이 있다
- 파티셔닝
- 오프로딩
1. 파티셔닝
파티셔닝은 복잡한 계산을 여러 작은 단위로 나누고, 이 작은 단위들을 이벤트 루프의 여러 순환에 걸쳐 분산시키는 방식이다.
간단한 예로, 1부터 n까지의 숫자들 평균을 계산하고 싶다고 가정해보자.
예제 1: 분할되지 않았을 때, O(n)의 비용이 든다.
for (let i = 0; i < n; i++)
sum += i;
let avg = sum / n;
console.log('avg: ' + avg);
예제 2: 분할 되었을 때 평균, 비동기적으로 분할된 각각의 n은 O(1) 소요
- 진행 중인 작업의 상태를 클로저에서 저장하는 것이 쉽다.
- setImmediate()를 사용하여 다음 계산 단위를 이벤트 루프의 다음 순환으로 넘기는 방식으로 이를 구현할 수 있다.
function asyncAvg(n, avgCB) {
// Save ongoing sum in JS closure.
let sum = 0;
function help(i, cb) {
sum += i;
if (i == n) {
cb(sum);
return;
}
// "Asynchronous recursion".
// Schedule next operation asynchronously.
setImmediate(help.bind(null, i + 1, cb));
}
// Start the helper, with CB to call avgCB.
help(1, function (sum) {
let avg = sum / n;
avgCB(avg); });
}
asyncAvg(n, function (avg) {
console.log('avg of 1-n: ' + avg);
});
2. 오프로딩
- 더 복잡한 작업이 필요하다면 파티셔닝은 좋은 방법이 아닐 수 있다. 파티셔닝은 Event Loop만 이용하기에 컴퓨터에 있는 여러 개의 코어를 사용하는 이점을 얻을 수 없다.
- 따라서 복잡한 작업을 하기 위해서는 Event Loop의 작업을 워커 풀로 옮기는 오프로딩 작업을 해야한다.
오프로딩 하는 방법
- C++ 애드온을 이용한 오프로딩
- C++애드온을 개발하여 Node.js 워커풀을 사용할 수 있다.
- 오래된 Node.js 버전: NAN을 사용하여 C++ 애드온을 빌드
- 최신 Node.js 버전: N-API를 사용하여 C++ 애드온을 빌드
- node-webworker-threads는 JavaScript만 Node.js 워커 풀에 접근하는 방법을 제공
- 계산 특화 워커 풀 사용
- Child Process 또는 Cluster를 사용하여 별도의 계산 워커 풀을 생성하고 관리
- 모든 클라이언트마다 Child Process를 생성해서는 안 된다. 자식을 생성하고 관리하는 것보다 클라이언트 요청을 더 빠르게 받게 되면서 서버가 fork bomb이 될 수 있다.
오프로딩의 단점
- 통신 비용의 형태로 오버헤드가 발생한다.
- Event Loop에서만 애플리케이션의 "네임스페이스"(JavaScript 상태)를 볼 수 있다. 워커에서는 Event Loop의 네임스페이스에 있는 JavaScript 객체를 조작할 수 없다. 대신, 공유하고자 하는 객체를 직렬화하고 역직렬화해야 한다. 그 후 워커는 이 객체(들)의 자체 복사본을 이용하여 작업을 수행하고 변경된 객체(또는 "패치")를 Event Loop에 반환한다.
오프로딩을 위한 몇 가지 제안
- 오프로딩 전략을 적용하여 CPU 및 I/O 집약적 작업을 효과적으로 처리하는 것은 Node.js 애플리케이션의 성능 최적화에 도움이 된다.
CPU 집약적인 작업은
- 처리를 위한 워커가 스케줄링 되었을 때만 진행되어야 한다.
- 워커는 반드시 컴퓨터의 logical cores 중 하나에 스케줄링 되어야 한다.
- 만약 4개의 logical cores와 5개의 워커가 있다면, 이 중 한 워커는 작동하지 않게 된다.
- 이는 해당 워커에 대한 메모리와 스테줄링에 대한 오버헤드가 발생 하면서도 어떠한 결과도 얻지 못하는 결과로 이어진다.
I/O 집약적인 작업은
- 외부 서비스 제공자(DNS, 파일 시스템 등)에게 요청하고 그 응답을 기다리는 것까지 포함된다.
- I/O 작업을 처리할 때는 가능한 한 비동기 API를 사용하여, 워커가 응답을 기다리는 동안 다른 작업을 수행할 수 있도록 한다.
- 데이터베이스와 파일 시스템과 같은 외부 서비스 제공자들은 많은 보류 중인 요청을 동시에 처리할 수 있도록 최적화되어 있다.
- 예를 들어 파일 시스템은 충돌이 있는 업데이트를 병합하고 파일을 최적의 순서로 검색하기 위해 대기 중인 많은 쓰기와 읽기 요청을 검토할 것이다. (예시 슬라이드)
- 만약 단 하나의 워커 풀, 예를 들어 Node.js 워커 풀에만 의존한다면, CPU 집약적인 작업과 I/O 집약적인 작업 간의 서로 다른 특징으로 인해 애플리케이션 성능이 저하될 수 있다.
- 이러한 이유로 별도의 계산 워커 풀을 유지하는 것이 좋다.
오프로딩: 결론
- 임의적으로 긴 배열의 요소를 반복하는 것과 같은 간단한 작업을 하는 경우에는 파티셔닝이 좋은 옵션이 될 수 있다.
- 계산이 이보다 더 복잡해진다면 오프로딩이 더 좋은 방법이다.
- Event Loop와 워커 풀 사이에 직렬화된 객체를 전달하는데 발생하는 오버헤드와 같은 커뮤니케이션 비용은 다수의 코어를 사용하는 이점으로 상쇄될 수 있다.
- 그러나 서버가 복잡한 계산에 많이 의존한다면 Node.js를 사용하는 것이 정말 적합한지 생각해 봐야 한다.
- Node.js 는 I/O 의존적인 작업에는 탁월하지만, 비용이 많이 드는 연산에 대해서는 적합하지 않을 수 있다.
워커 풀 차단하지 않기
- Node.js는 k개의 워커로 이루어진 워커 풀을 보유하고 있으며, 별도의 계산 특화 워커 풀을 포함한 오프로딩 전략을 적용할 수 있다. 이러한 워커 풀은 Node.js의 기본 철학인 "다수의 클라이언트를 처리하는 하나의 스레드"를 실현한다. 각 워커는 현재 작업을 완료한 후에야 워커 풀 큐의 다음 작업으로 이동할 수 있다.
- 클라이언트 요청을 처리하는 데 필요한 작업의 비용은 다양할 수 있다. 예를 들어, 짧거나 캐싱된 파일을 읽거나 소량의 랜덤 바이트를 생성하는 작업은 빠르게 처리될 수 있다. 반면에, 크거나 캐싱되지 않은 파일을 읽거나 대량의 랜덤 바이트를 생성하는 작업은 처리 시간이 더 오래 걸린다. 이러한 작업 소요 시간의 차이를 최소화하기 위해서는 작업 파티셔닝을 활용하는 것이 좋다.
작업 시간의 변동 최소화하기
- 워커 풀의 전체 크기를 효과적으로 활용하고 워커 풀의 처리량을 최대화하기 위해 작업 길이 변동을 최소화하는 것이 중요하다.
- 한 워커의 현재 작업이 다른 작업보다 비용이 많이 들 경우, 해당 워커는 대기 중인 다른 작업을 처리할 수 없게 되며, 이는 전체 워커 풀의 크기를 사실상 줄이는 결과를 초래한다. 이 상황은 워커 풀의 처리량을 감소시키며, 궁극적으로는 서버의 처리량 감소로 이어진다.
- 이를 방지하기 위해서 워커 풀에 위임하는 작업 길이간의 변동을 최소화하는 것이 좋다. 외부 시스템 접근에 대한 I/O 요청(DB, 파일 시스템 등)의 경우, 이러한 요청의 상대적인 비용을 인식하고, 특별히 길어질 것으로 예상되는 요청을 피하는 것이 좋다.
작업 시간에 있을 수 있는 변동을 보여주는 예시 두 가지
- 변동 예시: 긴 시간이 소요되는 파일 시스템 읽기
- 어떤 클라이언트 요청을 처리하기 위해서 서버는 반드시 파일을 정해진 순서대로 읽어야 한다고 가정을 해보자.
- Node.js의 File System API를 훑어본 후에 간단하게 처리하기 위해 fs.readFile()를 선택했다.
- 그러나 fs.readFile()는 (현재 기준으로) 분할되어 있지 않아 단일의 fs.read() 작업을 통해 전체 파일을 순회하게 된다.
- 어떤 유저의 작업에서는 짧은 파일을, 다른 유저에게서는 긴 파일을 읽는 경우, fs.readFile()는 작업 길이에 상당한 변동이 발생하게 되고 이는 곧 워커 풀의 처리량 감소로 이어지게 된다.
- 최악의 경우에는 공격자가 서버를 임의의 파일을 읽게 할 수 있다고 가정해 보자.(경로 순회 취약점)
- 만약 서버가 리눅스라면 공격자는 극심하게 느린 파일에 이름(/dev/random)을 지정할 수 있다. 현실적인 수준에서 /dev/random은 매우매우 느리며 /dev/random을 읽도록 요청된 모든 워커는 해당 작업에서 완료할 수 없다. 공격자는 k번의 요청(각 워커 당 한 번씩)만 하면 어떤 다른 클라이언트의 요청도 워커 풀이 처리하지 못하는 상황이 된다.
- 어떤 클라이언트 요청을 처리하기 위해서 서버는 반드시 파일을 정해진 순서대로 읽어야 한다고 가정을 해보자.
- 변동 예시: 긴 시간이 소요되는 암호화 연산
- 서버에서 암호학적으로 안전한 랜덤 바이트를 crypto.randomBytes()를 이용하여 생성한다고 가정해보자.
- crypto.randomBytes()는 분할되어 있지 않으므로 이는 하나의 randomBytes() 작업을 이용하여 요청하는 만큼의 바이트를 생성하게 된다.
- 어떤 유저에게는 짧은 바이트를 생성하고 다른 유저에게는 긴 바이트를 생성한다면 crypto.randomBytes()는 작업 길이의 변동이 발생하는 또다른 요인이다.
작업 시간의 변동 최소화하는 방법. 작업 파티셔닝
- 작업 간의 소요 시간 변동은 워커 풀 처리량의 감소로 이어질 수 있다.
- 작업 시간 변동을 최소화하기 위해 각 작업을 가능한 한 비슷한 비용이 들고 작은 하위 작업으로 분할해야 한다.
- 이는 각 하위 작업의 완료 후 다음 하위 작업으로 진행하며, 모든 하위 작업이 완료되면 전체 작업의 완료를 알리는 구조로 진행된다.
계속해서 fs.readFile() 예시를 들자면
- fs.read()(수동 파티셔닝)가 아닌 ReadStream(자동 파티셔닝)을 사용해야 한다.
CPU 의존적인 작업에서도 같은 방식이 적용된다.
- asyncAvg 예시가 Event Loop에서 적절하지 않을 수 있으나 워커 풀에서는 매우 적합하다.
- 하나의 작업을 하위작업을 분할할 때 짧은 작업은 적은 수의 하위작업으로 분리되고 긴 작업은 많은 수의 하위작업으로 분리되어야 한다.
- 짧은 작업에 지정된 워커는 해당 작업을 끝내고 긴 작업의 하위작업에 도와줄 수 있으므로 이는 워커 풀의 전체 처리량 증가로 이어지게 된다.
- 완료된 하위작업의 수는 워커 풀의 처리량을 측정하기에 좋은 메트릭이 아니라는 것을 명심하고, 대신에 완료된 작업의 수를 고려해야 한다.
작업 파티셔닝을 하지 않아도 되는 경우
- 작업 분할의 목적은 작업 시간 변동을 최소화하는 것이다.
- 만약 짧은 작업과 긴 작업(배열의 합산 vs 배열 정렬)임을 구분할 수 있다면, 각 작업의 클래스마다 하나의 워커 풀을 생성할 수 있다. 짧은 작업과 긴 작업을 각기 다른 워커 풀로 라우팅시키는 것은 작업 시간 변동을 최소화하는 또 다른 방법이다.
- 이 방식의 경우, 작업 파티셔닝에 의해 발생하는 오버헤드(워커 풀 작업 생성과 워커 풀 큐를 관리하는 비용)와 워커 풀에 접근하는 비용 등을 줄일 수 있다. 이는 또한 작업을 파티셔닝을 잘못하는 실수를 방지할 수 있다.
- 그러나 이 접근법의 경우, 모든 워커 풀의 워커가 공간, 시간 오버헤드를 발생시키며 CPU 시간을 경쟁적으로 사용하게 된다는 것이다. CPU에 바인딩 된 작업은 스케줄링이 되었을 때만 진행된다. 결과적으로 이러한 접근 방식을 고려하기 전에 신중한 분석을 해야 한다.
워커 풀: 결론
- Node.js 워커 풀만 사용하든 별도의 워커 풀을 유지하든, 작업 처리량을 최적화해야 된다.
- 이것을 위해 작업 파티셔닝을 사용하여 작업 시간의 변동을 최소화한다.
npm 모듈의 위험성
- Node.js 개발에서 npm 모듈을 사용하는 것은 개발 속도를 가속화하고 기능적인 확장성을 제공하는 큰 장점이 있다.
- npm 생태계에서는 개발자들이 활용할 수 있는 수십만 개의 모듈이 존재하며 이 모듈들은 다양한 애플리케이션의 개발을 용이하게 한다. 그러나 이러한 모듈의 대부분은 서브 파티 개발자에 의해 작성되며, 대부분의 경우 명확한 보장 없이 배포된다.
- 개발자가 npm모듈을 사용할 때 고려해야 할 주요 사항은 모듈이 제공하는 API가 예상대로 잘 동작하는지, API가 이벤트 루프나 워커 루프를 차단할 수도 있는지에 대한 것이다. 많은 모듈들은 API의 비용에 대해 쵸시하지 않아 커뮤니티에 해를 끼치고 있다.
- 문자열 조작 같은 간단한 API의 경우 비용을 추정하는 것은 어렵지 않다. 그러나 대부분의 경우 해당 API가 얼마나 비용이 드는지에 대해 명확하지 않다.
- 만약 값비싼 API를 호출하려고 한다면 비용을 더블 체크해야 한다. 해당 API의 개발자에게 비용에 대해 문서화를 요청하거나 직접 소스 코드를 분석해서 비용에 대한 문서화를 PR할 수도 있다.
- API가 비동기적으로 처리된다고 해서 그 API가 워커나 이벤트 루프에서 소요되는 시간을 예측할 수 있는 것은 아니다.
- 예를 들어 위의 asyncAvg 예시처럼, 각 헬퍼 함수의 호출이 숫자 중 하나가 아닌 절반을 합산했다고 할 때 이 함수는 여전히 비동기적이지만 각 파티션에 대한 비용은 O(1)이 아닌 O(n)이므로 임의의 n을 사용하는 것에 대해 더 주의해야 한다.
결론
- Node.js는 Event Loop와 k 워커 2가지 종류의 스레드를 가지고 있다.
- Event Loop는
- 자바스크립트 콜백과 논브로킹 I/O에 대한 책임이 있으며
- 워커는
- C++ 코드로 해당 작업을 실행하여 블로킹 I/O와 CPU 집약적 작업을 포함하는 비동기 요청을 완료한다.
- 두 종류의 스레드 모두 한 번에 한 작업보다 많은 일을 하지 않는다.
- 만약 콜백이나 작업이 긴 시간이 소요된다면 해당 스레드는 차단된다.
- 애플리케이션이 차단되는 콜백이나 작업을 생성한다면 이는 최소한 처리량(초당 클라이언트 요청)의 감소로 이어지고 최악의 경우 완전한 서비스 거부 상태가 될 수 있다.
- 높은 처리량을 내면서 Dos 방지가 가능한 웹 서버를 작성하기 위해서는 정상 입력이든 악의적 입력이든 Event Loop나 워커가 차단되지 않도록 해야 한다.
참조
https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop#should-you-read-this-guide
'BackEnd > NodeJS' 카테고리의 다른 글
TypeError: Router.use() requires a middleware function but got a Object (0) | 2023.09.17 |
---|---|
NVM으로 Windows에서 여러 노드(Node) 버전을 손쉽게 관리하기 (0) | 2023.08.04 |
Node JS의 .env 파일 생성하기 (0) | 2023.05.15 |
아주 간단한 Node.js express 만들기 (0) | 2023.04.20 |