docs(TechLog): SSOT 에 도메인과 비즈니스 규칙을 넣는다

이 문서는 결함 카탈로그로만 있었고 이 시스템이 무엇을 하는지가 없었다. 두 저장소의
도메인·유스케이스에서 확인해 §1.4 를 세웠다.

  종류 다섯이 각자 자기 애그리거트와 테이블을 갖는다 (ADR-003) — 개념은 문서 테이블에
  얹히고 concept_detail 에 기준 버전만 따로 둔다. §3 의 열세 건과 §14 의 외래키 없는
  축 표가 여기서 나온다
  화면 상태와 도메인 상태가 다르다 — 질문의 OPEN·INVESTIGATING·PAUSED 가 화면에서 하나로
  접히고 결정의 ACCEPTED 가 ADOPTED 로 보인다
  NextAction 판정 순서 — 서버가 조회 시점에 계산하고 저장하지 않는다. 프론트가 여러
  endpoint 를 조합해 workflow 를 재추론하지 않게 하려는 것이다
  검증·미리보기가 버려지지 않는 산출물인 이유와 그것이 유효한 조건 셋
  게시가 단계마다 다른 코드로 거절하는 이유 — 고칠 것과 다시 검증할 것이 다르다
  저장할 때와 공개할 때의 요구가 다르다 · 문서가 아닌 것은 다른 경로로 공개된다 ·
  주제는 참조가 있으면 안 지우고 축은 연결만 끊는다 · 없는 것을 가리키는 설정을 막는다

SSOT 62,643 → 71,870 자. 인용은 전부 저장소의 javadoc 과 코드에서 옮겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 19:35:11 +09:00
co-authored by Claude Opus 5
parent fd221353a3
commit 4769e52e48
+175
View File
@@ -114,6 +114,181 @@ import 합니다. 배포 단위는 `hyeonworks.com`(prod) 하나이고 서브도
---
## 1.4 이 저장소가 다루는 것 — 기록 하나가 공개되기까지
결함을 읽기 전에 이 시스템이 무엇을 하는지가 필요합니다. 여기서 다루는 것은 기술 기록이고,
기록 하나는 작성자가 쓰기 시작해서 공개 사이트에 뜨기까지 정해진 단계를 지납니다.
### 종류 다섯은 각자 자기 테이블을 갖는다
`RecordKind` 는 다섯입니다 — `CASE` · `REFERENCE` · `QUESTION` · `PROJECT_DECISION` ·
`CONCEPT`. 이것은 편집 화면이 고르는 유형이고 도메인 애그리거트가 아닙니다.
> Studio 편집 대상 유형. 계약(`studio-v1.yaml` `RecordKind`)의 discriminator이며 Domain
> Aggregate가 아니다 — ADR-003대로 각 유형은 각자 자기 Aggregate와 테이블을 그대로 소유한다.
>
> `CONCEPT`은 `document`의 세 번째 유형이다 — Case/Reference와 테이블을 공유하고
> `concept_detail`에 기준 버전만 따로 둔다.
그래서 「종류를 하나 더한다」가 테이블 하나를 더하는 일이 아닙니다. 개념은 문서 테이블에 얹히고
질문과 결정은 각자 다른 테이블에 있습니다. §3 의 열세 건과 §14 의 축 표에 외래키가 없는 것이
전부 이 구조에서 나옵니다.
### 화면 이름과 도메인 상태는 다른 값이다
계약이 화면에 주는 상태와 도메인이 들고 있는 상태가 같지 않습니다.
| 계약이 주는 값 | 도메인이 들고 있는 값 |
|---|---|
| `QuestionStatus` : OPEN · RESOLVED | OPEN · INVESTIGATING · PAUSED · RESOLVED |
| `DecisionStatus` : PROPOSED · ADOPTED | PROPOSED · ACCEPTED |
질문 상태 셋이 화면에서 하나로 접히고, 그 역방향 변환은 도메인 상태를 덮어쓰지 않습니다. 결정의
`ACCEPTED` 는 화면에서 `ADOPTED` 로 보입니다. §13.2 에서 종류 이름을 두 번 바꾸면서 계약의
`kind` 를 건드리지 않은 것과 같은 갈래입니다.
### 작성에서 공개까지 — 서버가 한 값으로 답한다
작성자가 다음에 무엇을 해야 하는지를 서버가 조회 시점에 계산해 한 값으로 줍니다.
```java
if (!isWorthValidating(document)) {
return NextAction.CONTINUE_EDITING;
}
if (!isValidationCurrent(validation, document, currentDependencyRevision, now)) {
return NextAction.VALIDATE;
}
if (validation.status() == ValidationStatus.INVALID) {
return NextAction.FIX_VALIDATION;
}
if (!isPreviewCurrent(preview, document, currentDependencyRevision, now)) {
return NextAction.CREATE_PREVIEW;
}
if (publication == null
|| publication.status() != PublicationAggregateStatus.PUBLISHED
|| publication.publishedVersion() != document.version()) {
return NextAction.PUBLISH;
}
return NextAction.NONE;
```
앞선 조건이 참이면 뒤는 보지 않습니다. 이 값을 저장하지 않고 조회할 때마다 계산하는 이유가
클래스 javadoc 에 있습니다:
> 서버가 조회 시점에 계산하는 Studio projection이며 어떤 domain 컬럼에도 저장하지 않는다.
>
> 프론트가 여러 endpoint를 조합해 workflow 상태를 재추론하지 않도록 서버가 단일 값으로 답한다.
제목이 비어 있으면 어떤 유형이든 검증을 돌릴 의미가 없다고 보고 거기서 끊습니다. 나머지 필수값
판정은 검증 자신의 일이고, 여기서 흉내 내면 두 곳이 서로 다른 답을 낼 수 있기 때문입니다.
### 검증과 미리보기는 버려지지 않는 산출물이다
검증은 요청 값을 보는 일이 아니라 계약이 정한 체인 전체를 돌리고 그 결과를 일급 산출물로
남깁니다. 남기는 이유는 미리보기와 게시가 `validationId` 로 「무엇을 근거로 통과했는지」를
가리키기 때문입니다.
그 산출물이 아직 유효한지는 세 가지를 다 만족해야 합니다.
| 조건 | 무엇을 확인하나 |
|---|---|
| `validatedVersion == document.version` | 검증한 뒤에 문서가 바뀌지 않았는가 |
| `dependencyRevision` 일치 | 그 문서가 참조하는 것들이 그대로인가 |
| `now < validUntil` | 검증이 만료되지 않았는가 |
`dependencyRevision` 은 산출물에 박제된 값이고, 지금 계산한 값과 다르면 그 산출물은 전제가 바뀐
것이라 더는 유효하지 않습니다.
미리보기는 공개 화면과 **같은** 렌더러와 Asset 리졸버를 씁니다(ADR-005). 그래야 작성자가 확인한
화면이 공개될 화면과 같습니다.
### 게시는 단계마다 다른 코드로 거절한다
> 단계별 실패가 서로 다른 계약 코드로 나가는 것이 이 use case 의 핵심이다.
> `DOCUMENT_VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가 바뀜)은
> 다른 사건이고, 작성자가 해야 할 일도 다르다 — 전자는 고치는 것이고 후자는 다시 검증하는 것이다.
미리보기도 같습니다 — `PREVIEW_EXPIRED`(만료됨)와 `PREVIEW_STALE`(이 버전을 설명하지 않음)이
갈립니다. §13.4 에서 화면이 세 가지를 추측한 것이 이 설계와 정반대인 자리입니다.
경고를 안고 게시하려면 작성자가 그 경고 코드를 전부 확인했다고 보내야 합니다.
> 계약: `acknowledgedWarningCodes` 가 현재 Validation 의 WARNING 집합을 모두 덮지 못하면
> 거절한다. 작성자가 보지 못한 경고를 안고 공개되는 일을 막는 장치다.
그리고 게시 시점에 다시 렌더링하지 않습니다. 사용자가 확인한 미리보기의 렌더 모델을 그대로
씁니다 — 다시 렌더링하면 승인한 것과 다른 것이 공개될 수 있기 때문입니다.
공개 취소는 스냅숏을 지우지 않습니다. 과거에 무엇이 공개됐는지는 지우지 않습니다.
### 저장할 때와 공개할 때의 요구가 다르다
저장은 초안을 비워 둔 채로도 허용합니다. 아니면 한 번에 다 쓰지 않으면 저장을 못 하기
때문입니다. 공개는 그럴 수 없습니다.
관계 줄도 같습니다. 대상을 아직 고르지 않은 관계 줄도 저장할 수 있고, 게시 가능한지는 검증이
판단합니다.
릴리스는 이 갈림이 특히 분명합니다. `workflow_status = 'PUBLISHED'` 가 공개 조회의 유일한
조건이므로 그 전이가 곧 공개이고, 그래서 발행 전에 계약이 필수로 선언한 것들이 실제로 채워져
있는지 그 시점에 확인합니다.
### 문서가 아닌 것들은 다른 경로로 공개된다
프로젝트와 릴리스는 `RecordKind` 에 없습니다. 본문도 검증 대상도 없으므로 문서 게시
파이프라인을 타지 않고 각자 자기 경로로 공개 상태를 세웁니다.
프로젝트는 slug 가 없으면 게시를 거절합니다.
> 공개 주소가 `/projects/<slug>` 이므로 slug 없이 게시하면 아무도 닿을 수 없는 페이지가 생긴다 —
> 저장은 성공했는데 링크는 없는, 이유를 알 수 없는 상태다.
§4.1 에서 프로젝트가 영원히 비공개였던 것이 이 경로가 없었기 때문입니다.
### 참조가 있으면 지우지 않는다
주제를 지울 때 그 주제를 쓰는 기록이 있으면 거절합니다. 외래키를 CASCADE 로 두지 않은 이유가
있습니다.
> 주제를 지웠다는 이유로 그 주제를 쓰던 문서의 분류가 조용히 사라지면 안 되기 때문이다 —
> 지우려면 먼저 그 문서들을 옮기라는 뜻이다.
축은 반대입니다. 축을 지우면 걸려 있던 기록은 지우지 않고 연결만 끊고, 그 기록은 주제의 공통
기록이 됩니다. 축이 분류가 아니라 비교 축이기 때문이고, 축 slug 제약이 `(topic_id, slug)` 쌍이라
여러 주제가 「공통」 같은 같은 이름을 각자 가질 수 있습니다.
### 없는 것을 가리키는 설정을 막는다
홈이 무엇을 앞에 세울지 정하는 설정은 지목한 대상에 외래키를 걸지 않습니다. 설정이 대상보다
오래 살아남는 것을 허용하는 설계입니다. 대신 저장할 때 그 대상이 실제로 있는지 확인합니다.
> 없는 id 를 그대로 저장할 수 있고, 그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은
> 성공했는데 화면에는 아무것도 안 나오는, 이유를 알 수 없는 실패가 된다.
대상이 *공개*인지는 확인하지 않습니다. 아직 게시하지 않은 프로젝트를 미리 지목해 두고 게시와
동시에 홈에 뜨게 하는 것이 정상적인 순서이고, 공개 여부는 공개 조회 쪽이 매번 다시 판단합니다.
§10.1 의 「이 프로젝트에 열린 질문이 없습니다」가 이 설계와 같은 갈래의 반대 사례입니다 —
저장은 성공했는데 화면이 아무것도 말하지 않는 상태를 여기서는 막고 저기서는 못 막았습니다.
### 서버가 판정한 것을 클라이언트가 못 바꾼다
Asset 상태 넷 중 둘은 서버 검증 결과라 클라이언트가 지정할 수 없습니다 — `REJECTED`
`QUARANTINED` 입니다. `READY` 가 아닌 Asset 은 게시할 때 거절됩니다.
### 읽는 것에도 권한이 필요하다
Studio 표면은 조회에도 권한을 요구합니다.
> 읽기에도 권한이 필요한 이유: Studio 가 읽는 것은 게시 전 초안이다. 인증만 통과하면 누구나
> `listStudioDocuments` 로 남의 초안 목록을, `getStudioDocument` 로 그 본문을 볼 수 있어서는
> 안 된다.
역할 이름은 코드에 박지 않고 배포가 자기 IdP 의 역할을 권한 토큰에 잇습니다. 역할 이름이
배포마다 다른 값이기 때문이고, 계약도 역할 이름을 고정하지 않습니다.
---
## 2. 결함을 어떻게 갈랐나
198개 커밋을 읽고 나서, 결함이 **원인의 종류**로 갈린다는 것이 보였습니다. 화면 증상으로 나누면