MCP를 만들면서 5편

한상우·2026년 4월 28일

AI

목록 보기
10/11
post-thumbnail

MCP를 다섯 번 만들고 나서 (5) — postman-mcp: 필요한 복잡도를 받아들이기

시리즈 5편. 네 번째로 만든 postman-mcp를 파헤친다. 지금까지 셋(bitbucket/jira/confluence)은 전부 stateless였다. 이 친구는 다르다. 컬렉션을 메모리에 들고 산다. 그리고 변수 치환 3단 우선순위, 바디 모드 3종, 컬렉션 로드 경로 3개… "단순함을 유지하라"는 지난 네 편의 모토가 처음으로 흔들리는 지점이다. 흔들렸지만, 결국 받아들였다. 이 편은 언제 단순함을 포기하는가에 대한 이야기다.

왜 네 번째로 만들었나

회사에서 백엔드 API를 호출할 때 항상 Postman 컬렉션을 켰다. 팀이 공유하는 컬렉션에 모든 엔드포인트가 깔끔하게 정리돼 있었다 — auth 헤더, base URL, path/query 파라미터, 샘플 body까지. 새 엔드포인트가 추가되면 누군가 컬렉션에 등록한다. 사실상 우리 팀의 살아있는 API 문서였다.

그런데 코드를 짜다가 "이 엔드포인트 한 번 호출해보고 싶은데" 싶을 때마다 Postman을 켜야 한다. Claude Code 안에서 작업하다가 따로 창을 띄우는 게 흐름을 끊었다. "Claude한테 직접 시키면 되는 거 아냐?"

그게 출발이었다. 컬렉션을 Claude에 노출하고, 컬렉션에 정의된 요청을 Claude가 직접 호출하게 만들기. 처음엔 jira-mcp 같은 단순한 모양일 줄 알았다. 결과적으로는 다섯 개 중 가장 큰 코드베이스가 됐다.

도구 7개

postman_load_collection    컬렉션 로드 (파일/URL/Postman API)
postman_set_config         런타임 설정 변경 (base_url/token/variables)
postman_list_folders       폴더 트리 + 요청 수
postman_list_requests      전체 요청을 폴더별로 그룹핑
postman_get_request        요청 상세 (이름/인덱스/`METHOD /path`로 지정)
postman_call_api           실제 호출 — 주력
postman_status             현재 로드 상태

주력은 postman_call_api 하나. 나머지는 "뭐가 있나" 둘러보는 탐색용이거나(list_*/get_request/status) 환경 설정용(load_collection/set_config)이다.

지금까지 MCP들은 도구 하나하나가 거의 동등한 무게를 가졌는데, postman-mcp는 call_api 하나에 무게가 쏠려 있다. 이게 stateful로 가는 이유와도 연결된다 — call_api를 빠르고 자연스럽게 부르게 하려면 컬렉션이 이미 로드돼 있어야 한다.

첫 번째 이야기: 왜 갑자기 stateful로 갔는가

지금까지 셋은 모두 stateless였다. 매 호출마다 필요한 데이터를 외부 API에서 받아왔다. 이 패턴이 좋은 이유는 명백하다 — 서버 프로세스가 어떤 상태도 들고 있지 않으니, 재시작해도 똑같이 동작하고, 동시 요청을 걱정할 필요도 없고, 메모리 누수도 없다.

postman-mcp에서 처음으로 이 패턴을 깼다. 모듈 스코프에 싱글턴이 산다:

const parser = new PostmanParser();         // 모듈 스코프 싱글턴
const client = new ApiClient({ baseUrl: '' });

// 서버 시작 시 자동 로드
const applyCliConfig = async () => {
  const config = loadCliConfig();
  if (config?.collectionUrl) {
    await loadCollection(config.collectionUrl, config.apiKey);
  }
};

서버가 시작되면 컬렉션을 한 번 로드해서 parser에 담아둔다. 이후 모든 도구 호출이 같은 parser를 본다.

왜 이렇게 했나. 답은 사용자 경험과 비용의 두 축에서 나온다.

비용 — 컬렉션은 무겁다

회사 컬렉션이 1MB가 넘는다. 엔드포인트가 200개 정도, 각각 헤더·파라미터·예시·문서까지 포함하면 그렇다. 이걸 매 호출마다 다시 다운로드하고 파싱하는 건 명백한 낭비다:

  • 다운로드 1MB × 호출 빈도 = 네트워크 트래픽 누적
  • 파싱 = JSON.parse + 트리 정규화. 작지 않은 CPU
  • Postman API에 호출이 너무 잦으면 rate limit 걸릴 수도

stateless로 가도 동작은 하지만, 사용자가 "한 번 부르고 5초 기다리는" 경험을 매번 한다.

사용자 경험 — 같은 세션에서 연쇄적으로 부른다

MCP를 실제로 써보면 호출이 단발성이 아니다. 흐름이 이런 식이다:

사용자: "사용자 생성 API 호출해서 테스트해봐"
Claude:
  1. postman_list_requests        ← 어떤 요청들이 있나 본다
  2. postman_get_request          ← "POST /users" 상세 본다
  3. postman_call_api             ← 실행한다
사용자: "이번엔 이메일을 다른 걸로 해서 다시"
Claude:
  4. postman_call_api             ← 다시 부른다

이 4번의 호출 사이에 컬렉션이 안 바뀐다. 매번 재로드는 명백히 낭비다. MCP 서버는 Claude Code 세션 동안 살아있는 장기 프로세스라는 사실이 여기서 처음 활용됐다.

stateful 도입의 룰

이 결정을 하면서 나도 모르게 룰이 생겼다 — 호출 간 공유할 가치가 있는 비싼 자원이 있을 때만 stateful로 간다.

Jira/Confluence/Bitbucket은 이런 게 없었다. 매번 외부 API를 칠 수밖에 없는 데이터(티켓·페이지·PR)였고, 캐시할 수 있을 만큼 안 변하는 것도 아니었다. postman-mcp는 다르다 — 컬렉션이라는 무겁고 거의 안 변하는 자원이 있다.

stateful이 나쁜 게 아니다. 이유 없이 stateful이 나쁜 거다. 이유가 명확하면 받아들여야 한다. 지난 네 편에서 단순함을 강조했지만, 단순함은 목적이 아니라 수단이다. 사용자 경험과 비용을 위해서라면 추가 복잡도를 안고 가는 게 맞다.

두 번째 이야기: 변수 치환 3단

Postman은 {{base_url}}/api/users/{{user_id}} 같은 이중 중괄호 변수를 쓴다. 이걸 누가 어떻게 채워주느냐에 따라 같은 요청이 dev/staging/prod 어느 환경으로도 갈 수 있다.

치환은 단순한 정규식 치환이다:

resolveVariables(str: string): string {
  return str.replace(/\{\{(\w+)\}\}/g, (_, key) => 
    this.variables[key] ?? `{{${key}}}`
  );
}

{{var}} 패턴을 찾아서 해당 키로 치환. 키가 없으면 원래 형태 유지(나중에라도 채워질 수 있게).

복잡한 건 치환 자체가 아니라 변수 출처의 우선순위다:

컬렉션 정의 변수
   ↓ 덮어씀
CLI setup에서 지정한 값  
   ↓ 덮어씀
호출 시 variables 파라미터

세 단계. 컬렉션에 base_url=https://dev.api.com이 있고, CLI에서 base_url=https://staging.api.com을 넣었으면 staging이 이긴다. 호출할 때 variables: { base_url: "https://prod.api.com" }을 주면 prod이 이긴다.

이 우선순위가 왜 이렇게 되어야 하는지 — "덜 영구적인 출처가 더 영구적인 출처를 덮어쓴다" 는 원칙이다. 컬렉션 정의는 가장 영구적(누군가 컬렉션 자체를 수정해야 바뀜), CLI setup은 그다음(사용자 환경에 한 번 박힘), 호출 시 파라미터는 가장 일시적(이번 한 번만). 일시적인 게 영구적인 걸 덮는 게 직관에 맞다. ENV 변수가 시스템 기본값을 덮고 CLI 인자가 ENV를 덮는 패턴과 같다.

base_url은 특별 케이스다. 컬렉션에 base_url / baseUrl / BASE_URL 중 하나가 있으면 자동으로 api-client.baseUrl에 박힌다. 사용자가 "이 컬렉션의 base URL이 뭐냐"를 입력 안 해도 호출이 되도록. 작은 자동화지만 사용성이 결정적으로 다르다.

세 번째 이야기: 바디 모드 3종

Postman 요청의 body에는 모드(mode)가 있다. 같은 "요청 body"라도 직렬화 방식이 셋이다:

modeContent-Type직렬화
raw보통 application/jsonJSON.stringify
urlencodedapplication/x-www-form-urlencodedURLSearchParams
formdatamultipart/form-dataFormData

api-client.ts가 모드별로 분기해서 fetch body를 만든다:

buildBody(bodyMode: string, body: unknown): BodyInit | undefined {
  if (!body) return undefined;
  
  switch (bodyMode) {
    case 'raw':
      return typeof body === 'string' ? body : JSON.stringify(body);
    
    case 'urlencoded': {
      const params = new URLSearchParams();
      Object.entries(body as Record<string, string>).forEach(([k, v]) => 
        params.append(k, v)
      );
      return params;
    }
    
    case 'formdata': {
      const fd = new FormData();
      Object.entries(body as Record<string, string>).forEach(([k, v]) => 
        fd.append(k, v)
      );
      return fd;
    }
  }
}

모드를 어떻게 결정하느냐 — 컬렉션에 정의된 모드가 기본값, 호출 시 사용자가 명시적으로 다른 모드를 줄 수도 있다. 대부분은 컬렉션 정의 그대로 가면 된다.

처음엔 "그냥 raw JSON만 지원하면 안 되나?" 싶었다. 안 된다. 우리 팀 컬렉션에 OAuth 토큰 발급 엔드포인트가 urlencoded로 정의돼 있었다(OAuth 2.0 표준이 그렇다). 파일 업로드 엔드포인트는 formdata. 현실의 컬렉션은 mode가 섞여 있다. raw만 지원하면 절반의 요청이 실패한다.

이건 "API의 불편함을 사용자에게 떠넘기지 않는다"(3편)의 또 다른 적용이다. Postman이 mode 셋을 지원하면, 내 MCP도 셋을 다 지원해야 한다. 사용자가 "이건 raw, 저건 urlencoded"를 의식하게 하면 추상화가 새는 거다.

네 번째 이야기: 컬렉션 로드 3경로

컬렉션을 어디서 가져오느냐 — 셋이다.

1. 로컬 파일. 사용자가 Postman에서 "Export" 한 JSON. 가장 단순하다. fs.readFileSync 끝.

2. 일반 URL. 누군가 컬렉션 JSON을 어디 호스팅해뒀을 수 있다. fetch 한 번.

3. Postman API. Postman 자체 API(api.getpostman.com/collections/{id})로 가져오기. 이게 가장 흔한 케이스인데, 두 가지 함정이 있다:

  • 인증 헤더가 Authorization이 아니라 X-Api-Key다. Postman이 자기 API를 위해 따로 만든 키 체계.
  • 응답이 컬렉션 그 자체가 아니라 { collection: { ... } }로 한 번 래핑돼 있다. 언래핑 필요.
const loadCollection = async (source: string, apiKey?: string) => {
  if (isUrl(source)) {
    const headers: Record<string, string> = { Accept: 'application/json' };
    if (apiKey) headers['X-Api-Key'] = apiKey;  // Postman API 인증
    
    const response = await fetch(source, { headers });
    const json = await response.text();
    const parsed = JSON.parse(json);
    
    // Postman API는 { collection: {...} } 래핑 → 언래핑
    const collectionJson = parsed.collection 
      ? JSON.stringify(parsed.collection) 
      : json;
    
    return parser.loadFromJson(collectionJson, source);
  }
  return parser.loadFromFile(source);  // 파일 경로
};

URL인지 아닌지로 1차 분기, URL이면 응답이 래핑돼 있는지로 2차 분기. 하나의 함수가 셋을 다 처리한다. 이게 가능한 건 셋이 결과적으로 같은 형태(컬렉션 JSON)를 만들기 때문이다 — 위 레이어에서는 출처를 모른다.

이게 4편의 "버전 차이는 클라이언트 아래에 가둔다"와 같은 패턴이다. 소스 차이도 클라이언트 아래에 가둔다. 어디서 왔는지는 위 레이어에 새지 않게.

다섯 번째 이야기: 호출 시 값 병합

postman_call_api 한 번이 부르는 일이 꽤 많다:

요청 name(또는 method+path)으로 컬렉션에서 찾기
   ↓
컬렉션의 headers / queryParams / body / pathVariables → 기본값
   ↓
호출 인자의 headers / query_params / body / path_params → 덮어쓰기
   ↓
{{var}} 치환 (3단 우선순위)
   ↓
fetch 실행
   ↓
응답 포매팅 (status / headers / body)

핵심은 사용자가 일부만 넘겨도 나머지는 컬렉션 값으로 채워진다는 것. body: { name: "홍길동" }만 넘기면 헤더·쿼리·경로는 컬렉션에 정의된 그대로 간다. 이게 Postman을 직접 쓸 때의 경험을 그대로 옮긴 것 — 사용자는 "이번에 바꾸고 싶은 것"만 의식하고, 안 건드리는 건 신경 끈다.

이 병합 로직 한 군데 때문에 api-client.ts가 다른 MCP들의 클라이언트보다 두 배 가까이 길어졌다. 그만한 가치가 있었다 — 사용자가 매번 모든 헤더를 다시 입력해야 했으면 이 MCP는 안 썼을 거다.

4-파일로 돌아온 이유

3편 jira-mcp에서 "포매팅을 분리하지 않았다"고 자랑했었다. 4편 confluence-mcp도 3-파일이었다. postman-mcp는 4-파일로 돌아왔다:

src/
├── index.ts            # MCP 서버 + 도구 디스패치
├── cli.ts              # setup / register / status
├── postman-parser.ts   # 컬렉션 JSON → 구조화된 Request[] / Folder[] / Variable[]
└── api-client.ts       # HTTP 호출 + 변수 치환 + 바디 모드

postman-parser.tsapi-client.ts가 갈라진 이유는 명확하다. 둘이 의존하는 도메인이 다르다.

  • postman-parser.ts: Postman Collection Schema v2.1을 안다. HTTP는 모른다. 입력은 컬렉션 JSON, 출력은 구조화된 객체.
  • api-client.ts: HTTP를 안다. Postman을 모른다. 입력은 {method, url, headers, body, ...}, 출력은 응답 텍스트.

이 분리의 진짜 가치는 swagger-mcp(다음 편)에서 드러난다 — api-client.ts가 Postman에 의존하지 않기 때문에, OpenAPI 파서를 새로 끼워서 거의 그대로 재사용할 수 있다. 분리의 가치는 종종 다음 프로젝트에서 회수된다.

2편의 분리 기준("이 로직을 단독으로 테스트하고 싶은가")에 더해 새 기준이 생긴 셈이다 — "이 로직이 다른 프로젝트에서 재사용될 가능성이 있는가." postman-mcp 만들 때는 swagger-mcp를 만들 생각이 없었지만, api-clientparser를 분리해뒀던 결정이 다음 MCP를 70% 빠르게 만들었다.

CLI는 같다

postman-mcp setup
# 컬렉션 URL / Postman API Key / Base URL / Bearer 토큰 / 변수 key=value
postman-mcp register
postman-mcp status

3단 패턴은 변함없음. 차이는 setup에서 컬렉션 URL을 입력하면 서버 시작 시 자동 로드된다는 것 — applyCliConfig()가 부팅 시점에 한 번 부른다. 매번 load_collection을 부를 필요가 없다.

등록은 다른 MCP와 약간 다르다 — --env에 토큰을 박지 않고 config.json만으로 시작한다:

claude mcp add \
  --transport stdio \
  --scope user \
  postman -- \
  node /Users/<user>/.claude/mcp-servers/postman-mcp/dist/index.js

이유: postman-mcp는 호출 대상 API의 인증이 컬렉션마다 다르다. 어떤 컬렉션은 Bearer, 어떤 건 API Key 헤더, 어떤 건 OAuth. 환경변수 하나로 박기 어려워서 런타임 설정(postman_set_config)으로 변경 가능하게 두는 편이 나았다.

돌아보면: 단순함을 포기한 자리

postman-mcp는 지금까지의 "단순함이 미덕"이라는 흐름을 두 군데에서 깼다:

  1. stateful로 갔다. 컬렉션이라는 무거운 자원을 메모리에 들고 사니까. 이유 없는 stateful은 나쁘지만, 이유가 명확하면 받아들여야 한다.
  2. api-client.ts가 비대해졌다. 변수 치환 + 바디 모드 + 값 병합. 한 책임 안에서 복잡도가 솟았다. 쪼개려고 시도해봤는데, 쪼개면 오히려 흐름이 끊겼다. 응집도가 높은 복잡도는 그대로 두는 게 낫다.

그리고 새로 생긴 룰들:

  • 호출 간 공유할 가치가 있는 비싼 자원이 있을 때만 stateful로 간다.
  • 현실의 입력은 깔끔하지 않다. mode 셋, 출처 셋, 인증 방식 가지가지. 추상화로 단순화하지 말고 그대로 받아들이자 — 단순화하면 사용자가 매번 우회를 만들어야 한다.
  • 분리의 가치는 다음 프로젝트에서 회수된다. api-client.tsparser.ts와 갈라놓은 결정이 swagger-mcp의 70% 재사용을 가능하게 했다.

지난 네 편에서 "단순함을 유지하라"를 반복했다. 이 편은 그 반대 면이다. 단순함은 기본값이지 신성한 가치가 아니다. 복잡도가 정당하면 안고 간다. 결국 좋은 설계는 "어디는 단순하게 두고 어디는 복잡함을 받아들일지"의 판단력이지, 모든 곳을 단순하게 만드는 미니멀리즘이 아니다.

다음 편

6편 — swagger-mcp: postman-mcp의 70% 복제. api-client.ts는 거의 그대로, parser.ts만 OpenAPI 3.x용으로 새로 쓴다. 차이는 변수 치환 방식({{var}} vs {path_param}), baseUrl 결정 로직(컬렉션 변수 vs servers[0].url), 그리고 OpenAPI에만 있는 스키마 탐색(get_schema / list_schemas). 시리즈의 마지막 편에서, 두 MCP를 나란히 두고 비교하면서 "재사용은 추상화가 아니라 패턴 복제" 라는 결론으로 마무리할 예정이다.

profile
안녕하세요

0개의 댓글