Node.js scheduler.yield 가이드: 긴 작업 중 이벤트 루프 응답성을 지키는 법
Node.js 서버가 갑자기 “느려진 것처럼” 보일 때가 있습니다. CPU 사용률이 100%까지 치솟지 않아도, 긴 반복문 하나가 이벤트 루프를 오래 붙잡으면 요청 처리, 타이머, 로그 플러시, 취소 신호 반영이 함께 밀릴 수 있습니다.
이럴 때 알아둘 만한 도구가 scheduler.yield()입니다.
핵심은 작업을 완전히 끝낼 때까지 독점하지 말고, 중간중간 이벤트 루프에 제어권을 돌려주는 것입니다.
Node.js scheduler.yield가 필요한 상황
H3. 비동기 함수 안에서도 긴 동기 계산은 그대로 병목이 된다
많이 놓치는 지점이 하나 있습니다.
함수가 async라고 해서 내부의 긴 for 루프가 자동으로 잘게 나뉘지는 않습니다.
반복문 안에 await가 없으면, 그 구간은 여전히 한 번에 실행됩니다.
async function buildRanking(items) {
const result = [];
for (let i = 0; i < items.length; i += 1) {
result.push(expensiveTransform(items[i]));
}
return result;
}
이 코드는 문법상 비동기 함수지만, 실제로는 expensiveTransform()이 길어질수록 이벤트 루프를 오래 점유합니다.
그 결과 아래 같은 증상이 생길 수 있습니다.
- HTTP 응답 시작이 늦어짐
AbortSignal취소 반영이 굼떠짐- 지연 모니터링 수치가 갑자기 튀기 시작함
- 배치 작업 중 로그가 한꺼번에 밀려 찍힘
이벤트 루프 지연을 먼저 측정하는 방법은 Node.js event loop lag monitoring 가이드: 서버 지연을 조기에 감지하는 법에서 함께 볼 수 있습니다.
H3. 전체 작업량보다 한 번에 붙잡는 시간이 더 중요할 때가 많다
같은 3초짜리 작업이라도, 한 번에 3초를 독점하는 작업과 30ms씩 나눠 100번 처리하는 작업은 체감이 다릅니다. 후자는 총 처리 시간은 비슷할 수 있어도, 중간에 다른 I/O가 끼어들 여지가 생깁니다.
즉 scheduler.yield()는 작업을 마법처럼 빠르게 만드는 기능이 아니라, 시스템의 응답성을 덜 망가뜨리는 기능에 가깝습니다.
scheduler.yield 기본 사용법
H3. 큰 반복문을 청크로 나누고 중간마다 양보한다
node:timers/promises의 scheduler.yield()는 현재 실행 흐름을 잠깐 멈추고, 이벤트 루프가 다른 일을 처리할 기회를 주는 데 쓸 수 있습니다.
import { scheduler } from 'node:timers/promises';
async function processInChunks(items) {
const chunkSize = 500;
const output = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const item of chunk) {
output.push(expensiveTransform(item));
}
await scheduler.yield();
}
return output;
}
이 패턴의 장점은 분명합니다.
- 긴 작업 도중 타이머와 I/O가 숨 쉴 시간을 확보할 수 있음
- 배치 처리 중 서버 전체 응답성 저하를 완화할 수 있음
- 취소 신호나 상태 체크를 중간마다 반영하기 쉬움
- 작업을 완전히 다른 스레드로 옮기기 전의 가벼운 1차 대응책이 됨
H3. yield 지점마다 취소 신호를 확인하면 운영성이 좋아진다
긴 배치 작업은 멈출 수 있어야 실무에서 다루기 편합니다.
yield() 앞뒤에서 취소 여부를 확인하면, 중단 요청이 더 빨리 반영됩니다.
import { scheduler } from 'node:timers/promises';
async function reindex(docs, signal) {
const chunkSize = 300;
for (let i = 0; i < docs.length; i += chunkSize) {
signal?.throwIfAborted();
const chunk = docs.slice(i, i + chunkSize);
for (const doc of chunk) {
updateSearchIndex(doc);
}
await scheduler.yield();
}
}
취소 흐름 자체를 더 안정적으로 설계하려면 Node.js AbortSignal.any 가이드: timeout과 사용자 취소를 함께 처리하는 법을 같이 보는 편이 좋습니다.
setImmediate, queueMicrotask와는 무엇이 다른가
H3. microtask는 양보처럼 보여도 I/O 기회를 충분히 주지 못할 수 있다
queueMicrotask()나 Promise.resolve().then()은 아주 짧은 후속 작업 예약에는 좋지만, 이벤트 루프의 다음 단계로 충분히 넘어가게 만드는 목적과는 다를 수 있습니다.
특히 “다른 I/O도 처리하게 하자”가 목표라면 microtask만으로는 기대한 효과가 약할 수 있습니다.
이 차이는 Node.js queueMicrotask vs process.nextTick 가이드: 실행 순서와 함정 정리와 연결해서 이해하면 더 명확합니다.
H3. setImmediate는 여전히 유효하지만 intent가 덜 직접적이다
기존에는 아래처럼 setImmediate()를 감싸서 비슷한 흐름을 만들곤 했습니다.
await new Promise((resolve) => setImmediate(resolve));
이 방식도 여전히 쓸 수 있지만, “이제 한 번 양보하겠다”는 의도를 드러내는 데는 scheduler.yield()가 더 직접적입니다.
또 node:timers/promises 계열 API를 이미 쓰는 코드베이스라면 스타일을 맞추기도 쉽습니다.
타이머 기반 제어 흐름은 Node.js timers/promises setTimeout 가이드: delay와 retry를 깔끔하게 다루는 법과 함께 보면 문맥이 잘 이어집니다.
언제 scheduler.yield로 충분하고, 언제 worker_threads가 필요한가
H3. 짧게 나눌 수 있는 작업이면 yield가 먼저다
아래 조건에 가까우면 scheduler.yield()가 좋은 첫 선택입니다.
- 작업이 큰 배열/목록 순회 중심임
- 중간 상태 저장이 쉬움
- 청크 단위로 끊어도 결과 일관성이 유지됨
- 전체 CPU 부담은 있지만 스레드 분리까지는 과하지 않음
즉 “당장 구조를 크게 바꾸지 않고 응답성부터 회복하고 싶다”면 꽤 실용적입니다.
H3. 순수 CPU 계산이 너무 무거우면 스레드 분리가 맞다
반대로 이미지 처리, 대형 JSON 변환, 암호화, 복잡한 점수 계산처럼 각 청크 자체가 이미 너무 무거운 경우에는 yield()만으로 부족할 수 있습니다.
이때는 이벤트 루프에 잠깐씩 양보하는 정도가 아니라, 아예 CPU 작업을 다른 스레드로 보내는 편이 낫습니다.
이 판단 기준은 Node.js worker_threads 가이드: CPU 바운드 작업 성능 개선 패턴과 함께 비교해 보면 명확해집니다.
실무 적용 체크리스트
H3. 무작정 넣지 말고 지연 구간을 먼저 찾는 편이 안전하다
도입할 때는 아래 순서가 현실적입니다.
- 이벤트 루프 지연이 실제로 있는지 측정하기
- 긴 반복문이나 대량 변환 구간을 찾기
- 청크 크기를 작게 시작해 응답성과 처리량을 비교하기
yield()지점에서 취소, 진행률, 로그 flush를 함께 점검하기- 그래도 CPU 점유가 높으면 worker_threads 전환 검토하기
청크 크기는 고정 정답이 없습니다. 너무 작으면 오버헤드가 늘고, 너무 크면 응답성 개선이 약해집니다. 그래서 운영 환경에서는 보통 50ms 안팎으로 한 번씩 양보하도록 맞춰 보는 방식이 실용적입니다.
마무리
Node.js의 scheduler.yield()는 성능 최적화라기보다 응답성 관리 도구에 가깝습니다.
긴 작업을 무조건 더 빨리 끝내게 해주지는 않지만, 서버가 다른 중요한 일을 너무 오래 굶지 않게 만들 수 있습니다.
배치 처리, 대량 순회, 변환 파이프라인에서 “전체 처리량은 괜찮은데 서비스가 버벅인다”는 느낌이 있다면,
먼저 작업을 청크로 나누고 await scheduler.yield()를 넣어 보는 것만으로도 꽤 큰 차이를 만들 수 있습니다.
함께 보면 좋은 글
이 글은 AI가 작성/정리하고, 오너가 방향을 결정한 프로젝트 운영 로그입니다.