Files
document-haness/docs/TechLog/final/document.md
T
DongHyeonkaandClaude Opus 5 a8ce0dda07 docs(TechLog): 주제 셋을 스킬대로 다시 쓰고 SSOT 를 저장소 실물로 고친다
건너뛴 참조 다섯을 읽고 나서 다시 썼다 — from-ssot-to-records.md 의 「그림과 증거는
배정 대상이다」, code-tables-diagrams.md 의 표·코드 규칙, explaining.md 의 「이름을
댔으면 왜 있는지도 댄다」.

**SSOT 를 먼저 고쳤다.** §3.3 의 코드블록이 저장소와 달랐다 — PATH_PREFIX_KINDS 는
Record 표가 아니라 튜플 배열이고, 진짜 경로 표는 EXPLORE_KIND_PATHS 다. 저장소에서
확인해 실물로 바꾸고, javadoc 이 적어 둔 이유를 함께 옮겼다. §16.7 에 BRANCH_FIELDS 와
pathOf 실물을, §4.3 에 ContractRouteCoverageTest 의 javadoc 과 면제 상수 둘을 더했다.
62,643 → 65,737 자.

**계약에 ssot-assets·ssot-evidence 를 배정했다.** 그 절차를 건너뛰어서 SSOT 가 이미
가진 그림과 측정이 글감에 배정되지 않은 채였다. TechLog 12 글감, keycloak-session-store
는 그림 21장·증거 19건을 배정하고 붙일 글감이 없는 그림 4장은 이유를 계약에 적었다.
배정하자 검사기가 「배정한 증거를 기록이 쓰지 않는다」 4건을 드러냈다.

**주제 셋을 다시 썼다.**
  hand-listed-kinds            중앙값 1,925 → 3,760 자
  declared-but-not-implemented          → 2,608 자
  values-lost-between-boundaries        → 2,602 자

게시된 기록은 keycloak 4,546 · n+1liner 3,190 이다. 표와 코드를 SSOT 에서 옮기고,
Reference 에 담을 수 없던 표(§5.5 의 여덟 자리)를 짝이 되는 Case 로 내렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:54:45 +09:00

1688 lines
106 KiB
Markdown

# 계약이 먼저인 시스템에서 값이 사라지는 자리들 — TechLog를 만들며 만난 결함의 전수 기록
제가 만들려던 것은 기술 기록을 쓰고 게시하는 사이트였습니다. 저장소는 셋입니다. 설계 패키지가
OpenAPI 계약을 소유하고, 백엔드가 그것을 반입해 구현하고, 프론트엔드가 같은 계약에서 타입을
생성합니다. 계약이 한 곳에 있으니 세 저장소가 어긋날 일이 없을 것이라고 생각했습니다.
실제로는 계속 어긋났습니다. 다만 어긋나는 방식이 제가 예상한 것과 달랐습니다. 계약이 틀려서
깨진 적은 거의 없었고, **계약은 맞는데 그 값이 어딘가의 경계에서 조용히 사라지는** 경우가
대부분이었습니다. 화면은 오류를 내지 않고 빈칸을 그렸고, 저는 그것을 "아직 안 쓴 글"로
읽었습니다. 이 문서는 그 자리들을 하나씩 짚고, 각각을 어떻게 막았는지 적은 글입니다.
> **이 문서의 출처와 한계** — 여기 적은 결함은 2026년 8월 20일부터 9월 2일까지 세 저장소에
> 쌓인 **커밋 198개**(frontend 108 · backend 48 · design-package 42)의 메시지와, 그 기간의
> 작업 대화 기록에서 복원했습니다. 전체 목록은 부록 A 에 있습니다. 각 항목에는 커밋 해시를 붙였으므로 원문을 확인할 수
> 있습니다. 다만 커밋으로 남지 않은 것 — 중간에 버렸다가 되돌린 시도, 배포 로그, 화면을
> 눈으로 확인만 하고 지나간 것 — 은 이 기록에 없습니다. 그리고 여기 적힌 "이렇게 고쳤다"는
> 그 시점의 판단이고, 뒤에 다시 뒤집힌 것이 몇 건 있습니다(§5.3, §7.2). 뒤집힌 것은 뒤집혔다고
> 적었습니다.
> **근거 자료** — 본문의 주장 중 지금 재현할 수 있는 것은 `evidence/` 아래에 원본을 두었고,
> 각 항목에서 링크합니다. 이미 고쳐진 과거 결함은 실패 상태를 다시 만들 수 없으므로, 그 경우
> **「가드가 실제로 잡는다」와 「현재 상태가 고쳐져 있다」** 를 증거로 남겼습니다.
>
> | 파일 | 무엇 |
> |---|---|
> | [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) | 주제·축의 실제 행 |
> | [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) | 축에 걸린 기록과 공통 기록 |
> | [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) | 결정 주소가 앵커로 고쳐진 상태 · V15 적용 확인 |
> | [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt) | 삭제를 막던 참조와 그 해소 |
> | [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) | 그 링크가 실제로 200 인가 |
> | [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) | 서버가 내보내는 주소 35개 전수 감사 |
> | [`evidence/raw/audit/link-audit.py`](./evidence/raw/audit/link-audit.py) | 그 감사를 다시 돌리는 스크립트 |
> | [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt) | 가드 셋을 되돌려 실제로 빨개지는 것을 확인 |
> | [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt) | 손 목록이 표로 바뀌었는지 · **남은 구멍 둘** |
> | [`evidence/browser/`](./evidence/browser/) | 홈 주제 탭 세 단계의 화면과 실측값 |
>
> 도식 셋은 `assets/diagrams/` 아래에 SVG·`.drawio` 편집 원본·`.alt.md`·`.manifest.json` 로
> 있고, `.techviz/<id>/spec.json` 이 각 도식이 답하는 질문과 근거 목록을 적어 둡니다.
---
## 1. 시스템의 모양
### 1.1 세 저장소와 계약의 흐름
```
tech-log-design-package OpenAPI 3.1 계약 3종을 소유한다
contracts/openapi/
public-v1.yaml 공개 조회 20 operation
studio-v1.yaml 작성/게시 19 operation
studio-management-v1.yaml 주제·프로젝트·릴리즈 관리 86 operation
├─ 반입(vendoring) ─→ tech-log-backend/src/config/openapi/
│ MANIFEST.sha256 으로 원본 리비전을 고정
│ 생성기가 Java 모델을 만든다
└─ 반입 ─────────────→ tech-log-frontend/src/features/tech-log/contracts/
npm run generate:tech-log-contract
openapi-typescript 가 타입을 만든다
```
계약은 설계 패키지에만 있고, 나머지 둘은 **복사본을 들고 그 해시를 기록합니다.** 이 구조가
의도한 것은 "계약이 바뀌면 양쪽이 반드시 다시 반입해야 한다"는 강제입니다. 실제로 그 강제는
작동했습니다. 문제는 그 다음이었습니다 — **반입된 계약이 맞아도 그 값이 화면까지 오지 못하는
경로가 계속 나왔습니다.**
### 1.2 값이 지나는 경계
공개 화면 한 줄이 그려지기까지 값이 지나는 경계는 이만큼입니다.
```
PostgreSQL 테이블
└─ public_resource_projection (게시 시점에 굳어진 투영)
└─ JDBC 어댑터의 SQL (컬럼 이름을 컴파일러가 검사하지 않는다)
└─ *View 레코드 (application-core)
└─ *ResponseMapper (adapter/inbound/web)
└─ 생성된 DTO (계약이 만든 모양)
└─ HTTP envelope
└─ openapi-typescript 타입
└─ http-public-content-gateway 의 매퍼
└─ 포트 타입 (application/ports)
└─ 화면 컴포넌트
```
<!-- techviz:begin id=value-boundaries context-sha256=93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f -->
<!-- techviz:generate id=value-boundaries -->
![저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 tech-log-backend·전선·tech-log-frontend 세 구역으로 나눠 이은 흐름도. 묶음마다 그 안에 든 경계 수가 2·4·1·3·1 로 적혀 있다.](assets/diagrams/value-boundaries/value-boundaries.svg)
<details>
<summary>Diagram description</summary>
왼쪽에서 오른쪽으로 읽는다. tech-log-backend 구역에 저장 묶음과 백엔드 조립 묶음이 있고 각각 경계 둘과 넷을 담는다. 전선 구역에는 HTTP envelope 하나가 있다. tech-log-frontend 구역에는 프론트엔드 조립 묶음과 화면 컴포넌트가 있고 각각 경계 셋과 하나다. 다 더하면 열한 개이고, 각 경계의 이름은 그림 위 목록에 있다.
</details>
[Editable source](assets/diagrams/value-boundaries/value-boundaries.drawio) · [Grounded VizSpec](.techviz/value-boundaries/spec.json)
<!-- techviz:end id=value-boundaries -->
**열한 개입니다.** 그리고 이 문서에 적힌 결함의 절반 이상은 "이 중 한 경계가 값을 버렸다"는
같은 모양이었습니다. 버려도 아무도 오류를 내지 않습니다. `undefined` 는 빈 문자열로 그려지고,
빈 배열은 "항목이 없습니다"로 그려집니다.
### 1.3 배포
```
로컬 docker build → docker save | gzip → scp dh-server:/tmp/deploy.tar.gz
→ kube-system 의 containerd import Job → kubectl set image
```
레지스트리가 없습니다. 공개 Hub 는 소스가 들어간 이미지라 쓸 수 없고, k3s 의 containerd 소켓은
root 전용이라 사용자 셸에서 닿지 않습니다. 그래서 클러스터 안에 일회성 Job 을 띄워 tar 를
import 합니다. 배포 단위는 `hyeonworks.com`(prod) 하나이고 서브도메인은 쓰지 않습니다 —
공개는 `/`, API 는 `/api` 입니다.
---
## 2. 결함을 어떻게 갈랐나
198개 커밋을 읽고 나서, 결함이 **원인의 종류**로 갈린다는 것이 보였습니다. 화면 증상으로 나누면
"어디가 비었다"가 대부분이라 아무것도 배울 수 없습니다. 그래서 아래 열한 갈래로 나눴습니다.
| § | 갈래 | 건수 | 공통된 모양 |
|---|---|---|---|
| 3 | 손으로 나열한 목록이 새 종류를 삼킨다 | 13 | 삼항 사슬 / 배열 리터럴의 마지막 `else` |
| 4 | 계약에 선언만 있고 구현이 없다 | 10 | 화면이 조용히 빈다 |
| 5 | 계약에 자리가 없어 값이 경계에서 사라진다 | 12 | DB 에는 있는데 화면에 없다 |
| 6 | 타입 검사가 통과시키는 자리 | 7 | `as` / bivariance / `never` |
| 7 | 테스트가 지나지 않는 이음매 | 6 | "통과했는데 운영에서 깨진다" |
| 8 | 라우트를 더하면 함께 울리는 손 목록 | 8 | 배포 직전에야 드러난다 |
| 9 | 서버가 갈 곳 없는 주소를 만든다 | 4 | 404 |
| 10 | 실패를 없음으로 그린다 | 6 | 화면이 거짓말을 한다 |
| 11 | CSS 규칙이 구역을 넘어 샌다 | 3 | "디자인이 안 된 것처럼" 보인다 |
| 12 | 운영에서만 드러난 것 | 9 | CrashLoopBackOff / 배포 인자 |
| 13 | 글과 말 | 6 | 같은 것이 화면마다 다른 이름 |
| | **합계** | **84** | |
각 절은 **증상 → 원인 → 고친 방법 → 재발 방지**로 씁니다. 재발 방지가 없는 항목은 없다고
적었습니다.
> **건수를 세는 기준** — 커밋 하나가 결함 여럿을 고친 경우가 많아 **커밋 수(198)와 결함
> 수(84)는 다릅니다.** 여기서 한 건은 "증상 하나 · 원인 하나"이고, 같은 원인이 여러 화면에
> 나타난 것은 한 건으로 셉니다. 반대로 한 커밋이 서로 다른 원인 셋을 고쳤으면 세 건입니다.
---
## 3. 손으로 나열한 목록이 새 종류를 삼킨다
이것이 이 저장소에서 가장 많이 반복된 실패입니다. **열세 번** 나왔습니다. 매번 같은 모양이라
따로 이름을 붙였습니다.
### 3.1 모양
문서 종류는 다섯입니다 — `CASE`, `REFERENCE`, `QUESTION`, `CONCEPT`, `PROJECT_DECISION`.
이 다섯을 어딘가에서 **손으로 나열하는 코드**가 계속 생겼습니다. 삼항 사슬이거나 배열
리터럴이었습니다.
```ts
// 삼항 사슬 — 마지막 else 가 모르는 것을 다 받아 간다
const path = kind === "CASE" ? "/cases/"
: kind === "REFERENCE" ? "/references/"
: kind === "QUESTION" ? "/questions/"
: "/projects/"; // ← CONCEPT 이 여기로 떨어진다
```
새 종류(`CONCEPT`)를 더할 때 이 자리를 빠뜨리면, **오류가 나지 않고 잘못된 값이 나갑니다.**
마지막 `else` 가 모르는 것을 조용히 받아 가기 때문입니다.
### 3.2 실제로 일어난 열세 건
| # | 어디 | 증상 | 커밋 |
|---|---|---|---|
| 1 | 게이트웨이의 문서 삭제 분기 | 개념을 지우면 "질문을 찾을 수 없습니다" | `dec86bd` |
| 2 | 게이트웨이의 문서 조회 분기 | `/concepts/idp-brokering` 이 404 (질문 조회를 불렀다) | `8996430` |
| 3 | 응답→기록 변환 분기 | 불렸어도 질문 매핑으로 떨어졌을 것 | `8996430` |
| 4 | 공개 주소→종류 역추적 삼항 | 개념 관계가 전부 `PROJECT` 로 분류 | `618a228` |
| 5 | 탐색 목록 매퍼 | `type=CONCEPT` 결과 0건 (서버는 보냈다) | `4da6d77` |
| 6 | 지식 목록 매퍼 | 개념이 통째로 버려짐 | `dc2fda7` |
| 7 | 작업본 목록의 종류 필터 | 개념 작업본을 걸러 볼 수 없음 | `b89a54f` |
| 8 | 모의 검증기의 유형별 칸 목록 | 개념 편집 시 모든 칸이 "허용되지 않은 속성" | `77ef304` |
| 9 | 백엔드 컨트롤러의 허용 enum 상수 | `?type=CONCEPT``PUBLIC_REQUEST_INVALID` | `3a226fb` |
| 10 | `CatalogEntry.kind` (계약) | 개념 작업본 생성 즉시 `/studio/catalog` 400 | `32d1785` |
| 11 | `ResolvedRelation.targetKind` (계약) | 개념을 관계로 걸면 미리보기 깨짐 | `2c25ccc` |
| 12 | `RelatedEntry.type` (관리 계약) | Case 가 개념을 가리킬 수 없음 | `2c25ccc` |
| 13 | `PublicSql.pathOf` (백엔드) | CONCEPT 케이스 없음 → `null` 경로 | `8cd8ee3` |
10·11·12 는 **계약 자체**에 있던 것입니다. 계약이 종류를 열거하는 자리가 여러 곳이라, 계약을
고치면서도 같은 실수를 했습니다.
### 3.3 고친 방법 — 표로 바꾸고 컴파일러에게 맡긴다
삼항 사슬을 `Record<RecordKind, Value>` 로 바꿨습니다. 종류별 목록 주소가 그 예입니다
(`presentation/shared/document-kind-labels.ts`):
```ts
export const EXPLORE_KIND_PATHS: Record<RecordKind, string> = {
CASE: "/explore/cases",
CONCEPT: "/explore/concepts",
REFERENCE: "/explore/references",
QUESTION: "/explore/questions",
PROJECT_DECISION: "/projects",
};
```
같은 파일의 javadoc 이 이 표가 왜 한 곳에 있는지 적어 두었습니다:
> 이 대응이 세 화면에 흩어져 있었고 셋 다 개념을 빠뜨렸다 — 홈의 「종류별로 읽기」에는 개념이
> 아예 없었고, 문서 머리말의 종류 링크는 삼항의 마지막 else 를 타 개념 문서에서 `/projects` 로
> 갔다. `/explore/concepts` 는 처음부터 열려 있었는데 그리로 가는 길이 없었다.
>
> 결정은 프로젝트 안에서만 읽히므로 자기 목록이 없다. 그 자리를 `/projects` 로 두는 것은
> 빠뜨린 것이 아니라 그렇게 정한 것이고, 표에 적혀 있으니 다음 사람이 구분할 수 있다.
**표로 바꿀 수 없는 자리도 있습니다.** 공개 주소에서 종류를 거꾸로 알아내는 자리
(`public-document-header.tsx`)는 키가 종류가 아니라 주소 앞머리라서 `Record<RecordKind, _>`
성립하지 않습니다. 배열로 두고 못 찾은 것을 조각으로 가릅니다:
```ts
const PATH_PREFIX_KINDS: ReadonlyArray<readonly [string, TargetKind]> = [
["/cases/", "CASE"],
["/references/", "REFERENCE"],
["/questions/", "QUESTION"],
["/concepts/", "CONCEPT"],
];
function targetKindOf(path: string): TargetKind {
const matched = PATH_PREFIX_KINDS.find(([prefix]) => path.startsWith(prefix));
if (matched) return matched[1];
// 결정은 프로젝트 화면 안의 앵커로 산다. 그래서 앞머리가 아니라 조각으로 가른다.
return path.includes("/decisions#") ? "PROJECT_DECISION" : "PROJECT";
}
```
백엔드에서는 **sealed switch 를 식(expression)으로** 쓴 자리가 이 일을 이미 하고 있었습니다.
`fa5158d`(개념 종류 추가) 커밋 메시지에 그 효과가 적혀 있습니다:
> sealed switch 가 이 변경을 안내했다 — 종류를 더하자 컴파일러가 게시 상태 코드·활동 유형·
> 소유자 유형·slug 중복 검사·렌더 모델까지 빠짐없이 짚었다. 문이 아니라 식으로 써 둔 덕이다.
**같은 언어 안에서도 문(statement)으로 쓴 switch 는 아무것도 잡아 주지 않습니다.** 식으로
써야 컴파일러가 빠진 가지를 요구합니다.
### 3.4 재발 방지 — 계약을 읽어 대조하는 가드
표로 바꿔도 **계약과 코드가 어긋나는 것**은 컴파일러가 모릅니다. 그래서 계약 문서를 직접
파싱해 대조하는 가드를 넣었습니다.
- `knowledge-list-kinds.test.ts` — 계약의 종류 enum 을 읽어, 목록 매퍼의 표에 전부 있는지 본다
- `contract-operation-coverage.test.ts` — 계약이 선언한 연산이 기여 목록에 등록됐는지 본다
- `StudioContractUnionJacksonTest`(백엔드) — 모든 `RecordKind``CatalogEntry.KindEnum` 으로
변환되는지 순회한다. 계약에서 CONCEPT 을 빼면 실제로 빨개지는 것을 확인했다 (`dd7c70e`)
- 설계 패키지에서는 **세 계약을 파싱해 "CASE 와 REFERENCE 를 함께 열거하면서 CONCEPT 이 없는
enum"을 전부 뽑아** 확인했습니다 (`2c25ccc`). 눈으로 찾을 일이 아니었습니다.
> **근거** — 지금 코드에서 표로 바뀐 자리와 **아직 남은 구멍 둘**:
> [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt).
> `PublicSql.pathOf` 는 sealed enum 이 아니라 String 으로 switch 하므로 여전히 `default -> null`
> 이 남아 있고, `validate-working-copy.ts` 의 `stringFields` 도 아직 삼항 사슬입니다.
### 3.5 이 갈래에서 배운 것
같은 실수를 열세 번 하고 나서야 규칙으로 굳혔습니다.
1. **종류를 나열하는 자리는 반드시 `Record<Kind, _>` 나 sealed switch 식으로 쓴다.** 삼항
사슬과 배열 리터럴은 새 종류를 조용히 삼킨다.
2. **컴파일러가 잡을 수 없는 자리(계약↔코드)는 계약을 읽어 대조하는 테스트를 둔다.**
3. **가드를 넣었으면 그 가드가 실제로 잡는지 되돌려 확인한다.** 위 가드들은 전부 결함을
되돌려 빨개지는 것을 확인한 뒤에 커밋했습니다.
---
## 4. 계약에 선언만 있고 구현이 없다
계약은 "이 연산이 있다"고 말하는데 서버에는 그 컨트롤러가 없는 상태입니다. 프론트는 계약을
믿고 부르고, 서버는 404 를 돌려주고, **화면은 그것을 "데이터가 없음"으로 그립니다.**
### 4.1 화면 다섯 곳이 조용히 비어 있었다 (`561d02a`, `b3aa304`)
계약에 선언만 되어 있고 구현이 없던 네 연산과, 의도된 스텁으로 남아 있던 catalog 두 종류가
공개 화면 다섯 곳을 비워 두고 있었습니다.
| 무엇이 비었나 | 왜 |
|---|---|
| 홈 「지금 집중하는 것」 | `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣었고, `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로 **운영에서 한 번도 나타난 적이 없다** |
| 프로젝트 공개 여부 | 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데, 공개 화면들은 전부 `public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는 경로가 없었으므로 **프로젝트는 영원히 비공개였다** |
| 문서 사이 관계 연결 | `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 스텁이었다. 어떤 기록도 연결 대상 목록을 채울 수 없었다 |
| 프로젝트 활동 | 계약에 목록·생성·수정이 선언돼 있었지만 구현이 없었고 `project_activity` 는 0행이었다 (`4c14f1e`) |
| 릴리즈(변경 기록) | 읽는 쪽은 있는데 쓰는 쪽이 없어, 페이지는 영원히 빈 채였다 (`386f360`) |
가장 무서운 것은 **홈 focus** 였습니다. 세 슬롯이 다 비면 화면이 그 영역을 통째로 그리지
않으므로, 그런 영역이 있다는 사실조차 화면에서 알 수 없었습니다.
### 4.2 편집기가 부르는 두 목록이 없었다 (`911e8ba`, `46e4e81`)
`GET /v1/studio/questions``GET /v1/studio/projects/{id}/decisions` 가 계약에 있고 모델도
생성됐는데 **컨트롤러가 없었습니다.** 프론트는 계약을 믿고 불렀고 서버는 404 를 돌려줬으며,
화면은 그것을 「이 프로젝트에 열린 질문이 없습니다」로 그렸습니다 — 실제로는 넷이 있었고 공개
사이트에도 나오고 있었습니다.
**생성 모델 검사는 schema 와 property 만 보므로 이 구멍을 잡지 못합니다.** 모델은 멀쩡히
생성되기 때문입니다.
### 4.3 재발 방지 — 계약↔컨트롤러 전수 대조
`ContractRouteCoverageTest`(백엔드)를 세웠습니다. `@RestController` 들을 리플렉션으로 훑어
매핑을 모으고, 계약이 선언한 경로와 대조합니다. 클래스 javadoc 이 이 검사가 왜 생겼는지를
적어 두었습니다:
> `listStudioQuestions` 와 `listStudioProjectDecisions` 는 계약에 있고 모델도 생성됐는데
> 컨트롤러가 없었다. 생성 모델 검사(`verifyManagementGeneratedModels`)는 schema 와 property 만
> 보므로 이 구멍을 잡지 못한다. 프론트는 계약을 믿고 불렀고 서버는 404 를 돌려줬으며, 화면은
> 그것을 「이 프로젝트에 열린 질문이 없습니다」로 그렸다 — 실제로는 넷이 있었다.
>
> 기대 목록을 손으로 적지 않고 계약에서 읽는다. 연산을 더하고 컨트롤러를 잊으면 여기서 멈춘다.
면제는 상수 둘로 명시합니다. 대조에서 빠지는 것이 코드에 이름으로 남습니다:
```java
private static final Set<String> ELSEWHERE = Set.of("getPublicMedia");
private static final Set<String> SUPERSEDED_BY_WORKING_COPY_API =
Set.of(
"acceptProjectDecision",
"addQuestionUpdate",
"archiveCase",
);
```
- 작업본 API 로 대체된 **옛 연산 51개**는 `SUPERSEDED_BY_WORKING_COPY_API` 로 명시해 둡니다 —
"구현하지 않기로 한 것"과 "빠뜨린 것"은 다릅니다
- 봉투 없이 바이트를 주는 `/media` 하나만 `ELSEWHERE` 로 면제합니다
- 매핑을 떼어 보고 **그 연산 하나를 정확히 짚는 것**을 확인했습니다
프론트에도 같은 가드를 뒀습니다(`contract-operation-coverage.test.ts`) — **양쪽에서 봐야
한쪽만 지웠을 때 잡힙니다.**
### 4.4 등록되지 않은 연산은 타입에는 보이는데 부를 수가 없다
이건 프론트 쪽의 같은 병입니다. 계약에서 타입은 생성되므로 **에디터에서는 멀쩡히 보이는데**,
기여 목록(`tech-log-management-contract-contribution.ts`)에 등록하지 않으면 실행 시 부를 수가
없습니다. 이 누락을 **네 번** 만났습니다:
- `getPublicConcept` — 개념 화면이 질문 조회를 불렀다 (`8996430`)
- `deleteConceptDraft` — 개념 삭제가 질문 삭제를 불렀다 (`dec86bd`)
- `listStudioQuestions` / `listStudioProjectDecisions` — 홈 편집기가 빈 목록을 그렸다 (`2b04282`)
- 축(variant) CRUD 네 연산 (`15e6ea8`)
`15e6ea8` 커밋에서 가드를 둘 넣었습니다. 공개 계약은 **전수 대조**하고, 관리 계약은 **한 종류만
빠진 자리**를 봅니다 — 깨진 것이 늘 그 모양이었기 때문입니다.
---
## 5. 계약에 자리가 없어 값이 경계에서 사라진다
DB 에는 작성자가 쓴 값이 그대로 있는데, 계약에 그 칸이 없어서 화면까지 오지 못하는 경우입니다.
**열한 건**이 있었습니다. 이 갈래가 가장 오래 눈에 띄지 않았습니다 — 오류가 전혀 없기 때문입니다.
### 5.1 공개 Reference 가 통째로 비어 있었다 (`ff0c12a`, `a5f93b9`, `7211dd1`)
Reference 를 공개했는데 **Studio 에서는 다 보이고 공개 화면만 비어 있었습니다.**
원인이 둘 겹쳤습니다.
1. **게이트웨이가 읽던 이름이 계약에 없는 것들이었습니다**`purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 계약이 주는 이름은
`scopeSummary`, `appliesTo`, `excludedScope` 입니다. 전부 `undefined` 로 떨어졌고,
**`as string` 단언 때문에 타입 검사는 아무 말도 하지 않았습니다.**
2. Reference 의 본문은 `body_markdown` 이 아니라 `reference_detail.rules`/`examples`
있습니다. Studio 편집기가 규칙(제목+본문)과 예시를 따로 받고 마크다운 본문은 비워 두기
때문입니다. 공개 조회는 `body_markdown` 만 봐서 `content: ""` 를 내보냈습니다.
고친 뒤에 **값이 아니라 이름을 지키는 테스트**를 뒀습니다. 계약에서 그 칸이 사라지면
`satisfies` 가 먼저 깨집니다 — 이번 결함은 값을 검사해서는 잡히지 않았습니다.
### 5.2 관계의 요약이 경계 세 곳을 지나며 사라졌다 (`642afa8`, `a3ed23e`, `fa67a64`)
라벨은 고쳤는데 요약이 여전히 비어 있었습니다. 값이 **경계 세 곳**을 지나며 사라지고
있었습니다.
```
계약(요약 있음)
└─ flattenRelations 가 담지 않음 ← 1차로 고침
└─ 렌더 모델로 바꿀 때 버림 ← 담을 자리 자체가 없었다
└─ 화면 목록으로 넘길 때 또 버림
```
렌더 모델 계약(`ResolvedRelation`)에 담을 자리가 없었고 `additionalProperties: false`
실을 수도 없었습니다. 계약에 `summary` 를 더하고(required 아님 — 이미 나가 있는 응답을 깨지
않는다) 세 경계를 모두 이었습니다.
**교훈:** 한 경계를 고치고 "고쳤다"고 판단하면 안 됩니다. 값의 **여정 끝에서** 확인해야 합니다.
### 5.3 관계 한 줄에 세 가지가 뭉쳐 있었다 (`618a228`, `ca1bbfe`)
관계 한 줄이 답해야 하는 것이 셋인데 `reason` 한 칸을 지나고 있었습니다.
| 무엇 | 뜻 | 경로별로 어떻게 나왔나 |
|---|---|---|
| 대상의 종류 | 「근거」「관련 기준」 같은 분류 | 렌더 모델 경로: 작성자의 문장이 이 자리에 눌려 나옴 |
| 작성자가 쓴 이유 | 「다음에 무엇을 읽을지」의 답 | 공개 조회 경로: **아예 버려짐** |
| 대상의 요약 | 대상이 무엇인지 | — |
셋을 `label` / `note` / `summary` 로 갈랐습니다. 설명 자리에는 문장이 있으면 문장을, 없으면
요약을 보입니다 — **요약은 대상을 설명하고 문장은 왜 지금 이것을 읽어야 하는지를 설명합니다.**
### 5.4 결정 화면이 네 가지를 못 그렸다 (`987c1b8`, `026460f`, `31afb4d`)
공개 결정 화면에 네 가지가 어긋나 있었습니다 — 제목 자리에 결정문 전문이 나오고, 요약이 아예
없고, 줄바꿈이 전부 접히고, 영향과 근거 기록이 늘 비어 있었습니다.
원인이 하나로 모입니다. **결정에는 상세 endpoint 가 없습니다** — 공개 주소가 목록 위의
앵커입니다. 그래서 화면이 그리는 칸은 전부 목록 항목에 있어야 하는데
`title`·`summary`·`consequences`·`evidence` 가 빠져 있었습니다. 그래서 프론트는 `statement`
를 제목 자리에도 썼고 영향은 빈 배열로 고정해 뒀습니다. **DB 에는 작성자가 쓴 제목, 여러 줄
요약, 영향 4건이 그대로 있었습니다.**
### 5.5 나머지 여섯 건
| 무엇이 비었나 | 원인 | 커밋 |
|---|---|---|
| 문서 요약(제목 아래 한 줄) | 공개 응답에 `summary` 자리가 없어 유형별 요약을 대신 씀 → 머리말이 바로 아래와 같은 글을 두 번 말함 | `0ffbc28`, `c6d9d2d` |
| 프로젝트 「주요 주제」 | `project_topic` 테이블도 조인도 가능했는데 **응답에 실을 자리가 없었다** | `06ae075`, `6aa1400` |
| 프로젝트 기록 목록의 요약·주제·게시일 | `RelatedEntry` 를 그대로 실어 칸이 없었다 → 모든 줄이 "제목만 있고 · 만 남은" 모양 | `76a7ccb`, `f0407d9` |
| 질문 목록의 주제 | 지식 목록은 처음부터 `primaryTopic` 을 실었는데 질문 목록만 빠짐 → 질문 줄만 맥락이 「· 프로젝트」로 시작 | `a58ad30`, `e185b87` |
| 프로젝트·주제의 논지(thesis) | 담을 칸이 없어 `purpose`(시작할 때 쓰는 글)를 대신 보여 줌 | `2d9672d`, `78ec5f9` |
| 주제 목록의 논지·축 | 이름과 개수만 실어, 독자가 들어갈지 말지 정할 근거가 없었다 | `559d04f`, `22a65dc` |
| 프로젝트 목록 행의 slug | 다른 목록이 프로젝트를 가리킬 때 쓰는 것은 id 가 아니라 slug 인데 행이 싣지 않았다 | `ffa088b`, `711b2c3` |
| 결정 목록 항목의 slug | 공개 주소가 `#{slug}` 앵커인데 항목에 slug 가 없어 화면이 앵커를 달 수 없었다 | `1aae8dc` |
### 5.6 이 갈래에서 배운 것
- **"Studio 에서는 보이는데 공개 쪽만 비어 있다"는 신호는 거의 항상 계약의 빈칸입니다.** 두
화면이 같은 DB 를 보는데 한쪽만 비면, 그 사이에 계약이 있습니다.
- 계약에 칸을 더할 때는 **required 에 넣을지**를 따로 판단해야 합니다. 이미 나가 있는 응답을
깨지 않으려면 required 가 아니어야 합니다(`fa67a64`).
- 화면이 그리는 칸이 전부 응답에 있는지는 **화면 쪽에서 역으로** 확인해야 합니다. 결정 목록이
그 예입니다 — 상세 endpoint 가 없으면 목록이 문서 전체를 실어야 합니다.
---
## 6. 타입 검사가 통과시키는 자리
"타입 검사가 통과했으니 반영됐다"는 판단이 여러 번 틀렸습니다. TypeScript 와 Java 각각에
**검사를 무력화하는 자리**가 있었고, 그 자리를 몰라서 잘못 판단했습니다.
### 6.1 메서드 매개변수는 bivariant 다 (`6429aee`)
개념 삭제가 계속 질문 삭제 경로로 나갔습니다. 앞선 커밋이 게이트웨이를 고치지 못했는데,
**타입 검사가 통과해서 반영된 줄 알았습니다.**
```ts
// 포트 시그니처
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string): Promise<void>;
// 구현이 이렇게 좁게 적혀 있어도 위 시그니처를 "만족"한다
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string) { }
```
**TypeScript 에서 메서드 매개변수는 bivariant 입니다.** 구현이 종류를 좁게 적어도 넓은 포트
시그니처를 만족한 것으로 통과합니다. 그래서 "타입 통과"를 보고 반영됐다고 판단한 것이
틀렸습니다.
배포된 번들에 옛 삼항이 그대로 남아 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404`
가 계속 찍혔습니다.
**같은 병이 `RecordFilters` 에서도 났습니다**(`67a5491`). 포트와 정적 어댑터에 타입이 따로
있어, 포트에 필터가 늘어도 어댑터는 모르는 상태가 됐습니다. `satisfies` 가 잡지 못했습니다 —
같은 이유입니다. 타입을 하나로 합쳤습니다.
### 6.2 `as` 단언이 어긋남을 가린다 (`7211dd1`, `ab4d822`)
```ts
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
```
전부 `undefined` 로 떨어졌는데 **타입 검사는 아무 말도 하지 않았습니다.** 계약의 타입을 그대로
쓰도록 바꿔서, 모양이 바뀌면 컴파일이 먼저 막게 했습니다.
`ab4d822` 는 더 나빴습니다. `points``{group, items}` 배열로 읽고 `.filter` 를 불렀는데
계약의 `QuestionPointGroup``facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는
**객체**입니다. 객체에는 `.filter` 가 없으니 매핑이 통째로 터졌고, `as` 캐스트가 그 어긋남을
타입 검사에서 가렸습니다.
### 6.3 `(input: never)` 로 받아 캐스팅하는 조립기 (`22090a4`)
목록의 페이지 번호를 눌러도 쪽이 넘어가지 않았습니다. 요청을 만드는 조립기가 질의 인자를
손으로 나열하는데 거기 `page` 가 없었습니다.
**이것이 타입 검사를 통과한 이유:** 조립기가 입력을 `(input: never)` 로 받아 캐스팅합니다.
계약에 인자를 더해도 여기 적지 않으면 **컴파일러는 아무 말도 하지 않고 요청만 조용히 그 값을
뺍니다.**
### 6.4 루트 tsconfig 가 한 파일도 검사하지 않았다 (`e9b8661`)
운영에서 릴리즈 목록이 `ReferenceError` 로 비었습니다. `GuardedStudioLink` import 가 빠졌고
`navigate` 는 아예 정의된 적이 없었습니다.
**`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤습니다.** 루트 tsconfig 는 `"files": []`
project references 만 나열하므로 그 명령은 **한 파일도 검사하지 않고 성공합니다.** 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 합니다.
그 명령으로 돌리자 저장소에 남아 있던 다른 오류도 함께 드러났습니다 — `CatalogEntry`
export 되지 않는 것, 라우트 파라미터가 `unknown` 인 것, 메시지 키가 파라미터를 받도록
등록되지 않은 것, `ReleaseIndexItem``summary` 가 없는 것.
> 이 건은 메모리에 남겨 뒀습니다 — `tech-log-frontend-typecheck-command.md`
### 6.5 Java 쪽: 클래스패스에 남은 Jackson 2 (`0da7c7e`)
`JdbcProjectRepositoryAdapter``com.fasterxml.jackson.databind.ObjectMapper`(Jackson 2)를
요구했습니다. 이 빌드는 Jackson 3(`tools.jackson.databind`)이라 그런 빈이 없고, 컨텍스트가
refresh 에 실패해 **파드가 CrashLoopBackOff** 로 들어갔습니다.
**컴파일이 잡지 못한 이유:** Jackson 2 타입이 어떤 전이 의존성을 통해 클래스패스에 아직
남아 있어서, 잘못된 import 가 정상적으로 해석됩니다. 컨테이너만이 알려 줍니다.
### 6.6 이 갈래에서 배운 것
- **"타입 검사 통과"는 반영의 증거가 아닙니다.** bivariance·`as`·`never` 캐스트·검사하지 않는
tsconfig — 네 가지가 각각 통과시켰습니다.
- 반영의 증거는 **그 값의 여정 끝**입니다. 배포본에서 실제 요청을 보거나, 실제로 게이트웨이를
불러 어떤 연산이 실행되는지 확인해야 합니다. `6429aee` 에서 그 가드를 넣었습니다 — CONCEPT
`deleteQuestion` 으로 되돌리면 깨지는 것을 확인했습니다.
---
## 7. 테스트가 지나지 않는 이음매
"모든 검사가 통과했는데 운영에서 깨졌다"가 일곱 번 있었습니다. 매번 **테스트가 그 이음매를
지나지 않았기** 때문입니다.
### 7.1 컨텍스트를 띄우지 않는 테스트 (`ca63d7d`)
새 활동 어댑터가 생성자를 둘 갖고 있었습니다 — 하나는 운영용, 하나는 테스트가 id 생성기를
넣기 위한 것. 둘 중 어느 것에도 `@Autowired` 가 없어 컴포넌트 스캔이 고르지 못했습니다.
> 컴파일도, 단위 테스트도, **실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다.
> 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다.** 운영에서 파드가
> CrashLoopBackOff 로 들어갔고, 그때서야 드러났다.
**재발 방지:** D20 규칙을 세웠습니다 — 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면
그중 하나에 `@Autowired` 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했습니다.
### 7.2 SQL 이 한 번도 실행되지 않았다 (`37f474a`)
작업본 삭제가 500 을 돌려줬습니다. 참조 검사가
`public_resource_projection.document_id` 를 조회했는데 **그 컬럼이 없습니다** — 이 테이블은
한 테이블이 case·question·project·release 를 모두 담기 때문에 `(resource_type, resource_id)`
로 기록을 가리킵니다.
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
**진짜 실패는 이 SQL 이 한 번도 실행된 적이 없다는 것이었습니다.** 표준 `check`
Testcontainers 를 띄우지 않으므로 **persistence SQL 은 한 번도 실행되지 않은 채 빌드가
통과합니다.** 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못합니다.
**재발 방지:** 삭제 경로 전용 통합 테스트 태스크를 만들고, 실패했던 그 쿼리를 포함해 여덟
시나리오를 실제 PostgreSQL 에서 돌립니다.
### 7.3 HTTP 게이트웨이의 매핑을 지나는 테스트가 없었다 (`ab4d822`)
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠습니다.
> 이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
> 때문이다. **화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
> 않는다.**
**재발 방지:** 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣었습니다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패합니다.
### 7.4 합성 루트(composition root)에 테스트가 없었다 (`03986da`, `7600711`)
**공개 사이트 전체가 오류 화면이었습니다.** 로그아웃 상태 방문자 — 공개 사이트의 전체
독자 — 가 브라우저에서 요청을 한 건도 내보내지 못했습니다.
세 결함이 겹쳐 있었고 각각이 다음 것을 가렸습니다.
1. `attachCredentials` 가 Studio 헬퍼에 먼저 묻는데, 그 헬퍼는 자기 것이 아닌 프로파일에
`null` 을 돌려줍니다. 그 아래 폴백이 세션을 읽고 인증되지 않은 것을 거절합니다. 공개
읽기는 ANONYMOUS 프로파일을 선언하므로 그 폴백에 떨어졌습니다.
2. 요청이 흐르자 두 번째가 드러났습니다 — `envelopeError()``ApiError.code` 를 **Studio
enum 에 고정**해 세 표면이 공유했습니다. 공개/관리는 각자 자기 계약에 enum 을 선언하므로
그들이 돌려준 모든 오류가 검증에 실패해 `CONTRACT_VIOLATION` 으로 도착했습니다.
**엄격한 enum 을 잘못된 표면의 계약에 대고 검사해도 여전히 엄격해 보입니다** — 그래서
어떤 게이트도 잡지 못했습니다.
3. not-found 경로가 봉투에 없는 `status` 를 읽고 있었습니다.
> 이 결함은 공개 소스가 HTTP 가 된 뒤에야 나타날 수 있었다. 이번 주까지 그 경로는 브라우저에서
> 한 번도 돌지 않았다. **스위트가 잡지 못한 이유는 게이트웨이와 화면을 검사할 뿐 합성 루트의
> credential 결정은 검사하지 않기 때문이다 — 그 이음매에는 테스트가 없고, 이것이 그 대가다.**
**재발 방지:** 회귀 테스트가 **실제 런타임 어댑터를 배포된 백엔드의 실제 404 본문에 대고**
조립합니다. 게이트웨이 테스트(실행기를 스텁)도 화면 테스트(게이트웨이를 스텁)도 이 이음매를
덮지 않고, 장애 전체가 거기 살고 있었습니다.
### 7.5 화면 테스트를 아예 돌리지 않았다 (`fd73bc8`)
> 화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위 두
> 결함과, 의도한 변경에 고정돼 있던 단언들이 **23건 빨간 채로 여러 커밋을 지나갔다.**
> 이 건도 메모리에 남겼습니다 — 배포 전 검증은 `check:types` + `lint` + `test:unit` +
> `test:component` + `test:tech-log` **다섯 개**를 다 돌려야 합니다.
### 7.6 생성기가 계약 필드를 조용히 빠뜨렸다 (`365560e`)
이 건은 결이 다릅니다. **테스트가 아니라 생성기가** 값을 버렸습니다.
파생 단계의 YAML alias 때문에 swagger-parser 가 스키마 15개를 "is not of type `object`" 로
거절했습니다. 거절당한 스키마들은 전부 `type: object` 를 명시하고 있어서 **계약 결함처럼
보이지 않았고**, `validateSpec` 을 끄면 생성은 성공했습니다. 그런데 그렇게 만든 모델에서
`LatestEntry.publishedAt`, `ProjectListItem.updatedAt`, `SearchResultItem.matchedFields`,
`ReleaseListItem.changeTypes` 가 사라져 있었습니다. **컴파일은 통과합니다 — 아직 아무도 그
필드를 안 쓰니까.**
원인은 prepare 단계였습니다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고
snakeyaml 이 그 지점을 anchor/alias(`&id001` / `*id001`)로 덤프했습니다. 파생 스펙에 alias 가
**34곳** 있었습니다.
**재발 방지:**
- 덤프 직전 deep copy 로 노드 identity 를 끊어 alias 를 원천 차단하고, 남으면 빌드가
실패하도록 fail-closed 게이트를 뒀습니다. `validateSpec` 은 다시 켰습니다
- `verifyPublicGeneratedModels` 를 **schema 이름 대조에서 property 대조로 강화**했습니다.
이번 누락을 그 게이트가 통과시켰기 때문입니다. 지금은 schema 62개 · property 250개를 셉니다
### 7.7 이 갈래에서 배운 것
| 이음매 | 무엇이 지나지 않았나 | 어떻게 덮었나 |
|---|---|---|
| 스프링 컨텍스트 | 어떤 테스트도 컨텍스트를 띄우지 않았다 | ArchUnit D20 규칙 |
| persistence SQL | `check` 가 Testcontainers 를 안 띄운다 | 전용 통합 테스트 태스크 |
| HTTP 매퍼 | 화면 테스트는 픽스처를 쓴다 | 계약 모양 응답을 진짜 게이트웨이에 넣는 테스트 |
| 합성 루트 | 게이트웨이/화면 테스트 둘 다 스텁을 쓴다 | 실제 어댑터 + 실제 404 본문 |
| 생성기 | 모델이 만들어지면 통과한다 | property 단위 대조 |
---
## 8. 라우트를 하나 더하면 함께 울리는 손 목록
이 저장소는 라우트를 여러 곳에서 셉니다. 라우트를 하나 더하면 그 자리가 전부 울립니다. 문제는
**어떤 것은 빌드 직전에야, 어떤 것은 배포 뒤에야** 운다는 것입니다.
### 8.1 라우트 하나가 건드리는 자리
`048c1b2`(개념 라우트 추가) 커밋이 그 목록을 남겼습니다.
```
라우트 계약 tech-log-route-contract.ts
런타임 등록 route-runtime-contract
메시지 카탈로그 화면 제목·설명
nginx 서빙 패턴 tech-log-serving-contract.json → 생성된 nginx conf
코드 분할 청크 vite.config.ts 의 chunk 이름 표
CI 게이트 FE-GATE-009 라우트마다 수동 접근성 증거 1개
CI 게이트 아티팩트 기준선 정확한 개수를 고정
CI 게이트 형상 digest 게이트 집합의 sha256
```
### 8.2 nginx 가 모르는 라우트는 404 다 (`ab8c6c1`, `6784eb1`)
`/studio/releases` 가 nginx 에서 **평문 404** 를 돌려줬습니다. 라우트는 있고 청크도 빌드됐고
SPA 내부 이동으로는 화면에 닿을 수 있었지만, **하드 로드나 새로고침은 거기까지 가지 못합니다**
웹 서버가 그 경로의 존재를 들은 적이 없기 때문입니다.
> 서빙 계약의 공개 절반은 라우트 레지스트리에서 패턴을 유도한다. **Studio 절반은 손으로
> 유지하는 배열이었고, 손으로 유지하는 배열이 실패하는 방식 그대로 실패했다** — `^/studio/assets$`
> 위의 주석이 바로 그 버그를 한 번 고친 기록이고, 라우트를 더하니 즉시 반복됐다.
`6784eb1` 은 더 근본적이었습니다. 서빙 계약이 **번들된 픽스처에 우연히 들어 있던 공개 경로를
전부 열거**하고, 생성된 nginx 가 정확히 그것들을 `location =` 블록으로 게시했습니다. **빌드
이후에 게시된 기록** — 백엔드를 두는 이유 그 자체 — 은 SPA 에 묻기도 전에 엣지에서 404 였습니다.
경로 27개가 얼어 있었고, 28번째는 무엇이든 닿을 수 없었습니다.
이제 라우트 계약에서 **등록된 Public 라우트마다 정규식 하나**를 만듭니다. 파라미터는 한
세그먼트만 잡고 슬래시는 잡지 않으므로 `/cases/a/b` 는 404 로 남습니다. catch-all 라우트는
번역하지 않고 버립니다 — 모든 미매치 URL 에 index.html 을 주면 엣지 404 가 soft 200 이 되어
깨진 링크를 크롤러와 우리에게서 숨깁니다.
### 8.3 vite chunk 이름 표 (`197db74`)
주제 편집 화면을 더하고 이 표를 빠뜨렸더니 **번들은 만들어지는데 빌드 매니페스트 단계에서**
`Missing built route chunk: TECH_LOG_STUDIO_TOPIC_EDIT` 로 멈췄습니다 — 다섯 개의 검사를 다
통과한 뒤 **배포 직전에야** 드러난다는 뜻입니다.
이 표도 손으로 나열한 목록 중 하나이므로 다섯 검사 안에서 대조하게 했습니다
(`route-chunk-names.test.ts`).
### 8.4 CI 게이트 기준값이 함께 움직인다
FE-GATE-009 는 **설치된 라우트마다 수동 접근성 증거를 하나씩** 요구하고 그 집합이 정확히
일치하지 않으면 거절합니다. 그래서 라우트를 더할 때마다 이 셋이 함께 움직입니다.
| 커밋 | 라우트 | 아티팩트 기준선 | 증거 개수 | digest |
|---|---|---|---|---|
| `16e5b9f` | `/studio/projects/:id` | 132 → 133 | 111 → 112 | 187dbd96… 재계산 |
| `84d72c4` | `/studio/releases/:id` | 133 → 134 | 112 → 113 | f9e7e521… 재계산 |
| `048c1b2` | `/concepts/:slug` | +1 | +1 | fb138e7c… 재계산 |
| `fe6b56a` | `/topics`, `/topics/:s/:v`, `/studio/topics/:id` | 135 → 138 | 114 → 117 | 87a22f68… 재계산 |
**digest 재계산의 규칙:** 매번 **이전 gates.json 에서 옛 상수를 먼저 재현**해 계산 방법이
맞는지 확인한 뒤 새 파일을 해싱했습니다. 그렇게 하지 않으면 "계산이 달라졌는데 새 값이
나왔다"와 "파일이 바뀌어서 새 값이 나왔다"를 구분할 수 없습니다.
### 8.5 남은 문제
주제 화면 셋(`/topics`, `/topics/:slug/:variant`, `/studio/topics/:id`)을 더할 때 저는 이
목록을 **또 빠뜨렸습니다.** 게이트가 빨간 채로 여러 커밋을 지나갔고, 결정 404 를 고치던
`fe6b56a` 에서야 함께 맞췄습니다.
**가드는 작동했지만 제가 그 가드를 돌리지 않았습니다.** §7.5 와 같은 병입니다.
---
## 9. 서버가 갈 곳 없는 주소를 만든다
화면 코드 어디에도 흔적이 없고 **방문자만 404 를 만나는** 부류입니다. 주소가 게시 시점에
굳어져 DB 에 저장되기 때문입니다.
### 9.1 축(variant) 링크가 자기 자신을 가리켰다 (`8828005`, `63eb177`, `71bab4c` → `67a5491`, `b93d62a`)
주제 화면의 네 줄(SPA·Mediator·BFF·Forward-Auth)은 링크인데 **눌러도 아무 일이 없었습니다.**
처음에 `/topics/{주제}/{축}` 이라 적어 두었는데 그런 화면이 없어서, 축의 주소를 **주제 화면
안의 앵커**로 바꿨습니다(`63eb177`, `71bab4c`). 그랬더니 정작 주제 화면에서는 그 링크가
**자기 자신을 가리켰습니다** — 주소만 바뀌고 화면은 그대로였습니다.
그래서 **축에 자기 화면을 줬습니다**(`67a5491`). 목록 조회에 `variant` 필터를 더해
`record_variant` 로 거릅니다. 축 slug 는 주제 안에서만 유일하므로 주제까지 함께 맞춥니다 —
주제를 빼면 다른 주제의 같은 이름 축이 함께 걸립니다.
> **이 건에서 제가 만든 2차 사고:** 축 화면을 만들고 **백엔드를 프론트보다 먼저 배포**했습니다.
> nginx 설정은 라우트 계약에서 생성되므로, 프론트가 배포되기 전까지 `/topics/x/y` 는 404 입니다.
> 서버는 이미 그 주소를 내보내고 있었고, 사용자는 네 링크가 전부 404 인 화면을 봤습니다.
> **순서가 있습니다 — 새 라우트는 프론트가 먼저입니다.**
### 9.2 결정 링크가 404 였다 (`1aae8dc`, `8cd8ee3`, `fe6b56a`)
`/references/external-idp-federation-application-boundary` 의 「다음에 읽을 것」 두 번째
항목이 404 였습니다.
<!-- techviz:begin id=decision-path-404 context-sha256=93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f -->
<!-- techviz:generate id=decision-path-404 -->
![계약, 게시 시점 경로 생성, 저장 테이블, 조회 시점 경로 생성, 방문자, 공개 라우트 여섯 참가자 사이에서 주소가 만들어져 저장되고 방문 시 404 로 끝나는 순서도.](assets/diagrams/decision-path-404/decision-path-404.svg)
<details>
<summary>Diagram description</summary>
위에서 아래로 여섯 번의 이동이 있다. 계약 ProjectDecisionItem 은 공개 주소가 decisions#{slug} 앵커라고 규정한다. 게시 시점의 PublicPaths.forKind 는 그 대신 decisions/{slug} 를 만들어 public_resource_projection 에 저장한다. 조회 시점의 PublicSql.pathOf 가 저장된 주소를 읽고 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 projects/{slug}/decisions 하나뿐이라 맞는 라우트가 없고 404 가 돌아온다.
</details>
[Editable source](assets/diagrams/decision-path-404/decision-path-404.drawio) · [Grounded VizSpec](.techviz/decision-path-404/spec.json)
<!-- techviz:end id=decision-path-404 -->
**원인:** 결정에는 상세 화면이 없고 공개 라우트는 `/projects/{slug}/decisions` 하나뿐인데,
게시할 때 만든 주소는 `/projects/{slug}/decisions/{slug}` 였습니다. 계약은 **이미** 공개 주소가
`#{slug}` 앵커라고 적어 두었는데, 만드는 쪽(`PublicPaths.forKind`, `PublicSql.pathOf`)이
계약을 따르지 않았습니다.
**고친 것:**
- 두 곳이 앵커를 만들게 했다
- **주소는 게시 시점에 굳어져 저장되므로 이미 게시된 행도 V15 마이그레이션에서 함께 고쳤다** —
코드만 고치면 기존 링크는 깨진 채 남는다
- `public_route.slug` 는 앵커가 있으면 그 뒤를 조각으로 읽는다 — 마지막 `/` 뒤를 자르면
`decisions#slug` 가 slug 로 저장된다
- 목록 항목이 앵커를 달 수 있도록 계약에 `slug` 를 더했다
- 목록 화면이 `slug` 를 element id 로 달고, 앵커로 들어오면 데이터를 받아 그린 뒤 스크롤한다
**재발 방지 (두 겹):**
1. `PublicPathsTest`(백엔드) — 종류마다 만들어 낸 경로가 실제 공개 라우트 패턴에 맞는지 본다
2. `resolvesToPublicRoute`(프론트) — route contract 에서 읽은 라우트 표에 서버가 준 주소를
맞춰 보고, **맞는 라우트가 없으면 링크로 그리지 않는다.** 이 부류가 또 생겨도 방문자가
404 를 만나지는 않는다
배포 후 사이트 전체를 훑어 **서버가 내보내는 주소 26개 + 주제·축 9개 = 35개 전부 200** 임을
확인했습니다.
> **근거** —
> [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) (저장된 주소가 앵커로 바뀌고 V15 가 적용된 것) ·
> [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) (그 링크가 실제로 200) ·
> [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) (35개 전수 200)
### 9.3 주제가 없는 기록이 죽은 링크를 달았다 (`23efcf0`)
주제 없이 게시된 기록이 있는데 화면이 그것을 모르고 `/topics/` 로 가는 **이름 없는 링크**를
만들고 있었습니다 — 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다. 프로젝트
조각은 처음부터 조건부였는데 주제 쪽만 아니었습니다.
### 9.4 주제 화면이 주제 셋만 열었다 (`2632850` → `15e6ea8`, `8828005`)
문서 머리말의 주제 링크가 `/topics/:slug` 로 가는데, 그 화면은 `jpa`/`authentication`/`redis`
**셋을 하드코딩**해 두고 있어 실제 주제는 무엇이든 404 였습니다. 게시한 모든 문서의 주제 링크가
거기로 갔습니다.
당시에는 주제 페이지를 채우는 대신 링크를 탐색 필터(`/explore?topic=`)로 **우회**했습니다
(`2632850`). 그 페이지만 줄 수 있는 것 — 설명, 범위, 선별한 대표 기록 — 이 전부 비어 있었고
Studio 에 주제 설명을 쓸 칸조차 없었기 때문입니다.
나중에 주제 화면을 계약에 잇고 하드코딩을 없앤 뒤(`15e6ea8`) 링크를 곧장 주제 화면으로
되돌렸습니다(`8828005`).
> **이건 뒤집힌 판단입니다.** 우회가 틀린 것은 아니었습니다 — 그때는 채울 내용이 없었습니다.
> 다만 우회를 남겨 두면 "왜 주제 링크가 탐색으로 가지?"라는 질문이 계속 남습니다. 우회할
> 때는 **되돌릴 조건**을 함께 적어야 합니다. `2632850` 커밋 메시지에 그 조건을 적어 뒀고,
> 실제로 그 조건이 충족됐을 때 되돌렸습니다.
---
## 10. 실패를 없음으로 그린다
화면이 **거짓말을 하는** 부류입니다. 못 읽은 것을 "없다"고 그리면 작성자는 자기가 아직 쓰지
않은 것으로 읽습니다.
### 10.1 「이 프로젝트에 열린 질문이 없습니다」 (`7acde27`)
홈 「지금 집중하는 것」 편집기는 열린 질문과 결정을 못 읽으면 **빈 배열로 삼키고** 「이
프로젝트에 열린 질문이 없습니다」라고 적었습니다. 실제로는 넷이 있었고 공개 사이트에도 나오고
있었습니다.
> 거짓말을 하느니 못 읽었다고 말한다.
### 10.2 한 칸의 실패가 옆 칸을 끌고 내려간다 (`6e784ed`, `fd73bc8`, `3bb724b`)
편집기가 질문과 결정을 `Promise.all` 로 묶어 읽어서, **결정만 터지는데 멀쩡히 오던 질문
목록까지** 「불러오지 못했습니다」가 됐습니다. 둘을 따로 읽도록 갈랐습니다.
`fd73bc8` 은 더 미묘한 변종입니다:
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로
> 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸
> 때문에 대시보드 전체가 빈 화면이 된다.
이 판단은 나중에 주제 탭에도 적용했습니다(`3bb724b`) — 탭 하나를 못 받아도 탭 줄과 나머지는
그대로 남고, 그 자리에 못 받았다고 적습니다.
### 10.3 계약 밖 값이 500 을 만든다 (`365560e`, `edb0890`)
`latestEntries` 가 투영의 **모든** `resource_type` 을 흘렸습니다. 계약의 `LatestEntry.entryType`
은 네 값뿐이라 `QUESTION` 이 섞이면 매퍼가 500 을 냅니다 — **홈 화면 전체를 못 쓰게 만듭니다.**
그래서 질의가 먼저 걸러 냈고, 그 결과 **게시한 Open Question 이 홈 최근 기록에 나오지
않았습니다.** 백엔드가 담지 않은 것이 아니라 **담을 수 없었습니다.**
계약을 넓히고(`ef49d3a`) `LATEST_ENTRY_TYPES``QUESTION` 을 더했습니다. `pathOf` 는 이미
`/questions/{slug}` 를 만들고 있었고 projection 에도 질문 행이 `ACTIVE`/`PUBLIC` 으로
채워져 있었습니다 — **막고 있던 것은 이 `IN` 목록 하나였습니다.**
### 10.4 배포 직후 첫 요청부터 홈이 깨졌다 (`365560e`)
`home_focus_config.default_focus_type` 은 마이그레이션 직후 NULL 인데 계약은 이 필드를
**required + enum 3값**으로 선언합니다. **배포 직후 첫 요청부터 `/home` 이 깨졌습니다.**
`HomeFocusView.resolve` 가 반드시 유효한 값 하나를 정하도록 고쳤습니다.
### 10.5 스모크 스윕이 늑대를 외쳤다 (`7289ce9`)
미리보기가 아직 없는 문서는 현재 미리보기를 물으면 404 를 답하고, 화면은 그것을 "미리보기를
만드세요"로 바꿉니다. **스윕은 그것을 실패로 셌습니다.** 그래서 건강한 배포 아래에 매번 같은
빨간 줄이 남았습니다.
> 매번 늑대를 외치는 검사는 읽히지 않게 되고, 진짜 실패가 그 옆에 눈에 띄지 않은 채 앉아
> 있게 된다.
로그인 전 세션 탐침의 401 도 같은 부류라 같은 조건으로 제외하고, **나머지 4xx·5xx 는 전부
스윕을 실패시킵니다.**
### 10.6 기록이 조용히 사라졌다 (`77125d1`)
프로젝트 기록에서 Open Question 이 보이지 않았습니다. 이 목록은 탐색의 지식 목록과 **응답
모양이 다른데** 지식 목록의 매퍼를 그대로 쓰고 있었습니다. 그 매퍼는 CASE/REFERENCE 가 아니면
`null` 을 돌려주고 호출부가 `filter` 로 걸러 내므로, **질문과 개념은 오류도 빈 자리도 남기지
않고 조용히 없어졌습니다** — 목록이 한 줄 짧아질 뿐이라 눈으로는 알아채기 어렵습니다.
---
## 11. CSS 규칙이 구역을 넘어 샌다
"디자인이 안 된 것처럼 보인다"고 보고된 것 셋이 전부 **규칙이 샌 것**이었습니다.
### 11.1 구역 전체에 건 격자가 제목까지 잡았다 (`344dadb`)
```css
.home-comparison a {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
padding: 27px 2px 28px;
}
```
비교 **행**을 위한 규칙인데 선택자가 **구역 전체**라 제목 안의 링크까지 잡았습니다. 제목이
200px 칸에 갇혀 두 줄로 접히고 행용 padding 까지 물려 **h2 높이가 199px** 이 됐습니다. 같은
이유로 주제 화면의 안내 문단도 행의 크기·색으로 덮여 있었습니다.
사용자가 원인을 정확히 짚어 주었습니다 — 「디자인이 안 된 게 아니라 CSS 선택자가 새고
있습니다」.
**고친 것:** 배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적습니다(`li > a`). 같은 모양이
**다른 구역 아홉 곳에도** 있어서 **51개 선택자**를 고쳤습니다.
**CSS 로만 막는 것은 임시방편이라 구조도 바꿨습니다** — 제목 안에 링크를 두지 않고, 주제로
가는 길은 아래 한 줄이 맡습니다.
**재발 방지:** `section-selector-scope.test.ts``.클래스 태그` 모양에 배치 속성
(`display: grid|flex`, `grid-template-columns`, `padding`)이 걸려 있으면 멈춥니다. 이미 좁혀
둔 자리는 `SETTLED` 로 명시합니다. **색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로
대상이 아닙니다.**
### 11.2 규칙이 없었던 게 아니라 절반만 있었다 (`68538f2`)
「구조별로 알게 된 것」만 26px·굵기 400 으로 나왔습니다. 형제 구역은 30px·650 이라 같은
화면에서 이 구역만 급이 낮았습니다.
> 규칙이 없었던 게 아니라 **절반만 있었다.** 정본은 `.section-heading-row h2` 인데 그 안에
> 들어가지 않는 두 구역이 **크기만 각자 적어 두어 굵기를 아무도 정하지 않았고**, 그래서
> 기본값 400 으로 떨어졌다.
**재발 방지:** `section-heading-rank.test.ts` — 나란히 서는 구역 제목들이 정본과 같은
`font-size`/`font-weight` 를 쓰는지 CSS 를 파싱해 확인합니다. 굵기를 빼 보고 실제로 멈추는
것을 확인했습니다.
> **이때 제가 저지른 판단 오류:** 처음에 grid/columns 만 측정하고 "정상"이라고 답했습니다.
> 사용자가 다시 지적한 뒤 **전체 페이지 스크린샷**을 찍어서야 26px/400 을 봤습니다.
> **프록시 지표가 아니라 보이는 것을 측정해야 합니다.**
### 11.3 CSS module 은 전역 규칙이 닿지 않는다 (`8c5dbe1`)
버튼에서 상자를 걷어내는 변경이 `.studio-app` 규칙만 고쳤습니다. 게시 기록·게시 흐름·워크플로
게이트는 **CSS module 을 쓰므로 그 규칙이 닿지 않아**, 다른 화면에서 상자를 걷어낸 뒤에도
「Snapshot 보기」·「게시 취소」·「적용」만 테두리와 파란 채움으로 남아 있었습니다. **한 화면
안에서 두 언어가 섞여 더 눈에 띄었습니다.**
---
## 12. 운영에서만 드러난 것
### 12.1 파드가 CrashLoopBackOff 로 들어간 두 건
| 원인 | 왜 컴파일·테스트가 못 잡았나 | 커밋 |
|---|---|---|
| 스캔되는 컴포넌트에 생성자 둘, `@Autowired` 없음 | 어떤 테스트도 애플리케이션 컨텍스트를 띄우지 않는다 | `ca63d7d` |
| Jackson 2 `ObjectMapper` 를 요구(이 빌드는 Jackson 3) | Jackson 2 타입이 전이 의존성으로 클래스패스에 남아 있어 import 가 정상 해석된다 | `0da7c7e` |
### 12.2 배포 인자를 빠뜨려 배포본이 `api.example.com` 을 불렀다
프론트 이미지 빌드에 `RUNTIME_API_BASE_URL` 을 넘기지 않으면 **배포본이 존재하지 않는 주소를
부릅니다.** Dockerfile 이 문자 그대로 그 경고를 적어 두고 있는데도 빠뜨렸습니다.
`kubectl rollout undo` 로 되돌리고 다시 빌드했습니다.
> 메모리에 남겼습니다 — `techlog-deploy-runtime-api-base.md`
프론트 이미지가 요구하는 인자 전부:
```
APP_PROFILE=production
VITE_ROUTER_BASE_PATH=/
RUNTIME_API_BASE_URL=https://hyeonworks.com/ ← 빠뜨리면 api.example.com
VITE_BUILD_ID / VITE_COMMIT_SHA / RELEASE_ID
CI_RUNNER_IMAGE=node@sha256:… ← 반드시 @sha256 다이제스트
SOURCE_DATE_EPOCH
```
백엔드 이미지는 Dockerfile 이 `src/` 아래에 있고 `RELEASE_VERSION`/`BUILD_VERSION`/`GIT_SHA`/
`SOURCE_URL` 을 받습니다. 태그는 **짧은 SHA**(7자)입니다 — 배포된 것과 맞춰야 합니다.
### 12.3 stale JAR 검사
빌드 산출물 이름에 커밋 해시가 들어갑니다(`app-bootstrap-0.0.1+<sha>.jar`). 작업 트리가
더러우면 해시가 달라져 `verifyNoStaleTraceableJars` 가 멈춥니다. **커밋한 뒤
`cleanStaleTraceableJars build` 로 돌려야 합니다.** 이 순서를 몰라 두 번 헤맸습니다.
### 12.4 컨테이너가 읽을 수 없는 설정 파일 (`83409be`)
빌드가 `config.json` 을 0600 으로 씁니다. **nginx 가 읽지 못해** 컨테이너는 healthy 로
올라오고 **SPA 가 부팅에 필요한 그 파일 하나만 403** 을 돌려줬습니다. 이미지가 권한을
정규화하도록 고쳤습니다.
### 12.5 favicon 이 404 였다 (`83409be`)
`index.html``public/favicon.svg` 를 참조한 적이 없습니다. 파일은 이미지에 들어 있었고
nginx 도 서빙했지만 **브라우저는 `/favicon.ico` 를 물었고** 404 를 받아 기본 아이콘으로
떨어졌습니다.
### 12.6 robots.txt 가 404 였다 (`a936444`)
파일은 이미지에 있었지만 **nginx 설정이 서빙할 파일을 하나씩 명시하는 구조**라 등록되지 않은
것은 SPA 폴백으로 떨어집니다 — 크롤러가 index.html 을 규칙으로 읽을 수는 없으므로 **규칙이
없는 것과 같았습니다.**
### 12.7 테스트 JVM 이 OOM 났다 (`561d02a`)
테스트 JVM 힙이 Gradle 기본 512m 이라 **Spring context 캐시 + ArchUnit + Testcontainers**
조합에서 OOM 이 났습니다. **증상이 테스트 실패가 아니라 "Executor 를 완료할 수 없음"이어서
원인을 가렸습니다.**
### 12.8 npm 환경 변수 누출 (운영 아님, 검증 절차)
vitest 를 `npm`/`npx` 로 돌리면 `npm_config_*` 환경 변수가 설정되고
`ci-workflow-generation.test.ts` 가 실패합니다. 이 저장소에서 테스트를 돌릴 때는:
```bash
env $(env | grep -i "^npm_config" | cut -d= -f1 | sed 's/^/-u /' | tr '\n' ' ') \
./node_modules/.bin/vitest run …
```
---
## 13. 글과 말
기술 결함은 아니지만 같은 뿌리를 갖습니다 — **같은 것을 여러 곳에서 손으로 적으면 갈라집니다.**
### 13.1 한 화면에 종류 이름이 아홉 개 (`dc2fda7`, `ca1fc92`)
홈 한 화면에 종류 이름이 **아홉 개** 떠 있었습니다.
```
최근 기록 목록: CASE · CONCEPT · OPEN QUESTION · REFERENCE
바로 아래 「종류별로 읽기」: 검증 기록 · 동작 원리 · 적용 기준 · 열린 질문
```
독자는 둘이 같은 것이라는 단서를 어디서도 받지 못했습니다 — **이름을 바꾸기 전보다 나빠진
유일한 자리였습니다.**
`ca1fc92` 는 그 원인을 짚었습니다:
> 표가 화면마다 복사되어 **여섯 벌**이었고 그래서 갈라졌다: 같은 QUESTION 이 공개 화면에서
> "Open Question", 작업본 목록과 게시 기록에서 "Question", 편집기 상태 줄에서 "QUESTION"
> 이었다. **쓰는 사람은 같은 문서를 화면마다 다른 이름으로 만난다.**
`Record<RecordKind, string>` 하나로 모았습니다.
### 13.2 종류 이름을 두 번 바꿨다 (`a6413d0` → `af5a6bb`)
이 저장소의 종류 이름은 **글을 담아 둔 방식의 이름**이었습니다 — Case, Reference, Open
Question. 독자는 그 말을 배우고 나서야 목록을 읽을 수 있었고, 정작 뜻풀이는 홈 바닥(2,000px
아래)에 있었습니다.
1차로 이름이 하는 일을 말하게 했습니다:
```
Case → 직접 해보니 Reference → 다음에 쓸 기준
Question → 아직 모르는 것 Concept → 어떻게 동작하나
Decision → 이렇게 하기로
```
**그런데 이게 기술 기록의 톤에 비해 가벼웠습니다.** 역할은 그대로 말하되 문어체로 다시
세웠습니다(`af5a6bb`):
```
CASE → 검증 기록 CONCEPT → 동작 원리
REFERENCE → 적용 기준 DECISION → 설계 결정
QUESTION → 열린 질문
```
**계약의 kind 는 그대로 뒀습니다.** 바꾸는 것은 화면에 보이는 이름뿐입니다.
### 13.3 AI 스러운 문구 (`7acde27`, `6e784ed`, `eedc90b`)
사용자가 프로필의 「기록을 운영하는 원칙」이 AI 스럽다고 지적했습니다. 구체적으로:
- 「섞는다」 「함께 기록한다」 「흩어지지 않게」 — 무엇을 하는지 말하지 않는 동사로 끝남
- 「결론이 서는 조건」 「자리」 「프로젝트를 답니다」 「결정 순서로 읽는다」 「접근을 나눠
견주고」 — 번역투
**제가 고쳐 쓴 첫 번째 안도 거절당했습니다.** 결국 사용자가 직접 쓴 텍스트를 그대로
실었습니다.
> **여기서 배운 것:** 이 사이트의 글은 작성자의 목소리입니다. 제가 "더 나은 문장"을 제안하는
> 것과 **그 사람의 말투로 쓰는 것**은 다른 일이고, 후자는 제가 잘하지 못합니다. 톤 지적이
> 나오면 고쳐 쓰기보다 **어떤 말을 쓸지 물어야** 합니다.
같은 판단이 다른 자리에도 적용됐습니다:
- 「무엇을 견줬나」 → 의문형 꼬리 + 이 기록에서 쓰지 않는 낱말. 작성자가 Case 소제목에 쓰는
말은 「이 구조에서 감수한 것」처럼 **「~한 것」 명사형**이라 그쪽에 맞췄습니다 (`344dadb`)
- 「이 프로젝트가 밝힌 것」 → 「프로젝트를 통해 확인한 결과」 (`eedc90b`)
- 「운영 가능한 설계로 연결합니다」 → 「실제 운영에 적용할 수 있는 형태로 정리합니다」
### 13.4 오류 문구가 추측을 출력했다 (`1801414`)
작업본 삭제 실패가 **「게시됐거나, 참조하는 곳이 있거나, 누가 먼저 고쳤을 수 있습니다」** —
**세 가지 추측**을 출력했습니다. 서버는 정확히 하나를 답했는데도요:
「이 기록을 참조하는 곳이 있어 삭제할 수 없습니다」.
버전 충돌이 "사용 중"으로 읽혔고, 어떤 삭제는 되고 어떤 삭제는 안 되는 것을 지켜보는 작성자는
**둘을 구분할 방법이 없었습니다.**
게이트웨이가 서버의 클라이언트 안전 메시지를 실어 나르고 화면이 그것을 보이게 했습니다.
**이 문제는 아직 완전히 안 끝났습니다** — §14.2 를 보세요.
### 13.5 편집기 칸 이름을 공개 화면과 맞췄다 (`82e992d`)
```
목적 → 이 기준을 쓰는 이유 규칙 → 판단 기준
적용 조건 → 적용할 때 예외 → 예외와 주의
사실 → 확인한 사실 미지수 → 남은 미지수
선택지 → 검토한 선택지
```
쓰는 사람이 **지금 채우는 칸이 공개 화면 어디로 가는지 외우지 않아도 되게** 했습니다.
### 13.6 한글 slug (`5cffe30`, `7093d84`)
주제 만들기가 **간헐적으로** 실패했습니다 — "그 slug 를 가진 주제가 이미 있습니다". 다른
이름으로 다시 하면 됐습니다.
> 규칙은 간헐적이었던 적이 없다. **보이지 않았을 뿐이다** — slug 생성이 `[a-z0-9]` 만 남기고
> 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
두 가지로 어긋났고 사용자는 둘 다 만났습니다:
- `인증` → 빈 문자열 → 폼이 요청 전에 거절
- `Redis 캐시``Redis 클러스터`**둘 다 `redis`** → 두 번째가 충돌
**한글을 버리지 않고 로마자로 옮깁니다.** 음절을 초성·중성·종성으로 산술 분해하므로 표가
필요 없고 결정적입니다: `백엔드 아키텍처``baekendeu-akitekcheo`. 국어의 로마자 표기법의
**자모 대응만** 적용하고 음운 변화 규칙은 일부러 뺐습니다 — slug 는 읽는 것이지 발음하는 것이
아니고, 그 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 됩니다.
---
## 14. 정보 구조가 바뀐 과정 — 주제와 축
이 절은 결함이 아니라 **설계가 바뀐 과정**입니다. 다만 그 과정에서 나온 결함이 §9 의 절반을
차지하므로 함께 적습니다.
### 14.1 문제 — 하나의 질문에 네 개의 답
「브라우저와 서버 사이 credential 책임을 어디에 둘 것인가」 하나의 질문에 대해 네 구조
(SPA·Mediator·BFF·Forward-Auth)를 만들어 봤는데, **기록이 주제와 프로젝트로만 자리를 갖고
있어** 그 넷을 담을 데가 없었습니다. 화면은 그것을 **시간순 목록으로만** 보여 줄 수
있었습니다.
**주제를 넷으로 쪼개지 않았습니다.** 쪼개면 PKCE·CSRF·Authorization Code 처럼 네 구조가 함께
쓰는 기록을 어디에 둘지 애매해지고 비교도 어려워집니다. 대신 **주제 안에 축(variant)을 하나**
뒀습니다 (`2d9672d`, `d11cda8`).
```
topic (주제)
├─ variant_label 축의 이름 — 주제마다 다르다
│ 인증 경계 → 「구조」 / 조회 성능 → 「조회 전략」
└─ topic_variant 축의 값들 (SPA, Mediator, BFF, Forward-Auth)
└─ record_variant 어느 기록이 어느 축에 걸리는지 (kind, id) 쌍
```
<!-- techviz:begin id=topic-variant-model context-sha256=93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f -->
<!-- techviz:generate id=topic-variant-model -->
![왼쪽부터 topic, topic_variant, record_variant 로 이어지고 record_variant 가 document·open_question·project_decision 세 테이블을 가리키는 구조도.](assets/diagrams/topic-variant-model/topic-variant-model.svg)
<details>
<summary>Diagram description</summary>
왼쪽에 topic 이 있고 variant_label 로 축의 이름을 스스로 정한다. 그 오른쪽에 topic_variant 가 있고 SPA, Mediator, BFF, Forward-Auth 같은 축의 값들을 담는다. 그 오른쪽에 record_variant 가 있고 어느 기록이 어느 축에 걸리는지를 종류와 아이디의 쌍으로 적는다. record_variant 는 오른쪽의 document, open_question, project_decision 세 테이블을 가리키는데, 기록이 종류마다 다른 테이블에 살기 때문에 외래키를 걸지 못하고 쌍으로만 가리킨다.
</details>
[Editable source](assets/diagrams/topic-variant-model/topic-variant-model.drawio) · [Grounded VizSpec](.techviz/topic-variant-model/spec.json)
<!-- techviz:end id=topic-variant-model -->
**설계 판단 셋:**
1. **축 이름은 주제가 정합니다.** 내부 이름은 `variant` 로 두고 화면에 보이는 이름은
`variantLabel` 로 둡니다
2. **기록은 여러 축에 걸릴 수 있습니다**(`variantIds` 배열). 아무 데도 걸리지 않은 기록은 그
주제의 **공통 기록**으로 읽습니다 — 「공통」 축을 따로 만들지 않습니다
3. **`record_variant` 는 외래키가 없습니다.** 기록이 종류마다 다른 테이블에 살기 때문입니다
(`document` / `open_question` / `project_decision`). `studio_validation`·`publication`
이미 쓰는 방식을 따랐습니다
**editorial 칸을 함께 세웠습니다.** `topic.thesis`, `project.thesis`,
`topic_variant.summary/conclusion`**기록을 합쳐 자동으로 나오는 글이 아닙니다.** 특히
`conclusion` 은 비교표가 읽는 칸이라 기록의 요약 첫 줄을 잘라 쓰면 안 됩니다.
### 14.2 홈의 비교 구역이 세 번 바뀌었다
| 단계 | 무엇 | 왜 바꿨나 | 커밋 |
|---|---|---|---|
| 1 | 주제 하나만 펼치고 아래 「다른 주제 N개 보기」 한 줄 | 홈이 「무엇을 만들었나」로 시작했다. 30초 안에 알아야 할 것은 무엇을 견줬나다 | `604ded5`, `69eabc7` |
| 2 | 제목 자리를 **주제 이름 탭**이 대신 (30px/650) | 「다른 주제」 줄은 목록을 다 읽고 나서야 만나는 자리라 대개 지나쳤다 — JPA 주제는 홈에 있으면서도 없는 것과 같았다 | `de4cb8b` |
| 3 | 탭을 **칩 크기**로 낮추고 개수 상한 제거 | 주제가 열 개, 스무 개가 되면 이름만으로 화면이 덮인다. 상한은 주제마다 상세를 미리 받느라 둔 것인데, 그러면 상한 밖의 주제가 다시 밀려난다 | `3bb724b`, `2b2f443` |
**3단계에서 요청 구조를 바꿨습니다.** 탭은 목록 호출 하나가 주는 전부이고, 상세는 **고른
탭만 그때 받아 캐시**합니다. 그래서 주제가 몇 개가 되든 홈이 처음 보내는 요청은 **목록 1 +
주제 1** 로 고정됩니다.
**그리고 시각 언어를 두 번 고쳤습니다:**
- 고른 탭의 **파란 밑줄**을 없앴습니다 — 주제가 스무 개면 밑줄 설 자리 스무 개가 함께 늘어섭니다
- 칩으로 낮추니 **목록 위에 글자만 떠 있는 것처럼** 보였습니다. 고른 탭에 형태(알약)를 주고,
묶음의 윗선을 목록이 아니라 패널이 갖게 해서 탭 줄이 그 선에 바로 얹히게 했습니다
### 14.3 축이 무엇을 기준으로 묶이나 (실제 데이터)
> **근거** — [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) ·
> [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) ·
> 화면과 실측값은 [`evidence/browser/`](./evidence/browser/)
두 주제가 **같은 구조**를 씁니다. 다만 내용의 양이 달라 다르게 보입니다.
```
oauth-oidc-auth-boundary 축 이름 「구조」 축 4개
spa ← CASE 1 + REFERENCE 3 (기록 4)
mediator ← CASE 1 + QUESTION 2 + REFERENCE 2 (기록 5)
bff ← CASE 1 + QUESTION 3 + REFERENCE 1 (기록 5)
forward-auth ← CASE 1 + QUESTION 1 + REFERENCE 1 (기록 3)
공통 기록: CONCEPT 1 + 결정 2 + REFERENCE 2
jpa-feed-query-performance 축 이름 「조회 전략」 축 3개
derived-query ← CASE 1
fetch-join ← CASE 1
fetch-join-paging ← CASE 1
```
**JPA 가 「문서가 그대로 나온다」로 보이는 이유**는 축마다 붙은 기록이 1개씩이고 축 제목을
그 Case 제목과 비슷하게 적었기 때문입니다. **구조 차이가 아니라 내용 양의 차이입니다.**
기록을 20개 더 붙여도 **홈의 줄은 그대로 3줄**입니다 — 줄은 문서가 아니라 축입니다. 줄을
늘리려면 Studio 에서 축을 추가해야 합니다.
> **자동으로 안 따라오는 것:** 줄에 보이는 **결론 문장은 축에 손으로 쓴 글**입니다. 기록을
> 20개 붙여도 그 문장은 누가 고치기 전까지 그대로입니다. 의도된 설계이지만(요약은 「무엇인가」,
> 결론은 「무엇을 알게 됐나」) **사람이 갱신해야 하는 지점**입니다.
---
## 15. 재발 방지 장치 목록
이 기간에 세운 가드 전부입니다. **각각 결함을 되돌려 실제로 멈추는 것을 확인한 뒤** 커밋했습니다.
> **근거** — 가드 셋(`public-path-reachability` · `section-heading-rank` · `route-chunk-names`)을
> 각각 결함으로 되돌려 실제로 빨개지는 것을 확인한 기록:
> [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt)
### 15.1 프론트엔드
| 가드 | 무엇을 지키나 |
|---|---|
| `contract-operation-coverage.test.ts` | 계약이 선언한 연산이 기여 목록에 등록됐는가 |
| `knowledge-list-kinds.test.ts` | 목록 매퍼의 종류 표가 계약의 enum 을 전부 담는가 |
| `topic-variant-wiring.test.ts` | 축 필터가 주제와 함께 나가는가 |
| `route-chunk-names.test.ts` | vite chunk 이름 표에 모든 라우트가 있는가 |
| `section-selector-scope.test.ts` | 배치 규칙이 구역 전체가 아니라 대상까지 좁혀졌는가 |
| `section-heading-rank.test.ts` | 나란히 서는 구역 제목이 같은 급인가 |
| `home-focus-choices.test.ts` | 홈 편집기가 고른 프로젝트의 것만 거르는가 |
| `home-topic-tabs.test.tsx` | 탭이 데이터를 따르는가 · 상세를 고를 때만 받는가 · 실패해도 목록이 남는가 |
| `public-path-reachability.test.ts` | 서버가 만드는 주소가 실제 라우트에 맞는가 |
| `markdown-toolbar.test.ts` | 본문 도구가 유효한 문법을 넣는가 |
| `tech-log-serving-contract.test.ts` | nginx 로 나가는 경로 패턴이 라우트 계약과 같은가 |
| ESLint 규칙 (`f9d20e8`) | `setXxx(…)` 업데이터 안에서 `currentTarget` 을 읽지 않는가 |
### 15.2 백엔드
| 가드 | 무엇을 지키나 |
|---|---|
| `ContractRouteCoverageTest` | 계약이 선언한 연산에 컨트롤러 매핑이 있는가 (미구현 51개는 명시) |
| `StudioContractUnionJacksonTest` | 모든 `RecordKind` 가 계약 enum 으로 변환되는가 |
| `PublicPathsTest` | 종류마다 만든 공개 경로가 실제 라우트에 맞는가 |
| `DecisionConsequencesTest` | 운영 DB 의 실제 JSON 모양을 파싱하는가 |
| `PublicContractDriftTest` | springdoc 이 게시하는 표면과 계약을 양방향 대조 (operation 수 고정) |
| `PublicErrorRegistryTest` | `PublicError``error-codes.yaml` ↔ 계약 enum 3자 대조 |
| `postgresqlTechLogPublicPersistenceIntegrationTest` | 어댑터 SQL 을 **실제 PostgreSQL** 에서 돌린다 |
| ArchUnit D20 | 스캔되는 컴포넌트의 생성자가 하나이거나 `@Autowired` 가 있는가 |
| `verifyNoStaleTraceableJars` | 빌드 산출물이 현재 커밋의 것인가 |
### 15.3 설계 패키지
| 가드 | 무엇을 지키나 |
|---|---|
| `check-openapi.py` | 계약 자체의 유효성 |
| `check-consistency.py` | 세 계약 사이의 정합 |
| `check-contract-parity.py` | FE/BE 가 아는 종류·오류 코드가 같은가 |
| alias fail-closed 게이트 | 파생 스펙에 YAML anchor/alias 가 남으면 빌드 실패 |
| `verifyPublicGeneratedModels` | 생성된 모델이 계약의 **property 단위**로 일치하는가 (schema 62 · property 250) |
### 15.4 배포 전 검증 (사람이 돌려야 하는 것)
```bash
# 프론트 — 다섯 개를 다 돌린다. npx tsc --noEmit 은 아무것도 검사하지 않는다
npm run check:types
npm run lint
env $(env | grep -i "^npm_config" | cut -d= -f1 | sed 's/^/-u /' | tr '\n' ' ') \
./node_modules/.bin/vitest run tests/unit tests/component tests/features/tech-log
# 백엔드 — 커밋한 뒤에 돌린다(산출물 이름에 커밋 해시가 들어간다)
cd src && ./gradlew cleanStaleTraceableJars build
# 설계 패키지
python3 scripts/check-openapi.py && python3 scripts/check-consistency.py \
&& python3 scripts/check-contract-parity.py
```
---
## 16. 아직 남은 것
정직하게 적습니다. **이 목록은 "고쳤다"가 아니라 "안 고쳤다"입니다.**
### 16.1 삭제를 막는 이유를 문구가 말하지 않는다
> **근거** — [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt)
> (진단 당시 캡처 + 사용자가 조치한 뒤의 사후 확인)
작업본 삭제 실패는 다섯 가지 이유가 **전부 같은 한 문장**으로 나옵니다:
```
another record still links to this one; unlink it first
```
실제로 막는 것은 이 중 하나입니다:
```sql
SELECT 1 FROM document_relation WHERE target_document_id = :id
UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id
UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id
UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id
UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
```
실제 사례: 「DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1」(CASE, DRAFT/PRIVATE)을 지우려는데
관계를 다 지워도 삭제가 안 됐습니다. 남아 있던 것은 `project_document_link` 의 **PRIMARY 링크
1행**(프로젝트 「Liner N + 1문제」)이었습니다. **프로젝트 연결은 「관계」 편집기가 아니라 문서의
`Project` 필드**라서, 관계를 아무리 지워도 그 행은 남습니다.
문구가 「another record」라고 하니 관계를 찾아 지우게 되는데, **정작 막는 것은 record 가 아니라
프로젝트입니다.** 문구가 잘못된 것을 가리키고 있습니다.
- **해결 방법(사용자):** `Project` 필드를 「미지정」으로 바꾸고 저장 → 삭제
- **안 고친 것:** 오류가 무엇이 막는지 말하게 하기
### 16.2 홈 비교표에 기록 수가 없다
주제 화면에는 축마다 「기록 N」이 붙는데 **홈에는 없습니다.** 그래서 기록 1개짜리 축과 20개짜리
축이 똑같아 보이고, 기록을 20개 채워도 홈 화면은 오늘과 똑같습니다.
### 16.3 두 탭 줄의 표시 방식이 다르다
「지금 집중하는 것」 탭에는 파란 밑줄이 그대로 있고, 주제 탭은 알약입니다. 주제 탭 밑줄을 그쪽에서
베껴 왔다가 뺐기 때문입니다. **한 화면 안에서 두 언어가 섞여 있습니다** — §11.3 과 같은 모양입니다.
### 16.4 릴리즈 0.3.0 이 초안 상태
`/studio/releases/336bd1e7-68da-46bc-94a6-cfe17807960a` 에 초안으로 있고 **의도적으로 게시하지
않았습니다.** 사용자 검토 대상입니다. 이번 세션의 변경 일부만 담겨 있습니다.
### 16.5 수동 접근성 증거가 전부 미서명
`artifacts/tests/a11y-manual/*.md` 는 전부 `pending-manual-review` 입니다. 라우트마다 파일은
있지만 **사람이 서명한 것은 하나도 없습니다.** `review:a11y-manual` 스크립트는 그래서 실패하는
것이 정상입니다.
### 16.6 환경 의존으로 실패하는 테스트 3개
`ci-workflow-generation.test.ts` 의 세 케이스(`corepack pnpm` 하위 프로세스를 띄우는 것들)가
이 환경에서 실패합니다. **HEAD 에서도 동일하게 재현**되므로 코드 변경과 무관합니다.
### 16.7 종류 열거 두 곳이 아직 컴파일러의 보호를 못 받는다
이 문서를 쓰며 근거를 모으다가 새로 확인한 것입니다
([`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt)).
- **`PublicSql.pathOf`** — `RecordKind` 가 아니라 `String`(공개 투영의 `resource_type`)으로
switch 합니다. 그 칸은 `RecordKind` 에 없는 값(`PROJECT`, `RELEASE`)도 담기 때문입니다.
그래서 `default -> null` 이 남아 있고, 새 종류를 더할 때 이 자리를 빠뜨리면 컴파일은 통과하고
경로가 `null` 로 나갑니다. 실제로 CONCEPT 이 여기서 빠져 있었습니다(§3.2 의 13번). 지금은
`PublicPathsTest` 가 막지만, `pathOf` 만 쓰는 경로(홈 focus 의 `recentDecision`)에는 그
테스트가 닿지 않습니다.
```java
static String pathOf(String resourceType, String slug, String projectSlug) {
return switch (resourceType) {
case "CASE" -> "/cases/" + slug;
case "REFERENCE" -> "/references/" + slug;
case "QUESTION" -> "/questions/" + slug;
case "CONCEPT" -> "/concepts/" + slug;
case "PROJECT" -> "/projects/" + slug;
case "PROJECT_DECISION" ->
projectSlug == null ? null : "/projects/" + projectSlug + "/decisions#" + slug;
case "RELEASE" -> "/releases/" + slug;
default -> null;
};
}
```
- **`validate-working-copy.ts` 의 `stringFields`** — 아직 삼항 사슬입니다. 다만 배타적 사슬이
아니라 가산형이라 종류를 빠뜨리면 "잘못된 분기로 떨어진다"가 아니라 "그 종류의 추가 칸을
검사하지 않는다"가 됩니다. 덜 위험하지만 조용하기는 마찬가지입니다.
같은 함수 안에서 두 목록의 상태가 갈립니다. 유형별 칸은 표로 바뀌었고 문자열 칸은 사슬로
남았습니다:
```ts
const BRANCH_FIELDS: Record<RecordKind, string[]> = {
CASE: ["problem", "conclusion", "environment", "reproduction", "lastVerifiedOn", "bodyMarkdown"],
CONCEPT: ["bodyMarkdown", "basisVersion"],
REFERENCE: ["purpose", "rules", "applyWhen", "exceptions", "examples", "verifiedOn"],
QUESTION: ["questionStatus", "facts", "assumptions", "unknowns", "constraints", "options", "nextValidation", "resolution"],
PROJECT_DECISION: ["decisionStatus", "decidedOn", "statement", "rationale", "consequences"],
};
```
그 표 위의 주석이 왜 바꿨는지를 적어 두었습니다:
> 유형별 칸. 삼항 사슬이던 동안 개념이 어디에도 없어서, 개념 작업본은 「알 수 없는 종류」로
> 거절되고 그 문서의 모든 칸이 「허용되지 않은 속성」이 됐다 — 손으로 나열한 목록에 새 종류를
> 빠뜨리는 일이 이 저장소에서 반복됐다. `Record<종류, …>` 로 두면 컴파일러가 빈 자리를 잡는다.
즉 §3 의 「표로 바꿨다」는 대부분 사실이지만 전부는 아닙니다.
### 16.8 검토용 스크린샷 3장이 저장소에 커밋돼 있다
`3bb724b`·`2b2f443` 에서 `git add -A` 로 홈 탭 검토용 PNG 를 프론트 저장소 루트에 커밋했습니다
— `home-tabs-keycloak.png`, `home-topic-tabs.png`, `home-topic-tabs-2.png`. 소스에 들어갈
파일이 아닙니다. (이 문서의 `evidence/browser/` 에는 사본을 뒀습니다.)
### 16.9 주제 논지·축 결론의 출처
`topic.thesis`, `topic_variant.conclusion` 은 **2026-09-01 에 AI 가 써서 DB 에 직접 넣은
초안**입니다. 사용자 검토 대상이고, 아직 검토되지 않았습니다.
> 메모리에도 남겨 뒀습니다 — `techlog-topic-variant-content.md`
---
## 17. 이 기간 전체에서 배운 것
198개를 다 읽고 남는 것은 다섯 줄입니다.
### 17.1 값의 여정 끝에서 확인한다
한 경계를 고치고 "고쳤다"고 판단해서 세 번 틀렸습니다(§5.2, §6.1, §9.1). 값이 지나는 경계가
열한 개(§1.2)인데 그중 하나만 보고 판단했기 때문입니다.
**확인은 배포본에서, 그 값이 실제로 그려지는 자리에서 합니다.** 타입 검사·단위 테스트·"코드를
읽어 보니 맞다"는 전부 중간 지점입니다.
### 17.2 손으로 나열한 목록은 반드시 갈라진다
종류 목록(§3, 13건), 라우트에 딸린 목록(§8, 6건), 화면마다 복사된 이름 표(§13.1, 6벌).
**전부 같은 병입니다.**
고치는 방법도 하나입니다 — **컴파일러나 테스트가 대신 세게 만듭니다.**
`Record<Kind, _>`, sealed switch 식, 계약을 파싱해 대조하는 가드, 라우트 계약에서 유도하는
서빙 패턴.
### 17.3 화면은 못 읽은 것을 없다고 말하면 안 된다
빈 배열로 삼키면 작성자는 자기가 아직 쓰지 않은 것으로 읽습니다(§10.1). 실제로는 넷이 있었고
공개 사이트에도 나오고 있었습니다.
`Promise.all` 도 같은 병입니다 — 한 칸의 실패가 옆 칸을 끌고 내려갑니다(§10.2).
### 17.4 가드는 넣는 것보다 돌리는 것이 어렵다
가드를 넣었는데 **제가 그것을 돌리지 않아** 두 번 새어 나갔습니다:
- 화면 테스트를 `test:unit` 이 아니라 `test:tech-log` 가 돌리는데 그것을 안 돌려서 **23건이
빨간 채로 여러 커밋을 지나갔습니다** (§7.5)
- 주제 화면 셋을 더하면서 CI 게이트 기준값을 빠뜨려 **게이트가 빨간 채로 여러 커밋을
지나갔습니다** (§8.5)
**가드는 CI 에 묶여야 의미가 있습니다.** 사람이 기억해서 돌리는 가드는 절반만 존재합니다.
### 17.5 프록시 지표가 아니라 보이는 것을 측정한다
grid/columns 를 재고 "정상"이라 답했는데, 전체 페이지 스크린샷을 찍으니 26px/400 이었습니다
(§11.2). 목록 간격을 바운딩 박스로만 재서 「판단 기준」을 결함으로 잘못 지목한 적도 있습니다
(`9f14ca8`).
`f846f51` 에서 **촬영 스크립트**를 둔 이유가 이것입니다:
> 손으로 찍으면 두 가지를 반드시 놓친다. **뷰포트** — 브라우저 세션이 리셋되면 창은 약 877px
> 로 돌아가는데 이 사이트의 분기는 1179/1050/900/767 이라 **방문자 대부분이 보지 않는 배치**를
> 놓고 디자인을 논하게 된다. **축소** — 전체 페이지를 한 장으로 찍으면 1425x4466 이 638x2000
> 으로 들어와 **17px 글자가 7~8px** 이 된다.
폭 셋을 고정으로 돌고 화면 높이만큼 잘라 찍으며, **폭마다 측정도 함께 남깁니다.**
---
## 부록 A. 커밋 색인
이 문서가 인용한 커밋 전부입니다. 각 저장소에서 `git show <해시>` 로 원문을 볼 수 있습니다.
### A.1 tech-log-frontend
| 해시 | 날짜 | 제목 |
|---|---|---|
| `2b2f443` | 2026-09-02 | fix: 주제 탭을 아래 묶음에 붙인다 |
| `3bb724b` | 2026-09-02 | fix: 주제 탭을 칩 크기로 줄이고 개수 상한을 없앤다 |
| `de4cb8b` | 2026-09-02 | feat: 홈이 주제를 탭으로 나란히 세운다 |
| `fe6b56a` | 2026-09-02 | fix: 열리지 않는 주소를 링크로 그리지 않는다 |
| `eedc90b` | 2026-09-02 | fix: 프로필과 프로젝트의 문구를 사용자가 쓴 말로 바꾼다 |
| `094e755` | 2026-09-02 | fix: 편집기가 프로젝트 slug 를 목록 행에서 직접 읽는다 |
| `6e784ed` | 2026-09-02 | fix: 편집기의 결정 목록을 살리고, 프로필 원칙을 이 기록의 말로 고친다 |
| `aca4f18` | 2026-09-02 | feat: 주제 목록 화면을 만들어 주 내비에 넣는다 |
| `69eabc7` | 2026-09-02 | fix: 축 화면에서 결론을 빼고, 홈에서 다른 주제로 갈 길을 낸다 |
| `a71c588` | 2026-09-01 | fix: 축 화면의 머리말을 문서 화면과 같은 것으로 맞춘다 |
| `68538f2` | 2026-09-01 | fix: 구역 제목의 급이 갈리던 것을 정본에 맞춘다 |
| `67a5491` | 2026-09-01 | feat: 축에 자기 화면을 준다 |
| `344dadb` | 2026-09-01 | fix: 구역 규칙이 제목까지 잡던 것을 행에만 건다 |
| `46e4e81` | 2026-09-01 | test: 편집기가 부르는 두 목록이 사라지면 먼저 멈추게 한다 |
| `7acde27` | 2026-09-01 | fix: Studio 가 못 읽은 것을 없는 것으로 그리지 않게 한다 |
| `ffdeaa1` | 2026-09-01 | fix: 홈의 종류 배지도 목록과 같은 배지로 만든다 |
| `db83228` | 2026-09-01 | fix: 「먼저 읽을 것」을 종류마다 한 편으로 줄이고 나머지도 같은 순서로 둔다 |
| `dc2fda7` | 2026-09-01 | fix: 종류 이름을 화면마다 하나로 맞추고, 읽는 순서를 기록에서 만든다 |
| `88d841d` | 2026-09-01 | fix: 홈이 실제로 가장 많이 다룬 주제를 앞에 세운다 |
| `23efcf0` | 2026-09-01 | fix: 주제가 없는 기록이 죽은 링크를 달지 않게 한다 |
| `b89a54f` | 2026-09-01 | fix: 작업본 목록의 종류 필터에 개념을 넣고 이름을 맞춘다 |
| `3660404` | 2026-09-01 | fix: 관계 목록의 제목이 그 목록이 답하는 것을 말한다 |
| `197db74` | 2026-09-01 | fix: 새 화면의 chunk 이름을 표에 넣고, 빠뜨리면 먼저 멈추게 한다 |
| `77ef304` | 2026-09-01 | fix: 모의 검증기가 개념을 아는 종류로 다룬다 |
| `8828005` | 2026-09-01 | fix: 주제로 가는 링크가 실제로 주제 화면에 닿게 한다 |
| `3510ec0` | 2026-09-01 | feat: 프로젝트 화면이 어디서부터 읽을지를 말한다 |
| `604ded5` | 2026-09-01 | feat: 홈이 무엇을 견줬는지 먼저 말한다 |
| `4da6d77` | 2026-09-01 | feat: 기록을 축에 걸고, 탐색을 주제로 묶는다 |
| `3616502` | 2026-09-01 | feat: 주제의 논지와 축을 Studio 에서 쓸 수 있게 한다 |
| `15e6ea8` | 2026-09-01 | feat: 주제 화면을 계약에 잇고, 등록을 잊는 사고를 가드로 막는다 |
| `618a228` | 2026-09-01 | fix: 관계의 묶음 이름과 작성자가 쓴 문장을 갈라 놓는다 |
| `af5a6bb` | 2026-09-01 | fix: 종류 이름을 기록의 톤에 맞는 말로 바꾼다 |
| `a6413d0` | 2026-09-01 | feat: 문서 종류를 독자의 말로 바꾸고 미해결을 눈에 띄게 한다 |
| `2b04282` | 2026-09-01 | fix: 홈 초점의 질문·결정을 고른 프로젝트 것으로 좁힌다 |
| `d835276` | 2026-09-01 | fix: 개념 관계 라벨을 계약이 주는 이름에 맞추고 표를 계약에 묶는다 |
| `8996430` | 2026-09-01 | fix: 공개 개념 문서를 열 수 있게 한다 |
| `a3ed23e` | 2026-08-31 | fix: 관계 요약이 화면까지 닿게 한다 |
| `642afa8` | 2026-08-31 | fix: 관계 목록이 사람이 읽는 이름과 요약을 보여 준다 |
| `6429aee` | 2026-08-31 | fix: 개념 삭제가 실제로 개념 경로로 나가게 한다 |
| `dec86bd` | 2026-08-31 | fix: 개념 삭제가 질문 삭제로 떨어지던 것을 고친다 |
| `8c5dbe1` | 2026-08-31 | fix: 게시 기록과 워크플로 버튼도 글자만 남긴다 |
| `805d400` | 2026-08-31 | fix: 버튼을 글자만 남기고, 결정 절의 문단이 격자에 갇히던 것을 고친다 |
| `795a4bf` | 2026-08-31 | fix: 목록·검색 카드에서 백틱을 지운다 |
| `833943a` | 2026-08-31 | feat: 검색 결과에 찾은 말을 표시하고 릴리스 탭 제목을 구분한다 |
| `a936444` | 2026-08-31 | fix: 릴리스 노트를 목록으로 제대로 읽고 robots.txt 를 서빙한다 |
| `82e992d` | 2026-08-31 | fix: Studio 리뷰 지적을 반영하고 죽은 머리말 컴포넌트를 걷어낸다 |
| `ca1fc92` | 2026-08-31 | fix: 편집기에서 글을 쓸 수 있게 하고 종류 이름을 한 곳에 모은다 |
| `9b7ad82` | 2026-08-31 | fix: 산문 칸의 문단과 인라인 코드를 읽어서 그린다 |
| `f846f51` | 2026-08-30 | feat: 화면 검토용 촬영 스크립트를 둔다 |
| `22090a4` | 2026-08-30 | fix: 페이지 번호를 눌러도 쪽이 넘어가지 않던 것을 고치고 UI 를 정리한다 |
| `9f14ca8` | 2026-08-30 | fix: 공개 문서 본문을 읽을 수 있는 자수와 목록 구조로 되돌린다 |
| `4e486aa` | 2026-08-29 | fix: 요약을 2000자까지 쓰고, 목록과 머리말이 그 길이를 견디게 한다 |
| `8fce8ea` | 2026-08-29 | fix: 요약이 문장 중간에서 멈추지 않게 하고 남은 자리를 보여 준다 |
| `df371d3` | 2026-08-29 | feat: 작업본·게시 기록 목록을 번호로 오간다 |
| `77125d1` | 2026-08-29 | fix: 프로젝트 기록 목록이 모든 종류를 싣고 요약·주제·날짜를 보여 준다 |
| `31afb4d` | 2026-08-29 | fix: 결정 문서가 작성자가 채운 칸을 그대로 보여 준다 |
| `3b6ba64` | 2026-08-28 | fix: 개념을 관계로 가리킬 수 있게 계약을 다시 생성한다 |
| `071405a` | 2026-08-28 | feat: 개념의 공개 라우트와 탐색 유형을 설치한다 |
| `048c1b2` | 2026-08-28 | feat: 개념(CONCEPT) 문서 종류 — 편집기·공개 화면·탐색 |
| `9ecc017` | 2026-08-28 | docs: 개념(CONCEPT) 문서 종류 구현 계획 |
| `05df030` | 2026-08-28 | docs: 개념(CONCEPT) 문서 종류 설계 |
| `2420fce` | 2026-08-26 | fix: 공개 문서가 실제로 그리는 주제 링크도 탐색 필터로 돌린다 |
| `2632850` | 2026-08-26 | fix: 최근 기록에 Open Question 을 싣고, 주제 링크를 탐색 필터로 돌린다 |
| `ad8f322` | 2026-08-26 | fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다 |
| `ab4d822` | 2026-08-26 | fix: 게시된 Open Question 의 공개 상세가 열리게 한다 |
| `344a163` | 2026-08-26 | fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다 |
| `60c8c82` | 2026-08-26 | fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다 |
| `b311995` | 2026-08-25 | fix: 제목 아래에 문서의 요약을 그린다 |
| `7211dd1` | 2026-08-25 | fix: 공개 Reference 가 계약이 주는 이름을 읽게 한다 |
| `3036b8d` | 2026-08-25 | feat: Ctrl+S 로 저장한다 |
| `fd73bc8` | 2026-08-24 | fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다 |
| `e5770df` | 2026-08-24 | fix: Decision 미리보기 오류가 할 일을 말하게 한다 |
| `a7fe069` | 2026-08-24 | feat: 프로젝트 활동을 게시가 남기는 로그로 바꾼다 |
| `ab67eb9` | 2026-08-24 | fix: 릴리즈 편집 하단 버튼이 두 벌 나오던 것을 하나로 되돌린다 |
| `e9b8661` | 2026-08-24 | fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다 |
| `84d72c4` | 2026-08-24 | feat: 릴리즈 편집을 자기 주소로 옮긴다 |
| `6b9dc3f` | 2026-08-24 | fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다 |
| `f9d20e8` | 2026-08-23 | fix: setState 업데이터 안에서 event.currentTarget 을 읽지 않는다 |
| `014f21b` | 2026-08-23 | feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다 |
| `16e5b9f` | 2026-08-23 | feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다 |
| `0eb3c86` | 2026-08-23 | fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다 |
| `c87e0a2` | 2026-08-23 | fix: home focus 경로를 계약과 맞춘다 |
| `b3aa304` | 2026-08-23 | feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다 |
| `c03b0c7` | 2026-08-22 | fix: 오류 수정 |
| `1801414` | 2026-08-21 | fix: show the reason the server gave for a failed action |
| `7345500` | 2026-08-21 | feat: show the way to publish, and what is blocking it, in the editor |
| `fb478f9` | 2026-08-21 | fix: say which version a stale validation judged, before showing its errors |
| `7289ce9` | 2026-08-21 | fix: stop the smoke sweep reporting an expected 404 as a failure |
| `3484206` | 2026-08-21 | fix: stop claiming a 1x1 size for an image whose dimensions are unknown |
| `89a73c1` | 2026-08-21 | feat: delete a decision, manage assets while writing, and sweep before deploying |
| `21f8425` | 2026-08-21 | fix: keep line breaks in the fields that are not Markdown |
| `7093d84` | 2026-08-21 | fix: give a document a slug, and say so when saving fails |
| `d2c289c` | 2026-08-21 | fix: keep the line breaks an author typed |
| `5cffe30` | 2026-08-21 | fix: derive a topic slug that survives a Korean name |
| `197b2c7` | 2026-08-21 | chore: pick up the regenerated Question input contract |
| `c5e8735` | 2026-08-21 | feat: read the profile's topics from Studio, and add working-copy deletion |
| `ab8c6c1` | 2026-08-21 | fix: derive the Studio serving patterns from the route contract |
| `3754269` | 2026-08-21 | feat: add the Studio release editor and point the footer at the changelog |
| `7600711` | 2026-08-21 | fix: give each API surface its own error-code enum |
| `03986da` | 2026-08-21 | fix: let a signed-out visitor read the public site |
| `31dca00` | 2026-08-21 | fix: keep the public screens usable on an empty site, and centre the dialogs |
| `11c2713` | 2026-08-20 | feat: let Studio create the topics and projects publishing requires |
| `4b62bf3` | 2026-08-20 | chore: re-vendor both contracts from the merged design package |
| `6784eb1` | 2026-08-20 | fix: serve the public routes the router declares, not the slugs the build saw |
| `24c01ae` | 2026-08-20 | feat: give the public surface an HTTP adapter, and a switch to reach it |
| `4566f2d` | 2026-08-20 | refactor: make the public read port async so a network adapter can implement it |
| `c362ec6` | 2026-08-20 | feat: vendor the public read contract, and give it its own source switch |
| `83409be` | 2026-08-20 | feat: give the frontend a deployment artifact, and show its logo |
### A.2 tech-log-backend
| 해시 | 날짜 | 제목 |
|---|---|---|
| `8cd8ee3` | 2026-09-02 | fix: 결정을 가리키는 링크가 열리는 주소를 갖는다 |
| `711b2c3` | 2026-09-02 | feat: 프로젝트 목록 행이 slug 를 싣는다 |
| `bd6db0f` | 2026-09-02 | fix: 결정의 consequences 를 실제 저장 모양대로 읽는다 |
| `22a65dc` | 2026-09-02 | feat: 주제 목록에 논지와 축을 싣는다 |
| `6d3b68b` | 2026-09-01 | feat: 축으로 기록을 거른다 |
| `911e8ba` | 2026-09-01 | fix: 편집기가 고를 목록을 서버가 실제로 준다 |
| `e185b87` | 2026-09-01 | feat: 질문 목록에도 주제를 싣는다 |
| `63eb177` | 2026-09-01 | fix: 축의 주소를 주제 화면 안의 자리로 적는다 |
| `78ec5f9` | 2026-09-01 | feat: 기록이 어느 축에 걸리는지를 저장한다 |
| `ee664fd` | 2026-09-01 | fix: 통합 테스트의 프로젝트 명령 인자를 thesis 만큼 맞춘다 |
| `d11cda8` | 2026-09-01 | feat: 주제 안의 접근/구조(Variant) 스키마를 세운다 |
| `92679f5` | 2026-08-31 | chore: 관계 요약을 담는 계약을 반입한다 |
| `926f058` | 2026-08-31 | feat: 개념 작업본을 지우는 경로를 연다 |
| `d34e42c` | 2026-08-29 | fix: 요약 상한을 2000자로 올린다 |
| `68db5f5` | 2026-08-29 | feat: 목록을 번호로 오가고, 요약이 문장 중간에서 멈추지 않게 한다 |
| `f0407d9` | 2026-08-29 | feat: 프로젝트 기록 목록이 요약·주제·게시일을 싣는다 |
| `026460f` | 2026-08-29 | feat: 결정 목록이 제목·요약·영향·근거를 싣는다 |
| `dd7c70e` | 2026-08-28 | fix: 관계 후보 목록이 개념을 실을 수 있게 하고, 그 대응을 테스트로 고정한다 |
| `3a226fb` | 2026-08-28 | fix: 공개 질의 파라미터의 허용 집합이 개념을 받아들이게 한다 |
| `ce2ef61` | 2026-08-28 | feat: 개념의 공개 조회 — 상세·탐색·최근 기록 |
| `fa5158d` | 2026-08-28 | feat: 개념(CONCEPT) 문서 종류 — 계약·스키마·작성·게시 |
| `edb0890` | 2026-08-26 | feat: 홈과 주제의 최근 기록이 게시된 Open Question 을 담는다 |
| `c6d9d2d` | 2026-08-25 | feat: 공개 Case·Reference 응답에 문서 요약을 싣는다 |
| `a5f93b9` | 2026-08-25 | feat: 공개 Reference 응답에 판단 기준과 예시를 싣는다 |
| `f1fd56f` | 2026-08-24 | chore: decision 미리보기기 계약 수정 |
| `bd66fb3` | 2026-08-24 | fix: Decision 미리보기가 열리도록 프로젝트 공개 경로를 catalog 에 싣는다 |
| `a7e2b7d` | 2026-08-24 | feat: 게시가 프로젝트 활동을 남기게 한다 |
| `6aa1400` | 2026-08-23 | feat: 프로젝트 주제를 저장하고 공개 응답에 싣는다 |
| `ca63d7d` | 2026-08-23 | fix: 스캔되는 스프링 컴포넌트의 생성자를 하나로 고정한다 |
| `4c14f1e` | 2026-08-23 | feat: 프로젝트 활동을 만들고 고치고 지울 수 있게 한다 |
| `561d02a` | 2026-08-23 | feat: 프로젝트 게시와 홈 focus, 그리고 기록 사이 연결을 실제로 가능하게 한다 |
| `23d82bd` | 2026-08-22 | fix: 오류 수정 |
| `857e6a9` | 2026-08-21 | fix: refuse deletion only while a record is live, and say why |
| `8d22825` | 2026-08-21 | style: apply the formatter to the image dimension reader |
| `e65b9e2` | 2026-08-21 | fix: record an uploaded image's dimensions |
| `96521a9` | 2026-08-21 | feat: serve uploaded media, delete a decision, and stop orphaning publications |
| `af91f7d` | 2026-08-21 | fix: accept a Question working copy with no resolution |
| `37f474a` | 2026-08-21 | fix: read the public projection by the columns it actually has |
| `1befdc3` | 2026-08-21 | feat: let an author delete a working copy |
| `386f360` | 2026-08-21 | feat: implement release authoring, so the changelog can be written |
| `48517b9` | 2026-08-21 | fix: cast the nullable uuid so Postgres can type the existence check |
| `0da7c7e` | 2026-08-20 | fix: use the Jackson 3 mapper the persistence module actually has |
| `bb6d233` | 2026-08-20 | feat: implement topic and project management, so documents can be authored |
| `bde5826` | 2026-08-20 | fix: ship the object storage adapter, so Studio asset uploads have a backend |
| `55a71fb` | 2026-08-20 | merge: develop — Tech Log 백엔드 계약 2종 완성 (studio-v1 19/19, public-v1 18/18) |
| `0854d42` | 2026-08-20 | merge: feature/techlog-public-v1 — Tech Log 공개 조회 백엔드 (public-v1 18/18) |
| `365560e` | 2026-08-20 | feat: Tech Log 공개 조회 백엔드 — public-v1 18개 operation 구현 |
| `e3254de` | 2026-08-20 | test: 미추적으로 남아 있던 Studio authz 배선 테스트 2개를 추적에 넣는다 |
### A.3 tech-log-design-package
| 해시 | 날짜 | 제목 |
|---|---|---|
| `1aae8dc` | 2026-09-02 | fix: 결정의 공개 주소가 앵커임을 항목에 담는다 |
| `ffa088b` | 2026-09-02 | feat: 프로젝트 목록 행에 slug 를 싣는다 |
| `559d04f` | 2026-09-02 | feat: 주제 목록에 논지와 축을 싣는다 |
| `b93d62a` | 2026-09-01 | feat: 축으로 기록을 거를 수 있게 한다 |
| `a58ad30` | 2026-09-01 | feat: 질문 목록에도 주제를 싣는다 |
| `71bab4c` | 2026-09-01 | fix: 축의 주소를 실제로 있는 자리로 적는다 |
| `0f4d2cd` | 2026-09-01 | feat: 공개 프로젝트에 논지를 싣는다 |
| `ca1bbfe` | 2026-09-01 | feat: 관계에 이유와 우선순위를, 주제에 접근/구조 관리를 연다 |
| `2d9672d` | 2026-09-01 | feat: 주제 안의 접근/구조(Variant)를 계약에 세운다 |
| `fa67a64` | 2026-08-31 | feat: 관계에 대상 요약을 실을 수 있게 한다 |
| `f56a03b` | 2026-08-31 | feat: 개념 작업본을 지울 수 있게 한다 |
| `5942a2e` | 2026-08-29 | fix: 요약 상한을 2000자로 올린다 |
| `617eb6a` | 2026-08-29 | fix: 요약이 문장 중간에서 멈추지 않도록 상한을 넓힌다 |
| `d445555` | 2026-08-29 | contract: Studio 목록이 페이지 번호를 그릴 수 있게 한다 |
| `76a7ccb` | 2026-08-29 | contract: 프로젝트 기록 목록이 요약·주제·게시일을 싣는다 |
| `987c1b8` | 2026-08-29 | contract: 결정 목록이 화면이 그리는 칸을 다 싣는다 |
| `2c25ccc` | 2026-08-28 | contract: 개념을 열거해야 하는 자리를 빠짐없이 채운다 |
| `32d1785` | 2026-08-28 | contract: 관계·근거 후보 목록이 개념을 실을 수 있게 한다 |
| `777aeaa` | 2026-08-28 | contract: 개념 envelope 을 기존 envelope 모양에 맞춘다 |
| `33c41cc` | 2026-08-28 | contract: 개념의 즉시 미리보기 렌더 모델을 더한다 |
| `04c791f` | 2026-08-28 | contract: 개념(CONCEPT) 문서 종류를 더한다 |
| `ef49d3a` | 2026-08-26 | contract: 홈과 주제의 최근 기록이 Open Question 을 담을 수 있게 한다 |
| `0ffbc28` | 2026-08-25 | contract: 공개 Case·Reference 에 문서 요약을 싣는다 |
| `ff0c12a` | 2026-08-25 | contract: 공개 Reference 에 판단 기준과 예시를 싣는다 |
| `83148b2` | 2026-08-24 | contract: Decision 렌더 모델의 결정일을 비울 수 있게 한다 |
| `06ae075` | 2026-08-23 | contract: 공개 프로젝트에 주제를 싣는다 |
| `436937f` | 2026-08-23 | contract: 프로젝트 활동을 envelope 으로 옮기고 지우기를 더한다 |
| `ed04872` | 2026-08-23 | contract: home focus 경로를 AIP-122 에 맞춘다 |
| `caa98be` | 2026-08-23 | contract: 프로젝트 게시와 홈 focus 를 envelope 으로 확정한다 |
| `b195b29` | 2026-08-21 | contract: let a list item be empty too |
| `fb36d56` | 2026-08-21 | contract: let a published record carry empty prose |
| `65a04fc` | 2026-08-21 | contract: let an author delete a project decision |
| `a30d61d` | 2026-08-21 | contract: declare the media path relative to the server prefix |
| `a981249` | 2026-08-21 | contract: declare the public media endpoint |
| `dc290b4` | 2026-08-21 | contract: stop requiring a resolution on an unresolved question |
| `332b11f` | 2026-08-21 | contract: name the in-use refusal for working-copy deletion |
| `356cb48` | 2026-08-21 | contract: convert the working-copy delete operations to the ADR-006 envelope |
| `0c10a4a` | 2026-08-21 | contract: convert the release operations to the ADR-006 envelope |
| `6ef5c1c` | 2026-08-20 | contract: TopicEdit에 id와 version을 싣는다 |
| `501bfb2` | 2026-08-20 | contract: studio-management-v1의 topics/projects 9개를 응답 봉투로 변환 (ADR-006) |
| `b98eaf9` | 2026-08-20 | merge: feature/public-v1-response-envelope — public-v1 계약을 응답 봉투로 재정의 (ADR-006) |
| `55a9599` | 2026-08-20 | contract: public-v1.yaml을 응답 봉투로 재정의 (ADR-006 갱신) |