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>
This commit is contained in:
DongHyeonka
2026-09-07 18:54:45 +09:00
co-authored by Claude Opus 5
parent 6feee5ba57
commit a8ce0dda07
15 changed files with 484 additions and 132 deletions
+93 -8
View File
@@ -188,18 +188,48 @@ const path = kind === "CASE" ? "/cases/"
### 3.3 고친 방법 — 표로 바꾸고 컴파일러에게 맡긴다
삼항 사슬을 `Record<Kind, Value>` 로 바꿨습니다.
삼항 사슬을 `Record<RecordKind, Value>` 로 바꿨습니다. 종류별 목록 주소가 그 예입니다
(`presentation/shared/document-kind-labels.ts`):
```ts
// 종류가 늘면 이 자리가 비어 있다고 컴파일러가 잡는다
const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
CASE: "/cases/",
REFERENCE: "/references/",
QUESTION: "/questions/",
CONCEPT: "/concepts/",
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`(개념 종류 추가) 커밋 메시지에 그 효과가 적혀 있습니다:
@@ -272,7 +302,27 @@ const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
### 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` 로 명시해 둡니다 —
"구현하지 않기로 한 것"과 "빠뜨린 것"은 다릅니다
@@ -1307,10 +1357,45 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
경로가 `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장이 저장소에 커밋돼 있다
@@ -59,20 +59,32 @@ tech-log-frontend : 15e6ea8 이후
## 등록하지 않으면 옆으로 떨어진다
개념 삭제가 계속 질문 삭제 경로로 나갔고, 배포된 번들에서 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404` 가 찍혔다. 개념 상세 주소도 마찬가지로 질문 조회를 불러 404 를 받았다.
관리 계약의 연산은 기여 목록에 등록해야 실행할 때 부를 수 있다. 계약에서 타입은 생성되므로 등록을 빠뜨려도 에디터에서는 그 연산이 멀쩡히 보이고 컴파일도 통과한다.
게이트웨이가 옆 분기로 떨어지므로 서버는 정상적으로 응답하고, 다만 다른 기록을 다룬다.
증상이 「연산을 찾을 수 없습니다」였다면 바로 보였을 것이다. 게이트웨이는 등록된 것 중에서 고르므로 옆 분기로 떨어지고, 서버는 그 요청에 정상적으로 응답한다. 다만 다른 기록을 다룬다.
개념 삭제가 계속 질문 삭제 경로로 나갔고, 배포된 번들에서 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404` 가 찍혔다. 개념 상세 주소도 마찬가지로 질문 조회를 불러 404 를 받았다.
## 네 번의 누락
- `getPublicConcept` — 개념 화면이 질문 조회를 불렀다
- `deleteConceptDraft` — 개념 삭제가 질문 삭제를 불렀다
- `listStudioQuestions``listStudioProjectDecisions` — 홈 편집기가 빈 목록을 그렸다
- 축(variant) CRUD 네 연산 — 축 화면이 데이터를 받지 못했다
| 연산 | 화면에서 무엇으로 보였나 |
|---|---|
| `getPublicConcept` | 개념 화면이 질문 조회를 불러 404 |
| `deleteConceptDraft` | 개념 삭제가 질문 삭제로 나가 404 |
| `listStudioQuestions` · `listStudioProjectDecisions` | 홈 편집기가 빈 목록을 그림 |
| 축 CRUD 네 연산 | 축 화면이 데이터를 받지 못함 |
앞의 둘은 옆 분기로 떨어져 다른 기록을 다뤘고, 뒤의 둘은 아예 값이 오지 않아 빈 화면이 됐다. 등록되지 않은 연산이 무엇으로 보이는지는 게이트웨이가 그 종류를 어떻게 고르느냐에 달려 있다.
## 기여 목록이 무엇을 들고 있나
기여 목록은 연산 이름만 나열하는 것이 아니라 그 연산이 쓰는 오류 코드 집합까지 들고 있다. 관리 계약의 오류 코드 상수는 계약의 enum 과 `1:1` 이라고 주석이 못 박아 둔다. 등록이 빠지면 그 연산에 딸린 이 배선이 전부 없는 것이 된다.
## 가드 둘
축 CRUD 를 더한 커밋에서 가드를 둘 넣었다. 공개 계약은 전수 대조한다 — 계약이 선언한 연산이 기여 목록에 전부 있는지 본다. 관리 계약은 86 operation 이라 전수 대조가 무겁고, 대신 「한 종류만 빠진 항목」을 본다. 깨진 것이 늘 그 모양이었기 때문이다.
축 CRUD 를 더한 커밋에서 가드를 둘 넣었다. 공개 계약은 전수 대조한다 — 계약이 선언한 연산이 기여 목록에 전부 있는지 본다.
관리 계약은 86 operation 이라 전수 대조가 무겁다. 대신 「한 종류만 빠진 항목」을 본다. 깨진 것이 늘 그 모양이었기 때문이다.
## 확인하지 못한 것
@@ -60,6 +60,12 @@ tech-log-frontend : 계약에서 생성한 타입을 그대로 사용
<!-- body:start -->
## 빈 화면과 안 쓴 글이 같아 보인다
계약이 「이 연산이 있다」고 말하면 프론트는 그것을 부른다. 서버에 그 컨트롤러가 없으면 404 가 돌아오고, 화면은 목록 요청이 실패했을 때와 0건일 때를 같은 그림으로 그린다.
작성 도구에서 이것이 특히 오래 숨는다. 작성자는 자기가 쓴 것과 화면을 대조하므로, 「없습니다」를 보면 아직 안 썼거나 게시하지 않았다고 읽는다.
## 비어 있던 다섯 화면
| 무엇이 비었나 | 왜 |
@@ -70,27 +76,37 @@ tech-log-frontend : 계약에서 생성한 타입을 그대로 사용
| 프로젝트 활동 | 계약에 목록·생성·수정이 선언돼 있었지만 구현이 없었고 `project_activity` 는 0행이었다 |
| 릴리즈(변경 기록) | 읽는 쪽은 있는데 쓰는 쪽이 없어, 페이지는 영원히 빈 채였다 |
홈 focus 가 가장 오래 숨었다. 세 슬롯이 다 비면 화면이 그 영역을 통째로 그리지 않으므로, 그런 영역이 있다는 사실조차 화면에서 알 수 없다.
다섯이 같은 원인인데 숨은 깊이가 달랐다. 프로젝트 공개 여부와 관계 연결은 화면에 자리는 있고 값만 없으므로 「아직 안 채웠다」로 읽힌다. 홈 focus 는 그보다 깊다 — 세 슬롯이 다 비면 화면이 그 영역을 통째로 그리지 않으므로, 그런 영역이 있다는 사실조차 화면에서 알 수 없다. 그래서 운영에서 한 번도 나타난 적이 없다.
> 화면은 오류를 내지 않고 빈칸을 그렸고, 저는 그것을 "아직 안 쓴 글"로 읽었습니다.
## 같은 계약이 반대 방향으로도 깨졌다
## 편집기가 부르던 두 목록
빠진 구현이 화면을 비우는 것과 반대로, 있는 계약이 값을 거절하는 경우도 났다. `home_focus_config.default_focus_type` 은 마이그레이션 직후 NULL 인데 계약은 이 필드를 required 에 enum 세 값으로 선언한다.
`GET /v1/studio/questions``GET /v1/studio/projects/{id}/decisions` 도 같은 모양이었다. 계약에 있고 모델도 생성됐는데 컨트롤러가 없었다. 화면은 그것을 「이 프로젝트에 열린 질문이 없습니다」로 그렸고, 실제로는 넷이 있었으며 공개 사이트에도 나오고 있었다.
배포 직후 첫 요청부터 `/home` 이 깨졌다. `HomeFocusView.resolve` 가 반드시 유효한 값 하나를 정하도록 고쳤다.
## 마이그레이션 직후의 값
## 생성 모델 검사가 못 잡는 이유
같은 계약이 반대 방향으로도 깨졌다. `home_focus_config.default_focus_type` 은 마이그레이션 직후 NULL 인데 계약은 이 필드를 required 에 enum 세 값으로 선언한다. 배포 직후 첫 요청부터 `/home` 이 깨졌고, `HomeFocusView.resolve` 가 반드시 유효한 값 하나를 정하도록 고쳤다.
계약에서 모델을 생성하는 단계는 스키마와 속성만 본다. 연산에 구현이 없어도 그 연산의 요청·응답 모델은 멀쩡히 만들어지고 컴파일도 통과한다. 그래서 「모델이 생성됐다」는 구현이 있다는 증거가 아니다.
## 계약과 컨트롤러를 전수로 맞춘다
`ContractRouteCoverageTest``@RestController` 들을 리플렉션으로 훑어 매핑을 모으고 계약이 선언한 경로와 대조한다.
`ContractRouteCoverageTest``@RestController` 들을 리플렉션으로 훑어 매핑을 모으고 계약이 선언한 경로와 대조한다. 기대 목록을 손으로 적지 않고 계약에서 읽으므로, 연산을 더하고 컨트롤러를 잊으면 여기서 멈춘다.
- 작업본 API 로 대체된 옛 연산 51개는 `SUPERSEDED_BY_WORKING_COPY_API` 로 명시한다 — 「구현하지 않기로 한 것」과 「빠뜨린 것」은 다르다
- 봉투 없이 바이트를 주는 `/media` 하나만 `ELSEWHERE` 로 면제한다
- 매핑을 떼어 보고 그 연산 하나를 정확히 짚는 것을 확인했다
면제는 상수 둘로 명시한다.
프론트에도 같은 가드를 뒀다. 양쪽에서 봐야 한쪽만 지웠을 때 잡힌다.
```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개가 뒤쪽 목록에 있다. 이것을 적어 두지 않으면 대조 결과가 51건의 실패로 나오고, 그렇게 되면 아무도 결과를 읽지 않는다. 봉투 없이 바이트를 주는 미디어 연산 하나만 앞쪽 목록으로 면제한다.
매핑을 떼어 보고 그 연산 하나를 정확히 짚는 것을 확인했다. 프론트에도 같은 가드를 뒀다 — 양쪽에서 봐야 한쪽만 지웠을 때 잡힌다.
## 확인하지 못한 것
@@ -28,29 +28,36 @@ source:
## 목적
계약이 선언한 연산에 구현이 없는 상태를 배포 전에 잡는다. 이 상태는 오류를 내지 않는다 — 서버는 404 를 주고 화면은 그것을 빈 데이터로 그린다.
계약이 선언한 연산에 구현이 없는 상태를 배포 전에 잡는다.
이 상태는 오류를 내지 않는다. 서버는 404 를 주고 화면은 그것을 빈 데이터로 그린다. 계약에서 모델을 생성하는 단계도 스키마와 속성만 보므로 구현이 없어도 모델이 만들어지고 컴파일이 통과한다.
## 규칙
**서버 쪽은 매핑을 리플렉션으로 모아 계약의 경로와 전수 대조한다**
`@RestController` 들을 훑어 실제 매핑을 모으고, 계약이 선언한 경로 전부와 맞춘다.
`@RestController` 들을 훑어 실제 매핑을 모으고 계약이 선언한 경로 전부와 맞춘다. 기대 목록을 손으로 적으면 그 목록이 또 하나의 손 목록이 되므로 계약에서 읽는다.
**화면 쪽은 계약이 선언한 연산이 기여 목록에 등록됐는지 본다**
타입은 계약에서 생성되므로 등록을 빠뜨려도 컴파일이 통과한다. 그 상태에서 부르면 게이트웨이가 옆 분기로 떨어져 다른 연산이 실행된다.
타입은 계약에서 생성되므로 등록을 빠뜨려도 에디터에서 그 연산이 보이고 컴파일이 통과한다. 그 상태에서 부르면 게이트웨이가 등록된 것 중에서 고르므로 옆 분기로 떨어지고, 서버는 그 요청에 정상 응답한다.
**구현하지 않기로 한 연산은 이유와 함께 명시 목록에 넣는다**
「빠뜨린 것」과 구분되지 않으면 대조 결과가 곧 무시된다. 이 저장소는 작업본 API 로 대체된 옛 연산 51개를 그렇게 표시하고, 봉투 없이 바이트를 주는 연산 하나를 면제 목록에 뒀다.
「빠뜨린 것」과 구분되지 않으면 대조 결과가 곧 무시된다. 이 저장소는 작업본 API 로 대체된 옛 연산 51개를 그 이름의 상수에 담고, 봉투 없이 바이트를 주는 연산 하나를 별도 상수로 면제한다. 면제가 코드에 이름으로 남아 다음 사람이 세어 볼 수 있다.
**두 쪽 다 돌린다**
한쪽만 대조하면 다른 쪽을 지웠을 때 잡히지 않는다.
**생성 모델 검사를 이 대조로 세지 않는다**
모델 생성은 schema 와 property 만 본다. 구현이 없어도 모델은 멀쩡히 만들어진다.
모델 생성은 스키마와 속성만 본다. 구현이 없어도 모델은 멀쩡히 만들어진다.
**전수 대조가 무거우면 깨지는 모양으로 좁힌다**
관리 계약은 86 operation 이라 전수 대조가 무겁다. 이 저장소는 대신 「한 종류만 빠진 항목」을 보게 했다 — 깨진 것이 늘 그 모양이었기 때문이다. 좁힌 기준은 무엇을 보지 않는지도 함께 적는다.
## 적용 조건
계약이 한 저장소에 있고 두 저장소가 그것을 반입해 각자 구현하는 구조. 연산을 더하거나 지우는 변경에서 이 대조를 돌린다.
화면이 「데이터가 없습니다」를 그리는데 저장소에는 값이 있을 때 이 대조를 먼저 본다. 구현이 없어서 404 인 것과 정말 0건인 것이 화면에서 같아 보인다.
## 예외
계약과 구현이 같은 저장소에 있고 같은 빌드를 지나면 컴파일러가 이 대조를 대신한다.
@@ -61,4 +68,8 @@ source:
매핑 하나를 떼어 보고 대조 검사가 그 연산 하나를 정확히 짚는 것을 확인한 뒤 커밋했다.
관리 계약은 86 operation 이라 전수 대조 대신 「한 종류만 빠진 항목」을 보게 했다. 깨진 것이 늘 그 모양이었다.
두 목록 조회에 컨트롤러가 없어 홈 편집기가 「이 프로젝트에 열린 질문이 없습니다」를 그렸다. 실제로는 넷이 있었고 공개 사이트에도 나오고 있었다.
옛 연산 51개를 명시하지 않았다면 대조 결과가 51건의 실패로 나와 아무도 읽지 않았을 것이다.
기여 목록에 등록하지 않은 연산 넷을 만났다. 둘은 옆 분기로 떨어져 다른 기록을 다뤘고 둘은 빈 목록이 됐다.
@@ -78,7 +78,7 @@ const path = kind === "CASE" ? "/cases/"
: "/projects/"; // ← CONCEPT 이 여기로 떨어진다
```
CONCEPT 을 더해도 이 코드는 컴파일된다. `/concepts/idp-brokering` 을 열면 질문 조회가 나가 404 가 돌아온다.
CONCEPT 을 더해도 이 코드는 컴파일된다. 마지막 가지가 나머지를 받으므로 모든 값에 갈 곳이 있고, 각 가지가 내놓는 타입도 같기 때문이다. 실행하면 `/concepts/idp-brokering` 대신 프로젝트 경로가 만들어지고, 그 주소로 질문 조회가 나가 404 가 돌아온다.
## 열세 곳
@@ -98,29 +98,41 @@ CONCEPT 을 더해도 이 코드는 컴파일된다. `/concepts/idp-brokering`
| 12 | `RelatedEntry.type` (관리 계약) | Case 가 개념을 가리킬 수 없음 | `2c25ccc` |
| 13 | `PublicSql.pathOf` (백엔드) | CONCEPT 케이스 없음 → `null` 경로 | `8cd8ee3` |
10·11·12 는 계약 안에 있다. 계약이 종류를 열거하는 곳이 여러 곳이라, 계약을 고치는 커밋에서 같은 실수를 다시 했다.
증상이 셋으로 갈린다. 오류를 낸 것은 3건(404 · 400 · 요청 거절)이고 나머지 열은 조용했다. 8번은 특히 읽기 어려웠다 — 개념 작업본을 열면 모든 칸이 「허용되지 않은 속성」이 되므로 칸 하나가 잘못된 것처럼 보이는데, 실제로는 그 종류를 아는 목록이 없어서 허용 칸 집합이 통째로 비어 있었다.
같은 병이 종류가 아닌 곳에서도 났다. `latestEntries` 가 투영의 모든 `resource_type` 을 흘리는데 계약의 `LatestEntry.entryType` 은 네 값뿐이라, QUESTION 이 섞이면 매퍼가 500 을 내고 홈 화면 전체를 못 쓰게 만든다. 그래서 질의가 먼저 걸러 냈고, 게시한 Open Question 이 홈 최근 기록에 나오지 않았다. `pathOf` 는 이미 `/questions/{slug}` 를 만들고 있었고 projection 에도 질문 행이 채워져 있었다.
10·11·12 는 계약 안에 있었다. 계약이 종류를 열거하는 곳이 여러 개라, 계약을 고치는 커밋에서 같은 실수를 다시 했다.
## 같은 병이 종류가 아닌 목록에서도 났다
홈의 최근 기록이 게시한 Open Question 을 싣지 않고 있었다. 원인이 같은 모양이었다. 공개 투영은 종류를 담는 칸에 문서 다섯 종류 말고 프로젝트와 릴리스도 담는데, 계약의 최근 기록 항목은 그중 일부만 열거한다. 계약 밖 값이 섞이면 응답 매퍼가 500 을 내고 그 500 은 홈 화면 전체를 못 쓰게 만든다.
그래서 질의가 먼저 걸러 냈고, 거르는 목록에 질문이 없었다. 경로 생성기는 이미 질문 주소를 만들고 있었고 투영에도 질문 행이 공개 상태로 채워져 있었다.
## 표로 바꾼 곳
삼항 사슬을 `Record<RecordKind, Value>` 로 바꿨다. 종류별 목록 주소가 그 예다.
```ts
// 종류가 늘면 이 자리가 비어 있다고 컴파일러가 잡는다
const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
CASE: "/cases/",
REFERENCE: "/references/",
QUESTION: "/questions/",
CONCEPT: "/concepts/",
export const EXPLORE_KIND_PATHS: Record<RecordKind, string> = {
CASE: "/explore/cases",
CONCEPT: "/explore/concepts",
REFERENCE: "/explore/references",
QUESTION: "/explore/questions",
PROJECT_DECISION: "/projects",
};
```
백엔드에서는 sealed switch 를 식으로 쓴 곳이 이미 이 일을 하고 있었다. 개념 종류를 더한 커밋 메시지가 그 효과를 적어 두었다.
이 대응이 흩어져 있던 동안 세 화면이 각각 개념을 빠뜨렸다. 같은 파일의 javadoc 이 그것을 적어 두었다.
> 이 대응이 세 화면에 흩어져 있었고 셋 다 개념을 빠뜨렸다 — 홈의 「종류별로 읽기」에는 개념이 아예 없었고, 문서 머리말의 종류 링크는 삼항의 마지막 else 를 타 개념 문서에서 `/projects` 로 갔다. `/explore/concepts` 는 처음부터 열려 있었는데 그리로 가는 길이 없었다.
백엔드에서는 sealed switch 를 식으로 쓴 곳이 이 일을 이미 하고 있었다. 개념 종류를 더한 커밋 메시지가 그 효과를 적었다.
> sealed switch 가 이 변경을 안내했다 — 종류를 더하자 컴파일러가 게시 상태 코드·활동 유형·소유자 유형·slug 중복 검사·렌더 모델까지 빠짐없이 짚었다. 문이 아니라 식으로 써 둔 덕이다.
## 계약과 코드 사이
표로 바꿔도 계약이 종류를 빠뜨린 것은 컴파일러가 모른다. 계약 문서를 직접 파싱해 대조하는 가드를 넣었다.
표로 바꿔도 계약이 종류를 빠뜨린 것은 컴파일러가 모른다. 계약은 다른 저장소에 있고 생성기를 지나 타입으로 들어오므로, 계약 쪽 열거형이 좁아도 그 타입은 유효하다. 계약 문서를 직접 파싱해 대조하는 가드를 넣었다.
- `knowledge-list-kinds.test.ts` — 계약의 종류 enum 을 읽어 목록 매퍼의 표에 전부 있는지 본다
- `contract-operation-coverage.test.ts` — 계약이 선언한 연산이 기여 목록에 등록됐는지 본다
@@ -132,7 +144,7 @@ const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
표로 바뀐 곳과 남은 곳을 현재 코드에서 조회했다. 되돌려 확인한 것은 계약 대조 가드 셋이고, 나머지 열 건은 커밋 메시지와 현재 상태로만 확인했다. 옛 증상은 재현하지 않았다.
`PublicSql.pathOf``validate-working-copy.ts``stringFields` 는 아직 표가 아니다.
두 곳이 아직 표가 아니다. 공개 경로 생성기는 분기 대상이 문자열이라 컴파일러가 셀 수 있는 값 집합이 없고, 작업본 검증기의 문자열 칸 목록은 사슬로 남아 있다.
:::evidence key="kind-tables-now" alt="현재 코드에서 종류를 나열하는 곳을 조회한 출력" caption=" " zoom="false"
:::
@@ -30,37 +30,98 @@ source:
<!-- body:start -->
## 유한한 집합을 분기하는 두 가지 방법
## 컴파일러가 요구하는 것과 요구하지 않는 것
문서 종류는 다섯 값이다. 그 다섯을 분기하는 코드는 두 모양 중 하나로 쓰다.
문서 종류는 다섯 값이다. 그 다섯을 분기하는 코드는 두 모양 중 하나로 쓰이고, 컴파일러가 무엇을 묻느냐가 갈린다.
하나는 값을 하나씩 비교하고 마지막에 나머지를 받는다. 삼항 사슬, `if`/`else if` 사슬, 문으로 쓴 `switch` 가 여기 속한다. 이 모양에서 컴파일러가 확인하는 것은 각 가지의 타입이 맞는지까지이고, 어떤 값이 어느 가지로 가는지는 확인하지 않는다.
하나는 값을 하나씩 비교하고 마지막에 나머지를 받는 모양이다. 삼항 사슬, `if`/`else if` 사슬, 문으로 쓴 `switch` 가 여기 속한다. 이 모양에서 컴파일러가 확인하는 것은 각 가지가 같은 타입을 내놓는지까지다. 어떤 값이 어느 가지로 가는지는 실행할 때 정해지므로 확인 대상이 아니고, 마지막 `else` 가 있으면 모르는 값도 갈 곳이 있으니 타입도 맞는다.
다른 하나는 값마다 항목을 하나씩 요구다. `Record<Kind, V>` 는 키 집합이 `Kind` 와 정확히 같기를 요구하고, 식으로 쓴 `switch` 는 모든 가지가 값을 내놓기를 요구한다. 값을 하나 더하면 그 키가 비었다고 컴파일러가 말한다.
다른 하나는 값마다 항목을 하나씩 요구하는 모양이다. `Record<K, V>` 는 키 집합이 `K` 와 정확히 같은 객체 타입이라, 키가 하나 모자라면 그 객체 리터럴이 그 타입이 아니게 된다. 그래서 `K` 에 값이 늘어나면 리터럴을 쓴 곳이 전부 타입 오류가 된다.
```ts
const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
CASE: "/cases/",
REFERENCE: "/references/",
QUESTION: "/questions/",
CONCEPT: "/concepts/",
export const EXPLORE_KIND_PATHS: Record<RecordKind, string> = {
CASE: "/explore/cases",
CONCEPT: "/explore/concepts",
REFERENCE: "/explore/references",
QUESTION: "/explore/questions",
PROJECT_DECISION: "/projects",
};
```
이 표를 한 곳에 둔 뒤 두 가지가 함께 걸린다. 종류를 더하면 이 리터럴이 비었다고 컴파일러가 말하고, 표를 읽는 화면들은 목록을 손으로 적지 않고 이 표에서 뽑으므로 화면마다 다른 목록이 생기지 않는다.
## 표에 남기는 「그렇게 정했다」
이 표에서 결정은 자기 목록이 아니라 프로젝트 목록으로 간다. 결정이 프로젝트 안에서만 읽히기 때문이고, 그것이 빠뜨린 것인지 정한 것인지가 표만 봐서는 갈리지 않는다. 그래서 같은 파일이 이유를 적어 둔다.
> 결정은 프로젝트 안에서만 읽히므로 자기 목록이 없다. 그 자리를 `/projects` 로 두는 것은 빠뜨린 것이 아니라 그렇게 정한 것이고, 표에 적혀 있으니 다음 사람이 구분할 수 있다.
## 키가 종류가 아니면 표가 성립하지 않는다
`Record<K, V>` 가 걸리는 조건은 키 집합이 그 열거형과 같다는 것이다. 키가 다른 것이면 이 방법을 쓸 수 없다.
공개 주소에서 종류를 거꾸로 알아내는 곳이 그렇다. 키가 종류가 아니라 주소 앞머리이므로 배열로 두고, 앞머리로 못 찾은 것을 조각으로 가른다.
```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";
}
```
담기는 값이 열거형 밖으로 나가는 칸도 마찬가지다. 공개 투영의 `resource_type` 은 종류 다섯에 더해 `PROJECT`·`RELEASE` 도 담으므로, 그것을 읽는 백엔드의 경로 생성기는 `RecordKind` 가 아니라 `String` 으로 분기하고 `default -> null` 이 남는다.
```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;
};
}
```
## 문으로 쓴 switch 와 식으로 쓴 switch
Java 에서도 같은 갈림이 있다. `switch` 를 문으로 쓰면 어떤 가지도 는 값이 그냥 지나간다. 식으로 쓰면 그 에 대해 무엇을 반환할지 컴파일러가 요구한다. sealed 인터페이스와 함께 쓰면 하위 타입이 늘어날 때도 같은 요구가 걸린다.
Java 에서도 같은 갈림이 있다. `switch` 를 문으로 쓰면 아무 가지걸리지 않는 값이 그냥 지나간다. 식으로 쓰면 그 식이 값을 내놓아야 하므로 모든 경우에 대해 무엇을 반환할지 컴파일러가 요구하고, sealed 인터페이스를 대상으로 쓰면 하위 타입이 늘어날 때도 같은 요구가 걸린다.
이 저장소에서도 게시 상태 코드와 활동 유형과 소유자 유형과 slug 중복 검사와 렌더 모델이 전부 식으로 쓰인 switch 를 지나고 있어서, 종류를 하나 더했을 때 백엔드에서 조용히 넘어간 곳이 프론트보다 적었다.
`pathOf` 식으로 switch 인데도 `default` 가 있어 그 요구를 받지 않는다. 분기 대상이 `String` 이라 컴파일러가 셀 수 있는 값 집합이 없고, `default` 없이는 컴파일되지 않기 때문이다.
## 표로 못 바꾸는 칸
이 저장소의 백엔드에서 게시 상태 코드와 활동 유형과 소유자 유형과 slug 중복 검사와 렌더 모델은 전부 sealed 타입을 대상으로 식으로 쓰인 switch 를 지난다. 그래서 종류를 하나 더했을 때 백엔드에서 조용히 넘어간 곳이 프론트보다 적었다.
키 집합이 그 열거형과 정확히 같을 때만 `Record<Kind, V>` 가 쓰인다. 담기는 값이 열거형 밖으로 나가면 이 방법이 걸리지 않는다.
## 같은 함수 안에서 두 목록이 갈릴 수 있다
공개 투영의 `resource_type` 이 그런 칸이다. 이 칸은 `RecordKind` 다섯에 더해 `PROJECT``RELEASE` 도 담는다. 그래서 `PublicSql.pathOf``RecordKind` 가 아니라 `String` 으로 분기하고 `default -> null` 이 남는다.
한 파일을 표로 바꿨다고 그 파일이 다 바뀐 것은 아니다. 작업본 검증기는 유형별 칸을 표로 갖고 있으면서 문자열 칸은 사슬로 남겨 두었다.
```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"],
};
```
남은 사슬은 배타적이 아니라 가산형이다. 종류를 빠뜨리면 잘못된 분기로 떨어지는 것이 아니라 그 종류의 추가 칸을 검사하지 않는다.
## 컴파일러가 보지 못하는 경계
표로 바꿔도 컴파일러가 보는 것은 한 저장소 안이다. 계약은 다른 저장소에 있고 생성기를 지나 들어오므로, 계약이 종류를 빠뜨린 것은 타입 검사가 아니라 계약을 읽어 대조하는 검사가 는다.
표로 바꿔도 컴파일러가 보는 것은 한 저장소 안이다. 계약은 다른 저장소에 있고 생성기를 지나 타입으로 들어오므로, 계약 쪽 열거형이 종류를 빠뜨린 것은 타입 검사에 잡히지 않는다. 그 경계는 계약 문서를 읽어 코드의 표와 맞춰 보는 검사가 는다.
<!-- body:end -->
@@ -7,6 +7,8 @@ topicName: 손으로 나열한 종류 목록
project: TechLog
status: 게시 전
questionStatus: OPEN
evidence:
- ../../../final/evidence/raw/guards/kind-tables-now.txt
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§16.7
@@ -27,53 +29,55 @@ source:
## 사실
`PublicSql.pathOf``RecordKind` 가 아니라 공개 투영의 `resource_type`(String)으로 switch 하고 `default -> null` 이 남아 있다.
공개 경로 생성기는 문서 종류가 아니라 공개 투영의 종류 칸(String)으로 switch 하고 `default -> null` 이 남아 있다.
그 칸은 `RecordKind` 다섯에 더해 `PROJECT``RELEASE` 도 담는다.
그 칸은 문서 종류 다섯에 더해 PROJECT 와 RELEASE 도 담는다. 지금 그 switch 가 다루는 값은 여덟이다.
CONCEPT 이 실제로 이 분기에서 빠져 있었고, 경로가 null 로 나갔다.
CONCEPT 이 실제로 이 분기에서 빠져 있었고 경로가 null 로 나갔다. 열세 건 중 13번이다.
`validate-working-copy.ts``stringFields` 는 아직 삼항 사슬이다.
PublicPathsTest 가 지금 종류마다 만들어 낸 경로를 공개 라우트 패턴에 맞춰 본다.
`PublicPathsTest` 가 지금 종류마다 만들어 낸 경로를 공개 라우트 패턴에 맞춰 본다.
작업본 검증기의 유형별 칸은 이미 표다. 같은 함수의 문자열 칸 목록만 사슬로 남았다.
이 둘은 작업하면서 드러난 것이 아니다. 나중에 근거를 모으며 종류를 나열하는 곳을 다시 훑다가 새로 확인했다.
남은 사슬은 배타적이 아니라 가산형이다. 종류를 빠뜨리면 잘못된 분기로 떨어지는 것이 아니라 그 종류의 추가 칸을 검사하지 않는다.
## 가정
`stringFields` 는 배타적 사슬이 아니라 가산형이라, 종류를 빠뜨리면 잘못된 분기로 떨어지는 것이 아니라 그 종류의 추가 칸을 검사하지 않는 결과가 된다. 실제로 종류를 빠뜨려 확인하지 않았다.
PROJECT 와 RELEASE 를 다른 분기로 빼면 나머지를 sealed 로 좁힐 수 있다고 보고 있다. 그 둘이 공개 투영에서 어떤 경로를 갖는지 전수로 확인하지 않았다.
`PROJECT``RELEASE` 를 다른 분기로 빼면 나머지를 sealed 로 좁힐 수 있다고 보고 있다. 그 둘이 공개 투영에서 어떤 경로를 갖는지 전수로 확인하지 않았다.
문자열 칸 목록을 표로 바꿔도 검사 결과가 지금과 같다고 보고 있다. 종류를 하나 빼서 확인하지 않았다.
홈 focus 의 최근 결정 경로가 PublicPathsTest 를 지나지 않는다고 보고 있다. 그 테스트가 도는 대상 목록을 직접 세지 않았다.
## 미지수
`pathOf` 가 읽는 칸이 종류 밖의 값도 담는 상태에서, 그 둘을 어디로 옮기면 나머지를 컴파일러가 세게 만들 수 있는가.
공개 경로 생성기가 읽는 칸이 종류 밖의 값도 담는 상태에서, 그 둘을 어디로 옮기면 나머지를 컴파일러가 세게 만들 수 있는가.
`PublicPathsTest` 가 닿지 않는 경로가 몇 개인가. 홈 focus 의 `recentDecision``pathOf` 만 쓰고 그 테스트를 지나지 않는다.
PublicPathsTest 가 닿지 않는 경로가 몇 개인가.
`stringFields` 표로 바꾸면 그 검사가 지금과 다른 결과를 내는 종류가 있는가.
문자열 칸 목록을 표로 바꾸면 지금과 다른 결과를 내는 종류가 있는가.
## 제약
공개 투영은 한 테이블이 문서·프로젝트·릴리를 함께 담는다. 이 구조는 바꾸지 않는다.
공개 투영은 한 테이블이 문서프로젝트릴리를 함께 담는다. 이 구조는 바꾸지 않는다.
계약의 `RecordKind` 는 다섯이고 여기에 `PROJECT`·`RELEASE` 를 더하지 않는다. 그 둘은 문서 게시 파이프라인을 타지 않다.
계약의 문서 종류는 다섯이고 여기에 프로젝트와 릴리스를 더하지 않는다. 그 둘은 문서 게시 파이프라인을 타지 않고, 릴리스는 자체 상태로 공개되므로 투영에 행이 아예 없다.
## 선택지
**`pathOf` 를 두 분기로 가른다**
`RecordKind` 를 받는 sealed switch 와 `PROJECT`·`RELEASE` 를 받는 분기로 나눈다. 호출부가 어느 쪽인지 알아야 하므로 호출부를 함께 본다.
**경로 생성기를 두 분기로 가른다**
문서 종류를 받는 sealed switch 와 프로젝트·릴리스를 받는 분기로 나눈다. 호출부가 어느 쪽인지 알아야 하므로 호출부를 함께 본다.
**`resource_type` 을 sealed 타입으로 올린다**
`RecordKind` 다섯에 `PROJECT`·`RELEASE` 를 더한 별도 열거형을 만들고 `pathOf` 를 그것으로 분기한다. 열거형이 하나 늘고 두 열거형 사이 변환이 생긴다.
**투영의 종류 칸을 별도 열거형으로 올린다**
문서 종류 다섯에 프로젝트·릴리스를 더한 열거형을 만들고 경로 생성기를 그것으로 분기한다. 열거형이 하나 늘고 두 열거형 사이 변환이 생긴다.
**`stringFields` 만 먼저 표로 바꾼다**
가산형이라 위험이 낮고 다른 것과 얽히지 않는다. 이 선택만으로는 `pathOf` 가 null 경로를 내보내는 것을 막지 못한다.
**문자열 칸 목록만 먼저 표로 바꾼다**
가산형이라 위험이 낮고 다른 것과 얽히지 않는다. 이 선택만으로는 경로 생성기가 null 내보내는 것을 막지 못한다.
## 다음 검증
1. `pathOf``RecordKind` switch 와 `PROJECT`/`RELEASE` 분기로 갈라 보고, 새 종류를 하나 더해 컴파일이 멈추는지 본다
2. 홈 focus 의 `recentDecision` 경로가 `PublicPathsTest` 에 덮이는지 확인하고, 덮이지 않으면 그 경로를 테스트에 넣는다
3. `stringFields``Record<Kind, string[]>` 로 바꾸고 종류 하나를 빼서 컴파일이 멈추는지 본다
1. 경로 생성기를 문서 종류 switch 와 프로젝트·릴리스 분기로 갈라 보고, 새 종류를 하나 더해 컴파일이 멈추는지 본다
2. PublicPathsTest 가 도는 대상 목록을 세고, 홈 focus 의 최근 결정 경로가 거기 있는지 확인한다. 없으면 그 경로를 테스트에 넣는다
3. 문자열 칸 목록을 표로 바꾸고 종류 하나를 빼서 컴파일이 멈추는지 본다
닫는 조건 : 새 종류를 더했을 때 이 두 곳이 컴파일 오류로 먼저 멈추면 닫는다. 구조상 좁힐 수 없다는 것이 확인되면 대조 검사를 두는 Decision 으로 넘긴다
@@ -29,15 +29,26 @@ source:
## 목적
새 값을 더했을 때 오류 없이 잘못된 값이 나가는 것을 막는다. 이 부류는 테스트가 지나가고 컴파일도 지나가므로, 배포된 뒤 사용자가 만나기 전까지 아무도 모른다.
새 값을 더했을 때 오류 없이 잘못된 값이 나가는 것을 막는다.
이 부류는 컴파일도 테스트도 지나간다. 마지막 `else` 가 모르는 값을 받아 가면 모든 값에 갈 곳이 있고 각 가지가 내놓는 타입도 같으므로, 타입 검사가 물을 것이 남지 않는다. 그래서 배포된 뒤 사용자가 만나기 전까지 아무도 모른다.
## 규칙
**유한한 집합의 분기는 값마다 항목을 요구하는 형태로 쓴다**
`Record<Kind, V>` 나 식으로 쓴 sealed switch 를 쓴다. 삼항 사슬과 배열 리터럴은 마지막 가지가 모르는 값을 받아 가므로 새 값을 조용히 삼킨다.
`Record<K, V>` 는 키 집합이 `K` 와 정확히 같은 객체 타입이라, 키가 하나 모자라면 그 리터럴이 그 타입이 아니게 된다. `K` 에 값을 더하면 리터럴을 쓴 곳이 전부 타입 오류가 된다. Java 에서는 sealed 타입을 대상으로 switch 를 식으로 쓴다 — 식은 값을 내놓아야 하므로 모든 경우에 무엇을 반환할지 컴파일러가 요구한다. 문으로 쓴 switch 는 요구하지 않는다.
**키가 그 열거형이 아니면 표로 좁히지 말고 그 이유를 코드 옆에 적는다**
주소 앞머리로 종류를 거꾸로 찾는 코드는 키가 종류가 아니라 주소다. 담기는 값이 열거형 밖으로 나가는 칸도 같다. 억지로 표로 바꾸면 실제 값 집합과 타입이 어긋나므로, 배열이나 문자열 분기로 두고 왜 좁힐 수 없는지를 주석으로 남긴다.
**표에 있는 「그렇게 정했다」를 표 안에서 읽히게 한다**
표의 어느 칸이 다른 값들과 다른 규칙을 따르면, 그것이 빠뜨린 것인지 정한 것인지 표만 봐서는 갈리지 않는다. 이 저장소의 종류별 목록 표에서 결정만 자기 목록이 아니라 프로젝트 목록으로 가는데, 그 이유가 표 위에 적혀 있어 다음 사람이 구분한다.
**한 파일을 표로 바꿨다고 그 파일이 다 바뀐 것으로 보지 않는다**
작업본 검증기는 유형별 칸을 표로 갖고 있으면서 문자열 칸 목록은 사슬로 남겨 두었다. 같은 함수 안에서 두 목록의 상태가 갈린다.
**컴파일러가 못 보는 경계에는 계약을 읽어 대조하는 검사를 둔다**
계약이 다른 저장소에 있고 생성기를 지나 들어오면, 계약이 값을 빠뜨린 것은 타입 검사가 잡지 못한다. 계약 문서를 파싱해 코드의 표와 맞춰 본다.
계약이 다른 저장소에 있고 생성기를 지나 타입으로 들어오면, 계약 쪽 열거형이 좁아도 그 타입은 유효하다. 계약 문서를 파싱해 코드의 표와 맞춰 본다.
**가드를 넣었으면 그 가드가 실제로 잡는지 되돌려 확인한 뒤 커밋한다**
계약에서 값을 하나 빼고 검사가 빨개지는 것을 본다. 확인하지 않은 가드는 그 값이 원래 없었는지 검사가 안 도는지 구별되지 않는다.
@@ -49,18 +60,26 @@ source:
값이 유한한 집합인 것을 코드나 계약 여러 곳에서 분기하거나 열거하는 곳. 문서 종류, 상태, 역할, 오류 코드, 라우트 이름이 여기 해당한다.
새 값을 더하는 변경을 시작할 때 이 규칙을 먼저 건다. 다 더한 뒤에 빠진 곳을 찾는 순서로는 조용히 빠진 곳을 못 찾는다.
새 값을 더하는 변경을 시작할 때 이 규칙을 먼저 건다. 다 더한 뒤에 빠진 곳을 찾는 순서로는 조용히 빠진 곳을 못 찾는다 — 조용히 빠진 곳은 증상이 없으므로 훑어서는 나오지 않는다.
계약을 소유한 저장소가 따로 있으면 계약 쪽 열거형에도 같이 건다. 이 저장소에서 열세 건 중 셋이 계약 안에 있었다.
## 예외
그 칸이 집합 밖의 값도 담으면 표로 좁힐 수 없다. 공개 투영의 resource_type 이 그런 칸이다 — RecordKind 다섯에 더해 PROJECT 와 RELEASE 를 담는다. 이때는 대조 검사를 대신 둔다.
그 칸이 집합 밖의 값도 담으면 표로 좁힐 수 없다. 공개 투영의 resource_type 이 그런 칸이다 — 문서 종류 다섯에 더해 PROJECT 와 RELEASE 를 담는다. 이때는 대조 검사를 대신 둔다.
키가 그 열거형이 아닌 자료 구조도 대상이 아니다. 주소 앞머리로 종류를 찾는 배열이 그렇다.
값이 하나뿐이거나 분기가 한 곳에만 있으면 표로 바꾸는 비용이 이득보다 크다.
## 예시
종류를 더한 커밋에서 컴파일러가 게시 상태 코드와 활동 유형과 렌더 모델까지 짚었다. 식으로 쓴 switch 였기 때문이다.
종류를 하나 더한 커밋에서 컴파일러가 게시 상태 코드와 활동 유형과 소유자 유형과 slug 중복 검사와 렌더 모델까지 짚었다. 식으로 쓴 switch 였기 때문이다.
같은 종류를 더할 때 프론트엔드에서는 열세 곳이 조용히 지나갔다. 삼항 사슬과 배열 리터럴이었다.
계약에서 CONCEPT 을 빼자 백엔드의 계약 대조 테스트가 빨개졌다. 그 확인을 하고 커밋했다.
라우트에 딸린 청크 이름 표도 같은 부류라 다섯 검사 안에서 대조하게 했다.
종류별 목록 주소가 세 화면에 흩어져 있던 동안 셋 다 개념을 빠뜨렸다. 표 하나로 모은 뒤에는 화면들이 목록을 손으로 적지 않고 그 표에서 뽑는다.
@@ -113,6 +113,9 @@
"case:an-operation-you-can-see-but-cannot-call"
],
"kind": "case",
"ssot-evidence": [
"raw/guards/kind-tables-now.txt"
],
"publication": "초안",
"file": "hand-listed-kinds/case/case-one-new-kind-fell-through-thirteen-places.md",
"status": "게시 전",
@@ -204,13 +207,18 @@
"case:an-address-frozen-at-publish-time"
],
"kind": "question",
"ssot-evidence": [
"raw/guards/kind-tables-now.txt"
],
"publication": "초안",
"file": "hand-listed-kinds/question/openquestion-two-kind-tables-outside-the-compiler.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
"evidenceFiles": [
"../../../final/evidence/raw/guards/kind-tables-now.txt"
]
}
],
"decision": []
@@ -429,6 +437,9 @@
"reference:verify-at-the-end-of-the-value-journey"
],
"kind": "concept",
"ssot-assets": [
"value-boundaries"
],
"publication": "초안",
"file": "values-lost-between-boundaries/concept/concept-eleven-boundaries-a-value-crosses.md",
"status": "게시 전",
@@ -896,6 +907,9 @@
"reference:do-not-draw-a-link-that-does-not-resolve"
],
"kind": "decision",
"ssot-evidence": [
"raw/audit/dead-link-sweep.txt"
],
"publication": "초안",
"file": "one-route-many-hand-kept-lists/decision/decision-do-not-translate-the-catch-all-route.md",
"status": "게시 전",
@@ -940,6 +954,15 @@
"case:a-link-that-pointed-at-itself"
],
"kind": "case",
"ssot-assets": [
"decision-path-404"
],
"ssot-evidence": [
"raw/db/decision-path-after-v15.txt",
"raw/api/decision-anchor-fixed.txt",
"raw/audit/dead-link-sweep.txt",
"raw/audit/link-audit.py"
],
"publication": "초안",
"file": "addresses-frozen-at-publish-time/case/case-an-address-frozen-at-publish-time.md",
"status": "게시 전",
@@ -1349,6 +1372,9 @@
"case:the-comparison-band-changed-three-times"
],
"kind": "reference",
"ssot-evidence": [
"browser/tab-metrics.txt"
],
"publication": "초안",
"file": "css-rules-that-leak/reference/reference-measure-what-is-visible-not-a-proxy.md",
"status": "게시 전",
@@ -1602,6 +1628,9 @@
"reference:say-you-could-not-read-it"
],
"kind": "question",
"ssot-evidence": [
"raw/db/delete-blocked-by-project-link.txt"
],
"publication": "초안",
"file": "one-thing-many-names/question/openquestion-the-refusal-does-not-name-what-blocks-it.md",
"status": "게시 전",
@@ -1642,6 +1671,13 @@
"case:one-cell-failing-took-its-neighbour-down"
],
"kind": "case",
"ssot-evidence": [
"browser/home-tabs-keycloak.png",
"browser/home-topic-tabs.png",
"browser/home-topic-tabs-2.png",
"browser/home-tabs-grouped.png",
"browser/tab-metrics.txt"
],
"publication": "초안",
"file": "an-axis-inside-a-topic/case/case-the-comparison-band-changed-three-times.md",
"status": "게시 전",
@@ -1677,6 +1713,13 @@
"case:the-comparison-band-changed-three-times"
],
"kind": "concept",
"ssot-assets": [
"topic-variant-model"
],
"ssot-evidence": [
"raw/db/topic-variant-rows.txt",
"raw/db/record-variant-links.txt"
],
"publication": "초안",
"file": "an-axis-inside-a-topic/concept/concept-topic-variant-and-record-variant.md",
"status": "게시 전",
@@ -1716,6 +1759,9 @@
"reference:measure-what-is-visible-not-a-proxy"
],
"kind": "question",
"ssot-evidence": [
"raw/db/record-variant-links.txt"
],
"publication": "초안",
"file": "an-axis-inside-a-topic/question/openquestion-the-conclusion-line-does-not-follow-the-records.md",
"status": "게시 전",
@@ -1750,6 +1796,10 @@
"case:the-comparison-band-changed-three-times"
],
"kind": "decision",
"ssot-evidence": [
"raw/db/topic-variant-rows.txt",
"raw/db/record-variant-links.txt"
],
"publication": "초안",
"file": "an-axis-inside-a-topic/decision/decision-an-axis-inside-a-topic-not-four-topics.md",
"status": "게시 전",
@@ -1823,6 +1873,9 @@
"reference:enumerate-kinds-where-the-compiler-sees-it"
],
"kind": "reference",
"ssot-evidence": [
"raw/guards/guards-actually-fail.txt"
],
"publication": "초안",
"file": "when-a-guard-can-be-trusted/reference/reference-revert-the-defect-and-watch-the-guard-fail.md",
"status": "게시 전",
@@ -2965,5 +3018,5 @@
"unlisted": 0,
"candidates": 91
},
"ssotSha256": "fe8f789edaa3bcf2c88f9ce83983d437ac34cfacda42e75dba6733ec1344893d"
"ssotSha256": "4f2c6b31f152a7d786e76d8efda5fe27c056ff685a031ba8b772c9595e7d03ad"
}
@@ -61,24 +61,38 @@ tech-log-frontend : 31afb4d 이후
## 상세가 없으면 목록이 전부 실어야 한다
결정은 자기 화면을 갖지 않는다. 공개 라우트는 `/projects/{slug}/decisions` 하나이고, 개별 결정은 그 목록 위의 앵커로 간다.
결정은 자기 화면을 갖지 않는다. 공개 라우트는 프로젝트의 결정 목록 하나이고, 개별 결정은 그 목록 위의 앵커로 간다.
상세를 부를 곳이 없으므로 화면이 그리는 칸이 전부 목록 항목에 있어야 한다.
앞선 종류들과 여기서 갈린다. Case 와 Reference 와 Question 은 상세 화면이 있어서 목록 항목이 가볍고 상세를 부르면 나머지가 온다. 결정에는 상세를 부를 곳이 없으므로 화면이 그리는 칸이 전부 목록 항목에 있어야 한다.
## 네 증상이 한 원인이었다
`ProjectDecisionItem``title` 이 없어서 프론트가 `statement` 를 제목 칸에 썼다. 결정문은 한 문장이 아니라 문단일 수 있으므로 제목 칸에 전문이 들어갔다.
목록 항목에 `title`·`summary`·`consequences`·`evidence` 가 빠져 있었고, 화면 쪽이 없는 칸을 각자 메우고 있었다.
`summary` 가 없어서 요약 줄이 비었다. `consequences``evidence` 가 없어서 프론트가 그 둘을 빈 배열로 고정해 뒀다.
| 화면에서 무엇이 보였나 | 목록 항목에 무엇이 없었나 | 프론트가 무엇으로 메웠나 |
|---|---|---|
| 제목 자리에 결정문 전문 | `title` | `statement` 를 제목 자리에 씀 |
| 요약 줄이 비어 있음 | `summary` | 아무것도 그리지 않음 |
| 영향과 근거가 늘 비어 있음 | `consequences` · `evidence` | 빈 배열로 고정 |
줄바꿈은 다른 이유였다. 마크다운이 아닌 칸의 줄바꿈을 화면이 접고 있었다.
결정문은 한 문장이 아니라 문단일 수 있다. 제목 자리에 전문이 들어가면 목록의 한 줄이 문단 높이로 늘어난다.
## DB 에는 값이 다 있었다
줄바꿈은 원인이 달랐다. 마크다운이 아닌 칸의 줄바꿈을 화면이 접고 있었고, 그것은 목록 항목의 문제가 아니라 렌더링의 문제였다.
DB 를 조회하면 작성자가 쓴 제목과 여러 줄 요약과 영향 4건이 있었다. 어느 것도 화면까지 오지 못했다.
## 저장된 값은 그대로 있었다
데이터베이스를 조회하면 작성자가 쓴 제목과 여러 줄 요약과 영향 4건이 있었다. 어느 것도 화면까지 오지 못했다.
Studio 편집기에서는 그 값들이 다 보인다. 두 화면이 같은 데이터를 보는데 한쪽만 비었으므로, 다른 것은 그 사이에 놓인 계약이다.
## 화면 쪽에서 역으로 확인한다
이 부류는 응답에서 출발하면 보이지 않는다. 응답에 없는 칸을 찾는 일이기 때문이다.
화면이 그리는 칸을 먼저 적고 그 칸이 응답에 있는지 하나씩 맞춰야 한다. 상세 endpoint 가 없는 종류에서는 그 목록이 곧 문서 전체가 된다.
## 확인하지 못한 것
저장된 값은 조회로 확인했다. 그 시점의 화면 캡처는 남기지 않았다.
저장된 값이 그대로였다는 것은 조회로 확인했다. 그 시점의 화면 캡처는 남기지 않았다.
<!-- body:end -->
@@ -69,21 +69,45 @@ tech-log-backend : a5f93b9 이후
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
```
`as string` 이 붙어 있으므로 컴파일러는 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나오고 화면은 빈 문자열을 그린다.
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이므로, 컴파일러는 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나오고 화면은 빈 문자열을 그린다.
계약이 주는 이름은 `scopeSummary`, `appliesTo`, `excludedScope` 다.
| 게이트웨이가 읽던 이름 | 계약이 주는 이름 |
|---|---|
| `purposeSummary` | `scopeSummary` |
| `applyWhenMarkdown` | `appliesTo` |
| `exceptionsMarkdown` | `excludedScope` |
| `examplesMarkdown` | (해당 칸 없음) |
## 본문이 다른 테이블에 있었다
번째 원인은 저장 구조였다. Reference 의 본문은 문서 본문 칸이 아니라 `reference_detail` 의 규칙과 예시에 들어 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문을 비워 두기 때문이다.
번째 원인을 고쳐도 본문은 여전히 비어 있다. 두 번째 원인이 저장 구조에 있었다.
공개 조회는 문서 본문만 읽고 `content: ""` 를 내보냈다. 첫 번째 원인을 고쳐도 본문은 여전히 비어 있었다.
Reference 의 본문은 문서 본문 칸이 아니라 규칙과 예시를 담는 별도 테이블에 들어 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문을 비워 두기 때문이다. 공개 조회는 문서 본문만 읽고 빈 문자열을 내보냈다.
한 종류의 저장 구조가 다른 종류와 다르면 조회 쪽이 그것을 알아야 한다. 여기서는 몰랐다.
## 같은 신호가 여덟 번 더 있었다
계약에 칸이 없어 값이 화면에 오지 못한 것이 이 건 말고도 여덟 번 있었다.
| 무엇이 비었나 | 원인 |
|---|---|
| 문서 요약(제목 아래 한 줄) | 공개 응답에 `summary` 자리가 없어 유형별 요약을 대신 씀 |
| 프로젝트 「주요 주제」 | `project_topic` 테이블도 조인도 가능했는데 응답에 실을 칸이 없었다 |
| 프로젝트 기록 목록의 요약·주제·게시일 | `RelatedEntry` 를 그대로 실어 칸이 없었다 |
| 질문 목록의 주제 | 지식 목록은 처음부터 `primaryTopic` 을 실었는데 질문 목록만 빠짐 |
| 프로젝트·주제의 논지 | 담을 칸이 없어 `purpose` 를 대신 보여 줌 |
| 주제 목록의 논지·축 | 이름과 개수만 실어, 독자가 들어갈지 말지 정할 근거가 없었다 |
| 프로젝트 목록 행의 slug | 다른 목록이 프로젝트를 가리킬 때 쓰는 것은 id 가 아니라 slug 인데 행이 싣지 않았다 |
| 결정 목록 항목의 slug | 공개 주소가 앵커인데 항목에 slug 가 없어 화면이 앵커를 달 수 없었다 |
문서 요약은 증상이 조금 다르다. 빈 칸에 유형별 요약을 대신 넣었더니 머리말이 바로 아래와 같은 글을 두 번 말했다 — 비어 보이는 대신 겹쳐 보였다.
## 값이 아니라 이름을 지킨다
고친 뒤에 둔 테스트는 값을 비교하지 않는다. 게이트웨이가 읽는 이름이 계약의 타입에 있는지를 `satisfies` 로 묻는다. 계약에서 그 칸이 사라지면 컴파일이 먼저 멈춘다.
값을 비교하는 테스트로는 이 결함이 잡히지 않았을 것이다. 픽스처를 게이트웨이가 읽는 이름으로 만들면 값이 그대로 나오기 때문이다.
값을 비교하는 테스트로는 이 결함이 잡히지 않다. 픽스처를 게이트웨이가 읽는 이름으로 만들면 값이 그대로 나오기 때문이다 — 테스트 작성자와 게이트웨이 작성자가 이름에 대해 합의한 것을 확인할 뿐이다.
## 확인하지 못한 것
@@ -63,18 +63,22 @@ tech-log-backend : 92679f5 이후
## 세 번 버려졌다
관계 목록의 라벨을 고치고 화면을 봤을 때 요약은 여전히 비어 있었다. 값이 지나는 경계를 하나씩 따라가니 세 곳에서 버려지고 있었다.
```text
계약(요약 있음)
└─ flattenRelations 가 담지 않음 ← 1차로 고침
└─ 렌더 모델로 바꿀 때 버림 ← 담을 자체가 없었다
└─ 렌더 모델로 바꿀 때 버림 ← 담을 자리 자체가 없었다
└─ 화면 목록으로 넘길 때 또 버림
```
첫 번째를 고치고 화면을 봤을 때도 요약은 비어 있었다. 두 번째를 고치려고 보니 렌더 모델 계약에 담을 칸이 없었고, `additionalProperties: false` 라 계약을 고치지 않고는 실을 수 없었다.
첫 번째는 코드가 그 칸을 안 읽은 것이라 한 줄로 고쳤다. 두 번째에서 막혔다 — 렌더 모델 계약에 담을 칸이 없었고, 그 스키마가 `additionalProperties: false` 라 계약을 고치지 않고는 실을 수 없었다.
## 계약에 칸을 더할 때 required 를 따로 판단한다
`ResolvedRelation` `summary` 를 더하면서 required 에는 넣지 않았다. 이미 나가 있는 응답에는 그 칸이 없어서, required 로 올리면 배포 순서에 따라 검증이 깨진다.
렌더 모델`summary` 를 더했다. required 에는 넣지 않았다.
이미 나가 있는 응답에는 그 칸이 없다. required 로 올리면 계약을 반입한 쪽이 배포되기 전까지 그 응답이 검증에 걸린다. 배포 순서에 따라 깨지는 것과 값이 안 오는 것 중에서 뒤쪽을 골랐다.
## 한 칸에 셋이 뭉쳐 있었다
@@ -82,11 +86,19 @@ tech-log-backend : 92679f5 이후
| 무엇 | 뜻 | 경로별로 어떻게 나왔나 |
|---|---|---|
| 대상의 종류 | 「근거」「관련 기준」 같은 분류 | 렌더 모델 경로: 작성자의 문장이 이 에 눌려 나옴 |
| 대상의 종류 | 「근거」「관련 기준」 같은 분류 | 렌더 모델 경로: 작성자의 문장이 이 자리에 눌려 나옴 |
| 작성자가 쓴 이유 | 「다음에 무엇을 읽을지」의 답 | 공개 조회 경로: **아예 버려짐** |
| 대상의 요약 | 대상이 무엇인지 | — |
셋을 `label` · `note` · `summary` 로 갈랐다. 설명 칸에는 작성자가 쓴 문장이 있으면 그것을, 없으면 대상의 요약을 보인다. 요약은 대상을 설명하고 문장은 왜 지금 이것을 읽어야 하는지를 설명한다.
한 칸에 셋이 들어가니 경로마다 다른 것이 나왔다. 렌더 모델을 지나는 화면에서는 작성자가 쓴 문장이 분류 자리에 눌려 나왔고, 공개 조회를 지나는 화면에서는 그 문장이 아예 사라졌다.
셋을 `label`·`note`·`summary` 로 갈랐다. 설명 자리에는 문장이 있으면 문장을, 없으면 요약을 보인다. 요약은 대상이 무엇인지 말하고 문장은 왜 지금 그것을 읽어야 하는지 말하므로, 문장이 없을 때 요약으로 대신하는 것은 되지만 반대는 되지 않는다.
## 한 경계를 고치고 판단하면 안 된다
이 건에서 「고쳤다」고 두 번 판단했고 두 번 다 틀렸다. 값이 지나는 경계가 열한 개인데 그중 하나만 보고 판단했기 때문이다.
확인은 배포본에서, 그 값이 실제로 그려지는 곳에서 한다. 타입 검사와 단위 테스트와 「코드를 읽어 보니 맞다」는 전부 중간 지점이다.
## 확인하지 못한 것
@@ -35,6 +35,8 @@ source:
## 열한 번 모양이 바뀐다
공개 화면의 한 줄은 게시 시점에 굳어진 투영 테이블에서 출발해 열한 번 모양을 바꾼 뒤에 그려진다.
```text
PostgreSQL 테이블
└─ public_resource_projection (게시 시점에 굳어진 투영)
@@ -52,30 +54,37 @@ PostgreSQL 테이블
:::evidence key="value-boundaries" alt="저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 세 저장소 구역으로 나눠 이은 흐름도" caption=" " zoom="true"
:::
저장 쪽에 둘, 백엔드 조립에 넷, 전선에 하나, 프론트엔드 조립에 셋, 화면에 하나다.
저장 쪽에 둘, 백엔드 조립에 넷, 전선에 하나, 프론트엔드 조립에 셋, 화면에 하나다. 저장소 경계로 보면 백엔드가 여섯, 전선이 하나, 프론트엔드가 넷이다.
이 기간에 적은 결함의 절반 이상이 「이 중 한 경계가 값을 버렸다」는 같은 모양이었다.
## 경계마다 무엇이 값을 지키는가
## 어디서 검사가 끊기는가
같은 경계라도 무엇이 확인되는지가 다르다.
경계마다 무엇이 값을 지키는지가 다르다.
| 어디 | 무엇이 확인되나 | 무엇이 확인되지 않나 |
|---|---|---|
| 어댑터 SQL | 실행할 때 컬럼이 있는지 | 컴파일 시점에는 컬럼 이름을 아무도 안 본다 |
| 생성된 DTO | 계약의 스키마 모양 | 그 칸에 값이 담겼는지 |
| 게이트웨이 매퍼 | 계약이 준 타입의 이름 | `as` 단언을 쓰면 그 확인이 사라진다 |
| 포트와 화면 | 두 타입이 맞는지 | 포트와 어댑터가 타입을 따로 들면 한쪽만 늘어난다 |
JDBC 어댑터 SQL 은 컬럼 이름을 문자열로 적는다. 컬럼이 없거나 이름이 다르면 실행할 때 알게 된다.
어댑터 SQL 은 컬럼 이름을 문자열로 적는다. 이름이 틀리면 실행할 때 알게 되고, 그 SQL 을 실제로 돌리는 검사가 없으면 배포 뒤에 알게 된다.
계약이 만든 DTO 와 생성된 타입 사이는 생성기가 지킨다. 다만 생성기가 보는 것은 스키마의 모양이고, 그 칸에 값이 담기는지는 보지 않는다.
게이트웨이의 매퍼는 계약의 타입을 읽는다. `as` 단언을 쓰면 그 확인이 사라진다.
포트 타입과 화면 컴포넌트 사이는 TypeScript 가 지킨다. 포트와 어댑터가 타입을 따로 들고 있으면 그 확인도 사라진다.
## 값을 버려도 오류가 나지 않는다
이 경계들은 값을 담지 않아도 그렇다고 말하지 않 다음으로 넘긴다.
이 경계들은 값을 담지 않다고 말하지 않는다. 담지 않은 채 다음으로 넘긴다.
`undefined` 는 화면에서 빈 문자열이 된다. 빈 배열은 「항목이 없습니다」가 된다. 그래서 화면만 보면 값이 없는 것과 값을 잃은 것이 같아 보다.
`undefined` 는 화면에서 빈 문자열이 되고 빈 배열은 「항목이 없습니다」가 된다. 그래서 화면만 보면 값이 없는 것과 값을 잃은 것이 같아 보이고, 작성 도구에서는 작성자가 그것을 자기가 아직 쓰지 않은 것으로 읽는다.
## 두 화면이 같은 데이터를 볼 때
## 계약을 지나는 길이 둘이다
Studio 와 공개 화면은 같은 DB 를 보지만 계약이 다르다. Studio 는 작성 계약을, 공개 화면은 조회 계약을 지난다. 한쪽에만 값이 보이면 그 사이의 계약에 칸이 없다.
Studio 편집기와 공개 화면은 같은 데이터베이스를 보지만 다른 계약을 지난다. 작성 쪽은 작성 계약을, 조회 쪽은 조회 계약을 지난다.
두 화면이 같은 값을 두고 다르게 보이면 그 사이에 있는 것은 저장소가 아니라 계약이다. 이 저장소에서 그 신호가 여덟 번 같은 원인을 가리켰다.
## 이 문서의 결함 절반이 여기서 났다
이 기간에 적은 결함의 절반 이상이 「이 중 한 경계가 값을 버렸다」는 같은 모양이었다. 한 경계를 고치고 확인하면 다음 경계가 같은 값을 다시 버리는 일도 났다.
<!-- body:end -->
@@ -28,31 +28,40 @@ Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있으
## 목적
DB 에 값이 있는데 화면이 비어 있을 때, 어디를 먼저 볼지 정한다. 이 부류는 오류를 내지 않으므로 로그에서 출발하면 아무것도 나오지 않는다.
데이터베이스에 값이 있는데 화면이 비어 있을 때 어디를 먼저 볼지 정한다.
이 부류는 오류를 내지 않으므로 로그에서 출발하면 아무것도 나오지 않는다. `undefined` 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
## 규칙
**Studio 에서는 보이고 공개 쪽만 비면 그 사이의 계약을 먼저 본다**
두 화면이 같은 DB 를 보는데 한쪽만 비면, 다른 것은 그 사이에 놓인 계약이다.
두 화면이 같은 데이터베이스를 보는데 한쪽만 비면, 다른 것은 그 사이에 놓인 계약이다. 작성 쪽은 작성 계약을 지나고 조회 쪽은 조회 계약을 지난다. 이 저장소에서 같은 신호가 여덟 번 같은 원인을 가리켰다.
**화면이 그리는 칸을 먼저 적고 응답에 있는지 하나씩 맞춘다**
응답에서 출발하면 없는 칸은 보이지 않는다. 상세 endpoint 가 없는 종류에서 특히 그렇다 — 목록 항목이 문서 전체를 실어야 다.
응답에서 출발하면 없는 칸은 보이지 않는다. 상세 endpoint 가 없는 종류에서 특히 그렇다 — 부를 상세가 없으므로 목록 항목이 문서 전체를 실어야 하고, 그 목록에 없는 칸은 화면이 각자 메운다.
**한 종류의 저장 구조가 다른 종류와 다르면 조회 쪽이 그것을 알아야 한다**
Reference 의 본문은 문서 본문 칸이 아니라 규칙과 예시를 담는 별도 테이블에 있다. 이름이 맞아도 읽는 곳이 틀리면 빈 문자열이 나온다.
**칸을 더할 때 required 로 올릴지는 따로 판단한다**
이미 나가 있는 응답에는 그 칸이 없다. required 로 올리면 배포 순서에 따라 검증이 깨진다.
이미 나가 있는 응답에는 그 칸이 없다. required 로 올리면 계약을 반입한 쪽이 배포되기 전까지 그 응답이 검증에 걸린다. 배포 순서에 따라 깨지는 것과 값이 안 오는 것 중에서 고른다.
**값이 아니라 이름을 지키는 검사를 둔다**
게이트웨이가 읽는 이름이 계약의 타입에 있는지를 묻는다. 값을 비교하는 검사는 픽스처를 게이트웨이가 읽는 이름으로 만들면 그대로 통과한다.
게이트웨이가 읽는 이름이 계약의 타입에 있는지를 묻는다. 값을 비교하는 검사는 픽스처를 게이트웨이가 읽는 이름으로 만들면 그대로 통과한다 — 테스트 작성자와 게이트웨이 작성자가 이름에 대해 합의한 것을 확인할 뿐이다.
## 적용 조건
같은 데이터를 두 표면이 각자의 계약으로 읽고, 한쪽만 비어 보이는 화면. 작성 계약과 조회 계약이 나뉜 구조에서 걸린다.
계약에 칸을 더하거나 화면에 칸을 더하는 변경에서도 건다. 화면이 먼저 늘면 그 칸이 응답에 있는지 확인할 곳이 없다.
## 예외
두 표면이 같은 계약을 쓰면 이 신호는 성립하지 않는다. 그때는 매퍼나 질의를 먼저 본다.
저장 구조가 종류마다 다르면 계약이 아니라 조회가 원인일 수 있다. Reference 의 본문이 문서 본문 칸이 아니라 별도 테이블에 있던 것이 그 예다.
저장 구조가 종류마다 다르면 계약이 아니라 조회가 원인일 수 있다. 이름이 계약과 맞는데도 값이 비면 그쪽을 본다.
값이 있는데 겹쳐 보이는 경우는 이 신호가 아니다. 빈 칸을 다른 값으로 메우면 같은 글이 두 번 나온다.
## 예시
@@ -63,3 +72,5 @@ DB 에 값이 있는데 화면이 비어 있을 때, 어디를 먼저 볼지 정
질문 목록만 주제가 빠져 있어서 질문 줄의 맥락이 「· 프로젝트」로 시작했다. 지식 목록은 처음부터 그 칸을 싣고 있었다.
프로젝트 목록 행에 slug 가 없었다. 다른 목록이 프로젝트를 가리킬 때 쓰는 것은 id 가 아니라 slug 다.
문서 요약 자리에 유형별 요약을 대신 넣었더니 머리말이 바로 아래와 같은 글을 두 번 말했다.
@@ -29,31 +29,38 @@ source:
## 목적
「고쳤다」는 판단이 틀리는 것을 막는다. 값이 열한 경계를 지나는 구조에서 한 경계만 보고 판단하면, 다음 경계가 같은 값을 다시 버려도 알 수 없다.
「고쳤다」는 판단이 틀리는 것을 막는다.
값이 열한 경계를 지나는 구조에서 한 경계만 보고 판단하면, 다음 경계가 같은 값을 다시 버려도 알 수 없다. 이 저장소에서 그 판단이 세 번 틀렸다.
## 규칙
**고친 값이 실제로 그려지는 곳까지 가서 본다**
배포본에서 그 화면을 열거나, 실제 요청을 보내 응답을 읽는다.
배포본에서 그 화면을 열거나 실제 요청을 보내 응답을 읽는다. 관계 요약은 세 경계에서 연달아 버려졌고, 매번 화면을 보고 나서야 다음 경계가 버리는 것을 알았다.
**타입 검사 통과를 반영의 증거로 쓰지 않는다**
메서드 매개변수의 bivariance, `as` 단언, 검사 대상이 없는 tsconfig 가 각각 통과시킨 사례가 있다.
메서드 매개변수의 bivariance, `as` 단언, 검사 대상이 없는 tsconfig 가 각각 통과시킨 사례가 있다. 통과는 「코드가 맞다」가 아니라 「검사가 그 질문을 하지 않았다」를 뜻할 수 있다.
**게이트웨이를 실제로 불러 어떤 연산이 나가는지 확인한다**
등록을 빠뜨린 연산은 옆 분기로 떨어지므로 서버는 정상 응답을 준다. 나가는 경로를 봐야 알 수 있다.
**여정이 끝나는 곳을 먼저 정하고 시작한다**
어디까지 가면 확인이 끝나는지 모르면 중간에서 멈다.
어디까지 가면 확인이 끝나는지 모르면 중간에서 멈추게 된다. 공개 화면의 한 줄이면 그 줄이 그려지는 화면이 끝이다.
**검사가 덮는 구간을 적어 둔다**
값이 아니라 이름을 지키는 검사를 두면 여정의 한 구간을 그 검사가 대신한다. 그 구간이 어디까지인지 적어 두지 않으면 다음 사람이 검사를 여정 전체로 읽는다.
## 적용 조건
값이 계약·매퍼·포트를 여러 번 갈아타는 구조에서 「고쳤다」를 판단할 때. 계약을 소유한 저장소가 따로 있고 생성기를 지나 들어오면 특히 걸린다.
앞선 커밋이 같은 값을 고치려다 못 고친 이력이 있으면 반드시 건다. 그 커밋이 무엇을 근거로 고쳤다고 판단했는지가 대개 중간 지점이다.
## 예외
경계가 하나뿐이거나 고친 그 곳이 여정의 끝이면 중간 확인으로 충분하다.
값이 아니라 이름을 지키는 검사를 이미 뒀으면 그 검사가 여정의 한 구간을 대신한다. 다만 검사가 덮는 구간이 어디까지인지 적어 둔다.
배포본을 열 수 없는 변경 — 아직 배포되지 않은 경로 — 은 여정의 끝까지 갈 수 없다. 그때는 어디까지 확인했는지를 적는다.
## 예시
@@ -61,4 +68,6 @@ source:
개념 삭제가 계속 질문 삭제 경로로 나갔다. 타입 검사가 통과해서 반영된 줄 알았고, 배포된 번들의 서버 로그에서 404 를 보고 알았다.
CONCEPT 을 `deleteQuestion`로 되돌려 가드가 깨지는 것을 확인했다.
CONCEPT 을 질문 삭제로 되돌려 가드가 깨지는 것을 확인했다.
한 경계를 고치고 판단해 세 번 틀렸다. 값이 지나는 경계가 열한 개다.