MCP 동시 호출의 값이 섞일 때: 전역 변수와 요청별 상태 분리

MCP 도구를 하나씩 호출하면 괜찮은데 두 요청을 겹쳐 실행할 때 로그나 결과의 식별자가 뒤섞인다면, 먼저 요청 정보를 전역 변수에 넣고 있지 않은지 확인하자. 응답을 기다리는 동안 다른 요청이 같은 변수를 덮어쓸 수 있다. 간단한 함수라면 요청별 값을 인자로 전달하고, 여러 비동기 단계에서 공통 맥락을 읽어야 한다면 요청마다 새 저장소를 만드는 AsyncLocalStorage를 검토한다.

이 글에서 직접 확인한 것은 Node.js의 요청 상태 공유 문제다. 실제 MCP 서버·클라이언트의 장애를 재현한 보고서는 아니다. 아래 A와 B는 합성 식별자이며, 고객 데이터·인증값·실제 사용자 정보는 사용하지 않았다.

기다림과 상태 분리는 다른 문제다

두 작업이 모두 끝날 때까지 기다렸는데도 값이 틀릴 수 있다. Promise.all은 작업의 완료를 모을 뿐, 함수 밖에 있는 변수를 요청마다 복사해 주지 않는다. “응답이 너무 일찍 나간다”와 “응답에 다른 요청의 값이 들어간다”는 진단을 나눠야 한다.

먼저 각 요청의 입력값과 반환값을 비교한다. 전송된 응답의 식별자 자체가 잘못된 경우와, 식별자는 맞지만 본문 또는 로그에 다른 요청의 상태가 섞인 경우도 구분한다. 아래 예제는 후자의 원인이 될 수 있는 애플리케이션 코드만 떼어 확인한다.

같은 변수 하나를 쓰면 A도 B를 읽는다

다음 코드는 외부 통신 없이 실행된다. 두 함수를 같은 대기 지점에 멈췄다가 풀어 주므로, 우연히 실행 순서가 겹치기를 기다릴 필요가 없다. 파일 이름은 context-demo.mjs로 저장하고 Node.js로 실행하면 된다.

import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
let currentId;
async function bad(id, gate) {
  currentId = id;
  await gate;
  return currentId;
}
async function local(id, gate) {
  const context = { id };
  await gate;
  return context.id;
}
function scoped(id, gate) {
  return als.run({ id }, async () => {
    await gate;
    return als.getStore().id;
  });
}
const shared = {};
function reused(id, gate) {
  shared.id = id;
  return als.run(shared, async () => {
    await gate;
    return als.getStore().id;
  });
}
async function pair(fn) {
  let release;
  const gate = new Promise(resolve => { release = resolve; });
  const a = fn('A', gate);
  const b = fn('B', gate);
  release();
  return Promise.all([a, b]);
}
for (const [name, fn, expected] of [
  ['global', bad, ['B', 'B']],
  ['local', local, ['A', 'B']],
  ['fresh-store', scoped, ['A', 'B']],
  ['shared-store', reused, ['B', 'B']],
]) {
  const result = await pair(fn);
  assert.deepEqual(result, expected);
  console.log(name, JSON.stringify(result));
}
assert.equal(als.getStore(), undefined);
console.log('outside', String(als.getStore()));
console.log('node', process.version);

2026년 10월 20일 KST, Node.js v24.19.0에서 실제 실행한 출력은 다음과 같다. 코드에 넣은 assertion도 모두 통과했다.

global ["B","B"]
local ["A","B"]
fresh-store ["A","B"]
shared-store ["B","B"]
outside undefined
node v24.19.0

global은 A가 currentId에 A를 적은 뒤 멈추고, B가 같은 자리에 B를 적는다. 대기를 풀면 둘 다 B를 읽는다. local은 호출할 때마다 별도 context를 만들어 A와 B를 유지한다. fresh-store도 요청마다 새 객체를 전달했기 때문에 각 값을 읽는다. 반면 shared-store는 같은 객체를 두 번 넘겼으므로 다시 B, B가 된다. 비동기 맥락을 나눴다고 객체 자체까지 복제되는 것은 아니다.

어느 방법을 선택할까

함수 몇 개를 거치는 작업: 요청별 context를 인자로 전달하는 쪽부터 시작한다. 값이 어디에서 오는지 보이고, 함수 테스트도 쉽다. 단, 인자로 전달하더라도 같은 변경 가능한 객체를 여러 요청에 재사용하면 분리가 되지 않는다.

깊은 호출 단계에서 로그 식별자를 반복해서 읽는 작업: AsyncLocalStorage가 후보가 된다. 애플리케이션이 구분한 요청의 진입점에서 run을 호출하고 새 객체를 넘긴다. 하나의 HTTP 연결이나 세션에 여러 작업이 실릴 수 있으므로, 연결 하나를 곧 요청 하나라고 가정하지 않는다. 실제 진입점은 사용하는 서버 구현에 맞게 정해야 한다.

캐시나 공용 클라이언트: 모든 전역 객체를 없애야 한다는 뜻은 아니다. 공유해도 되는 설정·자원과 요청별 현재 사용자·처리 중 항목을 구분한다. 특히 공용 클라이언트의 가변 헤더나 기본 옵션에 요청별 값을 덮어쓰는 구조는 별도로 확인한다. 이 부분은 진단 제안이며 특정 SDK에서 시험한 결과가 아니다.

공식 문서와 대조한 범위

Node.js v24.19.0 Async context 공식 문서는 run 안에서 만들어진 비동기 작업이 해당 저장소에 접근할 수 있다고 설명한다. getStore는 현재 저장소를 돌려준다. 이 예제처럼 기본값 없이 만든 인스턴스에서 초기화된 맥락 밖을 읽으면 undefined가 나온다.

같은 문서는 특별한 이유가 없다면 enterWith보다 run을 권한다. enterWith가 현재 동기 실행의 나머지 부분에도 영향을 줄 수 있기 때문이다. 따라서 이 예제에서는 run을 사용했다. run이 반환하는 Promise를 호출자가 기다려야 하는지는 별개의 완료 처리 문제다. 여기서는 pair가 Promise.all로 두 반환값을 기다린다.

실제 코드에 적용하기 전 확인할 순서

1. 요청별 쓰기 위치 찾기. 모듈 최상단의 let, 싱글턴 속성, 공용 객체의 currentUser·currentItem 같은 필드를 살핀다. 단순히 이름이 같다고 오류는 아니다. 서로 다른 요청이 같은 저장 위치를 바꾸는지 확인한다.

2. 겹치는 입력 만들기. 민감하지 않은 A·B 식별자로 작업을 겹쳐 실행한다. 입력, 대기 직전, 대기 직후, 반환 직전의 식별자를 비교한다. 운영 고객 원문이나 토큰을 진단 로그에 추가하지 않는다.

3. 저장소 생성 위치 확인. new AsyncLocalStorage 자체를 요청마다 만드는 것이 핵심은 아니다. 예제처럼 인스턴스를 공유하더라도 run에 넣는 요청별 객체는 새로 만든다. 중첩 객체를 다른 요청과 공유한다면 그 내부의 변경도 다시 점검한다.

4. 맥락이 없는 경계 확인. getStore가 undefined라면 임의의 전역 기본값으로 덮지 않는다. 요청 진입점 밖에서 호출했는지, 비동기 경계를 어떻게 넘었는지 먼저 찾는다. 필요한 맥락이 없을 때의 실패 처리는 애플리케이션에서 명시한다.

5. 분리 이후에도 권한 검사 유지. 저장소에 사용자 식별자가 있다는 사실만으로 접근 권한이 증명되지는 않는다. 인증과 자원별 권한 검사는 별도로 수행한다. AsyncLocalStorage는 데이터베이스 트랜잭션, 프로세스 간 통신, 보안 격리를 대신하지 않는다.

이 실험이 증명하지 않는 것

확인한 것은 한 Node.js 프로세스에서 위 코드가 정해진 순서로 겹쳤을 때의 반환값이다. 모든 라이브러리의 콜백 경계, 워커·다른 프로세스, 실제 MCP 전송, 다중 사용자 서비스의 보안까지 검증한 것은 아니다. 공유 상태가 없어도 응답이 섞인다면 직렬화·캐시 키·응답 매핑 등 다른 경로를 따로 조사해야 한다.

적용 범위는 Node.js v24.19.0의 기본 AsyncLocalStorage API다. MCP SDK와 프로토콜 버전을 바꿔 비교하지 않았고 특정 MCP 버전의 결함으로 주장하지 않는다. 문서 대조와 합성 코드 실행 확인일은 2026년 10월 20일 KST다.

함께 확인할 글

MCP 결과가 비어 있을 때: async forEach와 완료 시점은 작업이 끝나기 전에 응답하는 문제를 다룬다. 기다림을 고친 뒤에도 값이 다른 요청과 섞인다면, 이번 글의 상태 분리 확인이 이어진다.


다른 글 보기 · 주제 탐색 · 작성자와 편집 기준 · 문의·정정 요청

RUDA DIRECTOR에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기