배경
이전 글에서 AWS App Runner의 120초 제한으로 인해 setImmediate를 사용해 비동기 처리를 했다고 언급했었다. 그때는 “임시 방편으로 땜빵했다”고 했는데, 사실 setImmediate가 무엇인지 제대로 모르고 사용했던 게 사실이다.
이번에 제대로 공부해보니 생각보다 흥미로운 함수였다. 물론 여전히 메시지 큐가 정답이긴 하지만…
setImmediate란?
setImmediate는 Node.js에서 제공하는 비동기 함수로, 현재 이벤트 루프 사이클이 완료된 후 콜백을 실행한다.
console.log('1');
setImmediate(() => {
console.log('2');
});
console.log('3');
// 출력:
// 1
// 3
// 2
setTimeout(fn, 0)과의 차이점
처음에는 setTimeout(fn, 0)과 같은 건 줄 알았는데, 실제로는 다르다.
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
// 결과는 환경에 따라 다를 수 있음
// 일반적으로는:
// setImmediate
// setTimeout
왜 다를까?
Node.js 이벤트 루프의 단계를 보면 이해가 된다:
- Timer phase:
setTimeout,setInterval콜백 실행 - Pending callbacks phase: I/O 콜백 실행
- Idle, prepare phase: 내부용
- Poll phase: 새로운 I/O 이벤트 수집
- Check phase:
setImmediate콜백 실행 ← 여기! - Close callbacks phase: close 이벤트 콜백 실행
setImmediate는 Check phase에서 실행되고, setTimeout은 Timer phase에서 실행된다.
I/O 내에서의 실행 순서
I/O 콜백 내에서는 항상 setImmediate가 setTimeout보다 먼저 실행된다:
const fs = require('fs');
fs.readFile('file.txt', () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
// 항상:
// setImmediate
// setTimeout
실제 사용 사례
1. 이전 프로젝트에서의 사용
@Post('/generate')
async generateArticle(@Body() request: GenerateRequest) {
// 먼저 응답을 보내고
const response = { message: 'Generation started', jobId: generateId() };
// 백그라운드에서 처리
setImmediate(async () => {
try {
await this.articleService.generateLongRunningTask(request);
// 완료 후 웹소켓이나 다른 방식으로 알림
} catch (error) {
this.logger.error('Article generation failed', error);
}
});
return response;
}
2. 긴 작업을 작은 단위로 나누기
function processLargeArray(array, callback) {
if (array.length === 0) {
return callback();
}
// 100개씩 처리
const chunk = array.splice(0, 100);
// CPU 집약적인 작업
chunk.forEach(item => processItem(item));
// 이벤트 루프에 제어권 양보
setImmediate(() => {
processLargeArray(array, callback);
});
}
process.nextTick()과의 비교
Node.js에는 process.nextTick()도 있다. 이건 또 다르다:
console.log('1');
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
console.log('2');
// 출력:
// 1
// 2
// nextTick
// setImmediate
process.nextTick()은 현재 단계가 끝나기 전에 실행되고, setImmediate는 다음 단계에서 실행된다.
브라우저에서는?
아쉽게도 setImmediate는 Node.js 전용이다. 브라우저에서는 대안이 필요하다:
// Node.js
if (typeof setImmediate !== 'undefined') {
setImmediate(callback);
} else {
// 브라우저
setTimeout(callback, 0);
}
// 또는 MessageChannel 사용
function setImmediatePolyfill(callback) {
const channel = new MessageChannel();
channel.port2.onmessage = () => callback();
channel.port1.postMessage(null);
}
성능 고려사항
장점
- CPU 집약적 작업에서 이벤트 루프 블로킹 방지
setTimeout(fn, 0)보다 빠름 (타이머 오버헤드 없음)
단점
- 메모리 사용량 증가 가능 (콜백이 쌓일 경우)
- 디버깅이 어려워질 수 있음
현실적인 사용법
Good ✅
// 긴 작업을 작은 단위로 나누기
function heavyTask(data, callback) {
const batch = data.splice(0, 1000);
processBatch(batch);
if (data.length > 0) {
setImmediate(() => heavyTask(data, callback));
} else {
callback();
}
}
Bad ❌
// 재귀적으로 계속 호출 (메모리 누수 위험)
function badRecursion() {
setImmediate(badRecursion);
}
// 메시지 큐 대신 남용
setImmediate(() => {
// 복잡한 비즈니스 로직...
});
결론
setImmediate는 간단한 비동기 작업이나 CPU 집약적 작업을 나누는 데는 유용하지만, 복잡한 백그라운드 작업에는 여전히 메시지 큐가 정답이다.
내가 이전에 사용한 방식도 “120초 제한을 우회하는” 목적으로는 동작했지만, 실제 프로덕션 환경에서는 다음 중 하나를 사용하는 게 좋다:
- Redis + Bull Queue
- AWS SQS
- RabbitMQ
- Kafka (오버킬일 수도…)
하지만 setImmediate를 이해하고 나니, Node.js의 이벤트 루프에 대해서도 더 깊이 알게 되었다. 때로는 “임시방편”도 좋은 학습 기회가 되는 것 같다.
다음에 할 일
- Bull Queue 도입해서 제대로 된 메시지 큐 구현하기
- 이벤트 루프에 대한 더 깊은 이해를 위해 libuv 공부하기