MCP 구축 4회차

한상우·2026년 4월 24일

AI

목록 보기
9/11
post-thumbnail

MCP를 다섯 번 만들고 나서 (4) — confluence-mcp: 쌍둥이를 만든다는 것

시리즈 4편. 세 번째로 만든 confluence-mcp를 파헤친다. jira-mcp와 거의 똑같이 생겼다. 인증도 같고, 파일 구조도 같고, CLI도 같다. 그래서 이 글은 "복제된 코드베이스를 어떻게 다뤘는가"에 대한 이야기에 가깝다. 그리고 Confluence가 가진 두 가지 고유한 짜증 — CQL(Confluence Query Language)과 v1/v2 API 혼용, 특히 사용자가 외운 키와 v2가 요구하는 숫자 ID 사이의 간극.

왜 세 번째로 만들었나

jira-mcp 만들고 나서 너무 자연스럽게 따라왔다. 같은 Atlassian 토큰 재사용, 같은 base URL 도메인(xxx.atlassian.net), 같은 Basic auth. 회사에서 Jira와 Confluence는 같이 돈다 — 티켓에는 결정 사항이 짧게 들어가고, 자세한 RFC나 회의록은 Confluence에 있다. Jira만 Claude가 볼 수 있고 Confluence는 못 보면 반쪽짜리였다.

그리고 솔직히 말하면, jira-mcp의 골격을 그대로 가져다 쓸 수 있다는 게 컸다. 첫 번째 제대로 된 "복제" 였다. 이 경험이 나중에 swagger-mcp(postman-mcp의 70% 복제)를 만드는 데도 자신감이 됐다.

도구 7개

search_pages         CQL 패스스루 검색
get_page             페이지 상세 (제목/본문)
list_spaces          워크스페이스의 스페이스 목록
get_space_pages      특정 스페이스의 페이지 목록 (키 또는 ID)
get_child_pages      하위 페이지
get_recent_pages     최근 수정 페이지
get_my_recent_work   내가 기여한 페이지

jira-mcp의 6개보다 하나 많다. 그리고 모두 조회다. 쓰기가 없다. 페이지 작성/수정 같은 무거운 변경은 의도적으로 빼뒀다 — Jira 코멘트 같은 단발성 글과 달리, Confluence 페이지는 사람이 에디터에서 다듬는 워크플로우가 정착돼 있어서, MCP 도구로 자동 생성하면 오히려 팀 컨벤션을 깨뜨릴 위험이 있었다.

첫 번째 이야기: 쌍둥이를 만든다는 것

jira-mcp와 confluence-mcp의 파일 트리를 나란히 두면 거의 데칼코마니다:

jira-mcp/                        confluence-mcp/
├── package.json                 ├── package.json
├── tsconfig.json                ├── tsconfig.json
├── config.json                  ├── config.json
├── src/                         ├── src/
│   ├── index.ts                 │   ├── index.ts
│   ├── cli.ts                   │   ├── cli.ts
│   └── jira-client.ts           │   └── confluence-client.ts
└── dist/                        └── dist/

<domain>-client.ts 한 글자만 바뀌고, 나머지는 구조가 같다. CLI의 setup/register/status 3단도 같고, 환경변수 폴백 우선순위도 같고, MCP SDK 사용 패턴도 같다.

이 시점에서 자연스럽게 떠오르는 질문 — "그러면 공통 코드를 패키지로 추출해야 하지 않나?"

진지하게 고민했다. @my/atlassian-mcp-base, @my/mcp-cli-runner 같은 걸 만들어서 jira-mcp와 confluence-mcp가 둘 다 import하게 하는 그림. 그런데 결론은 하지 않기로 했다. 이유 셋:

  1. 두 번 복붙은 추상화의 근거가 약하다. "Three strikes and you refactor"라는 격언이 있는데, 두 번에 추출하면 보통 추상이 빨리 굳어버려서 세 번째가 들어올 자리가 없다. 실제로 나중에 postman-mcp에서 4-파일 구조로 다시 갈라졌으니 — 만약 두 번째에 base 패키지를 만들었으면 그때 깨졌을 거다.
  2. 각 코드베이스가 5분 안에 통째로 읽힌다. 파일 셋, 한 파일당 200줄 안. base 패키지를 import하기 시작하면 "이게 어디 정의된 거지?"가 추가된다. 작은 코드베이스의 가독성은 의존성 추가로 쉽게 망가진다.
  3. 변형이 생길 때 마찰이 줄어든다. 나중에 보겠지만 confluence-mcp는 v1/v2 두 base URL을 섞어 쓴다. base 패키지가 "request 메서드는 하나"라고 가정했으면 이걸 표현하는 데 추가 추상화가 필요했을 거다. 복붙된 코드는 그냥 메서드 둘 추가하면 끝.

복붙 vs 추상화 사이의 선은 내 안에서 이렇게 정리됐다 — 복사된 코드가 동시에 같은 방향으로 변경되는 빈도가 높을 때만 추상화한다. Jira와 Confluence는 그렇지 않다. 인증 코드 세 줄을 같이 고쳐야 할 일이 1년에 두 번 정도 있을까 말까. 그 정도 마찰을 피하려고 패키지 분리를 만드는 건 손해다.

두 번째 이야기: CQL이라는 것

Confluence의 검색 언어는 CQL(Confluence Query Language)이다. JQL의 사촌. 같은 회사가 만들었으니 문법도 비슷하다 — space = DEV AND type = page AND lastModified >= "2025-04-01" 같은 식.

search_pages 도구는 CQL을 그대로 받아 패스스루한다. 가공 안 한다. 이게 의도적이다.

Claude는 CQL을 안다. 학습 데이터에 충분히 있다. "지난 주 DEV 스페이스에 만들어진 RFC 페이지 찾아줘"라고 사용자가 말하면, Claude가 알아서 CQL로 번역한다:

space = DEV AND type = page AND title ~ "RFC" AND created >= now("-1w")

내 MCP가 자연어를 CQL로 번역하는 자체 레이어를 가질 이유가 없다. 이 번역은 Claude가 더 잘하고, 내가 끼어들면 망가진다. 내가 자체 DSL을 만들어 봤자 Claude가 그걸 다시 배우게 만드는 비용이 추가될 뿐.

이 결정이 일반화되면 — MCP 도구는 "사용자 의도 → 외부 시스템 호출"을 직접 노출할 수 있을 때 그렇게 하는 게 좋다. 중간에 단순화 레이어를 두면 표현력만 잃는다. CQL/JQL/SQL/JMESPath처럼 잘 알려진 DSL을 그대로 받는 게 답이다.

get_my_recent_workget_recent_pages 같은 편의 도구는 별개다 — 자주 쓰는 패턴을 한 번에 부르는 단축. 하지만 일반 검색은 CQL 패스스루.

세 번째 이야기: v1/v2 혼용

confluence-mcp의 confluence-client.ts를 열어보면 base URL 두 개를 섞어 쓴다:

//  v1: /wiki/rest/api/...
searchPages(cql, maxResults)   // /wiki/rest/api/search
listSpaces(limit)              // /wiki/rest/api/space

//  v2: /wiki/api/v2/...
getPage(pageId)                // /wiki/api/v2/pages/{id}
getSpacePages(spaceId, limit)  // /wiki/api/v2/spaces/{id}/pages
getChildPages(pageId, limit)   // /wiki/api/v2/pages/{id}/children

기능마다 어느 쪽이 더 안정적이고 풍부한지가 달라서 그렇다. Atlassian이 v2 API를 점진적으로 출시하면서 일부 기능은 v2가 훨씬 깔끔하게 정리됐는데, 일부 기능(특히 검색)은 v1만 지원되거나 v1이 더 풍부한 결과를 준다. v2는 페이지 트리 탐색 같은 신규 영역에서 강하고, v1은 검색·전체 목록 같은 레거시 영역에서 안정적이다.

이건 jira-mcp의 "두 개 REST 엔드포인트"(3편)와 비슷한 상황 같지만 사실 다르다:

  • Jira의 두 엔드포인트(/rest/api/3, /rest/agile/1.0)는 도메인이 다르다. Core API와 Agile API는 별개 제품이었다가 합쳐진 것.
  • Confluence의 두 엔드포인트(/wiki/rest/api, /wiki/api/v2)는 같은 도메인의 다른 버전이다. 점진적 마이그레이션의 흔적.

후자가 더 이상하다. 같은 페이지를 v1으로도 v2로도 가져올 수 있는데, 응답 스키마가 다르다. 결과 형태를 통일하느라 클라이언트 안에서 한 번 더 정규화한다 — Claude에게 노출되는 형태는 어느 버전에서 왔든 같다.

3편에서 했던 것과 같은 결론으로 다시 돌아온다 — 버전 차이는 클라이언트 아래에 가둔다. MCP 도구 핸들러도, Claude도, "v1이냐 v2냐"를 모른다. 위 레이어는 그냥 getPage(pageId)를 부른다.

네 번째 이야기: 스페이스 키와 ID의 간극

이게 사용자 입장에서 제일 짜증나는 함정이다.

Confluence 사용자는 URL에서 본 스페이스 코드를 외우고 있다. https://xxx.atlassian.net/wiki/spaces/DEV/...에서 DEV 같은 짧은 대문자 키. 이건 사람이 외우라고 만든 식별자다.

근데 v2 API는 숫자 ID만 받는다. DEV 같은 키는 안 받는다. 받으면 400.

GET /wiki/api/v2/spaces/DEV/pages    → 400 Bad Request
GET /wiki/api/v2/spaces/13107201/pages → 200 OK

사용자는 "DEV 스페이스 페이지 보여줘"라고 말하지 13107201를 말하지 않는다. Claude도 마찬가지. 이 간극을 누가 메우나? 내 MCP가 메운다.

async getSpacePages(spaceIdentifier: string, limit: number) {
  // 숫자면 그대로, 알파벳이면 키로 보고 ID 해석
  const spaceId = /^\d+$/.test(spaceIdentifier)
    ? spaceIdentifier
    : await this.resolveSpaceKey(spaceIdentifier);

  return this.request(`/wiki/api/v2/spaces/${spaceId}/pages?limit=${limit}`);
}

private async resolveSpaceKey(key: string): Promise<string> {
  // v1 API로 키→ID 해석. v2는 키 검색을 안 받음.
  const space = await this.requestV1(`/wiki/rest/api/space/${key}`);
  return space.id;
}

호출 한 번이 더 든다. 캐싱하면 더 줄일 수 있는데, 스페이스가 추가/삭제되는 빈도를 생각하면 매번 해석해도 큰 비용은 아니라 단순함을 택했다. 한 세션에서 같은 키를 여러 번 쓰면 그때 가서 메모리 캐시를 붙일 생각이었는데, 1년 가까이 써본 결과 그 단계는 안 와도 됐다.

이 작은 자동 해석 덕분에 사용자 경험이 결정적으로 좋아진다. "사용자가 외운 형태"와 "API가 받는 형태"가 다를 때, 그 변환을 내가 품지 않으면 사용자가 매번 ID를 찾아야 한다. 3편의 ADF 왕복도 같은 패러다임이고, 이게 내 안에서 점점 굳어진 원칙 — API의 불편함을 사용자에게 떠넘기지 않는다.

본문 추출 — Storage Format이라는 또 다른 포맷

Jira는 ADF, Bitbucket은 마크다운, Confluence는…? Storage Format이다. HTML의 변형 같은 것. <p>, <ul>, <ac:structured-macro> 같은 태그가 섞여 있다.

다행히 v2 API는 응답에 평문 표현도 같이 줄 때가 많다(representation: 'plain' 옵션). 평문이 가능하면 평문으로 받고, Storage Format으로만 오면 태그 스트립으로 처리한다. ADF만큼 짜증나지는 않았지만, 세 시스템이 각자 다른 리치 텍스트 포맷을 쓴다는 사실 자체가 한 번 더 확인됐다. Atlassian 안에서도 통일이 안 돼 있다.

인증과 등록 — Jira와 동일

const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
this.headers = {
  Authorization: `Basic ${auth}`,
  'Content-Type': 'application/json',
  Accept: 'application/json',
};

3편에서 "세 번 복붙은 그냥 복붙이 낫다"고 한 그 코드. 여기가 세 번째다. CLI도 동일 패턴:

confluence-mcp setup       # URL / 이메일 / API 토큰
confluence-mcp register
confluence-mcp status

등록:

claude mcp add \
  --transport stdio \
  --env CONFLUENCE_BASE_URL=https://xxx.atlassian.net \
  --env CONFLUENCE_EMAIL=... \
  --env CONFLUENCE_API_TOKEN=... \
  --scope user \
  confluence -- \
  node /Users/<user>/.claude/mcp-servers/confluence-mcp/dist/index.js

mcp__confluence__search_pages, mcp__confluence__get_page 등으로 노출.

Jira-Confluence 콤보의 위력

이 두 MCP를 같이 등록해두면 진짜 좋은 건 따로 있다. Claude가 둘을 자연스럽게 조합한다.

"PROJ-247 티켓 관련된 RFC 문서 찾아줘"라고 하면:
1. mcp__jira__get_ticket으로 PROJ-247 가져옴
2. 티켓 제목/설명에서 핵심 키워드 추출
3. mcp__confluence__search_pages로 CQL 검색
4. 후보 몇 개 보고 가장 관련 있는 것 골라서 mcp__confluence__get_page

내가 두 호출을 명시적으로 시키지 않아도 Claude가 알아서 한다. MCP 두 개를 나란히 두니까 1+1=3이 되는 순간이었다. 이게 단일 MCP에서는 안 보이는 가치다.

확장하면 — MCP는 단독으로 평가하면 안 되고, 같이 떠 있는 다른 MCP들과의 조합에서 평가해야 한다. 이 감각이 5번째 MCP까지 오는 동안 점점 강해졌다.

돌아보면: "쌍둥이"를 만들어 본 가치

confluence-mcp 자체는 jira-mcp의 기술적 변형이다. 새로운 패턴을 발견한 게 적다. 대신 "두 번째 만들 때 무엇을 안 하는가" 에 대한 감각이 생겼다:

  1. base 패키지로 추출하지 않는다. 두 번 복붙은 추상화를 정당화하지 않는다. 세 번째가 어떻게 갈라질지 모른다.
  2. 잘 알려진 DSL은 패스스루한다. CQL/JQL은 Claude가 더 잘 안다. 자체 단순화 레이어를 만들지 않는다.
  3. 버전 차이는 클라이언트 아래에 가둔다. 위 레이어는 v1/v2를 모른다.
  4. 사용자가 외운 형태와 API가 받는 형태의 간극은 내가 메운다. 스페이스 키 자동 해석.

그리고 나도 모르게 깨달은 것 — "좋은 MCP는 다른 MCP와 조합될 때 더 빛난다." Jira+Confluence 콤보가 이걸 처음 보여줬다.

profile
안녕하세요

0개의 댓글