토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기
안녕하세요, 토스페이먼츠 김용성입니다.
지난주, 토스페이먼츠에서 PG업계 최초로 MCP를 소개하면서 많은 분들이 관심가져 주셨는데요. 이 글을 통해 MCP 서버 구현 과정과 그 안에서 얻은 러닝을 공유하고자 해요.
요약
* 길드(guild): 서로 다른 팀이나 사일로에 속한 팀원들이 느슨하게 모여 공통의 관심사를 공유하는 조직
먼저 이해를 돕기 위해 짧은 시연 영상을 준비했어요. 외부 개발자분들이 토스페이먼츠 MCP를 활용해서 주문서 페이지를 만드는 상황에 대한 예시입니다. 로딩이나 일부 수정 과정은 생략했고, Claude 4 모델을 사용했습니다.
구현 배경
토스페이먼츠는 출범 초기부터 어떻게 하면 고객사가 결제 시스템을 더 쉽게 연동할 수 있을지에 대한 고민이 많았습니다. 기존의 결제 시스템 연동 방식은 개발자에게 복잡하고 번거로운 작업이라는 인식을 풀어야 했기 때문이죠.
이 문제를 해결하기 위해 직관적인 API와 SDK를 새로 만들고, 토스페이먼츠 결제 시스템을 연동하는 개발자들을 위한 개발자 센터를 만드는 등 다양한 활동을 수행하였고, 개발자분들로부터 좋은 피드백을 받았습니다.
그렇지만 여전히 결제 시스템 연동의 어려움을 겪는 분들이 많이 계신데요, 개발팀이 따로 없는 작은 가맹점이나 외부에 개발을 맡긴 사업자분들이 특히 어려움을 겪고 있었습니다. AI 기반 코딩 도구를 활용하면 토스페이먼츠 연동을 더 쉽게 할 수 있지 않을까?하는 생각을 바탕으로 토스페이먼츠 연동 코드를 작성해 보았지만, 생성된 코드의 정확도가 많이 떨어지는 문제가 있었어요.
이 문제를 해결하기 위한 고민을 하던 중, AI 모델이 이해할 수 있는 맥락 정보를 제공할 수 있는 방법인 MCP(Model-Context Protocol)라는 개념이 등장해서 이를 활용해보기로 했습니다.
MCP란?

MCP는 엔트로픽에서 제안한, 인공지능 모델(LLM)이 다양한 상황과 맥락을 잘 이해할 수 있도록 돕는 표준적인 방법입니다. USB가 컴퓨터와 주변기기들을 간편하게 연결할 수 있는 표준을 만들었던 것처럼, MCP는 AI 모델과 다양한 환경이 자연스럽게 연결될 수 있도록 돕습니다. MCP 덕분에 하나의 서버에서 다양한 AI 툴(예: Cursor, Claude, Windsurf 등)을 편하게 사용할 수 있게 되는 것이죠. 저희는 MCP를 활용해, 개발자들이 더 쉽고 즐겁게 작업할 수 있는 '바이브 코딩' 환경을 만들려고 하고 있어요.
양질의 콘텐츠 준비하기
MCP에서 가장 중요한 건, AI가 잘 이해할 수 있는 콘텐츠를 전달하는 것입니다. 하지만 모든 제품에 대해 콘텐츠를 개별적으로 제작하는 건 시간과 비용이 너무 많이 들죠. 다행히 저희는 이미 MDX 기반의 개발자 센터를 운영하고 있었고, CI/CD 과정에 CDN에 배포되는 구조라서 MCP 서버가 해당 콘텐츠에 접근한다면 문서의 버전업을 그대로 따라가는 구조를 충족할거라 생각했습니다. 이를 기반으로 LLM이 잘 이해할 수 있도록 llms.txt
파일을 만들어 프로토타입을 빠르게 구축할 수 있었어요. 특히 이 과정에서 프론트엔드 챕터의 신지호님께서 큰 도움을 주셨습니다.
llms.txt는 대규모 언어 모델(LLM)이 웹사이트 콘텐츠를 더 잘 이해하고 상호 작용할 수 있도록 돕는 제안된 표준 파일이에요. 이 파일은 LLM이 웹사이트의 문서, 코드베이스 등과 어떻게 상호 작용해야 하는지에 대한 정보를 제공하여 LLM 기반 도구와 서비스의 효율성을 높이는 데 사용됩니다. * 참고: https://llmstxt.org/
MCP Transport 결정하기
처음에는 Websocket이나 SSE 기반의 Remote MCP도 고려했지만 로컬 기반의 MCP 서버를 구현하기로 결정했어요. 로컬 기반 MCP 서버를 사용하며 얻을 수 있었던 장점들은 아래와 같습니다.
첫 번째 버전
llms.txt에 기반하여 사전의 정의된 키워드 기반으로 문서를 검색하는 방식으로 구현하였습니다.


도구 목록
프로토타입은 아래 4개의 도구를 지원하였고, LLM은 이 도구들을 사용하여 토스페이먼츠 개발자 센터 문서를 탐색해요.
get-keywords
사전에 정의된 키워드 목록을 반환하는 도구입니다. LLM은 조회한 키워드 목록을 보고 사용자 질의를 분석하여 적절한 키워드를 추출 후 docs-by-keywords를 호출합니다.
documents-by-keywords
LLM 키워드를 적절하게 추출하여 질의하면, MCP 서버가 문서들을 순회하며 다양한 조건별 점수를 계산하고 상위 10개의 문서를 반환하는 도구입니다.
documents-by-link
Link 기반으로 문서를 탐색하는 도구입니다. LLM이 문서를 탐색하는 중에 Markdown 링크로 표현된 문서를 만나는 경우 탐색을 실시합니다.
document-by-id
MCP 서버 내부에서 채번한 ID를 기반으로 문서를 조회하는 도구입니다. get-keywords, documents-by-keywords, documents-by-link 도구를 사용하여 사용자 질의에 맞는 문서를 찾는 경우 해당 도구로 문서를 조회합니다.
결과 및 후기
첫 번째 버전은 사전에 정의된 키워드를 기반으로 문서를 검색하는 방식이었습니다. 하지만 실제 운영을 해보니 AI 모델이 자꾸 엉뚱한 결과(Hallucination)를 내거나, 중요한 정보를 놓치는 경우가 많았어요. LLM의 호출 빈도도 증가했죠.
문서별 길이가 천차만별이고, 길이가 긴 것은 5,000 라인이 넘어간다는 점이 원인으로 추측되었어요. 그런 문서들을 LLM이 여러 번 질의하면서 Checkpoint를 만들거나 대화내용을 요약하는 과정에서 중요한 정보들이 유실되는 현상으로 보여졌습니다. 때문에 새로운 방법을 고민해야했고 우리의 친구 Gemini, GPT와 이야기하며 다른 방법을 탐구했어요.
두번째 버전
Gemini와 많은 대담을 나누다가 BM25라는 키워드를 확인했고, 해당 방식으로 변경해보기로 했어요.

BM25는 Best Matching 25의 약자로, 확률적 정보 검색 모델인 Okapi BM25에서 유래했습니다. 이 모델은 특정 문서가 주어진 질의에 얼마나 관련이 있는지를 측정하는 방식으로 작동합니다.
BM25 사용하기
Javascript에는 Python처럼 잘 구현된 BM25가 없어서, GPT의 도움을 받아 빠르게 만들어 볼 수 있었습니다. 하지만 해당 코드를 바로 사용할 수 없었는데요, 이유는 한국어의 특징 때문이었어요.
예를 들어 다음 문장을 살펴 보겠습니다.
"BM25는 정보 검색에 사용되는 랭킹 함수입니다."
영어식으로 토큰화하면 아래와 같은 결과가 나옵니다.
["bm25는", "정보", "검색에", "사용되는", "랭킹", "함수입니다"]
한국어 문장을 일관성 있고 의미 있게 토큰화하기 위해서는 형태소 분석 라이브러리가 필요했어요. 그러나 형태소 분석 라이브러리는 Java나 C와 같은 추가적인 환경 설정을 요구하기 때문에, 이러한 외부 의존성들이 로컬 MCP의 사용성을 저하시킬 수 있다고 판단하여 배제하였습니다. 다행히도 이 문제는 보다 간단한 방식으로 해결이 가능했어요.
MCP 실행 환경에서는 모든 선택의 주체가 LLM입니다. 어떤 도구를 사용할지, 그리고 해당 도구를 어떤 파라미터로 실행할지는 전부 LLM이 결정하며, 사용자는 오직 도구 호출 여부만 선택하면 됩니다. 사용자의 질의를 LLM을 통해 유의미한 형태로 토큰화하고, 이 토큰화 된 질의에 정규식을 적용하여 매번 BM25 점수를 계산하면, 질문과 유사한 문서들을 효과적으로 색인할 수 있습니다.
하지만 이 방법만으로 모든 문제가 해결되는 것은 아닙니다. 한국어 기반 문서에서 BM25를 적용하는 아이디어는 명확히 떠올렸지만, 본질적인 문제는 문서의 크기가 크다는 점이에요. 따라서 문서를 유의미한 크기의 청크(Chunk)로 잘라내는 작업이 추가로 필요했습니다.
문서별 의미있는 Chunk 만들기
청크(Chunk)를 너무 세분화하는 경우 중요한 정보가 유실될 수 있으므로, 마크다운 Header (#, ##)를 기준으로 문서를 자른 다음 BM25를 기반으로 점수를 계산하도록 개선하였습니다.

마크다운을 파싱할 수 있는 라이브러리는 다양하지만, 저는 unist-util-visit, remark-parse, unified
세 가지 라이브러리를 활용하여 마크다운을 청크로 변환했습니다.
unified 와 remark-parse 를 통해 markdown 을 분석하여 AST 트리로 변환합니다.
const tree = unified().use(remarkParse).parse(markdown);
그 다음 unist-util-visit 을 사용하여 tree 를 순회합니다.
visit(tree, (node) => {
if (node.type === "heading" && node.depth <= 2) {
chunks.push(...)
}
...
});
위의 과정을 거쳐서 각 마크다운 별로 header(#, ##)를 기준으로 문서를 자를 수 있습니다.
마지막으로, 청크의 크기가 너무 작으면 유의미한 정보가 유실되거나 무의미한 데이터가 될 수 있으므로 일정 크기 이하의 청크는 병합하는 작업도 진행합니다.
export function joinShortChunks(chunks: string[], minWords = 30): string[] {
const result: string[] = [];
let buffer = "";
let bufferCount = 0;
for (const chunk of chunks) {
const wc = chunk.split(/\s+/).length;
if (wc < minWords) {
buffer += (buffer ? "\n\n" : "") + chunk;
bufferCount += wc;
continue;
}
if (buffer) {
result.push(buffer.trim());
buffer = "";
bufferCount = 0;
}
result.push(chunk.trim());
}
if (buffer) {
result.push(buffer.trim());
}
return result;
}
BM25에 정규식 및 청크 문서 적용하기
이제 준비는 끝났습니다. 청크를 관리하는 Document 클래스와 유저가 입력한 키워드를 정규식으로 변환하여 점수를 계산하는 Calculator 를 준비합니다.
export class TossPaymentsDocument {
private readonly chunks: DocumentChunk[] = [];
constructor(
private readonly keywordSet: Set<string>,
private readonly remoteMarkdownDocument: RemoteMarkdownDocument,
private readonly _version: string | undefined,
public readonly id: number
) {
remoteMarkdownDocument.chunks.forEach((chunk, index) => {
this.chunks.push({
id: this.id,
chunkId: this.id * 1000 + index,
originTitle: remoteMarkdownDocument.metadata.title,
text: chunk,
wordCount: chunk.split(/\s+/).length,
});
});
}
getChunkWithWindow(chunkId: number, windowSize: number): DocumentChunk[] {
const chunkIndex = this.chunks.findIndex(
(chunk) => chunk.chunkId === chunkId
);
if (chunkIndex === -1) {
return [];
}
const start = Math.max(0, chunkIndex - windowSize);
const end = Math.min(this.chunks.length, chunkIndex + windowSize + 1);
return this.chunks.slice(start, end);
}
getChunks(): DocumentChunk[] {
return this.chunks;
}
get content(): string {
return this.remoteMarkdownDocument.markdown;
}
get title() {
return this.remoteMarkdownDocument.metadata.title;
}
get version(): string | undefined {
return this._version;
}
get description() {
return this.remoteMarkdownDocument.metadata.description;
}
toString() {
return this.remoteMarkdownDocument.markdown;
}
toJSON() {
return {
version: this.version,
id: this.id,
title: this.title,
link: this.remoteMarkdownDocument.link,
description: this.description,
keywords: Array.from(this.keywordSet),
};
}
}
export class TossPaymentsBM25Calculator {
private readonly allChunks: DocumentChunk[];
private readonly totalCount: number;
private readonly averageDocLength: number;
private readonly N: number;
constructor(
documents: TossPaymentsDocument[],
private readonly k1: number = 1.2,
private readonly b: number = 0.75
) {
this.allChunks = documents.flatMap((doc) => doc.getChunks());
this.totalCount = this.allChunks.reduce(
(count, doc) => count + doc.wordCount,
0
);
this.averageDocLength = this.totalCount / this.allChunks.length;
this.N = this.allChunks.length;
}
calculate(keywords: string): Result[] {
const { termFrequencies, docFrequencies } =
this.calculateFrequencies(keywords);
const scores = this.calculateScore(termFrequencies, docFrequencies);
scores.sort((a, b) =>
b.score !== a.score ? b.score - a.score : b.totalTF - a.totalTF
);
return scores.map(({ id, score, chunkId }) => ({ id, chunkId, score }));
}
private calculateFrequencies(query: string) {
const pattern = new RegExp(query, "gi");
const termFrequencies: Record<number, Record<string, number>> = {};
const docFrequencies: Record<string, number> = {};
for (const doc of this.allChunks) {
const text = doc.text;
const matches = Array.from(text.matchAll(pattern));
const termCounts: Record<string, number> = {};
for (const match of matches) {
const term = match[0].toLowerCase();
termCounts[term] = (termCounts[term] || 0) + 1;
}
if (Object.keys(termCounts).length > 0) {
termFrequencies[doc.chunkId] = termCounts;
for (const term of Object.keys(termCounts)) {
docFrequencies[term] = (docFrequencies[term] || 0) + 1;
}
}
}
return { termFrequencies, docFrequencies };
}
private calculateScore(
termFrequencies: Record<number, Record<string, number>>,
docFrequencies: Record<string, number>
) {
return this.allChunks
.filter((chunk) => termFrequencies[chunk.chunkId])
.map((chunk) => {
const tf = termFrequencies[chunk.chunkId];
const len = chunk.wordCount;
const score = Object.keys(tf)
.map((term) => {
const df = docFrequencies[term];
const idf = Math.log((this.N - df + 0.5) / (df + 0.5));
const numerator = tf[term] * (this.k1 + 1);
const denominator =
tf[term] +
this.k1 * (1 - this.b + this.b * (len / this.averageDocLength));
return idf * (numerator / denominator);
})
.reduce((sum, v) => sum + v, 0);
const totalTF = Object.values(tf).reduce((sum, v) => sum + v, 0);
return {
id: chunk.id,
chunkId: chunk.chunkId,
score,
totalTF,
};
});
}
}
export interface Result {
id: number;
chunkId: number;
score: number;
}
이제 위의 클래스들을 Repository에서 적절하게 호출합니다.
여기서 getChunkWithWindow
함수에 대해 궁금할 수 있는데요. 단일 청크만 반환하는 경우에는 문맥(Context)이 부족하여 충분한 정보를 제공하기 어려울 수 있습니다. 각 청크는 헤더 단위로 분할되지만, 실제 질문에 대한 답변은 인접한 내용과 함께 있어야 온전한 의미를 전달할 수 있어요. 이를 해결하기 위해 getChunkWithWindow(chunkId, windowSize)
Method를 사용하여, 질의와 가장 유사한 청크 주변의 인접 청크들을 함께 묶어 LLM에 전달합니다. 이 방식은 LLM이 더 풍부한 맥락을 이해하도록 도와주며, 그 결과 Hallucination을 줄이고 응답 정확도를 향상시키는 데 효과적이에요.
export class TossPaymentDocsRepository {
private readonly documentV1BM25Calculator: TossPaymentsBM25Calculator;
private readonly documentV2BM25Calculator: TossPaymentsBM25Calculator;
constructor(private readonly documents: TossPaymentsDocument[]) {
const v1Documents = documents.filter(
(document) => document.version === "v1"
);
const v2Documents = documents.filter(
(document) => document.version === "v2"
);
const generalDocuments = documents.filter(
(document) => document.version == null
);
this.documentV1BM25Calculator = new TossPaymentsBM25Calculator(
v1Documents.concat(generalDocuments)
);
this.documentV2BM25Calculator = new TossPaymentsBM25Calculator(
v2Documents.concat(generalDocuments)
);
}
async findV1DocumentsByKeyword(
keywords: string[],
topN: number = 10
): Promise<string> {
const results = this.documentV1BM25Calculator.calculate(
keywords.map((keyword) => escapeRegExp(keyword.trim())).join("|")
);
const docs = results.slice(0, topN);
return this.toString(docs);
}
async findV2DocumentsByKeyword(
keywords: string[],
topN: number = 10
): Promise<string> {
const results = this.documentV2BM25Calculator.calculate(
keywords.map((keyword) => escapeRegExp(keyword.trim())).join("|")
);
const docs = results.slice(0, topN);
return this.toString(docs);
}
findOneById(id: number) {
return this.documents[id];
}
private toString(results: Result[]): string {
const docs = results
.map((item) => {
const document = this.findOneById(item.id);
return document.getChunkWithWindow(item.chunkId, 1);
})
.filter((item) => item !== undefined)
.map((items) => this.normalizeChunks(items));
return docs.join("\n\n");
}
private normalizeChunks(chunks: DocumentChunk[]): string {
return `## 원본문서 제목 : ${chunks[0].originTitle}\n* 원본문서 ID : ${
chunks[0].id
}\n\n${chunks.map((chunk) => chunk.text).join("\n\n")}`;
}
}
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $&는 일치한 전체 문자열을 의미합니다.
}
최종적으로, 아래와 같은 순서로 유저의 질의를 처리하는 기능 구현이 완료되었습니다.
.png)
도구 목록
get-v2-documents
get-v1-documents
document-by-id
MCP 서버 내부에서 채번한 ID를 기반으로 문서를 조회하는 도구입니다. get-v1-documents와 get-v2-documents에서 조회한 청크에서 더 자세하게 해당 문서를 탐색하고 싶은 경우 LLM이 호출합니다.
구현 후기
이번 구현에서는 다음과 같은 장단점을 얻을 수 있었어요.
이러한 경험을 바탕으로, 상황에 따라 적절한 방식과 전략을 선택하는 것이 중요하다는 점을 다시 한 번 느꼈습니다.
AI 기반 코딩 도구에 MCP를 조합하여 활용해 본 결과
개발한 MCP 서버를 활용하여 토스페이먼츠 결제 시스템을 연동할 경우, 어떤 개선이 발생하는지 확인이 필요했어요. 그래서 토스페이먼츠 연동과 관련한 기술적 문제 해결을 도와주시는 Technical Account Manager분께서 아래와 같은 조건으로 실험을 진행해주셨습니다.
테스트 조건
사용한 프롬프트 (모든 조건에 동일한 프롬프트 사용)
테스트 결과 요약
1. Cursor 단독 사용
이 경우 결제 연동의 시작점이 되는 Js SDK 주소와 같이 가장 기본이 되는 코드조차 올바르지 않게 생성되지 않는 결과를 도출했습니다. 결제 처리의 기본적인 Flow에 해당하는 인증 및 승인 흐름은 어느정도 맞게 생성해주었지만, 정확한 스펙에 맞는 코드 생성에 실패하고 경우에 따라 일부 로직 자체를 잘못 구현하는 모습도 보였어요.
2. Cursor + Docs Context 조합
맥락 정보를 추가로 제공했음에도 불구하고 Js SDK 주소를 정확히 확인하지 못했어요. 아이러니하게도 Cursor 단독 사용 케이스보다 추가적인 정보가 제공되었음에도 불구하고, 클라이언트(웹화면)에서 결제 승인 API를 호출하는 등 결제 처리의 기본적인 Flow에 어긋나는 구현을 하는 모습을 보였습니다. 또 클라이언트에 절대로 노출되면 안되는 Secret Key가 클라이언트 코드에 노출되도록 구현이 되었어요.
3. Cursor + MCP 조합
클라이언트 코드 생성, SDK 주소를 찾지 못하는 문제 개선, 세부 연동 스펙을 정확히 맞춘 코드 생성, 전체적인 결제 처리 흐름 상 문제 없는 코드가 생성됨을 확인할 수 있었어요.
특히, “승인 API 호출해줘” 프롬프트를 처리하는 과정에서 보안적으로 유리한 가이드를 제공해주는 것을 확인할 수 있었습니다. (Secret Key는 안전하게 보관되어야 하며, 외부에 노출되면 안되므로 승인 API는 반드시 백엔드 서버에서 호출해야 한다는 결과 출력)
마치며
로컬 기반 MCP를 직접 구현해보면서 여러 가지를 실험하고 고민해볼 수 있었는데요. 아직 부족한 점도 많고 개선할 부분도 분명 존재합니다. 예를 들어, 특정 키워드에 대해 BM25 점수를 가중치 기반으로 조정하거나, 개발 관련 질문과 기술 블로그(예: 토스페이먼츠 블로그)문서를 별도로 분리하고 카테고리화하여 도구를 구분하는 등의 방향으로 개선할 수 있을 것입니다. 이러한 내용들은 이미 백로그에 정리되어 있으며, 앞으로의 발전 가능성을 내포하고 있어요.
AI가 생성하는 코드가 100% 완벽하지는 않기에 당장 모든 어려움을 해소하기는 어렵겠지만, 앞으로 발전해 가면서 점차 더 많은 분들의 어려움을 낮추는데 도움이 될 거라 기대해요. 이번 경험이 저희와 유사한 고민을 하고 있는 분들께 실질적인 도움이 되었기를 바라며, 긴 글 읽어주셔서 감사합니다.