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/promisesscheduler.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. 무작정 넣지 말고 지연 구간을 먼저 찾는 편이 안전하다

도입할 때는 아래 순서가 현실적입니다.

  1. 이벤트 루프 지연이 실제로 있는지 측정하기
  2. 긴 반복문이나 대량 변환 구간을 찾기
  3. 청크 크기를 작게 시작해 응답성과 처리량을 비교하기
  4. yield() 지점에서 취소, 진행률, 로그 flush를 함께 점검하기
  5. 그래도 CPU 점유가 높으면 worker_threads 전환 검토하기

청크 크기는 고정 정답이 없습니다. 너무 작으면 오버헤드가 늘고, 너무 크면 응답성 개선이 약해집니다. 그래서 운영 환경에서는 보통 50ms 안팎으로 한 번씩 양보하도록 맞춰 보는 방식이 실용적입니다.

마무리

Node.js의 scheduler.yield()는 성능 최적화라기보다 응답성 관리 도구에 가깝습니다. 긴 작업을 무조건 더 빨리 끝내게 해주지는 않지만, 서버가 다른 중요한 일을 너무 오래 굶지 않게 만들 수 있습니다.

배치 처리, 대량 순회, 변환 파이프라인에서 “전체 처리량은 괜찮은데 서비스가 버벅인다”는 느낌이 있다면, 먼저 작업을 청크로 나누고 await scheduler.yield()를 넣어 보는 것만으로도 꽤 큰 차이를 만들 수 있습니다.

함께 보면 좋은 글