Next.js 서버가 느려질 때 Node.js 안에서 일어나는 일
콜 스택, 이벤트 루프, 스레드풀, 메모리 한도까지
Next.js 서버에서 무거운 요청 하나가 다른 요청의 응답까지 늦출 수 있는지 확인해 봤습니다.
실험을 위해 두 페이지를 만들었습니다. /light는 문단 하나만 렌더링하고, /heavy는 렌더링 과정에서 약 2초 동안 CPU 연산을 수행합니다.
/light만 요청하면 응답은 4~6ms 정도면 돌아옵니다. 그런데 /heavy를 먼저 요청한 뒤 0.2초 후 /light를 호출하자 결과가 달라졌습니다. 평소 몇 ms면 끝나던 /light가 약 1.8초 뒤에야 응답했습니다.
두 페이지가 공유하는 컴포넌트나 데이터는 없습니다. 그럼에도 /heavy의 계산이 끝날 때까지 /light의 처리도 함께 밀렸습니다.
이유를 찾으려면 Next.js보다 한 단계 아래를 봐야 합니다. 같은 Node.js 프로세스에서 실행되는 요청의 JavaScript 코드는 기본적으로 하나의 메인 스레드에서 처리됩니다. 한 요청이 긴 동기 연산으로 이 스레드를 점유하면 그동안 다른 요청의 JavaScript도 실행될 수 없습니다.
CPU 연산만 알아서는 실제 서버의 문제를 모두 설명하기 어렵습니다. 파일 읽기나 암호화는 어디서 처리되는지, 네트워크 요청은 왜 같은 방식으로 막히지 않는지, 이벤트 루프가 멀쩡한데도 요청이 밀릴 수 있는 이유는 무엇인지도 알아야 합니다. 메모리가 부족할 때 프로세스가 어떤 방식으로 종료되는지도 마찬가지입니다.
이 글에서는 요청 하나가 Node.js 안에서 처리되는 경로를 따라가며 콜 스택, 이벤트 루프, 스레드풀, 메모리가 각각 어떤 역할을 하는지 살펴봅니다. 마지막에는 같은 구조를 Next.js 서버 렌더링에 대입해, 서버가 느려지거나 갑자기 종료될 때 무엇부터 확인해야 하는지 정리합니다.
실측은 macOS 12코어 환경의 Node.js
23.11.0, Next.js16.4.0-canary.31에서 진행했습니다. App Router를 사용했고next build후next start로 서버를 실행했습니다.
Node.js 요청은 V8과 libuv를 오가며 처리됩니다
JavaScript 명세인 ECMAScript에는 setTimeout이나 파일 읽기 API가 없습니다. fetch처럼 실행 환경이 제공하는 기능도 언어 자체의 기능은 아닙니다.
V8은 JavaScript 코드를 실행하고 객체를 저장할 힙과 Promise 마이크로태스크를 관리합니다. Node.js는 여기에 파일, 네트워크, 타이머 같은 서버 기능을 붙입니다. 그 아래에서 이벤트 루프와 비동기 I/O를 담당하는 핵심 구성 요소가 libuv입니다.
브라우저에서는 브라우저가 JavaScript의 실행 환경을 제공하고, Node.js에서는 Node.js의 내장 API와 libuv가 그 역할을 맡는 셈입니다.
libuv란?
Node.js가 비동기 I/O를 처리할 때 사용하는 C 라이브러리입니다. 이벤트 루프와 스레드풀을 제공하고, epoll·kqueue·IOCP처럼 운영체제마다 다른 I/O 방식을 하나의 인터페이스로 감쌉니다.
fs.readFile() 하나를 호출해도 실제 작업은 여러 계층을 거칩니다.
app.js fs.readFile('a.txt', cb)
↓
Node.js 내장 모듈 인수 검증, 콜백 등록
↓
C++ 바인딩 JS 호출을 네이티브 요청으로 변환
↓
libuv 파일 작업을 스레드풀에 전달
↓
운영체제 open · read · close
여기서 중요한 것은 작업을 누가 하느냐와 그동안 메인 스레드가 비어 있느냐입니다.
readFile()은 파일 읽기를 넘긴 뒤 JavaScript 실행을 계속할 수 있습니다. 반면 readFileSync()는 파일 읽기가 끝날 때까지 호출한 자리에서 빠져나오지 못합니다.
단계를 넘기며 fs 블록을 따라가 보세요. readFileSync로 바꾸면 app.js의 띠가 첫 줄에서 움직이지 않습니다.
앞으로 볼 구성 요소를 먼저 정리해 두겠습니다.
| 구성 요소 | 역할 |
|---|---|
| Call Stack | 현재 실행 중인 JavaScript 함수가 쌓입니다. 한 함수가 오래 실행되면 다음 JavaScript 작업도 기다립니다. |
| nextTick Queue | process.nextTick() 콜백이 기다립니다. Node.js가 별도로 관리합니다. |
| Microtask Queue | Promise와 queueMicrotask() 콜백이 기다립니다. V8이 관리합니다. |
| Memory Heap | 객체, 배열, 클로저 같은 JavaScript 값이 저장됩니다. 참조가 사라진 값은 GC 대상이 됩니다. |
| Event Loop | I/O 완료, 타이머 등의 콜백을 적절한 시점에 JavaScript 실행으로 돌려보냅니다. |
| Thread Pool | 파일, 일부 DNS, 암호화·압축 작업처럼 별도 스레드가 필요한 작업을 처리합니다. |
| 커널 I/O | 네트워크 소켓처럼 운영체제가 효율적으로 기다릴 수 있는 I/O를 감시합니다. |
이 구성 요소들이 서로 다른 일을 맡기 때문에 똑같이 2초가 걸리는 작업이라도 서버에 미치는 영향은 달라집니다.
메인 스레드: 콜 스택과 두 개의 대기열
일반적인 Node.js 서버에서 요청 처리용 JavaScript는 메인 스레드에서 실행됩니다.
함수가 호출되면 콜 스택에 올라가고, 함수가 끝나야 빠져나옵니다. 콜 스택에 오래 걸리는 함수가 남아 있으면 다른 JavaScript 콜백은 실행될 기회를 얻지 못합니다.
여기에 Node.js에서는 두 종류의 짧은 대기열도 알아둘 필요가 있습니다.
process.nextTick()은 Node.js가 관리하는 nextTick 큐에 들어가고, Promise의 .then()과 queueMicrotask()는 V8의 마이크로태스크 큐에 들어갑니다.
다음 코드를 실행해 보겠습니다.
// order.cjs
const fs = require('node:fs');
console.log('1 동기 코드 시작');
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('3 Promise.then'));
process.nextTick(() => console.log('2 nextTick'));
fs.readFile(__filename, () => {
console.log('readFile 콜백');
setTimeout(() => console.log('I/O 안의 setTimeout'), 0);
setImmediate(() => console.log('I/O 안의 setImmediate'));
});
console.log('1 동기 코드 끝');
여러 번 실행해도 앞부분은 같은 순서로 나왔습니다.
1 동기 코드 시작
1 동기 코드 끝
2 nextTick
3 Promise.then
먼저 현재 실행 중인 동기 코드가 끝납니다. 그다음 Node.js는 nextTick 큐를 처리하고 V8의 마이크로태스크를 실행한 뒤 이벤트 루프의 다음 작업으로 넘어갑니다.
그래서 process.nextTick()이나 Promise 콜백이 자기 자신을 계속 등록하면 문제가 생길 수 있습니다. 짧게 끝나야 할 작업이 계속 추가되면 이벤트 루프가 다른 I/O를 처리할 기회를 얻지 못하기 때문입니다.
다만 nextTick이 Promise보다 항상 먼저 실행된다고 외우면 안 됩니다.
위 순서는 CommonJS 파일의 최상위 코드에서는 맞지만, ES 모듈의 최상위 코드는 평가 과정 자체가 마이크로태스크 안에서 진행됩니다. 같은 코드를 .mjs로 실행하면 Promise 콜백이 nextTick보다 먼저 실행될 수 있습니다.
단계를 넘기며 블록이 콜 스택으로 올라오는 순서를 보세요. 모듈 형식을 바꾸면 tick과 then의 순서가 뒤바뀝니다.
- console.log('1 동기 코드 시작');
- setTimeout(timer, 0);
- Promise.resolve().then(then);
- process.nextTick(tick);
- console.log('1 동기 코드 끝');
핵심은 순서 자체보다 콜 스택이 끝난 뒤에도 바로 이벤트 루프로 돌아가는 것은 아니라는 점입니다. 실행을 기다리는 nextTick과 마이크로태스크가 있다면 이 작업들이 먼저 처리됩니다.
그다음에야 setTimeout, setImmediate, 파일 I/O 같은 이벤트 루프의 작업을 이해할 수 있습니다.
libuv가 맡는 것: 이벤트 루프, 스레드풀, I/O
이벤트 루프는 종류가 다른 콜백을 단계별로 처리합니다
Node.js의 이벤트 루프를 하나의 콜백 큐라고 생각하기 쉽지만, 실제로는 콜백 종류에 따라 처리 단계가 나뉩니다.
대표적인 단계는 다음과 같습니다.
| 단계 | 주로 처리하는 작업 |
|---|---|
| timers | 만료된 setTimeout, setInterval |
| pending callbacks | 이전 루프에서 미뤄진 일부 시스템 콜백 |
| idle, prepare | Node.js와 libuv 내부 작업 |
| poll | 완료된 I/O 처리와 새로운 I/O 대기 |
| check | setImmediate |
| close callbacks | 소켓 등의 종료 콜백 |
앞의 예제에서 fs.readFile()의 완료 콜백은 poll 단계에서 실행됩니다. 그 콜백 안에서 setImmediate()와 setTimeout()을 함께 등록하면 바로 다음 check 단계에 들어갈 수 있는 setImmediate()가 먼저 실행되는 경우를 쉽게 볼 수 있습니다.
반면 최상위 코드에서 등록한 setTimeout(0)과 setImmediate()의 순서는 고정되어 있지 않습니다. 이벤트 루프에 진입하는 시점과 I/O 완료 시점에 따라 결과가 달라질 수 있습니다.
Node.js 20에 포함된 libuv 1.45부터는 타이머 처리 시점도 바뀌었습니다. 이전에는 poll 전후에 타이머를 확인할 수 있었지만, 이후에는 poll 이후에 타이머를 실행하는 방식으로 변경됐습니다. 이 때문에 타이머와 setImmediate()의 상대적인 실행 시점도 이전 버전과 조금 달라질 수 있습니다.
따라서 setTimeout(0), setImmediate(), I/O 완료 사이의 세부 순서에 의존하는 코드는 피하는 편이 안전합니다.
파일과 암호화 작업은 스레드풀로 갑니다
파일 읽기는 네트워크와 처리 방식이 다릅니다.
일반적인 파일 시스템 작업은 운영체제에 맡겨 놓고 알림만 기다리는 방식으로 통일하기 어렵습니다. 그래서 libuv는 이런 작업을 별도의 워커 스레드에서 실행합니다.
기본 스레드풀 크기는 4입니다.
파일 시스템 API, dns.lookup(), 일부 비동기 crypto API, zlib 작업 등이 이 스레드풀을 함께 사용합니다. UV_THREADPOOL_SIZE를 설정하면 워커 수를 늘릴 수 있습니다.
워커가 정말 네 개씩 일을 처리하는지 확인하기 위해 crypto.pbkdf2()를 동시에 여덟 번 실행했습니다.
const crypto = require('node:crypto');
const start = performance.now();
for (let i = 1; i <= 8; i++) {
crypto.pbkdf2('secret', 'salt', 300_000, 64, 'sha512', () => {
console.log(`#${i} ${Math.round(performance.now() - start)}ms`);
});
}
결과는 뚜렷했습니다.
| 실행 방식 | 완료 시각 |
|---|---|
| 비동기, 기본 스레드풀 4개 | 4개가 66~74ms, 나머지 4개가 128~139ms |
| 비동기, 워커 8개 | 8개 모두 63~75ms |
pbkdf2Sync 4번 | 55ms, 111ms, 166ms, 221ms |
기본 설정에서는 먼저 시작한 네 작업이 워커를 모두 사용했습니다. 뒤의 네 작업은 앞 작업이 끝나 워커가 빌 때까지 기다렸습니다.
워커를 여덟 개로 늘리자 여덟 작업이 비슷한 시점에 끝났습니다.
반면 pbkdf2Sync()는 스레드풀에 일을 넘기지 않습니다. 메인 JavaScript 스레드에서 작업을 하나씩 실행하므로 시간이 그대로 더해졌습니다.
방식을 바꾸면 같은 작업 여덟 개를 다시 돌립니다. 완료 칸 아래 숫자가 각 작업이 끝난 시각입니다.
여기서 중요한 차이가 하나 보입니다.
스레드풀이 밀리는 것과 이벤트 루프가 막히는 것은 다른 문제입니다.
워커 네 개가 모두 바쁜 상태에서는 새로운 파일이나 암호화 작업이 스레드풀 앞에서 기다립니다. 하지만 메인 JavaScript 스레드 자체는 다른 요청을 계속 처리할 수 있습니다.
반대로 동기 API를 호출하면 해당 작업이 끝날 때까지 메인 스레드 자체가 멈춥니다.
네트워크 요청은 스레드풀에서 기다리지 않습니다
Node.js의 비동기 작업이 모두 스레드풀로 가는 것은 아닙니다.
네트워크 소켓은 Linux의 epoll, macOS의 kqueue, Windows의 IOCP 같은 운영체제 기능을 이용합니다. Node.js는 소켓을 계속 확인하는 대신 운영체제에 감시를 맡기고, 읽거나 쓸 수 있는 상태가 되면 알림을 받습니다.
그래서 HTTP 응답을 기다리는 동안 워커 하나가 2초 동안 붙잡혀 있을 필요가 없습니다.
Node.js의 fetch도 결국 네트워크 소켓을 통해 응답을 기다리기 때문에 이 경로를 탑니다.
다만 새 연결을 만들면서 호스트 이름을 IP로 바꿔야 할 때는 DNS 조회가 필요합니다. 기본적인 dns.lookup()은 libuv 스레드풀을 사용합니다. DNS 조회가 필요한 상황에서 스레드풀이 포화되면 네트워크 연결 시작도 영향을 받을 수 있습니다.
작업 종류를 바꾸면 같은 여덟 개를 다시 돌립니다. fetch는 응답을 기다리는 동안 워커를 쓰지 않습니다.
여기까지 보면 Node.js에서 말하는 ‘비동기’가 하나의 구현을 뜻하지 않는다는 점이 드러납니다.
파일은 스레드풀에서 처리할 수 있고, 네트워크는 운영체제의 I/O 알림을 이용합니다. 중요한 공통점은 작업을 기다리는 동안 메인 JavaScript 스레드를 점유하지 않는다는 것입니다.
메인 스레드가 멈추면 다른 요청도 함께 밀립니다
이제 처음의 질문으로 돌아갈 수 있습니다.
pbkdf2Sync()처럼 동기 작업이 콜 스택을 계속 점유하면 이벤트 루프는 다음 JavaScript 작업을 실행할 수 없습니다.
이벤트 루프와 요청 처리용 JavaScript가 같은 메인 스레드에서 움직이기 때문입니다.
그동안 새 요청이 들어와도 서버의 JavaScript는 요청을 처리할 수 없습니다. 네트워크 계층이나 운영체제에는 요청이 도착해 있을 수 있지만, 해당 Node.js 프로세스가 다시 실행 기회를 얻을 때까지 실제 핸들러 처리는 밀립니다.
브라우저에서 이런 일이 생기면 한 탭이 멈춘 것처럼 보입니다.
서버에서는 영향 범위가 더 큽니다. 같은 Node.js 프로세스가 처리하는 다른 사용자의 요청도 함께 영향을 받습니다.
이를 확인하기 위해 Node.js 기본 http 서버를 만들었습니다. 100ms마다 가벼운 요청을 보내고, 1초 뒤에 2초짜리 요청 하나를 추가했습니다.
function busy(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
http.createServer(async (req, res) => {
if (req.url === '/block') {
busy(2000);
}
if (req.url === '/slow') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
res.end('ok');
});
/block과 /slow 모두 응답에는 약 2초가 걸립니다.
하지만 내부에서는 전혀 다른 일이 벌어집니다.
/block은 2초 동안 JavaScript를 계속 실행합니다. /slow는 타이머를 등록한 뒤 콜 스택에서 빠져나옵니다.
측정 결과도 차이가 컸습니다.
| 섞은 요청 | 가벼운 요청 응답 시간 중앙값 / 최대 | 100ms 초과 | ELU |
|---|---|---|---|
| 없음 | 2ms / 9ms | 0 / 39 | 0.01 |
/block 동기 계산 2초 | 79ms / 1,995ms | 19 / 39 | 0.32 |
/slow 비동기 대기 2초 | 2ms / 8ms | 0 / 39 | 0.01 |
ELU(Event Loop Utilization)
이벤트 루프가 I/O를 기다리지 않고 실제로 일을 한 시간의 비율입니다.
performance.eventLoopUtilization()으로 확인할 수 있습니다. CPU 사용률과 비슷해 보이지만, 이벤트 루프가 얼마나 바빴는지를 따로 보여 줍니다.
/block이 실행되는 동안 도착한 요청은 계산이 끝날 때까지 JavaScript 처리를 시작하지 못했습니다. 가장 오래 기다린 요청은 응답까지 거의 2초가 걸렸습니다.
반면 /slow가 기다리는 동안에는 콜 스택이 비어 있었기 때문에 다른 요청의 응답 시간은 거의 변하지 않았습니다.
1초 시점에 2초짜리 무거운 요청이 들어옵니다. 그 요청이 CPU로 계산하는지(동기), 응답을 기다리기만 하는지(비동기)를 바꿔 실행해 보세요. 파란 블록이 100ms마다 들어오는 가벼운 요청입니다.
둘 다 ‘2초짜리 요청’이지만 서버에 미치는 영향은 완전히 다릅니다.
그래서 서버 성능을 볼 때는 요청이 얼마나 오래 걸렸는지만 봐서는 부족합니다. 그 시간 동안 메인 스레드가 일하고 있었는지, 아니면 외부 작업을 기다리고 있었는지를 함께 봐야 합니다.
실제 코드에서는 무엇이 이벤트 루프를 막을까
실무 코드에서 2초짜리 while문을 직접 작성할 일은 거의 없습니다.
문제는 평범해 보이는 코드도 입력 크기에 따라 같은 결과를 만들 수 있다는 점입니다.
| 원인 | 예 |
|---|---|
| 큰 JSON 처리 | JSON.parse(huge), JSON.stringify(huge) |
| 비효율적인 정규식 | 특정 입력에서 과도한 backtracking이 발생하는 정규식 |
| 동기 I/O | fs.readFileSync(), child_process.execSync() |
| 동기 암호화·압축 | crypto.pbkdf2Sync(), zlib.gzipSync() |
| 큰 데이터 연산 | 대량 정렬, 중첩 반복문, 큰 데이터 변환 |
실행 시간은 하드웨어와 데이터 형태에 따라 크게 달라집니다.
제 환경에서는 약 63MB 배열을 JSON.parse()하는 데 110ms 정도가 걸렸습니다. 2초와 비교하면 짧아 보이지만, 서버에서는 110ms도 작지 않습니다.
그 110ms 동안 같은 프로세스에서 JavaScript 실행이 필요한 다른 요청은 실행 시점이 뒤로 밀릴 수 있습니다. 이런 작업이 여러 요청에서 반복되면 짧은 블로킹도 응답 시간의 긴 꼬리로 이어집니다.
반대로 /slow처럼 비동기로 기다리는 작업은 이벤트 루프를 막지 않습니다.
그렇다고 비용이 없는 것은 아닙니다.
외부 API가 계속 느려지면 응답을 기다리는 요청 자체가 많아지고, 각 요청이 들고 있는 객체와 소켓, 커넥션 풀 사용량도 늘어납니다.
따라서 이벤트 루프 지표는 정상인데 메모리와 동시 대기 요청 수가 계속 오른다면 CPU 블로킹과는 다른 문제를 의심해야 합니다.
메모리를 볼 때 heapUsed만 보면 안 됩니다
Node.js 프로세스의 메모리는 V8 힙만으로 이루어져 있지 않습니다.
process.memoryUsage()를 보면 여러 값이 따로 나옵니다.
| 필드 | 의미 |
|---|---|
rss | 프로세스가 실제 메모리에 올려 둔 전체 영역 |
heapTotal | V8이 JavaScript 힙으로 확보한 영역 |
heapUsed | 그중 실제로 JavaScript 객체가 사용 중인 영역 |
external | V8 객체와 연결된 네이티브 메모리 |
arrayBuffers | ArrayBuffer와 Node.js Buffer가 사용하는 메모리. external에도 포함됩니다. |
특히 Buffer의 실제 데이터는 V8 힙 바깥에 저장됩니다.
차이를 확인하려고 JavaScript 객체 100만 개를 만든 뒤 10MB짜리 Buffer 20개를 추가했습니다. 이후 GC를 한 번 실행하고 메모리를 확인했습니다.
| 시점 | rss | heapUsed | arrayBuffers |
|---|---|---|---|
| 시작 | 33MB | 3MB | 0MB |
| 객체 100만 개 | 144MB | 82MB | 0MB |
| Buffer 200MB 추가 | 346MB | 82MB | 200MB |
프로세스에는 200MB가 추가됐지만 heapUsed는 거의 변하지 않았습니다.
따라서 파일 데이터나 압축 결과, 큰 응답처럼 Buffer를 많이 사용하는 코드에서 메모리가 새고 있다면 heapUsed만 보고는 문제를 놓칠 수 있습니다.
rss, heapUsed, arrayBuffers를 함께 봐야 하는 이유입니다.
GC가 있어도 메모리 누수는 생깁니다
V8은 세대별 가비지 컬렉션을 사용합니다.
새로 만들어진 객체는 비교적 자주 검사되고, 오래 살아남은 객체는 오래된 세대로 이동합니다. 오래된 객체가 쌓이면 더 큰 범위를 대상으로 GC가 실행됩니다.
최근 V8은 표시와 정리 작업의 상당 부분을 점진적·병렬·동시 방식으로 처리해 멈춤 시간을 줄입니다. 그래도 모든 단계가 메인 스레드와 완전히 독립적인 것은 아니기 때문에 짧은 pause는 남습니다.
더 중요한 것은 참조가 남아 있는 객체는 GC가 지울 수 없다는 점입니다.
예를 들어 모듈 스코프에 크기 제한 없는 Map을 만들고 계속 값을 추가한다면 GC 입장에서는 모두 여전히 사용 중인 데이터입니다.
GC가 반복해서 실행돼도 heapUsed의 바닥선이 계속 올라가는 형태가 나타날 수 있습니다.
힙 한도와 컨테이너 한도는 다릅니다
프로세스가 사용할 수 있는 메모리에도 여러 한도가 있습니다.
V8 힙이 먼저 한계에 도달할 수도 있고, 프로세스 전체의 메모리 사용량이 컨테이너 한도를 먼저 넘을 수도 있습니다.
두 경우는 겉으로 보이는 종료 방식이 다릅니다.
| 상황 | 종료 주체 | 일반적으로 보이는 결과 |
|---|---|---|
| V8 힙을 더 이상 확보하지 못함 | V8 / Node.js 프로세스 | JavaScript heap out of memory 오류와 함께 비정상 종료 |
| 컨테이너 메모리 한도 초과 | 운영체제의 OOM 처리 | 프로세스가 SIGKILL을 받아 종료 |
POSIX 환경에서 V8이 SIGABRT로 종료되면 셸에서 134가 보일 수 있습니다. 컨테이너 OOM으로 SIGKILL을 받으면 137이 표시되는 경우가 많습니다.
다만 이 숫자를 모든 환경에서 절대적인 규칙으로 보면 안 됩니다. 프로세스 관리자나 컨테이너 플랫폼이 종료 상태를 다른 방식으로 표시할 수 있기 때문입니다.
또 컨테이너 OOM이라고 해서 이전 로그까지 모두 사라지는 것은 아닙니다. 다만 프로세스가 강제로 종료되므로 마지막 순간에 애플리케이션 코드가 오류를 기록하거나 종료 처리를 실행할 기회가 없습니다.
따라서 애플리케이션 로그에 명확한 오류가 없는데 프로세스가 갑자기 사라졌다면 컨테이너의 OOM 상태나 종료 이유도 함께 확인해야 합니다.
객체나 Buffer를 계속 쌓아 보세요. 블록 하나가 20MB입니다.
이 구조를 Next.js 서버에 대입해 보기
여기까지는 Node.js 자체의 이야기였습니다.
이제 처음의 Next.js 실험으로 돌아가 보겠습니다.
Node.js 런타임을 사용하는 Next.js 서버에서도 요청 처리용 JavaScript는 같은 원리를 따릅니다. 한 Node.js 프로세스 안에서 서버 컴포넌트의 JavaScript가 긴 동기 연산을 수행하면 그동안 같은 프로세스가 처리해야 할 다른 JavaScript 작업도 밀립니다.
서버 컴포넌트의 동기 계산도 메인 스레드에서 실행됩니다
처음 사용한 /heavy 페이지는 다음과 같습니다.
// app/runtime/heavy/page.tsx
import { connection } from 'next/server';
export default async function Page() {
await connection();
const end = Date.now() + 2000;
while (Date.now() < end) {}
return <p>heavy done</p>;
}
connection()을 사용해 요청 시점에 렌더링하도록 만든 뒤, 렌더링 안에서 약 2초 동안 동기 계산을 실행했습니다.
결과는 다음과 같았습니다.
| 요청 | /light 응답 시간 |
|---|---|
/light만 요청 | 4~6ms |
/heavy 시작 0.2초 뒤 /light 3개 요청 | 세 요청 모두 약 1.8초 |
서버 컴포넌트도 결국 JavaScript 함수입니다.
렌더링 안에서 시작한 동기 계산이 끝날 때까지 해당 프로세스의 메인 JavaScript 스레드는 다른 렌더링 작업을 실행할 수 없습니다.
React의 스트리밍이 이 문제를 자동으로 해결해 주지도 않습니다.
<Suspense>를 기준으로 준비된 결과부터 나눠 보낼 수는 있지만, 이미 실행 중인 하나의 동기 JavaScript 함수를 중간에서 잘라 다른 요청에 CPU를 넘겨 주는 기능은 아니기 때문입니다.
따라서 렌더링 경로에 무거운 작업이 있다면 반복하지 않거나, 비동기 구현으로 바꾸거나, 메인 JavaScript 스레드 밖으로 보내는 방향으로 접근해야 합니다.
요청마다 같은 결과를 계산한다면 캐시할 수 있습니다. Next.js의 Cache Components를 사용하는 경우 'use cache'로 함수나 컴포넌트 결과를 재사용할 수 있습니다. 다만 최초의 cache miss에서 실행되는 무거운 동기 계산은 여전히 메인 스레드를 사용합니다. 빌드 시점에 계산할 수 있다면 아예 정적으로 만들어 두는 편이 더 낫습니다.
Node.js가 비동기 API를 제공하는 작업이라면 동기 API를 피합니다.
import { readFile } from 'node:fs/promises';
async function getTemplate(path: string) {
return readFile(path, 'utf8');
}
readFileSync()와 달리 파일을 읽는 동안 메인 JavaScript 스레드를 계속 점유하지 않습니다.
반대로 순수 JavaScript 계산 자체가 무겁다면 비동기 함수로 감싸는 것만으로는 해결되지 않습니다.
async function heavy() {
// 여전히 메인 스레드에서 계산된다
return expensiveCalculation();
}
이 경우에는 worker_threads처럼 JavaScript 계산을 실제 다른 스레드에서 실행할 방법을 검토해야 합니다.
fetch가 느린 것과 이벤트 루프가 막힌 것은 다릅니다
서버 컴포넌트에서 외부 API를 fetch()한다고 가정해 보겠습니다.
외부 서버가 응답하는 데 2초가 걸려도 그 2초 동안 JavaScript가 계속 실행되는 것은 아닙니다.
const response = await fetch(url);
요청을 보낸 뒤 네트워크 응답을 기다리는 동안 현재 함수는 멈추고 콜 스택에서 빠져나옵니다. 그래서 그 시간에 다른 요청의 JavaScript가 실행될 수 있습니다.
앞에서 본 /slow와 같은 상황입니다.
다만 느린 외부 요청이 많아지면 다른 문제가 생깁니다.
서로 독립적인 요청을 순서대로 await하면 각 대기 시간이 합쳐집니다. 외부 API 자체가 느려지면 동시에 응답을 기다리는 서버 요청도 늘어납니다.
이 경우 이벤트 루프는 막히지 않아도 메모리, 열린 소켓, 커넥션 풀 같은 자원이 증가할 수 있습니다.
또 네트워크 응답을 받은 뒤 아주 큰 JSON을 파싱한다면 이야기가 다시 달라집니다.
네트워크를 기다리는 시간은 이벤트 루프를 막지 않지만, 응답을 받은 뒤 JSON.parse()가 CPU를 오래 사용한다면 그 구간에서는 다시 메인 스레드가 막힐 수 있습니다.
모듈 스코프의 값은 요청마다 새로 만들어지지 않습니다
Node.js는 한 번 불러온 모듈을 캐시합니다.
따라서 같은 Node.js 프로세스가 살아 있는 동안 모듈 스코프의 변수도 요청마다 새로 만들어지지 않습니다.
// app/runtime/counter/page.tsx
let visits = 0;
export default async function Page() {
await connection();
visits += 1;
return <p>visits: {visits}</p>;
}
같은 프로세스에서 이 페이지를 세 번 요청하면 visits가 계속 증가합니다.
visits: 1
visits: 2
visits: 3
즉 서버의 모듈 스코프를 한 사용자의 요청 공간처럼 생각하면 안 됩니다.
특히 사용자별 값을 모듈 스코프에 넣은 뒤 await를 만나면 문제가 더 쉽게 드러납니다.
사용자 A의 요청이 값을 저장하고 외부 API를 기다리는 동안 콜 스택은 비어 있습니다. 그사이 사용자 B의 요청이 들어와 같은 변수를 바꿀 수 있습니다.
A의 요청이 다시 실행됐을 때는 자신이 넣었던 값이 아니라 B가 덮어쓴 값을 읽게 됩니다.
단계를 넘기며 currentUser 칸을 보세요. 값을 요청 안으로 옮기면 A의 응답이 달라집니다.
let currentUser 모듈 스코프 await fetch() const currentUser 요청마다 따로 요청 안에서만 중복 호출을 합치거나 값을 공유하려면 React.cache처럼 요청 범위로 제한되는 수단을 사용할 수 있습니다.
반대로 여러 요청에 걸쳐 결과를 저장하려면 명시적인 캐시 정책이 필요합니다. TTL이나 최대 크기를 두고, 여러 서버 인스턴스에서 공유해야 한다면 프로세스 메모리 밖의 저장소도 고려해야 합니다.
모듈 스코프는 프로세스가 재시작되면 사라지고, 여러 인스턴스 사이에서 동일하게 공유된다는 보장도 없습니다.
따라서 요청 사이의 영속적인 상태 저장소처럼 사용하기에는 적합하지 않습니다.
문제를 Node.js 지표로 확인하기
지금까지 본 현상은 대부분 Node.js가 제공하는 지표로 확인할 수 있습니다.
Next.js에서는 instrumentation.ts의 register()를 이용해 서버 프로세스가 시작될 때 측정을 등록할 수 있습니다.
register()는 여러 런타임에서 호출될 수 있으므로 Node.js 전용 코드는 런타임을 확인한 뒤 불러오는 편이 안전합니다.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation-node');
}
}
이제 이벤트 루프와 메모리를 주기적으로 기록합니다.
// instrumentation-node.ts
import {
monitorEventLoopDelay,
performance,
} from 'node:perf_hooks';
const MB = 1024 * 1024;
const delay = monitorEventLoopDelay({
resolution: 10,
});
delay.enable();
let lastElu = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(lastElu);
lastElu = performance.eventLoopUtilization();
const {
rss,
heapUsed,
arrayBuffers,
} = process.memoryUsage();
console.log({
delayMaxMs: Math.round(delay.max / 1e6),
delayP99Ms: Math.round(delay.percentile(99) / 1e6),
elu: elu.utilization.toFixed(2),
rssMB: Math.round(rss / MB),
heapUsedMB: Math.round(heapUsed / MB),
arrayBuffersMB: Math.round(arrayBuffers / MB),
});
delay.reset();
}, 5000).unref();
이 상태에서 /heavy를 한 번 호출했습니다.
{
delayMaxMs: 2019,
delayP99Ms: 11,
elu: '0.41',
rssMB: 133,
heapUsedMB: 33,
arrayBuffersMB: 0
}
2초 동안 이벤트 루프가 막혔기 때문에 delayMaxMs는 약 2초까지 올랐습니다.
그런데 p99는 11ms에 불과했습니다.
monitorEventLoopDelay()가 여러 번 수집한 지연 값 가운데 2초짜리 긴 지연은 하나뿐이었기 때문입니다. 표본 대부분이 정상이라면 p99에서는 드문 긴 블로킹이 보이지 않을 수 있습니다.
그래서 간헐적인 멈춤을 찾을 때는 p99 하나만 보지 않고 최대 지연과 ELU도 같이 보는 편이 좋습니다.
여기서 이벤트 루프가 막힌 사실까지는 알 수 있지만, 어느 함수가 CPU를 사용했는지는 알 수 없습니다.
그 단계에서는 CPU profile을 남겨 확인할 수 있습니다.
node --cpu-prof node_modules/.bin/next start
부하를 재현한 뒤 생성된 CPU profile을 열어 보면 어떤 함수가 메인 스레드 시간을 많이 사용했는지 확인할 수 있습니다.
마치며
처음의 /heavy와 /light 실험은 Next.js만의 특별한 동작이 아니었습니다.
서버 컴포넌트의 JavaScript도 결국 Node.js 런타임 위에서 실행됩니다. 같은 프로세스에서 긴 동기 계산이 메인 스레드를 점유하면 다른 요청의 JavaScript 실행도 함께 밀립니다.
반면 파일 I/O나 네트워크처럼 비동기로 기다릴 수 있는 작업은 기다리는 동안 콜 스택을 비워 둡니다. 파일과 일부 암호화 작업은 libuv 스레드풀을 이용하고, 네트워크는 주로 운영체제의 I/O 알림을 이용한다는 차이는 있지만 메인 스레드를 계속 붙잡지 않는다는 점은 같습니다.
메모리 문제도 같은 구조 안에서 볼 수 있습니다. JavaScript 객체는 V8 힙을 사용하지만 Buffer처럼 힙 바깥에서 커지는 메모리도 있습니다. 그래서 heapUsed만으로는 프로세스 전체의 메모리 상태를 판단하기 어렵습니다.
결국 Next.js 서버가 느려졌을 때 가장 먼저 구분해야 하는 것은 서버가 계산하느라 바쁜지, 아니면 무언가를 기다리고 있는지입니다.
렌더링 경로에서 *Sync API를 호출하고 있지는 않은지, 큰 JSON이나 배열을 요청마다 가공하고 있지는 않은지, 같은 계산을 반복하고 있지는 않은지부터 확인할 수 있습니다.
이벤트 루프 지연과 ELU가 높다면 CPU를 점유하는 코드를 찾고, 이벤트 루프는 정상인데 응답만 느리다면 외부 I/O와 동시 대기 요청 수를 살펴볼 수 있습니다. 메모리가 계속 오른다면 heapUsed뿐 아니라 rss와 arrayBuffers까지 같이 봐야 합니다.
Node.js 내부 구조를 알고 나면 ‘서버가 느리다’는 하나의 현상을 조금 더 구체적인 문제로 나눌 수 있습니다.
메인 스레드가 막혔는지, 스레드풀이 밀렸는지, 외부 I/O를 기다리고 있는지, 메모리가 쌓이고 있는지.
원인을 어디서 찾아야 할지도 그때부터 훨씬 명확해집니다.