---
id: beacon-chat
type: project
doc: portfolio
url: https://auejin.com/ko/projects/beacon-chat/portfolio/
lang: ko
---

# 대화를 통해 인용 자료의 신뢰도를 능동적으로 끌어올리는 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 전체 구조

```mermaid
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 데이터와 문장을 분리한다

```mermaid
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 등급 사다리

```mermaid
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 재현

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

```bash
# 소스/테스트 줄 수 비율
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**: 전체 포트폴리오 · 요약 문서
