← 목록으로 돌아가기

Node.js CPU 작업 분리: Worker Threads, Queue, Backpressure로 이벤트 루프 지연 줄이기

Backend

Node.js가 느린 것이 아니라 이벤트 루프가 막힌다

Node.js는 I/O 중심 서비스에 강합니다. 하지만 이미지 리사이징, CSV 파싱, 암호화, 대용량 JSON 변환, 리포트 생성처럼 CPU를 오래 쓰는 작업을 요청 처리 경로에서 실행하면 이벤트 루프가 막힙니다. 한 요청의 무거운 계산이 다른 모든 요청의 응답 지연으로 번집니다.

이벤트 루프 지연은 단순히 평균 응답 시간이 느려지는 문제가 아닙니다. health check가 늦어지고, timeout이 증가하고, websocket heartbeat가 밀리고, queue consumer의 poll 간격이 흔들립니다. CPU 작업은 I/O 작업과 다른 격리 전략이 필요합니다. Node.js 프로세스 하나에 모든 일을 맡기는 구조는 운영 트래픽에서 쉽게 한계에 닿습니다.

이 글에서는 CPU bound 작업을 Worker Threads와 별도 queue로 분리하는 기준을 정리합니다. 어떤 작업을 분리해야 하는지, worker pool 크기를 어떻게 잡는지, backpressure를 어떻게 걸어야 메모리 폭증을 막을 수 있는지 다룹니다.


Node.js 작업 분리와 백프레셔

1. 먼저 이벤트 루프 지연을 측정한다

CPU 작업 문제를 감으로 판단하면 늦습니다. event loop delay, p95 latency, CPU 사용률, GC pause, request timeout을 함께 봐야 합니다. CPU가 100%가 아니어도 이벤트 루프가 긴 작업에 막히면 사용자 요청은 밀릴 수 있습니다. Node.js의 perf_hooks.monitorEventLoopDelay 같은 도구로 지연 분포를 측정할 수 있습니다.

APM trace에서 특정 endpoint가 긴 synchronous block을 만드는지도 확인합니다. JSON.stringify가 큰 객체에 대해 오래 걸릴 수도 있고, 정규식이 특정 입력에서 폭발할 수도 있습니다. 외부 API가 느린 것처럼 보였지만 실제로는 응답을 받은 뒤 동기 파싱에서 시간을 쓰는 경우도 흔합니다.

측정 후에는 요청 경로에서 반드시 즉시 처리해야 하는 작업과 비동기로 넘겨도 되는 작업을 나눕니다. 사용자에게 즉시 결과가 필요한 작은 계산은 최적화하고, 오래 걸리는 리포트 생성이나 파일 변환은 job queue로 분리하는 편이 낫습니다.


2. Worker Threads는 같은 프로세스 안의 CPU 격리다

Worker Threads는 CPU 작업을 메인 이벤트 루프 밖에서 실행하게 해줍니다. 메인 스레드는 요청을 받고 worker에게 작업을 넘긴 뒤 결과를 기다립니다. 이미지 처리, 압축, 암호화, 파싱처럼 순수 계산에 가까운 작업에 적합합니다. 단, worker를 요청마다 새로 만들면 생성 비용이 큽니다. worker pool을 유지해야 합니다.

Worker pool 크기는 CPU 코어 수와 작업 특성에 맞춰야 합니다. 너무 작으면 queue가 쌓이고, 너무 크면 context switching과 메모리 사용량이 늘어납니다. 일반적으로 코어 수를 기준으로 시작해 실제 부하 테스트로 조정합니다. worker 내부에서도 blocking I/O를 섞으면 기대한 격리가 깨질 수 있습니다.

데이터 전달 비용도 고려해야 합니다. 큰 객체를 worker로 복사하면 serialization 비용이 커집니다. ArrayBuffer를 transfer하거나, 파일 경로만 넘기고 worker가 직접 읽게 하는 방식이 더 효율적일 수 있습니다. CPU 작업을 분리했는데 데이터 복사 비용 때문에 전체 시간이 늘어나는 경우도 있습니다.


3. Queue와 Backpressure가 없으면 worker도 터진다

Worker pool을 만들었다고 끝이 아닙니다. 들어오는 요청이 처리량보다 많으면 대기열이 계속 늘어납니다. 메모리가 증가하고, 대기 시간이 길어지고, 결국 프로세스가 죽습니다. 그래서 queue 길이 제한과 backpressure가 필요합니다. 대기열이 임계값을 넘으면 요청을 거부하거나 202 Accepted로 비동기 처리 상태를 돌려야 합니다.

사용자 요청 안에서 오래 기다리는 방식도 위험합니다. 리포트 생성처럼 수 초 이상 걸리는 작업은 job id를 발급하고, 클라이언트가 상태를 조회하게 만드는 편이 안정적입니다. 작업 결과는 object storage나 데이터베이스에 저장하고, 완료 알림을 보낼 수 있습니다. 이 구조에서는 API timeout과 CPU 작업 시간을 분리할 수 있습니다.

Queue consumer에서도 concurrency를 조절해야 합니다. 동시에 너무 많은 worker 작업을 실행하면 CPU가 포화되고 다른 서비스 기능까지 영향을 받습니다. 작업별 우선순위도 고려합니다. 사용자 요청과 연결된 짧은 작업과, 배치성 대량 변환 작업을 같은 queue에 넣으면 중요한 작업이 뒤로 밀릴 수 있습니다.


4. 운영 체크리스트

  • event loop delay p95와 p99를 모니터링하는가
  • CPU bound 작업이 요청 이벤트 루프에서 동기 실행되지 않는가
  • worker pool 크기가 CPU 코어와 부하 테스트 결과를 기준으로 정해졌는가
  • worker로 넘기는 데이터의 복사 비용을 측정했는가
  • queue 길이 제한과 요청 거부 정책이 있는가
  • 긴 작업은 job id 기반 비동기 처리로 분리했는가
  • worker 실패, timeout, 재시도, poison job 처리가 정의되어 있는가

5. Worker 작업은 취소와 timeout을 지원해야 한다

CPU 작업은 한 번 시작하면 끝날 때까지 기다리는 구조가 되기 쉽습니다. 하지만 사용자가 요청을 취소하거나, job deadline이 지나거나, 배포 중 worker를 종료해야 하는 상황이 있습니다. 작업 취소와 timeout을 고려하지 않으면 오래 걸리는 작업이 리소스를 계속 붙잡습니다. Worker pool에는 작업별 deadline과 취소 신호가 필요합니다.

Node.js Worker Threads에서는 AbortSignal을 직접 계산 루프 안에서 확인하거나, 작업을 작은 chunk로 나눠 중간에 중단 가능하게 만들 수 있습니다. 완전히 synchronous한 긴 루프는 취소를 받기 어렵습니다. CSV 파싱이나 이미지 처리처럼 단계가 있는 작업은 파일 단위, row batch 단위, image 단위로 쪼개면 timeout 처리와 진행률 보고가 쉬워집니다.

Timeout이 발생했을 때는 worker를 재사용할지 폐기할지도 정해야 합니다. 일부 native addon이나 복잡한 작업은 timeout 후 내부 상태를 신뢰하기 어렵습니다. 이런 경우 worker를 종료하고 새 worker를 만드는 편이 안전합니다. 비용은 들지만 오염된 worker가 다음 작업에 영향을 주는 것보다 낫습니다.


6. 진행률과 결과 저장소를 분리한다

긴 CPU 작업은 사용자에게 진행 상태를 보여줘야 합니다. "처리 중"만 오래 보이면 사용자는 새로고침하거나 같은 작업을 반복 요청할 수 있습니다. job 테이블에 status, progress, startedAt, finishedAt, errorCode를 저장하고, 클라이언트는 job id로 상태를 조회하게 만듭니다. 완료 결과는 object storage나 DB에 저장하고 다운로드 URL을 제공합니다.

작업이 재시도될 수 있다면 idempotency key가 필요합니다. 같은 파일 변환 요청을 사용자가 두 번 보내도 같은 job을 재사용하거나, 이전 job이 진행 중임을 알려줘야 합니다. 그렇지 않으면 같은 CPU 작업이 중복 실행되어 queue가 더 빨리 막힙니다. 특히 리포트 생성, 동영상 변환, 대량 export 기능에서 중요합니다.

작업 결과의 보존 기간도 정해야 합니다. 생성된 파일을 영구 보관하면 저장 비용과 개인정보 위험이 늘어납니다. 다운로드 가능 기간, 재생성 정책, 삭제 배치를 명확히 둡니다. CPU 작업 분리는 처리 성능뿐 아니라 결과 데이터 수명 관리까지 포함합니다.


7. 부하 테스트는 이벤트 루프 지연을 목표로 본다

Worker 도입 전후를 비교할 때 평균 응답 시간만 보면 개선이 잘 보이지 않을 수 있습니다. 중요한 것은 이벤트 루프 지연 p95, p99, timeout 수, queue 대기 시간입니다. CPU 작업을 worker로 넘겼는데 queue 대기 시간이 너무 길어지면 사용자는 여전히 느리다고 느낍니다.

부하 테스트에서는 일반 API 트래픽과 CPU 작업 트래픽을 섞어야 합니다. 실제 장애는 무거운 작업이 들어올 때 가벼운 health check나 조회 API까지 늦어지는 방식으로 나타납니다. Worker 분리가 잘 되어 있다면 CPU 작업이 몰려도 일반 API의 이벤트 루프 지연은 통제되어야 합니다.

운영 지표에는 active worker count, queued job count, job wait time, job execution time, timeout count, worker restart count를 포함합니다. 이 지표가 있어야 worker pool을 늘릴지, 작업을 더 작게 나눌지, 요청을 제한할지 판단할 수 있습니다.


8. CPU 작업은 배포 단위도 분리할 수 있다

Worker Threads는 같은 애플리케이션 안에서 CPU 작업을 분리하는 방법이지만, 작업이 더 커지면 별도 서비스로 분리하는 편이 나을 수 있습니다. 이미지 변환, 대량 리포트, 동영상 처리처럼 라이브러리 의존성이 무겁고 장애 영향이 큰 기능은 API 서버와 배포 단위를 나누면 운영이 쉬워집니다.

별도 worker 서비스로 분리하면 스케일링 기준도 달라집니다. API 서버는 요청 latency와 connection 수를 기준으로 늘리고, worker 서비스는 queue length와 job duration을 기준으로 늘릴 수 있습니다. 배포도 독립적으로 진행할 수 있어 CPU 작업 코드 변경이 사용자 요청 처리 서버에 직접 영향을 덜 줍니다.

물론 서비스 분리는 복잡도를 늘립니다. queue, 결과 저장소, 재시도 정책, idempotency, observability가 필요합니다. 그래서 처음부터 분리하기보다 이벤트 루프 지연과 CPU 작업량이 실제로 문제인지 측정한 뒤 결정하는 것이 좋습니다.


9. 메모리 사용량은 CPU 작업의 숨은 병목이다

CPU bound 작업은 계산 시간만 문제가 아닙니다. 대용량 CSV를 한 번에 메모리에 올리거나, 이미지 버퍼를 여러 개 복사하거나, 큰 JSON을 worker 사이에서 전달하면 메모리 사용량이 빠르게 증가합니다. 메모리가 부족해지면 GC pause가 길어지고, 결국 이벤트 루프 지연으로 다시 돌아옵니다.

가능하면 스트리밍 처리와 chunk 처리를 사용합니다. CSV는 row 단위로 읽고, 이미지 작업은 필요한 크기의 버퍼만 유지하고, 결과 파일은 메모리 대신 임시 파일이나 object storage로 흘려보냅니다. worker로 데이터를 넘길 때도 복사보다 transfer 가능한 구조를 우선합니다.

운영 지표에는 heap 사용량, RSS, GC pause, worker별 메모리, job당 peak memory를 포함하는 것이 좋습니다. CPU 작업을 분리했는데 메모리 때문에 전체 프로세스가 죽는다면 격리 효과가 반쪽짜리입니다. 무거운 작업은 CPU와 메모리를 함께 예산으로 관리해야 합니다.


10. 작은 최적화보다 경계 설정이 먼저다

CPU 작업을 빠르게 만들기 위해 알고리즘을 개선하는 것도 중요하지만, 운영 안정성 관점에서는 경계 설정이 먼저입니다. 어떤 작업이 요청 안에서 실행될 수 있는지, 몇 ms 이상 걸리면 queue로 넘길지, worker queue가 가득 찼을 때 어떤 응답을 줄지 정해야 합니다. 기준이 없으면 개발자마다 다른 판단을 하고, 무거운 작업이 다시 이벤트 루프로 들어옵니다.

팀 차원의 기준을 문서화하면 리뷰도 쉬워집니다. "대용량 파일 파싱은 API 서버에서 직접 실행하지 않는다" 같은 규칙은 장애를 예방하는 설계 가드가 됩니다.


결론: CPU 작업은 격리와 속도 제한이 필요하다

Node.js 서비스의 안정성은 이벤트 루프를 얼마나 가볍게 유지하느냐에 달려 있습니다. CPU가 오래 걸리는 작업은 worker나 별도 job 시스템으로 분리하고, 처리량보다 많은 요청이 들어올 때는 backpressure를 걸어야 합니다.

성능 최적화는 더 빠른 코드만의 문제가 아닙니다. 무거운 작업이 사용자 요청 전체를 막지 않도록 경계를 만드는 일입니다. Worker Threads와 queue는 그 경계를 만드는 현실적인 도구입니다.