Claude의 도구 호출(tool-calling) 루프는 Node.js에서 복잡하게 얽힌 Promise 코드를 생성한다는 평이 있습니다.
Node.js 22의 새로운 Promise.withResolvers()를 사용하면 개발자는 번거로운 new Promise 패턴을 Promise와 resolve/reject 함수를 한 번에 제공하는 단 한 줄의 코드로 대체할 수 있습니다. 그 결과, resolve 호출을 누락하는 일이 줄어들고, 중복 reject 경고가 발생하지 않으며, 제어 흐름이 평탄해져 서버리스 환경에서 테스트와 상태 유지가 더 쉬워집니다.
기존 패턴이 흐름을 끊는 이유
Claude와 같은 LLM이 도구를 요청할 때, 일반적인 Node 구현은 다음과 같습니다.
return new Promise((resolve, reject) => {
// launch the tool, attach callbacks, maybe fire another async call
});
세 가지 반복적인 함정이 발생합니다.
- resolve 누락 – 코드 경로에서
resolve를 호출하지 않으면, Lambda 또는 기타 서버리스 핸들러가 타임아웃이 발생할 때까지 대기 상태로 머물러 비용이 상승합니다. - 중복 reject –
reject를 두 번 호출하는 에러 경로는 strict mode에서 프로세스를 중단시킬 수 있는 “unhandled rejection” 경고를 발생시킵니다. - 깊은 중첩 – 각 비동기 단계가 생성자 내부에 또 다른 콜백을 중첩시켜 로직을 분산시키고 단위 테스트를 까다롭게 만듭니다.
이러한 모든 문제는 Promise의 제어 함수가 생성자의 클로저(closure) 안에 갇혀 있어, 나머지 코드가 이를 다시 참조해야만 한다는 사실에서 비롯됩니다.
한 줄로 끝내는 Promise.withResolvers()
Node 22는 Promise와 이를 완료(settle)시키는 두 함수를 포함하는 객체를 반환하는 정적 헬퍼를 추가했습니다.
const { promise, resolve, reject } = Promise.withResolvers();
이제 Promise를 HTTP 핸들러, 데이터베이스 리스너 또는 백그라운드 워커와 같은 시스템의 어느 부분으로든 전달할 수 있으며, 원래 호출자는 단순히 Promise를 await하기만 하면 됩니다. 도구 실행 블록 전체를 new Promise 생성자로 감쌀 필요가 없습니다.
Claude의 도구 루프에 적용하기
Claude의 워크플로우는 다음과 같습니다.
- LLM이 도구 요청을 생성합니다.
- 코드가 도구를 실행합니다(예: API 호출, 파일 읽기).
- 도구 결과가 다음 턴을 위해 Claude에게 다시 전송됩니다.
withResolvers를 사용하면 루프가 다음과 같이 단순화됩니다.
async function runTool(request) {
const { promise, resolve, reject } = Promise.withResolvers();
// Kick off the tool; it can call resolve/reject from anywhere
executeTool(request, { resolve, reject });
// Optional timeout wrapper
const timeout = setTimeout(() => reject(new Error('Tool timed out')), 10_000);
try {
const result = await promise;
clearTimeout(timeout);
return result; // feed back to Claude
} finally {
// clean-up if needed
}
}
도구 구현을 더 이상 새로운 Promise로 감쌀 필요가 없으며, 단순히 resolve와 reject를 받기만 하면 됩니다. 이를 통해 위에서 언급한 세 가지 실패 모드를 제거할 수 있습니다.
실전에서의 주요 고려 사항
Promise 구조가 깔끔해지더라도, 실제 환경의 에이전트는 다른 제약 사항에 직면합니다.
- 타임아웃 – 위의 스니펫은 도구가 임계값을 초과하면 reject하는 간단한 타이머를 보여줍니다. SLA 기대치에 따라 지속 시간을 조정하십시오.
- 스로틀링(Throttling) – 기반 서비스가 스로틀링 에러(예: Bedrock의
ThrottlingException)를 반환하면, 이를 캐치하여 일시 중지한 후 지수 백오프(exponential back-off)를 적용하여 재시도하십시오. resolve/reject 쌍은 동일하며 재시도 로직만 변경됩니다. - Lambda 비용 – AWS Lambda에서는
callbackWaitsForEmptyEventLoop = false로 설정하십시오. 이는 스트림이나 기타 백그라운드 핸들이 여전히 열려 있더라도 핸들러가 반환되는 즉시 런타임이 함수를 종료하도록 합니다. 이를 통해 Promise가 다른 곳에서 완료되는 동안 함수가 계속 남아 있는 것을 방지할 수 있습니다.
새로운 헬퍼가 만능 해결책은 아닌 경우
Promise.withResolvers()는 Node 22 이상에서만 사용할 수 있습니다. 이전 LTS 버전에 고정된 프로젝트는 이 패턴을 폴리필(polyfill)하거나 기존의 생성자를 계속 사용해야 합니다. 폴리필은 API를 모방할 수는 있지만 네이티브 성능 이점은 얻을 수 없습니다. 또한, 이 헬퍼가 논리적 버그를 마법처럼 해결해 주지는 않습니다. 개발자는 모든 요청에 대해 resolve 또는 reject 중 정확히 하나만 호출되도록 보장해야 하며, 그렇지 않으면 Promise는 무기한 대기(pending) 상태로 남게 됩니다.
향후 주목할 점
- 프레임워크 채택 – LLM 에이전트 루프를 추상화하는 라이브러리(예: 오픈 소스 Claude 래퍼)들이
withResolvers를 선택적 기능(opt-in feature)으로 노출하기 시작했습니다. 이 패턴이 기본값으로 설정되는 업데이트를 주시하십시오. - Node 생태계 – 더 많은 서비스가 Node 22로 전환됨에 따라, 이 헬퍼는 LLM 에이전트뿐만 아니라 모든 “fire-and-wait” 비동기 패턴의 사실상 표준(de-facto standard)이 될 것입니다.
- 도구 호출 표준 – 새롭게 등장하는 LLM 도구 호출 사양은 “단일 Promise” 계약을 규정할 수 있으며, 이는
withResolvers방식과 완벽하게 일치합니다.
요약: 런타임이 Node 22를 지원한다면, 장황한 new Promise 래퍼를 한 줄짜리 Promise.withResolvers()로 교체함으로써 Claude 기반 에이전트는 더 명확한 흐름, 적은 런타임 오류, 그리고 서버리스 비용에 대한 더 정밀한 제어력을 얻을 수 있습니다.
