콜백 메서드를 promisify(client.lookup)으로 바꾼 뒤, 같은 입력에서 갑자기 TypeError가 날 수 있다. 설정이 실제로 지워진 것인지 확인하기 전에 메서드를 어떻게 호출했는지 본다. client.lookup(...)과 객체에서 꺼낸 lookup(...)은 메서드 안의 this가 같다는 보장이 없다.
이 글은 콜백 기반 라이브러리를 Promise로 연결하는 Node.js AI 도구 어댑터를 위한 진단 안내다. MCP 규격의 오류를 설명하는 글은 아니다. Node.js v24.22.1에서 실행한 합성 코드로 호출 객체, 오류 전달, 변환 순서를 검사했다. 확인 기준일은 2026년 11월 6일 KST다. 실제 서비스 장애, 네트워크 호출, 성능 측정 결과는 포함하지 않는다.
함수만 꺼내면 원래 객체가 함께 전달되지는 않는다
Node.js의 util.promisify 문서는 마지막 인자로 오류 우선 콜백 (err, value)를 받는 함수를 변환 대상으로 설명한다. 같은 문서는 this를 사용하는 메서드에 별도 처리가 필요하며, 변환된 함수를 call이나 bind로 객체에 연결하는 예를 제시한다.
아래 실험의 LookupClient는 실제 SDK가 아니라 메모리 안에서 문자열을 만드는 모형이다. lookup은 먼저 this.namespace를 읽고, 이후 마이크로태스크에서 콜백을 호출한다. 여기서 namespace는 조회 대상의 가상 이름이며 주소나 인증 정보가 아니다.
const detached = promisify(alpha.lookup)만 만든 뒤 detached("item-7")을 호출하면 이 모형에서는 TypeError로 Promise가 거부된다. 반면 detached.call(alpha, "item-7")은 alpha/item-7, detached.call(beta, "item-7")은 beta/item-7을 반환한다. 함수와 입력을 유지한 채 호출 객체만 바꾼 비교다.
실제 라이브러리에서는 객체가 잘못됐다고 항상 예외가 생기지는 않는다. 같은 이름의 속성을 가진 다른 객체가 전달되면 다른 값으로 성공할 수도 있다. 따라서 검사는 “오류가 사라졌다”에서 끝내지 않고 반환값이 의도한 인스턴스에서 나왔는지까지 확인한다.
8개 검사로 호출 객체와 결과를 함께 확인하기
Node.js가 이미 설치된 환경에서 다음 코드를 promisify-receiver-check.mjs로 저장하고 node promisify-receiver-check.mjs로 실행한다. 외부 패키지 설치, 파일 읽기·쓰기, 외부 API 호출은 없다. 테스트 코드 안의 객체만 바꾼다.
import assert from 'node:assert/strict';
import { promisify } from 'node:util';
class LookupClient {
constructor(namespace) {
this.namespace = namespace;
}
lookup(key, callback) {
const namespace = this.namespace;
queueMicrotask(() => {
if (key === '') {
const error = new Error('key must not be empty');
error.code = 'E_KEY';
callback(error);
return;
}
callback(null, `${namespace}/${key}`);
});
}
}
const alpha = new LookupClient('alpha');
const beta = new LookupClient('beta');
const detached = promisify(alpha.lookup);
const bound = detached.bind(alpha);
const bindFirst = promisify(alpha.lookup.bind(alpha));
const forwarded = promisify((key, callback) => alpha.lookup(key, callback));
const checks = [
['detached rejects', async () => {
await assert.rejects(detached('item-7'), TypeError);
}],
['call selects receiver', async () => {
assert.equal(await detached.call(alpha, 'item-7'), 'alpha/item-7');
assert.equal(await detached.call(beta, 'item-7'), 'beta/item-7');
}],
['bind retains alpha', async () => {
assert.equal(await bound('item-7'), 'alpha/item-7');
assert.equal(await bound.call(beta, 'item-7'), 'alpha/item-7');
}],
['plain bind-first works', async () => {
assert.equal(await bindFirst('item-7'), 'alpha/item-7');
}],
['forwarding wrapper works', async () => {
assert.equal(await forwarded('item-7'), 'alpha/item-7');
}],
['callback error survives', async () => {
await assert.rejects(bound(''), { code: 'E_KEY' });
}],
['bind is not a state snapshot', async () => {
alpha.namespace = 'alpha-v2';
assert.equal(await bound('item-7'), 'alpha-v2/item-7');
alpha.namespace = 'alpha';
}],
['custom path depends on order', async () => {
function read(key, callback) {
callback(null, `callback:${this.namespace}/${key}`);
}
read[promisify.custom] = function (key) {
return Promise.resolve(`custom:${this.namespace}/${key}`);
};
const custom = promisify(read);
assert.equal(custom, read[promisify.custom]);
assert.equal(await custom.bind(alpha)('item-7'), 'custom:alpha/item-7');
const preBound = read.bind(alpha);
assert.equal(Object.hasOwn(preBound, promisify.custom), false);
assert.equal(await promisify(preBound)('item-7'), 'callback:alpha/item-7');
}],
];
console.log(`Node ${process.version}`);
for (const [name, check] of checks) {
await check();
console.log(`PASS: ${name}`);
}
console.log(`${checks.length} checks passed`);
검증 환경에서는 다음과 같이 8개 검사 묶음이 모두 통과했다. 첫 검사의 PASS는 분리 호출이 성공했다는 뜻이 아니라, 의도한 TypeError 거부를 검사기가 확인했다는 뜻이다. 실제 오류 문구는 런타임에 따라 달라질 수 있어 문구 전체 대신 오류 종류를 검사했다.
Node v24.22.1
PASS: detached rejects
PASS: call selects receiver
PASS: bind retains alpha
PASS: plain bind-first works
PASS: forwarding wrapper works
PASS: callback error survives
PASS: bind is not a state snapshot
PASS: custom path depends on order
8 checks passed
콜백 오류 검사도 따로 둔다. 수신 객체를 고친 뒤 빈 키를 넣었을 때 E_KEY가 여전히 거부 사유로 전달돼야 한다. 성공 문자열만 비교하면 입력 오류가 숨겨진 수정도 통과할 수 있다. 이 모형에서는 오류를 콜백으로 전달한다. 나중에 실행되는 임의의 콜백 안에서 던진 예외까지 promisify가 모두 포착한다고 넓혀 읽어서는 안 된다.
bind는 객체를 고정하지만 객체의 상태를 복사하지 않는다
ECMAScript의 bind 명세는 대상 함수와 주어진 this 값을 가진 새 함수를 만드는 절차를 정의한다. call 명세의 주석은 이미 바인딩된 함수가 call의 this 인자를 사용하지 않는다고 설명한다.
실험에서 bound = detached.bind(alpha)를 만든 뒤 bound.call(beta, "item-7")을 실행해도 결과는 alpha/item-7이다. 호출 지점에서 beta를 적었다고 대상이 바뀌지 않는다. 여러 인스턴스를 받는 공통 도구 등록기에 특정 인스턴스용 함수를 넣었다면 이 차이를 검사해야 한다.
반대로 alpha의 namespace를 alpha-v2로 바꾸면 같은 bound 함수가 alpha-v2/item-7을 반환한다. 이 코드는 호출할 때마다 객체의 현재 속성을 읽는다. bind를 설정값의 복사본을 만드는 기능으로 취급하면, 나중에 설정을 바꾼 영향이 없을 것이라고 잘못 예상하게 된다. 실험은 검사가 끝난 뒤 가상 속성을 원래 값으로 되돌린다.
bind와 promisify의 순서를 바꾸기 전에 custom을 확인한다
일반 메서드만 있는 앞부분에서는 promisify(alpha.lookup.bind(alpha))도 통과한다. 하지만 이 한 사례를 근거로 모든 라이브러리에 같은 변환 순서를 적용할 수는 없다. 마지막 검사는 원본 함수에 별도의 Promise 구현이 붙은 경우를 비교한다.
Node.js의 custom promisified functions 문서에 따르면 original[promisify.custom]에 함수가 지정되어 있으면 promisify는 그 함수를 반환한다. 실험의 원본 read는 일반 콜백 경로에서 callback:, custom 경로에서 custom:이라는 표식을 붙인다. 두 경로를 일부러 다르게 만든 진단용 장치다.
원본에 먼저 promisify를 적용하면 custom 함수가 선택되고, 그 뒤 alpha에 바인딩한 호출은 custom:alpha/item-7을 반환한다. 반대로 read.bind(alpha)로 만든 함수에는 이 실험에서 원본의 custom 심볼 속성이 없었다. 이 함수에 promisify를 적용한 결과는 callback:alpha/item-7이다. 예외는 없어도 변환 경로가 달라졌다.
따라서 수신 객체만 맞췄는지와 라이브러리가 제공한 Promise 경로를 유지했는지를 나눠 확인한다. 이 실험은 일반 함수로 만든 custom 구현에 바인딩을 적용한다. 화살표 함수나 이미 바인딩된 custom 구현이라면 뒤늦게 bind를 붙여 this를 바꾸려 하지 말고 해당 구현의 계약을 확인해야 한다.
실제 어댑터에 적용할 때 확인할 순서
- 콜백 계약부터 읽는다. 마지막 인자가 오류 우선 콜백인지, 라이브러리에 Promise 전용 API나 custom 구현이 있는지 확인한다. Node.js 문서는 이미 Promise를 반환하는 함수에 promisify를 적용하는 사용을 폐기 대상으로 표시한다.
- 인스턴스가 달라지는 테스트를 둔다. 같은 키를 alpha와 beta 같은 두 가상 인스턴스로 조회해 결과의 소속을 비교한다. 실서비스 인증 정보나 민감한 응답을 로그에 남길 필요는 없다.
- 등록할 함수의 소속을 정한다. 특정 클라이언트 전용이라면 변환 뒤 해당 객체에 바인딩한 함수를 등록하는 방법을 검토한다. 호출마다 다른 객체가 필요한 코드라면 미리 한 객체에 고정하지 않고 호출 경로를 설계한다.
- 성공과 오류 경로를 함께 검사한다. 정상 결과, 잘못된 입력, 원래 라이브러리 오류가 의도대로 전달되는지 확인한다. 콜백이 아예 호출되지 않는 경우와 시간초과·취소는 이 글의 검사 범위 밖이다.
- 버전과 실제 연동을 분리해 기록한다. 합성 검사 통과는 해당 라이브러리의 네트워크 동작이나 권한 설정까지 검증했다는 뜻이 아니다. 라이브러리 버전과 공식 API 계약을 확인한 뒤 별도 연동 테스트를 진행한다.
promisify를 넣은 줄만 보면 변환 전후가 비슷해 보인다. 이 실험에서 결과를 갈라놓은 것은 호출할 때의 객체와 선택된 Promise 구현이었다. 수정 전후 비교에도 두 정보를 남겨야, 예외만 없어진 변경과 의도한 경로가 복원된 변경을 구별할 수 있다.