try { emitter.emit("job"); } catch { ... }로 감쌌는데도 async 리스너의 실패가 밖으로 빠질 수 있다. 여기서 확인할 것은 catch의 위치만이 아니다. 이벤트를 발생시킨 함수가 리스너의 Promise까지 기다리는지, 거부된 Promise를 누가 받아 처리하는지 나눠 봐야 한다.
이 글은 Node.js EventEmitter를 쓰는 AI 도구 어댑터를 위한 진단 안내다. MCP 규격 자체의 동작을 설명하는 글은 아니다. Node.js v24.22.1에서 실행한 합성 실험으로 동기 throw, async 리스너, captureRejections, 작업 완료 경로를 비교한다. 확인 기준일은 2026년 11월 6일 KST이며, 실제 서비스 장애나 성능 측정 결과는 포함하지 않는다.
emit이 true여도 작업은 끝나지 않았을 수 있다
Node.js의 emit 문서에서 반환값은 이벤트에 리스너가 있었는지를 나타내는 Boolean이다. 리스너는 등록 순서대로 동기 호출되지만, async 리스너가 반환한 Promise의 완료를 emit이 기다리는 것은 아니다.
첫 await 전에 오류를 던지는 async 함수도 Promise를 반환한다. 따라서 다음 실험의 두 async 사례는 모두 emit=true를 먼저 남긴다. emit을 감싼 catch가 실행됐다는 표시는 없고, 그 뒤 별도 테스트 프로세스가 오류로 종료된다. 같은 위치에서 일반 함수가 동기적으로 throw한 사례는 바깥 catch에 잡힌다. 겉으로 같은 throw여도 호출자에게 이어지는 경로가 다르다.
captureRejections는 오류를 전달할 곳을 바꾼다
captureRejections 공식 설명에 따르면 이 옵션은 리스너가 반환한 Promise의 거부를 비동기적으로 전달한다. Symbol.for("nodejs.rejection") 메서드가 있으면 그쪽으로, 없으면 error 이벤트로 전달한다. 아래 실험은 사용자 정의 rejection 메서드가 없는 기본 경로만 다룬다.
인스턴스를 new EventEmitter({ captureRejections: true })로 만들고 동기 error 리스너를 등록한 사례에서는, emit 직후 오류 개수가 0이고 이후 E_JOB 하나가 관찰됐다. 옵션을 켰다고 emit이 Promise를 반환하거나 호출자의 catch로 오류가 되돌아오는 것은 아니다.
오류 처리기도 검토 대상이다. 공식 문서는 captureRejections가 만든 error 이벤트의 처리기에 다시 async 함수를 쓰지 않도록 권한다. 오류 처리 과정의 거부를 계속 같은 경로로 포착하면 무한 오류 순환이 생길 수 있기 때문이다. 실험의 async 오류 처리기는 두 번째 오류 E_ERROR_HANDLER를 만들어 종료된다.
종료되는 사례를 별도 Node 프로세스에서 검사하기
실패 예제를 현재 서버에 붙여 실행하지 않는다. 아래 검사는 아홉 사례를 각각 새 Node 프로세스에서 실행한다. 테스트 프로세스가 의도대로 종료됐는지는 부모 검사기가 종료 코드, 표준 출력, 오류 표식을 함께 대조한다. Node가 이미 설치된 환경에서 실행하며, 패키지 설치나 외부 API 호출은 없다.
모든 자식 프로세스에는 --unhandled-rejections=strict를 명시했다. 이 모드는 미처리 거부를 잡히지 않은 예외로 올린다. 테스트에는 이를 받아 계속 실행하는 프로세스 전역 처리기가 없다. 운영 환경의 옵션과 훅이 다르면 종료 방식도 다를 수 있으므로, 이 결과를 “모든 Node 환경에서 언제나 같은 방식으로 종료된다”는 주장으로 넓히지 않는다.
NODE_OPTIONS는 자식 프로세스에서만 비워 외부 preload 등의 영향을 줄였다. 기존 셸이나 서비스 설정은 바꾸지 않는다. spawnSync는 호출한 쪽을 기다리게 하므로, 여기서는 짧은 로컬 검사기에만 쓴다. 요청 처리 서버의 구현 예제가 아니다. 코드를 eventemitter-check.cjs로 저장한다.
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const prelude = `
const assert = require('node:assert/strict');
const { EventEmitter, errorMonitor } = require('node:events');
`;
function check(name, code, status, stdout, marker) {
const result = spawnSync(process.execPath,
['--unhandled-rejections=strict', '-e', prelude + code],
{ encoding: 'utf8', timeout: 3000,
env: { ...process.env, NODE_OPTIONS: '' } });
assert.ifError(result.error);
assert.equal(result.signal, null, name);
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout.trim(), stdout, name);
if (marker) assert.match(result.stderr, new RegExp('Error: ' + marker));
else assert.equal(result.stderr, '', name);
console.log(`${name}: exit=${result.status}, ${result.stdout.trim()}`);
}
console.log(`Node ${process.version}`);
check('sync-throw', `
const ee = new EventEmitter();
ee.on('job', () => { throw new Error('E_SYNC'); });
try { ee.emit('job'); }
catch (error) { console.log('outer=' + error.message); }
`, 0, 'outer=E_SYNC');
for (const afterAwait of [false, true]) {
const name = afterAwait ? 'async-after-await' : 'async-before-await';
check(name, `
const ee = new EventEmitter();
ee.on('job', async () => {
${afterAwait ? 'await Promise.resolve();' : ''}
throw new Error('E_ASYNC');
});
try { console.log('emit=' + ee.emit('job')); }
catch { console.log('OUTER_CAUGHT'); }
`, 1, 'emit=true', 'E_ASYNC');
}
check('captured-rejection', `
const ee = new EventEmitter({ captureRejections: true });
const errors = [];
ee.on('error', error => { errors.push(error.message); });
ee.on('job', async () => { throw new Error('E_JOB'); });
console.log('emit=' + ee.emit('job') + ', immediate=' + errors.length);
setImmediate(() => {
assert.deepEqual(errors, ['E_JOB']);
console.log('error=' + errors[0]);
});
`, 0, 'emit=true, immediate=0\nerror=E_JOB');
check('missing-error-listener', `
const ee = new EventEmitter({ captureRejections: true });
ee.on('job', async () => { throw new Error('E_NO_HANDLER'); });
console.log('emit=' + ee.emit('job'));
`, 1, 'emit=true', 'E_NO_HANDLER');
check('async-error-listener', `
const ee = new EventEmitter({ captureRejections: true });
ee.on('error', async () => { throw new Error('E_ERROR_HANDLER'); });
ee.on('job', async () => { throw new Error('E_JOB'); });
console.log('emit=' + ee.emit('job'));
`, 1, 'emit=true', 'E_ERROR_HANDLER');
check('detached-promise', `
const ee = new EventEmitter({ captureRejections: true });
let errors = 0;
ee.on('error', () => { errors += 1; });
ee.on('job', async () => {
void Promise.reject(new Error('E_DETACHED'));
});
console.log('emit=' + ee.emit('job') + ', immediate=' + errors);
`, 1, 'emit=true, immediate=0', 'E_DETACHED');
check('monitor-only', `
const ee = new EventEmitter();
ee.on(errorMonitor, error => { console.log('monitor=' + error.message); });
ee.emit('error', new Error('E_MONITOR'));
`, 1, 'monitor=E_MONITOR', 'E_MONITOR');
check('await-emit', `
(async () => {
const ee = new EventEmitter({ captureRejections: true });
let release;
const gate = new Promise(resolve => { release = resolve; });
let finished = false;
let resolveDone;
const done = new Promise(resolve => { resolveDone = resolve; });
ee.on('error', error => { throw error; });
ee.on('job', async () => {
await gate;
finished = true;
resolveDone();
});
const emitted = await ee.emit('job');
assert.equal(emitted, true);
assert.equal(finished, false);
console.log('await-emit: finished=' + finished);
release();
await done;
assert.equal(finished, true);
console.log('await-done: finished=' + finished);
})().catch(error => { console.error(error); process.exitCode = 1; });
`, 0, 'await-emit: finished=false\nawait-done: finished=true');
console.log('PASS: 9 isolated scenarios');
node eventemitter-check.cjs
Node.js v24.22.1에서 확인한 출력이다. exit=1은 해당 자식 프로세스에서 의도한 오류 종료를 확인했다는 뜻이다. 마지막 PASS는 아홉 사례의 모든 비교가 맞았다는 뜻이며, 오류가 사라졌다는 뜻은 아니다.
Node v24.22.1
sync-throw: exit=0, outer=E_SYNC
async-before-await: exit=1, emit=true
async-after-await: exit=1, emit=true
captured-rejection: exit=0, emit=true, immediate=0
error=E_JOB
missing-error-listener: exit=1, emit=true
async-error-listener: exit=1, emit=true
detached-promise: exit=1, emit=true, immediate=0
monitor-only: exit=1, monitor=E_MONITOR
await-emit: exit=0, await-emit: finished=false
await-done: finished=true
PASS: 9 isolated scenarios
출력에서 확인할 세 가지 실패 경계
error 리스너 누락. missing-error-listener는 captureRejections를 켰지만 오류를 처리할 리스너가 없다. 이 실험에서는 비동기 전달된 error가 처리되지 않아 종료됐다. 일반적인 emit("error", error) 자체는 리스너가 없으면 throw하므로, 동기 호출을 감싼 try가 있다면 잡을 수 있다. “error 리스너가 없으면 어떤 코드에서도 무조건 즉시 종료”라고 이해하면 진단을 그르친다. 공식 error 이벤트 설명을 실제 호출 위치와 함께 읽어야 한다.
반환되지 않은 Promise. detached-promise에서는 async 리스너가 안쪽 Promise를 만들고 버린다. 리스너 자신의 반환 Promise가 그 실패와 연결되지 않으므로 captureRejections로 보호되지 않는다. 실험에서는 E_DETACHED 때문에 종료됐다. 실제 코드에서는 먼저 return이나 await가 빠진 지점을 찾는다. 작업을 의도적으로 분리한다면 그 작업의 실패 처리와 종료 추적을 별도로 설계해야 한다.
관찰만 하는 리스너. monitor-only는 E_MONITOR를 기록하고도 종료된다. errorMonitor는 error를 관찰할 뿐 소비하지 않는다. 모니터링 로그가 찍혔다는 사실을 오류가 처리됐다는 증거로 삼으면 안 된다.
await emit 대신 기다릴 결과를 명시한다
마지막 사례는 gate를 열어야만 리스너가 작업을 마칠 수 있게 만들었다. await ee.emit("job") 다음의 finished는 false다. await가 기다린 대상은 emit이 반환한 true였기 때문이다. gate를 열고 별도 done Promise를 기다린 뒤에야 true가 된다. 임의의 시간을 재서 늦는지 판단한 것이 아니라, 완료될 수 없는 조건을 먼저 만들어 확인한 실험이다.
이 done은 성공 완료 시점을 보여주는 테스트 장치다. 운영에 그대로 복사할 완성된 작업 큐는 아니다. 실패 시 reject, 취소와 시간 제한, 요청별 연결, 종료 뒤 정리까지 구현하지 않았으며, 테스트의 동기 error 리스너는 예상 밖 실패를 즉시 드러내려고 throw한다.
호출자가 한 작업의 성공이나 실패를 받아야 한다면, 실제 작업 함수를 직접 호출하고 그 Promise를 반환하거나 await하는 구조가 가장 먼저 검토할 대안이다. 예를 들어 저장 작업의 Promise가 확인되기 전에 emit의 true만 보고 도구 호출을 성공으로 끝내면, 오류가 나중에 기록돼도 이미 전달한 성공 결과는 바로잡히지 않는다.
이벤트 구조를 유지해야 한다면 역할을 분리한다. 이벤트는 작업 접수나 상태 변경을 알리고, 요청별 완료 객체는 최종 성공·실패를 전달하게 한다. 여러 작업이 동시에 실행되는 시스템에서는 어느 요청의 실패인지 연결할 식별자도 필요하다. captureRejections는 이 연결이나 성공 판정을 대신 만들어주지 않는다. 전역 옵션을 일괄 변경하기보다는 먼저 문제의 인스턴스와 등록된 리스너를 확인한다.
실제 어댑터에 적용할 때의 확인 순서
1. 반환 경로를 찾는다. on(..., async ...)의 반환 Promise가 어느 코드에 연결되는지 읽는다. 내부의 별도 Promise와 콜백에서 시작한 작업도 구분한다.
2. 오류 도착지를 확인한다. captureRejections 설정, 사용자 정의 rejection 메서드, error 리스너를 같은 인스턴스에서 대조한다. 로그 라이브러리가 errorMonitor만 등록했는지도 확인한다.
3. 완료 판정을 따로 검사한다. 처리 전에는 결과가 pending인지, 성공 후에는 결과와 요청 ID가 맞는지, 실패 후에는 성공 응답이 먼저 나가지 않았는지 확인한다. 이 글의 실험은 pending과 성공 완료의 분리까지이며, 실제 도구의 최종 응답 계약은 별도 검사해야 한다.
4. 오류 처리기의 실패도 시험한다. 오류 보고 중 네트워크 전송이나 파일 기록이 실패하는 경로를 빠뜨리지 않는다. 동기 error 리스너 안에서 비동기 보고를 시작한다면 그 Promise에도 명시적인 거부 처리가 필요하다. 보고 실패를 같은 error 이벤트로 무한 재전달하거나 전역 처리기로 숨기는 방식은 피한다.
이 글이 확인한 범위는 Node.js v24.22.1의 기본 EventEmitter와 내장 Promise다. 브라우저 EventTarget, 다른 라이브러리의 이벤트 버스, 병렬 작업의 취소·재시도·복구는 검증하지 않았다. 현장에서 필요한 최종 증거는 오류 로그 하나가 아니라, 실패한 작업이 정확한 요청의 최종 결과로 연결됐는지다.