이벤트를 기다리는 코드에서는 await의 줄 순서뿐 아니라 리스너가 붙는 시점도 확인해야 한다. ready를 받은 뒤 done을 기다리는 두 줄은 읽기 쉽다. 하지만 생산자가 두 이벤트를 연달아 내보내면, 두 번째 대기는 이미 지나간 이벤트를 기다릴 수 있다.
이 글은 Node.js의 EventEmitter와 node:events의 Promise 반환 함수 once()를 다룬다. 콜백을 받는 emitter.once()와 구별해야 한다. 아래 결과는 외부 서비스와 연결하지 않은 합성 실험이며, 실제 서비스 장애를 재구성한 사례가 아니다.
두 번째 once는 첫 번째 await가 끝나야 실행된다
await once(bus, 'ready');
await once(bus, 'done');
첫 줄은 ready 리스너를 등록하고 실행을 양보한다. 그동안 같은 process.nextTick() 콜백에서 ready와 done을 연이어 발생시키면, done이 나올 때는 두 번째 once()가 아직 호출되지 않았다. EventEmitter.emit()은 리스너를 동기적으로 호출하지만, await 다음 코드는 현재 콜백이 끝난 뒤에 실행된다. 첫 번째 Promise가 이행됐다는 사실과 await 뒤의 코드가 이미 실행됐다는 사실은 다르다.
Node의 v24.22.1 공식 문서의 다중 이벤트 대기 절도 이 누락 가능성을 설명하고, 어느 하나를 기다리기 전에 필요한 Promise를 모두 만들도록 안내한다. 문서 확인일과 코드 실행일은 2026년 11월 7일 KST다. 문서와 실제 실행 환경의 버전은 모두 Node v24.22.1이다.
먼저 등록하고, 결과는 나중에 기다린다
const ready = once(bus, 'ready');
const done = once(bus, 'done');
const result = Promise.all([ready, done]);
// 생산자는 두 등록과 result 생성 뒤에 시작한다.
const [[readyValue], [doneValue]] = await result;
수정할 부분은 소비자의 리스너 등록 시점이다. Promise.all()이라는 이름만 추가하는 것으로는 부족하다. 생산자가 시작되기 전에 두 once() 호출이 실제로 끝나 있어야 한다. 위 조각의 실패 처리까지 포함한 실행본은 아래에 있다.
수정 실험에서는 일부러 done → ready 순으로 이벤트를 발생시킨다. 그래도 결과는 입력 Promise 배열에 맞춰 R,D로 나온다. 이것은 두 이벤트를 받았다는 확인이지, ready → done 순서를 지켰다는 검증이 아니다. 업무상 순서가 필수라면 별도의 상태 검사도 필요하다.
복사해서 실행하는 재현 코드
아래 전체 코드를 test.mjs로 저장하고 node test.mjs로 실행한다. 패키지 설치, 네트워크 요청, 유료 API 호출은 없다. 정상 출력의 첫 줄은 사용 중인 Node 버전에 따라 달라질 수 있다.
실패 실험은 시간을 재며 멈춤을 추측하지 않는다. 이벤트를 발생시키는 순간의 리스너 수와 setImmediate()로 실행을 넘긴 뒤의 완료 상태를 검사한다. 검사가 끝나면 AbortController로 남은 대기를 취소하므로 예제가 끝없이 대기하지 않는다.
import assert from 'node:assert/strict';
import { EventEmitter, once } from 'node:events';
import { setImmediate as nextTurn } from 'node:timers/promises';
async function sequential() {
const bus = new EventEmitter();
const controller = new AbortController();
let completed = false;
let secondListenersAtEmit = -1;
const task = (async () => {
await once(bus, 'ready', { signal: controller.signal });
await once(bus, 'done', { signal: controller.signal });
completed = true;
})();
// Attach a rejection handler before the later abort.
const settled = task.then(() => 'fulfilled', error => error.name);
process.nextTick(() => {
bus.emit('ready', 'R');
secondListenersAtEmit = bus.listenerCount('done');
bus.emit('done', 'D');
});
await nextTurn();
assert.equal(secondListenersAtEmit, 0);
assert.equal(completed, false);
assert.equal(bus.listenerCount('done'), 1);
console.log(`before: listeners at done=${secondListenersAtEmit}, completed=${completed}`);
controller.abort();
assert.equal(await settled, 'AbortError');
assert.equal(bus.eventNames().length, 0);
console.log('before cleanup: AbortError, listeners=0');
}
async function preregister(mode) {
const bus = new EventEmitter();
const controller = new AbortController();
const ready = once(bus, 'ready', { signal: controller.signal });
const done = once(bus, 'done', { signal: controller.signal });
const waits = [ready, done];
// Register both waits AND their rejection handlers before starting the producer.
const result = Promise.all(waits);
process.nextTick(() => {
if (mode === 'error') {
bus.emit('error', new Error('producer failed'));
} else if (mode === 'cancel') {
controller.abort();
} else {
// Reverse the emission order on purpose.
bus.emit('done', 'D');
bus.emit('ready', 'R');
}
});
try {
const [[r], [d]] = await result;
assert.equal(mode, 'success');
assert.equal(`${r},${d}`, 'R,D');
console.log('after: emitted=done,ready; results=R,D');
} catch (error) {
if (mode === 'success') throw error;
assert.equal(error.message, mode === 'error' ? 'producer failed' : 'The operation was aborted');
assert.equal(error.name, mode === 'cancel' ? 'AbortError' : 'Error');
console.log(`${mode}: ${error.name}`);
} finally {
controller.abort();
await Promise.allSettled(waits);
assert.equal(bus.eventNames().length, 0);
}
}
async function errorAsData() {
const bus = new EventEmitter();
const result = once(bus, 'error');
bus.emit('error', new Error('data'));
const [error] = await result;
assert.equal(error.message, 'data');
assert.equal(bus.eventNames().length, 0);
console.log('waiting for error: fulfilled=data');
}
console.log(`Node ${process.version}`);
await sequential();
await preregister('success');
await preregister('error');
await preregister('cancel');
await errorAsData();
console.log('PASS');
예상 출력과 실제 실행 결과
Node v24.22.1
before: listeners at done=0, completed=false
before cleanup: AbortError, listeners=0
after: emitted=done,ready; results=R,D
error: Error
cancel: AbortError
waiting for error: fulfilled=data
PASS
before에서는 done이 발생할 때 리스너가 0개였다. 코드의 다음 검사는 done 리스너가 뒤늦게 1개 생겼음도 확인한다. 이미 발생한 이벤트는 이 새 대기에 다시 전달되지 않는다. 이 실험처럼 추가 이벤트를 내보내지 않으면 정상 완료할 경로가 없다. 다만 해결되지 않은 Promise 하나만으로 Node 프로세스가 계속 살아 있는 것은 아니므로, 여기서 말하는 문제는 대기의 미완료다.
after에서는 발생 순서를 뒤집어도 두 값을 받았다. 이어지는 검사는 오류 발생과 명시적 취소를 구분하고, 각 종료 경로에서 이 실험이 만든 리스너가 남지 않았는지 확인한다. PASS는 이 합성 조건의 단언이 모두 통과했다는 뜻이다.
오류와 취소는 별도로 닫아야 한다
다른 이벤트를 기다리는 once()는 그동안 발생한 error로 거부될 수 있다. 반면 once(bus, 'error')는 오류 객체를 값으로 받아 이행된다. 취소 신호를 전달한 대기는 취소 시 AbortError로 거부된다. 이 차이는 공식 once API 설명과 위 실험의 error, cancel, waiting for error 출력을 함께 보면 확인하기 쉽다.
대기를 만드는 코드에는 종료 경로도 함께 두어야 한다. 예제는 생산자 시작 전에 두 Promise를 Promise.all()에 연결하고, finally에서 취소한 뒤 남은 결과를 수거한다. Promise.all() 자체가 다른 대기나 생산자를 취소해 주지는 않는다. 이 예제의 abort도 이벤트 대기만 끝낸다. 실제 소켓이나 작업을 멈추는 절차는 해당 생산자의 수명주기에 맞춰 따로 구현해야 한다.
이 예제에는 운영용 제한 시간이 없다. 어느 이벤트가 영원히 오지 않는다면 미리 등록했어도 성공하지 못한다. 실제 코드에는 종료 조건, 외부 취소 또는 요구사항에 맞는 제한 시간을 둬야 한다. 또한 일회성 대기가 끝나면 오류용 리스너도 제거될 수 있으므로, 이후에도 동작하는 생산자의 error 처리 책임을 이 두 대기에만 맡겨서는 안 된다.
이번 점검의 범위
이미 지나간 이벤트를 되살리거나 반복 이벤트를 모두 모으는 기능까지 이 패턴이 제공하지는 않는다. 같은 이벤트 이름에 once()를 두 번 미리 등록하면 첫 발생에 둘 다 반응할 수 있다. 서로 다른 두 이벤트를 한 번씩 받는 이 예제를 연속 데이터 수집기로 확장해서 읽으면 안 된다.
앞서 다룬 리스너 수명주기와 MaxListeners 경고가 작업 종료 후 무엇을 해제할지에 관한 글이라면, 이번 점검에서는 생산자 시작 전에 무엇을 등록해야 하는지 살핀다. 두 문제는 코드의 서로 다른 시점에서 생긴다.
이 글에서 입증한 범위는 Node.js의 로컬 EventEmitter 실험이다. 이를 MCP 전송 규격이나 특정 SDK가 이벤트를 잃는다는 증거로 쓰지는 않는다. 이와 같은 누락을 점검할 때는 필요한 리스너를 모두 등록하기 전에 생산자가 실행될 수 있는지 확인한다.