Fyreflock의 채팅창에 게임을 처음 배포했을 때, 재생 버튼이 없었습니다. 사이트는 새 빌드 상태였고, 서버는 새로운 페이지를 제공하고 있었지만, Claude Desktop은 여전히 이전 버전을 표시했습니다. 이는 해당 URI 아래에서 박스를 캐시했기 때문에 다시 요청할 이유가 없었기 때문입니다.
이를 수정하는 데 이틀에 걸쳐 두 번의 시도가 더 필요했고, 그 첫 번째 시도는 상황을 더욱 악화시켰습니다. 이것이 세 가지 버전이며, 어떤 것이 고착되었는지와 열 때마다 비용이 얼마나 드는지 설명합니다.
박스 소개 (The box, briefly)
Fyreflock은 무작위 채팅 앱이며, 그 일부는 MCP 서버로 AI 채팅 내부에서 실행됩니다. Claude나 ChatGPT에게 이 앱을 열도록 요청하면, 모델은 fyreflock_open이라는 하나의 도구를 호출하고 호스트가 MCP 앱(샌드박스 iframe 내의 HTML 페이지)을 표시합니다. 이후부터 사용자는 그 박스에 타이핑하며, 이는 제 서버와 직접 통신합니다. 이 과정에서 모델은 루프 밖으로 빠집니다.
이 도구는 _meta를 통해 페이지를 가리킵니다:
// apps/server/src/mcp/app/index.ts
registerAppTool(server, 'fyreflock_open', {
// ...
...
그리고 그 페이지 자체는 동일한 URI 아래에 등록된 리소스입니다. 간단합니다. 호스트가 도구 목록을 읽고, URI를 확인하고, 리소스를 가져와 렌더링하는 것입니다.
문제는 '가져온다(fetches)'라는 단어에 있습니다. 이는 한 번만 의미합니다.
시도 1: 고정된 URI, ui://fyreflock/chat
이것이 원래 방식이었습니다. 하나의 URI, 하나의 페이지였고, 이 페이지는 배포될 때마다 재구축되었습니다.
저는 이미 이것이 문제가 될 것이라는 힌트를 가지고 있었습니다. 2026-10-19일, 저는 ChatGPT가 페이지 새로고침을 거쳐 캐시에서 박스를 제공하는 것을 목격했습니다. 그러자 게임 배포가 이루어졌는데, 이전에 박스를 열었던 Claude Desktop은 여전히 이전의 게임 페이지를 유지하고 있었습니다. 호스트는 MCP 앱을 URI별로 캐싱하며 다시 읽지 않습니다. 서버 측에서는 아무 문제가 없었습니다. 저에게 도달하는 모든 요청은 새 페이지를 받았습니다. 단지 요청 자체가 오지 않았습니다.
따라서 박스의 어떤 변경 사항도 이전에 본 적이 없는 사용자들에게만 배포되었고, 이는 첫 주 동안의 기능으로는 거의 '아무도'가 아니었습니다.
시도 2: URI에 해시(hash) 추가하기
이름으로 키를 지정하는 캐시에 대한 명백한 해결책은 이름을 변경하는 것입니다:
// ace4245, 대체됨(since replaced)
export const APP_RESOURCE_URI =
ui://fyreflock/chat/${createHash('sha256').update(HTML).digest('hex').slice(0, 10)};
동일한 바이트, 동일한 URI. 새로운 바이트, 새로운 URI, 캐시 미스(cache miss), 신규 페이지 로드. 모든 번들러가 하는 방식대로 캐시를 무력화(Cache busting)했고, 저는 스스로 매우 영리하다고 느꼈습니다.
이것은 페이지 캐시를 통과하여 다음 캐시로 곧장 들어갔습니다. 호스트는 URI를 자체적으로 발견하지 못합니다. 도구 목록(tool list)에서 학습하며, 커넥터가 연결될 때 이 도구 목록을 읽기 때문입니다. 따라서 어제 연결된 호스트는 여전히 어제의 도구 목록을 가지고 있었고, 이는 여전히 어제의 해시를 가리키고 있었습니다.
그리고 그 해시는 제 서버에 더 이상 존재하지 않았습니다. 리소스는 현재의 해시로만 등록되었습니다. 재연결하지 않은 사용자는 오래된 상자(old box)를 받지 못했습니다. 로드에 실패한 상자를 받은 것입니다.
flowchart TD
A[배포: 박스 바이트 변경] --> B[서버가 ui://fyreflock/chat/NEWHASH 등록]
C[지난주 연결된 호스트] --> D[그의 도구 목록은 여전히 OLDHASH를 표시]
...
결과적으로 해시는 “사용자가 재연결할 때까지 오래된 상자를 본다”는 상황을 “사용자가 재연결할 때까지 아무 상자도 보지 못한다”는 상황으로 바꾸었습니다. 명백히 더 나쁜 경우입니다. 변명하자면, 하루 동안만 지속되었습니다.
시도 3: 로더 캐싱 및 박스 가져오기 (cache the loader, fetch the box)
만약 호스트가 URI에 있는 것을 영원히 유지할 것이라면, 해결책은 그것이 무엇인지 중요하지 않게 만드는 것입니다. URI는 고정된 ui://fyreflock/box/v1로 돌아갔고, 이제 그것이 담고 있는 것은 로더(loader)입니다. 열릴 때마다, 이 로더는 평문 스크립트인 /box/live.js를 통해 현재의 박스를 제 사이트에 요청하고, 받은 내용을 마운트합니다.
sequenceDiagram
participant H as 호스트 (Host) (Claude, ChatGPT)
participant L as 캐시된 로더, ui://fyreflock/box/v1
...
이 스크립트는 전체 페이지를 문자열로 받는 단일 함수 호출에 불과합니다:
export function boxLiveScript(origin: string): string {
return `window.__fyreflockBox(${literal(page(origin))});\n`;
...
이 literal는 보이는 것보다 더 많은 일을 합니다. 이는 JSON.stringify에 하나의 replace를 추가한 것이기 때문입니다. 왜냐하면 문자열 내 어디에든 원시적인 </script가 있으면 로더의 스크립트 태그를 박스 중간에서 끝내버릴 것이기 때문입니다:
const literal = (s: string) => JSON.stringify(s).replaceAll('</', '<\/');
이것을 정확히 테스트하는 과정이 있습니다. 왜냐하면 이 문제는 게임 번들(games bundle)에 우연히 해당 문자열이 포함될 때까지는 아무 문제 없이 작동하기 때문입니다.
스크립트 태그를 사용하고 fetch를 사용하지 않는 이유
두 가지 이유가 있습니다. 앱이 CSP(Content Security Policy)의 resourceDomains에 선언하는 도메인으로부터 오는 <script src>는 호스트들이 이미 허용하는 것이며, 저는 이에 대한 탐지(probe)를 진행했습니다. 실제 호스트 내부에서 실행한 테스트 위젯으로, 이 위젯은 선언된 도메인에서 스크립트를 로드하고 실제로 실행되었는지 보고합니다. 2026-10-28일에는 Claude Desktop과 claude.ai에서 로드가 성공했습니다. ChatGPT는 측정하지 않았으며, 만약 거부하더라도 로더가 내장된 복사본을 마운트하는데, 이는 모두가 이전에 받았던 것과 정확히 같습니다. 아무도 더 나빠지지 않습니다.
두 번째 이유는 다른 사람의 HTML을 문서에 마운트한다고 해서 그 스크립트가 실행되지 않기 때문입니다. DOMParser나 importNode 모두 가져온 <script>를 실행하지 않습니다. 따라서 로더는 새로운 head와 body로 교체한 다음, 모든 script 요소를 신선한 복사본으로 대체합니다. 왜냐하면 새로 생성된 스크립트 요소만이 브라우저가 실제로 실행할 수 있는 것이기 때문입니다:
var next = new DOMParser().parseFromString(html, 'text/html');
document.head.replaceWith(document.importNode(next.head, true));
...
mounted 플래그는 가장 먼저 도착하는 복사본이 승리하게 만듭니다. 따라서 4초의 폴백(fallback) 이후에 도착하는 느린 라이브 스크립트가 첫 번째 박스 위에 두 번째 박스를 마운트하지 못하도록 합니다.
이전 해시(hashes)도 여전히 응답을 받습니다
시도 2 동안 도구 목록을 읽는 호스트들은 여전히 해시된 URI(hashed URIs)를 요청합니다. 서버는 이를 거부하는 대신, 그들 중 어느 것과도 일치하는 리소스 템플릿(resource template)을 등록하고 로더를 반환합니다:
const HASHED_URI = new ResourceTemplate('ui://fyreflock/chat/{version}', { list: undefined });
// 거부(refused) 대신 응답(answered): 여전히 오래된 도구 목록을 가지고 있는 호스트가 이 서버가 더 이상 가지고 있지 않은 해시를 요청합니다.
server.registerResource('Fyreflock chat (earlier address)', HASHED_URI, /* ... */
...
해시 데이(hash day) 동안 연결된 사용자들은 v1을 가져오기 위해 한 번의 재연결이 더 필요했습니다. 그 이후부터는 상자를 열 때마다 배포가 그들에게 도달합니다. 가짜 호스트에서 테스트한 결과, 마운트 과정에서 살아남는 핸드셰이크(handshake), 약 110ms 만에 폴백(fallback)하는 연결 불가능 사이트, 비스크립트 응답, 이를 차단하는 CSP(Content Security Policy), 그리고 변경된 상자를 마운트하기 전에 포착되는 로더까지 모두 테스트했습니다.
최신 상태를 유지하는 비용
이것이 청구서입니다. 오늘 저는 프로덕션 환경에서 /box/live.js 파일을 50번 가져왔고, 매번 새로운 연결을 사용했습니다:
Fetch time for /box/live.js: median 1173 ms uncompressed, 837 ms gzipped, 523 ms for a 304, all far under the 4 second fallback
이 상자는 문자열 형태의 JavaScript 488 KB이며, gzipped 상태에서는 167 KB입니다. 채팅 위젯은 이 중 110 KB를 차지하고, 게임 번들은 나머지 부분을 차지합니다. 가장 느린 압축 해제 실행 시간인 2.9초도 로더가 포기하는 4초보다 훨씬 짧았습니다. 흥미로운 부분은 304 응답입니다. 데이터 전송량은 0바이트이지만 여전히 약 반 초의 비용이 발생하는데, 이는 어떤 것도 그리드(draw)하기 전에 오리진(origin)까지 왕복 트립(round trip)을 해야 하기 때문입니다.
그것은 세 번째 시도(attempt 3)의 첫 버전이었으며, Cache-Control: no-cache를 사용했기 때문에 열 때마다 재검증이 이루어졌고, 변경되지 않은 상자는 304로 돌아왔습니다. 맞습니다. 그리고 매번 여는 것마다 오리진까지 왕복 트립을 해야 했습니다. 다음 날에는 다음과 같이 수정되었습니다:
// apps/server/src/http.ts
res.type('text/javascript')
.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=86400')
...
단순 캐싱으로 1분, 그리고 브라우저가 가지고 있는 복사본을 즉시 표시하고 그 뒤에서 다시 가져올 수 있는 하루. 나는 Cloudflare에 의존하지 않았는데, 내가 남긴 댓글에 따르면 Free 플랜은 자체적으로 두 시간 미만으로는 캐싱되지 않으며, 나타나는 데 두 시간이 걸리는 배포 자체가 내가 처음에 가지고 있던 문제였기 때문이다.
그 대가는 솔직한 오래됨(staleness)이다. stale-while-revalidate를 지원하는 브라우저에서는 배포 후 첫 번째 열림에서 이전 박스를 즉시 가져오면서 새 것을 가져올 수 있고, 그다음 열림은 최신 상태가 된다. 나는 '항상 최신이지만 항상 반 초 느린' 것을 '즉각적이고, 한 번의 열림이 뒤처지는 것'과 맞바꾸었다.
여전히 잘못된 부분
로더는 폴백(fallback)을 인라인으로 가지고 있으며, 이는 호스트가 가져온 날짜의 전체 페이지이다. 따라서 로더는 라이브 스크립트와 거의 같은 크기인 488 KB이며, 그 안에 있는 복사본은 시간이 지날수록 더 오래된다. 만약 내 사이트에 한 달 후에 접근할 수 없다면, 오늘 v1을 캐싱한 호스트는 한 달 된 박스를 마운트하게 될 것이다. 이것은 채팅에는 괜찮지만, 이후 규칙이 변경된 게임에는 덜 괜찮다.
그리고 로더 자체는 이제 모든 사람에게 재연결을 요청하지 않고는 절대 업데이트할 수 없는 것이 되었다. 여기에 변경 사항이 생기면 v2와 함께 배포되며, 금요일에 잊어버릴 수 없도록 APP_RESOURCE_URI 위의 코드 주석에 그 규칙을 작성해 두었다.
다르게 할 것들
캐시 버스팅(cache busting) 전에 프로브(Probe)를 수행할 것이다. 해시는 내가 하나의 캐시에 대해서만 생각했기 때문에 올바르게 보였다. 실제로는 페이지 캐시와 도구 목록, 두 가지가 있었고, 둘째는 호스트가 실제로 요청하는 URI를 로깅하지 않으면 서버에서는 보이지 않는다. 서버가 알지 못하는 리소스 URI에 대한 단일 로그 라인만으로도 호스트가 그것을 처음 요청했을 때 깨진 박스를 보여주었을 것이다.
또한 로더부터 시작했을 것이다. "영원히 캐싱되는 것은 작고 단순해야 한다"는 것은 서비스 워커(Service Workers)의 오래된 조언이며, 나는 느린 방식으로 그것을 다시 배웠다.
여전히 고민 중인 트레이드오프(Tradeoff)
폴백(fallback)은 시간이 지나서 구식이 되는 두꺼운 인라인 복사본이거나, 아니면 아예 아무것도 표시되지 않는 빈 상자입니다. 현재는 그 두꺼운 복사본을 유지하고 있습니다. 만약 당신이 영원히 캐싱하는 호스트(MCP Apps, 브라우저 확장 프로그램, 임베디드 위젯 등)에 UI를 배포했다면, 어떤 것을 선택했습니까? 오래된 폴백(stale fallback)을 선택했나요, 아니면 사람들이 문제가 있다는 것을 알 수 있도록 명확하게 실패하는 방식을 선택했나요?