당신의 데이터베이스 MCP는 당신에게 거짓말을 하고 있습니다
요약
MCP를 사용하는 에이전트가 JSONB 데이터 구조를 정확히 파악하지 못해 잘못된 SQL 쿼리를 생성하고, 결과적으로 잘못된 데이터를 보고하는 문제를 다룹니다. 잘못된 키 추측이 에러가 아닌 NULL을 반환하여 피드백 루프가 끊어지는 현상을 분석합니다.
핵심 포인트
- JSONB 타입의 구조적 불투명성이 에이전트의 쿼리 오류를 유발함
- 잘못된 키 추측이 에러 대신 NULL을 반환하여 피드백 루프를 차단함
- 모델이 스스로 오류를 수정할 수 있는 신호(Signal)가 부족한 것이 핵심 문제임
제가 한 번 이상 겪었던 대화 내용입니다.
나: 빠른 배송(express delivery)을 사용하는 주문이 몇 건인가요?
에이전트 (Agent): _(
SELECT count(_) FROM orders WHERE details ->> 'express_delivery' = 'true'실행)*에이전트 (Agent): 없습니다. 테이블에 빠른 배송을 사용하는 주문이 없습니다.
실제 정답은 2,317건이었습니다.
에이전트가 환각 (Hallucination)을 일으킨 것이 아닙니다. 에이전트는 실제 데이터베이스에 대해 실제 쿼리 (Query)를 실행했고 실제 결과를 보고했습니다. 단지 쿼리가 틀렸을 뿐입니다. 그리고 이 부분이 중요한데, 스택의 그 어디에서도 이를 알아챌 수 있는 방법이 없었습니다.
왜 거짓말을 하는가
MCP 서버에 테이블에 대해 설명해 달라고 요청하면, 서버는 자신이 아는 범위 내에서 진실을 말합니다:
column_name | data_type
-------------+-----------
id | uuid
...
jsonb. 그것이 계약의 전부입니다. 해당 컬럼은 모든 행에 {}를 담고 있을 수도 있고, 400개의 서로 다른 경로를 가진 9단계의 fulfillment 트리(fulfillment tree)를 담고 있을 수도 있습니다. information_schema는 진정으로 어느 쪽인지 알려줄 수 없습니다. 왜냐하면 Postgres의 타입 시스템 (Type system) 관점에서 jsonb는 하나의 스칼라 타입 (Scalar type)이기 때문입니다. 구조는 타입이 아니라 값 (Value) 안에 존재합니다. 카탈로그(Catalog)에 그 누구도 알려준 적이 없습니다.
따라서 모델은 자신이 할 수 있는 유일한 일을 수행합니다. 컬럼 이름과 대화 중에 떠다니는 정보들을 바탕으로 그럴듯한 키 (Key) 이름을 패턴 매칭 (Pattern-matches)하는 것입니다. 모델은 express_delivery라고 작성합니다. 하지만 데이터는 다음과 같이 되어 있습니다:
fulfillment.shippingV2.expressDelivery
스네이크 케이스 (Snake case)인가, 카멜 케이스 (Camel case)인가. 평면적인가 (Flat), 아니면 중첩되어 있는가 (Nested). 단수인가 (Singular), 아니면 복수인가 (Plural). 세 번의 독립적인 동전 던지기와 같으며, 모델은 이 세 가지를 모두 맞춰야만 했습니다.
왜 아무도 잡아내지 못하는가
만약 잘못된 추측이 에러 (Error)를 발생시켰다면, 이 모든 문제는 중요하지 않았을 것입니다. 에이전트는 에러를 확인하고, 수정하고, 다시 시도했을 것입니다. 그 루프 (Loop)는 잘 작동하며, 그것이 에이전트들이 하루 종일 하는 일의 대부분입니다.
하지만 존재하지 않는 키에 대해 -> 연산자를 사용하면 NULL을 반환합니다. NULL = 'true'는 NULL로 평가됩니다. NULL은 해당 행을 필터링하여 제외합니다. 따라서 잘못된 추측은 0개의 행을 반환하게 되며, 이는
전체 실패의 원인이 바로 그것입니다. 나쁜 모델도, 나쁜 프롬프트 (Prompt)도 아닙니다. 끊어진 피드백 루프 (Feedback loop)가 문제입니다. 모든 잘못된 추측은 결과 집합이 비어 있는 상태에서의 올바른 쿼리 (Query)와 동일하게 보이기 때문에, 모델은 스스로를 수정할 신호 (Signal)를 얻지 못합니다. 모델은 마치 기상 캐스터와 같은 평온한 자신감을 가지고 자신의 추측을 발견 사항으로 보고합니다. 그러고 나면 누군가가 그 숫자를 발표 자료(deck)에 집어넣게 됩니다.
생각해 볼 만한 점은, 여러분도 정확히 똑같은 문제를 겪고 있다는 것입니다. 여러분은 그저 슬랙 (Slack) 히스토리와 '데이브(Dave)'라는 이름의 동료가 있어 이를 보완할 수 있을 뿐입니다. 에이전트 (Agent)에게는 둘 다 없습니다. (제 경험상, 데이브도 회사를 떠났습니다.)
작동하지 않는 해결책
가장 뻔한 방법은 모델에게 몇 개의 행 (Rows)을 건네주고 그 형태를 파악하게 하는 것입니다:
SELECT details FROM orders LIMIT 20;
이것은 보기보다 더 나쁜 상황을 초래합니다. 40KB 크기의 문서 20개는 잘못된 추론 (Inference)에 수만 개의 토큰 (Tokens)을 소비하는 것입니다. 모델은 빈도 정보도, 타입 일관성 검사 (Type consistency check)도, 무엇을 놓쳤는지에 대한 개념도 없이, 단지 그 특정 20개의 행에서 우연히 발생한 경로만을 보게 됩니다. 선택적 필드 (Optional fields)는 필수 필드처럼 보이고, 희귀한 필드는 아예 존재하지 않는 것처럼 보입니다. 그리고 그 과정에서 컨텍스트 윈도우 (Context window)의 절반을 태워버리게 됩니다.
여러분은 데이터를 추론 (Reasoning) 쪽으로 보내고 있는 것입니다. 데이터는 정답보다 4자릿수(four orders of magnitude)나 더 큽니다.
작동하는 해결책
데이터베이스가 집계 (Aggregation)를 수행하게 하고, 모델에게는 결론을 전달하십시오.
jsonb는 텍스트가 아닙니다. 이는 파싱된 이진 구조 (Parsed binary structure)이며, Postgres는 아무것도 다시 파싱하지 않고도 이를 반복 (Iterate)할 수 있습니다. 세 가지 기본 요소(jsonb_each, jsonb_array_elements, jsonb_typeof)만 있으면 충분합니다. 이를 재귀적 CTE (Recursive CTE)에 연결하면 전체 탐색 (Walk)이 서버 측 (Server-side)에서 실행됩니다:
WITH RECURSIVE walk AS (
SELECT s.rid, e.key AS path, e.value, 1 AS depth
FROM sample s, LATERAL jsonb_each(s.details) e
...
재귀 항 (Recursive term)에 있는 저 CROSS JOIN LATERAL은 단순히 스타일을 위한 것이 아닙니다. SQL은 재귀 항에서 정확히 하나의 자기 참조 (Self-reference)만을 허용하며, 객체 (Objects)와 배열 (Arrays)은 두 가지 서로 다른 하강 규칙 (Descent rules)을 가집니다. 이들을 LATERAL 서브쿼리 내부에서 유니온 (Unioning)하는 것이 이 전체 과정을 문법적으로 유효하게 만드는 핵심입니다.
결과로 돌아오는 것은 문서당 한 행이 아니라, 고유한 경로(distinct path)당 한 행입니다:
path | types | occurrences
-----------------------------------------+-----------+-------------
fulfillment.shippingV2.enabled | {boolean} | 973
...
네 줄의 데이터. 수백 개의 토큰. 이 포스트 상단에서 제기했던 미스터리는 보는 즉시 스스로 해결됩니다.
다음 세 가지 요소가 이를 영리한 아이디어에서 실용적인 도구로 바꿔줍니다:
행(row)이 아닌 페이지(page) 단위로 샘플링하십시오. TABLESAMPLE SYSTEM — 또는 contrib 확장 기능이 설치되어 있다면 system_rows — 방식은 전체를 스캔하는 대신 무작위 _페이지(pages)_를 읽기 때문에, 비용이 테이블 크기가 아닌 샘플 크기에 따라 확장됩니다. ORDER BY random()은 트렌치코트를 입은 전체 스캔(full scan)과 같습니다. 단 천 개의 행을 건네주기 위해 5,200만 개의 행을 모두 읽어버립니다.
수렴(convergence) 시점에 멈추십시오. 배치(batch) 단위로 프로파일링하고, 배치당 새로 발견된 경로의 수를 세다가, 연속된 몇 개의 배치에서 새로운 것이 발견되지 않으면 중단하십시오. 프로덕션 JSON은 유한한 양의 애플리케이션 코드에 의해 생성되므로, 발견 곡선(discovery curve)은 매우 가파릅니다. 대부분의 테이블은 300~500행 정도에서 정체기(plateau)에 도달합니다.
불확실성을 보고하십시오. 샘플링을 하면 5,200만 개 중 단 3개의 행에만 나타나는 키를 반드시 놓치게 되며, 소비자(consumer)는 그 사실을 알아야 합니다:
"sampling": {
"method": "system_rows",
"rows_scanned": 1500,
...
여기서 missing_mass는 Good–Turing 추정법을 따릅니다: 샘플링된 문서 수로 나눈 싱글톤 경로(singleton paths)의 비율입니다. 0.004라는 값은 대략 250개 중 하나의 문서가 아직 보지 못한 경로를 포함하고 있음을 의미하며, 이는 쿼리를 작성하기에 충분히 괜찮은 수준입니다. 0.20은 테이블이 이질적(heterogeneous)이며 당신의 맵(map)을 아직 신뢰할 수 없음을 의미합니다. 테이블의 0.003%만 건드리고도 "완료(complete)"라고 보고하는 프로파일러는 아예 없는 것보다 못합니다. 왜냐하면 그것은 '알려진 미지의 영역(known unknown)'을 '미지의 미지의 영역(unknown unknown)'으로 세탁해버리기 때문입니다.
도구로 연결하기
이를 MCP 도구로 노출하면 루프가 완성됩니다:
mcp.NewTool("profile_json_columns",
mcp.WithDescription("json/jsonb 구조 추론 (키 경로, 타입, 빈도, 예시); 수렴할 때까지 블록 샘플링 수행"),
mcp.WithString("schema", mcp.Required()),
...
모델이 이 도구를 호출하여 실제 경로 맵(path map)을 가져오면, 네 번째 시도 대신 첫 번째 시도에 바로 details #>> '{fulfillment,shippingV2,expressDelivery}'를 작성합니다. 데이터베이스가 집계(aggregation)를 수행하고, 모델은 결론을 얻으며, 컨텍스트 윈도우(context window)는 보존됩니다.
아무도 요청하지 않은 버그 리포트
컬럼을 프로파일링(profile)하면 보너스로 버그 리포트를 얻게 됩니다. 단일 경로가 "types": ["number", "string"]으로 반환된다면, 프로덕션 환경에 {"amount": 1999}와 {"amount": "19.99"}가 동시에 존재한다는 뜻입니다. 즉, 두 명의 작성자, 두 가지 컨벤션(convention)이 존재하며, 하나는 정수 형태의 센트(cents)를 저장하고 다른 하나는 달러 문자열을 저장하고 있는 것입니다. 이는 전혀 관련 없는 질문을 던졌을 때 발견되는, 잠재적인 100배 가격 오류 위에 쌓인 잠재적인 형변환(cast) 오류입니다.
빈도(Frequency) 분석은 설계 리뷰(design review) 역할도 합니다. 100% 빈도의 경로는 선택적 데이터가 아닙니다. 그것은 증인 보호 프로그램에 들어간 컬럼과 같으며, ALTER TABLE ... GENERATED ALWAYS AS를 통해 밖으로 나올 수 있습니다. 1% 미만의 경로는 대개 누군가가 방치한 실험 데이터입니다.
요약
당신의 에이전트(agent)는 의도적으로 거짓말을 하는 것이 아닙니다. 비어 있는 카탈로그를 바탕으로 답변하고 있을 뿐이며, jsonb는 조용히 실패(fails silently)하기 때문에 루프 내의 그 어떤 것도 잘못된 쿼리와 데이터가 없는 상태를 구분할 수 없습니다. 카탈로그 문제를 해결하면 거짓말은 멈출 것입니다:
- 서버 측에서 JSON을 탐색(Walk)하세요.
jsonb_each/jsonb_array_elements에 대해 재귀적 CTE(Recursive CTE)를 사용하세요. 기가바이트 단위의 데이터는 제자리에 머물고, 킬로바이트 단위의 데이터만 반환됩니다. - 페이지 단위로 샘플링하세요.
TABLESAMPLE SYSTEM을 사용하면 비용이 테이블 크기가 아닌 샘플 크기의 함수가 됩니다. - 수렴하고, 놓친 것을 인정하세요. 새로운 경로의 발견을 관찰하고, 누락된 질량(missing mass)을 추정하며, 검증되지 않은 완전성을 결코 주장하지 마세요.
덧붙이자면, 이 논리는 그림에 AI가 전혀 없는 상황에서도 동일하게 적용됩니다. 이것은 단지 Postgres에 jsonb를 위한 \d+ 명령어가 탑재된다면 어떤 모습일지에 대한 이야기입니다.
전체 내용을 확인하고 싶으신가요? 저는 스토리지 계층 (storage layer), 쿼리 플래너 (query planner)가 왜 똑같이 눈먼 상태인지 (데이터와 상관없이 contsel이 모든 포함 관계 쿼리에 대해 하드코딩된 0.001을 반환하는 이유), 해당 재귀적 CTE (recursive CTE) 안에 숨겨진 네 가지 비자명한 세부 사항, 왜 ctid가 파티션 간에 고유하지 않아 이를 기반으로 구축된 중복 제거 (dedupe) 작업을 조용히 망가뜨리는지, 그리고 수렴 (convergence) 뒤에 숨겨진 통계에 대해 다루는 훨씬 더 긴 버전의 글을 작성했습니다:
LLM도 볼 수 없는 컬럼 (The Column No LLM Could See)
구현 및 MCP 연결: github.com/rasikraj01/psql-json-profiling-mcp
어느 쪽이든: 그 컬럼에 실제로 무엇이 들어있는지 직접 확인해 보세요. 당신이 생각하는 것보다 훨씬 더 기괴할 것이라고 약속합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기