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