이 페이지가 담는 것
이력서에는 “MCP 서버를 OAuth 2.1 보호 리소스로 구현했다” 한 줄이 들어간다. 그 한 줄이 생략하는 것은 무엇을 고르려다 말았는지, 무엇이 깨져서 다시 했는지, 어떤 값을 보고 정했는지다. 아래는 그 부분이다.
왜 인앱 AI를 만들지 않았나
가계부에 AI를 붙이는 가장 흔한 방식은 앱 안에 채팅 화면을 두는 것이다. 이걸 하지 않기로 정했다.
고른 것: 앱은 MCP 서버로 구조화된 데이터만 내보내고, 그 데이터를 어떻게 보여줄지는 사용자가 이미 쓰는 AI 클라이언트에 맡긴다. 도구 등록은 server.registerTool 하나이고 응답은 content 텍스트와 structuredContent JSON 두 겹이다.
접은 것
- 인앱 AI 채팅: 채팅 UI와 대화 상태 관리를 새로 만들어야 하고, 사용자는 도치야 전용 채팅과 원래 쓰던 에이전트를 오가게 된다.
- ChatGPT Apps SDK의 인라인 위젯:
@modelcontextprotocol/ext-apps로 대화 안에 React 위젯을 띄우는 경로다. 위젯 빌드 파이프라인이 하나 더 생기는데, 그것이 특정 클라이언트 한 곳에서만 값을 낸다.
치른 대가: 데이터를 어떻게 보여줄지를 앱이 통제하지 못한다. 연결된 에이전트가 structuredContent를 빈약하게 시각화해도 앱 쪽에서 보정할 수단이 없다.
MCP 엔드포인트 하나가 두 스펙을 함께 받는다
2026년 8월에 SDK를 @modelcontextprotocol/sdk 1.x에서 @modelcontextprotocol/server 2.0.0으로 교체했다. 이 시점에 클라이언트가 두 갈래로 갈려 있었다. 신 스펙 클라이언트는 server/discover로 협상하고 구 스펙 클라이언트는 initialize로 협상한다. 커넥터 등록에 필요한 협상 메서드를 토큰 없이 허용하는 목록에 둘 다 넣지 않으면, 어느 한쪽은 등록 단계에서 401부터 맞는다.
src/app/mcp/route.ts의 토큰 없이 허용하는 목록은 server/discover, initialize, notifications/initialized, ping, tools/list 다섯 개다. 나머지 요청은 전부 Bearer 토큰을 검증한다.
SDK 안을 읽고 정한 것
createMcpHandler의 동작을 문서가 아니라 배포된 번들에서 확인하고 정한 항목들이다. 모두 @modelcontextprotocol/server 2.0.0 기준이고 2026-08-12에 확인했다.
parsedBody를 넘기면 SDK가 요청을 clone하지도 읽지도 않는다.dist/index.mjs:1055의if (parsedBody === void 0)분기 안에 clone과request.text()가 둘 다 들어 있다. 그래서 라우트에서 이미 파싱한 바디를 그 옵션으로 넘긴다.- 415와 406 게이트는 원본 요청 헤더에서 돌고
parsedBody보다 먼저 실행된다. 구 스펙 leg의 406은Accept에application/json과text/event-stream이 둘 다 있어야 통과시킨다(dist/index.mjs:631). 그래서 라우트가 헤더를 정규화한 Request를 새로 만들어 넘긴다. 이 정규화를 지우면 ChatGPT가 보내는 요청이 그 자리에서 막힌다. - 엔트리는 토큰 검증을 하지 않는다. SDK 주석이 “The entry performs no token verification”이라고 적는다(
dist/createMcpHandler-CLhGwQTn.d.mts:4036). 검증도 401 응답도 우리 몫으로 남는다. - SDK가 내부 예외를 잡아 합성 500을 내보내고 원본은 아무 데도 남지 않는다.
createMcpHandler에onerror훅을 등록해 원본 오류를 기록한다.
접은 대안
legacy: 'reject'로 신 스펙 전용 엔드포인트 운영: 아직 전환하지 않은 클라이언트의 연결이 끊긴다.- 구 SDK를 병행 라우팅: 구 스펙 응답의 Content-Type을
application/json으로 유지할 수 있지만, 두 SDK를 동시에 의존하고 도구 등록 코드를 두 벌 유지해야 한다. 얻는 것이 응답 봉투 하나뿐이다. responseMode: 'json'으로 되돌리기: 이 옵션은 신 스펙 leg 전용이다. 구 스펙 폴백 경로에는 켤 수단이 없다.- 전송 계층 테스트에서 도구 핸들러까지 대역으로 교체: 그러면 SDK가 인자를 스키마로 거르고 핸들러에 넘기는 구간이 통째로 빠진다. SDK 버전이 올라갈 때 가장 먼저 깨지는 곳이 검증 대상에서 사라진다.
남은 대가: 구 스펙 클라이언트가 받는 응답 봉투가 application/json에서 text/event-stream으로 바뀌었고 되돌릴 수단이 SDK에 없다. 신 스펙 프로토콜 버전을 상수로 받을 길이 없어 테스트가 "2026-07-28" 문자열을 직접 갖는다.
도구 인자에서 UUID를 걷어냈다
처음에는 MCP 쓰기 도구가 계정과 금고를 UUID로 받았다. 사람이 아니라 에이전트가 그 인자를 채우는데, 에이전트에게 UUID를 다루게 하면 정확도가 떨어진다.
고친 것(커밋 3c0465e5, 2026-08-15)
- 쓰기 도구가 계정과 금고를 이름 문자열로 받는다.
from: "카카오뱅크",to: "식비"형태다. - 이름을 식별자로 쓰려면 유일해야 하므로
accounts에(vault_id, lower(trim(name))),vaults에(owner_id, lower(trim(name)))부분 unique 인덱스를 걸었다. 조건은where deleted_at is null이다. - 이름 해석에 실패하면 후보를 담은 오류를 낸다. 맞는 것이 없을 때와 여러 개일 때를 다른 문구로 가르고, 하나를 임의로 고르지 않는다.
- UUID는 조회 도구의 반환에만 남는다. 웹 UI의 링크와 내부 참조는 계속 UUID를 쓴다.
이 변경으로 함께 없앤 것이 있다. upsert_snapshot이 accountId와 accountName 두 인자를 동시에 받고 동명이 있으면 임의로 하나를 고르던 경로다.
치른 대가: 금고 이름이 갈리면 vault를 받는 도구가 전부 막히는데, 금고 이름을 바꿀 MCP 도구가 없어 웹 UI로 가야 복구된다.
테스트를 개수가 아니라 판정 기준으로 다시 세웠다
AI에 테스트 작성을 맡기면서 개수는 늘었는데, 늘어난 것이 실행 시간뿐인 구간이 생겼다. 2026-09-20에 한 번 갈아엎었다.
감사에서 드러난 것
유닛 감사는 “같은 판정이 통합에 있으니 유닛을 지우자”로, 통합 감사는 “같은 판정이 유닛에 있으니 통합을 지우자”로 같은 3쌍을 각각 삭제 후보에 올렸다. 둘 다 적용했으면 그 판정의 커버리지가 0이 된다. 대차평형 거부 판정이 그랬다(tests/integration/compound-transactions.test.ts의 describe("복합 분개 검증") 3건과 src/lib/transaction-utils.test.ts:185, :191).
어느 쪽이 정본인지를 문서가 정하지 않으면 정리할 때마다 이 충돌이 다시 난다. 그래서 정리보다 먼저 경계를 docs/decisions/test-level-boundaries.md에 고정했다.
정한 경계
- 레벨은 무엇을 건드리는가로만 나눈다. unit은 함수 입출력, integration은 로컬 Supabase 실 DB, e2e는 브라우저, docs는 파일 시스템이다. 다른 기준으로 다섯 번째 자리를 만들지 않는다.
- DB에 닿기 전에 끝나는 판정은 unit이 갖는다. 계산식, 분기, 스키마 검증, 포맷 변환이다. 그 판정을 통합에서 다시 확인하지 않는다.
- 둘이 겹치면 통합을 지운다. 같은 식을 확인하는 데 fixture 생성과 트랜잭션 비용을 내고, 입력 조합을 늘리기도 어렵다.
- 양쪽을 동시에 지우지 않는다. 한쪽을 지우는 근거가 다른 쪽의 존재일 때, 지우기 전에 남는 쪽을 열어 같은 사실을 같은 강도로 단언하는지 확인한다.
실제로 한 일
- 레벨 사이와 파일 사이의 중복 단언, 타입 검사기가 이미 막는 타입 단언, 분기 없는 글루 코드의 mock 테스트를 지웠다.
- MCP 어댑터 테스트를 mock에서 실 DB로 옮기고, 핸들러가 테스트 트랜잭션에 참여하도록
src/db/client.ts에setDbForTest주입 지점을 만들었다. toBeTruthy()와 인자 없는rejects.toThrow()로 끝나던 단언을 구체적인 필드 값과 오류 문장 비교로 바꿨다.- 레벨 오배치로 판정된 13건은 순수 함수 테스트로 내렸다. 통합이 942건에서 850건으로 줄었다.
timestamp컬럼의mode: "string"검사처럼 테스트가 아니라 린터가 할 일은biome-plugins/timestamp-mode-string.grit으로 옮겼다.
커밋 740438f0 한 건에서 163개 파일이 바뀌어 3,894줄이 추가되고 9,371줄이 삭제됐다.
기각한 정리 방식
- 통합이 판정을 갖고 unit을 지운다: 실제 DB를 거친 결과가 더 믿을 만하다는 이유인데, 판정 대상이 DB와 무관하다. 요금 계산식은 입력만으로 결과가 정해져 DB를 거쳐도 확인되는 사실이 같고, 조합마다 fixture가 필요해 경계값을 촘촘히 보기도 어렵다. 실제로 이번 정리에서 부동산 요금 판정은 통합 쪽의 조합 수가 유닛보다 적었다.
- 시나리오 커버리지를 ratchet으로 강제: 2026-08-20 시점 시나리오 2건인데 강제할 대상이 86건이었다. 미달을 실패로 만들면 그 실패를 끄는 baseline 파일만 남는다.
- Gherkin과 Cucumber 도입: 여정을 별도 문법으로 적으면 단계 정의와 구현 사이에 매핑 레이어가 생긴다. 지금 필요한 것은 단계 라벨과 요구사항 태그 둘뿐이고 둘 다 함수 두 개로 된다.
통합 테스트를 격리하는 방식
통합 테스트 61개 파일이 로컬 Supabase 한 대를 공유한다. 파일마다 DB를 지우고 다시 만들면 느리고, 지우지 않으면 앞 파일의 데이터가 샌다.
tests/setup.ts가 파일당 바깥 트랜잭션 하나를 열어 두고, 테스트마다 그 안에 SAVEPOINT를 연다. afterEach가 ROLLBACK TO SAVEPOINT로 그 테스트의 변경분만 되돌리고, afterAll이 바깥 트랜잭션을 롤백해 fixture까지 되돌린다. 디스크 write가 없고 파일 간 커밋이 새지 않는다.
모든 읽기와 쓰기가 같은 reserved 커넥션의 같은 트랜잭션 핸들에서 실행되어야 자기가 쓴 값을 자기가 읽을 수 있다. 그래서 withTx(fn)이 현재 SAVEPOINT를 쓰고, 없으면 바깥 트랜잭션을 쓴다.
아직 못 한 것
tools/list외에 캐시 가능한 결과(server/discover등)에는 캐시 힌트를 두지 않았다. 그 결과가 얼마나 자주 조회되는지 관측한 뒤 정한다.- 헤더 정규화와 커스텀 401이 라우트에 남아, SDK 안에서 도는 게이트와 라우트의 게이트가 두 곳으로 나뉜 채다.