상세 포트폴리오

대화를 통해 인용 자료의 신뢰도를 능동적으로 끌어올리는 LLM 챗봇

모든 수치에 확정 등급을 매기고, 역질문으로 범위를 좁혀 "최소 N건"을 "정확히 N건"으로, 표본 인용을 전수 인용으로 승격시키는 건설 현장 검색 시스템

코드명: Beacon · 문서 버전: 2026-08 회사·제품·인명·내부 호스트는 일반화했습니다. 코드 심볼과 수치는 실제 값입니다.


리드

무엇을 만들었나 건설 현장을 3D로 촬영해 관리하는 B2B SaaS 위에 올린 LLM 챗 서버(TypeScript) 와, 두 개의 앱에 붙여 쓰는 패널 UI(Angular). 사용자는 촬영된 현장 데이터를 자연어로 검색한다. 모델은 문장만 쓰고, 개수·부재·실패에 대한 판단은 전부 코드가 한다.

어디까지 검증했나 사내 데모를 3단계(CLI → 브라우저 → 전체 UI)로 나눠 dev·qa 실제 배포본에 실계정·실토큰으로 왕복시켜 통과시켰다. 마지막 단계는 기능 구현과 내부 검증을 마쳤고 지금은 UI/UX를 다듬는 중이다. 아직 프로덕션 사용자 트래픽은 없다 — 이 문서의 어떤 숫자도 사용자 규모를 주장하지 않는다.

규모 서버 25,283줄 · 테스트 54,227줄(2.14 : 1, 약 2,817 케이스) · 패널 UI 10,284줄 · 브라우저 e2e 16개 · 상태 전이표 116행(금지된 전이 포함) · 외부 계약 경계 103개 등록 · 31일간 18번 릴리스.

내가 한 일 서버·패널·검증 도구 설계와 구현. 코드를 고칠 권한이 없는 외부 팀의 API를 측정 리포트로 두 번 바꿈.


1. 요약

건설 현장에서 "이번 주 3층에서 나온 안전 지적사항이 몇 건이냐"는 잡담이 아니다. 그 숫자로 작업이 멈추고 하도급 정산이 갈린다. 틀린 숫자를 자신 있게 말하는 챗봇은 없느니만 못하다.

그런데 LLM 챗봇이 숫자를 틀리는 가장 흔한 경로는 환각이 아니다. 아무도 거짓말하지 않았는데 결과가 거짓말이 되는 경로가 따로 있다. 측정해 보니 네 단계였다.

  1. 검색 엔진은 "조건에 맞는 게 전부 몇 건인지"를 응답에 담아 보낸다.
  2. 그 앞의 MCP 서버가 파싱하면서 그 필드를 버린다. 그리고 잘라내고 남은 개수에 Total: 라벨을 붙인다.
  3. 우리 챗 서버는 모델이 요청한 개수가 10을 넘으면 조용히 10으로 줄인다.
  4. 모델은 받은 10건을 세어 "10건을 찾았습니다"라고 답한다.

사용자는 12장을 요청했고, 10장을 봤고, 그게 전부라고 믿는다. 어느 층도 악의가 없었다.

이 프로젝트의 답은 잘라내기를 없애는 게 아니라 "페이지"라는 정직한 계약으로 바꾸는 것, 그리고 한 걸음 더 나가 대화로 그 페이지를 전체로 만드는 것이다.

  • 말할 후보가 되는 모든 숫자는 모델이 입을 열기 전에 4등급으로 판정된다 — EXACT(정확) / LOWER_BOUND(하한) / SHOWN(표시분) / UNKNOWN(불명). 등급마다 쓸 수 있는 문장 형태가 정해져 있고, UNKNOWN이면 숫자를 아예 못 쓴다.
  • 셀 수 없는 숫자는 "최소 N건"으로 정직하게 내놓되 거기서 멈추지 않는다. 되묻기가 하한을 확정으로 바꾸는 연산이다 — 좁힌 조건이 한 페이지에 들어가고 그 뒤가 없다는 게 확인되는 순간, 화면의 카드가 표본에서 전수로 바뀌고 등급이 SHOWN에서 EXACT로 올라간다.
  • 같은 원리가 "없다"는 주장, "실패했다"는 판정, 권한 차단에도 적용된다. 주장의 종류마다 요구되는 자격이 따로 있다.

대조표

# 흔한 구현 (ChatGPT 클론 · RAG 튜토리얼 · MCP 툴 호출 데모) Beacon 참조
1 모델이 받은 배열 길이를 세어 "N개를 찾았습니다"라고 답한다 말할 후보 숫자를 말하기 전에 4등급으로 판정하고, 등급이 쓸 수 있는 문장 형태를 강제한다. UNKNOWN이면 숫자를 아예 못 쓴다 §4.1
2 애매하면 되묻는다 (프롬프트에 그렇게 써 둔다) 되묻기가 하한 → 확정 승격 연산이다. 좁힌 조건이 한 페이지에 들어가면 표본 인용이 전수 인용이 되고 등급이 올라간다 §4.2
3 검색 결과가 비면 "없습니다"라고 답한다 "없다"고 말하려면 자격이 필요하다. 서버가 집계 정보를 붙여 "내 검색 결과다"라고 표시한 응답에서만 부재를 주장한다. 자격이 없으면 아무 말도 안 한다 §4.3
4 툴이 실패했는지를 isError 하나로 판단한다 HTTP 200으로 위장해서 오는 실패를 잡는다. 근거는 에러 문구가 아니라 정상 응답이면 반드시 붙는 메타데이터의 부재다 — 상대가 문구를 바꿔도 안 깨진다 §4.4
5 권한을 시스템 프롬프트로 지시한다 툴을 호출하기 전에 막는다. 데이터가 안 오니 유출이 구조적으로 불가능하고, 플래너는 라이선스 단어를 일부러 모른다(그 단어가 프롬프트에 있으면 테스트가 깨진다). 이 축 전용 테스트 14개 파일·131 케이스 §4.6
6 happy path 스크린샷이 데모 검증의 전부다 금지된 전이까지 표로 적은 116행 + CLI 재현 17개 + 브라우저 e2e 16개. e2e가 붙는 가짜 서버 자체를 계약 테스트로 검증 §4.7
7 혼자 만들고 혼자 배포한다 외부 팀 API를 측정 리포트로 두 번 바꿨다(조회 상한 30→200, 커서 페이지네이션 · 도달 가능 결과 16→38). 받은 구현이 요청과 다른 부분은 우리 쪽 방어 조건으로 막았다 §4.9

2. 문제 정의

2.1 데이터의 성질

이 도메인의 데이터에는 세 가지 특징이 있다.

  • 양이 많고 중복이 심하다. 프로젝트 하나에 파노라마 수천 장. 같은 위치를 여러 번 찍는다.
  • 의미 검색과 정형 질의가 섞인다. "안전모 미착용"은 벡터 검색이고, "3층 4월 촬영 건수"는 SQL이다.
  • 틀린 답의 비용이 비대칭이다. 있는데 못 찾는 것보다 없는데 있다고 하는 게 비싸고, 그중에서도 "이게 전부"라고 잘못 말하는 게 제일 비싸다. 안전 점검에서 "지적사항 없음"은 누군가 서명하는 문장이다.

2.2 어려운 지점

세 가지가 동시에 성립해야 한다.

  1. 정직해야 한다. 모르면 모른다고 해야 하고, 표본을 전체인 것처럼 말하면 안 된다.
  2. 그런데 쓸모가 있어야 한다. "정확히는 모르겠습니다"만 반복하는 챗봇은 아무도 안 쓴다.
  3. 그리고 코드로 강제돼야 한다. 프롬프트에 "정직하게 답해"라고 써 두면 모델 버전이 바뀌는 순간 무너진다.

1번과 2번은 서로 당긴다. 이 프로젝트의 설계 전체가 그 긴장을 푸는 방식이다. 정직성을 등급 체계로 고정하고, 등급을 올리는 경로를 대화 안에 만든다.

2.3 제약 조건

  • 챗 서버는 검색 인덱스를 갖고 있지 않다. 검색은 다른 팀이 소유한 MCP 서버를 통해서만 가능하고, 그 코드에 대한 쓰기 권한이 없다. (MCP = 챗봇이 외부 도구를 호출하는 표준 프로토콜)
  • 패널을 붙일 앱이 둘이다. 3D 현장 뷰어와 관리 콘솔인데, 렌더링 능력이 완전히 다르다.
  • 라이선스 등급이 4단계이고, 사용자가 보유하지 않은 등급의 데이터는 인용에도 썸네일에도 문장에도 나오면 안 된다.

3. 아키텍처

3.1 전체 구조

flowchart LR
    subgraph HOST["패널이 붙는 앱 2종"]
        SV["3D 현장 뷰어"]
        PAC["관리 콘솔"]
    end

    subgraph PANEL["패널 UI (Angular 배포용 라이브러리)"]
        AD["HostAdapter · 9개 메서드<br/>뷰어 상태 구독 ↔ 인용 클릭 처리"]
        CARD["인용 카드 · 패널 상태 11종"]
    end

    subgraph SRV["챗 서버 (TypeScript)"]
        GATE["권한 검사<br/>툴 호출 전에 차단"]
        LOOP["오케스트레이터 루프<br/>플래너 ↔ 리스폰더"]
        JUDGE["판정 계층<br/>숫자 등급 · 부재 판정<br/>위장 실패 탐지 · 부분집합 검사"]
        ANCH["지시문 주입<br/>코드가 알아낸 사실을 모델에게 알림"]
    end

    subgraph EXT["외부 (쓰기 권한 없음)"]
        MCP["다른 팀 MCP 서버<br/>사진 검색 · 데이터 조회 외 4종"]
        LLM["관리형 LLM"]
    end

    SV --> AD
    PAC --> AD
    AD -->|"보고 있는 화면 + 인증 + 권한 힌트"| LOOP
    LOOP --> GATE
    GATE -->|통과| MCP
    GATE -->|"차단<br/>(데이터가 아예 안 옴)"| ANCH
    MCP --> JUDGE
    JUDGE --> ANCH
    ANCH --> LLM
    LLM -->|"문장만"| LOOP
    JUDGE -->|"구조화 채널<br/>인용 · 개수"| CARD
    CARD -->|"클릭 = 앱 상태 전환"| SV

    classDef judge fill:#0ea5e9,color:white
    classDef gate fill:#ef4444,color:white
    class JUDGE,ANCH judge
    class GATE gate

핵심은 모델이 두 갈래 중 한쪽에만 있다는 것이다. 데이터는 판정 계층 → 구조화 채널 → 카드로 흐르고, 모델은 그 옆에서 문장만 쓴다.

3.2 데이터와 문장을 분리한다

flowchart LR
    subgraph DATA["데이터 쪽 — 결정론적"]
        R["툴 결과"] --> ACC["집계 정보<br/>반환 수 · 전체 수 · 더 있음 · 커서"]
        ACC --> FRAME["인용 프레임<br/>숫자만, 문장 없음"]
        FRAME --> UI["카드 + 정직한 개수 표시"]
    end
    subgraph NARR["문장 쪽 — 모델"]
        ACC -.->|"등급 판정 결과를<br/>지시문으로"| A["지시문 주입"]
        A --> M["모델: 서술자<br/>숫자는 집계 정보에서만"]
        M --> P["답변 문장"]
    end
    classDef good fill:#10b981,color:white
    class ACC,A good

값은 구조화된 필드에서 결정론적으로 정하고, 표현은 모델이 그때그때 만든다. 서버는 문장을 직접 찍지 않고 지시문을 넣는다. 이 분리가 없으면 정직성 규칙이 전부 프롬프트 안에 쌓이는데, 프롬프트 규칙은 지켜졌는지 확인할 방법이 없다.

3.3 등급 사다리

flowchart LR
    U["UNKNOWN<br/>숫자 발화 금지"] -->|"집계 정보 도착"| S["SHOWN<br/>'이 N건은…'<br/>주어를 섞지 않아야 함"]
    S -->|"범위 축소 →<br/>1페이지 + 더 없음"| E["EXACT<br/>'정확히 N건'<br/>단정 가능"]
    L["LOWER_BOUND<br/>'최소 N건'<br/>단정 금지"] -->|"범위 축소"| E
    S -.->|"모집단 하한을 알 때"| L
    classDef e fill:#10b981,color:white
    classDef u fill:#6b7280,color:white
    class E e
    class U u

오른쪽으로 가는 화살표가 되묻기다. 이 사다리가 문서 전체의 뼈대다.


4. 기술 하이라이트

배치 순서는 새로움 × 업계 임팩트 기준이다. 각 항목은 문제 → 설계 → 트레이드오프 순.


4.1 숫자를 말할 자격을 4등급으로 판정한다

문제

대부분의 LLM 챗봇이 개수를 말하는 근거는 받은 배열의 길이 하나뿐이다. 그 배열이 전체인지 첫 페이지인지 판단하는 계층이 없다. 그런데 그 판단에 필요한 정보는 원래 있었다 — 검색 엔진이 전체 개수를 응답에 담아 보냈는데 중간 서버가 파싱하면서 버렸을 뿐이다.

더 나쁜 경우도 있다. 틀린 완료 신호가 온다. 데이터 조회 툴은 자연어를 SQL로 바꿔 실행하는데, 이때 LIMIT 3000을 자동으로 붙인다. 결과가 3,000건에 딱 차면 전체 개수도 3,000으로 고정되고, 페이지를 다 넘겼을 때 has_more: false를 준다. 파노라마가 7,961장인 프로젝트에서 실제로 그랬다. 서버가 자신 있게 틀린 것이다.

설계

말할 후보가 되는 모든 숫자를 모델이 입을 열기 전에 구조화된 필드만으로 4등급으로 나눈다. 판정 모듈(src/mcp/count-confidence.ts)의 헤더 주석이 설계 의도를 직접 밝힌다.

"every number that is a candidate for utterance is classified deterministically from structured fields BEFORE the model speaks, and the classification decides which phrasing the narration anchor permits. The model never grades a number itself."

등급 판정 조건 쓸 수 있는 문장
EXACT LIMIT 상한 미만인 전체 개수, 단일값 집계 결과 "may be asserted ('exactly N')" — "정확히 N건"
LOWER_BOUND 벡터 검색의 하한값, LIMIT 상한에 포화된 개수 "'at least N' only, never asserted as exact" — "최소 N건"만
SHOWN 반환·표시된 만큼 "Always spoken with the subject-separation rule ('these N')" — 주어를 "이 N건"으로
UNKNOWN 집계 정보가 없음 "No count may be uttered at all." — 숫자 금지

틀린 완료 신호는 우리가 덮어쓴다. applyLimitSentinel은 포화를 감지하면 검색 서버가 보낸 has_more: false를 무시하고 hasMore: true로 뒤집는다. 근거가 코드에 있다 — "a saturated page that was exhausted reports has_more: false upstream … but rows beyond the cap may exist."

그리고 이 재분류가 어느 쪽으로도 거짓을 만들지 않는다는 증명이 주석에 있다.

"even when the true total is exactly the sentinel value, 'at least N' still holds (>= includes =)."

뼈대가 되는 규칙은 "주어를 섞지 않는다"이다. 모델의 답변은 두 문장으로 나뉘고, 두 문장의 주어가 절대 겹치지 않는다.

  • 첫째 — 주어는 전체 모집단: "프로젝트 전체에서 최소 189건이 일치하는데,"
  • 둘째 — 주어는 받아 온 만큼: "이 38건은 3층에 등록됐고 …"

"결과는 3층이다"는 금지다(전체를 함의하니까). "이 38건은 3층이다"만 허용된다.

이 규칙은 검색 결과에만 적용되는 게 아니다. 사용자가 지금 보고 있는 화면 정보에도 똑같이 적용된다. 패널이 붙은 앱이 어노테이션 목록을 통신 상한까지 잘라서 보내기 때문에, 그 길이는 "a SHOWN-grade number, never a total" 로 취급되고 상한에 닿으면 "잘렸을 수 있다"고 알린다. 규칙이 일회성 패치가 아니라는 가장 확실한 증거다.

LIMIT 3000은 우리 상수가 아니다. 코드가 스스로 "an external contract, not our constant" 라고 밝히고, 환경변수로 덮어쓸 수 있게 해 두고, 정기 점검이 감시하는 외부 계약 목록에 등록해 뒀다. 상대가 상한을 올리면 우리 기준값이 조용히 가짜 EXACT를 만들기 시작하는데, 바로 그 상황을 막기 위해서다.

트레이드오프

  • 말할 수 없는 게 늘어난다. UNKNOWN이면 숫자를 아예 못 쓴다. 구 버전 서버를 상대하면 모든 응답이 UNKNOWN으로 떨어져서 개수 설명이 통째로 사라진다. 틀린 말을 하느니 아무 말도 안 하는 쪽으로 일부러 기울인 것이고, 그 손실은 §4.2가 되갚는다.
  • 외부 계약에 묶인다. 기준값이 상대편 프롬프트 상수의 사본이다. 하드코딩 금지, 계약 목록 등록, 정기 점검으로 완충했지만 의존 자체는 남는다.
  • 모델이 자유롭게 요약을 못 한다. 주어를 섞지 말라는 규칙 때문에 문장이 다소 길어진다. 정확성을 위해 지불한 자연스러움이다.

4.2 되묻기는 하한을 확정으로 바꾸는 연산이다

문제

§4.1만 있으면 챗봇이 정직한데 답답해진다. "안전 지적사항이 몇 건이야?"에 "최소 189건입니다"만 반복하면 사용자는 정직함을 얻고 답을 못 얻는다.

그리고 이건 게으름이 아니라 원리의 문제다. 의미 검색(벡터)에서 정확한 개수는 얻을 수 없다. 유사도 임계값을 어디서 자르느냐가 곧 개수인데, 그 경계가 질문마다 다르기 때문이다.

설계

제품 원칙을 이렇게 세웠다.

제한된 정보를 주는 한이 있어도 거짓 정보는 주지 않는다. 제한만 줄 수 있다면, 확정 가능한 숫자로 가는 길을 제시한다.

길이 둘이다.

① 집계 질의로 자동 전환. "몇 개야?" 같은 질문에는 리스트 결과를 세지 않는다. 대신 플래너가 데이터 조회 툴에 단일값 집계(COUNT/SUM/AVG/MIN/MAX) 를 다시 던지도록 유도한다. 단일값 집계는 LIMIT 주입 대상에서 빠지기 때문에 결과가 EXACT로 나온다. 집계 결과 행에 인용을 달 수 없다는 건 상관없다 — 숫자에는 인용 카드가 필요 없으니까.

② 범위를 좁히는 되묻기. 집계로 갈 수 없는 축(의미 검색 속성)에서는 되묻기의 목적을 다시 정의한다. 모집단을 전부 훑을 수 있는 크기로 줄이는 것이다. 규칙 원문이 이렇다.

"once a narrowed scope fits in one page with nothing more beyond it, the shown count IS exact for that scope, and you may say so."

좁힌 조건이 한 페이지 안에 들어가고 has_more = false가 되는 순간, 화면의 카드가 표본에서 전수로 바뀌고 등급이 SHOWN에서 EXACT로 올라간다. 되묻기는 UX 장식이 아니라 등급을 올리는 연산이다.

되묻기 자체도 규칙 아래 있다. "never promise counts or listings you have not retrieved" — 아직 가져오지 않은 개수나 목록을 약속하지 말라고 못 박아 뒀다. 그리고 제안은 툴이 실제로 받는 필터 항목만 근거로 삼는다(기간, 범위, SQL로 표현 가능한 차원). 실행할 수 없는 제안은 금지다.

더 일반화한 원칙도 같은 자리에 있다.

"When a request cannot be satisfied deterministically … do not refuse outright and do not silently pick one interpretation. Ask a brief counter-question that makes the outcome deterministic. If there is exactly one way to resolve the request, just ask; if there are several, offer the options and recommend one."

대표 사례가 권한이 일부만 있는 상황이다. 전부 거절하는 것도 아니고 말없이 일부만 진행하는 것도 아니라, "가능한 범위는 여기까지인데, 진행할까요?" 로 수렴한다.

실측

배포본 probe 2라운드에서 포트폴리오 질문에 선택지를 제시하는 되묻기가 나왔고, 두 라운드 모두 같은 형태였다. "I need to clarify your request … 1. Analyze your current/primary project … 2. List all your accessible projects first … Which approach would you prefer?"

트레이드오프

  • 왕복이 늘어난다. 한 번에 안 끝나는 질문이 생긴다. 대신 돌아오는 답은 등급이 올라간 답이다.
  • 모델이 규칙을 지켰는지는 유닛 테스트로 확인할 수 없다. 테스트가 확인하는 건 "지시문이 시스템 프롬프트에 들어갔다"는 사실뿐이고, 실제로 지켰는지는 배포본 관측으로만 확인한다(2/2 통과). 이 한계는 없앨 수 없다 — 모델이 쓴 자유 문장 위에 정규식 판정기를 얹는 건 더 나쁜 해법이라 금지했다. 실제로 한 번 그렇게 판정했다가 통과한 라운드를 실패로 오판했고, 결국 텍스트를 직접 읽어서 정정했다.
  • 되묻기가 남발되면 짜증난다. 그래서 "결정론적으로 답할 수 있으면 그냥 답하라"를 규칙 안에서 우선순위로 못 박았다.

4.3 "없다"고 말하려면 자격이 필요하다

문제

"검색 결과가 없습니다"는 챗봇이 제일 쉽게 하는 말이고 제일 위험한 말이다. 안전 점검 맥락에서 "해당 지적사항 없음"은 누군가 서명하는 문장이니까.

그런데 이걸 판단할 때 흔히 쓰는 두 신호가 둘 다 틀렸다.

"인용 0건 ≠ 결과 0건." 인용 조립기가 빈 배열을 반환하는 건 결과가 없다는 뜻이 아니라 인용을 달 수 없다는 뜻이다 — 딥링크 대상이 없는 테이블, id를 SELECT하지 않은 SQL, 집계 행 같은 경우. 모듈 자신의 주석이 빈 배열 반환은 "far more often than it returns refs" 이며 "the designed behaviour, not a failure" 라고 밝힌다. 여기서 부재를 읽으면 결과를 눈앞에 두고 있는 사용자에게 "결과 없음"을 말하게 된다.

"히트 0건 ≠ 결과 0건." 애초에 검색이 아니었던 응답도 0을 센다. 실제로 관측된 4종: 툴 자체 에러, 권한 차단 시 넣는 가짜 결과, "프로젝트를 먼저 선택하세요" 같은 평문 응답, 게이트웨이가 뭉갠 빈 객체.

설계

readResultEmptiness(toolName, rawResult) → 'empty' | 'non_empty' | 'unknown'. 검사 순서가 그대로 논증이다.

  1. 결과 계약이 있는 툴이 아니면 → unknown
  2. 히트/행 수 > 0 → non_empty ("a result with findings in it is not empty whatever else the payload is missing")
  3. 툴 자체 에러 → unknown ("A tool-level failure is not a statement about how much data exists.")
  4. 서버가 "이건 내 검색 결과다"라고 집계 정보를 붙여 보냈는가 → 그렇다면 empty, 아니면 unknown

4번이 핵심이다. 개수는 non_empty를 말할 근거로만 충분하고, empty는 집계 정보가 붙은 응답에서만 주장한다.

unknown은 계산 실패가 아니라 그 자체로 답이다.

그리고 부재는 원인에 따라 정답이 다르다. 화면은 똑같아 보여도 복구 경로가 넷이다.

원인 판별 방법 올바른 안내
권한 차단 호출 전에 막힘 "X 구독이 필요합니다" (구조화 카드)
인증 실패 HTTP 상태 확인 "인증에 실패했습니다"
좁힌 뒤 0건 우리가 범위를 보냈다는 우리 자신의 기록 "이전 결과에는 없습니다. 전체에서 다시 찾을까요?"
처음부터 0건 범위 지정 없음 "프로젝트에 해당 데이터가 없습니다"

능동 복구는 재실행이 아니라 범위 해제로 구현했다. 모델은 검색 범위를 제어할 수 없다. 범위는 턴 상태에서 만들어지고 마지막에 spread되면서 모델이 같은 키에 넣은 값을 덮어쓰기 때문이다. 모델이 "전체 검색"이라고 명시해도 실제로 나가는 호출은 바이트 단위로 동일하게 좁혀져 있다. 그래서 복구는 턴이 끝날 때 범위를 푸는 것으로 구현했고, 지시문은 실행 가능한 것만 약속한다. "범위는 이미 해제됐고 다음 검색은 전체를 덮는다. 이번 턴 안에서 다시 돌리지 마라."

트레이드오프

  • 구 버전 서버를 상대하면 전부 침묵한다. 집계 정보를 안 붙여 보내는 배포본에서는 모든 판정이 unknown이 되고 부재 설명이 사라진다. 의도한 안전 방향이다.
  • 상대편 협조에 의존한다. 이 판정은 서버가 자기 결과에 집계 정보를 붙여 준다는 계약 위에 선다. 그 계약을 얻어내는 작업이 §4.9다.
  • 모델이 알아서 "없는 것 같다"고 말할 여지는 남는다. 코드는 지시문을 넣을 뿐 모델 출력을 검열하지 않는다. 검열하면 자유 문장 판정이 되고, §4.2와 같은 문제가 생긴다.

4.4 HTTP 200으로 위장한 실패를 잡아낸다

문제

MCP 명세는 툴 레벨 실패를 JSON-RPC 에러가 아니라 결과 안에 넣는다. 그리고 우리가 붙은 MCP 서버는 한 술 더 뜬다. 자기가 부르는 검색 API가 죽으면 그 실패를 평범한 텍스트 블록으로 중계하면서 isError를 false로 둔다.

실측(서로 다른 질문 4개 × 프로젝트 2곳, 전부 동일): Search API error: 502 Server Error: Bad Gateway …, isError: false, 블록에 _meta 없음.

"Nothing in isError distinguishes that from an answer, so the loop handed it to the model as data and the turn answered with no citations and no basis."

모델이 에러 문자열을 데이터로 받아 그 위에 답을 지어냈고, 사용자에게는 실패 신호가 아무 데도 안 떴다.

같은 종류의 사고가 전에도 있었다. 401 응답에 JSON-RPC 에러가 안 실려서 툴 결과가 문자열 "{}" 가 되던 경로다. "{}"아무것도 못 찾은 검색과 바이트 단위로 똑같이 생겼다. 사용자는 인증이 끊긴 줄도 모르고 "내 데이터가 없구나"라고 듣는다.

설계

판정 근거를 에러 문구가 아니라 응답 메타데이터(_meta)의 부재로 옮겼다. 진짜 결과라면 아래 네 가지 표시 중 하나는 반드시 있다.

  1. 그 툴에 인용 계약이 있음 (없으면 평문이 정답이라 판정 대상이 아님)
  2. 서버가 _meta를 붙였음 — 어느 블록이든, 또는 구조화 콘텐츠가 있음
  3. 결과 구조가 있음 — 결과 테이블(0행이어도 통과: "a table is the server having answered with a result set") 또는 세어진 결과가 1건 이상
  4. 텍스트 블록이 상대편의 프로그램이 찍는 고정 문구로 시작함

설계를 가른 건 반대 방향 실측이었다. 배포본은 0건 응답에도 _meta를 붙인다. 날짜를 1999년으로 강제해 진짜 0건을 만들어 확인했다. 그래서 "0건"은 실패 근거가 될 수 없고, _meta가 없다는 사실이 정직한 판별 기준이 된다.

기각한 대안도 코드에 남겼다.

"A predicate that decided on hit units alone would ride entirely on one regex over [the upstream's] ## Image #N heading, so a heading rewrite on their side would flip EVERY photo turn to 'failed data source' … _meta survives a presentation change; the heading does not."

"THE ERROR TEXT IS NEVER CLASSIFIED. Keying the verdict on [their] wording would put it one rewording, one localization or one new upstream away from silently restoring the false-green."

4번(고정 문구)은 "실패가 아니다"를 말할 때만 쓴다. 절대 "실패다"의 근거로 쓰지 않는다. 상대가 문구를 바꾸면 최악의 결과가 원래 상태로 돌아가는 것이지 새로운 오판이 아니다.

틀릴 방향도 일부러 기울였다. "a false positive here suppresses a real answer and tells the user their data source is broken, so the predicate errs toward reading a failure as an answer rather than the reverse." — 여기서 거짓 양성이 나면 멀쩡한 답을 지우고 "네 데이터 소스가 고장났다"고 말하게 되므로, 판정은 실패를 답변으로 읽는 쪽으로 기운다.

로그도 개인정보 마스킹을 우회하지 않는다. 진단용으로는 원문 대신 블록 개수 · 타입 · _meta 키 이름만 남긴다.

트레이드오프

  • 위장 실패를 전부 잡지는 못한다. "프로젝트를 먼저 선택하세요" 같은 정상 평문 응답은 502 응답과 구조적으로 똑같이 생겼다. 그 축은 다른 방어선이 담당한다(호출 자체를 막는 범위 판정).
  • 상대편 _meta 계약에 묶인다. 상대가 _meta를 빼면 모든 응답이 실패로 읽힐 수 있다. 그래서 3번·4번 표시를 남겨 뒀다.
  • 판정 하나에 경로 4개를 유지해야 한다. isError 한 줄보다 관리 비용이 크다. 그 비용이 "자신 있게 틀린 답"보다 싸다는 판단이다.

4.5 모델에게 데이터 배달을 시키지 않는다

문제

실측: 사진 10건 결과가 1,175,854 바이트, 리스폰더 입력으로 약 57,000 토큰. 사진 10장 설명이 장편소설 한 권 분량이다.

그래서 각 계층이 자기 방어로 조용히 자른다. 상대편 상한 주석이 그대로 증언한다 — "50개가 넘어가면 responder node에서 에러 발생". 우리 쪽 상한이 10이었던 것도 같은 이유다. 둘은 같은 병의 두 증상이었고, 그 조용한 절삭이 §1의 거짓말 사슬을 만들었다.

설계

핵심은 이거다. 사용자가 사진을 실제로 "보는" 통로는 모델의 문장이 아니라 구조화된 인용 프레임 → UI 카드다. 모델이 데이터 배달까지 겸할 구조적 이유가 없다.

  • 데이터 쪽: 집계 정보를 인용 프레임에 {source, displayed, total, totalIsLowerBound, hasMore} 로 실어 보낸다. 숫자만, 문장 없음.
  • 문장 쪽: 모델에게는 등급 판정 결과를 지시문으로 준다.

덤으로 비용·지연·정직성이 한 설계로 같이 풀린다. 상한의 의미가 "cap"에서 "페이지 크기"로 바뀌고, has_more와 함께 있는 페이지는 정직하다.

프롬프트 캐시까지 고려해서 조립 순서를 잡았다. 턴마다 바뀌는 블록(보고 있는 화면, 좁히기 상태)을 맨 뒤로 미뤄서 앞쪽 고정 부분이 바이트 단위로 동일하게 유지되게 했다. 정직성 지시문을 추가하면서도 캐시 히트를 지킨 배치다.

트레이드오프

  • 절반만 만들었다. 모델 입력 자체를 압축하는 요약본 단계는 아직 안 만들었다. 지금 완결된 범위는 지시문 + 구조화 프레임까지고, 입력이 부풀어 오르는 문제 자체는 남아 있다(§7 로드맵).
  • UI가 구조화 프레임을 소비해야 의미가 있다. 서버만 고치면 카드가 안 그려진다. 실제로 서버가 정직한 개수를 보내기 시작한 뒤 UI가 그걸 쓰기까지 시차가 있었다.
  • 프레임 스키마가 커진다. 통신 계약이 늘어나고 양쪽 버전 차이를 관리할 비용이 생긴다.

4.6 권한은 사후 필터가 아니라 호출 전 차단으로 막는다

문제

사용자가 보유하지 않은 라이선스 등급의 데이터가 인용에도 썸네일에도 문장에도 나오면 안 된다. 흔한 구현은 둘이다. 프롬프트에 "권한 없으면 답하지 마"라고 쓰거나, 받아 온 결과에서 지우거나.

둘 다 실패 방식이 같다. 한 번 놓치면 그게 곧 유출이다.

설계

필터를 어디 둘지 네 가지 안을 비교하고 사후 필터를 명시적으로 폐기했다. 채택한 건 호출 직전 차단이다. 툴 호출이 나가기 전에 막으니까 데이터가 아예 도착하지 않고, 유출이 구조적으로 불가능하다.

방어는 2중이다. 카탈로그 필터가 모델 시야에서 툴을 미리 빼고, 그래도 부르면 인터셉터가 호출을 막는다. 둘이 같은 맵을 import하므로 서로 어긋날 일이 없다.

그리고 플래너를 일부러 눈멀게 했다. 플래너·리스폰더 프롬프트에 라이선스 관련 단어가 하나도 없어야 하고, 그걸 테스트가 강제한다. 모델이 권한을 모르면 모델이 권한을 새게 할 방법이 없다.

검증 현황(실측). 이 축에만 전용 테스트 파일 14개 / 131 케이스가 있고, 종류가 이렇게 갈린다.

테스트 종류 무엇을 고정하나
금지 조합 절대 허용되면 안 되는 (툴 × 권한) 조합
중복 정의 차단 인터셉터가 권한 맵을 자기 파일에 다시 정의하지 않았는지 소스를 읽어서 검사 — 두 경로가 갈라지는 순간을 잡는다
금지 어휘 플래너·리스폰더 프롬프트에 라이선스 단어가 없는지
감사 컬럼 고정 권한 판정 감사 로그의 컬럼 집합
e2e 브리지 클라이언트 힌트 → 서버 판정 왕복

안 만든 것 명시: 설계 문서에 적어 둔 권한(6) × 툴(10) × 힌트(2) = 120셀 전수 매트릭스는 아직 안 만들었다. 지금 커버리지는 위 14개 파일의 조합이고 전수가 아니다. 로드맵 §7.6 참조.

거절 문구도 모델이 짓지 않는다. 5개 언어 구조화 카피와 전용 SSE 프레임이 담당한다. 권한 판정을 데이터 웨어하우스에 감사 행으로 쌓는 경로도 만들어 뒀다(적재 자체는 트래픽 개시 전이라 미검증).

트레이드오프

  • 툴 카탈로그와 권한 맵이 정적이다. 상대편에 툴이 추가되면 맵을 갱신해야 한다. 런타임 조회로 바꾸면 어긋날 일은 줄지만 판정이 런타임에 의존하게 되어 전수 테스트가 원리적으로 불가능해진다. 검증 가능성을 택했고, 그 전수 검증은 아직 빚으로 남아 있다.
  • 눈먼 플래너는 계획을 최적으로 못 짠다. 권한 없는 툴을 계획에 넣었다가 차단당하는 왕복이 생긴다. 그 비용으로 유출 표면 하나를 통째로 없앤다.
  • 거절이 두 번 나올 뻔했다. 구조화 카드와 모델 문장이 같은 거절을 중복해서 말하는 문제였다. 서버 지시문 교체 + UI 중복 제거로 2중으로 잡았는데, 측정해 보니 모델은 여전히 재서술했고 사용자에게 보이는 결과를 한 목소리로 만든 건 UI 쪽 중복 제거였다. 프롬프트 준수는 보조 신호이고 하중은 결정론적 계층이 진다 — 이 프로젝트에서 반복되는 패턴이다.

4.7 정상 경로와 에러 경로를 같은 비중으로 테스트한다

문제

LLM 챗봇의 데모 검증은 대개 정상 경로 스크린샷이다. 그런데 실제로 사용자를 화나게 하는 건 에러 경로다. 권한 부족, 0건, 범위 거절, 인증 만료, 툴 502. 재현이 어려워서 테스트를 안 하게 되고, 그래서 데모 당일에 처음 발견된다.

설계

① 금지된 전이를 표로 적는다.

대상 상태 × 이벤트 행 수
SSE 프레임 순서 6 × 10 60
좁히기 부분집합 검사 20
테스트 하네스 자신의 상태 6 × 6 36

각 행이 {id, from, event, to, legal, sideEffect} 형태다. 절반 이상이 legal: false다. 에러 경로가 1급 시민이라는 뜻이다. 금지된 순서에는 이름까지 붙어 있다(delta-before-start, plan-after-delta, unterminated, duplicate-terminal).

② 전 경로를 CLI로 재현한다. 배포본 왕복 probe(K라운드 반복), 로컬 전체 시나리오 스크립트, SSE 클라이언트, 턴 루프, 인용 diff, 콜드 스타트 지연 측정, 부팅 스모크, 인증 매트릭스 probe, 상대편 버전 확인 등 17개. 회귀가 나면 브라우저를 열기 전에 명령 한 줄로 재현한다.

③ 브라우저 e2e 16개 — 포트폴리오 질의, 좁히기, 자산 카드, 권한 부족, 앱별 마운트, 로케일·컨텍스트 전달, 인용 딥링크, 트리거.

④ 그리고 e2e가 붙는 가짜 서버 자체를 계약 테스트한다. 여기가 핵심이다. 그 파일 헤더에 이렇게 적혀 있다.

"Proves the substrate the deterministic corpus stands on, so a spec failure can be attributed: if THIS is red, no [e2e] spec verdict means anything."

검사 3종: (1) 빌드 산출물이 진짜 HTTP/SSE로 시나리오 프레임을 흘려보내는가, (2) 토큰 없이 부르면 401로 막는가"this is what makes that forwarding load-bearing rather than decorative", (3) e2e가 DOM에서 확인하는 바로 그 필드들이 왕복하는가.

이게 가짜 서버가 진짜와 따로 노는 상황(e2e는 초록불인데 프로덕션이 깨지는 것)을 구조적으로 막는다.

규모

지표
서버 소스 / 테스트 25,283줄 / 54,227줄 → 2.14 : 1
테스트 케이스 약 2,817 (파라미터 확장 미포함, 보수적 수치)
통합 테스트 디렉터리 74
패널 UI 10,284줄 / spec 21개

트레이드오프

  • 기법 자체는 새롭지 않다. 전이표 기반 테스트나 계약 테스트는 분산 시스템 쪽에서 이미 성숙한 도구다. 새로운 건 이 계층에 가져왔다는 사실이지 기법이 아니다. LLM 스트리밍 UX의 에러 경로에 이걸 씌우는 제품이 사실상 없다.
  • 전이표는 유지 비용이 있다. 프레임을 하나 추가하면 행이 6개 늘어난다. 대신 "이 순서가 합법인가"가 코드 리뷰 논쟁이 아니라 표 조회가 된다.
  • 가짜 서버 계약 테스트는 빌드 산출물이 필요하다. 실행 전에 dist 빌드를 해야 해서 CI 단계가 하나 늘어난다.

4.8 화면 상태를 입력으로 쓰면서, 서버는 어느 앱인지 모른다

문제

"이 층에 있는 안전 지적사항"에서 "이 층"은 대화에 없다. 사용자가 3D 뷰어에서 보고 있는 화면에 있다. 대화 컨텍스트만 쓰는 챗봇은 이 질문에 답할 수 없다.

동시에 패널을 붙일 앱이 둘인데 능력이 완전히 다르다. 3D 뷰어는 카메라를 움직일 수 있고 관리 콘솔은 라우팅밖에 못 한다. 서버가 앱별로 분기하기 시작하면 앱이 늘 때마다 서버가 커진다.

설계

보고 있는 화면 정보를 통신 계약으로 만들었다. 현재 열려 있는 프로젝트·리뷰·팀 이름, 촬영 라벨과 시각, 층, 파노라마 id, 선택된 어노테이션, 그 파노라마의 어노테이션 목록까지. 전부 선택 항목이고, 앱은 자기가 정직하게 갖고 있는 것만 보낸다(관리 콘솔은 이름만, 3D 뷰어는 전체). 서버는 도착한 것만 렌더링한다.

"a host sends what it honestly has … and the server renders only what arrived"

악의적인 앱을 가정한다. 모든 문자열에 길이 제한을, 배열에 개수 제한을 걸었다 — "every string bounded so a hostile or buggy host cannot balloon the prompt." 프롬프트를 신뢰 경계로 취급한다는 뜻이다.

여기에도 등급 규칙이 적용된다. 어노테이션 목록이 상한에 닿아서 오면 그 길이는 전체가 아니라 표시분이므로, 화면 정보 지시문이 잘렸을 수 있다고 알린다.

앱 차이는 전부 클라이언트 어댑터가 흡수한다. 9개 메서드 계약(getFacilityKey / getAuthToken / getLicenseTierSet / getCurrentSurfaceId / getViewerSnapshot / dispatchCitation / subscribeViewerState / resolveDeepLink / getIsAdmin)과 정적 능력 선언만으로 처리하고, 서버나 MCP 봉투에 앱별 분기를 넣는 것은 규칙으로 금지했다.

서버 세션 상태를 안 쓰기로 한 것도 같은 축이다. MCP 서버는 "현재 프로젝트"를 서버 쪽 세션에 갖고 있고, 인자를 생략하면 DB에 저장된 값으로 폴백한다. 다른 클라이언트가 남긴 오래된 값이 조용히 쓰일 수 있다는 뜻이다. 우리는 매 호출마다 인자를 명시해서 그 경로를 안 탄다. 여러 프로젝트를 훑는 질의는 저장된 값에 오염되지 않도록 별도 테스트로 강제한다.

트레이드오프

  • 통신량이 늘고 프롬프트 비용이 는다. 화면 정보 블록만큼 매 턴 토큰을 더 쓴다. 캐시 고정 구간 뒤로 배치해서 완화했지만 비용 자체는 남는다.
  • 앱이 거짓말하면 못 막는다. 길이 제한은 부풀리기를 막을 뿐 내용의 진위는 못 본다.
  • 어댑터 계약이 9개로 고정돼 있다. 새 능력이 필요하면 인터페이스를 바꿔야 하고 두 앱이 동시에 따라와야 한다. 그 경직성이 서버가 앱을 모르게 유지하는 값이다.

4.9 권한 없는 외부 팀 API를 두 번 바꾸고, 구현 차이는 우리가 막았다

문제

절삭 사슬의 두 번째 고리가 우리 것이 아니었다. MCP 서버는 다른 팀 자산이고 우리에겐 읽기 권한만 있다. "전체 개수를 같이 보내 달라"는 요청은 그쪽 우선순위에 없으면 그냥 안 된다.

설계

측정 리포트를 지렛대로 썼다. 요청서에 세 가지를 넣었다.

  1. 심볼 단위 인용 — 어느 함수가 어느 필드를 버리는지
  2. 재현 명령 — 그쪽이 직접 돌려볼 수 있게
  3. 사내 선례 지목같은 저장소 같은 폴더의 다른 툴은 이미 "Showing N of M" 규약을 쓰고 있고, 딱 한 툴만 거기서 벗어나 있다

세 번째가 결정적이었다. "우리 요구를 들어달라"가 "당신들 규약이 한 곳에서만 안 지켜지고 있다"로 바뀐다.

두 번 배송받았다.

시점 내용
2026-07 집계 메타데이터 신설(returned / total 또는 total_lower_bound / has_more) + 거짓 Total: 라벨을 Showing으로 교체. Python·TypeScript 양쪽
2026-08 커서 페이지네이션 + 백엔드 조회 상한 30 → 200

우리 재측정: 같은 질의의 도달 가능한 결과 16 → 38, 페이지 4번에 소진 · 중복 0 · 마지막 커서 null.

그리고 받은 구현이 요청과 다른 지점을 잡아냈다. 데이터 조회 툴의 커서가 SQL을 고정하지 않고 매 페이지마다 자연어 질문을 다시 실행한다는 것을 소스를 읽어 확인했다. 같은 SQL이 다시 생성되는 동안에만 페이지가 이어진다는 뜻이고, 그건 계약상 보장이 아니다. 왕복 테스트에서 연속성이 완벽했던 건 그 실행의 우연이다.

그래서 우리 쪽에 방어 조건을 넣었다.

  • I-a 페이지 간 전체 개수가 동일할 것 (하한인지 정확값인지까지)
  • I-b 페이지끼리 id가 겹치지 않을 것
  • 위반하면 이어붙이기를 폐기하고 1페이지로만 답한 뒤 그 사실을 알린다 — "never a blended population"
  • 커서 거절이 isError: false 로 오기 때문에 탐지는 구조로 한다 (집계 정보 없는 페이지 = 신뢰할 수 없는 페이지)
  • 이어붙인 전체 개수는 절대 페이지 합이 아니다 — 검증된 페이지별 전체 개수를 유지하고 "표시한 개수"만 누적한다
  • 이상 상황 4종을 enum으로 명시 + 페이지 상한 5(설정 가능)

트레이드오프

  • 외부 일정에 묶인다. 배송을 기다리는 동안 우리 쪽은 "전체 개수를 모르는 상태에서 할 수 있는 정직한 최선"으로 버텨야 했다.
  • 이어붙인 결과는 사용자에게 개수로 말하지 않는다. id 수집 용도로만 쓰고 5페이지에서 끊는다. 보수적이지만, 페이지 사이에 모집단이 바뀔 수 있는 계약에서는 이게 유일하게 정직한 선택이다.
  • 위반이 나면 사용자 경험이 나빠진다. 이어붙이기를 버리고 1페이지로 답하면 결과가 줄어든다. 줄어든 정직한 답이 늘어난 뒤섞인 답보다 낫다.

4.10 테스트는 초록불인데 죽어 있던 경로를 찾아내고, 재발을 게이트로 막았다

(사고 서사는 §6.1에 있다. 여기서는 그 결과로 심은 구조만.)

문제

작업 항목을 "완료"로 넘기는 유일한 관문이 머지 검증이었다. 머지와 완료 사이에 코드를 실제로 실행해 보는 단계가 하나도 없었다. 그래서 "테스트 초록불 + 머지 완료 + 항목 완료"인데 한 번도 동작한 적 없는 경로가 21장분 쌓였다.

설계

여섯 가지를 심었다.

  1. 양쪽을 이어 붙여 돌리는 테스트를 의무화. 실측해 보니 내부 자료구조를 쓰는 테스트가 8개, 통신 스키마를 쓰는 테스트가 6개, 둘을 이어서 쓰는 테스트가 0개였다. 양쪽을 따로 테스트하면 영원히 초록불이다. 이제 파서의 출력을 그대로 소비자에게 먹이는 테스트를 요구한다.
  2. 계약 점검을 강제로 발화시킨다. 관측 게이트를 열 때 점검이 오래됐으면 exit 5로 거부한다. 우회 플래그는 하나뿐이고, 썼다는 사실이 게이트 증거에 박힌다.
  3. 점검 기록에 커밋 sha와 점검한 경로를 둘 다 남긴다. 경로가 없으면 점검이 한 번도 열어 본 적 없는 경로를 소유한 게이트가 남의 작업 덕분에 초록불이 된다.
  4. 외부 계약 경계 103개를 목록으로 고정. external-wire 41 / pinned-mirror 31 / internal-seam 15 / doc-citation 12. 경계를 나열하는 단계가 비결정적이라 실제로 9개를 놓친 적이 있어서, 이미 이름 붙은 경계를 파일에 고정하고 다음 점검의 하한으로 삼는다.
  5. 미검증 항목 누적 감시. 지표 정의는 이렇다 — 관측 게이트 하나 뒤에 직접 걸린 코드 항목 수 = 아무도 실행해 본 적 없는 토대 위에서 "완료"에 도달할 수 있는 항목 수. 임계를 넘으면 경고한다. 도입 시점에 최종 게이트 하나가 51장을 달고 있었다.
  6. "이 게이트를 누가 부르는가"를 같은 커밋에서 답하기. 이 규칙을 적용한 날 3건이 한 번에 나왔다. 어느 파이프라인에도 연결되지 않은 점검 도구, 실행 런타임이 없어 exit 127로 죽으면서 clean install한 사람에게는 한 번도 안 돌던 배포 전 스모크 테스트, 그리고 룰과 타깃은 있는데 CI 워크플로 34개 중 호출이 0개인 모듈 경계 lint.

덤으로 릴리스 파이프라인이 3채널 승격 구조가 됐다. 상위 환경은 다시 빌드하지 않고 이전 환경이 실제로 돌린 그 산출물을 그대로 올린다.

트레이드오프

  • 게이트가 사람을 막는다. 점검이 오래되면 릴리스가 멈춘다. 우회는 가능하지만 흔적이 남는다. 흔적 없는 우회보다 낫다.
  • 경계 103개는 관리 대상이다. 늘기만 하고 줄지 않으면 점검 비용이 커진다.
  • 누적 감시는 경고일 뿐 차단이 아니다. 일부러 그렇게 뒀다. 차단으로 만들면 임계값을 올려서 조용히 시키려는 유인이 생긴다.

4.11 인용 클릭은 링크가 아니라 앱 상태 전환이다

문제

RAG 챗봇의 인용은 URL이다. 클릭하면 새 탭이 열리고 사용자는 맥락을 잃는다. 3D 현장 데이터에서는 더 나쁘다. 의미 있는 대상은 "그 사진"이 아니라 "그 지점을, 그 시선으로" 보는 것이기 때문이다.

설계

인용을 클릭하면 앱의 명령 API를 직접 호출한다. 딥링크 페이로드에 앱 스코프 4필드 + BIM 3축 + 좌표 3필드(카메라 위치, 바라보는 점, 시선 방향) 가 실린다. 강제 조항은 전체 리로드 없음이고 e2e가 고정한다.

성능 수치를 수용 기준에서 내린 일화가 이 축의 정직성을 보여준다. 원래 "클릭 → 이동 500ms 이내"가 수용 기준이었는데 진단용 상한으로 격하했다. 근거가 3단이다. (a) 측정 구간에 엔진 카메라 애니메이션이 들어 있고 그 기본값만 600ms라, 네트워크를 0으로 쳐도 항 하나가 이미 예산을 넘는다. (b) 나머지 항의 하한은 CDN이지 우리 코드가 아니다. (c) 그 필드를 읽는 코드가 앱 전체에 0개라 한 번도 실측된 적이 없었다.

그리고 고객 대면 문서와의 차이를 "알려진 미해소 차이"로 남기고 숨기지 않는다고 명문화했다.

트레이드오프

  • 앱에 결합된다. 명령 API가 바뀌면 어댑터가 깨진다. 어댑터 계약으로 국소화했지만 결합 자체는 남는다.
  • 관리 콘솔에서는 반쪽이다. 3D 뷰어가 없으니 라우팅 이동이고, 좌표 필드가 거기선 의미가 없다.
  • 좌표 해석 체인이 길다. 요소 id → 모델 → world 변환까지 가야 해서 일부 축은 아직 미구현이다.

5. 정량 지표

5.1 코드·검증 규모

지표
서버 소스 25,283줄 / 121개 파일
서버 테스트 54,227줄 / 349개 파일 / 약 2,817 케이스 → 2.14 : 1
통합 테스트 디렉터리 74
패널 UI 10,284줄 / spec 21개
브라우저 e2e 16개
상태 전이 행 (허용 + 금지) 116+
CLI 재현 스크립트 17
릴리스 18번 / 31일
외부 계약 경계 등록 103 (4종 분류)
지원 언어 (구조화 카피) 5

5.2 개선 전후 (측정된 것만)

항목
백엔드 조회 상한 30 200
같은 질의의 도달 가능 결과 16 38
개수 라벨 Total: <잘린 개수> (거짓) Showing N + 집계 정보
페이지네이션 툴 6종 전부 커서/offset 0건 목록형 2종에 커서
좁히기 왕복 39 → 1 (부분집합 + 범위 적용 검증, 배포본 2/2)
툴 실패 탐지 isError 하나 메타데이터 부재 판정 (표시 4종)

5.3 못 채운 지표 — 그리고 그 이유

세 카테고리 중 하나가 비어 있고, 비었다고 보고한다.

지표 상태 이유
누적 사용자 · 세션 · 메시지 없음 프로덕션 사용자 트래픽 미개시
일평균 / 피크 없음 동일
처리 토큰량 미계측 단건 실측(10건 ≈ 57,000토큰)은 부하 지표가 아니라 설계 근거
연속 무중단 가동일수 개념 부재 서버리스 실행 모델
인프라 비용 조회 권한 밖
첫 토큰까지 p50 / p95 미계측 트래픽 부재
프로덕션 장애 · MTTR 해당 없음 트래픽 부재

계측 코드는 있다 — 메트릭 발행과 데이터 웨어하우스 적재(툴 호출 로그, 권한 판정 로그)를 다 짜 뒀다. 코드가 있는 것과 데이터가 있는 것은 다른 문제이고, 코드의 존재를 수치인 척 쓰지 않는다.

시간·비용 절감 환산도 하지 않았다. 위 항목이 전부 비어 있어서 보수~낙관 범위조차 가정 위의 가정이 된다. "단일 점추정 금지"보다 "근거 없는 수치 금지" 가 우선한다.


6. 사고 사례

6.1 테스트는 초록불인데 죽어 있던 경로

증상. 데모를 처음 실제로 돌렸더니 시나리오 4종이 전부 실패했고 인용이 0건이었다.

첫 가설과 기각. 최근 회귀를 의심하고 커밋 이력을 문자열 단위로 뒤졌다(git log -S). 결과는 릴리스 태그 6개 전부에 존재. 회귀가 아니라 처음부터 동작한 적이 없었다.

제일 불편한 사실. 그 경로 위의 작업 항목 21장이 이미 완료 상태였고, 규칙은 전부 지켜져 있었다. 머지 검증은 100% 통과했다. 규칙 위반이 아니라 규칙대로 한 결과였다.

근본 원인. 두 겹이다.

  • 완료를 승인하는 유일한 관문이 머지 검증이고, 머지와 완료 사이에 실행 단계가 0개
  • 경계 양쪽을 따로 테스트하고 있었다. 파서 출력을 소비자에게 먹이는 테스트가 0개

구조 전환. §4.10의 여섯 가지. 요점은 버그를 고친 게 아니라 같은 사고가 다시 날 수 없는 형태로 파이프라인을 바꾼 것이다.

전환이 작동한 증거. 새 규칙("게이트를 만들 때 누가 부르는지 같은 커밋에서 답하라")을 기존 자산에 적용하자마자 3건이 바로 나왔다. 그중 하나는 배포 전 스모크 테스트인데, 실행 런타임이 설치돼 있지 않아 exit 127로 죽으면서 clean install한 사람에게는 한 번도 안 돌고 있었다.

남은 것. 누적 감시는 경고이지 차단이 아니다. 도입 시점에 최종 게이트 하나가 51장을 달고 있었고, 그건 같은 사고가 2배 규모로 예약돼 있었다는 뜻이다.


6.2 실패가 성공인 척하고 도착했다

증상. 특정 툴을 쓰는 턴이 인용 0건짜리 답을 만들었다. 사용자에게 실패 신호는 아무 데도 안 떴다.

분해 — 원인이 세 겹이었고 두 겹이 우리 것이었다.

원인
외부 검색 API가 502로 죽음 (질문 4/4 · 프로젝트 2곳에서 재현)
계약 그 실패가 isError: false + 메타데이터 없이 도착
우리 실패 감지기가 isError === true 하나뿐

설계를 가른 건 반대 방향 실측이었다. "0건이면 실패"로 가려다가, 날짜를 1999년으로 강제해 진짜 0건 응답을 만들어 봤다. 0건에도 메타데이터가 붙어 있었다. 그래서 "0건"은 실패 근거가 될 수 없고 메타데이터 부재가 판별 기준이 됐다.

기각한 대안도 기록했다. 히트 단위 정규식으로 판정하면 상대가 헤딩 표기를 바꾸는 순간 모든 사진 턴이 "데이터 소스 고장"으로 뒤집힌다. 지금 잃는 것보다 큰 손해다.

틀릴 방향을 일부러 기울였다. 여기서 거짓 양성이 나면 멀쩡한 답을 지우고 "네 데이터 소스가 고장났다"고 말하게 되므로, 판정은 실패를 답변으로 읽는 쪽으로 기운다.

같은 종류의 선례. 401이 빈 객체로 흘러서 아무것도 못 찾은 검색과 바이트 단위로 똑같아지던 경로. 같은 원리(전송 계층 상태를 읽는다)로 막았다.


6.3 아무도 거짓말하지 않았는데 결과가 거짓말이 된 개수

증상. "12장 보여줘"에 10장을 보여주며 "10장을 찾았어요."

분해. 잘라내는 지점이 3곳이었고, 각 층이 전체 개수 정보를 만들거나 받고도 다음 층에 안 넘겼다. 어느 층도 악의가 없었다.

프레이밍이 해법을 결정했다.

잘라내기를 없애는 게 아니라 "페이지"라는 정직한 계약으로 바꾼다. 페이지는 거짓말이 아니다. 페이지 크기를 "Total"이라고 부르는 게 거짓말이다.

분업. 외부 팀 4개 항목 / 우리 5개 항목으로 나누고, 각 항목에 심볼 인용과 실측을 붙였다. 외부 배송 2회 확보(§4.9).

우리 쪽 구조 전환. 4등급 체계 + 주어 분리 규칙 + LIMIT 상한 미러링 + 이어붙이기 방어 조건.

그리고 받은 구현의 차이까지 흡수했다. 커서가 SQL을 고정하지 않는다는 걸 소스를 읽어 잡아내고 우리 쪽 방어 조건으로 막았다. 받은 걸 그냥 쓰고 끝낸 게 아니라, 약한 지점을 특정해서 우리가 봉인한 것이다.


7. 한계와 로드맵

강점 서술과 절을 분리해서 적는다. 아래는 지금 참인 것이다.

7.1 응답 속도 — 목표 미달

첫 토큰까지 P95 3.5초가 목표인데 달성하지 못했다. 실측은 콜드 스타트 약 30초, 웜 상태 20초 이내, 일부 라운드 43초. 데모 가이드에 "첫 질문은 워밍업으로 한 번 버리세요" 라고 적혀 있다는 사실이 현재 상태를 정확히 말해 준다.

원인은 셋으로 갈린다. 콜드 스타트, 툴 왕복(플래너 라운드 포함), 그리고 §4.5에서 절반만 끝낸 입력 압축. 우선순위는 입력 압축이다. 검색 결과 전문이 모델 입력으로 들어가는 한 결과량이 곧 지연이다.

7.2 프로덕션 검증 부재

계측 코드는 있지만 집계할 트래픽이 없다. 아래는 프로덕션 개시 전까지 원리적으로 공란이다.

  • 실사용자 규모, 세션·메시지 누적
  • 첫 토큰 지연과 에러율의 실제 분포
  • 장애 이력과 MTTR
  • 인프라 비용과 토큰 단가

이 문서는 그 공란을 채우지 않았다.

7.3 모델이 규칙을 지켰는지는 유닛으로 보증 안 된다

프롬프트 계층에 사는 정직성 규칙(되묻기, 주어 분리, 모집단 명시)은 지시문이 들어갔는지만 테스트한다. 실제 준수는 배포본 관측으로만 확인한다. 실측에서 모델이 지시를 어긴 사례가 나왔고(거절을 다시 말하지 말라는 지시를 2/2 라운드에서 위반), 사용자에게 보이는 결과를 구한 건 UI 쪽 중복 제거였다. 프롬프트 준수는 보조 신호이고 하중은 결정론적 계층이 진다.

7.4 외부 계약 의존

LIMIT 상한값, 메타데이터 필드, 커서 의미론이 전부 외부 계약의 사본이다. 계약 경계 등록과 정기 점검으로 어긋남을 감시하지만 의존 자체는 남는다. 특히 데이터 조회 툴의 커서가 SQL을 고정하지 않는 문제는 우리 쪽 방어 조건으로 막고 있을 뿐 해결되지 않았다. 상대 팀에 결정성 여부를 물어 둔 상태다.

7.5 라우팅 비결정성

플래너가 파노라마 질의에 데이터 조회 툴을 고르는 라운드가 실재한다(3라운드 중 1회 관측). 툴 설명이 상대편 소유라 우리 어휘가 아니어서, 공유 지시문으로 라우팅 규칙을 추가했다. 그 규칙이 실제로 오선택을 없애는지는 배포 후 왕복으로만 검증된다.

7.6 로드맵

우선순위 항목
P0 모델 입력 요약본 — 전문 대신 요약을 먹여 지연·비용을 결과량에서 분리
P0 "더 보기" UX — 집계 정보는 이미 나가고 있으니 UI 소비와 재조회 엔드포인트만 남음
P1 안전 필터 구현 — 입력/출력 훅과 프로덕션 차단 프레임
P1 권한 120셀 전수 매트릭스 — 현재는 14개 파일·131 케이스의 부분 커버리지
P1 프로덕션 계측 개시 후 지연·에러율 분포 확보
P2 딥링크 좌표 체인 완성(요소 → world 변환)

8. 부록

8.1 기술 스택

계층 스택
챗 서버 TypeScript, Node.js, 서버리스 실행 + Function URL, SSE 단일 스트리밍
LLM 관리형 LLM (플래너/리스폰더 역할 분리, 프롬프트 캐시 활용)
툴 연동 MCP (JSON-RPC over HTTP), 커서 페이지네이션
상태 관리형 세션 + 세션 레지스트리, 대화·피드백 내용 AES-256-GCM 저장 암호화(키 슬롯 회전, 폐기 키 조회)
인증 JWKS 기반 RS256 서명 검증, 2종 토큰 계열 지원, 세션 소유권 바인딩
관측 CloudWatch 메트릭, 데이터 웨어하우스 적재(툴 호출·권한 판정)
패널 UI Angular 배포용 라이브러리, 시그널, 어댑터 DI
검증 vitest(단위·통합), node:test(계약), Playwright(e2e), CLI probe 17개
릴리스 3채널 승격(dev → qa → prod), dist-tag 기반

8.2 서버 모듈 지도 (판정 계층 중심)

src/mcp/
  count-confidence.ts     숫자 4등급 판정 + 검색 서버의 틀린 완료 신호 교정
  identifier-assembler.ts 인용 조립 + 부재 판정(readResultEmptiness)
  result-identity.ts      집계 정보 판독(carriesResultAccounting)
  in-band-failure.ts      위장 실패 탐지(isInBandToolFailure) + 구조 fingerprint
  page-stitcher.ts        페이지 이어붙이기 방어 조건 I-a / I-b + 이상 4종
  narration-anchor.ts     코드가 알아낸 사실을 모델에게 알리는 지시문 11종
  interceptor.ts          권한 검사 (호출 전 차단)
  tool-capability-map.ts  툴 → 권한 정적 맵 (카탈로그 필터와 공유)
  envelope.ts             범위 주입 (모델 값을 덮어쓰는 spread)
src/responder/
  subset-gate.ts          좁히기 부분집합 검사 (전이표 20행)
src/frame/
  fsm.ts                  SSE 프레임 순서 FSM (전이표 60행)
src/replay/
  harness-fsm.ts          테스트 하네스 자신의 FSM (전이표 36행)

8.3 재현

이 문서의 모든 코드 수치는 저장소에서 아래 형태로 재현된다.

# 소스/테스트 줄 수 비율
git ls-tree -r --name-only <ref> <server>/src | grep '\.ts$' \
  | while read f; do git show <ref>:"$f" | wc -l; done | awk '{s+=$1} END {print s}'

# 전이표 행 수
git show <ref>:<server>/src/frame/fsm.ts | grep -c "legal:"

# 계약 경계 목록
grep -oE '"kind": "[a-z-]+"' contract-boundaries.json | sort | uniq -c

라이브 수치(도달 가능 결과 16 → 38, 하한 189 대 유니크 38, LIMIT 3000 포화, 0건 응답에도 메타데이터 동반, 좁히기 39 → 1)는 CLI probe 스크립트로 재현하며, 각 명령과 측정 시점은 별도 부록에 기록해 뒀다.


요약본: 요약 문서 (1~2p) English: 전체 포트폴리오 · 요약 문서