
사내 Oracle DB를 Claude한테 자연어로 물어볼 수 있게 만들고 싶었습니다. "회원 테이블 최근 가입자 몇 명이지?" 같은 질문을 사람이 SQL로 옮겨 적지 않고, Claude가 알아서 짜고 알아서 실행하게요. 그래서 사내 Oracle DB에 붙는 MCP 서버를 직접 만들었습니다.
MCP(Model Context Protocol)는 Anthropic이 공개한 프로토콜입니다. Claude 같은 AI 모델이 외부 시스템과 어떻게 대화할지를 정해놓은 약속이고, 모델이 사내 DB나 사내 위키, 깃 저장소 같은 곳에 닿게 해줍니다. 모델을 다시 학습시키지 않고, 모델에게 도구를 쥐여주는 방식이라고 이해했습니다.
이 MCP 도구를 만든 과정과 깨달은 점들을 포스팅하려 합니다. 처음에 설계했던 방향이 실제로 만들어 보니 어떻게 깨졌고, 어떻게 다시 설계했는지도 같이 적었습니다.
최종적으로 MCP에서 구현한 함수는 다섯 개입니다.
| Tool | 역할 |
|---|---|
list_schemas | 접근 가능한 스키마 목록 |
list_tables | 한 스키마의 테이블 목록 + 코멘트 + 예상 행 수 |
describe_table | 한 테이블의 컬럼 구조 + PK |
run_query | SELECT/WITH SQL 실행 → 마크다운 테이블 |
analyze_table | 테이블 기본 통계 분석 리포트 |
함수를 하나가 아니라 다섯 개로 나눈 이유부터 적어 보겠습니다.
처음 구조는 단순했습니다. run_query 하나. SQL을 받아서 결과를 돌려준다. 끝.
막상 돌려보니 문제가 있었습니다. Claude가 SQL을 짜려면 스키마를 알아야 했습니다. 어떤 테이블이 있고, 그 테이블에 어떤 컬럼이 있고, 어떤 자료형인지를 모르면 SQL을 못 짭니다. 한 번은 어떻게든 넘겨줘야 했죠.
문제는 그걸 통째로 넘기는 순간 컨텍스트가 늘어난다는 점이었습니다. 컬럼 코멘트와 PK까지 포함하면 테이블 한 개를 조회하는 데 수백 토큰이 들었습니다. 보통 사내 한 스키마에 테이블이 200개를 넘는 경우가 흔하니까, 그것만으로 수만 토큰이 한 번에 날아갑니다. 거기에 "회원 테이블에서…"라는 단순한 질문을 추가로 한다면 응답 한 번에 5만 토큰을 쓰는 셈입니다.
비용도 문제지만, 더 큰 문제는 그 컨텍스트가 사라지지 않는다는 점이었습니다. 같은 대화가 이어지는 동안 Claude는 한 번 받은 정보를 매 질문마다 그대로 싣고 갑니다. 첫 질문에서 스키마 전체를 받아 버리면, 두 번째 질문부터는 물어보지도 않은 테이블 200개의 정보까지 계속 끌고 다니는 겁니다.
그래서 방향을 바꿨습니다. 한 번에 다 주지 말자. 필요할 때 필요한 만큼만 가져가게 만들자.
전체를 한 번에 주는 대신, 함수를 필요에 맞게 세분화했습니다.
list_schemas는 스키마 이름만 줍니다.list_tables는 한 스키마의 테이블 이름에 예상 행 수와 코멘트를 얹어 줍니다.describe_table은 한 테이블의 컬럼 구조와 PK를 줍니다.세 함수를 부르는 순서가 핵심입니다. "회원 테이블에서…"라는 질문이 오면 Claude는 먼저 list_schemas로 스키마 목록을 봅니다. 그 다음 회원이 있을 법한 스키마 하나만 list_tables를 부릅니다. 그 안에서 후보 테이블이 추측되면 그것만 describe_table을 부릅니다. 나머지는 거들떠보지도 않습니다.
list_schemas가장 짧은 함수입니다. ALL_TABLES의 OWNER를 뽑아 돌려주는 게 전부입니다. 다만 환경변수 ALLOWED_SCHEMAS가 설정되어 있으면 그 안의 스키마만 보여줍니다.
// src/tools/schema.ts
export async function listSchemas(): Promise<string> {
const allowedSchemas = getAllowedSchemas();
let sql: string;
let binds: string[];
if (allowedSchemas.length > 0) {
const placeholders = allowedSchemas.map((_, i) => `:s${i}`).join(", ");
sql = `SELECT DISTINCT OWNER FROM ALL_TABLES WHERE OWNER IN (${placeholders}) ORDER BY OWNER`;
binds = allowedSchemas;
} else {
sql = `SELECT DISTINCT OWNER FROM ALL_TABLES ORDER BY OWNER`;
binds = [];
}
const result = await executeQuery(sql, binds);
const schemas = result.rows.map((r) => r[0] ?? "");
...
}
"왜 굳이 함수로 따로 뺐을까"라는 질문이 있을 수도 있습니다. Claude가 사내 스키마 이름을 다 알고 있는 게 아니라서입니다. 모델이 직접 짜는 SQL 안에 우리 회사 스키마 이름이 박혀 있을 리가 없죠. 그래서 모델이 처음 던지는 질문 하나는 거의 항상 list_schemas 호출입니다.
list_tables는 다음 호출을 줄입니다출력에 일부러 행 수와 코멘트를 같이 끼워뒀습니다.
// src/tools/schema.ts
const sql = `
SELECT
t.TABLE_NAME,
t.NUM_ROWS,
c.COMMENTS
FROM ALL_TABLES t
LEFT JOIN ALL_TAB_COMMENTS c
ON c.OWNER = t.OWNER AND c.TABLE_NAME = t.TABLE_NAME
WHERE t.OWNER = :schema
ORDER BY t.TABLE_NAME
`;
Claude가 코멘트만 보고 "이건 회원이 아니라 회원 로그 테이블이네"를 가려낼 수 있게 하려는 의도입니다. 코멘트가 있으면 describe_table을 한 번 덜 부릅니다. 행 수가 같이 보이면 "통계 테이블인지 운영 테이블인지"를 다시 가늠해 볼 수 있고요.
반대로 컬럼은 일부러 안 줬습니다. "그쯤은 그냥 다 보여 줘도 되지 않나"라는 생각도 들었지만, 한 스키마에 테이블이 200개를 넘으면 테이블당 컬럼 몇 줄이 곧 수만 토큰이 됩니다. 정보를 후하게 줄수록 토큰은 정확히 그만큼 나갑니다.
describe_table이 PK까지 안고 가는 이유테이블 컬럼을 보여줄 때 PK 정보를 같이 끼운 건, Claude가 WHERE 조건을 잡거나 JOIN을 짤 때 PK를 알아야 하기 때문입니다. 다만 Oracle은 PK 정보가 ALL_CONSTRAINTS와 ALL_CONS_COLUMNS에 흩어져 있어서, 컬럼 조회와는 별도의 쿼리가 필요했습니다.
// src/tools/schema.ts
const pkSql = `
SELECT cc.COLUMN_NAME
FROM ALL_CONSTRAINTS c
JOIN ALL_CONS_COLUMNS cc
ON cc.OWNER = c.OWNER
AND cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME
WHERE c.OWNER = :schema
AND c.TABLE_NAME = :table
AND c.CONSTRAINT_TYPE = 'P'
ORDER BY cc.POSITION
`;
const pkResult = await executeQuery(pkSql, [upperSchema, upperTable]);
const pkColumns = new Set(pkResult.rows.map((r) => r[0] ?? ""));
처음에는 컬럼 조회 SQL 안에 PK를 같이 묶어보려고 시도했습니다. OUTER JOIN으로 한 번에 가져오면 깔끔하다고 생각했는데, 막상 짜 보니 시스템 뷰 조인 조건이 꼬여서 NULL이 잘못 끼는 케이스가 생겼습니다. PK는 그냥 별도 쿼리로 가져온 다음, 메모리에서 Set으로 매칭하는 단순한 방식으로 돌아왔습니다. 두 번 쿼리를 날리는 쪽이 한 번에 묶다가 틀리는 것보다 결과적으로 나았습니다.
run_query 하나만으로는 부족한 순간이 또 있었습니다. 천만 행짜리 테이블의 분포를 알고 싶을 때 SELECT *를 돌릴 수는 없습니다. Claude는 "테이블에 데이터가 어떻게 흩어져 있는지"가 궁금한데, 그걸 보려고 행을 직접 받으면 그 자체로 컨텍스트가 터집니다.
두 개로 나눴습니다.
analyze_table은 통계만 줍니다. 컬럼별 최솟값·최댓값·평균·고유값 수준입니다.run_query는 실제 데이터를 줍니다. 대신 MAX_ROWS(기본 500)로 자르고, 잘리면 경고를 같이 돌려줍니다.analyze_tableanalyze_table은 토큰 관점에서는 가장 위험한 함수입니다. 컬럼이 많은 테이블에 잘못 호출하면 통계 결과만으로도 컨텍스트가 부풀어 오릅니다. 함수 자체에 안전장치를 걸어둔 이유입니다.
// src/tools/analyze.ts
const targetNumbers = numbers.slice(0, 5);
const targetDates = dates.slice(0, 3);
const targetVarchars = varchars.slice(0, 3);
숫자 컬럼 5개, 날짜 3개, 문자 3개까지만 분석합니다. 컬럼 60개짜리 테이블이 들어와도 출력은 11줄 안에서 끝납니다.
내부적으로는 컬럼 타입별로 다른 통계 쿼리를 돌립니다.
// src/tools/analyze.ts (NUMBER 컬럼 통계)
const sql = `
SELECT
MIN(${qCol}),
MAX(${qCol}),
ROUND(AVG(${qCol}), 4),
COUNT(DISTINCT ${qCol})
FROM ${tableRef}${where}
`;
// src/tools/analyze.ts (DATE 컬럼 통계)
const sql = `
SELECT
TO_CHAR(MIN(${qCol}), 'YYYY-MM-DD HH24:MI:SS'),
TO_CHAR(MAX(${qCol}), 'YYYY-MM-DD HH24:MI:SS')
FROM ${tableRef}${where}
`;
where_clause를 선택 인자로 받게 한 것도 작은 디테일입니다. "특정 회원군만 분석"이라는 요구가 자주 들어왔습니다. WHERE 절을 외부에서 끼울 수 있게 열어 두니 MCP 도구로 할 수 있는 일이 다양해졌습니다. 다만 이 인자는 검증 없이 문자열 그대로 SQL에 끼어 들어갑니다. 드라이버가 한 번에 한 문장만 실행하니 세미콜론으로 DML을 이어 붙이는 주입은 통하지 않지만, 그래도 마음이 아주 편하지는 않아서 여기에 가드를 붙이는 걸 다음 숙제로 적어 뒀습니다.
여기서 한 가지 고민이 있었습니다. 숫자 5개, 날짜 3개라는 숫자가 적당한가. 결론적으로 부족할 수 있습니다. 처음에는 10/5/5로 시작했다가, 실제로 사용해 보니 출력이 너무 길어서 다시 줄였습니다. 그리고 부족하면 Claude가 알아서 메웁니다. 11번째 숫자 컬럼이 궁금하면 그 컬럼만 run_query로 SELECT MIN, MAX, AVG를 직접 짜서 던집니다. 함수가 모든 경우를 다 커버하지 않아도, Claude가 알아서 조합한다는 걸 도구를 쓰면서 알게 됐습니다.
MCP를 설계하면서 이게 제일 신기했습니다. 모든 가능성을 함수로 다 만들 필요는 없습니다. 빈틈은 모델이 채웁니다.
run_query가 돌려줍니다run_query는 진짜 SQL을 받아서 실행합니다. 결과는 마크다운 테이블로 변환해서 돌려주고, 행 수가 MAX_ROWS에 도달하면 경고 문구를 끼워 줍니다.
// src/tools/query.ts
const result = await executeQuery(sql, [], maxRows);
const parts: string[] = [];
if (result.rowCount === 0) {
parts.push("쿼리 결과가 없습니다.");
} else {
parts.push(formatMarkdownTable(result.columns, result.rows));
parts.push(`\n**조회된 행 수:** ${result.rowCount}행`);
if (result.rowCount >= maxRows) {
parts.push(
`\n> ⚠️ **경고:** 최대 조회 행 수(${maxRows}행)에 도달했습니다. 결과가 잘려있을 수 있습니다.`
);
}
}
parts.push(`\n**실행된 SQL:**\n\`\`\`sql\n${sql.trim()}\n\`\`\``);
응답에 실행된 SQL을 함께 넣은 건, 사용자가 Claude의 응답만 보고 결과를 신뢰하기 어렵기 때문입니다. "그래서 어떤 SQL이 돌았는데?"가 자연스러운 다음 질문이고, 그걸 매번 묻지 않게 하려고 도구 응답에 SQL을 같이 박아 두었습니다.
NULL 처리도 신경을 썼습니다. Oracle의 NULL을 그대로 JSON 직렬화하면 null로 가 버려서 사람이 봤을 때 헷갈립니다. 그래서 마크다운 셀에 "NULL"이라고 명시적으로 찍습니다. 사람이 결과를 볼 때 NULL과 빈 칸을 헷갈리지 않게 하려는 의도였습니다.
Claude는 똑똑하지만, 가끔
DELETE도 짭니다.
농담 같지만 실제로 일어났습니다. 제가 "회원 테이블 청소하는 SQL 좀 짜 줘"라고 했더니 Claude가 자연스럽게 DELETE 쿼리를 짜서 run_query로 던졌습니다. 이 도구가 물고 있는 DB 계정은 SELECT 전용이 아니라 모든 명령을 실행할 수 있는 계정입니다. 코드에서 막지 않았다면 그대로 돌았을 겁니다. 미리 걸어 둔 읽기 전용 가드가 그 쿼리를 잡아냈고, 이 가드를 괜히 만든 게 아니라는 걸 그날 확인했습니다.
run_query는 SELECT 또는 WITH로 시작하는 SQL만 통과합니다. 그 외에는 즉시 에러. 정규식 두 개로 끝납니다.
// src/tools/query.ts
const FORBIDDEN_PATTERN =
/^\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|EXEC|EXECUTE|ALTER|CREATE|GRANT|REVOKE|MERGE|CALL|BEGIN|DECLARE)\b/i;
const ALLOWED_PATTERN = /^\s*(SELECT|WITH)\b/i;
function validateSql(sql: string): void {
const trimmed = sql.trim();
if (!ALLOWED_PATTERN.test(trimmed)) {
throw new Error(
"SELECT 또는 WITH 구문만 실행할 수 있습니다. DML/DDL 문은 허용되지 않습니다."
);
}
if (FORBIDDEN_PATTERN.test(trimmed)) {
throw new Error(
"허용되지 않는 SQL 구문입니다. SELECT 또는 WITH 구문만 실행 가능합니다."
);
}
}
ALLOWED_PATTERN만 두고 끝낼 수도 있었지만, 굳이 FORBIDDEN_PATTERN도 함께 두었습니다. 둘 다 문장 시작만 검사하니 방어 범위가 넓어지는 건 아닙니다. 대신 코드를 읽는 사람이 "여기서 이런 키워드는 절대 안 통과한다"를 한눈에 보게 되고, 나중에 누가 허용 패턴을 느슨하게 고치더라도 금지 목록이 한 번 더 걸러 줍니다.
사실 더 확실한 방법은 DB 계정 자체를 SELECT 권한만 받는 겁니다. 다만 사내에서 계정 권한을 분리받는 건 별도 절차라 아직 진행하지 못했고, 지금은 이 코드 단 가드가 유일한 방어입니다. 그래서 더 보수적으로 잡았습니다. 계정 권한 분리는 다음 과제로 남겨 뒀습니다. 그게 갖춰지면 한쪽이 뚫려도 다른 쪽이 막아 주는 이중 방어가 됩니다.
한 단계 더 추가합니다. 환경변수 ALLOWED_SCHEMAS에 적힌 스키마만 접근을 허용합니다. 비우면 전체 허용이고, 채워 두면 그 외 스키마는 SQL을 만들기도 전에 막힙니다.
// src/db/oracle.ts
export function getAllowedSchemas(): string[] {
const raw = process.env.ALLOWED_SCHEMAS ?? "";
return raw
.split(",")
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
}
export function isSchemaAllowed(schema: string): boolean {
const allowed = getAllowedSchemas();
if (allowed.length === 0) return true;
return allowed.includes(schema.toUpperCase());
}
list_tables / describe_table / analyze_table은 호출 첫 줄에서 이 함수를 부릅니다.
// src/tools/schema.ts
export async function listTables(schemaName: string): Promise<string> {
if (!isSchemaAllowed(schemaName)) {
throw new Error(
`스키마 '${schemaName}'에 대한 접근 권한이 없습니다. ` +
`허용된 스키마: ${getAllowedSchemas().join(", ")}`
);
}
...
}
도메인 단위로 풀어줄 수 있게 만든 이유는 단순합니다. 회원 도메인을 들여다보고 싶은 사람한테 굳이 회계 스키마까지 열어 줄 필요가 없으니까요. 사람마다 환경변수를 다르게 설정해서, 권한 범위를 다르게 가져갈 수 있습니다.
ORA-00942 같은 Oracle 에러 메시지를 한국말로 다듬지 않고 그대로 위로 던집니다.
처음에는 다듬으려고 했습니다. "테이블이나 뷰가 존재하지 않습니다"라고 친절하게요. 그런데 그렇게 했을 때 Claude가 다음 행동을 잘 잡지 못했습니다. 같은 한국말 문구를 받았을 때 모델이 "그래서 뭘 다시 부르라는 거지?"를 결정하기 어려워했죠.
ORA-00942가 그대로 가야 Claude가 다음 행동을 정확히 잡습니다. "테이블 이름을 잘못 추측했구나, list_tables부터 다시 부르자"로 이어집니다. ORA-00904면 "컬럼 이름을 틀렸구나, describe_table을 다시 부르자"가 됩니다. Claude는 Oracle 에러 코드를 학습한 상태로 들어옵니다. 사람이 보기 좋게 다듬는 순간 그 학습된 신호가 사라집니다. 굳이 통역할 필요가 없습니다.
도구를 만들면서 이 발견이 제일 재미있었습니다. 친절함의 기준이 사람과 AI가 서로 다릅니다. 인터페이스를 설계할 때 누구를 위한 것인지부터 다시 생각하게 됐습니다.
만들어 놓고 쓰는 사람이 나 혼자면, 그건 사내 도구가 아니라 사이드 프로젝트입니다. 처음 만들었을 때 README에는 "git clone → npm install → npm run build → 설정 파일 편집 → 절대 경로 박기 → ..." 같은 7~8단계 절차가 있었습니다. 동료 한 명에게 보여줬더니 두 번째 단계에서 막혔습니다. 노드 버전이 18 미만이라고 했고, 다른 사람은 npm 권한 문제로 막혔고, 또 다른 사람은 사내 프록시 때문에 막혔습니다. 거기서 알았습니다. 내 노트북에서 잘 도는 건 의미가 없습니다.
oracledb 라이브러리 v6부터는 기본이 Thin 모드입니다. initOracleClient()를 부르지 않으면 Oracle Instant Client 자체가 필요 없습니다. 순수 자바스크립트로 DB와 통신합니다.
// src/db/oracle.ts
import oracledb from "oracledb";
// oracledb v6 기본값은 Thin 모드 — initOracleClient() 미호출 시 Oracle Client 불필요
Thick 모드(Instant Client 사용)가 성능이나 일부 기능에서 더 낫다는 건 압니다. 다만 Instant Client는 OS별로 따로 받아야 하고, 환경변수 설정이 필요하고, 깔다가 막히는 사람이 한 명이라도 나오면 그게 진입장벽이 됩니다. 이번 도구에서 Thick의 추가 성능은 필요 없었습니다. 한 사람이 자연어로 한 번 질문하는 트래픽이 전부였으니까요.
처음에는 "성능이 더 좋다는데 그냥 Thick으로 가자"는 마음도 있었습니다. 그쪽이 더 그럴듯해 보였거든요. 그런데 동료한테 가이드를 적어 보니, Instant Client 설치 안내만 한 페이지 가까이 됩니다. 그걸 읽고 따라할 사람은 거의 없습니다. 단단해 보이는 선택이 항상 옳지는 않다는 걸 그때 다시 느꼈습니다.
도구를 동료한테 넘기는 가장 깔끔한 방법은 npm install -g였습니다. 그런데 Windows + npm 11 조합에서 의존성 설치가 자꾸 ENOENT로 깨졌습니다. 같은 명령어, 같은 코드인데 누구는 되고 누구는 안 됐습니다.
며칠 동안 노드 버전, npm 버전, 사내 프록시 설정, npm 캐시 위치를 다 뒤졌습니다. 결국 npm 11이 글로벌 패키지의 nested 의존성을 설치할 때 임시 디렉토리 처리에서 깨지는 버그라는 결론을 내렸습니다. 라이브러리 문제라 우리가 고칠 수는 없었습니다. 그래서 우회로를 세 가지 깔았습니다.
prepare 스크립트를 추가해서, git에서 직접 설치할 때 자동으로 빌드되게 했습니다.bundledDependencies로 oracledb / SDK / dotenv를 패키지 안에 묶었습니다. npm이 nested install에서 실수해도 핵심 패키지는 따라옵니다.prepare가 실패해도 미리 빌드된 결과물이 남아 있게요.// package.json (발췌)
{
"scripts": {
"prepare": "node -e \"if(!require('fs').existsSync('dist/index.js')){require('child_process').execSync('node node_modules/typescript/bin/tsc',{stdio:'inherit'})}\""
},
"bundledDependencies": [
"oracledb",
"@modelcontextprotocol/sdk",
"dotenv"
]
}
세 가지 모두 npm의 정상 동작을 못 믿어서 둘러친 방어막입니다. 이상적이진 않죠. dist를 git에 같이 넣는 건 라이브러리 관행상으로는 권장되지 않습니다. bundledDependencies도 패키지 용량을 키웁니다. 다만 사내에서 누가 어떤 노드/npm 조합을 깔고 있을지 모르는 상황에서, 제가 보장할 수 있는 건 단 하나, "어쨌든 설치는 된다"였습니다.
이 구간이 가장 답답했습니다. 코드 자체는 다 짜 놨는데, 동료한테 넘기는 단계에서만 한 주가 더 갔으니까요.
마지막에는 OS별 설치 스크립트로 묶었습니다. Windows는 PowerShell, Mac/Linux는 bash. 동료가 복사해서 붙여 넣으면 끝납니다.
# Mac/Linux
curl -fsSL 'https://gitlab.konai.com:8081/.../scripts/install.sh' | bash
# Windows (PowerShell)
irm 'https://gitlab.konai.com:8081/.../scripts/install.ps1' | iex
스크립트 자체는 단순합니다. git에서 받아서, 적당한 위치에 풀어 놓고, Claude Desktop 설정 파일에 자동으로 등록까지 하는 정도입니다. 그런데 이 "한 줄"이 동료들이 가장 적게 막힌 형태였습니다. 한 줄이면 일단 붙여 넣기는 합니다. 일곱 단계짜리 README는 끝까지 읽지 않습니다.
도구를 한 번 깔고 끝나지 않습니다. 함수가 바뀌거나 보안 패치가 들어가면 동료도 받아야 합니다. 그렇다고 "업데이트 받으세요"라는 메시지를 매번 슬랙으로 보낼 수는 없죠. 서버가 시작될 때 GitLab의 package.json을 한 번 조회해서 버전을 비교하게 했습니다.
// src/index.ts (발췌)
const CURRENT_VERSION: string = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
).version;
const UPDATE_CMD =
process.platform === "win32"
? `powershell -ExecutionPolicy Bypass -Command "irm '...install.ps1' | iex"`
: `curl -fsSL '...install.sh' | bash`;
async function checkForUpdates(): Promise<void> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
const res = await fetch(GITLAB_PKG_URL, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return;
const pkg = (await res.json()) as { version?: string };
if (pkg.version && pkg.version !== CURRENT_VERSION) {
process.stderr.write(
`[Kona-db-MCP] 업데이트가 있습니다 (현재: ${CURRENT_VERSION} → 최신: ${pkg.version}).\n` +
`아래 명령어를 터미널에서 실행하세요:\n${UPDATE_CMD}\n`
);
}
} catch {
// 네트워크 불가 시 무시
}
}
세 가지 디테일을 챙겼습니다.
작은 디테일이지만, 도구가 "한 번 만들고 끝난 결과물"이 아니라 "계속 살아 있는 도구"로 보이게 한 부분입니다. 도구를 만든 사람이 옆에 없어도, 도구 스스로 "지금 너 옛날 버전이야"라고 말해 줍니다.
함수는 잘게 쪼개는 게 토큰에 친절합니다. "한 함수에 다 담으면 깔끔하다"는 직관은 사람용 API에는 맞지만, AI용 도구에는 정반대로 작용합니다. AI는 자기가 필요한 만큼만 부릅니다. 그 "필요한 만큼"을 잘게 정의해 둘수록 컨텍스트는 작아집니다. 함수가 모든 경우를 다 커버하지 않아도 됩니다. 빈틈은 모델이 채우니까요.
AI에게 도구를 쥐여주는 일은 결국 인터페이스 설계였습니다. "이 함수를 AI가 어떤 순서로, 어떤 단서를 보고 부를까"를 먼저 상상해야 합니다. list_tables 출력에 코멘트를 끼워둔 이유도, analyze_table에 컬럼 수 제한을 둔 이유도 같은 질문에서 출발했습니다. 에러를 다듬지 않은 것도 마찬가지입니다. ORA-00942는 Claude에게 다음 행동을 알려주는 살아 있는 신호고, 사람 보기 좋게 통역하는 순간 그 신호가 죽습니다.
핵심 로직은 며칠이면 끝났는데, 동료에게 넘기는 데는 한 주가 더 걸렸습니다. 처음에는 이 시간이 아깝게 느껴졌습니다. 돌이켜 보면 그 한 주가 도구를 "내 도구"에서 "우리 도구"로 만든 시간이었습니다.
이 도구를 만든 다음, 사내에서 60분 세미나로도 풀어 봤습니다. 발표를 준비한 과정과 발표 이후의 회고는 MCP 세마나 회고록
에 따로 정리해 두었습니다.