MCP 도구가 빈 배열을 반환한 뒤에 처리 로그가 찍힌다면, 핸들러가 내부 비동기 작업을 끝까지 기다리는지 먼저 확인한다. JavaScript의 forEach(async ...)는 콜백이 돌려준 Promise들을 모아 기다리지 않는다. 앞에 await를 붙여도 모든 항목의 완료를 기다리는 코드가 되지 않는다.
이 진단은 JavaScript/Node.js로 MCP 도구를 구현하는 개발자에게 해당한다. 모든 빈 결과가 비동기 반복문의 잘못은 아니다. 조회 조건에 맞는 데이터가 정말 없는 경우, 응답 직렬화에서 값이 바뀐 경우, UI가 결과를 숨긴 경우도 분리해야 한다.
먼저 확인할 세 지점
① 항목 처리 시작, ② 항목 처리 완료, ③ 핸들러 반환 직전을 같은 임시 요청 식별자로 연결해 본다. 고객 원문이나 인증값 대신 입력 건수·완료 건수·오류 건수만 남긴다. 반환 직전 완료 건수가 예상보다 작고, 반환 이후에 완료 로그가 이어진다면 기다리지 않은 Promise를 의심할 근거가 된다. 로그 수집기의 지연도 있을 수 있으므로 로그 표시 순서만으로 확정하지 않는다.
이 글의 권장 완료 조건은 요청에 포함된 각 항목의 결과가 결정된 뒤 응답을 구성하는 것이다. 접수만 하는 별도 작업 큐를 의도했다면 접수 상태와 조회 방법을 계약으로 명시해야 한다. 접수 성공을 실제 처리 완료로 표현하면 안 된다.
forEach 앞의 await가 해결하지 못하는 이유
ECMAScript의 Array.prototype.forEach 알고리즘은 콜백을 호출한 뒤 반환값을 결과 배열에 모으지 않고 마지막에 undefined를 반환한다. async 콜백의 Promise를 기다리는 단계도 없다. 따라서 바깥 await가 기다리는 것은 전체 작업을 대표하는 Promise가 아니다. forEach 공식 알고리즘
콜백 내부의 await는 그 콜백 안에서만 순서를 만든다. 모든 항목의 순서를 만들지는 않는다. 콜백이 기다리던 I/O가 끝나기 전에 핸들러가 결과를 직렬화하면 비어 있는 배열이 전송될 수 있다. 나중에 같은 배열 객체가 채워져도 이미 만들어 보낸 JSON 문자열은 바뀌지 않는다.
복사해서 확인하는 작은 재현
아래 코드는 외부 연결 없이 Promise의 완료 시점을 직접 제어한다. 타이머를 길게 잡아 우연히 재현하는 대신, gate를 풀기 전과 후를 비교한다. 마지막 검사는 Promise.all이 실패해도 이미 시작한 다른 작업을 자동 취소하지 않는다는 별도의 함정을 확인한다. probe.mjs로 저장하고 node probe.mjs로 실행할 수 있다.
import assert from 'node:assert/strict';
const ids = [1, 2];
let release;
const gate = new Promise(resolve => { release = resolve; });
const late = [];
await ids.forEach(async id => {
await gate;
late.push(id * 10);
});
const sent = JSON.stringify(late);
assert.equal(sent, '[]');
release();
await gate;
assert.deepEqual(late, [10, 20]);
assert.equal(sent, '[]');
const read = async id => id * 10;
const sequential = [];
for (const id of ids) sequential.push(await read(id));
assert.deepEqual(sequential, [10, 20]);
const parallel = await Promise.all(ids.map(read));
assert.deepEqual(parallel, [10, 20]);
let releaseSlow;
let completed = false;
const slow = new Promise(resolve => { releaseSlow = resolve; })
.then(() => { completed = true; return 20; });
await assert.rejects(
Promise.all([Promise.reject(new Error('synthetic')), slow]),
/synthetic/
);
assert.equal(completed, false);
releaseSlow();
await slow;
assert.equal(completed, true);
console.log(JSON.stringify({sent, late, sequential, parallel,
completedAfterAggregateRejection: completed, assertions: 8}));
Node.js v24.19.0에서 실행한 출력이다. 여기서 sent는 전송을 흉내 낸 문자열이며 실제 MCP 통신을 수행한 것은 아니다.
{"sent":"[]","late":[10,20],"sequential":[10,20],"parallel":[10,20],"completedAfterAggregateRejection":true,"assertions":8}
8개 assert가 통과했다. gate를 풀기 전 sent는 빈 배열 문자열이었고, 이후 메모리의 late 배열은 두 값을 갖게 됐다. 순차 처리와 Promise.all 방식은 두 값이 모인 뒤 결과를 얻었다. 또 묶음 Promise가 거부된 뒤에도 slow 작업은 따로 풀어 주자 완료됐다. 고객 장애나 특정 호스트의 버그를 재현한 시험은 아니다. 실제 MCP SDK·호스트 통합과 모바일 화면은 시험하지 않았다.
순차와 병렬 중 무엇으로 고칠까
순서·의존성이 있으면 for...of 안에서 await한다. 이전 항목의 결과가 다음 항목의 입력이거나, 같은 대상을 갱신하거나, 호출 한도를 보수적으로 지켜야 한다면 순차 처리부터 검토한다. 첫 실패에서 반복을 멈추더라도 이전 항목의 변경이 되돌려진다는 뜻은 아니다.
독립적인 소량 읽기는 await Promise.all(items.map(...))을 검토한다. 성공 결과 배열은 입력 순서에 대응하지만 작업 완료 순서가 입력 순서와 같다는 뜻은 아니다. map 콜백은 반드시 작업의 Promise를 반환해야 한다. 중괄호 안에서 호출만 하고 return을 빠뜨리면 다시 기다릴 대상을 잃는다.
const values = await Promise.all(ids.map(id => {
return read(id);
}));
실제 대량 데이터에 이 패턴을 그대로 적용하면 모든 요청을 한꺼번에 시작할 수 있다. 동시 실행 수를 제한하는 큐·작업 풀과 서비스의 요청 한도를 별도로 설계해야 한다. 이 글의 두 항목 예제는 처리량이나 속도 우위를 측정하지 않는다.
Promise.all 실패는 전체 취소가 아니다
Promise.all은 입력 Promise 중 하나가 거부되면 그 이유로 거부될 수 있다. 나머지 작업이 중단되거나, 이미 일어난 저장·발행·전송이 롤백된다는 보장은 없다. 그러므로 묶음 실패를 보고 전체 입력을 바로 재전송하면 중복 처리가 생길 수 있다. Promise.all 및 PerformPromiseAll 공식 알고리즘
부분 결과가 필요한 업무라면 Promise.allSettled처럼 항목별 종료 상태를 모으는 방식을 검토할 수 있다. 단, allSettled도 취소나 롤백을 제공하지 않으며 하나가 영원히 끝나지 않으면 전체도 끝나지 않는다. 시간초과·취소 신호·결과 조회·중복 방지 정책은 실제 API와 업무에 맞게 따로 정해야 한다. 이 추가 설계는 권고이며 이 글에서 운영 환경으로 시험한 항목은 아니다.
핸들러의 try/catch가 분리된 async 콜백의 실패까지 자동으로 잡는다고 가정하지 않는다. 반환하거나 await하지 않은 Promise의 거부는 별도 경로로 흐를 수 있다. 실패를 빈 배열로 바꿔 정상 결과처럼 내보내는 수정도 피한다.
MCP에 붙일 때 남는 확인
MCP revision 2026-07-28의 Tools 문서는 도구 실행 오류를 isError: true로 나타내는 구조와 결과 계약을 설명한다. 일반 성공 결과와 실행 오류를 구별해야 한다는 근거다. 다만 async 반복문을 어떻게 작성할지는 JavaScript 및 서버 구현의 책임이다. MCP Tools 공식 명세
실제 적용에서는 먼저 읽기 전용 합성 입력으로 핸들러의 반환값을 확인하고, 사용 중인 SDK에서 예외가 도구 오류로 어떻게 전달되는지 검사한다. 입력 건수와 결과 건수, 항목별 실패 상태, 출력 스키마, 클라이언트에 표시된 최종 상태까지 대조해야 한다. SDK·호스트별 오류 포장 방식과 작업 취소 동작은 이 예제로 보증하지 않는다.
확인 날짜 / Node.js / MCP revision / SDK 버전:
입력 건수 / 완료 건수 / 실패 건수:
핸들러 반환 시 남은 작업:
각 작업의 Promise를 반환하거나 await했는가:
순서 의존성 / 동시 실행 상한:
실패 후 다른 작업의 실제 상태:
재시도 전 결과 조회 / 중복 방지 기준:
확인 범위와 관련 글
확인일은 2026년 10월 19일 KST이며 실행 환경은 Node.js v24.19.0이다. 외부 API·DB·MCP SDK를 설치하거나 호출하지 않고 위 합성 코드의 8개 검사를 실행했다. 공식 ECMAScript 링크는 열람 당시 표제가 ECMAScript 2027인 살아 있는 초안이다. 이 글은 열람한 알고리즘과 명시한 런타임 결과를 구분하며, 최신 확정판 전체나 특정 제품의 호환성을 주장하지 않는다.
반환 시점에는 값이 있었는데 전송 후 사라졌다면 JSON 직렬화 전후 진단으로 이어 간다. 응답은 왔지만 클라이언트가 계속 기다린다면 요청 ID와 응답 연결 진단을 확인한다. 실패·취소 뒤 재실행 여부는 실제 작업 결과를 먼저 조회하는 순서로 판단한다.