feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,59 @@
# writing-tech-log-records
Tech Log Studio에 올릴 기록을 쓰는 스킬. Case · Concept · Reference · Question · Decision 다섯 종류의
종류 선택, 칸 채우기, Case 본문 작성, 게시 전 대조를 다룬다.
## 파일
| 파일 | 무엇 |
|---|---|
| `SKILL.md` | 진입점. 종류 선택과 절차 |
| `references/record-kinds.md` | 다섯 종류의 칸·상한·게시 조건 |
| `references/from-ssot-to-records.md` | 긴 글에서 글감을 뽑는 기준과 `tech-log-tree.json` |
| `references/writing-each-kind.md` | 종류마다 무엇을 어떤 순서로 쓰나 |
| `references/body-syntax.md` | Case 본문의 허용·금지 문법 |
| `references/code-tables-diagrams.md` | 코드블록·표·SVG·이미지 |
| `references/explaining.md` | 설명의 깊이와 말투 |
| `references/review-checklist.md` | 게시 전 대조 |
| `examples/case-body.md` | 통과하는 본문 예시 |
| `scripts/check_body.mjs` | 본문을 Studio 파서로 미리 검사 |
## 본문 미리 검사
Studio에 붙여넣기 전에 확인한다. Studio가 쓰는 파서를 그대로 부르므로, 통과하면 저장도
통과한다.
```bash
node --experimental-transform-types \
.agents/skills/writing-tech-log-records/scripts/check_body.mjs 초안.md
```
`tech-log-frontend` 체크아웃이 기본 경로에 없으면 알려 준다.
```bash
node --experimental-transform-types scripts/check_body.mjs 초안.md \
--frontend /path/to/tech-log-frontend
# 또는 TECH_LOG_FRONTEND 환경변수
```
통과하면 블록 구성을, 실패하면 줄·칸과 이유를 낸다.
```text
PASS 14개 블록 — CALLOUT 2 · CODE_BLOCK 1 · DATA_TABLE 1 · …
FAIL 초안.md:3:1 unsupported block syntax: html
```
## 계약 기준
| 계약 | 버전 |
|---|---|
| `@tech-log/studio-contract` | 3.1.0 |
| `@tech-log/public-contract` | 2.1.0 |
계약이 올라가면 `body-syntax.md`의 허용 목록과 `record-kinds.md`의 상한을 다시 맞춘다. 특히
블록 유니온(`CaseRenderBlock`)에 타입이 늘면 쓸 수 있는 문법이 늘어난다.
## 알아둘 제약
코드·표·다이어그램·이미지는 **본문에만** 들어간다. 본문이 있는 종류는 Case 와 Concept 이다. Reference·Question·Decision의 모든
칸은 평문으로 렌더링된다. 설계상 그렇다 — 본문을 가진 종류는 Case뿐이다.
@@ -0,0 +1,85 @@
---
name: writing-tech-log-records
description: Use when writing or revising a Tech Log Studio record — Case, Concept, Reference, Question, or Decision — including choosing the right kind, filling each kind's fields, authoring body Markdown with code blocks, tables, callouts, diagrams and evidence images, and linking records so a published document renders correctly on the public site.
metadata:
version: "1.1.0"
language: "ko-KR"
studioContract: "@tech-log/studio-contract@3.1.0"
publicContract: "@tech-log/public-contract@2.1.0"
---
# Tech Log 기록 작성
## 개요
Studio는 다섯 종류를 구분한다. 종류를 잘못 고르면 채울 칸이 맞지 않는다.
| 종류 | 쓰는 때 | 본문 |
|---|---|---|
| **Case** | 내가 재현하고 검증해 결론을 냈다 | 있음 |
| **Concept** | 남의 것이 어떻게 동작하는지 읽고 정리했다 | 있음 |
| **Reference** | 반복 적용할 기준을 굳혔다 | 없음 |
| **Question** | 아직 판단이 안 끝났다 | 없음 |
| **Decision** | 프로젝트가 방향을 정했다 (`PROJECT_DECISION`) | 없음 |
## 절대 규칙
**코드·표·다이어그램·이미지는 본문에만 들어간다. 본문이 있는 종류는 Case 와 Concept 둘뿐이다.**
본문이 없는 세 종류의 칸은 평문으로 렌더링돼 백틱과 파이프가 글자 그대로 보인다. 그런 자료는 Case 나
Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
## 필수 절차
0. **글감 나누기** — 긴 글(SSOT)에서 옮겨 오는 것이면 먼저 나눈다. 기준과 `tech-log-tree.json`
형식은 `references/from-ssot-to-records.md`. 나눈 뒤 글을 쓴다.
1. **종류 선택** — 위 표. 애매하면 "재현했나"를 묻는다.
2. **칸 채우기** — 칸과 게시 조건은 `references/record-kinds.md`.
3. **본문 작성**(Case·Concept) — **종류마다 무엇을 어떤 순서로 쓰는지는
`references/writing-each-kind.md`.** 문법은 `references/body-syntax.md`, 표·코드·그림은
`references/code-tables-diagrams.md`. **문장은 `references/explaining.md`.**
**문서군 전체의 리듬은 `references/ai-tells.md`.**
그림이 필요하면 손으로 그리지 말고 `technical-visualizer` 스킬로 만든다.
이미 쓴 문장이 AI가 쓴 것처럼 읽히면 `rewriting-technical-prose-naturally` 로 다시 쓴다.
4. **검사** — 둘 다 돌린다. 파서와 문장은 다른 것을 본다.
- `scripts/check_body.mjs` — Case 본문이 Studio 파서를 통과하는지. 같은 파서를 그대로 부른다.
- `rewriting-technical-prose-naturally/scripts/check_prose.mjs` — 문장 규범. **error 0 이 될 때까지 고친다.**
칸 하나나 한 절만 고쳤으면 `--doc` 없이 부른다. 이어서 `style_profile.mjs` 로 문체 수치를 본다.
5. **관계 연결** — Decision은 근거가 **1개 이상** 없으면 게시가 거절된다.
6. **Studio에서 확인** — 넣고 **저장까지만** 한 뒤 미리보기로 읽는다.
절차는 `references/studio-draft-review.md`. **게시하지 않는다.**
7. **게시** — 고칠 것이 없을 때만. 못 채운 칸은 그 칸 아래에 표시된다.
## 보호 구간
수치, 날짜, 버전, 단위, 코드, 명령어, URL, 인용, 공식 명칭은 원문과 한 글자도 달라지면 안 된다.
측정하지 않은 값을 채우지 않는다 — 검증일은 실제로 확인한 날이다.
## 쓰지 않는 것
- 자료가 뒷받침하지 않는 선택 이유. 썼다는 사실을 왜 골랐는지로 바꾸지 않는다.
- 지어낸 경험·실패·감정. 자료에 없는 1인칭 서술.
- 가능성을 확정으로, 한 구조에서 본 것을 protocol 전체로 넓히기.
## 흔한 실패
| 실패 | 대응 |
|---|---|
| Reference 규칙에 코드블록 | Case로 옮겨 관계 연결 |
| 본문 밖 칸에 백틱 | 평문이다. 백틱을 뺀다 |
| 평문 칸에 쉼표 나열 | `이름 : 값`으로 줄 나눔. 있음·없음은 `o`·`x` |
| 표 머리글이 명사뿐 | 질문으로. 비교 축은 `전`·`후` 시점으로 |
| 그림 안에 문장·숫자 | `<text>`는 이름만. 문장은 `<desc>`·옆 문단에 |
| 이름만 대고 넘어감 · 「역할이 다르다」로 끝냄 | 왜 있는지·왜 못 합치는지까지 |
| 산문에 내부 코드명 | 구조 이름으로. 번호는 표 축·식별자에만 |
| `**굵게**` 남발 · 끊어 나열 · 되풀이 강조 | 한 절에 하나, 한 문단으로, 한 번만 |
| `~하는 것은 ~이다` · 「~한 것은 아니다」로 시작 | 번역투다. 문제 → 할 일 → 확인 |
| `싣는다`·`낸다`·`둘이` · `자리`·`떠안다` | 동작을 풀고, 비유 없이 그대로 |
| 읽는 법을 지시 | 「봐야 한다」·「여기까지다」 삭제 |
| `..?`·`~해보자` 억지 구어체 | 명사구 제목으로. 본 것을 먼저 쓴다 |
| 문서마다 같은 문형·같은 길이 | `references/ai-tells.md` |
| 표를 `:::table`로 감쌈 | 그냥 파이프로 쓴다 |
| `![](https://…외부)` | Asset으로 올려 `/api/v1/public/media/…` |
| Decision에 근거 없음 | 관계 1개 이상 연결 |
| 측정 안 한 검증일 | 비워 둔다 |
작성 후 `references/review-checklist.md`로 대조한다.
@@ -0,0 +1,49 @@
## 측정값 — 직접 측정
초기화 컬렉션 수와 총 PreparedStatement는 Hibernate `Statistics`, 지연은 `System.nanoTime`으로 읽은 값이다.
:::table id="direct-measurement" caption="N별 초기화 컬렉션 수와 총 PreparedStatement" rowHeaderColumn="1"
| N | 초기화 Highlight 컬렉션 | 총 PreparedStatement | 지연 중앙값 |
|---:|---:|---:|---:|
| 10 | 10 | 25 | 32.8 ms |
| 100 | 100 | 222 | 85.9 ms |
| 1,000 | 1,000 | 2,022 | 193.7 ms |
:::
조회량은 N에 정확히 비례했다. 여기서 N은 전체 테이블 크기가 아니라 한 요청이 반환한 FeedItem 수다.
## 문제가 된 조회
```java label="컬렉션을 지연 로딩하는 최초 구현"
@Query("select fi from FeedItem fi where fi.visibility = :visibility")
Page<FeedItem> loadFeed(@Param("visibility") Visibility visibility, Pageable pageable);
```
:::note
같은 `@ManyToOne(EAGER)`라도 실행 횟수는 Persistence Context 안의 distinct 대상 수가 정한다. 애너테이션 하나로 갈리지 않는다.
:::
---
## 확인한 실행 계획
:::evidence key="feed-explain-plan" alt="Index Scan 뒤 rows=500이 찍힌 EXPLAIN 출력" caption="반복되는 자식 조회의 실행 계획" zoom="true"
:::
## 측정의 범위와 한계
:::warning
지연 값은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 단일 스레드·warm-cache 로컬 비교값이다. HTTP 종단 지연도 운영 p99도 아니다.
:::
Hibernate `Statistics`가 주는 값은 획득한 PreparedStatement 수이지 SQL shape별 실행 횟수가 아니다. shape별 횟수를 원문 SQL 수준에서 확정하려면 다음 중 하나로 따로 수집해야 한다.
- SQL 로그 또는 `StatementInspector`
- datasource-proxy 또는 p6spy
- PostgreSQL statement logging
@@ -0,0 +1,147 @@
# AI가 쓴 티
문장 하나하나는 멀쩡한데 문서군 전체가 같은 리듬으로 굴러갈 때 티가 난다. 개별 문장을 일부러
어눌하게 만드는 방향은 역효과다. 아래는 실제 리뷰에서 지적된 것들이다.
## 억지 구어체를 만들지 않는다
AI 티를 지우려고 넣은 질문체와 청유형이 오히려 「AI 문장을 억지로 인간화한 것」으로 읽힌다.
제목은 명사구로 두고, 본문은 무엇을 봤는지로 시작한다.
| 억지로 사람처럼 | 그냥 제목 |
|---|---|
| `무엇이 서버로 책임 이전을 했지?` | `서버로 옮겨진 책임` |
| `AP2_SESSION은 언제 생기지?` | `AP2_SESSION이 생성되는 시점` |
| `one-time handoff인가..?` | `/token/access는 일회성 전달이 아니다` |
| `memory-only가 위험을 막아주나..?` | `memory-only가 줄이는 위험` |
| `세 겹으로 나눠서 막아보자` | `세 개의 독립된 경계` |
| `그래서 이 패턴의 문제는 받은 헤더를 어떻게 믿지?` | `upstream은 헤더의 출처를 구분할 수 없다` |
`..?`, `~하지?`, `~해보자`, `~하나?`, `확인하자`가 보이면 지운다.
## 결론을 먼저 정리하지 않는다
관측한 사실이 결론을 만들게 둔다. 정리된 대구 문장은 한 문서에 한 번이면 충분하다.
```text
✗ BFF는 token을 브라우저에서 제거하는 대신 session과 CSRF 책임을 갖게 된다.
○ 브라우저 network에서 token endpoint 호출과 Authorization Bearer가 사라졌다.
대신 `/bff/api/me` 요청에는 AP3_SESSION이 자동으로 붙었다.
상태 변경 요청을 추가하면서 이 cookie 때문에 CSRF 검증이 필요해졌다.
```
Case는 튜토리얼이 아니라 사건의 순서를 따라간다.
```text
처음 예상 → 실제 요청·코드에서 본 것 → 예상과 달랐던 지점 → 왜 그런지 → 확인한 범위
```
「처음에는 ~라고 봤다. 그런데 ~를 따라가 보니 ~였다」는 **실제로 그렇게 생각한 기록이 있을
때만** 쓴다. 없으면 지어낸 1인칭이다.
## 같은 문형을 문서마다 되풀이하지 않는다
한 문서군에서 아래 두 구조가 반복되면 그것 자체가 티다.
```text
A를 얻는다. 대신 B를 내준다. 그래서 C를 해야 한다.
A와 B는 다르다. 둘을 나눠야 한다. 같은 이름으로 부르면 안 된다.
```
다 쓰고 나면 세어 본다. `대신`·`그래서`·`함께`·`그대로`·`따로`·`하게 된다`·`정해야`가 문서마다
비슷한 횟수로 나오면 문형이 굳은 것이다.
```bash
grep -o '대신\|그래서\|함께\|그대로\|따로\|하게 된다' *.md | sort | uniq -c | sort -rn
```
## 길이를 고르게 맞추지 않는다
규칙 11개를 같은 길이로 쓰면 사람이 고른 것으로 읽히지 않는다. 중요한 규칙은 길게 쓰고 자명한
규칙은 한 줄로 끝낸다. 항목이 축으로 정리되는 내용이면 산문 대신 표 하나가 낫다.
```text
endpoint | 브라우저가 접근 | credential | secret | 검증 주체
```
표로 정리한 뒤 특이사항만 문장으로 쓴다.
## Question은 균형 잡힌 비교표가 아니다
선택지마다 「장점. 대신 단점.」을 똑같이 붙여 놓으면 아직 모르는 문제가 아니라 비교를 요청받고
답한 문서가 된다. 실제 설계 기록은 이렇게 생겼다.
```text
지금 확인한 사실
지금 모르는 것
유력한 후보와 그 후보에서 확인할 항목
제외한 후보와 제외한 이유
무엇으로 결정할지
```
후보를 균등하게 나열하는 대신 지금 위치에서 **한 단계 앞의 결정만** 본다. 그리고 저장소 선택과
구조 변경처럼 층이 다른 선택지는 같은 목록에 넣지 않는다.
## 검증 전 결과를 결론으로 쓰지 않는다
Question이 재현하지 않은 일을 단정하면 답을 이미 아는 문서가 된다.
```text
✗ 두 replica가 같은 refresh token으로 동시에 갱신하면 한쪽은 거부되게 된다.
○ 두 replica가 같은 refresh token으로 동시에 갱신할 수 있다. rotation 정책 때문에 두 번째
사용이 거부될 가능성이 있고, 실제 응답과 session 영향은 아직 재현하지 않았다.
```
제약에 「이 전제는 바꾸지 않는다」고 써 놓고 그 전제를 바꾸는 선택지를 나란히 두지 않는다.
비교용으로 남기려면 「제약상 제외」로 따로 뺀다.
## 가짜 정량성을 만들지 않는다
측정할 수 없는 것을 숫자처럼 쓰지 않는다.
```text
✗ 헤더 계약 수가 BFF 계약 수를 넘는 지점이 되돌릴 기준이다.
○ 전달하려는 claim이 계속 늘어나는가. role·tenant 변경이 즉시 반영돼야 하는가.
정책이 애플리케이션 도메인을 알아야 하는가.
```
## 범위를 넓히는 단정을 쓰지 않는다
| 넓힌 것 | 좁힌 것 |
|---|---|
| token이 memory 밖으로 나가는 **유일한** 구간 | 현재 SPA 코드에서 access token이 외부 요청으로 나가는 지점 |
| 짧은 수명이 **사실상 유일한** 방어 | 이 구성에는 denylist도 introspection도 없다. 그래서 노출 시간을 줄이는 주된 수단이 짧은 TTL이다 |
| 배포 한 번에 **전원이** 로그아웃된다 | 상태가 process-local이라 그 instance가 종료되면 그 instance가 들고 있던 session과 authorized client가 사라진다 |
| JDBC는 컬럼 암호화 수단이 **대부분 이미** 갖춰져 있다 | (환경마다 다르다. 확인한 것만 쓴다) |
「~하면 ~을 우회할 수 있다」도 실제 구성에서 확인한 범위까지만 쓴다.
## 기록되지 않은 과거를 만들지 않는다
`~하던 관행을 버리게 된다`, `그동안 ~라고 불러 왔다`. 그런 이력이 자료에 없으면 지운다.
## Reference는 Case의 재설명이 아니다
같은 프로젝트의 Case를 문장만 바꿔 옮기면 규칙 수만 늘어난다. 다른 프로젝트에서 다시 적용할 수
있는 기준만 남기고, 사건은 Case에 두고 관계로 가리킨다. 규칙이 10개를 넘으면 축이 겹치는지 본다.
## 현재 검증과 운영 권고를 섞지 않는다
secret manager, network policy, mTLS처럼 지금 구성에 없는 것을 규칙에 그냥 적으면 Best
Practice를 덧붙인 문서가 된다. 두 묶음으로 나눈다.
```text
현재 확인한 것
운영에서 추가로 필요한 것
```
## 테스트가 무엇을 단정하는지 쓴다
```text
✗ 이 요청이 200을 받는지 아닌지는 중요하지 않다.
○ 이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청은 200이
될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
```
`설정 → 실제 요청 → 실제 status`를 잇는 문장을 늘리고 일반론을 줄인다.
@@ -0,0 +1,97 @@
# Case 본문 문법
`본문 Markdown` 칸에만 해당한다. 다른 종류의 칸은 평문이다.
본문은 자유 Markdown이 아니라 **화이트리스트로 좁힌 Markdown**이다. 공개 사이트가 raw HTML이
아니라 타입 블록을 렌더링하기 때문에, 유니온에 없는 문법은 렌더링할 대상이 없어 거절된다.
벗어나면 `CONTENT_FORMAT_INVALID`로 게시가 막힌다.
## 쓸 수 있는 것
| 문법 | 예 |
|---|---|
| 제목 | `#` ~ `######` (1~6단계) |
| 문단 | 그냥 쓴다 |
| 강조 | `**굵게**` `*기울임*` `` `코드` `` |
| 링크 | `[문구](/경로)` · `[문구](https://…)` |
| 목록 | `- 항목` · `1. 항목` |
| 인용 | `> 한 문단` |
| 수평선 | `---` |
| 코드블록 | ```` ```java ```` |
| 표 | 파이프 표. **감싸지 않는다** |
| 그림 | `![대체 텍스트](/api/v1/public/media/…)` |
| callout | `:::note` `:::tip` `:::warning` `:::danger` |
| 증거 이미지 | `:::evidence key="…" alt="…" caption="…" zoom="true"` |
제목에 고정 id를 주려면 `## 측정 결과 {#measurement}`.
## 쓸 수 없는 것
- **raw HTML** — `<div>`, `<br>`, `<img>` 모두 거절
- **각주** — `[^1]`
- **체크박스 목록** — `- [ ] 할 일`
- **중첩 목록** — 목록 항목 안에 목록
- **중첩 인용** — `> >`
- **취소선** — `~~지움~~`
- **링크 title** — `[문구](/경로 "설명")`
- **외부 스킴** — `javascript:`, `data:`, `//다른호스트`
인용과 callout은 **문단을 정확히 하나만** 담는다. 목록 항목도 문단 하나만 담는다. 여러 문단이
필요하면 블록을 나눈다.
## 링크와 이미지 주소
허용되는 주소는 넷뿐이다.
```text
#앵커
/상대경로
https://… 또는 http://…
mailto:…
```
`//호스트`로 시작하는 주소는 거절된다. 프로토콜 상대 주소는 어느 사이트를 가리키는지 원문만
보고 알 수 없기 때문이다.
**object storage 주소를 본문에 직접 쓰지 않는다.** 만료되는 presigned URL이 원문에 박히면 나중에
깨진다. 이미지는 Asset으로 올리고 `/api/v1/public/media/{assetId}` 또는 `:::evidence`로 가리킨다.
## directive 쓰는 법
세 개뿐이다: `table`, `callout`, `evidence`. 그리고 서버가 아는 이름 넷: `note`, `tip`,
`warning`, `danger`.
속성은 **정확히 맞아야 한다** — 하나라도 빠지거나 남으면 거절된다.
```text
:::table id="…" caption="…" rowHeaderColumn="1"|"none"
:::callout tone="warning"|"info" label="…"
:::evidence key="…" alt="…" caption="…" zoom="true"|"false"
```
`note`·`tip`·`warning`·`danger`는 이름이 곧 성격이라 속성을 받지 않는다.
```text
:::note
참고할 내용 한 문단.
:::
```
여는 줄과 닫는 `:::` 사이에 **빈 줄**을 둔다. 붙여 쓰면 문단으로 인식되지 않는다.
## 자주 나오는 거절과 원인
| 메시지 | 원인 |
|---|---|
| `unsupported block syntax: html` | raw HTML을 썼다 |
| `unsupported inline syntax: image` | 문단 안에 글과 그림을 섞었다 |
| `unknown block directive: …` | 위 일곱 이름이 아니다 |
| `table directive must contain exactly one GFM table` | `:::table` 안에 표가 없거나 둘이다 |
| `callout directive must contain exactly one paragraph` | callout에 문단이 없거나 둘 이상이다 |
| `list items must contain exactly one paragraph` | 목록을 중첩했다 |
| `unsafe link URL: …` | 허용되지 않는 스킴이나 `//` 주소 |
| `duplicate explicit ID: …` | 같은 id를 두 번 썼다 |
거절 메시지에는 줄·칸 번호가 붙는다. 그 줄을 먼저 본다.
@@ -0,0 +1,188 @@
# 코드·표·다이어그램·이미지
본문(`bodyMarkdown`)에만 해당한다. 본문이 있는 종류는 Case 와 Concept 이다.
## 코드블록
````text
```java
@Query("select fi from FeedItem fi join fetch fi.user")
List<FeedItem> findFeed(Pageable pageable);
```
````
언어는 `^[A-Za-z0-9][A-Za-z0-9_.+-]*$`만 쓴다 — `java`, `kotlin`, `sql`, `yaml`, `bash`,
`text`. 언어를 모르면 `text`.
설명을 붙이려면 `label` 하나만 쓴다. 다른 속성은 거절된다.
````text
```sql label="N+1이 발생하는 조회"
SELECT * FROM highlights WHERE feed_item_id = ?;
```
````
**코드에 자격증명·토큰·내부 호스트를 남기지 않는다.** 지울 때는 지웠다는 사실이 보이게 한다 —
`Authorization: Bearer <생략>`처럼. 조용히 빼면 다음 사람이 그 헤더가 없었다고 읽는다.
붙여넣은 코드는 원문 그대로 둔다. 줄바꿈과 들여쓰기를 손보면 재현이 달라진다.
## 표
파이프로 그냥 쓴다. **감싸지 않는다.**
```text
| N | 초기화 컬렉션 | 총 쿼리 |
|---|---|---|
| 10 | 10 | 25 |
| 100 | 100 | 222 |
| 1,000 | 1,000 | 2,022 |
```
설명이나 행 머리글이 필요할 때만 `:::table`로 감싼다. 세 속성이 전부 있어야 한다.
```text
:::table id="direct-measurement" caption="N별 직접 측정값" rowHeaderColumn="1"
| N | 초기화 컬렉션 | 총 쿼리 |
|---|---|---|
| 10 | 10 | 25 |
:::
```
- `id` — 문서 안에서 유일해야 한다. 제목 id와도 겹치면 안 된다
- `rowHeaderColumn` — `"1"`이면 첫 열이 행 머리글, 아니면 `"none"`
- 정렬은 구분줄로 준다: `|---:|` 오른쪽, `|:---:|` 가운데
**측정값과 파생값을 한 표에 섞지 않는다.** 섞어야 한다면 성격 열을 두어 어느 것이 잰 값이고
어느 것이 계산한 값인지 밝힌다.
### 머리글이 질문이면 행이 답이 된다
열 이름을 명사로 두면 독자가 표를 훑는다. 질문으로 두면 읽고 답을 얻는다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| `memory-only가 줄이는가` | `memory-only가 위험을 막아주나..?` |
| `남는 데이터` · `reload 뒤` | `reload 전` · `reload 후` |
| `확인함` · `확인 안 함` | `o` · `x` |
비교 표의 축은 **시점이나 상태**로 못 박는다. ``·``, ``·``처럼 어느 때의 값인지 열
이름이 말해야 한다. `남는 데이터` 같은 두루뭉술한 이름을 쓰면 그 열이 언제 얘긴지 본문을 다시
봐야 한다.
있음·없음을 가르는 열은 `o`·`x`로 채운다. 열이 좁아지고 값이 눈에 띄게 갈린다. 문장 안이 아니라
값 자리에서만 쓴다.
**열을 줄인다.** 감싸개가 `--body-copy`(672px)에 묶여 있고 표에는 `min-width: 780px`이 걸려
있어서, 열이 몇 개든 오른쪽 108px은 늘 잘린다. 3열을 2열로 줄일 수 있으면 줄이고, 첫 열
문구를 짧게 해서 값 열을 왼쪽으로 당긴다.
```text
3열 2열로
| 방어선 | 무엇을 막나 | 뚫리면 | → | 위치 | 여기서 어떻게 막지? |
```
세 번째 열에 있던 내용은 표 다음 문단에서 이어 쓴다.
## 다이어그램·SVG
### 그림에 문장을 넣지 않는다
가장 흔한 실패다. 설명을 그림 안으로 밀어 넣으면 라벨이 길어지고, 글자는 작아지고, 검색도
복사도 화면 낭독도 안 되는 텍스트가 된다. 그림은 **관계**를 보이고 문장은 그 옆 문단에 쓴다.
지킬 선:
| 항목 | 기준 |
|---|---|
| 상자 | 7개 이하. 넘으면 그림을 나눈다 |
| 라벨 | 한 줄 40자 이하. 문장이 아니라 이름 |
| 글자 크기 | 2종 (제목·보조). 3종부터는 위계가 아니라 소음이다 |
| 색 | 의미를 색에만 싣지 않는다. 빗금·테두리·위치를 함께 쓴다 |
| 화살표 | 방향이 논지일 때만. 장식으로 긋지 않는다 |
| 숫자 | 넣지 않는다. 산문에 쓴다 |
빨강·초록 조합은 피한다. 색각 이상에서 구분되지 않는다.
**자가 점검.** 올리기 전에 `<text>`를 전부 뽑아 읽는다.
```bash
grep -o '<text[^>]*>[^<]*</text>' 그림.svg
```
하나라도 아래에 걸리면 문장이므로 뺀다. 길이가 아니라 **서술하느냐**가 기준이다 — 40자 이하여도
문장은 문장이다.
- 마침표나 물음표로 끝난다
- 서술어가 있다 — `~있다`, `~막는다`, `~바꿔도`
- 조사로 두 대상을 잇는다 — `A를 B로`, `A에서 B까지`
`세 가지가 한 영역 안에 있다`는 그림이 이미 보여 주는 것을 글로 다시 쓴 것이다. 상자를 한
영역 안에 그렸으면 그 문장은 필요 없다. 지우면 그림이 더 명확해진다.
`<title>`과 `<desc>`는 예외다. 화면을 못 보는 사람이 듣는 자리이므로 여기에는 문장을 쓴다.
그림 안에서 뺀 설명이 갈 곳이기도 하다.
### 무엇을 그릴지 정하는 법
그림 하나에 주장 하나다. "이 그림이 없으면 독자가 무엇을 못 보나"에 한 문장으로 답할 수
없으면 그리지 않는다. 표로 되는 것을 그림으로 그리지 않는다 — 표는 값을 비교하고, 그림은
**포함·순서·경계**처럼 자리로만 보이는 것을 맡는다.
Studio는 다이어그램을 그려 주지 않는다. **파일로 만들어 Asset으로 올린다.**
1. `본문에 Asset 삽입` → `업로드 종류`를 **다이어그램**으로 → `Asset 업로드`
2. 목록에서 고르면 커서 자리에 `:::evidence` 구문이 삽입된다
SVG는 `image/svg+xml`로 올라가고 다른 이미지와 같게 다뤄진다. 벡터라 확대해도 깨지지 않으니
구조도·흐름도에 맞다.
올리기 전에 SVG에서 지울 것:
- `<script>` 요소와 `on*` 속성
- 외부 폰트·이미지 참조 — 열람자 환경에서 안 불러온다. 글자는 path로 변환하거나 일반 폰트만
- 절대 좌표에 의존하는 고정 크기 — `viewBox`를 두어 늘어나게 한다
## 증거 이미지
```text
:::evidence key="asset-key" alt="무엇을 보여 주는 그림인가" caption="설명" zoom="true"
:::
```
- 네 속성이 전부 있어야 한다. 본문은 비운다
- `key`는 `^[a-z0-9]+(?:-[a-z0-9]+)*$`
- `zoom="true"`면 눌러서 확대할 수 있다. 표·로그처럼 글자가 작으면 켠다
- `alt`는 화면을 못 보는 사람이 읽는 문장이다. "스크린샷"이 아니라 **무엇이 보이는지** 쓴다
- 장식용 그림은 Asset의 `decorative`를 켜고 `alt`를 비운다
`READY`가 아닌 Asset은 게시 시 거절된다.
## 일반 이미지
Asset이 아닌 그림은 Markdown으로 쓴다.
```text
![요청 흐름 네 단계](/api/v1/public/media/72f1f9c6-6fc1-4b57-9297-808d026f5fb9)
```
**문단 하나가 그림 하나로만 이루어져야** 그림으로 인식된다. 글과 섞으면 인라인 이미지가 되어
거절된다.
설명이 필요하면 Markdown title을 쓰지 말고 — 거절된다 — `:::evidence`의 `caption`을 쓰거나
그림 다음 문단에 쓴다.
## 무엇을 어디에 쓰나
| 담을 것 | 쓸 것 |
|---|---|
| 명령어·설정·소스 | 코드블록 |
| 숫자 비교 | 표 |
| 구조·흐름 | SVG 다이어그램 Asset |
| 화면·로그 캡처 | 이미지 Asset + `zoom="true"` |
| 놓치면 안 되는 단서 | `:::warning` |
| 곁가지 설명 | `:::note` |
캡처로 표를 대신하지 않는다. 그림 속 숫자는 검색도 복사도 안 되고 화면 낭독기가 읽지 못한다.
@@ -0,0 +1,413 @@
# 설명의 깊이와 말투
가장 자주 나오는 지적은 **설명이 짧다**는 것이다. 사실은 맞는데 독자가 따라오지 못한다.
## 이름을 댔으면 왜 있는지도 댄다
낯선 클래스·기법·설정 이름을 적고 다음 문장으로 넘어가지 않는다. **왜 그것이 존재하는지**를 한
문장 붙인다.
```text
쓰지 않는다
XorCsrfTokenRequestAttributeHandler가 token을 XOR와 Base64로 가린다.
쓴다
XorCsrfTokenRequestAttributeHandler가 token을 XOR와 Base64로 가린다.
BREACH 공격을 줄이기 위해서다. 여기서 깊게 다루지는 않는다 — HTTP 응답 압축 크기의
차이로 응답 안의 비밀값을 조금씩 추측하는 공격이고, 그래서 응답에 실리는 값을 매번
다르게 만든다.
```
깊게 안 갈 것이면 **안 간다고 밝히고 한 문장 요약을 준다.** 이름만 던지고 넘어가면 독자는 그
자리에서 검색하러 나간다.
## 「역할이 다르다」로 끝내지 않는다
두 값이 왜 하나로 합쳐질 수 없는지 **메커니즘**을 적는다.
```text
쓰지 않는다
첫 줄은 claim 검증 기준이고 셋째·넷째 줄은 network 경로다. 역할이 다르다.
쓴다
issuer는 요청을 보내는 주소가 아니라 발급된 token의 iss claim이 기대한 값과 같은지
확인하는 기준값이다. token URL과 userinfo URL은 실제로 요청을 보내는 내부 주소다.
브라우저는 docker 내부 호스트명에 접근할 수 없어 로그인에는 외부 주소를 쓰고,
컨테이너는 자기 localhost가 그 서버가 아니므로 내부 통신에는 service 이름을 쓴다.
```
## 값이 합쳐지면 합쳐진 결과를 보여 준다
두 곳에서 온 값이 하나의 요청이 되는 흐름은 **조립된 실물**까지 보여 준다. 대응표만 두면
독자가 머릿속으로 조립해야 한다.
```text
대응표만 두지 않는다
body.token = masked token
cookie XSRF-TOKEN = raw token
POST X-XSRF-TOKEN = same raw token
조립된 요청을 이어서 보여 준다
POST /bff/theme HTTP/1.1
Content-Type: application/json
Cookie: SESSION=abc123; XSRF-TOKEN=xyz789
X-XSRF-TOKEN: xyz789
```
## 직접 본 것은 따로 절을 만든다
테스트 계약을 인용하는 것과 **직접 열어서 본 것**은 다른 증거다. 화면을 열어 확인했다면 그
사실을 따로 적는다.
```text
로그인 뒤 브라우저 개발자 도구에서 요청과 저장소를 확인했다. Keycloak token endpoint를
직접 호출하지 않았고 Resource Server 포트도 직접 호출하지 않았다. localStorage와
sessionStorage에도 accessToken과 refreshToken이 없었다.
```
확인하지 않았으면 쓰지 않는다. 계약 인용은 계약 인용이라고 적는다.
## 내부 코드명을 산문에 쓰지 않는다
독자는 `AP1`~`AP4` 같은 내부 번호를 모른다. 산문에서는 **구조 이름**으로 부른다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| AP1은 | SPA 구조는 |
| AP2가 맞는 경우는 | Mediator를 고를 수 있다 |
| AP3은 | BFF에서는 · BFF 구조에선 |
| AP4는 | OAuth2-Proxy 구조는 |
번호는 **표의 축과 식별자에만** 남긴다 — `AP1~AP3 | AP4` 같은 비교 열, `AP3_SESSION` 같은
실제 값. slug에도 넣지 않는다.
```text
쓰지 않는다 ap4-identity-header-trust
쓴다 identity-header-trust
```
## 제목은 묻고 본문은 답한다
절 제목에 `~해보자` `~하지?` `~일까?`를 쓴다. 그리고 **첫 문장에서 그 질문을 다시 던지고**
답한다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 브라우저에 남은 것 | 브라우저에 관리 대상 |
| 위조 요청은 어떻게 생겼나 | 위조 요청은 어떻게 생겼을까? |
| 세 겹으로 나눠서 막는다 | 세 겹으로 나눠서 막아보자 |
| 무엇이 서버로 넘어왔나 | 무엇이 서버로 책임이 넘어왔지? |
| upstream이 JWT를 받지 않는다는 뜻 | upstream이 JWT를 받지 않는다? |
```text
## 브라우저에 관리 대상
브라우저에 관리 대상은 그럼 어떤 게 될까?
| 무엇 | 브라우저에 있나..? | JavaScript가 읽나..? |
```
## 굵게를 걷어낸다
`**굵게**`는 거의 쓰지 않는다. 한 절에 하나를 넘기면 강조가 아니라 얼룩이 된다. 강조는
**자리**로 한다 — 절을 따로 떼거나, 표에서 그 행을 첫 줄에 두거나, 짧은 문단으로 끊는다.
## 단정을 좁힌다
「항상 그렇다」로 적기 전에 예외를 센다.
```text
틀렸다 token endpoint는 server-to-server 호출이다
맞다 server-to-server 호출일 수도 있고 browser-to-server 호출일 수도 있다
```
public client는 브라우저가 직접 token endpoint를 부른다. 한 구조에서 본 것을 protocol
전체의 성질로 넓히지 않는다.
## 흐름은 끊지 않고 이어 간다
한 흐름은 한 문단으로 이어 간다. `이후` · `그리고` · `~하면` · `~한 뒤`로 다음 단계를 붙인다.
단계마다 문장을 끊고 각각 결론을 다는 방식은 쓰지 않는다.
```text
쓰지 않는다
브라우저가 로그인 요청을 보낸다. mediator가 세션을 확인한다. Keycloak으로 302를 준다.
브라우저가 URI를 조립해 Keycloak을 부른다. code를 받는다. mediator가 교환한다.
쓴다
브라우저가 로그인 요청을 보내면 mediator에서 세션을 확인한 뒤 Keycloak으로 302
리다이렉트를 하게 된다. 그리고 브라우저가 로그인 요청과 관련된 값을 조립해 URI를 만들고
Keycloak으로 요청을 보낸다. 이후 로그인 화면에서 아이디와 비밀번호를 넣어 전달하면
authorization code와 함께 redirect되고, 그 code를 mediator가 token으로 교환하게 된다.
```
짧은 문장을 나열하면 각 문장이 다 결론처럼 읽힌다. 읽는 사람은 어디가 흐름이고 어디가
판단인지 구분하지 못한다.
## 앞 구조와 무엇이 달라졌는지로 연다
절을 열 때 이전 구조를 먼저 세우고 무엇이 옮겨졌는지 말한다. 그러면 비교 축이 문단 안에서
고정된다.
```text
앞선 구조에서는 브라우저가 token 교환과 관리, API 요청까지 전부 맡았다.
이 구조에서는 token 관리와 교환의 위치가 브라우저에서 Spring backend로 옮겨지게 된다.
```
같은 대조를 절 끝에서 한 번 더 쓴다 — 「앞선 구조에서는 브라우저가 OIDC client였다면 이
구조에서는 mediator가 OIDC client가 된다」처럼.
## 결론은 문장 끝에 붙인다
한 줄짜리 단정문을 따로 떼어 강조하지 않는다. 앞 문장에서 `그래서` · `그렇기 때문에`
이어 간다.
```text
쓰지 않는다
code 교환과 API 호출을 둘 다 server가 대신해야 한다. 하나만 옮겨서는 안 된다.
원문이 응답 본문과 지역 변수와 헤더를 지난다. 세 자리 모두 같은 실행 영역이라
「브라우저에 token 없음」을 만족하지 못한다.
쓴다
refresh token만 옮기는 구조에서는 브라우저가 Resource Server를 직접 부르기 때문에
access token이 필요하고, 그것을 응답 본문으로 받게 된다. 그래서 원문이 응답 본문과
지역 변수, Authorization 헤더를 차례로 지나게 되고 결국 브라우저에 token이 없다고
말할 수 없게 된다.
```
**같은 말을 다시 말해 강조하지 않는다.** 앞 문장이 이미 말했으면 거기서 끝낸다.
`하나만 옮겨서는 안 된다` 같은 덧붙임이 그것이다.
## `~하게 된다`는 상태가 실제로 바뀌는 자리에만 쓴다
무엇이 바뀌는 대목에서는 `~한다`보다 `~하게 된다`가 맞다. 흐름을 따라가는 자리이기
때문이다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 위치가 바뀐다 | 위치가 옮겨지게 된다 |
| mediator가 code를 교환한다 | mediator가 code를 token으로 교환하게 된다 |
| session cookie를 발급한다 | 저장이 끝나고 session cookie를 발급하게 된다 |
**정의·분류·사실·지시에는 붙이지 않는다.** 어미만 바꾸면 문장이 어색해지고 무엇이 흐름이고
무엇이 기준인지도 흐려진다.
| 자리 | 틀렸다 | 맞다 |
|---|---|---|
| 정의 | 네 구조는 서로 다른 운영 계약이 된다 | 운영 계약이다 |
| 분류 | 이 흐름은 별도 client가 된다 | 별도 client다 |
| 사실 | upstream은 role 판단을 하지 않게 된다 | 하지 않는다 |
| 지시 | edge에 인증을 맡기지 않게 된다 | 맡기지 않는다 |
가릴 때는 **정말로 무엇이 되는지**를 묻는다. `refresh token이 무효가 된다`, `lock 자체가 새
장애 지점이 된다`는 상태가 바뀌므로 맞다. `배치가 된다`는 원래 배치였으므로 틀렸다.
Reference의 규칙 제목과 Decision의 결정문도 `~한다`로 끊는다. 그 자리는 기준이다.
## 그림은 한 줄로 예고한다
`전체적인 구조를 보면 다음과 같다` 같은 한 줄을 두고 그림을 넣는다. 문단 사이에 말없이
끼우지 않는다.
## 번역투를 걷어낸다
가장 자주 나오는 지적 두 번째다. 어미는 한국어인데 **문장 구조가 영어**여서 읽기 힘들다.
### `~하는 것은 ~이다`를 쓰지 않는다
영어의 `What matters is …`를 그대로 옮긴 구조다. 한국어는 동사로 바로 간다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 고르기 전에 답할 것은 세 가지 배치다 | 고르기 전에 누가 code를 바꾸고 누가 token을 드는지부터 정한다 |
| 실제로 갈리는 것은 보안 수준이 아니다 | 보안 수준으로는 구조가 갈리지 않았다 |
| 요구로 들어오면 남는 것은 BFF다 | 요구로 들어오면 BFF만 남는다 |
| 여기서 줄어드는 것은 재사용 반경이다 | 여기서는 재사용 반경만 줄어든다 |
### 지시대명사를 주어로 세우지 않는다
`그것이` · `이것이`로 문장을 시작하면 앞 문장을 되짚어야 읽힌다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 그것이 더 안전한 순서는 아니다 | 그렇다고 뒤로 갈수록 더 안전해지지는 않는다 |
| 그것이 학습 환경임을 문서에 남긴다 | 학습 환경이라고 문서에 적어 둔다 |
| 정책상 그것이 금지라면 | 정책상 브라우저 token이 금지라면 |
### `~라는 뜻은 아니다` · `~는 것은 아니다`를 쓰지 않는다
부정을 두 겹으로 쌓지 않는다. 긍정으로 뒤집고 조건을 붙인다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| confidential이라고 token이 안 가는 것은 아니다 | confidential이어도 token이 갈 수 있다 |
| client 인증이 있다고 PKCE가 필요 없어지는 것은 아니다 | client 인증이 있어도 PKCE는 여전히 쓸모가 있다 |
| 도메인 정책을 통과했다는 뜻은 아니다 | 도메인 정책은 아직 통과해 보지 않았다 |
| 이름이 장애 복구를 갖췄다는 뜻은 아니다 | 이름만 봐서는 장애 복구가 갖춰졌는지 알 수 없다 |
### 추상 공간 은유를 쓰지 않는다
`그 자리에 무엇이 들어오는지` 같은 표현은 실체가 없다. 무엇을 누가 하는지로 바꾼다.
```text
쓰지 않는다 token을 없앨 때 그 자리에 무엇이 들어오는지 적는다
쓴다 token을 없앤 대신 무엇을 관리해야 하는지 적는다
쓰지 않는다 CSRF와 공유 저장소가 그 자리에 들어오게 된다
쓴다 server로 옮기면 server session과 CSRF, 공유 저장소를 관리해야 한다
```
### 비유로 설명하지 않는다
기록하는 글이지 수필이 아니다. 그림이 떠오르는 표현을 쓰면 읽는 사람마다 다르게 읽는다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 얻은 것 옆에 내준 것을 같이 둔다 | 얻은 것과 내준 것을 함께 적는다 |
| 판단 자료가 아니라 홍보문이 되어 버린다 | 무엇을 감수해야 하는지 알 수 없다 |
| 그 자리를 PKCE가 메운다 | secret 대신 PKCE를 쓴다 |
| 짧은 수명이 그 자리를 대신한다 | token 수명을 짧게 두는 것이 유일한 방어가 된다 |
| BFF가 떠안게 된다 | BFF가 맡아야 한다 |
| session ID마다 독립된 금고 | session ID마다 token을 따로 보관 |
| 401을 그대로 흘리면 | 401을 그대로 내려보내면 |
| 재사용되는 반경 | 재사용될 범위 |
| 그 뒤 검사가 다 무의미해진다 | 그 뒤에 무엇을 검사해도 소용이 없다 |
| 값이 새면 | 값이 유출되면 |
| 덮어쓰기가 도는지 | 덮어쓰기가 실제로 동작하는지 |
| 인스턴스가 죽으면 | 인스턴스가 내려가면 |
| 경쟁이 자연히 흡수된다 | 경쟁이 저절로 해소된다 |
| 통째로 건너뛰어진다 | 함께 빠지게 된다 |
| 다른 사람이 되어 버린다 | 다른 사람으로 인식된다 |
| token을 치운다 | token을 없앤다 |
| 배치가 구조를 가른다 | 어디에 두느냐에 따라 구조가 달라진다 |
| 비교표의 축에 끼어든다 | 성격이 다른 항목이 섞인다 |
`자리` · `옆` · `칸` · `축` 같은 **공간 말**, `떠안다` · `죽다` · `흡수하다` 같은 **의인·비유**,
`무의미해진다` · `되어 버린다` 같은 **과장**을 지운다. 지우고 나면 무슨 일이 일어나는지만
남는다.
### 그 밖에 자주 나오는 것
- `~에 대한` → 조사로 푼다. `학생들에 대한 관심``학생에게 관심이 많다`
- `~에 있어서``~에서` · `~할 때`
- `~에 의해` · `~로 인해``~ 때문에` · `~가`
- 피동 → 능동. `결정이 내려졌다``결정했다`
- `~들` → 복수가 문맥으로 분명하면 뺀다
의미를 바꾸지 않는 단어를 먼저 지운다. 글자 수를 줄이는 것보다 이것이 앞선다.
## 읽는 법을 지시하지 않는다
「무엇을 막는지를 좁혀서 봐야 한다」, 「여기까지다」, 「먼저 본다」, 「~로 읽으면 안 된다」,
「두 질문을 따로 답한다」. 전부 독자에게 읽는 방법을 알려 주는 문장이다. 문서는 대상을 설명하지
독자의 읽기를 지시하지 않는다.
지울 자리를 찾는 법은 간단하다. 그 문장을 빼도 남은 내용이 그대로면 곁가지다.
```text
✗ 무엇을 막는지를 좁혀서 봐야 한다. code를 누가 훔쳐 가도 verifier가 없으면
token으로 바꾸지 못한다. 여기까지다.
○ code를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다.
```
앞 문장이 빠져도 뒷 문장의 뜻은 하나도 줄지 않는다. 「먼저 본다」도 마찬가지다. 순서를 지시하는
대신 왜 그런지를 쓴다.
```text
✗ 여기서 걸리면 나머지 비교가 필요 없어지기 때문에 먼저 본다.
○ 이 조건에 걸리면 다른 항목은 볼 필요가 없다.
```
규칙 제목이 이미 말한 것을 본문에서 다시 지시하는 것도 같은 문제다. 제목이 「얻은 것과 내준 것을
함께 적는다」면 본문 끝에 「함께 적는다」를 또 쓰지 않는다.
## 동작을 압축하지 않는다
`싣는다`, `낸다`, `친다`, `짠다`처럼 한 글자로 줄인 동사는 무엇을 어디로 하는지를 지운다. 실제
동작으로 풀어 쓰고, 목적어를 빼지 않는다.
| 압축 | 푼 것 |
|---|---|
| code_challenge를 싣고 | code_challenge를 담아서 보내고 |
| code_verifier를 낸다 | code_verifier를 보낸다 |
| secret을 함께 낸다 | secret을 함께 보낸다 |
| 401을 낸다 | 401을 돌려준다 |
| 요청마다 DB를 친다 | 요청마다 DB를 조회한다 |
| 같다고 가정하고 짜면 | 같다고 가정하고 구현하면 |
| 교환이 끝난다 | 토큰 교환이 된다 |
`교환이 끝난다`는 무엇의 교환인지가 없다. 앞 문장에서 짐작할 수 있어도 그 자리에 다시 쓴다.
## 앞에서 말한 것을 다시 짚는다
`둘이`, `셋이`, `그 둘은`은 무엇을 가리키는지 독자가 되짚게 만든다. 대명사 대신 세어서 가리키거나
이름을 다시 쓴다.
```text
✗ 둘이 맞아야 교환이 끝난다.
○ 이 두 값이 일치해야 토큰 교환이 된다.
✗ upstream이 받는 요청에서는 둘이 구분되지 않는다.
○ upstream이 받는 요청에서는 이 두 헤더가 구분되지 않는다.
```
가리키는 대상이 바로 앞 문장에 있어도 마찬가지다. 문장 하나만 떼어 읽어도 뜻이 서는 쪽을 고른다.
## 한 문장에 동작을 두 개 넣지 않는다
확인하는 동작과 그 결과는 서로 다른 일이다. 한 문장에 이어 붙이면 조건과 결론이 뭉개진다.
```text
✗ 처음 요청에 code_challenge를 싣고, 교환할 때 원본인 code_verifier를 낸다. 둘이 맞아야
교환이 끝난다.
○ 처음 요청에 code_challenge를 담아서 보내고, 교환할 때 원본인 code_verifier를 보내서
이 두 값이 일치하는지 확인한다.
두 값이 일치해야 토큰 교환이 된다.
```
## 못 하는 것이 아니라 할 것을 쓴다
「~라고 말할 수는 없다」, 「~를 확인한 것은 아니다」, 「~라고 단정하면 안 된다」로 문단을 열면
독자가 할 일을 스스로 뽑아내야 한다. 무엇이 문제인지, 무엇을 하면 되는지, 어떻게 확인하는지
순서로 쓴다.
```text
✗ wildcard allowlist는 학습 환경에서 편하다. 다만 그것으로 exact callback만 허용하는
가드레일을 확인했다고 말할 수는 없다.
허용 범위가 넓으면 같은 호스트의 다른 경로로 code를 흘릴 여지가 생긴다. 잘못된 redirect를
거부하는지 확인하는 검사도 따로 둔다.
○ wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른
경로로도 code가 갈 수 있다.
실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.
등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다.
```
고친 쪽은 문단이 셋으로 나뉜다. 첫 문단은 무엇이 문제인지, 둘째는 무엇을 하면 되는지, 셋째는
어떻게 확인하는지다. 원래 글은 이 셋이 두 문장 안에 뭉쳐 있어서 가운데 「그래서 좁힌다」가
빠져 있었다.
「여지가 생긴다」도 같이 지운다. 무엇이 어디로 가는지 그대로 쓰면 된다.
| 돌려 말한 것 | 그대로 말한 것 |
|---|---|
| code를 흘릴 여지가 생긴다 | 다른 경로로도 code가 갈 수 있다 |
| 확인한 것은 아니다 | (무엇이 실제로 일어나는지) |
| 단정하면 안 된다 | (그렇게 하려면 무엇이 필요한지) |
| token이 없다고 말할 수 없게 된다 | 브라우저에 token이 남는다 |
부정형이 규칙의 논지 자체일 때는 그대로 둔다. 「network 격리와 헤더 검증을 서로 대신하지
않는다」는 대체할 수 없다는 것이 규칙이고, 본문도 「두 가지를 다 둔다」로 끝난다.
## 문장 끝
`요` · `습니다`가 아니라 `한다` · `이다`로 끝낸다. 그 밖에는 이렇게 쓴다.
- **`~하면 된다`를 쓰지 않는다.** 조언하는 말투이지 기록하는 말투가 아니다.
`정하면 된다``정한다`, `적으면 된다``적는다`, `두면 된다``둔다`
- `A는 B다`보다 `A는 B라는 점이 문제가 된다` — 무엇이 걸리는지까지 말한다
- 무엇을 하자고 이끌 때는 `~해 보자`를 쓴다. 절 제목과 여는 문장에만 쓰고 규칙에는 쓰지 않는다
- 명령조를 줄인다. 검사 항목이 아니라 같이 읽는 사람의 말로 쓴다
@@ -0,0 +1,92 @@
# SSOT에서 글감을 뽑는 기준
`final/`의 긴 글 하나가 정본(SSOT)이다. 거기서 Studio에 올릴 기록을 뽑아낸다. 이 문서는 **무엇을
몇 건으로 나눌지**를 정하는 기준이다. 나눈 결과는 `tech-log-tree.json`에 제목만 먼저 적고, 글은
그다음에 쓴다.
## 왜 먼저 나누는가
긴 글을 앞에서부터 잘라 기록으로 만들면 절 하나가 기록 하나가 된다. 그러면 Case의 칸도
Reference의 칸도 반쯤만 맞는 기록이 나온다. 종류를 먼저 정하고 그 종류가 요구하는 것이 SSOT에
있는지 확인해야 한다.
## 한 건으로 자르는 단위
**절이 아니라 주장이다.** SSOT의 `##` 하나가 기록 하나가 아니다. 다음 넷 중 하나가 한 건이다.
| 단위 | 무엇 | 종류 |
|---|---|---|
| 재현한 관측 하나 | 조건을 갖추고 돌려서 얻은 수치·실행계획·로그 | Case |
| 남의 것의 동작 하나 | 문서와 코드를 읽어 정리한 규칙·흐름 | Concept |
| 반복 적용할 기준 하나 | 다음에도 같게 하기로 한 규칙 | Reference |
| 프로젝트가 고른 방향 하나 | 대안을 두고 정한 것 | Decision |
| 닫히지 않은 판단 하나 | 아직 모르는 것과 무엇을 하면 닫히는지 | Question |
## 종류를 정하는 물음
순서대로 묻는다. 먼저 걸리는 것이 그 글감의 종류다.
1. **내가 돌려서 얻은 결과인가** → Case. 수치·실행계획·로그가 SSOT에 있어야 한다. 없으면 Case가 아니다
2. **남의 것이 어떻게 동작하는지인가** → Concept. 무엇을 보고 썼는지(버전)가 있어야 한다
3. **다음에도 같게 하기로 한 규칙인가** → Reference
4. **대안을 두고 고른 것인가** → Decision. 근거로 걸 기록이 최소 하나 필요하다
5. **아직 모르는 것인가** → Question. 미지수가 최소 하나 필요하다
## 나눌 때 지키는 것
**한 건에 종류를 섞지 않는다.** N+1을 재현해 고쳤고 그 과정에서 조회 기준을 굳혔다면 Case 하나와
Reference 하나로 나누고 `관계`로 잇는다.
**증거가 없는 Case는 만들지 않는다.** SSOT에 그 수치가 없으면 글감 목록에는 남기되 `file` 없이
두고, 측정을 먼저 한다. 없는 수치를 쓰지 않는다.
**Decision은 근거 없이 만들지 않는다.** 게시가 거절된다. 근거로 걸 Case나 Concept이 먼저 있어야
한다. 그래서 Decision은 대개 마지막에 뽑는다.
**같은 관측을 두 건으로 쪼개지 않는다.** 「N+1이 났다」와 「그래서 몇 개가 나갔다」는 한 건이다.
쪼개면 둘 다 반쪽이 된다.
**주제를 먼저 정한다.** Studio는 주제 아래에 다섯 종류를 나눠 보여 준다. 주제가 다르면 같은
프로젝트여도 폴더가 갈린다. 주제 slug는 Studio의 것을 그대로 쓴다.
## `tech-log-tree.json`
주제 → 종류 → 글감 순서로 담는다. 아직 쓰지 않은 글감은 `file` 없이 제목만 둔다.
```json
{
"project": "n+1liner",
"ssot": "final/document.md",
"topics": {
"jpa-feed-query-performance": {
"topic": "jpa-feed-query-performance",
"kinds": {
"case": [
{ "title": "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
"slug": "eager-toone-nplus1-without-access",
"file": "jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md",
"status": "게시 전", "studioId": "32d0be7d-…", "assets": 1, "evidence": 3 },
{ "title": "아직 쓰지 않은 글감" }
],
"concept": [], "reference": [], "question": [], "decision": []
}
}
}
}
```
기록을 쓰거나 지운 뒤에는 다시 만든다. 스크립트는 기록 파일에서 값을 읽어 채우고, `file`이 없는
글감은 지우지 않는다.
```bash
python3 scripts/build-tech-log-tree.py [프로젝트]
```
## 순서
1. SSOT를 끝까지 읽는다. 절 제목만 훑지 않는다 — 수치와 근거가 어디 있는지 알아야 종류를 정한다
2. 위 물음으로 글감을 나누고 주제를 정한다
3. `tech-log-tree.json`에 제목만 적는다. 이때 글은 쓰지 않는다
4. 글감 하나를 골라 `<주제>/<종류>/`에 기록을 쓴다. 형식은 `writing-each-kind.md`
5. 트리를 다시 만든다
6. Studio에 넣고 저장한다
@@ -0,0 +1,205 @@
# 다섯 종류의 칸과 게시 조건
칸 이름은 Studio 편집 화면 그대로, 상한과 필수 여부는 `studio-api.openapi.yaml` 그대로다.
계약 필드명을 괄호에 적는다. 계약은 `tech-log-frontend/src/features/tech-log/contracts/studio/`
에 있고, 이 파일과 계약이 어긋나면 계약이 맞다.
`RecordKind` 는 다섯이다 — `CASE` · `REFERENCE` · `QUESTION` · `CONCEPT` · `PROJECT_DECISION`.
## 종류별 칸 (`새 문서` 화면이 적어 주는 그대로)
| 화면 이름 | 계약 `kind` | 그 종류만의 칸 |
|---|---|---|
| Case | `CASE` | 문제 · 결론 · 환경 · 재현 · 본문 |
| Reference | `REFERENCE` | 목적 · 규칙 · 적용 조건 · 예외 · 예시 |
| **개념** | `CONCEPT` | 기준 버전 · 본문 |
| Question | `QUESTION` | 상태 · 사실 · 가정 · 미지수 · 선택지 |
| Decision | `PROJECT_DECISION` | 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거 |
여기에 아래 공통 칸이 더해진다.
## 공통 (다섯 종류 모두 — `WorkingCopyInputBase`)
| 칸 | 필드 | 상한 | 게시 조건 |
|---|---|---|---|
| 제목 | `title` | 120자 | **필수** — 없으면 게시 거절 |
| slug | `slug` | 3~100자, `^[a-z0-9]+(?:-[a-z0-9]+)*$` | **필수** — 비우면 제목에서 만든다 |
| 요약 | `summary` | **2,000자** | 경고. 목록 카드에는 약 90자까지 보인다 |
| Topic | `topicId` | — | 경고 |
| 축 | `variantIds` | 8개 | 선택. 고르지 않으면 그 주제의 **공통 기록**이 된다 |
| Project | `projectId` | — | `PROJECT_DECISION`은 게시 시 필수 |
| 관계 | `relations` | 20개 | `PROJECT_DECISION`**1개 이상 필수**. 그 종류에서는 절 이름이 「근거」다 |
편집 화면에서 축은 체크박스 묶음으로 나오고 묶음 이름이 「축 — 고르지 않으면 이 주제의 공통
기록이 됩니다」다. **보이는 축은 고른 Topic 이 정한다** — OAuth/OIDC 인증 경계를 고르면
SPA·Mediator·BFF·Forward-Auth 가 나온다.
**축(`variantIds`)은 주제 안의 접근·구조다.** 인증 경계 주제의 축은 SPA·Mediator·BFF·Forward-Auth,
조회 성능 주제의 축은 조회 전략이다. 한 기록이 여러 축에 걸릴 수 있다 — PKCE 는 SPA 와 BFF 양쪽에
관계된다. 아무 축도 고르지 않으면 「공통」 축이 따로 있는 것이 아니라 그 주제의 공통 기록으로 읽힌다.
slug를 비우면 제목에서 만든다. 한글 제목도 로마자로 옮겨 유효한 slug가 된다. 직접 쓸 때는
영문 소문자·숫자·하이픈만 쓴다.
**Project에 slug가 없으면 공개 화면에 프로젝트가 표시되지 않는다.** 공개 계약의
`ProjectSummary``slug``path`를 요구하기 때문이다. `주제·프로젝트` 화면에서 확인한다.
## 기록이 가리키는 로컬 파일
기록은 `docs/<프로젝트>/tech-log-studio/<주제>/<종류>/` 에 있고, 그림과 증거는 같은 프로젝트의
`final/` 에 있다. 같은 파일을 양쪽에 두지 않고 frontmatter 로 잇는다.
```yaml
assets:
- key: eager-lazy-query-sequence # 본문의 :::evidence key 와 같은 값
file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg
evidence:
- ../../../final/evidence/explain/highlights-child-plan-A.txt
```
`assets` 는 Studio 에 올릴 파일이다. 아직 안 올렸으면 key 가 파일 이름과 같고, 올린 뒤에는
서버가 준 `<이름>-<해시8>` 로 바뀐다. **Studio 에 넣을 때 이 목록을 보고 Asset 을 올리고,
본문의 `:::evidence key` 를 서버가 준 키로 바꾼다.**
`evidence` 는 그 기록이 인용한 측정 자료다. 실행계획·csv·터미널 기록·스크린샷이 여기 온다.
본문에 값을 옮겨 적었으면 그 값이 어느 파일에서 나왔는지 이 줄이 말해 준다.
## 평문 칸 쓰는 법
본문(`bodyMarkdown`)을 뺀 모든 칸은 평문이다. 백틱·파이프·`#`는 글자 그대로 보이고 줄바꿈만
살아난다. 그 줄바꿈이 유일한 서식이므로 아끼지 않는다.
**나열은 `이름 : 값`으로 끊는다.** 쉼표로 이으면 읽는 사람이 항목을 세어야 한다.
```text
쓰지 않는다
access token 300초, refresh token rotation과 재사용 허용 0회, 그리고 issuer·audience 검증이다
쓴다
access token : 300초
refresh token rotation, 재사용 허용 : x
issuer · audience : 검증
```
있음·없음은 `o`·`x`로 적는다. `확인함`·`확인 안 함`보다 훑을 때 빨리 잡힌다.
**한 문장이 화면에서 두 줄을 넘으면 끊는다.** 편집 화면의 칸은 좁고 공개 화면은 넓다. 여기서
한 줄로 보이는 문장이 저기서는 덩어리가 된다. 절차·조건을 한 문단에 이어 쓰지 않는다.
## Case — 문제를 재현하고 검증한 결론
| 칸 | 필드 | 비고 |
|---|---|---|
| 문제 | `problem` | 무엇이 왜 문제였나 |
| 결론 | `conclusion` | 검증으로 확정한 것 |
| 검증 환경 | `environment` | 런타임·버전·DB·도구 |
| 재현 조건 | `reproduction` | 다른 사람이 같은 결과를 얻는 방법 |
| 마지막 검증일 | `lastVerifiedOn` | 실제로 확인한 날. 30일 지나면 경고 |
| 본문 Markdown | `bodyMarkdown` | 10만 자. 본문을 갖는 두 종류 중 하나 |
공개 화면에서 `검증 환경``재현 조건``environmentSummary` 배열에 그 순서로 실린다.
## Concept — 남의 것이 어떻게 동작하는지
`새 문서` 화면에서 이 종류만 이름이 한글이다. **「개념」을 고른다.** 나머지 넷은 Case·Reference·
Question·Decision 으로 적혀 있다. 화면 설명은 「개념의 내부 동작과 아키텍처를 처음부터 풀어 씁니다」다.
| 칸 | 필드 | 비고 |
|---|---|---|
| 본문 Markdown | `bodyMarkdown` | 10만 자. **Case 와 함께 본문을 갖는 두 종류 중 하나** |
| 기준 버전 | `basisVersion` | 120자. 무엇을 보고 쓴 글인지 한 줄 |
칸이 둘뿐이다. 문제도 결론도 재현 조건도 없다. 내가 재현한 결과가 아니라 **이미 그렇게 동작하는
것**을 적기 때문이다. `Authorization Code Flow 가 code 를 한 번 더 교환하는 이유`, `Forward-Auth
subrequest 가 실어 보내는 것` 같은 것이 여기 온다.
**`lastVerifiedOn` 이 없고 `basisVersion` 이 그 자리를 대신한다.** 개념은 날짜로 낡지 않고 버전으로
낡기 때문이다. `Keycloak 26.7.0 identity brokering`, `Kubernetes 1.31` 처럼 적는다. 비워도 게시된다.
공개 주소는 `/concepts/{slug}` 다.
**`기준 버전`을 비워도 게시된다.** 계약의 `required` 에 들어 있지만 빈 문자열을 허용하고, 게시
검증도 막지 않는다(2026-09-04 에 임시 개념 기록을 만들어 비운 채로 게시해 확인했다). 그래도
채우는 편이 낫다 — 개념이 언제 낡았는지 읽는 사람이 알 방법이 이 칸뿐이다.
편집 화면 오른쪽 `작업 상태` 는 이 종류를 「동작 원리」라고 부른다. `새 문서` 의 「개념」과 같은
것이다.
Case 와 헷갈리면 **내가 무엇을 했는지**를 묻는다. 내가 돌려 보고 수치를 얻었으면 Case, 남의 문서와
코드를 읽고 동작을 정리했으면 Concept 이다.
## Reference — 반복 적용할 기준
| 칸 | 필드 | 비고 |
|---|---|---|
| 목적 | `purpose` | 이 기준이 무엇을 막는가 |
| 규칙 | `rules[]` | 제목(120자) + 본문. **평문** |
| 적용 조건 | `applyWhen[]` | 언제 적용되는가 |
| 예외 | `exceptions[]` | 적용되지 않는 경우 |
| 예시 | `examples[]` | 짧은 문장. 코드가 아니다 |
| 마지막 검증일 | `verifiedOn` | |
규칙 본문에 코드를 쓰고 싶으면 그 코드가 있는 Case를 만들고 `관계`로 가리킨다.
## Question — 아직 닫히지 않은 판단
| 칸 | 필드 | 비고 |
|---|---|---|
| 질문 상태 | `questionStatus` | `OPEN` / `RESOLVED` / 미정 |
| 사실 | `facts[]` | 확인된 것 |
| 가정 | `assumptions[]` | 확인하지 않고 전제한 것 |
| 미지수 | `unknowns[]` | `OPEN`이면 **1개 이상 필수** |
| 제약 | `constraints[]` | 선택을 좁히는 조건 |
| 선택지 | `options[]` | 제목(120자) + 설명. 50개까지 |
| 다음 검증 | `nextValidation` | 무엇을 하면 판단이 끝나는가 |
`OPEN`인데 해결 내용을 채우면 게시가 거절된다. 상태와 내용이 어긋나기 때문이다.
사실과 가정을 섞지 않는다. 확인했으면 사실, 아니면 가정이다. 그 구분이 이 종류의 존재 이유다.
## Decision — 프로젝트가 정한 방향 (`PROJECT_DECISION`)
| 칸 | 필드 | 비고 |
|---|---|---|
| 결정 상태 | `decisionStatus` | `PROPOSED` / `ADOPTED` / 미정 |
| 결정일 | `decidedOn` | |
| 결정문 | `statement` | 무엇을 정했는가. 한 문장 |
| 판단 이유 | `rationale` | 왜 그렇게 정했는가 |
| 영향 | `consequences[]` | 이 결정으로 감수하는 것 |
| 근거 기록 | `relations` | **1개 이상 필수** |
근거가 없는 Decision은 게시되지 않는다(`DECISION_EVIDENCE_REQUIRED`). 무엇을 보고 정했는지
가리키지 못하면 그것은 결정이 아니라 선언이다.
`영향`에는 좋은 것만 적지 않는다. 감수한 비용이 빠지면 다음 사람이 같은 판단을 다시 못 한다.
## 종류 고르기
```text
내가 직접 돌려 보고 결과를 얻었나 ── 예 ──→ Case
아니오
남의 것이 어떻게 동작하는지 적나 ── 예 ──→ Concept
아니오
판단이 끝났나 ──── 아니오 ──→ Question
프로젝트의 방향인가 ── 예 ──→ Decision
아니오
└──→ Reference
```
Case 와 Concept 이 가장 자주 헷갈린다. **수치와 재현 조건이 있으면 Case**, 읽고 정리한 동작이면
Concept 이다. Concept 과 Reference 는 「무엇이 그런가」와 「우리가 어떻게 할 것인가」로 갈린다 —
`PKCE 가 code 가로채기를 막는 방식`은 Concept, `우리는 public client 에 PKCE 를 필수로 쓴다`
Reference 다.
한 자료가 여러 종류에 걸치면 나눈다. 예를 들어 N+1을 재현해 고쳤고 그 과정에서 조회 기준을
굳혔다면, Case 하나와 Reference 하나를 만들고 서로 관계로 잇는다. 한 기록에 몰아넣으면 Case의
칸도 Reference의 칸도 반쯤만 맞는다.
@@ -0,0 +1,110 @@
# 게시 전 대조
위에서 아래로 훑는다. 하나라도 걸리면 게시하지 않는다.
## 사실
- [ ] 수치·날짜·버전·단위·명령어·URL·인용이 원자료와 한 글자도 다르지 않다
- [ ] 측정하지 않은 값이 없다. 검증일은 실제로 확인한 날이다
- [ ] 측정값과 파생값이 구분돼 있다. 역산한 값을 잰 값처럼 적지 않았다
- [ ] 자료에 없는 선택 이유를 만들지 않았다
- [ ] 지어낸 경험·실패·감정이 없다
- [ ] 가능성을 확정으로, 상관을 인과로 넓히지 않았다
## 범위와 한계
- [ ] 이 측정으로 **말할 수 없는 것**을 적었다
- [ ] 로컬에서 본 것을 운영에서 본 것으로 올리지 않았다
- [ ] 감수한 비용·위험이 빠지지 않았다
- [ ] 적용되지 않는 조건을 적었다
## 종류
- [ ] 종류가 내용과 맞는다 (`record-kinds.md`의 판단 흐름)
- [ ] Question의 사실과 가정이 섞이지 않았다
- [ ] Question이 `OPEN`이면 미지수가 있다
- [ ] Decision에 근거 기록이 1개 이상 연결됐다
- [ ] Decision의 영향에 감수한 비용이 있다
- [ ] Reference의 예시가 문장이다. 코드를 밀어 넣지 않았다
## 설명
- [ ] 처음 나오는 클래스·기법 이름에 왜 있는지가 붙었다
- [ ] 깊게 다루지 않는 주제는 다루지 않는다고 밝히고 한 문장 요약을 줬다
- [ ] 「역할이 다르다」로 끝난 자리에 메커니즘을 적었다
- [ ] 두 값이 합쳐지는 흐름에 조립된 실물을 보여 줬다
- [ ] 직접 열어 본 것과 계약 인용을 나눠 적었다
- [ ] 산문에 내부 코드명이 없다. slug에도 없다
- [ ] 한 구조에서 본 것을 protocol 전체의 성질로 넓히지 않았다
- [ ] `**굵게**`가 한 절에 하나를 넘지 않는다
- [ ] 한 흐름이 한 문단으로 이어진다. 단계마다 끊어 나열하지 않았다
- [ ] 앞 문장을 다시 말해 강조한 자리가 없다
- [ ] 흐름을 말하는 자리에만 `~하게 된다`를 썼다. 정의·분류·사실·지시에는 붙이지 않았다
- [ ] `~하면 된다`가 없다
- [ ] `~하는 것은 ~이다` 구문이 없다. 동사로 바로 간다
- [ ] `그것이`·`이것이`로 시작하는 문장이 없다
- [ ] `~라는 뜻은 아니다`·`~는 것은 아니다` 이중부정이 없다
- [ ] `그 자리에` 같은 추상 공간 은유가 없다
- [ ] `자리`·`옆`·`칸`·`축` 같은 공간 말로 설명하지 않았다
- [ ] `떠안다`·`죽다`·`흡수하다`·`홍보문` 같은 비유가 없다
- [ ] `무의미해진다`·`되어 버린다` 같은 과장이 없다
- [ ] 「~라고 말할 수는 없다」·「~한 것은 아니다」로 문단을 열지 않았다
- [ ] 문제 → 할 일 → 확인 순서로 이어진다. 가운데 「그래서 무엇을 한다」가 빠지지 않았다
- [ ] `여지가 생긴다`·`소지가 있다` 대신 무엇이 어디로 가는지 썼다
- [ ] 읽는 법을 지시하는 문장이 없다. `봐야 한다`·`여기까지다`·`먼저 본다`·`읽으면 안 된다`
- [ ] 빼도 남은 뜻이 그대로인 문장이 없다
- [ ] 규칙 제목이 말한 것을 본문 끝에서 다시 지시하지 않았다
- [ ] `싣는다`·`낸다`·`친다`·`짠다`를 실제 동작으로 풀어 썼다
- [ ] 동사마다 목적어가 있다. `교환이 끝난다`처럼 무엇인지 빠지지 않았다
- [ ] `둘이`·`셋이`·`그 둘은` 대신 무엇인지 다시 짚었다
- [ ] 확인하는 동작과 그 결과를 한 문장에 이어 붙이지 않았다
- [ ] 절을 열 때 앞 구조와 무엇이 달라졌는지 먼저 말했다
## AI가 쓴 티 (`references/ai-tells.md`)
- [ ] `..?`·`~하지?`·`~해보자`·`확인하자` 같은 억지 구어체가 없다
- [ ] 결론을 먼저 정리한 대구 문장이 문서에 한 번을 넘지 않는다
- [ ] `대신`·`그래서`·`함께`·`따로`·`그대로`를 세어 봤다. 같은 문형이 문서마다 되풀이되지 않는다
- [ ] 규칙·선택지 길이가 고르게 맞춰져 있지 않다. 축이 겹치는 것은 표로 옮겼다
- [ ] Question이 재현하지 않은 결과를 단정하지 않았다
- [ ] 제약에서 바꾸지 않겠다고 한 전제를 선택지에서 바꾸지 않았다
- [ ] 측정할 수 없는 것을 숫자처럼 쓰지 않았다
- [ ] `유일한`·`사실상`·`전원`·`대부분 이미` 같은 범위 확장이 없다
- [ ] 자료에 없는 과거(`~하던 관행`)를 만들지 않았다
- [ ] Reference가 Case를 문장만 바꿔 옮기지 않았다
- [ ] 현재 확인한 것과 운영에서 추가로 필요한 것을 나눴다
- [ ] 테스트를 말할 때 무엇을 단정하는지 적었다
## 본문 (Case)
- [ ] raw HTML·각주·체크박스·중첩 목록·취소선이 없다
- [ ] 표를 파이프로 썼다. 감쌌다면 세 속성이 다 있다
- [ ] 표 머리글이 무엇을 묻는지 말한다. 비교 표의 축이 시점·상태로 적혔다
- [ ] 코드블록 언어가 허용 문자만 쓴다
- [ ] 이미지 주소가 `/api/v1/public/media/…`다. object storage 주소가 아니다
- [ ] 그림이 있는 문단에 글을 섞지 않았다
- [ ] `alt`가 무엇이 보이는지 말한다
- [ ] 그림 안 `<text>`가 전부 이름이다. 문장도 숫자도 없다 (`<title>`·`<desc>`는 예외)
- [ ] 코드에 자격증명·토큰·내부 호스트가 없다. 지운 자리가 보인다
- [ ] 제목 id와 표 id가 겹치지 않는다
## 평문 칸
- [ ] 본문 밖 칸에 백틱·파이프가 없다
- [ ] 나열을 쉼표로 잇지 않고 `이름 : 값`으로 끊었다
- [ ] 있음·없음을 `o`·`x`로 적었다
- [ ] 한 문장이 화면에서 두 줄을 넘지 않는다
## 연결
- [ ] Topic이 지정됐다
- [ ] Project가 필요하면 지정됐고, 그 Project에 slug가 있다
- [ ] 관계의 대상이 실제로 있는 공개 기록이다
- [ ] 코드·표·그림이 필요한 내용을 Reference나 Question에 밀어 넣지 않았다
## 마지막
- [ ] `저장`을 누른 뒤 `게시`를 눌렀다
- [ ] 게시 후 공개 페이지를 열어 표·코드·그림이 의도대로 나오는지 봤다
마지막 항목을 건너뛰지 않는다. 저장은 통과해도 공개 화면에서 다르게 보이는 경우가 있다.
@@ -0,0 +1,114 @@
# Studio 초안 검토
초안을 Studio에 넣고 **저장까지만** 한 뒤 미리보기로 읽는다. 게시하지 않는다.
파서를 통과한 본문도 화면에서는 다르게 보인다. 표가 가로로 넘치거나, 설명 없이 코드만 있거나,
callout이 연달아 나와 읽는 흐름이 끊기는 것은 파서가 잡지 못한다.
## 왜 저장까지만인가
게시는 공개 사이트에 올린다. 되돌리려면 `unpublish`를 해야 하고 그 사이에 누구나 볼 수 있다.
저장은 Studio 안에만 남는다 — 불완전한 초안도 저장할 수 있게 만들어진 이유가 이것이다.
`즉시 미리보기` 탭은 저장한 값이 아니라 **화면에 입력한 값**을 렌더링한다. 그래서 게시 없이도
공개 화면과 같은 블록 렌더러로 본문을 볼 수 있다.
## 절차
1. `새 문서`에서 종류를 고르고 `작업본 만들기`
2. 칸을 채운다. 본문은 미리 `check_body.mjs`를 통과시킨 것을 넣는다
3. **`저장`** — `게시`가 아니다
4. `즉시 미리보기` 탭으로 옮겨 아래 항목을 읽는다
5. 고칠 것이 있으면 `편집` 탭으로 돌아가 고치고 다시 저장
Playwright로 할 때는 편집 화면의 `aside`에 버튼이 `저장`·`게시` 둘뿐이라는 점에 주의한다.
**`게시`를 누르면 저장·검증·미리보기·게시가 한 번에 돈다.** 검토 단계에서는 `저장`만 누른다.
```js
// 저장만 — aside 의 첫 버튼
await page.locator('aside button').first().click();
// 미리보기 탭
await page.getByRole('tab', { name: '즉시 미리보기' }).click();
```
## evidence를 넣었다면 화면을 새로 고친다
`즉시 미리보기`는 편집 화면이 들고 있는 asset 목록에서만 evidence 키를 찾는다. Asset을 방금
올렸다면 그 목록에 아직 없어서 본문 전체가 이렇게 막힌다.
```text
초안을 미리 볼 수 없습니다
1:1 supported local evidence key not found: <asset-key>
```
Asset 업로드 패널로 올렸으면 화면이 바로 알지만, 다른 경로로 올렸다면 편집 화면을 한 번 새로
고쳐야 목록을 다시 받는다. 본문 문법 문제로 오해하기 쉽다.
## 미리보기에서 읽을 것
### 읽는 흐름
- [ ] 제목만 훑어도 무슨 이야기인지 따라가는가
- [ ] 첫 문단이 무엇을 다루는지 말하는가. 배경부터 길게 시작하지 않는가
- [ ] 문단이 너무 길어 화면에서 덩어리로 보이지 않는가
- [ ] 같은 말을 다른 자리에서 반복하지 않는가
### 본문 밖 칸
Case의 `문제`·`결론`·`검증 환경`·`재현 조건`도 본문이 아니라 **평문**이다. 백틱과 파이프가
글자 그대로 보인다.
- [ ] 백틱이 화면에 그대로 나오지 않는가
- [ ] 절차를 한 문단에 이어 쓰지 않았는가. 줄바꿈은 `<br>` 로 살아난다
- [ ] 한 칸이 화면에서 덩어리로 보이지 않는가
- [ ] 나열을 쉼표로 잇지 않았는가. `이름 : 값`으로 줄을 나눴는가
- [ ] 한 문장이 화면에서 두 줄을 넘지 않는가
### 설명이 빠진 곳
- [ ] 표 바로 앞이나 뒤에 그 표를 어떻게 읽는지 적었는가
- [ ] 코드블록에 무엇을 보라는 설명이 있는가. 붙여 놓기만 하지 않았는가
- [ ] 처음 나오는 약어와 고유명사를 풀었는가. 왜 있는지까지 말했는가
- [ ] 이름만 대고 다음 문단으로 넘어간 자리가 없는가
- [ ] 두 값이 합쳐지는 곳에 합쳐진 결과가 있는가
- [ ] 직접 본 것과 테스트 계약이 구분돼 있는가
- [ ] 수치에 단위와 측정 조건이 붙었는가
- [ ] 그림의 `alt``caption`이 무엇이 보이는지 말하는가
### 화면에서만 드러나는 것
- [ ] 표 머리글만 읽어도 그 표가 무엇을 묻는지 아는가
- [ ] 열을 더 줄일 수 있는가. 3열이 2열로 되는가
- [ ] 비교 표의 열 이름이 어느 시점·상태의 값인지 말하는가
- [ ] 표가 가로로 넘치지 않는가. 열이 너무 많지 않은가
- [ ] 코드블록이 가로 스크롤을 만들지 않는가. 긴 줄을 줄일 수 있는가
- [ ] callout이 연달아 나와 본문 흐름을 끊지 않는가
- [ ] 제목 단계가 건너뛰지 않는가 (`##` 다음에 바로 `####`)
- [ ] 그림이 의도한 자리에 있는가. 글과 섞여 사라지지 않았는가
- [ ] 그림 안에 문장이 없는가. `<text>`가 전부 이름인가
### 종류별
- [ ] **Case** — 문제·결론·검증 환경·재현 조건 네 칸이 본문 없이도 이해되는가
- [ ] **Reference** — 규칙 제목만 읽어도 무엇을 금지하는지 아는가
- [ ] **Question** — 사실과 가정이 화면에서 구분돼 보이는가
- [ ] **Decision** — 결정문이 한 문장인가. 영향에 감수한 비용이 있는가
## 고칠 것이 없을 때
`references/review-checklist.md`를 마지막으로 훑고 게시한다. 게시 뒤에는 공개 페이지를 열어
미리보기와 같게 보이는지 다시 확인한다 — 공개 경로는 미리보기와 다른 데이터를 쓴다.
## 초안을 남기지 않는다
검토용으로 만든 작업본은 지운다. Decision은 계약에 삭제 경로가 없으므로 확인용으로 만들지
않는 편이 낫다.
**한 번이라도 게시한 문서는 게시를 취소해도 지워지지 않는다.** 게시를 취소해
`publicationStatus``UNPUBLISHED` 가 된 뒤에도 삭제는 409 로 거절되고, 화면에는
「공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요」가 뜬다. 이미 취소했는데도 그렇다.
그러므로 **시험 삼아 게시하지 않는다.** 게시 동작을 확인해야 하면 지워도 되는 문서를 따로
만들고, 그것이 목록에 영구히 남는다는 것을 감수한다.
관계로 참조된 문서도 지워지지 않는다(`DOCUMENT_IN_USE`). 참조하는 쪽의 관계를 먼저 끊는다.
끊어도 막히면 그 문서들의 지난 Public Preview 스냅샷에 옛 관계가 남아 있는 경우다.
@@ -0,0 +1,133 @@
# 종류마다 무엇을 어떤 순서로 쓰나
칸 목록과 상한은 `record-kinds.md`, 문장 규칙은 `explaining.md`, 문서군의 리듬은 `ai-tells.md`
있다. 이 문서는 **그 칸을 무엇으로 채우는가**다.
**이미 쓴 47건에서 뽑았다.** keycloak 23건(Case 4·Concept 6·Reference 7·Question 4·Decision 2),
n+1liner 24건(Case 5·Reference 7·Question 5·Decision 7). 「대개 이렇게 쓴다」는 말은 그 47건이
그렇게 돼 있다는 뜻이다.
## 파일 뼈대 — 다섯 종류가 같다
```markdown
---
id · kind · slug · title · topic · project · status · studio
(종류별) basisVersion · decisionStatus · questionStatus · lastVerifiedOn
(있으면) assets · evidence
---
# 제목
리드 문단. 이것이 Studio 의 `요약` 칸이다. ← 47건 모두 있다
## 관계 ← Decision 만 「근거」다
## <칸 이름> ← 종류마다 다르다
## 본문 ← Case · Concept 만
<!-- body:start -->
...
<!-- body:end -->
```
frontmatter 는 메타데이터, `##` 는 Studio 의 칸, 제목 아래 첫 문단은 요약이다. 47건 모두 이 모양이다.
**관계 항목은 굵은 제목 한 줄 + 이유 한 줄**이다.
```markdown
- **Keyset Pagination 설계 기준**
이 결정을 규칙으로 편 기준이다.
```
## Case — 9건
칸은 `관계` · `문제` · `결론` · `검증 환경` · `재현 조건` · `본문`. 9건 모두 여섯 칸을 채웠다.
| 칸 | 무엇을 |
|---|---|
| 문제 | 무엇이 어떠해야 했는데 어떻게 됐나. 요구를 먼저, 실제를 다음에 |
| 결론 | 재현해서 확정한 것. 수치를 그대로. 「~일 것이다」가 아니라 「~였다」 |
| 검증 환경 | 런타임·버전·DB·측정 도구. `이름 : 값`으로 줄을 나눈다 |
| 재현 조건 | 다른 사람이 같은 값을 얻는 순서. 번호를 매긴다 |
**본문은 5~12절, 대개 6절이다.**
- **첫 절은 무대를 세운다.** 잰 코드나 구조를 먼저 보여 준다 — 「측정한 loadFeed 구현」,
「무대 — 페이징 한 줄만 추가」, 「가시성을 얹기 전 — 인덱스로 커서 이후만」,
「Mediator에서 Access Token과 Refresh Token을 관리하는 위치」
- 가운데는 측정값(표) → 그 값을 어떻게 읽나 → 실행계획이나 로그 순서다
- **마지막 절은 범위나 다음이다.** 9건 중 4건이 확인 범위(「증명하지 않는 것」, 「현재 자동
테스트로 확인한 범위」, 「Redirect URI와 CORS에서 아직 확인하지 않은 부분」), 2건이 「다음 선택」,
나머지가 지표 읽는 법이나 남긴 이유다. **재지 않은 것을 적지 않고 닫는 Case 는 없다**
코드블록에는 무엇을 보라는 한 줄을 붙인다. 표 앞이나 뒤에 그 표를 어떻게 읽는지 적는다. 예시는
한 규모로 고정한다 — 표가 전체 계열을 이미 보여 준다.
## Concept — 6건
칸은 `관계` · `본문` 둘뿐이다. 문제도 결론도 재현 조건도 없다. 내가 잰 결과가 아니라 이미 그렇게
동작하는 것을 적기 때문이다.
**`basisVersion` 은 frontmatter 에 있고 본 것을 `·` 로 잇는다.**
```yaml
basisVersion: Keycloak 26.7.0 · oidc-client-ts 3.3.0
basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
```
**본문은 5~8절, 대개 6절이다.**
- 첫 절은 무엇이 무엇을 주고받는지다 — 「두 개의 OAuth 왕복이 이어진다」,
「Resource Server가 받는 입력」, 「요청 하나가 두 번 평가된다」
- 가운데는 단계마다 실제로 일어나는 일이다
- **마지막 절은 막지 않는 것이나 확인한 범위다** — 「PKCE가 막지 않는 것」,
「CSRF가 XSS를 대신하지 않는다」, 「현재 검증한 범위」, 「지금 구성이 보여 주지 않는 것」
「확인했다」는 Case 의 말이다. Concept 은 「~한다」로 적는다. 규격이 정한 것과 그 구현이 그렇게 한
것을 구분한다.
## Reference — 14건
칸은 `관계` · `목적` · `규칙` · `적용 조건` · `예외` · `예시`. 14건 모두 여섯 칸을 채웠다. 본문이 없다.
| 칸 | 무엇을 |
|---|---|
| 목적 | 이 기준이 무엇을 막는가. 막으려는 실패를 먼저 |
| 규칙 | 제목은 무엇을 하는지/하지 않는지로. 본문에 왜인지 |
| 적용 조건 | 언제 이 기준이 걸리는가 |
| 예외 | 걸리지 않는 경우. 이 칸이 비면 규칙이 과잉 적용된다 |
| 예시 | 짧은 문장. 코드가 아니다 |
규칙 제목만 읽어도 무엇을 금지하는지 알아야 한다. 「지표를 정확히 읽는다」보다
「지표 이름이 뜻하는 것을 그대로 읽는다」가 낫다. 코드가 필요하면 그 코드가 있는 Case 를 만들고
`관계`로 가리킨다 — Reference 칸은 평문이라 백틱이 글자로 보인다.
## Question — 9건
칸은 `관계` · `사실` · `가정` · `미지수` · `제약` · `선택지` · `다음 검증`. 9건 모두 일곱 칸을 채웠다.
`questionStatus` 는 frontmatter 에 있다.
**가정을 사실 칸에 넣지 않는 것이 이 종류의 전부다.** 사실은 확인한 것, 가정은 확인하지 않고
전제한 것이다.
`다음 검증`은 실행할 수 있는 문장으로 적는다. 「더 알아본다」로는 닫히지 않는다 —
「seed(1000) 뒤 `ANALYZE highlights` 를 돌리고 Plan B 를 다시 잰다」처럼 적는다.
## Decision — 9건
칸은 **`근거`** · `결정문` · `판단 이유` · `영향`. **다른 넷과 달리 관계 절 이름이 「근거」다.**
`decisionStatus` 는 frontmatter 에 있다. 근거가 1개 이상 없으면 게시가 거절된다.
| 칸 | 무엇을 |
|---|---|
| 결정문 | 「~한다」로 끝나는 문장. 조건이 있으면 한 문단 더 |
| 판단 이유 | 무엇을 보고 그렇게 정했나. 근거로 건 기록을 가리킨다 |
| 영향 | **감수한 비용을 포함한다.** 좋아진 것만 적지 않는다 |
대안을 적고 왜 고르지 않았는지 적는다. 대안이 없으면 결정이 아니라 사실이다. 자료가 뒷받침하지
않는 이유를 만들지 않는다.
## 본문이 있는 두 종류의 공통 규칙
- 그림은 `technical-visualizer` 로 만든다. 손으로 SVG 를 그리지 않는다
- 그림 안에는 이름만 넣는다. 문장은 `<desc>` 와 옆 문단에 둔다
- 그림과 증거는 frontmatter 의 `assets` · `evidence` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
- 본문은 `<!-- body:start -->``<!-- body:end -->` 사이다. 그 밖은 Studio 로 가지 않는다
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Case 본문이 Studio 파서를 통과하는지 게시 전에 확인한다.
*
* Studio 에 붙여넣고 저장한 뒤에야 거절을 알게 되면, 어느 줄이 문제인지 찾느라 화면을 오가게
* 된다. 같은 파서를 그대로 부르므로 여기서 통과하면 저장도 통과한다.
*
* node check_body.mjs <파일> [--frontend <경로>]
*
* `--frontend` 는 tech-log-frontend 체크아웃 경로다. 생략하면 TECH_LOG_FRONTEND 환경변수를
* 쓰고, 그것도 없으면 기본 경로를 쓴다.
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const DEFAULT_FRONTEND =
"/home/donghyeon/workspace/desktop-server-git/tech-log-frontend";
function optionValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const target = process.argv[2];
if (!target || target.startsWith("--")) {
console.error("usage: node check_body.mjs <파일> [--frontend <경로>]");
process.exit(2);
}
const frontend = path.resolve(
optionValue("--frontend") ?? process.env.TECH_LOG_FRONTEND ?? DEFAULT_FRONTEND,
);
const parserPath = path.join(
frontend,
"src/features/tech-log/domain/content-format/parse-case-content.ts",
);
let parseCaseContent;
let ContentFormatError;
try {
({ parseCaseContent, ContentFormatError } = await import(
pathToFileURL(parserPath).href
));
} catch (error) {
console.error(`파서를 불러오지 못했습니다: ${parserPath}`);
console.error(
"tech-log-frontend 경로를 --frontend 또는 TECH_LOG_FRONTEND 로 알려 주세요.",
);
console.error(
"TypeScript 를 그대로 읽으므로 node 는 --experimental-transform-types 가 필요합니다.",
);
console.error(String(error instanceof Error ? error.message : error));
process.exit(2);
}
const raw = await readFile(target, "utf8");
// 기록 파일을 통째로 넣으면 칸의 <br> 과 주석까지 파서에 걸린다. Studio 가 받는 것은
// body:start ~ body:end 사이뿐이므로 그 구간만 잘라 검사한다. 마커가 없으면 파일 전체를
// 본문으로 본다 — 본문만 담은 초안을 그대로 넣는 경우다.
const BODY_START = "<!-- body:start -->";
const BODY_END = "<!-- body:end -->";
let source = raw;
let offset = 0;
const startIndex = raw.indexOf(BODY_START);
const endIndex = raw.indexOf(BODY_END);
if (startIndex !== -1 && endIndex > startIndex) {
const bodyStart = startIndex + BODY_START.length;
source = raw.slice(bodyStart, endIndex);
offset = raw.slice(0, bodyStart).split("\n").length - 1;
console.log(`본문 구간만 검사합니다 — ${BODY_START} ~ ${BODY_END}`);
}
try {
const blocks = parseCaseContent(source);
const counts = new Map();
for (const block of blocks) {
counts.set(block.type, (counts.get(block.type) ?? 0) + 1);
}
const summary = [...counts]
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, count]) => `${type} ${count}`)
.join(" · ");
console.log(`PASS ${blocks.length}개 블록 — ${summary}`);
} catch (error) {
if (error instanceof ContentFormatError) {
for (const issue of error.issues) {
console.error(`FAIL ${target}:${issue.line + offset}:${issue.column} ${issue.detail}`);
}
process.exit(1);
}
throw error;
}