블랙박스 디버깅하기: Claude를 대상으로 한 36번의 렌더링 테스트, 그리고 내 데이터가 틀렸던 부분
요약
MCP(Model Context Protocol) 환경에서 HTML 위젯이 렌더링되지 않는 블랙박스 문제를 디버깅하는 방법론을 다룹니다. 36번의 테스트를 통해 _meta.ui.domain 설정이 iframe 마운트와 오리진 안정성에 미치는 결정적인 영향을 분석합니다.
핵심 포인트
- MCP 서버의 _meta.ui.domain 설정은 iframe 마운트 성공 여부에 결정적임
- 잘못된 도메인 해시 값은 렌더링 실패의 주요 원인이 됨
- 소스 코드를 볼 수 없는 블랙박스 호스트를 위한 프로브 서버 구축 방법 제안
- 엔드포인트 URL의 미세한 차이(슬래시 유무 등)가 해시 값 오류를 유발함
만약 여러분이 MCP App — MCP 서버가 호스트에게 인라인 렌더링을 위해 전달하는 HTML 위젯 — 을 구축해 보았다면, 다음과 같은 상황을 겪었을 수 있습니다: 도구 호출(tool call)은 성공하고, structuredContent도 정상적으로 반환되며, 모델은 위젯이 렌더링되었다고 알리지만, 사용자의 눈에는 아무것도 보이지 않는 상황 말입니다. 에러도 없고, 콘솔 출력도 없습니다. 그저 대화창에 빈 공간만 생길 뿐입니다.
이와 정확히 동일한 증상을 겪는 사람들로 가득 찬 긴 이슈(long issue)가 있으며, 그들 모두(저를 포함하여) "내 서버는 스펙(spec)을 정확히 따르고 있는데 아무것도 렌더링되지 않는다"라는 식의 글을 올리고 있습니다. 이는 대응하기 매우 어려운 문제입니다. 그래서 저는 한 번에 하나의 질문에 답하도록 설계된 프로브 서버(probe server)를 구축하여 36번 실행했습니다.
이 포스트는 주로 _방법(method)_에 관한 것입니다. 즉, 소스 코드를 읽을 수 없고 렌더러(renderer)에 디버거를 연결할 수 없는 호스트를 어떻게 실험할 것인가에 대한 내용입니다. MCP의 세부 사항은 작업 예시로 사용되었습니다. 가장 유용한 부분은 마지막에 나오는데, 제 측정값이 저를 두 번이나 속였던 부분입니다.
여기서 모든 행동 양식(behavioural) 데이터는 2026년 7월 31일 claude.ai 웹을 대상으로 측정되었습니다. 호스트의 동작은 변할 수 있으므로, 이 수치들을 스펙이 아닌 스냅샷으로 취급하십시오.
결론부터 말하자면
해당 스레드에서 가장 많은 추천을 받은 단서는, 리소스가 _meta.ui.domain을 선언하지 않으면 claude.ai가 iframe 배치를 조용히 거부한다는 것입니다. 이 값은 sha256(<your endpoint URL>)[:32] + ".claudemcpcontent.com"로 계산됩니다.
이 필드 하나를 변경했을 때 실제로 일어나는 일은 다음과 같습니다:
_meta.ui.domain |
|---|
| 계산된 값 (computed value) |
| ... |
이 값을 생략한다고 해서 작동이 멈추는 것은 아닙니다. 이 필드가 실제로 제어하는 것은 오리진의 _안정성(stability)_이며, 이는 정확히 SDK 문서에서 명시한 용도와 일치합니다: 즉, API 서버가 CORS를 위해 화이트리스트(allowlist)에 추가할 수 있는 고정된 오리진을 제공하는 것입니다.
하지만 잘못된 값은 치명적입니다. 그리고 이를 만들어내는 쉬운 방법은 클라이언트가 접속한 URL과 미세하게 다른 엔드포인트 문자열을 해싱하는 것입니다. 예를 들어, 마지막 슬래시(trailing slash) 유무, 누락된 경로 세그먼트(path segment), 또는 http와 https의 차이 같은 것들 말이죠. 따라서 이 조언은 위험성을 반전시킵니다. 이 조언을 부정확하게 따르면, 잘 작동하던 앱을 고장 난 앱으로 바꿔버리게 됩니다.
원래의 댓글이 모든 면에서 틀렸던 것은 아닙니다. 잘못된 값이 실패한다는 점과, 리번들(rebundle) 이후에 오래된 ui:// URI가 깨진다는 점은 모두 사실입니다. 저는 실험 도중 우연히 두 번째 상황을 겪었습니다. 이 모든 내용을 기록한 것은 해당 스레드의 그 누구보다도 더 많은 노력을 기울인 결과였습니다.
디버깅할 수 없는 호스트를 A/B 테스트하는 방법
네 가지 요소가 이를 측정 가능하게 만들었습니다. 이 중 어느 것도 MCP(Model Context Protocol)에 특화된 것은 아닙니다.
1. JavaScript가 필요 없는 신호를 스스로에게 부여하기
핵심적인 모호함은 이것이었습니다: 아무것도 나타나지 않을 때, 호스트가 iframe을 아예 생성하지 않은 것인가, 아니면 생성은 했지만 내 코드를 실행하는 데 실패한 것인가? 이 두 가지는 원인이 완전히 다르며, 외부에서는 이를 구분할 수 없습니다.
그래서 모든 위젯의 상단에 다음을 추가했습니다:
<div id="marker" style="background:#1f7a4d;color:#fff;padding:18px 20px;font-weight:700">
STATIC MARKER — arm: with_domain
<small>this block needs no JavaScript</small>
...
단순하게 스타일이 적용된 블록입니다. 스크립트도, postMessage도, 핸드셰이크(handshake)도 없습니다. 문서가 프레임 내에서 조금이라도 렌더링된다면, 이 바(bar)는 보일 것입니다.
이 한 가지 조치로 모든 의문이 해결되었습니다. 카드가 빈 상태로 돌아올 때 마커도 보이지 않았습니다. 즉, 문서가 전혀 렌더링되지 않았다는 뜻이며, 내 앱의 코드, 핸드셰이크, 또는 SDK와 관련된 모든 설명은 시작부터 틀린 것이었습니다. 아무것도 없는 스크린샷만으로는 그런 결론에 도달할 수 없습니다.
2. 정확히 한 가지만 변경하기
하나의 서버, 하나의 엔드포인트, 그리고 테스트 대상인 단 하나의 필드를 제외하고는 바이트 단위로 동일한(byte-identical) 여러 도구들을 사용했습니다:
const ARMS = [
{ id: "with_domain", tool: "probe_with_domain", domain: "computed" },
{ id: "no_domain", tool: "probe_no_domain", domain: null },
...
그 외의 모든 요소는 일정하게 유지하며 의도적으로 최대화했습니다: 에코된 protocolVersion, 중첩된(nested) 및 레거시(legacy) _meta 리소스 URI 키, 정확한 text/html;profile=mcp-app MIME 타입, 허용적인 CORS, 외부 임포트(import) 제로. 만약 특정 arm이 다르게 동작한다면, 원인은 단 하나뿐입니다.
3. 직접 확인할 수 있는 호스트에서 대조군(control) 설정하기
실제 타겟을 건드리기 전에, 모든 arm을 로컬의 사양 준수 하네스(spec-conformant harness)를 통해 실행했습니다. 모두 통과(green)되었고, 모두 동일했습니다. 이것이 중요한 이유는, "내 앱이 고장 났다"라는 결론을 "내 앱은 괜찮고 타겟 호스트가 다르다"라는 결론으로 전환해주기 때문입니다. 그래야만 호스트에 대한 발견이 의미를 가질 수 있습니다.
4. 프록시 측정값(proxy measurement)을 구축한 후, 현실에 맞춰 보정(calibrate)하기
36번의 렌더링을 눈으로 직접 확인하다 보면, 결국 데이터 포인트 3개와 느낌(vibe)만 남게 됩니다. 위젯은 로드 시 size-changed를 호출하므로, 그려진(painted) 카드는 예약된 플레이스홀더(placeholder)보다 더 높게 자리 잡습니다. 즉, iframe의 높이는 "그려졌는가"를 판단할 수 있는 유효한 프록시(proxy)가 됩니다:
painted: (frame?.height ?? 0) > 150 // 150 = 예약된 빈 공간
결정적으로, 저는 이를 보정(calibrate)했습니다. 확실히 그려진 경우와 확실히 빈 경우를 스크린샷으로 찍어 높이를 확인한 후, 전체 배치(batch)에 대해 신뢰할 수 있도록 했습니다. 나중에 두 번째 위젯 템플릿을 추가했을 때, 높이가 다르게 설정되어 제 임계값(threshold)이 두 번의 실행을 빈 상태로 조용히 오분류했습니다. 숫자가 이상해 보여서 다시 스크린샷을 확인했기에 잡아낼 수 있었습니다.
보정되지 않은 프록시는 숫자가 붙은 추측에 불과합니다.
발견한 내용
위의 ui.domain 결과와 제가 실제로 관심을 두는 사항은 다음과 같습니다: 한 창에서는 10번의 렌더링 중 6번이 iframe을 마운트(mount)했음에도 전혀 그려지지 않았습니다. 에러도 없고, 콘솔 출력도 없었으며, 모델은 성공했다고 보고했습니다. 이후 다른 창에서는 18번 중 0번이었습니다.
이 문제가 제 쪽이 아닌 호스트 측임을 나타내는 두 가지 사실이 있습니다:
- 모든 빈 렌더링은 제 서버 로그에 **성공적인
resources/read**를 남겼습니다 — 19번의 도구 호출(tool calls), 20번의 리소스 읽기(resource reads). 호스트는 제 HTML을 가져갔지만, 그것을 표시하지는 않았습니다. - 정적 마커(static marker)가 그려지지 않았습니다. JavaScript와는 무관했습니다. 문서 자체가 렌더링되지 않은 것입니다.
원인을 모르겠습니다. 깨끗한 창(clean window)이 나타난 시점은 커넥터를 연결 해제했다가 다시 연결한 직후였습니다. 이는 n=1인 사례이며, 이를 해결책인 것처럼 꾸미고 싶지 않습니다. 이것이 솔직한 상태입니다: 재현되었고, 수치화되었으며, 원인은 알 수 없습니다.
내 데이터가 거짓말을 했던 두 번의 순간
이 부분이 바로 여러분이 읽고 싶어 할 내용입니다.
나의 36번의 실행은 내가 답하고자 했던 질문에 답할 수 없었다
나는 기본 샌드박스 오리진(sandbox origin)이 "렌더링당(per render)" 생성된다고 발표했습니다. 나는 36번의 실행 결과와 표를 가지고 있었습니다. 하지만 그것은 틀렸습니다.
해당 배치(batch)의 모든 실행은 **새로운 대화(fresh conversation)**를 사용했습니다. 따라서 10번의 실행에 걸쳐 나타난 10개의 서로 다른 오리진은 "렌더링당"이라는 가설과 "대화당(per conversation)"이라는 가설 모두와 동일하게 일치했습니다. 설계 자체가 결정적인 변수를 상수로 유지해 버린 것입니다. 나는 한 가지 요소를 신중하게 변화시켜 놓고는, 다른 요소에 대해 주장하고 있었던 것입니다.
내가 이를 알아챈 이유는 스펙(spec) 리포지토리의 병합된 PR(merged PR)에서 "일반적으로 대화당(typically per-conversation)"이라는 문구를 사용했고, 그것이 내가 쓴 내용과 일치하지 않았기 때문입니다. 실제 테스트에는 10분이 소요됩니다: 하나의 대화 내에서 세 번의 렌더링을 수행하는 방식입니다.
render 1: 0497825c…claudemcpcontent.com
render 2: 0497825c…claudemcpcontent.com
render 3: 0497825c…claudemcpcontent.com
...
대화당(Per-conversation) 방식이었습니다. 스펙이 맞았고, 나는 차이를 식별할 수 없는 데이터를 근거로 스펙과 모순되는 주장을 했습니다.
36번의 실행은 엄격한(rigour) 과정처럼 느껴졌습니다. 하지만 표본 크기(Sample size)가 가설을 분리할 수 없는 설계를 구원해주지는 못합니다.
페이지가 비어 있어서 통과되어 버린 체크
이후 정리 작업을 하던 중, 내 정리 스크립트(cleanup script)는 여전히 남아 있는 커넥터에 대해 verified removed(제거 확인됨)라고 보고했습니다.
해당 체크는 설정(settings) URL로 이동한 다음, 페이지 텍스트에 커넥터의 이름이 없는지 확인(assert)했습니다. 하지만 이미 해당 URL에 접속해 있는 상태였기에, 페이지 이동은 아무런 동작도 하지 않는 no-op(no-operation)이 되었고, 설정 패널은 전혀 렌더링되지 않았으며, 이름은 빈 페이지에 존재하지 않았습니다. 증거의 부재를 성공으로 점수 매긴 것입니다.
해결책은 부정적인 결과(negative)를 믿기 전에 전제 조건(precondition)을 요구하는 것입니다:
// 로드되지 않은 페이지로부터 "찾을 수 없음"을 수락하지 마세요
if (!(await openPanel())) {
console.log("UNVERIFIED — panel would not open");
...
그것은 즉시 STILL PRESENT를 반환했습니다.
두 가지 실수 모두 동일한 형태를 띠고 있습니다: 질문받은 상태를 구분할 수 없는 체크(check)가 깔끔해 보이는 답변을 반환한 것입니다. 하나는 통제된 실험 (controlled experiment)의 형태를 띠었고, 다른 하나는 해체 단언 (teardown assertion)의 형태를 띠었습니다. 어느 것도 스스로를 드러내지 않았습니다. 둘 다 확신에 차 있고 그럴듯하지만 틀린 출력을 생성했으며, 바로 그 점 때문에 잡아낼 가치가 있는 것입니다.
이 경험을 통해 얻은 습관은 다음과 같습니다: 통과할 수 있는 모든 체크(check)에 대해, 그 외에 무엇이 그것을 통과하게 만들 수 있는지 질문하십시오. 만약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기