refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일

This commit is contained in:
DongHyeonka
2026-09-04 18:56:01 +09:00
parent 4b9e7148b5
commit 43bccd08a8
121 changed files with 2861 additions and 534 deletions
@@ -0,0 +1,25 @@
결정 링크가 열리는지 — 고친 뒤의 실제 응답
출처: https://hyeonworks.com — 2026-09-04 재조회
재현: 아래 curl 을 그대로 실행하면 같은 값이 나온다.
=== [1] Reference 상세의 관계 목록 — 두 번째 항목이 404 였던 그 링크 ===
$ curl -s https://hyeonworks.com/api/v1/public/references/external-idp-federation-application-boundary \
| python3 -c "…relations 의 group | path 출력"
supportingCases /cases/spa-browser-credential-boundary
relatedDecisions /projects/keycloak-patterns/decisions#federation-is-not-an-application-pattern
relatedReferences /references/authorization-code-endpoint-credential-movement
→ relatedDecisions 의 주소가 /decisions#... 앵커다.
고치기 전에는 /projects/keycloak-patterns/decisions/federation-is-not-an-application-pattern
이었고 그런 라우트가 없어 404 였다.
=== [2] 그 주소가 실제로 200 인가 ===
200 /projects/keycloak-patterns/decisions
200 /references/external-idp-federation-application-boundary
=== [3] 결정 목록 항목이 앵커용 slug 를 싣는가 (계약에 더한 칸) ===
slug=bff-owns-token-when-browser-must-not title=BFF가 OAuth Token을 관리하는 조건
slug=federation-is-not-an-application-pattern title=외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
→ 계약 1aae8dc 에서 ProjectDecisionItem 에 slug 를 더했다. 이 값이 없으면 화면이
앵커를 달 수 없어(id 는 UUID) 다른 기록이 건 링크가 갈 곳을 잃는다.
@@ -0,0 +1,49 @@
죽은 링크 전수 감사 — 서버가 내보내는 모든 주소
출처: https://hyeonworks.com — 2026-09-04 실행
스크립트: evidence/audit/link-audit.py (재실행하면 같은 방식으로 다시 검사한다)
이 감사가 필요한 이유: 주소는 서버가 게시할 때 만들어 DB 에 저장한 문자열이라 화면
코드 어디에도 흔적이 없다. 저장소 안의 to=/href= 리터럴만 훑는 감사로는 잡히지
않는다 — 실제로 결정 링크가 그렇게 숨어 있었다(§9.2).
$ python3 evidence/audit/link-audit.py
======================================================================
200 /cases/bff-session-csrf-responsibility
200 /cases/collection-fetch-join-in-memory-paging
200 /cases/eager-toone-nplus1-without-access
200 /cases/fetch-join-multibag-and-row-explosion
200 /cases/identity-header-trust
200 /cases/spa-browser-credential-boundary
200 /cases/split-custody-access-token
200 /concepts/idp-brokering
200 /projects/backend-clean-architecture
200 /projects/keycloak-patterns
200 /projects/keycloak-patterns/decisions#bff-owns-token-when-browser-must-not
200 /projects/liner-n-plus-1
200 /questions/bff-session-authorized-client-store
200 /questions/edge-authorization-scope
200 /questions/refresh-rotation-replica-contention
200 /questions/server-session-pattern-multi-instance
200 /references/authorization-code-endpoint-credential-movement
200 /references/bff-authentication-design-criteria
200 /references/external-idp-federation-application-boundary
200 /references/forward-auth-identity-header-trust
200 /references/oauth-oidc-pattern-selection-criteria
200 /references/oauth-token-application-session-boundary
200 /references/public-confidential-client-boundary
200 /releases/0.1.0
200 /releases/0.2.0
200 /releases/0.3.0
200 /topics/jpa-feed-query-performance
200 /topics/jpa-feed-query-performance/derived-query
200 /topics/jpa-feed-query-performance/fetch-join
200 /topics/jpa-feed-query-performance/fetch-join-paging
200 /topics/oauth-oidc-auth-boundary
200 /topics/oauth-oidc-auth-boundary/bff
200 /topics/oauth-oidc-auth-boundary/forward-auth
200 /topics/oauth-oidc-auth-boundary/mediator
200 /topics/oauth-oidc-auth-boundary/spa
검사한 주소 35개
죽은 링크 없음
종료코드: 0
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""서버가 내보내는 모든 공개 주소를 모아 각각에 GET 을 보낸다.
주소는 게시 시점에 서버가 만들어 DB(public_resource_projection.navigation_path)에
저장한 문자열이다. 그래서 저장소 안의 `to=` / `href=` 리터럴만 훑는 감사로는 잡히지
않는다 — 실제로 결정 링크가 그렇게 숨어 있었다.
사용법: python3 link-audit.py [BASE] (기본 https://hyeonworks.com)
"""
import json, re, sys, urllib.request
from collections import defaultdict
BASE = sys.argv[1] if len(sys.argv) > 1 else "https://hyeonworks.com"
def get(path):
try:
with urllib.request.urlopen(BASE + path, timeout=20) as r:
return json.loads(r.read())
except Exception as e:
return {"__error__": str(e)}
def status(path):
# 앵커와 질의 문자열은 라우트를 고르지 않는다 — 떼고 확인한다.
target = path.split("#")[0].split("?")[0]
try:
with urllib.request.urlopen(BASE + target, timeout=20) as r:
return r.status
except Exception as e:
return getattr(e, "code", "ERR")
paths = defaultdict(set) # path -> 어디서 나왔나
def collect(node, origin):
if isinstance(node, dict):
for key in ("path", "canonicalPath", "projectPath"):
value = node.get(key)
if isinstance(value, str) and value.startswith("/"):
paths[value].add(origin)
for value in node.values():
collect(value, origin)
elif isinstance(node, list):
for value in node:
collect(value, origin)
# 1) 목록에서 시작한다
for seed in ("/api/v1/public/home", "/api/v1/public/topics", "/api/v1/public/projects",
"/api/v1/public/knowledge", "/api/v1/public/questions", "/api/v1/public/releases"):
collect(get(seed), seed)
# 2) 문서 상세를 전부 돈다 — 관계는 상세에만 있다
SEGMENTS = ("cases", "references", "questions", "concepts")
for path in [p for p in list(paths) if re.match(rf"^/({'|'.join(SEGMENTS)})/[^/]+$", p)]:
segment, slug = path.strip("/").split("/", 1)
collect(get(f"/api/v1/public/{segment}/{slug}"), path)
# 3) 프로젝트의 하위 목록
for path in [p for p in list(paths) if re.match(r"^/projects/[^/]+$", p)]:
slug = path.rsplit("/", 1)[-1]
for sub in ("", "/decisions", "/records", "/activity"):
collect(get(f"/api/v1/public/projects/{slug}{sub}"), path + sub)
# 4) 주제 허브와 축 — 목록 응답에 path 가 없어 위 크롤이 닿지 않는다
for topic in (get("/api/v1/public/topics").get("data") or {}).get("items", []):
paths[f"/topics/{topic['slug']}"].add("listPublicTopics")
detail = (get(f"/api/v1/public/topics/{topic['slug']}").get("data") or {})
for variant in (detail.get("variants") or []):
if variant.get("path"):
paths[variant["path"]].add(f"/topics/{topic['slug']}")
bad = []
for path in sorted(paths):
code = status(path)
print(f" {code} {path}")
if code != 200:
bad.append((code, path, sorted(paths[path])[:2]))
print(f"\n검사한 주소 {len(paths)}")
if not bad:
print("죽은 링크 없음")
else:
for code, path, origin in bad:
print(f" DEAD {code} {path}{origin}")
sys.exit(1 if bad else 0)
@@ -0,0 +1,26 @@
결정(PROJECT_DECISION)의 공개 주소 — V15 마이그레이션 적용 후
출처: hyeonworks-prod / postgres-0 / appdb — 2026-09-04 조회
배경: 게시 시점에 만든 주소가 /projects/{slug}/decisions/{slug} 였는데 그런 라우트가
없어 다른 기록이 건 링크가 404 였다. 계약은 이미 #{slug} 앵커라고 적어 두었다.
주소는 게시 시점에 굳어져 저장되므로 코드만 고치면 기존 행은 깨진 채 남는다 —
그래서 V15 가 이미 게시된 행도 함께 고쳤다.
V15 의 UPDATE:
UPDATE public_resource_projection
SET navigation_path = regexp_replace(navigation_path,
'^(/projects/[^/]+/decisions)/', '\1#')
WHERE resource_type = 'PROJECT_DECISION'
AND navigation_path ~ '^/projects/[^/]+/decisions/';
----------------------------------------------------------------------
[현재] public_resource_projection 의 PROJECT_DECISION 행
/projects/keycloak-patterns/decisions#bff-owns-token-when-browser-must-not ← BFF가 OAuth Token을 관리하는 조건
/projects/keycloak-patterns/decisions#federation-is-not-an-application-pattern ← 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
[현재] publication.public_path
/projects/keycloak-patterns/decisions#bff-owns-token-when-browser-must-not
/projects/keycloak-patterns/decisions#federation-is-not-an-application-pattern
[적용된 마이그레이션] flyway_schema_history 의 V14/V15
14 | techlog topic variant | true
15 | techlog decision anchor path | true
@@ -0,0 +1,64 @@
작업본 삭제를 막는 참조 — 실제 사례와 그 해소
출처: hyeonworks-prod / postgres-0 / appdb
진단 시각: 2026-09-04 (아래 [1]~[4])
사후 확인: 2026-09-04, 사용자가 조치한 뒤 (아래 [5])
증상
「DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1」을 지우려는데, 관계를 다 지웠는데도
삭제가 안 된다.
삭제 가드가 보는 다섯 테이블 (DeleteDocumentDraftUseCase → documentReferenced)
SELECT 1 FROM document_relation WHERE target_document_id = :id
UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id
UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id ← 여기서 걸림
UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id
UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
다섯 이유가 전부 같은 한 문장으로 나온다:
"another record still links to this one; unlink it first"
「another record」라고 하니 관계를 찾아 지우게 되는데, 정작 막는 것은 record 가 아니라
프로젝트 연결이다. 그리고 프로젝트 연결은 「관계」 편집기가 아니라 문서의 Project 필드다.
======================================================================
[1] 대상 문서 (진단 시점 캡처)
58c2d5d9-5865-4b2c-b1cc-3c5c955c33dc | CASE | collection-nplus1-dto-mapping
| DRAFT | PRIVATE | DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
→ workflow_status=DRAFT, target_visibility=PRIVATE 이고 publication /
public_resource_projection / public_route 에 행이 없다. 즉 게시 가드는 통과한다.
[2] 이 문서 id 를 참조하는 행 — uuid 컬럼 전수 스캔 (진단 시점 캡처)
NOTICE: project_document_link . document_id => 1 행
NOTICE: document . id => 1 행
NOTICE: case_detail . document_id => 1 행
→ 셋뿐이다. document(자신), case_detail(본문), 그리고 project_document_link 1행.
[3] 막고 있는 그 행의 정체 (진단 시점 캡처)
relation_type=PRIMARY | featured_order=- | project=Liner N + 1문제
→ relation_type 이 PRIMARY 이므로 ProjectLinkStore.setPrimary() 의 DELETE 대상이다.
즉 Studio 에서 Project 를 「미지정」으로 바꿔 저장하면 이 행이 지워진다.
(setPrimary 는 relation_type='PRIMARY' 인 행만 지운다. 만약 이 행이 RELATED 였다면
화면에서 풀 방법이 없어 진짜로 막혔을 것이다 — 그 경우는 아니었다.)
[4] document_relation 은 실제로 비어 있었다 (진단 시점 캡처)
document_relation 참조 행 수: 0
→ 사용자가 관계를 다 지운 것이 맞다. 관계는 문제가 아니었다.
======================================================================
[5] 사후 확인 — 사용자가 Project 를 미지정으로 바꾸고 삭제한 뒤
document 행: 0
project_document_link 행: 0
case_detail 행: 0
→ 삭제가 통과했다. 진단이 맞았다는 것이 이것으로 확인된다 — 프로젝트 연결을 푸는 것이
막힘의 해소였다.
주의: 그래서 이 결함은 이제 실시간으로 재현할 수 없다. [1]~[4] 는 진단 당시의 캡처이고
[5] 만 지금 다시 조회한 값이다.
@@ -0,0 +1,34 @@
축에 실제로 걸린 기록 (record_variant)
출처: hyeonworks-prod / postgres-0 / appdb — 2026-09-04 조회
쿼리: topic_variant JOIN record_variant, 제목은 public_resource_projection 에서
칸: variant.slug | record_kind | 기록 제목
메모: record_variant 는 외래키가 없다 — 기록이 종류마다 다른 테이블에 살아
(kind, id) 쌍으로 가리키기 때문이다(document / open_question / project_decision).
----------------------------------------------------------------------
bff | CASE | Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정
bff | QUESTION | 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
bff | QUESTION | BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
bff | QUESTION | Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
bff | REFERENCE | BFF 인증 구조 설계 기준
derived-query | CASE | Fetch 타입이 아닌 조회 방식으로 인한 N+1
fetch-join | CASE | Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
fetch-join-paging | CASE | Collection Fetch Join Pagination의 In-memory Paging
forward-auth | CASE | Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
forward-auth | QUESTION | Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
forward-auth | REFERENCE | Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
mediator | CASE | Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
mediator | QUESTION | 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
mediator | QUESTION | Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
mediator | REFERENCE | Authorization Code Flow의 Endpoint와 Credential 이동 기준
mediator | REFERENCE | Public Client와 Confidential Client 구분 기준
spa | CASE | SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
spa | REFERENCE | Authorization Code Flow의 Endpoint와 Credential 이동 기준
spa | REFERENCE | 외부 IdP 연동과 Application 인증 구조의 경계
spa | REFERENCE | Public Client와 Confidential Client 구분 기준
=== 어느 축에도 걸리지 않은 기록 (= 그 주제의 공통 기록) ===
oauth-oidc-auth-boundary | CONCEPT | 외부 IdP Brokering의 동작
oauth-oidc-auth-boundary | PROJECT_DECISION | BFF가 OAuth Token을 관리하는 조건
oauth-oidc-auth-boundary | PROJECT_DECISION | 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
oauth-oidc-auth-boundary | REFERENCE | OAuth Token과 Application Session을 구분하는 기준
oauth-oidc-auth-boundary | REFERENCE | OAuth/OIDC 인증 패턴 선택 기준
@@ -0,0 +1,12 @@
주제와 축(topic / topic_variant)의 실제 행
출처: hyeonworks-prod / postgres-0 / appdb — 2026-09-04 조회
쿼리: topic LEFT JOIN topic_variant, display_order 순
칸: topic.slug | topic.variant_label | display_order | variant.slug | variant.title
----------------------------------------------------------------------
jpa-feed-query-performance | 조회 전략 | 1 | derived-query | 파생 쿼리 그대로
jpa-feed-query-performance | 조회 전략 | 2 | fetch-join | 컬렉션 fetch join
jpa-feed-query-performance | 조회 전략 | 3 | fetch-join-paging | fetch join + 페이징
oauth-oidc-auth-boundary | 구조 | 1 | spa | SPA
oauth-oidc-auth-boundary | 구조 | 2 | mediator | Mediator
oauth-oidc-auth-boundary | 구조 | 3 | bff | BFF
oauth-oidc-auth-boundary | 구조 | 4 | forward-auth | Forward-Auth
@@ -0,0 +1,66 @@
가드가 실제로 잡는지 — 결함을 되돌려 확인
출처: tech-log-frontend @ 2b2f443 — 2026-09-04 실행
방법: 각 가드에 대해 결함을 되돌리고(sed), 테스트를 돌리고, git checkout 으로 원복한다.
가드는 넣는 것보다 「정말 잡는가」가 중요하다. 넣기만 하고 확인하지 않으면 늘 통과하는
테스트가 하나 늘 뿐이다.
======================================================================
### [A] public-path-reachability — 라우트 없는 주소를 링크로 그리지 않는가
정상:
✓ tests/features/tech-log/public-path-reachability.test.ts (18 tests) 12ms
Tests 18 passed (18)
결함 되돌림: resolvesToPublicRoute 가 '/' 로 시작하면 무조건 true 를 주게 한다
(= 가드가 없던 상태)
tests/features/tech-log/public-path-reachability.test.ts (18 tests | 3 failed) 14ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected true to be false // Object.is equality
tests/features/tech-log/public-path-reachability.test.ts:38:7
AssertionError: expected true to be false // Object.is equality
tests/features/tech-log/public-path-reachability.test.ts:44:43
AssertionError: expected true to be false // Object.is equality
tests/features/tech-log/public-path-reachability.test.ts:49:59
원복 완료: 0 개 변경 남음
### [B] section-heading-rank — 나란히 서는 구역 제목의 급이 갈리는가
배경: 「구조별로 알게 된 것」만 26px·굵기 400 으로 나왔다. 규칙이 없었던 게 아니라
절반만 있었다 — 그 구역들이 크기만 각자 적어 두어 굵기를 아무도 정하지 않았고,
기본값 400 으로 떨어졌다.
정본: .section-heading-row h2 (30px / 650)
공유 규칙(globals.css:2482):
.document-relations h2,
.topic-variants h2 { margin: 0 0 20px; font-size: 30px; font-weight: 650; letter-spacing: -0.045em; }
정상:
✓ tests/features/tech-log/section-heading-rank.test.ts (1 test) 5ms
Tests 1 passed (1)
결함 되돌림: 그 공유 규칙에서 font-weight: 650 만 뺀다 (= 굵기를 아무도 정하지 않는 상태)
.document-relations h2,
.topic-variants h2 { margin: 0 0 20px; font-size: 30px; letter-spacing: -0.045em; }
tests/features/tech-log/section-heading-rank.test.ts (1 test | 1 failed) 10ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: .document-relations h2 의 font-weight 가 .section-heading-row h2 과 다르다
tests/features/tech-log/section-heading-rank.test.ts:47:14
Tests 1 failed (1)
원복: 남은 변경 0 개
======================================================================
결론
[A] public-path-reachability 결함 되돌림 → 3건 실패 ✓
[B] section-heading-rank 결함 되돌림 → 1건 실패 ✓
[C] route-chunk-names 결함 되돌림 → 1건 실패 ✓
셋 다 되돌리면 실제로 빨개진다. 모두 git checkout 으로 원복했고 작업 트리에 변경은
남지 않았다.
메모: [C] 는 처음에 잘못된 문자열(TECH_LOG_STUDIO_TOPIC_EDIT)을 지워 통과했다. vite 설정의
표는 라우트 id 가 아니라 「화면 파일 경로 → chunk 이름」이라 그 이름이 없다. 올바른 줄
("/studio/pages/topic-edit-page.tsx")을 지우자 잡혔다. 검증을 검증해야 하는 이유다 —
"되돌렸는데 통과했다"를 "가드가 없다"로 읽을 뻔했다.
@@ -0,0 +1,72 @@
「손으로 나열한 목록」이 표로 바뀌었나 — 현재 코드
출처: tech-log-frontend @ 2b2f443 / tech-log-backend @ 8cd8ee3
조회: 2026-09-04
§3 의 주장: 종류를 나열하는 자리를 Record<Kind,_> 나 sealed switch 식으로 바꿔
새 종류가 늘면 컴파일러가 빈 자리를 잡게 했다.
======================================================================
### 프론트 — Record<Kind, _> 로 바뀐 자리
src/features/tech-log/presentation/public/components/explore-filter-form.tsx:12: 목록을 손으로 적지 않고 `Record<RecordKind, …>` 에서 뽑는다. 종류가 늘면 이 표가 비어 있는
src/features/tech-log/presentation/public/components/explore-filter-form.tsx:16:const EXPLORE_KIND_ORDER: Record<RecordKind, number> = {
src/features/tech-log/presentation/shared/document-kind-labels.ts:18: * 타입이 잡는다 — `Record<RecordKind, string>` 이므로 빠진 종류가 있으면 컴파일되지 않는다.
src/features/tech-log/presentation/shared/document-kind-labels.ts:20:export const DOCUMENT_KIND_LABELS: Record<RecordKind, string> = {
src/features/tech-log/presentation/shared/document-kind-labels.ts:35:export const DOCUMENT_KIND_STATES: Record<RecordKind, "CONFIRMED" | "SETTLED" | "OPEN"> = {
src/features/tech-log/presentation/shared/document-kind-labels.ts:53:export const EXPLORE_KIND_PATHS: Record<RecordKind, string> = {
src/features/tech-log/presentation/studio/components/document-list.tsx:15: 작업본 목록이 거를 수 있는 종류. 손으로 나열하지 않고 `Record<RecordKind, …>` 에서 뽑는다 —
src/features/tech-log/presentation/studio/components/document-list.tsx:18:const STUDIO_KIND_ORDER: Record<RecordKind, number> = {
src/features/tech-log/adapters/mock/validate-working-copy.ts:58: const BRANCH_FIELDS: Record<RecordKind, string[]> = {
src/features/tech-log/adapters/http/http-management-gateway.ts:153: 아래 표가 `Record<DeletableDocumentKind, …>` 인 것이 실제로 종류를 강제하는 지점이다.
src/features/tech-log/adapters/http/http-management-gateway.ts:155: const OPERATIONS: Record<DeletableDocumentKind, string> = {
src/features/tech-log/adapters/http/http-public-content-gateway.ts:186: const OPERATIONS: Record<RecordKind, string> = {
### 프론트 — 남아 있는 종류 삼항 사슬이 있나 (있으면 여기 나온다)
src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx:157: renderModel.kind === "CASE" ? renderModel.bodyBlocks : [],
src/features/tech-log/adapters/mock/validate-working-copy.ts:71: const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "CONCEPT" ? ["bodyMarkdown", "basisVersion"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : []), ...(kind === "PROJECT_DECISION" ? ["statement", "rationale"] : [])];
### 백엔드 — sealed switch 를 식으로 쓴 자리 (컴파일러가 빠진 가지를 요구한다)
../tech-log-backend/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java:25: return switch (kind) {
../tech-log-backend/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java-26- case CASE -> "/cases/" + slug;
../tech-log-backend/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java-27- case REFERENCE -> "/references/" + slug;
### 백엔드 — PublicSql.pathOf 에 CONCEPT 이 들어갔나 (13번째 사례)
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;
};
}
======================================================================
정직한 평가 — 이 증거가 드러내는 남은 구멍 둘
[1] PublicSql.pathOf 는 여전히 컴파일러가 강제하지 못한다
switch 의 대상이 sealed enum(RecordKind)이 아니라 String(resourceType)이다.
그래서 `default -> null` 이 남아 있고, 새 종류를 더할 때 이 자리를 빠뜨리면
컴파일은 통과하고 경로가 null 로 나간다 — §3 이 경고하는 바로 그 모양이다.
같은 파일의 PublicPaths.forKind 는 RecordKind 로 switch 하므로 강제된다.
둘의 차이는 pathOf 가 공개 투영의 resource_type 을 다루기 때문이다. 그 칸은
RecordKind 에 없는 값(PROJECT, RELEASE)도 담는다 — 그래서 String 이다.
지금은 PublicPathsTest 가 RecordKind 전수를 돌며 막고 있지만, pathOf 만 쓰는
경로(홈 focus 의 recentDecision)는 그 테스트가 닿지 않는다.
[2] validate-working-copy.ts 의 stringFields 는 아직 삼항 사슬이다
다만 모양이 다르다 — 배타적 사슬이 아니라 「종류마다 칸을 더한다」는 가산형이다.
종류를 빠뜨리면 "잘못된 분기로 떨어진다"가 아니라 "그 종류의 추가 칸을 검사하지
않는다"가 된다. 덜 위험하지만 조용하기는 마찬가지다.
같은 파일의 BRANCH_FIELDS 는 Record<RecordKind, string[]> 로 바뀌어 있다(58줄).
그쪽이 실제로 종류를 강제하는 자리다.
즉 §3 의 「표로 바꿨다」는 대부분 사실이지만 전부는 아니다. 위 둘은 남아 있다.