docs(TechLog): 글감 56개를 기록으로 쓴다
주제 13개 · Case 28 · Concept 5 · Reference 15 · Question 4 · Decision 4. 계약의 노드마다 종류가 요구하는 칸을 채우고, 본문이 있는 두 종류에는 SSOT 가 이미 그려 둔 도식 셋(value-boundaries · decision-path-404 · topic-variant-model)을 tech-log-studio/ 로 옮겨 붙였다. 새로 그린 그림은 없다. 검사 셋 전부 통과한다. check_body.mjs 56 편 중 본문이 있는 33 편 PASS check_prose.mjs 56 편 error 0 check_evidence.mjs --repo 포함 문제 없음 verify-tech-log-tree.py 프로젝트 5 · error 0 · warn 0 인용한 코드블록은 전부 SSOT 에서 찾아 대조했다. check_evidence.mjs 가 본문의 각 줄과 source 앵커와 계약 제목을 다시 확인한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6955611439
commit
f6c825e858
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1330" height="542" viewBox="0 0 1330 542" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">결정 주소가 게시 시점에 굳어져 방문자가 404 를 만나기까지</title>
|
||||
<desc id="diagram-description">위에서 아래로 여섯 번의 이동이 있다. 계약 ProjectDecisionItem 은 공개 주소가 decisions#{slug} 앵커라고 규정한다. 게시 시점의 PublicPaths.forKind 는 그 대신 decisions/{slug} 를 만들어 public_resource_projection 에 저장한다. 조회 시점의 PublicSql.pathOf 가 저장된 주소를 읽고 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 projects/{slug}/decisions 하나뿐이라 맞는 라우트가 없고 404 가 돌아온다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"decision-path-404","profile":"sequence"},"source_context":{"document":"document.md","document_sha256":"93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f","anchor":{"kind":"marker","value":"decision-path-404","line":673}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="1330" height="542" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="676-678" x="45.0" y="35.0" width="188.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="139.0" y="65.0">계약 ProjectDecisionItem</text>
|
||||
<line class="lifeline" x1="139.0" y1="99.0" x2="139.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="677-678" x="255.0" y="35.0" width="167.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="338.5" y="62.0">PublicPaths.forKind</text>
|
||||
<line class="node-detail-divider" x1="269.0" y1="83.0" x2="408.0" y2="83.0" />
|
||||
<text class="node-detail" x="271.0" y="100.0">게시 시점</text>
|
||||
<line class="lifeline" x1="338.5" y1="106.0" x2="338.5" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="682-683" x="465.0" y="35.0" width="190.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="560.0" y="65.0">public_resource_projection</text>
|
||||
<line class="lifeline" x1="560.0" y1="99.0" x2="560.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="677-678" x="675.0" y="35.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="750.0" y="62.0">PublicSql.pathOf</text>
|
||||
<line class="node-detail-divider" x1="689.0" y1="83.0" x2="811.0" y2="83.0" />
|
||||
<text class="node-detail" x="691.0" y="100.0">조회 시점</text>
|
||||
<line class="lifeline" x1="750.0" y1="106.0" x2="750.0" y2="512.0" />
|
||||
<g class="actor-symbol emphasis-normal" data-evidence="670-671"><circle cx="960.0" cy="55.0" r="11.0" /><line x1="960.0" y1="71.0" x2="960.0" y2="74.0" /><line x1="942.0" y1="81.0" x2="978.0" y2="81.0" /><line x1="960.0" y1="74.0" x2="945.0" y2="91.0" /><line x1="960.0" y1="74.0" x2="975.0" y2="91.0" /></g>
|
||||
<text class="node-label" x="960.0" y="106.0">방문자</text>
|
||||
<line class="lifeline" x1="960.0" y1="113.0" x2="960.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="675-676" x="1095.0" y="35.0" width="190.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="1190.0" y="62.0">공개 라우트</text>
|
||||
<line class="node-detail-divider" x1="1109.0" y1="83.0" x2="1271.0" y2="83.0" />
|
||||
<text class="node-detail" x="1111.0" y="100.0">/projects/{slug}/decisions 하나뿐</text>
|
||||
<line class="lifeline" x1="1190.0" y1="106.0" x2="1190.0" y2="512.0" />
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="139.0,140.0 338.5,140.0" data-evidence="676-678" />
|
||||
<rect class="edge-label-bg" x="142.6" y="114.0" width="192.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="238.8" y="129.0">1. …/decisions#{slug} 로 규정</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-warning" points="338.5,202.0 560.0,202.0" data-evidence="675-678" />
|
||||
<rect class="edge-label-bg" x="359.9" y="176.0" width="178.8" height="22" rx="3" />
|
||||
<text class="edge-label" x="449.2" y="191.0">2. …/decisions/{slug} 저장</text>
|
||||
<line class="failure-mark" x1="547.0" y1="189.0" x2="573.0" y2="215.0" />
|
||||
<line class="failure-mark" x1="573.0" y1="189.0" x2="547.0" y2="215.0" />
|
||||
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="560.0,264.0 750.0,264.0" data-evidence="682-683" />
|
||||
<rect class="edge-label-bg" x="605.8" y="238.0" width="98.4" height="22" rx="3" />
|
||||
<text class="edge-label" x="655.0" y="253.0">3. 저장된 주소 조회</text>
|
||||
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="750.0,326.0 960.0,326.0" data-evidence="677-678" />
|
||||
<rect class="edge-label-bg" x="795.8" y="300.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="855.0" y="315.0">4. 같은 형태로 링크 전달</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="960.0,388.0 1190.0,388.0" data-evidence="670-675" />
|
||||
<rect class="edge-label-bg" x="985.6" y="362.0" width="178.8" height="22" rx="3" />
|
||||
<text class="edge-label" x="1075.0" y="377.0">5. …/decisions/{slug} 요청</text>
|
||||
<polyline class="edge kind-response style-solid emphasis-warning semantic-dashed" points="1190.0,450.0 960.0,450.0" data-evidence="670-675" />
|
||||
<rect class="edge-label-bg" x="1045.9" y="424.0" width="58.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="1075.0" y="439.0">6. 404</text>
|
||||
<line class="failure-mark" x1="947.0" y1="437.0" x2="973.0" y2="463.0" />
|
||||
<line class="failure-mark" x1="973.0" y1="437.0" x2="947.0" y2="463.0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1444" height="488" viewBox="0 0 1444 488" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">주제 안의 축과 기록을 잇는 자리</title>
|
||||
<desc id="diagram-description">왼쪽에 topic 이 있고 variant_label 로 축의 이름을 스스로 정한다. 그 오른쪽에 topic_variant 가 있고 SPA, Mediator, BFF, Forward-Auth 같은 축의 값들을 담는다. 그 오른쪽에 record_variant 가 있고 어느 기록이 어느 축에 걸리는지를 종류와 아이디의 쌍으로 적는다. record_variant 는 오른쪽의 document, open_question, project_decision 세 테이블을 가리키는데, 기록이 종류마다 다른 테이블에 살기 때문에 외래키를 걸지 못하고 쌍으로만 가리킨다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"topic-variant-model","profile":"component-flow"},"source_context":{"document":"document.md","document_sha256":"93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f","anchor":{"kind":"marker","value":"topic-variant-model","line":1064}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="1444" height="488" />
|
||||
<rect class="group-box" x="1189.0" y="35.0" width="210.0" height="408.0" rx="8" />
|
||||
<rect class="group-label-bg" x="1203.0" y="25.0" width="155.0" height="22" />
|
||||
<text class="group-label" x="1213.0" y="40.0">기록은 종류마다 다른 테이블에 산다</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="279.0,249.0 359.0,249.0 359.0,249.0 439.0,249.0" data-evidence="1057-1060" />
|
||||
<rect class="edge-label-bg" x="333.2" y="207.0" width="51.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="359.0" y="222.0">1 : N</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="718.0,249.0 798.0,249.0 798.0,249.0 878.0,249.0" data-evidence="1060-1061" />
|
||||
<rect class="edge-label-bg" x="772.2" y="207.0" width="51.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="798.0" y="222.0">축에 건다</text>
|
||||
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,231.0 1139.0,231.0 1139.0,113.0 1219.0,113.0" data-evidence="1061-1073" />
|
||||
<rect class="edge-label-bg" x="1120.5" y="158.0" width="85.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="1163.0" y="173.0">(kind, id)</text>
|
||||
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,249.0 1139.0,249.0 1139.0,249.0 1219.0,249.0" data-evidence="1061-1073" />
|
||||
<rect class="edge-label-bg" x="1096.5" y="207.0" width="85.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="1139.0" y="222.0">(kind, id)</text>
|
||||
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,267.0 1139.0,267.0 1139.0,385.0 1219.0,385.0" data-evidence="1061-1073" />
|
||||
<rect class="edge-label-bg" x="1120.5" y="312.0" width="85.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="1163.0" y="327.0">(kind, id)</text>
|
||||
<g id="node-topic">
|
||||
<rect class="node-shape kind-database emphasis-normal role-source" data-evidence="1057-1059,1067-1068" x="70.0" y="213.5" width="209.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="174.5" y="240.5">topic</text>
|
||||
<line class="node-detail-divider" x1="84.0" y1="261.5" x2="265.0" y2="261.5" />
|
||||
<text class="node-detail" x="86.0" y="278.5">variant_label 로 축 이름을 정한다</text>
|
||||
</g>
|
||||
<g id="node-topic-variant">
|
||||
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1060-1060,1047-1048" x="439.0" y="213.5" width="279.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="578.5" y="240.5">topic_variant</text>
|
||||
<line class="node-detail-divider" x1="453.0" y1="261.5" x2="704.0" y2="261.5" />
|
||||
<text class="node-detail" x="455.0" y="278.5">SPA · Mediator · BFF · Forward-Auth</text>
|
||||
</g>
|
||||
<g id="node-record-variant">
|
||||
<rect class="node-shape kind-database emphasis-primary role-store" data-evidence="1061-1061,1071-1073" x="878.0" y="213.5" width="181.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="968.5" y="240.5">record_variant</text>
|
||||
<line class="node-detail-divider" x1="892.0" y1="261.5" x2="1045.0" y2="261.5" />
|
||||
<text class="node-detail" x="894.0" y="278.5">(kind, id) 쌍 · 외래키 없음</text>
|
||||
</g>
|
||||
<g id="node-document">
|
||||
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1219.0" y="81.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="1294.0" y="111.0">document</text>
|
||||
</g>
|
||||
<g id="node-open-question">
|
||||
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1219.0" y="217.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="1294.0" y="247.0">open_question</text>
|
||||
</g>
|
||||
<g id="node-project-decision">
|
||||
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1219.0" y="353.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="1294.0" y="383.0">project_decision</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1535" height="300" viewBox="0 0 1535 300" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">공개 화면 한 줄까지 값이 지나는 열한 개의 경계 — 다섯 묶음</title>
|
||||
<desc id="diagram-description">왼쪽에서 오른쪽으로 읽는다. tech-log-backend 구역에 저장 묶음과 백엔드 조립 묶음이 있고 각각 경계 둘과 넷을 담는다. 전선 구역에는 HTTP envelope 하나가 있다. tech-log-frontend 구역에는 프론트엔드 조립 묶음과 화면 컴포넌트가 있고 각각 경계 셋과 하나다. 다 더하면 열한 개이고, 각 경계의 이름은 그림 위 목록에 있다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"value-boundaries","profile":"component-flow"},"source_context":{"document":"document.md","document_sha256":"93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f","anchor":{"kind":"marker","value":"value-boundaries","line":82}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="1535" height="300" />
|
||||
<rect class="group-box" x="40.0" y="35.0" width="520.0" height="143.0" rx="8" />
|
||||
<rect class="group-label-bg" x="54.0" y="25.0" width="134.0" height="22" />
|
||||
<text class="group-label" x="64.0" y="40.0">tech-log-backend</text>
|
||||
<rect class="group-box" x="660.0" y="35.0" width="210.0" height="143.0" rx="8" />
|
||||
<rect class="group-label-bg" x="674.0" y="25.0" width="90.0" height="22" />
|
||||
<text class="group-label" x="684.0" y="40.0">전선 (HTTP)</text>
|
||||
<rect class="group-box" x="970.0" y="35.0" width="520.0" height="143.0" rx="8" />
|
||||
<rect class="group-label-bg" x="984.0" y="25.0" width="141.0" height="22" />
|
||||
<text class="group-label" x="994.0" y="40.0">tech-log-frontend</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,116.5 300.0,116.5 300.0,116.5 380.0,116.5" data-evidence="70-71" />
|
||||
<rect class="edge-label-bg" x="270.9" y="74.5" width="58.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="300.0" y="89.5">SQL 조회</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="530.0,116.5 610.0,116.5 610.0,116.5 690.0,116.5" data-evidence="74-75" />
|
||||
<rect class="edge-label-bg" x="577.5" y="74.5" width="64.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="610.0" y="89.5">HTTP 응답</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="840.0,116.5 920.0,116.5 920.0,116.5 1000.0,116.5" data-evidence="75-76" />
|
||||
<rect class="edge-label-bg" x="887.5" y="74.5" width="64.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="920.0" y="89.5">HTTP 수신</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="1150.0,116.5 1230.0,116.5 1230.0,116.5 1310.0,116.5" data-evidence="78-79" />
|
||||
<rect class="edge-label-bg" x="1200.9" y="74.5" width="58.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="1230.0" y="89.5">화면 한 줄</text>
|
||||
<g id="node-store">
|
||||
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="69-70" x="70.0" y="81.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="108.0">저장</text>
|
||||
<line class="node-detail-divider" x1="84.0" y1="129.0" x2="206.0" y2="129.0" />
|
||||
<text class="node-detail" x="86.0" y="146.0">경계 2</text>
|
||||
</g>
|
||||
<g id="node-backend-assembly">
|
||||
<rect class="node-shape kind-service emphasis-warning role-service" data-evidence="71-74" x="380.0" y="81.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="455.0" y="108.0">백엔드 조립</text>
|
||||
<line class="node-detail-divider" x1="394.0" y1="129.0" x2="516.0" y2="129.0" />
|
||||
<text class="node-detail" x="396.0" y="146.0">경계 4</text>
|
||||
</g>
|
||||
<g id="node-http-envelope">
|
||||
<rect class="node-shape kind-message emphasis-normal role-transfer" data-evidence="75-75" x="690.0" y="81.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="765.0" y="108.0">HTTP envelope</text>
|
||||
<line class="node-detail-divider" x1="704.0" y1="129.0" x2="826.0" y2="129.0" />
|
||||
<text class="node-detail" x="706.0" y="146.0">경계 1</text>
|
||||
</g>
|
||||
<g id="node-frontend-assembly">
|
||||
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="76-78" x="1000.0" y="81.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="1075.0" y="108.0">프론트엔드 조립</text>
|
||||
<line class="node-detail-divider" x1="1014.0" y1="129.0" x2="1136.0" y2="129.0" />
|
||||
<text class="node-detail" x="1016.0" y="146.0">경계 3</text>
|
||||
</g>
|
||||
<g id="node-screen">
|
||||
<rect class="node-shape kind-component emphasis-normal role-sink" data-evidence="79-79,66-66" x="1310.0" y="81.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="1385.0" y="108.0">화면 컴포넌트</text>
|
||||
<line class="node-detail-divider" x1="1324.0" y1="129.0" x2="1446.0" y2="129.0" />
|
||||
<text class="node-detail" x="1326.0" y="146.0">경계 1</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-link-that-pointed-at-itself
|
||||
title: 축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§9.1
|
||||
- final/document.md#§9.3
|
||||
---
|
||||
|
||||
# 축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다
|
||||
|
||||
주제 화면의 네 줄은 링크인데 눌러도 아무 일이 없었다. 축의 주소를 주제 화면 안의 앵커로 바꿨더니, 정작 주제 화면에서는 그 링크가 자기 자신을 가리켰다. 결국 축에 자기 화면을 줬고, 그 화면을 만들고 백엔드를 프론트보다 먼저 배포해 사용자가 네 링크 전부 404 인 화면을 봤다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **새 라우트는 프론트엔드를 먼저 배포한다**
|
||||
이 사건에서 정한 순서다.
|
||||
- **우회를 남길 때는 되돌릴 조건을 함께 적는다**
|
||||
같은 주제 링크를 다루며 굳힌 기준이다.
|
||||
- **축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다**
|
||||
축에 자기 화면을 주려면 알아야 하는 구조다.
|
||||
|
||||
## 문제
|
||||
|
||||
주제 화면의 네 줄(SPA·Mediator·BFF·Forward-Auth)은 링크로 그려져 있었다. 눌러도 아무 일이 없었다.
|
||||
|
||||
처음에 `/topics/{주제}/{축}` 이라 적어 두었는데 그런 화면이 없었다.
|
||||
|
||||
## 결론
|
||||
|
||||
주소를 두 번 옮긴 끝에 축에 자기 화면을 줬다.
|
||||
|
||||
1차 : 축의 주소를 주제 화면 안의 앵커로 바꿨다 — 주제 화면에서는 그 링크가 자기 자신을 가리켰다
|
||||
2차 : 축에 자기 화면을 줬다 — 목록 조회에 축 필터를 더해 걸러 낸다
|
||||
|
||||
축 slug 는 주제 안에서만 유일하므로 조회에서 주제까지 함께 맞춘다. 주제를 빼면 다른 주제의 같은 이름 축이 함께 걸린다.
|
||||
|
||||
같은 시기에 주제가 없는 기록이 이름 없는 주제 링크를 달고 있던 것도 고쳤다. 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다였고, 프로젝트 조각은 처음부터 조건부였는데 주제 쪽만 아니었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 8828005 · 67a5491
|
||||
tech-log-backend : 63eb177 · 6d3b68b
|
||||
tech-log-design-package : 71bab4c · b93d62a
|
||||
확인 방식 : 배포본에서 주제 화면의 네 링크를 눌러 각각 어디로 가는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 주제에 축을 넷 만들고 기록을 각 축에 건다
|
||||
2. 주제 화면에서 축 줄을 누른다
|
||||
3. 주소가 바뀌는지, 화면이 바뀌는지를 따로 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 주소만 바뀌고 화면은 그대로였다
|
||||
|
||||
처음에 축의 주소를 `/topics/{주제}/{축}` 이라 적어 두었는데 그런 화면이 없었다. 그래서 축의 주소를 주제 화면 안의 앵커로 바꿨다.
|
||||
|
||||
주제 화면에서 그 링크를 누르면 주소에 앵커가 붙는다. 화면은 이미 그 주제 화면이므로 아무것도 바뀌지 않는다.
|
||||
|
||||
## 축에 자기 화면을 줬다
|
||||
|
||||
목록 조회에 축 필터를 더하고 `record_variant` 로 거른다. 축 slug 는 주제 안에서만 유일하므로 주제까지 함께 맞춘다 — 주제를 빼면 다른 주제의 같은 이름 축이 함께 걸린다.
|
||||
|
||||
## 배포 순서로 만든 2차 사고
|
||||
|
||||
> **이 건에서 제가 만든 2차 사고:** 축 화면을 만들고 **백엔드를 프론트보다 먼저 배포했습니다.** nginx 설정은 라우트 계약에서 생성되므로, 프론트가 배포되기 전까지 `/topics/x/y` 는 404 입니다. 서버는 이미 그 주소를 내보내고 있었고, 사용자는 네 링크가 전부 404 인 화면을 봤습니다. **순서가 있습니다 — 새 라우트는 프론트가 먼저입니다.**
|
||||
|
||||
## 주제가 없는 기록
|
||||
|
||||
같은 시기에 주제 없이 게시된 기록이 이름 없는 주제 링크를 달고 있었다. 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다였다. 프로젝트 조각은 처음부터 조건부였는데 주제 쪽만 아니었다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
축 slug 가 주제 안에서만 유일하다는 전제를 조회에 반영했다. 주제를 빼고 조회하는 경로가 남아 있는지는 세지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-slug-rule-that-threw-korean-away
|
||||
title: slug 생성이 한글을 버려 주제 만들기가 간헐적으로 실패했다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§13.6
|
||||
---
|
||||
|
||||
# slug 생성이 한글을 버려 주제 만들기가 간헐적으로 실패했다
|
||||
|
||||
주제 만들기가 간헐적으로 실패했다 — 「그 slug 를 가진 주제가 이미 있습니다」. 다른 이름으로 다시 하면 됐다. 규칙은 간헐적이었던 적이 없었고 보이지 않았을 뿐이다. slug 생성이 영문 소문자와 숫자만 남기고 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
주소를 만드는 다른 곳에서 난 사건이다.
|
||||
- **한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다**
|
||||
이름을 다루는 다른 사건이다.
|
||||
- **서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다**
|
||||
slug 가 주소의 재료라는 점에서 이어진다.
|
||||
|
||||
## 문제
|
||||
|
||||
주제를 만들면 가끔 「그 slug 를 가진 주제가 이미 있습니다」가 나왔다. 다른 이름으로 다시 하면 됐다.
|
||||
|
||||
같은 이름으로 두 번 만든 적은 없었다.
|
||||
|
||||
## 결론
|
||||
|
||||
간헐적으로 보인 것은 두 가지 결정적 어긋남이었고, 사용자는 둘 다 만났다.
|
||||
|
||||
`인증` : 남는 글자가 없어 빈 문자열 → 폼이 요청 전에 거절
|
||||
`Redis 캐시` 와 `Redis 클러스터` : 둘 다 `redis` → 두 번째가 충돌
|
||||
|
||||
> 규칙은 간헐적이었던 적이 없다. **보이지 않았을 뿐이다** — slug 생성이 `[a-z0-9]` 만 남기고 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
|
||||
|
||||
한글을 버리지 않고 로마자로 옮긴다. 음절을 초성·중성·종성으로 산술 분해하므로 표가 필요 없고 결정적이다.
|
||||
|
||||
`백엔드 아키텍처` → `baekendeu-akitekcheo`
|
||||
|
||||
국어의 로마자 표기법의 자모 대응만 적용하고 음운 변화 규칙은 일부러 뺐다. slug 는 읽는 것이지 발음하는 것이 아니고, 그 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 5cffe30 · 7093d84
|
||||
변환 : 국어의 로마자 표기법의 자모 대응 · 음운 변화 규칙 제외
|
||||
확인 방식 : 한글 이름 여럿으로 주제를 만들어 생성된 slug 를 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 주제 이름을 `인증` 으로 적고 저장한다 — 옛 규칙에서는 빈 slug 가 되어 폼이 거절한다
|
||||
2. `Redis 캐시` 와 `Redis 클러스터` 를 차례로 만든다 — 옛 규칙에서는 두 번째가 충돌한다
|
||||
3. 새 규칙에서 같은 이름들의 slug 를 확인한다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 간헐적으로 보인 이유
|
||||
|
||||
slug 생성이 `[a-z0-9]` 만 남기고 나머지를 버렸다. 한글 이름은 통째로 사라지므로 이름에 영문이 얼마나 섞였는지에 따라 결과가 갈린다.
|
||||
|
||||
영문이 하나도 없으면 빈 문자열이 되어 폼이 요청 전에 거절한다. 영문이 앞에 붙어 있으면 그 부분만 남으므로 뒤가 다른 두 이름이 같은 slug 가 된다.
|
||||
|
||||
`Redis 캐시` 와 `Redis 클러스터` 가 둘 다 `redis` 였다. 두 번째를 만들 때 충돌이 났고, 사용자에게는 「가끔 안 된다」로 보였다.
|
||||
|
||||
## 산술 분해로 로마자를 만든다
|
||||
|
||||
한글 음절은 초성·중성·종성이 정해진 순서로 조합된 코드다. 음절 코드에서 세 값을 산술로 분해할 수 있으므로 변환표가 필요 없고 결과가 결정적이다.
|
||||
|
||||
`백엔드 아키텍처` → `baekendeu-akitekcheo`
|
||||
|
||||
## 음운 변화 규칙을 뺀 이유
|
||||
|
||||
국어의 로마자 표기법에는 자모 대응 외에 음운 변화 규칙이 있다. 그것을 넣지 않았다.
|
||||
|
||||
slug 는 읽는 것이지 발음하는 것이 아니다. 음운 변화를 적용하면 같은 이름이 앞뒤 글자에 따라 다른 slug 가 되고, 그러면 같은 이름을 두 번 만들 때 결과가 갈린다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
음운 변화 규칙을 빼서 같은 발음의 다른 이름이 다른 slug 가 된다. 그 충돌이 실제로 얼마나 자주 나는지는 재지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-address-frozen-at-publish-time
|
||||
title: 계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다 — 이미 저장된 행까지 고쳤다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
assets:
|
||||
- key: decision-path-404
|
||||
file: ../../../final/assets/tech-log-studio/decision-path-404.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/db/decision-path-after-v15.txt
|
||||
- ../../../final/evidence/raw/api/decision-anchor-fixed.txt
|
||||
- ../../../final/evidence/raw/audit/dead-link-sweep.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§9.2
|
||||
---
|
||||
|
||||
# 계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다 — 이미 저장된 행까지 고쳤다
|
||||
|
||||
공개 화면의 「다음에 읽을 것」 두 번째 항목이 404 였다. 계약은 결정의 공개 주소가 목록 위의 앵커라고 이미 적어 두었는데, 주소를 만드는 두 곳이 그 대신 별도 경로를 만들고 있었다. 주소는 게시할 때 만들어 DB 에 저장되므로 코드만 고치면 이미 게시된 링크는 깨진 채 남는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다**
|
||||
이 사건에서 세운 두 겹 가드다.
|
||||
- **결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다**
|
||||
같은 앵커 구조에서 난 계약 쪽 사건이다.
|
||||
- **축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다**
|
||||
같은 시기에 주소를 옮기다 난 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
`/references/external-idp-federation-application-boundary` 의 「다음에 읽을 것」 두 번째 항목이 404 였다.
|
||||
|
||||
화면 코드 어디에도 그 주소를 만드는 곳이 없다. 주소는 게시할 때 서버가 만들어 DB 에 저장한 문자열이고, 화면은 그것을 그대로 링크로 그린다.
|
||||
|
||||
## 결론
|
||||
|
||||
결정에는 상세 화면이 없고 공개 라우트는 목록 하나뿐인데, 게시할 때 만든 주소는 목록 아래에 slug 를 붙인 경로였다.
|
||||
|
||||
계약 : 공개 주소가 앵커라고 이미 적혀 있었다
|
||||
게시 시점 : 앵커가 아니라 경로를 만들어 저장했다
|
||||
조회 시점 : 저장된 주소를 읽어 링크로 내보냈다
|
||||
방문자 : 맞는 라우트가 없어 404
|
||||
|
||||
고친 것은 넷이다.
|
||||
|
||||
두 곳이 앵커를 만들게 했다
|
||||
이미 게시된 행도 V15 마이그레이션에서 함께 고쳤다 — 코드만 고치면 기존 링크는 깨진 채 남는다
|
||||
공개 라우트의 slug 를 앵커가 있으면 그 뒤를 조각으로 읽게 했다
|
||||
목록 항목이 앵커를 달 수 있도록 계약에 slug 를 더하고, 화면이 그 slug 를 element id 로 달고 앵커로 들어오면 데이터를 받아 그린 뒤 스크롤하게 했다
|
||||
|
||||
배포 후 서버가 내보내는 주소 26개와 주제·축 9개를 더해 35개 전부 200 인 것을 확인했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-design-package : 1aae8dc
|
||||
tech-log-backend : 8cd8ee3 · V15 마이그레이션 적용
|
||||
tech-log-frontend : fe6b56a
|
||||
확인 방식 : 배포본에서 서버가 내보내는 주소를 전수로 훑어 상태 코드를 셌다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 결정을 하나 게시하고 다른 기록에서 그것을 관계로 건다
|
||||
2. 공개 화면에서 그 관계 링크를 누른다
|
||||
3. 저장된 주소와 공개 라우트 패턴을 대조한다 — 맞는 라우트가 없으면 404 다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 주소가 만들어져 저장되고 방문에서 끝난다
|
||||
|
||||
:::evidence key="decision-path-404" alt="계약·게시 시점 경로 생성·저장 테이블·조회 시점 경로 생성·방문자·공개 라우트 여섯 참가자 사이의 순서도" caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
계약은 결정의 공개 주소가 앵커라고 규정한다. 게시 시점의 `PublicPaths.forKind` 는 그 대신 경로를 만들어 `public_resource_projection` 에 저장한다. 조회 시점의 `PublicSql.pathOf` 가 저장된 주소를 읽고 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 목록 하나뿐이라 맞는 라우트가 없다.
|
||||
|
||||
## 계약은 이미 맞게 적혀 있었다
|
||||
|
||||
이 사건에서 계약은 고칠 것이 없었다. 공개 주소가 `#{slug}` 앵커라는 것이 계약에 있었고, 만드는 쪽 두 곳이 그것을 따르지 않았다.
|
||||
|
||||
## 저장된 행까지 고쳐야 한다
|
||||
|
||||
주소가 게시 시점에 굳어져 저장되므로 코드만 고치면 이미 게시된 링크는 깨진 채 남는다. V15 마이그레이션에서 저장된 행을 함께 고쳤다.
|
||||
|
||||
`public_route.slug` 도 함께 손봤다. 마지막 슬래시 뒤를 자르면 앵커가 붙은 주소에서 `decisions#slug` 전체가 slug 로 저장된다. 앵커가 있으면 그 뒤를 조각으로 읽게 했다.
|
||||
|
||||
## 두 겹 가드
|
||||
|
||||
`PublicPathsTest` 는 백엔드에서 종류마다 만들어 낸 경로가 실제 공개 라우트 패턴에 맞는지 본다.
|
||||
|
||||
`resolvesToPublicRoute` 는 프론트에서 라우트 계약이 준 표에 서버가 준 주소를 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다. 이 부류가 또 생겨도 방문자가 404 를 만나지는 않는다.
|
||||
|
||||
## 배포 뒤 전수 감사
|
||||
|
||||
배포 후 사이트 전체를 훑어 서버가 내보내는 주소 26개와 주제·축 9개를 더해 35개 전부 200 인 것을 확인했다.
|
||||
|
||||
:::evidence key="dead-link-sweep" alt="서버가 내보내는 주소 35개를 전수로 훑은 감사 출력" caption=" " zoom="false"
|
||||
:::
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 감사는 서버가 내보내는 주소만 본다. 본문 안에 작성자가 손으로 쓴 링크는 대상이 아니다.
|
||||
|
||||
<!-- body:end -->
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: a-new-route-ships-frontend-first
|
||||
title: 새 라우트는 프론트엔드를 먼저 배포한다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
decidedOn: 2026-09-01
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§9.1
|
||||
---
|
||||
|
||||
# 새 라우트는 프론트엔드를 먼저 배포한다
|
||||
|
||||
새 라우트를 여는 변경은 프론트엔드를 먼저 배포한다. nginx 설정이 라우트 계약에서 생성되므로, 프론트가 배포되기 전까지 그 경로는 엣지에서 404 다. 백엔드를 먼저 배포하면 서버는 이미 그 주소를 내보내고 사용자는 전부 404 인 화면을 본다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다**
|
||||
이 순서를 정하게 된 사건이다. 반대 순서로 배포해 사용자가 네 링크 전부 404 인 화면을 봤다.
|
||||
- **nginx 가 모르는 라우트는 새로고침에서 404 다**
|
||||
엣지가 왜 그 경로를 모르는지가 그 기록에 있다.
|
||||
- **catch-all 라우트는 nginx 패턴으로 번역하지 않는다**
|
||||
이 순서가 필요해진 이유가 그 결정에 있다.
|
||||
|
||||
## 결정문
|
||||
|
||||
새 공개 라우트를 여는 변경은 프론트엔드를 먼저 배포하고, 그 주소를 내보내는 백엔드 변경을 다음 배포에 넣는다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
nginx 설정은 라우트 계약에서 생성된다. 프론트 이미지가 배포되기 전까지 그 경로는 서빙 패턴에 없고, 엣지에서 404 로 끝난다.
|
||||
|
||||
백엔드를 먼저 배포하면 서버는 이미 그 주소를 링크로 내보낸다. 방문자는 화면에 그려진 링크를 누르고 404 를 만난다. 축 화면을 만들 때 실제로 그렇게 배포했고 사용자가 네 링크 전부 404 인 화면을 봤다.
|
||||
|
||||
catch-all 을 서빙 패턴으로 번역하지 않기로 했으므로 이 구간이 soft 200 으로 덮이지 않는다. 그 결정과 이 순서는 함께 간다.
|
||||
|
||||
## 영향
|
||||
|
||||
새 주소를 내보내는 백엔드 변경이 한 배포 늦게 나간다. 두 저장소를 한 번에 배포하고 싶은 변경에서 그 사이가 벌어진다.
|
||||
|
||||
프론트를 먼저 배포한 뒤에는 그 경로가 열려 있지만 서버가 아직 그 주소를 내보내지 않는다. 이 구간에서는 화면에 링크가 그려지지 않으므로 방문자에게 보이는 문제가 없다.
|
||||
|
||||
배포 순서를 사람이 기억해야 한다. 이 순서를 강제하는 검사는 없다.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: do-not-draw-a-link-that-does-not-resolve
|
||||
title: 서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§9.2
|
||||
---
|
||||
|
||||
# 서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다
|
||||
|
||||
주소를 서버가 만들어 내보내고 화면이 그대로 링크로 그리면, 그 주소가 틀렸다는 것은 방문자만 안다. 화면 코드 어디에도 그 주소가 없기 때문이다. 만드는 쪽과 그리는 쪽 양쪽에서 라우트 표와 맞춘다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
이 기준의 근거 사건이다.
|
||||
- **catch-all 라우트는 nginx 패턴으로 번역하지 않는다**
|
||||
엣지의 404 가 감사에 남아야 이 기준이 성립한다.
|
||||
- **라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다**
|
||||
라우트 표가 어디서 오는지가 그 기준에 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
서버가 만든 주소가 열리지 않는 것을 방문자보다 먼저 잡는다. 이 부류는 화면 코드에 흔적이 없어 저장소 안의 링크 리터럴을 훑는 감사로는 잡히지 않는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**만드는 쪽에서 생성한 경로를 공개 라우트 패턴에 맞춘다**
|
||||
종류마다 만들어 낸 주소가 실제 라우트에 걸리는지 백엔드 테스트가 본다.
|
||||
|
||||
**그리는 쪽에서 서버가 준 주소를 라우트 표에 맞추고, 맞는 라우트가 없으면 링크로 그리지 않는다**
|
||||
같은 부류가 또 생겨도 방문자가 404 를 만나지는 않는다.
|
||||
|
||||
**주소를 고쳤으면 이미 저장된 행도 함께 고친다**
|
||||
주소가 게시 시점에 굳어져 저장되면 코드만 고쳐도 기존 링크는 깨진 채 남는다.
|
||||
|
||||
**배포 뒤 서버가 내보내는 주소를 전수로 훑는다**
|
||||
저장소를 훑는 감사와 다른 것을 본다. 실제로 나가는 주소는 DB 에 있다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
주소를 서버가 만들어 내보내고 화면이 그대로 링크로 그리는 구조. 게시 시점에 주소가 굳어져 저장되면 특히 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
외부 주소는 라우트 표에 없으므로 이 대조의 대상이 아니다.
|
||||
|
||||
그리는 쪽에만 가드를 두면 링크가 아예 그려지지 않는 것으로 끝나고 원인이 남는다. 만드는 쪽에도 같은 검사를 둔다.
|
||||
|
||||
## 예시
|
||||
|
||||
결정 링크가 404 였다. 계약은 앵커라고 적었고 만드는 두 곳이 경로를 만들었다.
|
||||
|
||||
배포 후 서버가 내보내는 주소 26개와 주제·축 9개를 더해 35개 전부 200 인 것을 확인했다.
|
||||
|
||||
저장소 안의 링크 리터럴을 훑는 감사로는 이 결함이 잡히지 않았다. 그 주소는 코드에 없다.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: write-down-what-would-undo-a-workaround
|
||||
title: 우회를 남길 때는 되돌릴 조건을 함께 적는다
|
||||
topic: addresses-frozen-at-publish-time
|
||||
topicName: 주소가 만들어지고 굳어지는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§9.4
|
||||
---
|
||||
|
||||
# 우회를 남길 때는 되돌릴 조건을 함께 적는다
|
||||
|
||||
그 화면이 줄 수 있는 것이 아직 비어 있어 링크를 다른 곳으로 돌린 적이 있다. 우회 자체는 틀리지 않았다. 문제는 우회를 남겨 두면 「왜 이 링크가 저기로 가지?」라는 질문이 계속 남는다는 것이다. 우회할 때 되돌릴 조건을 함께 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다**
|
||||
같은 주제 링크를 다루며 이 기준이 나왔다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
단계마다 무엇을 고치려 했는지 적어 둔 다른 예다.
|
||||
- **새 라우트는 프론트엔드를 먼저 배포한다**
|
||||
같은 사건에서 나온 짝이 되는 결정이다.
|
||||
|
||||
## 목적
|
||||
|
||||
임시 조치가 영구 구조로 굳는 것을 막는다. 되돌릴 조건이 적혀 있지 않으면 다음 사람이 그 우회를 설계로 읽는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**우회를 넣는 커밋에 되돌릴 조건을 적는다**
|
||||
무엇이 채워지면 되돌리는지 한 줄로 적는다. 그 조건이 충족됐을 때 실제로 되돌린다.
|
||||
|
||||
**우회할 때 무엇이 비어 있어서 우회하는지 함께 적는다**
|
||||
채울 것이 없어서 돌린 것과 구조상 그쪽이 맞아서 돌린 것은 다르다.
|
||||
|
||||
**되돌릴 생각이 없으면 우회가 아니라 결정으로 적는다**
|
||||
그때는 조건이 아니라 근거와 감수한 비용을 적는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
그 화면이 줄 수 있는 것이 아직 비어 있어 링크나 흐름을 다른 곳으로 돌릴 때.
|
||||
|
||||
## 예외
|
||||
|
||||
되돌릴 생각이 없는 영구 변경은 우회가 아니다. 조건 대신 근거를 적는다.
|
||||
|
||||
조건을 적을 수 없으면 그것은 우회가 아니라 아직 정하지 않은 것이다. 열린 질문으로 남긴다.
|
||||
|
||||
## 예시
|
||||
|
||||
주제 화면이 주제 셋을 하드코딩해 두고 있어 실제 주제는 무엇이든 404 였다. 그때 주제 페이지를 채우는 대신 링크를 탐색 필터로 돌렸다.
|
||||
|
||||
돌린 이유는 그 페이지만 줄 수 있는 것 — 설명, 범위, 선별한 대표 기록 — 이 전부 비어 있었고 Studio 에 주제 설명을 쓸 칸조차 없었기 때문이다.
|
||||
|
||||
그 조건을 커밋 메시지에 적었고, 주제 화면을 계약에 잇고 하드코딩을 없앤 뒤 링크를 곧장 주제 화면으로 되돌렸다.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-comparison-band-changed-three-times
|
||||
title: 홈의 비교 구역이 세 번 바뀌었다 — 상한을 없애고 요청을 목록 하나와 주제 하나로 고정했다
|
||||
topic: an-axis-inside-a-topic
|
||||
topicName: 주제 안의 축
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
evidence:
|
||||
- ../../../final/evidence/browser/home-tabs-grouped.png
|
||||
- ../../../final/evidence/browser/home-topic-tabs.png
|
||||
- ../../../final/evidence/browser/tab-metrics.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§14.2
|
||||
---
|
||||
|
||||
# 홈의 비교 구역이 세 번 바뀌었다 — 상한을 없애고 요청을 목록 하나와 주제 하나로 고정했다
|
||||
|
||||
홈의 비교 구역을 세 번 바꿨다. 처음에는 주제 하나만 펼치고 아래에 다른 주제로 가는 줄을 뒀고, 다음에는 주제 이름을 탭으로 세웠고, 마지막에 탭을 칩 크기로 낮추고 개수 상한을 없앴다. 상한을 없앨 때 요청 구조를 함께 바꿔 주제가 몇 개가 되든 첫 요청이 고정되게 했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다**
|
||||
이 화면이 그리는 구조다.
|
||||
- **축의 결론 문장과 기록 수는 기록을 붙여도 따라오지 않는다**
|
||||
이 화면에서 자동으로 안 따라오는 것이 그 질문에 있다.
|
||||
- **한 칸의 실패가 옆 칸을 끌고 내려갔다**
|
||||
탭 하나를 못 받아도 나머지가 남게 한 판단이 그 기록에 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
홈이 「무엇을 만들었나」로 시작하고 있었다. 30초 안에 알아야 할 것은 무엇을 견줬는가다.
|
||||
|
||||
## 결론
|
||||
|
||||
세 단계를 거쳤고 각 단계가 앞 단계의 무엇을 고치려 했는지가 남아 있다.
|
||||
|
||||
| 단계 | 무엇 | 왜 바꿨나 |
|
||||
|---|---|---|
|
||||
| 1 | 주제 하나만 펼치고 아래 「다른 주제 N개 보기」 한 줄 | 홈이 「무엇을 만들었나」로 시작했다 |
|
||||
| 2 | 제목 자리를 주제 이름 탭이 대신 (30px/650) | 「다른 주제」 줄은 목록을 다 읽고 나서야 만나는 곳이라 대개 지나쳤다 |
|
||||
| 3 | 탭을 칩 크기로 낮추고 개수 상한 제거 | 주제가 열 개, 스무 개가 되면 이름만으로 화면이 덮인다 |
|
||||
|
||||
3단계에서 요청 구조를 바꿨다. 탭은 목록 호출 하나가 주는 전부이고, 상세는 고른 탭만 그때 받아 캐시한다. 그래서 주제가 몇 개가 되든 홈이 처음 보내는 요청은 목록 하나와 주제 하나로 고정된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 604ded5 → de4cb8b → 3bb724b · 2b2f443
|
||||
확인 방식 : 배포본에서 단계마다 화면을 찍고 getComputedStyle 로 실측
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 홈을 열고 개발자도구 네트워크에서 처음 나가는 요청 수를 센다
|
||||
2. 탭을 하나 고르고 추가로 나가는 요청을 본다
|
||||
3. 같은 탭을 다시 고른다 — 캐시되어 요청이 나가지 않는다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 세 단계
|
||||
|
||||
1단계는 주제 하나만 펼치고 아래에 「다른 주제 N개 보기」 한 줄을 뒀다. 그 줄은 목록을 다 읽고 나서야 만나는 곳이라 대개 지나쳤다 — JPA 주제는 홈에 있으면서도 없는 것과 같았다.
|
||||
|
||||
2단계에서 제목 자리를 주제 이름 탭이 대신하게 했다. 30px 에 굵기 650 으로 세웠다.
|
||||
|
||||
3단계에서 탭을 칩 크기로 낮추고 개수 상한을 없앴다. 상한은 주제마다 상세를 미리 받느라 둔 것인데, 그러면 상한 밖의 주제가 다시 밀려난다.
|
||||
|
||||
## 요청 구조를 바꿨다
|
||||
|
||||
탭 줄은 목록 호출 하나가 주는 전부다. 상세는 고른 탭만 그때 받아 캐시한다.
|
||||
|
||||
그래서 주제가 몇 개가 되든 홈이 처음 보내는 요청은 목록 하나와 주제 하나로 고정된다. 상한을 없앨 수 있었던 이유가 이것이다.
|
||||
|
||||
## 시각 언어를 두 번 고쳤다
|
||||
|
||||
고른 탭의 파란 밑줄을 없앴다. 주제가 스무 개면 밑줄 설 곳 스무 개가 함께 늘어선다.
|
||||
|
||||
칩으로 낮추니 목록 위에 글자만 떠 있는 것처럼 보였다. 고른 탭에 알약 형태를 주고, 묶음의 윗선을 목록이 아니라 패널이 갖게 해서 탭 줄이 그 선에 바로 얹히게 했다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
주제가 스무 개일 때의 화면은 만들어 보지 않았다. 상한을 없앤 근거는 요청 구조이지 그 규모의 측정이 아니다.
|
||||
|
||||
「지금 집중하는 것」 탭에는 파란 밑줄이 그대로 있다. 주제 탭은 알약이라 한 화면 안에서 두 언어가 섞여 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: topic-variant-and-record-variant
|
||||
title: 축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다
|
||||
topic: an-axis-inside-a-topic
|
||||
topicName: 주제 안의 축
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
basisVersion: tech-log-backend 2026-09-01 의 축 스키마 · record_variant 에 외래키 없음 · studio_validation 과 publication 이 쓰는 방식을 따름
|
||||
assets:
|
||||
- key: topic-variant-model
|
||||
file: ../../../final/assets/tech-log-studio/topic-variant-model.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/db/topic-variant-rows.txt
|
||||
- ../../../final/evidence/raw/db/record-variant-links.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§14.1
|
||||
---
|
||||
|
||||
# 축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다
|
||||
|
||||
주제 안의 축은 세 테이블로 표현된다. 주제가 축의 이름을 스스로 정하고, 축의 값들이 따로 있고, 어느 기록이 어느 축에 걸리는지를 종류와 아이디의 쌍으로 적는다. 기록이 종류마다 다른 테이블에 살기 때문에 그 쌍에 외래키를 걸지 못한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **주제를 넷으로 쪼개지 않고 주제 안에 축을 하나 뒀다**
|
||||
이 구조를 만든 결정이다.
|
||||
- **축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다**
|
||||
이 구조 위에 축 화면을 만든 사건이다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
이 구조가 화면에서 어떻게 쓰이는지가 그 기록에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 세 테이블
|
||||
|
||||
```text
|
||||
topic (주제)
|
||||
├─ variant_label 축의 이름 — 주제마다 다르다
|
||||
│ 인증 경계 → 「구조」 / 조회 성능 → 「조회 전략」
|
||||
└─ topic_variant 축의 값들 (SPA, Mediator, BFF, Forward-Auth)
|
||||
└─ record_variant 어느 기록이 어느 축에 걸리는지 (kind, id) 쌍
|
||||
```
|
||||
|
||||
:::evidence key="topic-variant-model" alt="topic·topic_variant·record_variant 가 이어지고 record_variant 가 세 테이블을 가리키는 구조도" caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## 축 이름은 주제가 정한다
|
||||
|
||||
내부 이름은 축으로 고정하고, 화면에 보이는 이름은 주제가 자기 칸에 적는다. 인증 경계 주제는 「구조」, 조회 성능 주제는 「조회 전략」이다.
|
||||
|
||||
## 기록은 여러 축에 걸린다
|
||||
|
||||
한 기록이 여러 축에 걸릴 수 있다. PKCE 는 SPA 와 BFF 양쪽에 관계된다.
|
||||
|
||||
아무 축에도 걸리지 않은 기록은 그 주제의 공통 기록으로 읽는다. 「공통」이라는 축을 따로 만들지 않는다.
|
||||
|
||||
## 외래키가 없다
|
||||
|
||||
`record_variant` 는 외래키를 갖지 않는다. 기록이 종류마다 다른 테이블에 살기 때문이다 — 문서·열린 질문·프로젝트 결정이 각각 다른 테이블이다. 종류와 아이디의 쌍으로만 가리킨다.
|
||||
|
||||
이 방식은 이 저장소에서 처음 쓰는 것이 아니다. 검증 상태와 게시 기록이 이미 같은 방식으로 기록을 가리키고 있었다.
|
||||
|
||||
## 사람이 쓰는 칸
|
||||
|
||||
주제의 논지, 축의 요약과 결론은 기록을 합쳐 자동으로 나오는 글이 아니다. 특히 결론은 비교표가 읽는 칸이라 기록의 요약 첫 줄을 잘라 쓰면 안 된다.
|
||||
|
||||
## 같은 구조가 다르게 보일 때
|
||||
|
||||
두 주제가 같은 구조를 쓰는데 축에 걸린 기록 수가 달라 다르게 보인다.
|
||||
|
||||
```text
|
||||
oauth-oidc-auth-boundary 축 이름 「구조」 축 4개
|
||||
spa ← CASE 1 + REFERENCE 3 (기록 4)
|
||||
mediator ← CASE 1 + QUESTION 2 + REFERENCE 2 (기록 5)
|
||||
bff ← CASE 1 + QUESTION 3 + REFERENCE 1 (기록 5)
|
||||
forward-auth ← CASE 1 + QUESTION 1 + REFERENCE 1 (기록 3)
|
||||
공통 기록: CONCEPT 1 + 결정 2 + REFERENCE 2
|
||||
|
||||
jpa-feed-query-performance 축 이름 「조회 전략」 축 3개
|
||||
derived-query ← CASE 1
|
||||
fetch-join ← CASE 1
|
||||
fetch-join-paging ← CASE 1
|
||||
```
|
||||
|
||||
축마다 기록이 하나씩이고 축 제목을 그 기록 제목과 비슷하게 적으면 「문서가 그대로 나온다」로 보인다. 구조 차이가 아니라 내용 양의 차이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: an-axis-inside-a-topic-not-four-topics
|
||||
title: 주제를 넷으로 쪼개지 않고 주제 안에 축을 하나 뒀다
|
||||
topic: an-axis-inside-a-topic
|
||||
topicName: 주제 안의 축
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
decidedOn: 2026-09-01
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/db/topic-variant-rows.txt
|
||||
- ../../../final/evidence/raw/db/record-variant-links.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§14.1
|
||||
- final/document.md#§14.3
|
||||
---
|
||||
|
||||
# 주제를 넷으로 쪼개지 않고 주제 안에 축을 하나 뒀다
|
||||
|
||||
「브라우저와 서버 사이 credential 책임을 어디에 둘 것인가」 하나의 질문에 네 구조를 만들어 봤는데, 기록에 붙는 축이 주제와 프로젝트뿐이라 그 넷을 담을 데가 없었다. 주제를 넷으로 쪼개는 대신 주제 안에 축을 하나 뒀다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다**
|
||||
이 결정을 담은 스키마다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
이 결정이 화면에 어떻게 나타났는지가 그 기록에 있다.
|
||||
- **축의 결론 문장과 기록 수는 기록을 붙여도 따라오지 않는다**
|
||||
이 결정으로 감수한 비용이 그 질문에 있다.
|
||||
|
||||
## 결정문
|
||||
|
||||
한 질문에 여러 구조를 만들어 비교하는 경우, 주제를 그 수만큼 쪼개지 않고 주제 안에 축을 하나 둔다. 축의 이름은 주제가 정하고, 기록은 여러 축에 걸릴 수 있으며, 아무 축에도 걸리지 않은 기록은 그 주제의 공통 기록으로 읽는다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
주제를 넷으로 쪼개면 PKCE·CSRF·Authorization Code 처럼 네 구조가 함께 쓰는 기록을 어디에 둘지 애매해진다. 어느 한 주제에 넣으면 나머지 셋에서 그 기록에 닿을 수 없고, 넷에 복사하면 같은 글이 넷이 된다.
|
||||
|
||||
비교도 어려워진다. 네 주제가 나란히 서면 그것이 같은 질문의 네 답이라는 것을 화면이 말하지 못한다.
|
||||
|
||||
축을 주제 안에 두면 공통 기록은 축을 고르지 않고 두면 되고, 여러 구조에 걸치는 기록은 여러 축에 건다. 화면은 축을 나란히 세워 비교로 그린다.
|
||||
|
||||
축 이름을 주제가 정하게 한 것은 주제마다 비교 축이 다르기 때문이다. 인증 경계 주제의 축은 구조이고 조회 성능 주제의 축은 조회 전략이다.
|
||||
|
||||
## 영향
|
||||
|
||||
축의 이름·요약·결론이 기록에서 자동으로 나오지 않는다. 주제의 논지, 축의 요약과 결론은 사람이 쓰는 칸이고, 기록을 스무 개 붙여도 그 문장은 누가 고치기 전까지 그대로다.
|
||||
|
||||
홈의 비교 구역에서 줄은 문서가 아니라 축이다. 줄을 늘리려면 Studio 에서 축을 추가해야 한다.
|
||||
|
||||
기록이 어느 축에 걸리는지를 담는 표에 외래키를 걸 수 없다. 기록이 종류마다 다른 테이블에 살기 때문이다.
|
||||
|
||||
축이 붙은 기록 수가 적으면 「문서가 그대로 나온다」로 보인다. 축마다 기록이 하나씩이고 축 제목을 그 기록 제목과 비슷하게 적으면 그렇게 읽힌다.
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: the-conclusion-line-does-not-follow-the-records
|
||||
title: 축의 결론 문장과 기록 수는 기록을 붙여도 따라오지 않는다
|
||||
topic: an-axis-inside-a-topic
|
||||
topicName: 주제 안의 축
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/db/record-variant-links.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§14.3
|
||||
- final/document.md#§16.2
|
||||
---
|
||||
|
||||
# 축의 결론 문장과 기록 수는 기록을 붙여도 따라오지 않는다
|
||||
|
||||
홈의 비교 구역에서 줄은 문서가 아니라 축이다. 기록을 스무 개 붙여도 줄 수는 그대로이고, 줄에 보이는 결론 문장은 축에 손으로 쓴 글이라 누가 고치기 전까지 바뀌지 않는다. 주제 화면에는 축마다 기록 수가 붙는데 홈에는 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **주제를 넷으로 쪼개지 않고 주제 안에 축을 하나 뒀다**
|
||||
이 결정으로 감수한 비용이 이 질문이다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
이 화면을 세 번 고친 기록이다.
|
||||
- **프록시 지표가 아니라 보이는 것을 측정한다**
|
||||
화면에서 무엇이 달라지는지 확인하는 방법이 그 기준에 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
홈의 비교 구역에서 한 줄은 축 하나다. 기록을 스무 개 붙여도 줄 수는 그대로다.
|
||||
|
||||
줄에 보이는 결론 문장은 축에 손으로 쓴 글이다. 기록을 붙여도 그 문장은 누가 고치기 전까지 그대로다.
|
||||
|
||||
주제 화면에는 축마다 「기록 N」이 붙는다. 홈에는 그 수가 없다.
|
||||
|
||||
그래서 기록 1개짜리 축과 20개짜리 축이 홈에서 똑같아 보인다.
|
||||
|
||||
주제의 논지와 축의 결론은 2026-09-01 에 DB 에 직접 넣은 초안이고 아직 검토되지 않았다.
|
||||
|
||||
## 가정
|
||||
|
||||
결론을 사람이 쓰게 한 것이 의도라고 보고 있다. 요약은 「무엇인가」이고 결론은 「무엇을 알게 됐나」라서 기록의 요약 첫 줄을 잘라 쓰면 안 된다고 판단했지만, 그 판단이 지금도 맞는지 다시 보지 않았다.
|
||||
|
||||
기록 수는 유도할 수 있다고 보고 있다. 주제 화면이 이미 그 수를 그리므로 같은 값을 홈에 붙이면 된다고 짐작하지만, 홈의 목록 호출이 그 수를 싣는지 확인하지 않았다.
|
||||
|
||||
## 미지수
|
||||
|
||||
홈에 기록 수를 붙이면 축에 기록을 더했을 때 화면이 달라지는가. 달라진다면 결론 문장이 낡았다는 것도 같은 화면에서 드러나는가.
|
||||
|
||||
결론을 사람이 갱신해야 한다는 것을 화면이 말해야 하는가. 말한다면 어디에 말해야 읽히는가.
|
||||
|
||||
DB 에 직접 넣은 초안을 누가 언제 검토하는가. 검토 전까지 그 문장을 공개 화면에 그대로 둘지.
|
||||
|
||||
## 제약
|
||||
|
||||
축의 요약과 결론은 기록을 합쳐 자동으로 만들지 않는다. 결론은 비교표가 읽는 칸이라 기록의 요약 첫 줄을 잘라 쓰면 안 된다.
|
||||
|
||||
홈이 처음 보내는 요청은 목록 하나와 주제 하나로 고정한다. 기록 수를 붙이려고 주제마다 상세를 미리 받지 않는다.
|
||||
|
||||
## 선택지
|
||||
|
||||
**홈 비교표에 기록 수를 붙인다**
|
||||
목록 호출이 이미 그 수를 실을 수 있으면 요청이 늘지 않는다. 결론 문장은 그대로 사람이 쓴다.
|
||||
|
||||
**결론 문장이 마지막으로 고쳐진 때를 함께 보인다**
|
||||
기록이 그 뒤에 늘었으면 낡았다는 것이 드러난다. 화면에 날짜가 하나 더 늘어난다.
|
||||
|
||||
**결론을 쓰지 않은 축은 결론 줄을 비운다**
|
||||
쓰지 않은 것과 낡은 것을 구분한다. 지금은 초안이 들어 있어 둘이 같아 보인다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 홈의 목록 호출 응답에 축별 기록 수가 실려 있는지 확인하고, 없으면 싣는 비용을 잰다
|
||||
2. 축 하나에 기록을 더하고 홈에서 무엇이 달라지고 무엇이 그대로인지 화면으로 가른다
|
||||
3. DB 에 직접 넣은 주제 논지와 축 결론을 사용자가 검토하고, 남길 것과 지울 것을 가른다
|
||||
|
||||
닫는 조건 : 축에 기록을 더했을 때 자동으로 따라오는 것과 사람이 고쳐야 하는 것이 화면에서 구분되면 닫는다
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-section-wide-rule-caught-the-heading
|
||||
title: 구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다
|
||||
topic: css-rules-that-leak
|
||||
topicName: CSS 규칙이 구역을 넘어 샌다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§11.1
|
||||
- final/document.md#§11.3
|
||||
- final/document.md#§16.3
|
||||
---
|
||||
|
||||
# 구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다
|
||||
|
||||
「디자인이 안 된 것처럼 보인다」고 보고된 화면에서 h2 높이가 199px 이었다. 비교 행을 위한 격자 규칙의 선택자가 구역 전체라 제목 안의 링크까지 잡았다. 같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다**
|
||||
이 사건에서 굳힌 기준이다.
|
||||
- **프록시 지표가 아니라 보이는 것을 측정한다**
|
||||
이 부류를 어떻게 확인하는지가 그 기준에 있다.
|
||||
- **규칙이 없었던 게 아니라 절반만 있었다**
|
||||
같은 화면에서 난 다른 부류의 CSS 결함이다.
|
||||
|
||||
## 문제
|
||||
|
||||
사용자가 홈 비교 구역의 「디자인이 안 된 것처럼 보인다」고 보고했다.
|
||||
|
||||
제목이 접혀 두 줄이 되고 위아래로 크게 벌어져 있었다. 같은 이유로 주제 화면의 안내 문단도 행의 크기와 색으로 덮여 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
사용자가 원인을 정확히 짚어 주었다 — 「디자인이 안 된 게 아니라 CSS 선택자가 새고 있습니다」.
|
||||
|
||||
```css
|
||||
.home-comparison a {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
padding: 27px 2px 28px;
|
||||
}
|
||||
```
|
||||
|
||||
비교 행을 위한 규칙인데 선택자가 구역 전체라 제목 안의 링크까지 잡았다. 제목이 200px 칸에 갇혀 두 줄로 접히고 행용 padding 까지 물려 h2 높이가 199px 이 됐다.
|
||||
|
||||
배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다. 같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤다.
|
||||
|
||||
CSS 로만 막는 것은 임시방편이라 구조도 바꿨다 — 제목 안에 링크를 두지 않고, 주제로 가는 길은 아래 한 줄이 맡는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 344dadb · 805d400 · 8c5dbe1
|
||||
확인 방식 : 배포본에서 전체 페이지 스크린샷과 getComputedStyle 측정
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 구역 클래스 아래에 태그 선택자로 격자를 건다
|
||||
2. 그 구역 안 제목에 링크를 넣는다
|
||||
3. 제목의 높이를 잰다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 행을 위한 규칙이 제목을 잡았다
|
||||
|
||||
```css
|
||||
.home-comparison a {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
padding: 27px 2px 28px;
|
||||
}
|
||||
```
|
||||
|
||||
`a` 는 구역 안의 모든 링크를 잡는다. 제목 안에 링크가 있으면 그 링크도 격자가 되고 첫 칸의 200px 에 갇힌다. 행을 위한 padding 까지 물려 h2 높이가 199px 이 됐다.
|
||||
|
||||
## 아홉 구역에 같은 모양이 있었다
|
||||
|
||||
같은 형태가 다른 구역 아홉 곳에도 있었다. 선택자 51개를 `li > a` 처럼 그 배치를 쓰는 요소까지 좁혔다.
|
||||
|
||||
## CSS 만으로 막지 않았다
|
||||
|
||||
선택자를 좁히는 것은 같은 구조가 다시 생기면 다시 새게 한다. 제목 안에 링크를 두지 않도록 구조를 바꿨고, 주제로 가는 길은 아래 한 줄이 맡는다.
|
||||
|
||||
## 전역 규칙이 닿지 않는 화면
|
||||
|
||||
버튼에서 상자를 걷어내는 변경이 앱 전역 규칙만 고쳤다. 게시 기록·게시 흐름·워크플로 게이트는 CSS module 을 쓰므로 그 규칙이 닿지 않아, 다른 화면에서 상자를 걷어낸 뒤에도 세 버튼만 테두리와 파란 채움으로 남았다. 한 화면 안에서 두 언어가 섞여 더 눈에 띄었다.
|
||||
|
||||
## 검사
|
||||
|
||||
`section-selector-scope.test.ts` 가 `.클래스 태그` 모양에 배치 속성(`display: grid|flex`, `grid-template-columns`, `padding`)이 걸려 있으면 멈춘다. 이미 좁혀 둔 곳은 `SETTLED` 로 명시한다. 색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
CSS module 을 쓰는 화면은 전역 규칙이 닿지 않아 따로 고쳤다. 「지금 집중하는 것」 탭과 주제 탭의 표시 방식이 아직 다르다 — 한쪽은 파란 밑줄이고 한쪽은 알약이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-rule-was-not-missing-it-was-half-there
|
||||
title: 규칙이 없었던 게 아니라 절반만 있었다
|
||||
topic: css-rules-that-leak
|
||||
topicName: CSS 규칙이 구역을 넘어 샌다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§11.2
|
||||
---
|
||||
|
||||
# 규칙이 없었던 게 아니라 절반만 있었다
|
||||
|
||||
한 구역 제목만 26px 에 굵기 400 으로 나왔다. 형제 구역은 30px 에 650 이라 같은 화면에서 이 구역만 급이 낮았다. 규칙이 없었던 것이 아니라 절반만 있었다 — 크기만 각자 적혀 있고 굵기를 아무도 정하지 않아 기본값으로 떨어졌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **프록시 지표가 아니라 보이는 것을 측정한다**
|
||||
이 사건에서 굳힌 기준이다.
|
||||
- **구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다**
|
||||
같은 화면에서 난 다른 부류의 CSS 결함이다.
|
||||
- **배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다**
|
||||
선택자 범위를 다루는 짝이 되는 기준이다.
|
||||
|
||||
## 문제
|
||||
|
||||
「구조별로 알게 된 것」 구역의 제목만 다른 구역보다 작고 가늘게 보였다.
|
||||
|
||||
같은 화면에 나란히 서 있는 구역들이라 이 하나만 급이 낮아 보였다.
|
||||
|
||||
## 결론
|
||||
|
||||
정본 규칙은 특정 래퍼 안의 h2 를 잡는다. 그 안에 들어가지 않는 두 구역이 크기만 각자 적어 두었고, 굵기를 아무도 정하지 않아 기본값 400 으로 떨어졌다.
|
||||
|
||||
정본 : 30px · 650
|
||||
문제의 두 구역 : 26px · 400 (크기만 각자 선언, 굵기 미지정)
|
||||
|
||||
나란히 서는 구역 제목들이 정본과 같은 크기와 굵기를 쓰는지 CSS 를 파싱해 확인하는 검사를 뒀다. 굵기를 빼 보고 실제로 멈추는 것을 확인했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 68538f2
|
||||
확인 방식 : 전체 페이지 스크린샷과 getComputedStyle 로 실제 렌더된 값 측정
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 정본 규칙의 래퍼 밖에 구역 제목을 하나 만든다
|
||||
2. 크기만 선언하고 굵기는 선언하지 않는다
|
||||
3. 배포본에서 그 제목의 실제 크기와 굵기를 잰다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 절반만 있었다
|
||||
|
||||
> 규칙이 없었던 게 아니라 **절반만 있었다.** 정본은 `.section-heading-row h2` 인데 그 안에 들어가지 않는 두 구역이 **크기만 각자 적어 두어 굵기를 아무도 정하지 않았고**, 그래서 기본값 400 으로 떨어졌다.
|
||||
|
||||
크기를 각자 적어 두었기 때문에 「규칙이 없다」로 보이지 않는다. 선언이 있고 그 선언이 절반만 덮는다.
|
||||
|
||||
## 처음에 잘못 판단한 것
|
||||
|
||||
> **이때 제가 저지른 판단 오류:** 처음에 grid/columns 만 측정하고 "정상"이라고 답했습니다. 사용자가 다시 지적한 뒤 **전체 페이지 스크린샷**을 찍어서야 26px/400 을 봤습니다. **프록시 지표가 아니라 보이는 것을 측정해야 합니다.**
|
||||
|
||||
격자와 열은 정상이었다. 문제는 글자 크기와 굵기였고, 그것은 격자를 재서는 나오지 않는다.
|
||||
|
||||
## 검사
|
||||
|
||||
`section-heading-rank.test.ts` 가 나란히 서는 구역 제목들이 정본과 같은 `font-size` 와 `font-weight` 를 쓰는지 CSS 를 파싱해 확인한다. 굵기를 빼 보고 실제로 멈추는 것을 확인했다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 검사가 보는 것은 CSS 선언이다. 실제로 그려진 크기는 아니다 — 다른 규칙이 덮으면 선언이 같아도 결과가 갈릴 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: measure-what-is-visible-not-a-proxy
|
||||
title: 프록시 지표가 아니라 보이는 것을 측정한다
|
||||
topic: css-rules-that-leak
|
||||
topicName: CSS 규칙이 구역을 넘어 샌다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
evidence:
|
||||
- ../../../final/evidence/browser/tab-metrics.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§17.5
|
||||
- final/document.md#§11.2
|
||||
---
|
||||
|
||||
# 프록시 지표가 아니라 보이는 것을 측정한다
|
||||
|
||||
격자와 열을 재고 「정상」이라고 답했는데, 전체 페이지 스크린샷을 찍으니 제목이 26px 에 굵기 400 이었다. 목록 간격을 바운딩 박스로만 재서 엉뚱한 곳을 결함으로 지목한 적도 있다. 화면이 잘못됐다는 보고는 보이는 것을 재서 확인한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **규칙이 없었던 게 아니라 절반만 있었다**
|
||||
이 기준의 근거 사건이다.
|
||||
- **구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다**
|
||||
같은 화면에서 보이는 것을 재서 확인한 사건이다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
폭마다 측정을 남긴 기록이다.
|
||||
|
||||
## 목적
|
||||
|
||||
화면이 잘못됐다는 보고를 확인할 때 엉뚱한 값을 재고 「정상」이라 답하는 것을 막는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**보고된 증상과 같은 축의 값을 잰다**
|
||||
「제목이 작아 보인다」는 글자 크기와 굵기다. 격자와 열은 다른 축이다.
|
||||
|
||||
**촬영을 스크립트로 고정한다**
|
||||
손으로 찍으면 뷰포트와 축소 배율이 매번 달라진다.
|
||||
|
||||
**폭을 고정해 여러 개를 돌고, 폭마다 측정도 함께 남긴다**
|
||||
스크린샷만 남기면 나중에 그 값이 얼마였는지 다시 잴 수 없다.
|
||||
|
||||
**전체 페이지를 한 장으로 찍지 않는다**
|
||||
축소되어 글자 크기가 실제와 달라진다. 화면 높이만큼 잘라 찍는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
화면이 잘못됐다는 보고를 확인하는 일. 배포본의 실제 렌더 결과를 판단 근거로 삼을 때.
|
||||
|
||||
## 예외
|
||||
|
||||
측정 대상이 좌표나 간격 자체라면 바운딩 박스는 프록시가 아니라 대상이다.
|
||||
|
||||
CSS 선언이 정본과 같은지를 보는 검사는 렌더 결과를 재지 않는다. 그 검사가 덮는 범위를 알고 쓰면 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
격자와 열만 재고 「정상」이라 답했다. 사용자가 다시 지적한 뒤 전체 페이지 스크린샷을 찍어서야 26px 과 400 을 봤다.
|
||||
|
||||
목록 간격을 바운딩 박스로만 재서 엉뚱한 구역을 결함으로 지목한 적이 있다.
|
||||
|
||||
브라우저 세션이 리셋되면 창이 약 877px 로 돌아가는데 이 사이트의 분기는 1179·1050·900·767 이라, 방문자 대부분이 보지 않는 배치를 놓고 디자인을 논하게 된다.
|
||||
|
||||
전체 페이지를 한 장으로 찍으면 1425x4466 이 638x2000 으로 들어와 17px 글자가 7~8px 이 된다.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: scope-a-layout-rule-to-what-uses-it
|
||||
title: 배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다
|
||||
topic: css-rules-that-leak
|
||||
topicName: CSS 규칙이 구역을 넘어 샌다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§11.1
|
||||
---
|
||||
|
||||
# 배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다
|
||||
|
||||
구역 클래스 아래에 태그 선택자로 배치를 걸면 그 구역 안의 모든 같은 태그가 걸린다. 제목 안의 링크가 행용 격자에 갇혀 h2 높이가 199px 이 됐다. 배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다**
|
||||
이 기준의 근거 사건이다.
|
||||
- **프록시 지표가 아니라 보이는 것을 측정한다**
|
||||
이 부류를 확인하는 방법이 그 기준에 있다.
|
||||
- **규칙이 없었던 게 아니라 절반만 있었다**
|
||||
같은 화면에서 난 다른 부류의 CSS 결함이다.
|
||||
|
||||
## 목적
|
||||
|
||||
「디자인이 안 된 것처럼 보인다」는 보고의 원인이 되는 선택자 누출을 막는다. 이 부류는 규칙 자체는 맞고 걸리는 대상이 넓다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**배치 속성은 그 배치를 쓰는 요소까지 좁혀 적는다**
|
||||
`display: grid|flex`, `grid-template-columns`, `padding` 을 구역 클래스 아래 태그 선택자로 걸지 않는다.
|
||||
|
||||
**CSS 만으로 막지 않고 구조도 본다**
|
||||
선택자를 좁히면 같은 구조가 다시 생겼을 때 다시 샌다. 제목 안에 링크를 두지 않는 편이 낫다.
|
||||
|
||||
**이미 좁혀 둔 곳은 검사에서 명시적으로 뺀다**
|
||||
예외를 적어 두지 않으면 검사 결과가 늘 빨갛고 곧 읽히지 않는다.
|
||||
|
||||
**전역 규칙과 CSS module 이 섞인 화면을 따로 센다**
|
||||
전역 규칙을 고쳐도 module 을 쓰는 화면에는 닿지 않는다. 한 화면 안에서 두 언어가 섞이면 더 눈에 띈다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
구역 클래스 아래에 태그 선택자로 배치를 거는 CSS. 같은 구역 안에 제목과 목록이 함께 있으면 특히 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다.
|
||||
|
||||
구역 안의 모든 같은 태그가 실제로 같은 배치를 써야 하는 화면이라면 좁히지 않아도 된다. 그때는 그 의도를 검사의 예외 목록에 적는다.
|
||||
|
||||
## 예시
|
||||
|
||||
비교 행을 위한 격자가 제목 안의 링크까지 잡아 제목이 200px 칸에 갇혔다.
|
||||
|
||||
같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤다.
|
||||
|
||||
`section-selector-scope.test.ts` 가 `.클래스 태그` 모양에 배치 속성이 걸려 있으면 멈춘다.
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-operation-you-can-see-but-cannot-call
|
||||
title: 타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다
|
||||
topic: declared-but-not-implemented
|
||||
topicName: 계약에 선언만 있고 구현이 없다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§4.4
|
||||
---
|
||||
|
||||
# 타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다
|
||||
|
||||
계약에서 타입이 생성되므로 에디터에서는 그 연산이 멀쩡히 보인다. 기여 목록에 등록하지 않으면 실행할 때 부를 수가 없고, 게이트웨이는 다른 연산으로 떨어진다. 개념 화면이 질문 조회를 부르고 개념 삭제가 질문 삭제를 불렀다. 같은 누락을 네 번 만났다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다**
|
||||
이쪽은 서버에 구현이 없었고, 여기서는 프론트가 등록을 빠뜨렸다.
|
||||
- **계약과 구현은 서버와 화면 양쪽에서 전수 대조한다**
|
||||
이 누락을 잡는 가드가 그 기준에 있다.
|
||||
- **구현이 종류를 좁게 적어도 넓은 포트를 만족했다**
|
||||
같은 개념 삭제 경로에서 타입 검사가 통과시킨 다른 결함이다.
|
||||
|
||||
## 문제
|
||||
|
||||
관리 계약의 연산은 `tech-log-management-contract-contribution.ts` 에 등록해야 실행 시 부를 수 있다. 계약에서 타입은 생성되므로 등록을 빠뜨려도 컴파일은 통과한다.
|
||||
|
||||
등록되지 않은 연산을 부르면 게이트웨이가 그 연산을 찾지 못하고 옆의 분기로 떨어진다. 그래서 증상이 「없는 연산」이 아니라 「다른 연산이 실행됨」으로 나온다.
|
||||
|
||||
## 결론
|
||||
|
||||
네 번 났고 전부 같은 원인이었다.
|
||||
|
||||
`getPublicConcept` : 개념 화면이 질문 조회를 불렀다
|
||||
`deleteConceptDraft` : 개념 삭제가 질문 삭제를 불렀다
|
||||
`listStudioQuestions` · `listStudioProjectDecisions` : 홈 편집기가 빈 목록을 그렸다
|
||||
축(variant) CRUD 네 연산 : 축 화면이 데이터를 받지 못했다
|
||||
|
||||
공개 계약은 전수 대조하고, 관리 계약은 「한 종류만 빠진 항목」을 보는 가드를 뒀다. 깨진 것이 늘 그 모양이었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 15e6ea8 이후
|
||||
계약 : studio-management-v1 86 operation · public-v1 20 operation
|
||||
확인 방식 : 계약이 선언한 연산과 기여 목록을 대조하는 테스트
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 계약에 연산을 더하고 타입을 생성한다
|
||||
2. 기여 목록에 등록하지 않은 채 그 연산을 부르는 화면을 연다
|
||||
3. 개발자도구 네트워크에서 실제로 나가는 경로를 본다 — 등록된 다른 연산의 경로가 나간다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 등록하지 않으면 옆으로 떨어진다
|
||||
|
||||
개념 삭제가 계속 질문 삭제 경로로 나갔고, 배포된 번들에서 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404` 가 찍혔다. 개념 상세 주소도 마찬가지로 질문 조회를 불러 404 를 받았다.
|
||||
|
||||
증상이 「연산을 찾을 수 없습니다」였다면 바로 보였을 것이다. 게이트웨이가 옆 분기로 떨어지므로 서버는 정상적으로 응답하고, 다만 다른 기록을 다룬다.
|
||||
|
||||
## 네 번의 누락
|
||||
|
||||
- `getPublicConcept` — 개념 화면이 질문 조회를 불렀다
|
||||
- `deleteConceptDraft` — 개념 삭제가 질문 삭제를 불렀다
|
||||
- `listStudioQuestions` 와 `listStudioProjectDecisions` — 홈 편집기가 빈 목록을 그렸다
|
||||
- 축(variant) CRUD 네 연산 — 축 화면이 데이터를 받지 못했다
|
||||
|
||||
## 가드 둘
|
||||
|
||||
축 CRUD 를 더한 커밋에서 가드를 둘 넣었다. 공개 계약은 전수 대조한다 — 계약이 선언한 연산이 기여 목록에 전부 있는지 본다. 관리 계약은 86 operation 이라 전수 대조가 무겁고, 대신 「한 종류만 빠진 항목」을 본다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
관리 계약 쪽 가드는 종류가 빠진 것만 본다. 연산 전체를 빠뜨리는 경우는 이 가드가 잡지 않고, 그 상태를 만들어 확인하지도 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: five-screens-were-quietly-empty
|
||||
title: 계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다
|
||||
topic: declared-but-not-implemented
|
||||
topicName: 계약에 선언만 있고 구현이 없다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§4.1
|
||||
- final/document.md#§4.2
|
||||
- final/document.md#§10.4
|
||||
---
|
||||
|
||||
# 계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다
|
||||
|
||||
홈의 「지금 집중하는 것」 영역은 화면에 나타난 적이 없었고, 프로젝트는 공개할 방법이 없었고, 어떤 기록도 다른 기록을 연결 대상으로 고를 수 없었다. 계약에는 그 연산들이 전부 선언돼 있었다. 서버에 구현이 없었고, 프론트는 계약을 믿고 불렀고, 화면은 404 를 「데이터가 없음」으로 그렸다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다**
|
||||
같은 시기에 난 다른 부류의 누락이다. 이쪽은 서버에 구현이 없었고 그쪽은 프론트가 등록을 빠뜨렸다.
|
||||
- **계약과 구현은 서버와 화면 양쪽에서 전수 대조한다**
|
||||
이 사건 뒤에 세운 기준이다.
|
||||
- **「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다**
|
||||
같은 404 를 화면이 어떻게 그렸는지가 그 기록에 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
계약이 「이 연산이 있다」고 말하면 프론트는 그것을 부른다. 서버에 그 컨트롤러가 없으면 404 가 돌아오고, 화면은 그 404 를 빈 목록으로 그린다.
|
||||
|
||||
빈 목록과 「아직 안 쓴 글」은 화면에서 같아 보인다. 그래서 다섯 화면이 비어 있는 동안 아무도 오류를 보지 못했다.
|
||||
|
||||
## 결론
|
||||
|
||||
다섯 화면이 비어 있었고 원인은 하나였다. 계약에 선언만 있고 구현이 없었다.
|
||||
|
||||
홈 「지금 집중하는 것」 : 세 슬롯이 다 비면 영역 자체를 그리지 않아 운영에서 나타난 적이 없다
|
||||
프로젝트 공개 여부 : 투영의 PROJECT 행을 세우는 경로가 없어 영원히 비공개였다
|
||||
문서 사이 관계 연결 : 어댑터의 RELATION 과 EVIDENCE 가 빈 목록 스텁이었다
|
||||
프로젝트 활동 : 목록·생성·수정이 계약에 있고 테이블은 0행이었다
|
||||
릴리즈 : 읽는 쪽만 있고 쓰는 쪽이 없었다
|
||||
|
||||
생성 모델 검사는 schema 와 property 만 보므로 이 구멍을 잡지 못한다. 모델은 멀쩡히 생성된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-backend : 365560e 이후
|
||||
tech-log-frontend : 계약에서 생성한 타입을 그대로 사용
|
||||
확인 방식 : 계약이 선언한 연산과 `@RestController` 매핑을 리플렉션으로 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 계약에 연산을 하나 선언하고 컨트롤러는 만들지 않는다
|
||||
2. 프론트에서 그 연산을 부르는 화면을 연다 — 404 가 돌아오고 화면은 빈 목록을 그린다
|
||||
3. `ContractRouteCoverageTest` 를 돌린다 — 그 연산 하나를 짚는다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 비어 있던 다섯 화면
|
||||
|
||||
| 무엇이 비었나 | 왜 |
|
||||
|---|---|
|
||||
| 홈 「지금 집중하는 것」 | `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣었고, `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다 |
|
||||
| 프로젝트 공개 여부 | 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데, 공개 화면들은 전부 `public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다 |
|
||||
| 문서 사이 관계 연결 | `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 스텁이었다 |
|
||||
| 프로젝트 활동 | 계약에 목록·생성·수정이 선언돼 있었지만 구현이 없었고 `project_activity` 는 0행이었다 |
|
||||
| 릴리즈(변경 기록) | 읽는 쪽은 있는데 쓰는 쪽이 없어, 페이지는 영원히 빈 채였다 |
|
||||
|
||||
홈 focus 가 가장 오래 숨었다. 세 슬롯이 다 비면 화면이 그 영역을 통째로 그리지 않으므로, 그런 영역이 있다는 사실조차 화면에서 알 수 없다.
|
||||
|
||||
## 편집기가 부르던 두 목록
|
||||
|
||||
`GET /v1/studio/questions` 와 `GET /v1/studio/projects/{id}/decisions` 도 같은 모양이었다. 계약에 있고 모델도 생성됐는데 컨트롤러가 없었다. 화면은 그것을 「이 프로젝트에 열린 질문이 없습니다」로 그렸고, 실제로는 넷이 있었으며 공개 사이트에도 나오고 있었다.
|
||||
|
||||
## 마이그레이션 직후의 값
|
||||
|
||||
같은 계약이 반대 방향으로도 깨졌다. `home_focus_config.default_focus_type` 은 마이그레이션 직후 NULL 인데 계약은 이 필드를 required 에 enum 세 값으로 선언한다. 배포 직후 첫 요청부터 `/home` 이 깨졌고, `HomeFocusView.resolve` 가 반드시 유효한 값 하나를 정하도록 고쳤다.
|
||||
|
||||
## 계약과 컨트롤러를 전수로 맞춘다
|
||||
|
||||
`ContractRouteCoverageTest` 가 `@RestController` 들을 리플렉션으로 훑어 매핑을 모으고 계약이 선언한 경로와 대조한다.
|
||||
|
||||
- 작업본 API 로 대체된 옛 연산 51개는 `SUPERSEDED_BY_WORKING_COPY_API` 로 명시한다
|
||||
- 봉투 없이 바이트를 주는 `/media` 하나만 `ELSEWHERE` 로 면제한다
|
||||
- 매핑을 떼어 보고 그 연산 하나를 정확히 짚는 것을 확인했다
|
||||
|
||||
프론트에도 같은 가드를 뒀다. 양쪽에서 봐야 한쪽만 지웠을 때 잡힌다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
홈 focus 의 옛 증상은 재현할 수 없다. 세 슬롯이 다 비면 영역을 그리지 않으므로 화면에 남은 흔적이 없고, 지금 고쳐져 있다는 것만 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: compare-the-contract-with-both-implementations
|
||||
title: 계약과 구현은 서버와 화면 양쪽에서 전수 대조한다
|
||||
topic: declared-but-not-implemented
|
||||
topicName: 계약에 선언만 있고 구현이 없다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§4.3
|
||||
- final/document.md#§4.4
|
||||
---
|
||||
|
||||
# 계약과 구현은 서버와 화면 양쪽에서 전수 대조한다
|
||||
|
||||
계약이 한 저장소에 있고 두 저장소가 그것을 반입해 각자 구현하면, 어느 한쪽이 빠뜨린 것을 컴파일러가 보지 못한다. 이 저장소에서 그 구멍이 서버 쪽으로 다섯 번, 화면 쪽으로 네 번 났다. 두 쪽 모두에서 계약과 대조하는 검사를 돌린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다**
|
||||
서버 쪽 누락의 근거 사건이다.
|
||||
- **타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다**
|
||||
화면 쪽 누락의 근거 사건이다.
|
||||
- **종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다**
|
||||
컴파일러가 볼 수 있는 범위 안쪽을 다루는 짝이 되는 기준이다.
|
||||
|
||||
## 목적
|
||||
|
||||
계약이 선언한 연산에 구현이 없는 상태를 배포 전에 잡는다. 이 상태는 오류를 내지 않는다 — 서버는 404 를 주고 화면은 그것을 빈 데이터로 그린다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**서버 쪽은 매핑을 리플렉션으로 모아 계약의 경로와 전수 대조한다**
|
||||
`@RestController` 들을 훑어 실제 매핑을 모으고, 계약이 선언한 경로 전부와 맞춘다.
|
||||
|
||||
**화면 쪽은 계약이 선언한 연산이 기여 목록에 등록됐는지 본다**
|
||||
타입은 계약에서 생성되므로 등록을 빠뜨려도 컴파일이 통과한다. 그 상태에서 부르면 게이트웨이가 옆 분기로 떨어져 다른 연산이 실행된다.
|
||||
|
||||
**구현하지 않기로 한 연산은 이유와 함께 명시 목록에 넣는다**
|
||||
「빠뜨린 것」과 구분되지 않으면 대조 결과가 곧 무시된다. 이 저장소는 작업본 API 로 대체된 옛 연산 51개를 그렇게 표시하고, 봉투 없이 바이트를 주는 연산 하나를 면제 목록에 뒀다.
|
||||
|
||||
**두 쪽 다 돌린다**
|
||||
한쪽만 대조하면 다른 쪽을 지웠을 때 잡히지 않는다.
|
||||
|
||||
**생성 모델 검사를 이 대조로 세지 않는다**
|
||||
모델 생성은 schema 와 property 만 본다. 구현이 없어도 모델은 멀쩡히 만들어진다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
계약이 한 저장소에 있고 두 저장소가 그것을 반입해 각자 구현하는 구조. 연산을 더하거나 지우는 변경에서 이 대조를 돌린다.
|
||||
|
||||
## 예외
|
||||
|
||||
계약과 구현이 같은 저장소에 있고 같은 빌드를 지나면 컴파일러가 이 대조를 대신한다.
|
||||
|
||||
연산이 봉투 규약을 따르지 않으면 경로 대조에서 뺀다. 다만 뺀 이유를 목록에 적는다.
|
||||
|
||||
## 예시
|
||||
|
||||
매핑 하나를 떼어 보고 대조 검사가 그 연산 하나를 정확히 짚는 것을 확인한 뒤 커밋했다.
|
||||
|
||||
관리 계약은 86 operation 이라 전수 대조 대신 「한 종류만 빠진 항목」을 보게 했다. 깨진 것이 늘 그 모양이었다.
|
||||
|
||||
옛 연산 51개를 명시하지 않았다면 대조 결과가 51건의 실패로 나와 아무도 읽지 않았을 것이다.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: it-said-there-were-no-open-questions
|
||||
title: 「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다
|
||||
topic: failure-drawn-as-absence
|
||||
topicName: 실패를 없음으로 그린다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§10.1
|
||||
---
|
||||
|
||||
# 「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다
|
||||
|
||||
홈 「지금 집중하는 것」 편집기가 「이 프로젝트에 열린 질문이 없습니다」라고 적었다. 실제로는 넷이 있었고 공개 사이트에도 나오고 있었다. 편집기가 질문과 결정을 못 읽으면 빈 배열로 삼키고 있었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
이 사건에서 굳힌 규칙이다.
|
||||
- **계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다**
|
||||
못 읽은 원인이 그 기록에 있다 — 컨트롤러가 없었다.
|
||||
- **한 칸의 실패가 옆 칸을 끌고 내려갔다**
|
||||
같은 편집기에서 난 다른 부류의 실패다.
|
||||
|
||||
## 문제
|
||||
|
||||
홈 편집기에서 「지금 집중하는 것」에 걸 질문을 고르려 했다. 목록이 비어 있고 「이 프로젝트에 열린 질문이 없습니다」가 적혀 있었다.
|
||||
|
||||
같은 프로젝트의 공개 사이트에는 질문 넷이 나오고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
편집기가 질문과 결정 목록을 못 읽으면 빈 배열로 삼키고 있었다. 서버는 404 를 주고 있었다 — 그 두 연산에 컨트롤러가 없었다.
|
||||
|
||||
작성자에게는 「아직 안 쓴 것」으로 읽힌다. 실제로는 쓴 것을 못 읽은 것이다.
|
||||
|
||||
> 거짓말을 하느니 못 읽었다고 말한다.
|
||||
|
||||
못 읽었을 때 못 읽었다고 적게 고쳤다. 같은 판단을 주제 탭에도 적용했다 — 탭 하나를 못 받아도 탭 줄과 나머지는 그대로 남고, 못 받은 탭에는 못 받았다고 적는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 7acde27 · 3bb724b
|
||||
tech-log-backend : 911e8ba — 없던 두 컨트롤러를 구현
|
||||
확인 방식 : 편집기 목록과 공개 사이트의 같은 프로젝트를 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 프로젝트에 Open Question 을 하나 이상 게시한다
|
||||
2. 그 목록을 주는 연산을 서버에서 막는다
|
||||
3. 홈 편집기를 연다 — 옛 코드에서는 「없습니다」가, 지금은 못 읽었다는 문구가 나온다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 빈 배열이 두 가지를 뜻했다
|
||||
|
||||
편집기는 질문 목록을 받아 고를 수 있게 그린다. 목록이 비면 「이 프로젝트에 열린 질문이 없습니다」를 적는다.
|
||||
|
||||
요청이 실패했을 때도 빈 배열이 되고 있었다. 그래서 「없다」와 「못 읽었다」가 같은 화면이 됐다.
|
||||
|
||||
## 작성자가 무엇으로 읽었나
|
||||
|
||||
작성자는 자기가 쓴 것과 화면을 대조한다. 화면이 「없습니다」라고 하면 아직 안 썼거나 게시하지 않았다고 읽는다.
|
||||
|
||||
이 경우에는 넷이 있었고 공개 사이트에도 나오고 있었다. 편집기만 못 읽고 있었다.
|
||||
|
||||
## 못 읽었다고 적는다
|
||||
|
||||
> 거짓말을 하느니 못 읽었다고 말한다.
|
||||
|
||||
요청이 실패하면 실패했다고 적는다. 0건은 0건이라고 적는다. 이 둘을 구분할 수 있어야 작성자가 다음에 무엇을 할지 정할 수 있다.
|
||||
|
||||
## 같은 판단을 다른 화면에
|
||||
|
||||
주제 탭에도 같은 판단을 적용했다. 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그려지고, 못 받은 탭에는 못 받았다고 적는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
화면이 못 읽은 것을 없다고 그리는 곳을 전수로 세지 않았다. 고친 것은 홈 편집기와 주제 탭 둘이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: one-cell-failing-took-its-neighbour-down
|
||||
title: 한 칸의 실패가 옆 칸을 끌고 내려갔다
|
||||
topic: failure-drawn-as-absence
|
||||
topicName: 실패를 없음으로 그린다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§10.2
|
||||
---
|
||||
|
||||
# 한 칸의 실패가 옆 칸을 끌고 내려갔다
|
||||
|
||||
편집기가 질문과 결정을 하나로 묶어 읽고 있었다. 결정만 터지는데 멀쩡히 오던 질문 목록까지 「불러오지 못했습니다」가 됐다. 더 미묘한 변종도 있었다 — 호출이 동기적으로 던지면 묶는 중에 터져 거절 처리를 지나지도 못한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
같은 편집기에서 나온 짝이 되는 규칙이다.
|
||||
- **「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다**
|
||||
같은 편집기의 다른 부류 실패다.
|
||||
- **홈의 비교 구역이 세 번 바뀌었다**
|
||||
같은 판단을 주제 탭에 적용한 기록이다.
|
||||
|
||||
## 문제
|
||||
|
||||
홈 편집기가 질문과 결정을 한 번에 읽는다. 결정 쪽만 실패해도 화면 전체가 「불러오지 못했습니다」가 됐다.
|
||||
|
||||
질문 목록은 정상적으로 오고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 요청을 하나로 묶어 기다리면 한쪽의 실패가 전체를 실패로 만든다. 둘을 따로 읽도록 갈랐다.
|
||||
|
||||
더 미묘한 변종이 하나 더 있었다.
|
||||
|
||||
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
|
||||
|
||||
같은 판단을 주제 탭에도 적용했다. 탭 하나를 못 받아도 탭 줄과 나머지는 그대로 남고, 못 받은 탭에는 못 받았다고 적는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 6e784ed · fd73bc8 · 3bb724b
|
||||
확인 방식 : 한쪽 연산만 실패시키고 나머지가 그려지는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 편집기가 부르는 두 연산 중 하나만 실패시킨다
|
||||
2. 화면에서 나머지 하나가 그려지는지 본다
|
||||
3. 실패하는 쪽을 동기적으로 던지게 바꾸고 다시 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 묶어 읽으면 한쪽이 전체를 끌고 내려간다
|
||||
|
||||
편집기가 질문 목록과 결정 목록을 하나로 묶어 기다리고 있었다. 결정 쪽 연산에 컨트롤러가 없어 404 가 났고, 화면은 두 목록을 다 못 받은 것으로 그렸다.
|
||||
|
||||
질문 목록은 정상적으로 오고 있었다. 둘을 따로 읽도록 갈랐다.
|
||||
|
||||
## 동기적으로 던지면 거절 처리를 지나지 않는다
|
||||
|
||||
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
|
||||
|
||||
거절과 던짐이 다른 경로를 탄다. 배열을 만드는 표현식 안에서 던지면 그 표현식이 완성되지 않으므로 거절 처리기가 붙을 대상이 없다.
|
||||
|
||||
## 탭에도 같은 판단을
|
||||
|
||||
탭 줄은 목록 하나로 그리고 상세는 고른 탭만 받는다. 상세 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
같은 모양으로 묶어 읽는 곳을 전수로 세지 않았다. 갈라 놓은 것은 홈 편집기와 주제 탭 둘이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: records-disappeared-without-a-trace
|
||||
title: 매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다
|
||||
topic: failure-drawn-as-absence
|
||||
topicName: 실패를 없음으로 그린다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§10.6
|
||||
---
|
||||
|
||||
# 매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다
|
||||
|
||||
프로젝트 기록 목록에서 Open Question 이 보이지 않았다. 이 목록은 탐색의 지식 목록과 응답 모양이 다른데 그쪽 매퍼를 그대로 쓰고 있었다. 그 매퍼는 두 종류가 아니면 `null` 을 돌려주고 호출부가 걸러 내므로, 질문과 개념은 오류도 빈 줄도 남기지 않고 사라진다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
같은 부류를 다루는 규칙이다.
|
||||
- **개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다**
|
||||
이 매퍼가 그 열세 곳 중 하나다.
|
||||
- **합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다**
|
||||
스텁 때문에 매핑이 검사되지 않던 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
프로젝트 화면의 기록 목록에 CASE 와 REFERENCE 만 나왔다. 같은 프로젝트에 게시된 질문과 개념이 목록에 없었다.
|
||||
|
||||
오류는 없었다. 목록이 한 줄 짧아질 뿐이라 눈으로는 알아채기 어렵다.
|
||||
|
||||
## 결론
|
||||
|
||||
프로젝트 기록 목록은 탐색의 지식 목록과 응답 모양이 다른데 지식 목록의 매퍼를 그대로 쓰고 있었다.
|
||||
|
||||
그 매퍼는 CASE 나 REFERENCE 가 아니면 `null` 을 돌려준다. 호출부가 `filter` 로 걸러 내므로 그 항목은 목록에서 없어진다.
|
||||
|
||||
증상 : 목록이 한 줄 짧아진다
|
||||
오류 : 없음
|
||||
빈 줄 : 없음
|
||||
|
||||
응답 모양에 맞는 매퍼를 쓰고 모든 종류를 싣게 고쳤다. 요약과 주제와 게시일도 함께 실었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 77125d1
|
||||
tech-log-backend : f0407d9 — 목록이 요약·주제·게시일을 싣게 함
|
||||
tech-log-design-package : 76a7ccb
|
||||
확인 방식 : 프로젝트에 종류별로 기록을 게시하고 목록에 전부 나오는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 한 프로젝트에 CASE·REFERENCE·QUESTION·CONCEPT 을 각각 하나씩 게시한다
|
||||
2. 프로젝트 화면의 기록 목록을 연다
|
||||
3. 네 종류가 다 나오는지 센다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## null 을 돌려주고 걸러 내면 흔적이 없다
|
||||
|
||||
매퍼가 아는 종류가 아니면 `null` 을 돌려준다. 호출부는 그 목록에서 `null` 을 걸러 낸다.
|
||||
|
||||
이 조합에서는 오류가 나지 않고 빈 줄도 생기지 않는다. 목록의 길이만 줄어든다. 목록에 몇 개가 있어야 하는지 아는 사람만 알아챌 수 있다.
|
||||
|
||||
## 응답 모양이 다른 목록에 다른 매퍼를 썼다
|
||||
|
||||
프로젝트 기록 목록과 탐색의 지식 목록은 응답 모양이 다르다. 프로젝트 쪽은 관계 항목을 그대로 실어 요약도 주제도 게시일도 없었고, 지식 목록은 처음부터 그 칸들을 갖고 있었다.
|
||||
|
||||
모양이 다른데 매퍼를 공유하면서 종류 판정까지 그쪽 것을 따랐다.
|
||||
|
||||
## 고친 것
|
||||
|
||||
응답 모양에 맞는 매퍼를 쓰고 모든 종류를 싣게 했다. 목록 항목에 요약과 주제와 게시일을 더해 「제목만 있고 가운뎃점만 남은」 줄을 없앴다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
`null` 을 돌려주고 호출부가 거르는 매퍼가 다른 목록에도 남아 있는지는 세지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: an-expected-failure-must-not-be-counted-as-a-failure
|
||||
title: 매번 우는 검사는 읽히지 않는다 — 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다
|
||||
topic: failure-drawn-as-absence
|
||||
topicName: 실패를 없음으로 그린다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§10.5
|
||||
---
|
||||
|
||||
# 매번 우는 검사는 읽히지 않는다 — 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다
|
||||
|
||||
배포 뒤 전 화면을 훑는 스윕이 기대된 404 를 실패로 셌다. 건강한 배포 아래에 매번 같은 빨간 줄이 남았고, 그 줄은 곧 읽히지 않게 됐다. 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
같은 프로젝트에서 실패를 어떻게 다룰지 정한 짝이 되는 기준이다.
|
||||
- **가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다**
|
||||
검사가 실제로 무엇을 잡는지 확인하는 기준이다.
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
같은 스윕이 실제 결함을 잡아야 했던 사건이다.
|
||||
|
||||
## 목적
|
||||
|
||||
매번 우는 검사가 읽히지 않게 되는 것을 막는다. 진짜 실패가 그 옆에 앉아 있어도 아무도 보지 않는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**기대된 실패는 조건을 적어 뺀다**
|
||||
미리보기가 아직 없는 문서는 현재 미리보기를 물으면 404 를 답하고, 화면은 그것을 「미리보기를 만드세요」로 바꾼다. 이런 응답은 실패가 아니다.
|
||||
|
||||
**뺀 나머지는 전부 실패시킨다**
|
||||
조건에 걸리지 않는 4xx 와 5xx 는 모두 스윕을 실패시킨다.
|
||||
|
||||
**조건을 적을 수 없으면 빼지 않는다**
|
||||
조건 없이 빼면 진짜 실패도 같이 빠진다.
|
||||
|
||||
**뺀 조건을 사람이 읽을 수 있는 곳에 남긴다**
|
||||
왜 그 응답이 기대된 것인지 적혀 있지 않으면 다음 사람이 조건을 넓힌다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
배포 뒤 전 화면을 훑는 스윕처럼 결과를 사람이 훑어보는 검사. 정상 동작이 오류 상태 코드로 나타나는 화면이 있는 서비스에서 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
기계가 판정하고 사람이 결과를 읽지 않는 검사라면 빨간 줄이 쌓여도 무뎌지지 않는다. 그래도 통과 기준은 정해야 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
미리보기가 없는 문서의 404 를 스윕이 실패로 셌다. 건강한 배포 아래에 매번 같은 빨간 줄이 남았다.
|
||||
|
||||
> 매번 늑대를 외치는 검사는 읽히지 않게 되고, 진짜 실패가 그 옆에 눈에 띄지 않은 채 앉아 있게 된다.
|
||||
|
||||
로그인 전 세션 탐침의 401 도 같은 부류라 같은 조건으로 제외하고, 나머지 4xx·5xx 는 전부 스윕을 실패시킨다.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: say-you-could-not-read-it
|
||||
title: 화면은 못 읽은 것을 없다고 말하지 않는다
|
||||
topic: failure-drawn-as-absence
|
||||
topicName: 실패를 없음으로 그린다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§10.1
|
||||
- final/document.md#§17.3
|
||||
---
|
||||
|
||||
# 화면은 못 읽은 것을 없다고 말하지 않는다
|
||||
|
||||
목록이나 요약처럼 「비어 있음」이 정상값인 화면에서는 실패와 0건이 같은 모양으로 그려진다. 작성자는 그것을 자기가 아직 쓰지 않은 것으로 읽는다. 못 읽었으면 못 읽었다고 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다**
|
||||
이 규칙의 근거 사건이다.
|
||||
- **한 칸의 실패가 옆 칸을 끌고 내려갔다**
|
||||
실패가 어디까지 번지는지를 다룬 사건이다.
|
||||
- **매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다**
|
||||
실패가 아니라 항목이 사라진 변종이다.
|
||||
|
||||
## 목적
|
||||
|
||||
작성자가 「아직 안 썼다」와 「못 읽었다」를 구분할 수 있게 한다. 이 둘이 같은 화면이면 작성자는 다음에 무엇을 할지 정할 수 없다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**요청이 실패하면 실패했다고 적는다**
|
||||
빈 배열로 삼키지 않는다. 0건과 실패는 다른 문구를 쓴다.
|
||||
|
||||
**한 칸의 실패가 옆 칸을 끌고 내려가지 않게 한다**
|
||||
여러 목록을 하나로 묶어 기다리면 한쪽의 실패가 전체를 실패로 만든다. 따로 읽고 실패한 목록에만 적는다.
|
||||
|
||||
**거절만 잡는 처리로는 부족하다**
|
||||
호출이 동기적으로 던지면 그 처리기를 지나지 않는다. 던지는 경로도 함께 잡는다.
|
||||
|
||||
**항목을 걸러 낼 때 걸러 낸 것을 세어 둔다**
|
||||
매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 거르면, 목록이 한 줄 짧아지는 것 말고는 흔적이 없다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
목록·요약·카운트처럼 「비어 있음」이 정상값이라 실패와 구분되지 않는 화면. 작성 도구에서 특히 걸린다 — 작성자가 자기 작업물과 화면을 대조하기 때문이다.
|
||||
|
||||
## 예외
|
||||
|
||||
정말로 0건인 것과 못 읽은 것을 구분할 수 없는 화면이라면 그 구분을 먼저 만든다. 구분 없이 문구만 바꾸면 0건이 실패로 읽힌다.
|
||||
|
||||
읽는 사람이 그 데이터를 만들지 않는 화면 — 공개 조회 — 에서는 실패를 화면 전체의 오류로 다뤄도 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
「이 프로젝트에 열린 질문이 없습니다」가 적혀 있는 동안 그 프로젝트에는 질문이 넷 있었고 공개 사이트에도 나오고 있었다.
|
||||
|
||||
탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
|
||||
|
||||
매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 걸러 내, 질문과 개념이 목록에서 사라졌다.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: one-new-kind-fell-through-thirteen-places
|
||||
title: 개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다
|
||||
topic: hand-listed-kinds
|
||||
topicName: 손으로 나열한 종류 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/guards/kind-tables-now.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§3.1
|
||||
- final/document.md#§3.2
|
||||
- final/document.md#§3.4
|
||||
- final/document.md#§10.3
|
||||
---
|
||||
|
||||
# 개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다
|
||||
|
||||
문서 종류에 CONCEPT 을 더했다. 컴파일도 통과하고 테스트도 통과했는데, 개념을 지우면 「질문을 찾을 수 없습니다」가 나오고 개념 상세 주소는 404 였고 탐색에서 `type=CONCEPT` 은 0건이었다. 그 뒤로도 같은 모양이 계속 나와 열세 번을 셌다. 매번 원인이 같았다 — 다섯 종류를 손으로 적어 둔 곳이 있었고 새 종류가 마지막 `else` 로 떨어졌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식**
|
||||
이 열세 건이 왜 컴파일러를 지나갔는지가 그 개념에 있다.
|
||||
- **종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다**
|
||||
이 사건에서 굳힌 규칙이다.
|
||||
- **종류를 세는 곳 둘이 아직 컴파일러의 보호 밖에 있다**
|
||||
그중 아직 표로 바꾸지 못한 둘을 그 질문에서 다룬다.
|
||||
- **타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다**
|
||||
같은 종류를 더하면서 생긴 다른 부류의 누락이다.
|
||||
|
||||
## 문제
|
||||
|
||||
문서 종류는 다섯이다 — CASE, REFERENCE, QUESTION, CONCEPT, PROJECT_DECISION. 새 종류를 하나 더하면 그 종류를 아는 곳이 전부 함께 늘어나야 한다.
|
||||
|
||||
실제로는 늘지 않은 곳이 열세 곳 있었고, 그중 어느 곳도 오류를 내지 않았다. 삼항 사슬의 마지막 `else` 와 배열 리터럴의 끝이 모르는 값을 조용히 받아 갔다.
|
||||
|
||||
## 결론
|
||||
|
||||
열세 곳 전부가 같은 원인이었다. 위치는 게이트웨이, 매퍼, 백엔드 컨트롤러, 그리고 계약 자체까지 걸쳐 있다.
|
||||
|
||||
발생 위치 : 프론트엔드 9 · 백엔드 1 · 계약 3
|
||||
증상이 오류였던 건 : 3 (404 · 400 · PUBLIC_REQUEST_INVALID)
|
||||
증상이 조용한 누락이었던 건 : 10
|
||||
|
||||
표와 sealed switch 식으로 바꾼 뒤에는 종류를 더할 때 컴파일러가 빠진 값을 짚는다. 계약과 코드 사이는 컴파일러가 못 보므로 계약을 파싱해 대조하는 가드를 따로 뒀다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 2b2f443
|
||||
tech-log-backend : 8cd8ee3
|
||||
계약 : OpenAPI 3.1 3종을 반입해 타입·모델 생성
|
||||
확인 방식 : 현재 코드에서 표로 바뀐 곳과 남은 곳을 직접 조회
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. `PublicRecord["kind"]` 에 새 값을 하나 더한다
|
||||
2. `npm run check:types` 를 돌린다 — `Record<Kind, _>` 로 바꾼 곳은 여기서 멈춘다
|
||||
3. 계약의 enum 에서 CONCEPT 을 빼고 `StudioContractUnionJacksonTest` 를 돌린다 — 빨개진다
|
||||
4. `PublicSql.pathOf` 에 새 값을 넣지 않고 그 종류를 게시한다 — 컴파일은 통과하고 경로가 null 로 나간다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 마지막 else 가 모르는 것을 받아 간다
|
||||
|
||||
이 저장소에서 종류를 분기하는 코드는 대개 이렇게 생겨 있었다.
|
||||
|
||||
```ts
|
||||
// 삼항 사슬 — 마지막 else 가 모르는 것을 다 받아 간다
|
||||
const path = kind === "CASE" ? "/cases/"
|
||||
: kind === "REFERENCE" ? "/references/"
|
||||
: kind === "QUESTION" ? "/questions/"
|
||||
: "/projects/"; // ← CONCEPT 이 여기로 떨어진다
|
||||
```
|
||||
|
||||
CONCEPT 을 더해도 이 코드는 컴파일된다. `/concepts/idp-brokering` 을 열면 질문 조회가 나가고 404 가 돌아온다.
|
||||
|
||||
## 열세 곳
|
||||
|
||||
| # | 어디 | 증상 | 커밋 |
|
||||
|---|---|---|---|
|
||||
| 1 | 게이트웨이의 문서 삭제 분기 | 개념을 지우면 "질문을 찾을 수 없습니다" | `dec86bd` |
|
||||
| 2 | 게이트웨이의 문서 조회 분기 | `/concepts/idp-brokering` 이 404 (질문 조회를 불렀다) | `8996430` |
|
||||
| 3 | 응답→기록 변환 분기 | 불렸어도 질문 매핑으로 떨어졌을 것 | `8996430` |
|
||||
| 4 | 공개 주소→종류 역추적 삼항 | 개념 관계가 전부 `PROJECT` 로 분류 | `618a228` |
|
||||
| 5 | 탐색 목록 매퍼 | `type=CONCEPT` 결과 0건 (서버는 보냈다) | `4da6d77` |
|
||||
| 6 | 지식 목록 매퍼 | 개념이 통째로 버려짐 | `dc2fda7` |
|
||||
| 7 | 작업본 목록의 종류 필터 | 개념 작업본을 걸러 볼 수 없음 | `b89a54f` |
|
||||
| 8 | 모의 검증기의 유형별 칸 목록 | 개념 편집 시 모든 칸이 "허용되지 않은 속성" | `77ef304` |
|
||||
| 9 | 백엔드 컨트롤러의 허용 enum 상수 | `?type=CONCEPT` 이 `PUBLIC_REQUEST_INVALID` | `3a226fb` |
|
||||
| 10 | `CatalogEntry.kind` (계약) | 개념 작업본 생성 즉시 `/studio/catalog` 400 | `32d1785` |
|
||||
| 11 | `ResolvedRelation.targetKind` (계약) | 개념을 관계로 걸면 미리보기 깨짐 | `2c25ccc` |
|
||||
| 12 | `RelatedEntry.type` (관리 계약) | Case 가 개념을 가리킬 수 없음 | `2c25ccc` |
|
||||
| 13 | `PublicSql.pathOf` (백엔드) | CONCEPT 케이스 없음 → `null` 경로 | `8cd8ee3` |
|
||||
|
||||
10·11·12 는 계약 안에 있다. 계약이 종류를 열거하는 곳이 여러 곳이라, 계약을 고치는 커밋에서 같은 실수를 다시 했다.
|
||||
|
||||
같은 병이 종류가 아닌 곳에서도 났다. `latestEntries` 가 투영의 모든 `resource_type` 을 흘리는데 계약의 `LatestEntry.entryType` 은 네 값뿐이라, QUESTION 이 섞이면 매퍼가 500 을 내고 홈 화면 전체를 못 쓰게 만든다. 그래서 질의가 먼저 걸러 냈고, 게시한 Open Question 이 홈 최근 기록에 나오지 않았다. `pathOf` 는 이미 `/questions/{slug}` 를 만들고 있었고 projection 에도 질문 행이 채워져 있었다 — 막고 있던 것은 그 `IN` 목록 하나였다.
|
||||
|
||||
## 표로 바꾼 곳
|
||||
|
||||
```ts
|
||||
// 종류가 늘면 이 자리가 비어 있다고 컴파일러가 잡는다
|
||||
const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
|
||||
CASE: "/cases/",
|
||||
REFERENCE: "/references/",
|
||||
QUESTION: "/questions/",
|
||||
CONCEPT: "/concepts/",
|
||||
};
|
||||
```
|
||||
|
||||
백엔드에서는 sealed switch 를 식으로 쓴 곳이 이미 이 일을 하고 있었다. 개념 종류를 더한 커밋 메시지가 그 효과를 적어 두었다.
|
||||
|
||||
> sealed switch 가 이 변경을 안내했다 — 종류를 더하자 컴파일러가 게시 상태 코드·활동 유형·소유자 유형·slug 중복 검사·렌더 모델까지 빠짐없이 짚었다. 문이 아니라 식으로 써 둔 덕이다.
|
||||
|
||||
## 계약과 코드 사이
|
||||
|
||||
표로 바꿔도 계약이 종류를 빠뜨린 것은 컴파일러가 모른다. 계약 문서를 직접 파싱해 대조하는 가드를 넣었다.
|
||||
|
||||
- `knowledge-list-kinds.test.ts` — 계약의 종류 enum 을 읽어 목록 매퍼의 표에 전부 있는지 본다
|
||||
- `contract-operation-coverage.test.ts` — 계약이 선언한 연산이 기여 목록에 등록됐는지 본다
|
||||
- `StudioContractUnionJacksonTest` — 모든 `RecordKind` 가 `CatalogEntry.KindEnum` 으로 변환되는지 순회한다. 계약에서 CONCEPT 을 빼면 실제로 빨개지는 것을 확인했다
|
||||
|
||||
설계 패키지 쪽은 눈으로 찾을 일이 아니었다. 세 계약을 파싱해 「CASE 와 REFERENCE 를 함께 열거하면서 CONCEPT 이 없는 enum」을 전부 뽑았다.
|
||||
|
||||
## 지금 확인한 범위
|
||||
|
||||
표로 바뀐 곳과 남은 곳을 현재 코드에서 조회했다. 되돌려 확인한 것은 계약 대조 가드 셋이고, 나머지 열 건은 커밋 메시지와 현재 상태로만 확인했다. 옛 증상은 재현하지 않았다.
|
||||
|
||||
`PublicSql.pathOf` 와 `validate-working-copy.ts` 의 `stringFields` 는 아직 표가 아니다.
|
||||
|
||||
:::evidence key="kind-tables-now" alt="현재 코드에서 종류를 나열하는 곳을 조회한 출력" caption=" " zoom="false"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: exhaustive-switch-and-record-tables
|
||||
title: 컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식
|
||||
topic: hand-listed-kinds
|
||||
topicName: 손으로 나열한 종류 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
basisVersion: TypeScript 5.x 의 Record<K, V> 키 전수 요구 · Java 21 sealed interface 와 switch 식 · tech-log 저장소의 RecordKind 다섯 값
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§3.1
|
||||
- final/document.md#§3.3
|
||||
---
|
||||
|
||||
# 컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식
|
||||
|
||||
같은 언어 안에서도 어떤 분기는 새 값을 더할 때 컴파일러가 빠진 값을 짚고 어떤 분기는 아무 말도 하지 않는다. 삼항 사슬과 배열 리터럴은 후자이고, `Record<Kind, _>` 와 식으로 쓴 sealed switch 는 전자다. 갈림은 문법이 아니라 그 문법이 값을 전부 요구하는가에 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다**
|
||||
이 개념이 없으면 그 열세 건이 왜 컴파일을 지나갔는지 읽히지 않는다.
|
||||
- **종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다**
|
||||
이 개념에서 두 방법을 골라 규칙으로 굳혔다.
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
같은 컴파일러가 검사를 놓아 주는 다른 곳들이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 유한한 집합을 분기하는 두 가지 방법
|
||||
|
||||
문서 종류는 다섯 값이다. 그 다섯을 분기하는 코드는 두 모양 중 하나로 쓰인다.
|
||||
|
||||
하나는 값을 하나씩 비교하고 마지막에 나머지를 받는다. 삼항 사슬, `if`/`else if` 사슬, 문으로 쓴 `switch` 가 여기 속한다. 이 모양에서 컴파일러가 확인하는 것은 각 가지의 타입이 맞는지까지이고, 어떤 값이 어느 가지로 가는지는 확인하지 않는다.
|
||||
|
||||
다른 하나는 값마다 항목을 하나씩 요구한다. `Record<Kind, V>` 는 키 집합이 `Kind` 와 정확히 같기를 요구하고, 식으로 쓴 `switch` 는 모든 가지가 값을 내놓기를 요구한다. 값을 하나 더하면 그 키가 비었다고 컴파일러가 말한다.
|
||||
|
||||
```ts
|
||||
const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
|
||||
CASE: "/cases/",
|
||||
REFERENCE: "/references/",
|
||||
QUESTION: "/questions/",
|
||||
CONCEPT: "/concepts/",
|
||||
};
|
||||
```
|
||||
|
||||
## 문으로 쓴 switch 와 식으로 쓴 switch
|
||||
|
||||
Java 에서도 같은 갈림이 있다. `switch` 를 문으로 쓰면 어떤 가지도 없는 값이 그냥 지나간다. 식으로 쓰면 그 값에 대해 무엇을 반환할지 컴파일러가 요구한다. sealed 인터페이스와 함께 쓰면 하위 타입이 늘어날 때도 같은 요구가 걸린다.
|
||||
|
||||
이 저장소에서 종류를 하나 더했을 때 백엔드가 프론트보다 조용히 넘어간 곳이 적었던 이유가 그것이다. 게시 상태 코드, 활동 유형, 소유자 유형, slug 중복 검사, 렌더 모델이 전부 식으로 쓰인 switch 를 지나고 있었다.
|
||||
|
||||
## 표로 못 바꾸는 칸
|
||||
|
||||
키 집합이 그 열거형과 정확히 같을 때만 `Record<Kind, V>` 가 쓰인다. 담기는 값이 열거형 밖으로 나가면 이 방법이 걸리지 않는다.
|
||||
|
||||
공개 투영의 `resource_type` 이 그런 칸이다. 이 칸은 `RecordKind` 다섯에 더해 `PROJECT` 와 `RELEASE` 도 담는다. 그래서 `PublicSql.pathOf` 는 `RecordKind` 가 아니라 `String` 으로 분기하고 `default -> null` 이 남는다.
|
||||
|
||||
## 컴파일러가 보지 못하는 경계
|
||||
|
||||
표로 바꿔도 컴파일러가 보는 것은 한 저장소 안이다. 계약은 다른 저장소에 있고 생성기를 지나 들어오므로, 계약이 종류를 빠뜨린 것은 타입 검사가 아니라 계약을 읽어 대조하는 검사가 잡는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: two-kind-tables-outside-the-compiler
|
||||
title: 종류를 세는 곳 둘이 아직 컴파일러의 보호 밖에 있다
|
||||
topic: hand-listed-kinds
|
||||
topicName: 손으로 나열한 종류 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§16.7
|
||||
---
|
||||
|
||||
# 종류를 세는 곳 둘이 아직 컴파일러의 보호 밖에 있다
|
||||
|
||||
종류를 나열하는 곳을 표와 sealed switch 식으로 바꾸면서 둘을 남겼다. 하나는 담기는 값이 종류 밖으로 나가서, 하나는 아직 손대지 않아서다. 둘 다 새 종류를 더할 때 컴파일이 통과하고 값만 틀린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다**
|
||||
이 질문이 남은 사건이다.
|
||||
- **컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식**
|
||||
왜 이 두 곳가 표로 바뀌지 못했는지가 그 개념에 있다.
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
pathOf 가 만든 주소가 틀렸던 다른 사건이다.
|
||||
|
||||
## 사실
|
||||
|
||||
`PublicSql.pathOf` 는 `RecordKind` 가 아니라 공개 투영의 `resource_type`(String)으로 switch 하고 `default -> null` 이 남아 있다.
|
||||
|
||||
그 칸은 `RecordKind` 다섯에 더해 `PROJECT` 와 `RELEASE` 도 담는다.
|
||||
|
||||
CONCEPT 이 실제로 이 분기에서 빠져 있었고, 경로가 null 로 나갔다.
|
||||
|
||||
`validate-working-copy.ts` 의 `stringFields` 는 아직 삼항 사슬이다.
|
||||
|
||||
`PublicPathsTest` 가 지금 종류마다 만들어 낸 경로를 공개 라우트 패턴에 맞춰 본다.
|
||||
|
||||
## 가정
|
||||
|
||||
`stringFields` 는 배타적 사슬이 아니라 가산형이라, 종류를 빠뜨리면 잘못된 분기로 떨어지는 것이 아니라 그 종류의 추가 칸을 검사하지 않는 결과가 된다. 실제로 종류를 빠뜨려 확인하지는 않았다.
|
||||
|
||||
`PROJECT` 와 `RELEASE` 를 다른 분기로 빼면 나머지를 sealed 로 좁힐 수 있다고 보고 있다. 그 둘이 공개 투영에서 어떤 경로를 갖는지 전수로 확인하지 않았다.
|
||||
|
||||
## 미지수
|
||||
|
||||
`pathOf` 가 읽는 칸이 종류 밖의 값도 담는 상태에서, 그 둘을 어디로 옮기면 나머지를 컴파일러가 세게 만들 수 있는가.
|
||||
|
||||
`PublicPathsTest` 가 닿지 않는 경로가 몇 개인가. 홈 focus 의 `recentDecision` 은 `pathOf` 만 쓰고 그 테스트를 지나지 않는다.
|
||||
|
||||
`stringFields` 를 표로 바꾸면 그 검사가 지금과 다른 결과를 내는 종류가 있는가.
|
||||
|
||||
## 제약
|
||||
|
||||
공개 투영은 한 테이블이 문서·프로젝트·릴리즈를 함께 담는다. 이 구조는 바꾸지 않는다.
|
||||
|
||||
계약의 `RecordKind` 는 다섯이고 여기에 `PROJECT`·`RELEASE` 를 더하지 않는다. 그 둘은 문서 게시 파이프라인을 타지 않는다.
|
||||
|
||||
## 선택지
|
||||
|
||||
**`pathOf` 를 두 분기로 가른다**
|
||||
`RecordKind` 를 받는 sealed switch 와 `PROJECT`·`RELEASE` 를 받는 분기로 나눈다. 호출부가 어느 쪽인지 알아야 하므로 호출부를 함께 본다.
|
||||
|
||||
**`resource_type` 을 sealed 타입으로 올린다**
|
||||
`RecordKind` 다섯에 `PROJECT`·`RELEASE` 를 더한 별도 열거형을 만들고 `pathOf` 를 그것으로 분기한다. 열거형이 하나 늘고 두 열거형 사이 변환이 생긴다.
|
||||
|
||||
**`stringFields` 만 먼저 표로 바꾼다**
|
||||
가산형이라 위험이 낮고 다른 것과 얽히지 않는다. 이 선택만으로는 `pathOf` 가 null 경로를 내보내는 것을 막지 못한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. `pathOf` 를 `RecordKind` switch 와 `PROJECT`/`RELEASE` 분기로 갈라 보고, 새 종류를 하나 더해 컴파일이 멈추는지 본다
|
||||
2. 홈 focus 의 `recentDecision` 경로가 `PublicPathsTest` 에 덮이는지 확인하고, 덮이지 않으면 그 경로를 테스트에 넣는다
|
||||
3. `stringFields` 를 `Record<Kind, string[]>` 로 바꾸고 종류 하나를 빼서 컴파일이 멈추는지 본다
|
||||
|
||||
닫는 조건 : 새 종류를 더했을 때 이 두 곳가 컴파일 오류로 먼저 멈추면 닫는다. 구조상 좁힐 수 없다는 것이 확인되면 대조 검사를 두는 Decision 으로 넘긴다
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: enumerate-kinds-where-the-compiler-sees-it
|
||||
title: 종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다
|
||||
topic: hand-listed-kinds
|
||||
topicName: 손으로 나열한 종류 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§3.5
|
||||
- final/document.md#§3.4
|
||||
- final/document.md#§17.2
|
||||
---
|
||||
|
||||
# 종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다
|
||||
|
||||
값이 유한한 집합을 코드 여러 곳에서 분기할 때, 그 목록을 사람이 세면 새 값이 조용히 빠진다. 이 저장소에서 같은 실수가 열세 번 났고, 그 뒤로 두 가지만 쓴다 — 컴파일러가 항목을 요구하게 만들거나, 컴파일러가 못 보는 경계에는 계약을 읽어 대조하는 검사를 두거나.
|
||||
|
||||
## 관계
|
||||
|
||||
- **개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다**
|
||||
이 규칙의 근거가 되는 사건이다.
|
||||
- **컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식**
|
||||
규칙이 쓰는 두 방법을 그 개념이 설명한다.
|
||||
- **계약과 구현은 서버와 화면 양쪽에서 전수 대조한다**
|
||||
컴파일러가 못 보는 경계를 다루는 짝이 되는 기준이다.
|
||||
|
||||
## 목적
|
||||
|
||||
새 값을 더했을 때 오류 없이 잘못된 값이 나가는 것을 막는다. 이 부류는 테스트가 지나가고 컴파일도 지나가므로, 배포된 뒤 사용자가 만나기 전까지 아무도 모른다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**유한한 집합의 분기는 값마다 항목을 요구하는 형태로 쓴다**
|
||||
`Record<Kind, V>` 나 식으로 쓴 sealed switch 를 쓴다. 삼항 사슬과 배열 리터럴은 마지막 가지가 모르는 값을 받아 가므로 새 값을 조용히 삼킨다.
|
||||
|
||||
**컴파일러가 못 보는 경계에는 계약을 읽어 대조하는 검사를 둔다**
|
||||
계약이 다른 저장소에 있고 생성기를 지나 들어오면, 계약이 값을 빠뜨린 것은 타입 검사가 잡지 못한다. 계약 문서를 파싱해 코드의 표와 맞춰 본다.
|
||||
|
||||
**가드를 넣었으면 그 가드가 실제로 잡는지 되돌려 확인한 뒤 커밋한다**
|
||||
계약에서 값을 하나 빼고 검사가 빨개지는 것을 본다. 확인하지 않은 가드는 그 값이 원래 없었는지 검사가 안 도는지 구별되지 않는다.
|
||||
|
||||
**한 열거형을 여러 계약이 따로 적고 있으면 그 목록을 기계로 뽑는다**
|
||||
세 계약을 파싱해 「일부 값만 열거한 enum」을 전부 뽑는 편이 눈으로 찾는 것보다 빠르고 빠뜨림이 없다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
값이 유한한 집합인 것을 코드나 계약 여러 곳에서 분기하거나 열거하는 곳. 문서 종류, 상태, 역할, 오류 코드, 라우트 이름이 여기 해당한다.
|
||||
|
||||
새 값을 더하는 변경을 시작할 때 이 규칙을 먼저 건다. 다 더한 뒤에 빠진 곳을 찾는 순서로는 조용히 빠진 곳을 못 찾는다.
|
||||
|
||||
## 예외
|
||||
|
||||
그 칸이 집합 밖의 값도 담으면 표로 좁힐 수 없다. 공개 투영의 resource_type 이 그런 칸이다 — RecordKind 다섯에 더해 PROJECT 와 RELEASE 를 담는다. 이때는 대조 검사를 대신 둔다.
|
||||
|
||||
값이 하나뿐이거나 분기가 한 곳에만 있으면 표로 바꾸는 비용이 이득보다 크다.
|
||||
|
||||
## 예시
|
||||
|
||||
종류를 더한 커밋에서 컴파일러가 게시 상태 코드와 활동 유형과 렌더 모델까지 짚었다. 식으로 쓴 switch 였기 때문이다.
|
||||
|
||||
계약에서 CONCEPT 을 빼자 백엔드의 계약 대조 테스트가 빨개졌다. 그 확인을 하고 커밋했다.
|
||||
|
||||
라우트에 딸린 청크 이름 표도 같은 부류라 다섯 검사 안에서 대조하게 했다.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-route-the-web-server-never-heard-of
|
||||
title: nginx 가 모르는 라우트는 새로고침에서 404 다
|
||||
topic: one-route-many-hand-kept-lists
|
||||
topicName: 라우트 하나가 울리는 손 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§8.2
|
||||
- final/document.md#§12.6
|
||||
---
|
||||
|
||||
# nginx 가 모르는 라우트는 새로고침에서 404 다
|
||||
|
||||
`/studio/releases` 가 평문 404 를 돌려줬다. 라우트는 있고 청크도 빌드됐고 SPA 내부 이동으로는 화면에 닿는데, 하드 로드와 새로고침은 거기까지 가지 못한다. nginx 설정이 손으로 유지하는 배열에서 나오고 있었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다**
|
||||
이 사건에서 굳힌 기준이다.
|
||||
- **catch-all 라우트는 nginx 패턴으로 번역하지 않는다**
|
||||
유도 규칙에서 함께 정한 것이다.
|
||||
- **라우트 하나가 건드리는 여덟 곳과, 그것들이 우는 시점**
|
||||
같은 부류가 다른 목록에서 어떻게 우는지가 그 기록에 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
SPA 안에서 이동하면 화면이 열린다. 주소창에 그 주소를 직접 넣거나 새로고침하면 nginx 가 평문 404 를 준다.
|
||||
|
||||
웹 서버가 그 경로의 존재를 들은 적이 없기 때문이다. 서빙 계약이 어느 경로를 SPA 로 넘길지 정하는데, 그 목록에 없는 경로는 넘어가지 않는다.
|
||||
|
||||
## 결론
|
||||
|
||||
서빙 계약의 절반은 라우트 레지스트리에서 유도하고 있었고 나머지 절반은 손으로 유지하는 배열이었다.
|
||||
|
||||
> 서빙 계약의 공개 절반은 라우트 레지스트리에서 패턴을 유도한다. **Studio 절반은 손으로 유지하는 배열이었고, 손으로 유지하는 배열이 실패하는 방식 그대로 실패했다** — `^/studio/assets$` 위의 주석이 바로 그 버그를 한 번 고친 기록이고, 라우트를 더하니 즉시 반복됐다.
|
||||
|
||||
공개 절반도 유도라고 하기 어려웠다. 번들된 픽스처에 우연히 들어 있던 공개 경로를 전부 열거하고, 생성된 nginx 가 정확히 그것들을 `location =` 블록으로 게시했다. 빌드 이후에 게시된 기록 — 백엔드를 두는 이유 그 자체 — 은 SPA 에 묻기도 전에 엣지에서 404 였다. 경로 27개가 얼어 있었고 28번째는 무엇이든 닿을 수 없었다.
|
||||
|
||||
지금은 라우트 계약에서 등록된 Public 라우트마다 정규식 하나를 만든다. 파라미터는 한 세그먼트만 잡고 슬래시는 잡지 않으므로 `/cases/a/b` 는 404 로 남는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : ab8c6c1 · 6784eb1
|
||||
엣지 : nginx (서빙 계약에서 생성)
|
||||
확인 방식 : 생성된 서빙 패턴이 라우트 계약과 같은지 보는 테스트
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 라우트를 하나 더하고 서빙 계약 배열에는 넣지 않는다
|
||||
2. 빌드하고 배포한 뒤 그 주소를 주소창에 직접 넣는다 — 평문 404 가 온다
|
||||
3. SPA 안에서 링크로 이동한다 — 화면이 열린다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 내부 이동은 되고 하드 로드는 안 된다
|
||||
|
||||
SPA 안에서 이동할 때는 라우터가 화면을 그리므로 웹 서버가 개입하지 않는다. 주소를 직접 넣거나 새로고침하면 웹 서버가 먼저 그 경로를 받는다.
|
||||
|
||||
서빙 계약에 그 경로가 없으면 nginx 는 SPA 로 넘기지 않고 404 를 준다. 그래서 「내부에서는 되는데 새로고침하면 안 된다」로 나타난다.
|
||||
|
||||
## 손으로 유지하는 절반
|
||||
|
||||
> 서빙 계약의 공개 절반은 라우트 레지스트리에서 패턴을 유도한다. **Studio 절반은 손으로 유지하는 배열이었고, 손으로 유지하는 배열이 실패하는 방식 그대로 실패했다** — `^/studio/assets$` 위의 주석이 바로 그 버그를 한 번 고친 기록이고, 라우트를 더하니 즉시 반복됐다.
|
||||
|
||||
## 얼어붙은 27개
|
||||
|
||||
공개 절반도 유도가 아니었다. 서빙 계약이 번들된 픽스처에 우연히 들어 있던 공개 경로를 전부 열거하고, 생성된 nginx 가 정확히 그것들을 `location =` 블록으로 게시했다.
|
||||
|
||||
빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 였다. 경로 27개가 얼어 있었고 28번째는 무엇이든 닿을 수 없었다.
|
||||
|
||||
## 라우트 계약에서 유도한다
|
||||
|
||||
지금은 라우트 계약에서 등록된 Public 라우트마다 정규식 하나를 만든다. 파라미터는 한 세그먼트만 잡고 슬래시는 잡지 않으므로 `/cases/a/b` 는 404 로 남는다.
|
||||
|
||||
같은 구조 때문에 `robots.txt` 도 404 였다. 파일은 이미지에 있었지만 nginx 설정이 서빙할 파일을 하나씩 명시하는 구조라 등록되지 않은 것은 SPA 폴백으로 떨어진다. 크롤러가 index.html 을 규칙으로 읽을 수는 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
경로 27개가 얼어 있던 상태의 생성 결과물은 남기지 않았다. 지금 생성되는 패턴이 라우트 계약과 같은지만 테스트가 본다.
|
||||
|
||||
<!-- body:end -->
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: eight-places-a-single-route-touches
|
||||
title: 라우트 하나가 건드리는 여덟 자리와, 그것들이 우는 시점
|
||||
topic: one-route-many-hand-kept-lists
|
||||
topicName: 라우트 하나가 울리는 손 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§8.1
|
||||
- final/document.md#§8.3
|
||||
- final/document.md#§8.4
|
||||
---
|
||||
|
||||
# 라우트 하나가 건드리는 여덟 자리와, 그것들이 우는 시점
|
||||
|
||||
라우트를 하나 더하면 여덟 곳이 함께 울린다. 어떤 것은 빌드 직전에, 어떤 것은 배포 직전에, 어떤 것은 배포 뒤에 운다. 개념 라우트를 더한 커밋이 그 목록을 남겼다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **nginx 가 모르는 라우트는 새로고침에서 404 다**
|
||||
이 목록에서 배포 뒤에 우는 항목의 사건이다.
|
||||
- **라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다**
|
||||
이 목록을 다루는 기준이다.
|
||||
- **가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다**
|
||||
이 목록을 빠뜨린 뒤 게이트가 빨간 채로 지나간 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
라우트 하나를 더하면 그 라우트를 아는 곳이 여덟이다. 어느 하나를 빠뜨리면 우는 시점이 제각각이라, 빠뜨린 것을 알아채는 시점도 제각각이다.
|
||||
|
||||
## 결론
|
||||
|
||||
여덟 곳과 우는 시점이 갈린다.
|
||||
|
||||
빌드 매니페스트 단계 : vite chunk 이름 표
|
||||
배포 직전 CI : 아티팩트 개수 상수 · 수동 접근성 증거 개수 · 게이트 집합의 sha256
|
||||
배포 뒤 : nginx 서빙 패턴
|
||||
그 밖 : 라우트 계약 · 런타임 등록 · 메시지 카탈로그
|
||||
|
||||
청크 이름 표는 다섯 검사 안에서 대조하게 했다. 게이트 기준값 셋은 여전히 손으로 움직이고, 옛 값을 먼저 재현해 계산 방법을 확인한 뒤 갱신한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 048c1b2 · 197db74 · fe6b56a
|
||||
CI : FE-GATE-009 — 라우트마다 수동 접근성 증거 1개
|
||||
확인 방식 : 라우트를 더한 커밋 넷에서 기준값이 어떻게 움직였는지 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 라우트를 하나 더하고 vite chunk 이름 표에는 넣지 않는다
|
||||
2. 빌드한다 — 번들은 만들어지고 매니페스트 단계에서 멈춘다
|
||||
3. 표에 넣고 CI 기준값은 그대로 둔다 — 게이트가 거절한다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 여덟 곳
|
||||
|
||||
```text
|
||||
라우트 계약 tech-log-route-contract.ts
|
||||
런타임 등록 route-runtime-contract
|
||||
메시지 카탈로그 화면 제목·설명
|
||||
nginx 서빙 패턴 tech-log-serving-contract.json → 생성된 nginx conf
|
||||
코드 분할 청크 vite.config.ts 의 chunk 이름 표
|
||||
CI 게이트 FE-GATE-009 라우트마다 수동 접근성 증거 1개
|
||||
CI 게이트 아티팩트 기준선 정확한 개수를 고정
|
||||
CI 게이트 형상 digest 게이트 집합의 sha256
|
||||
```
|
||||
|
||||
## 우는 시점이 다르다
|
||||
|
||||
주제 편집 화면을 더하고 청크 이름 표를 빠뜨렸더니 번들은 만들어지는데 빌드 매니페스트 단계에서 `Missing built route chunk: TECH_LOG_STUDIO_TOPIC_EDIT` 로 멈췄다. 다섯 개의 검사를 다 통과한 뒤 배포 직전에야 드러난다는 뜻이다.
|
||||
|
||||
이 표도 손으로 나열한 목록이므로 다섯 검사 안에서 대조하게 했다.
|
||||
|
||||
## 게이트 기준값 셋이 함께 움직인다
|
||||
|
||||
FE-GATE-009 는 설치된 라우트마다 수동 접근성 증거를 하나씩 요구하고, 그 집합이 정확히 일치하지 않으면 거절한다.
|
||||
|
||||
| 커밋 | 라우트 | 아티팩트 기준선 | 증거 개수 | digest |
|
||||
|---|---|---|---|---|
|
||||
| `16e5b9f` | `/studio/projects/:id` | 132 → 133 | 111 → 112 | 187dbd96… 재계산 |
|
||||
| `84d72c4` | `/studio/releases/:id` | 133 → 134 | 112 → 113 | f9e7e521… 재계산 |
|
||||
| `048c1b2` | `/concepts/:slug` | +1 | +1 | fb138e7c… 재계산 |
|
||||
| `fe6b56a` | `/topics`, `/topics/:s/:v`, `/studio/topics/:id` | 135 → 138 | 114 → 117 | 87a22f68… 재계산 |
|
||||
|
||||
digest 를 다시 계산할 때는 매번 이전 gates.json 에서 옛 상수를 먼저 재현해 계산 방법이 맞는지 확인한 뒤 새 파일을 해싱했다. 그렇게 하지 않으면 「계산이 달라졌는데 새 값이 나왔다」와 「파일이 바뀌어서 새 값이 나왔다」를 구분할 수 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
게이트 기준값 셋은 여전히 손으로 움직인다. 옛 값을 먼저 재현하는 절차는 사람이 기억해야 하고 검사가 강제하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: do-not-translate-the-catch-all-route
|
||||
title: catch-all 라우트는 nginx 패턴으로 번역하지 않는다
|
||||
topic: one-route-many-hand-kept-lists
|
||||
topicName: 라우트 하나가 울리는 손 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
decidedOn: 2026-09-02
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/audit/dead-link-sweep.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§8.2
|
||||
---
|
||||
|
||||
# catch-all 라우트는 nginx 패턴으로 번역하지 않는다
|
||||
|
||||
라우트 계약에서 nginx 서빙 패턴을 만들 때 catch-all 라우트는 번역하지 않는다. 모든 미매치 주소에 index.html 을 주면 엣지의 404 가 soft 200 이 되고, 깨진 링크가 크롤러와 우리 감사에서 함께 사라진다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **nginx 가 모르는 라우트는 새로고침에서 404 다**
|
||||
서빙 패턴을 라우트 계약에서 유도하기로 한 사건이고, 이 결정이 그 유도 규칙의 일부다.
|
||||
- **서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다**
|
||||
같은 판단을 화면 쪽에 적용한 기준이다.
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
엣지의 404 가 감사에 잡혀야 하는 이유를 보여 주는 사건이다.
|
||||
|
||||
## 결정문
|
||||
|
||||
라우트 계약에서 nginx 서빙 패턴을 생성할 때, catch-all 라우트는 패턴으로 번역하지 않고 버린다. 등록된 Public 라우트마다 정규식 하나를 만들고, 파라미터는 한 세그먼트만 잡되 슬래시는 잡지 않는다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
모든 미매치 URL 에 index.html 을 주면 엣지에서 404 였을 요청이 200 으로 바뀐다. 그러면 깨진 링크를 크롤러도 우리도 볼 수 없다.
|
||||
|
||||
서버가 내보내는 주소를 전수 감사할 때 그 감사가 상태 코드로 판정한다. soft 200 이 섞이면 감사가 통과하고 방문자만 빈 화면을 만난다.
|
||||
|
||||
파라미터가 슬래시를 잡지 않게 한 것도 같은 이유다. `/cases/a/b` 가 404 로 남아야 그 주소가 잘못됐다는 것이 드러난다.
|
||||
|
||||
## 영향
|
||||
|
||||
라우트를 더할 때마다 서빙 패턴이 함께 움직인다. 이 비용은 라우트 계약에서 유도해 없앴다 — 손으로 배열을 고치지 않는다.
|
||||
|
||||
등록되지 않은 주소는 SPA 에 닿지 못한다. 라우트를 더하고 프론트를 배포하기 전까지 그 경로는 엣지에서 404 다. 그래서 새 라우트는 프론트를 먼저 배포한다.
|
||||
|
||||
감사에서 200 을 받은 35개 주소는 실제로 화면이 그려지는 주소다. 이 결정이 없으면 그 수는 아무것도 뜻하지 않는다.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: nobody-signed-the-accessibility-evidence
|
||||
title: 라우트마다 요구하는 수동 접근성 증거를 아무도 서명하지 않았다
|
||||
topic: one-route-many-hand-kept-lists
|
||||
topicName: 라우트 하나가 울리는 손 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§16.5
|
||||
- final/document.md#§8.4
|
||||
---
|
||||
|
||||
# 라우트마다 요구하는 수동 접근성 증거를 아무도 서명하지 않았다
|
||||
|
||||
CI 게이트가 설치된 라우트마다 수동 접근성 증거 파일을 하나씩 요구한다. 파일은 라우트마다 있고 그중 사람이 서명한 것은 하나도 없다. 게이트는 파일의 존재만 세고 서명은 보지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **라우트 하나가 건드리는 여덟 곳과, 그것들이 우는 시점**
|
||||
이 게이트가 그 목록의 한 줄이다.
|
||||
- **가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다**
|
||||
게이트가 무엇을 실제로 막는지 확인하는 기준이다.
|
||||
- **가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다**
|
||||
이 게이트를 빠뜨려 빨간 채로 지나간 사건이다.
|
||||
|
||||
## 사실
|
||||
|
||||
FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고, 그 집합이 정확히 일치하지 않으면 거절한다.
|
||||
|
||||
`artifacts/tests/a11y-manual/*.md` 는 전부 `pending-manual-review` 다.
|
||||
|
||||
`review:a11y-manual` 스크립트는 그래서 실패하는 것이 지금 정상이다.
|
||||
|
||||
라우트를 더할 때마다 이 증거 개수가 함께 움직였고, 커밋 넷에서 111 → 117 로 늘었다.
|
||||
|
||||
## 가정
|
||||
|
||||
게이트가 파일의 존재만 세는 것이 의도라고 보고 있다. 서명을 조건에 넣으면 라우트를 더할 때마다 배포가 막히므로 그렇게 두었다고 짐작하지만, 그 판단이 적힌 곳을 찾지 못했다.
|
||||
|
||||
서명 절차 자체는 사람이 화면을 열어 확인하는 일이라고 보고 있다. 어떤 항목을 어디까지 보는지 정한 문서를 확인하지 않았다.
|
||||
|
||||
## 미지수
|
||||
|
||||
게이트가 서명 여부까지 보게 하면 지금 몇 개의 라우트가 막히는가.
|
||||
|
||||
서명을 요구하지 않기로 한다면 이 게이트가 파일 개수를 세는 것이 무엇을 막는가.
|
||||
|
||||
라우트 하나를 사람이 실제로 검토하는 데 얼마가 드는가. 그 비용을 모르면 어느 쪽도 고를 수 없다.
|
||||
|
||||
## 제약
|
||||
|
||||
FE-GATE-009 는 라우트 집합과 증거 집합이 정확히 일치하기를 요구한다. 이 규칙은 바꾸지 않는다 — 빠뜨림이 통과가 되면 게이트가 아니다.
|
||||
|
||||
수동 접근성 증거는 사람이 만든다. 자동 검사로 대신하지 않는다.
|
||||
|
||||
## 선택지
|
||||
|
||||
**서명을 게이트의 통과 조건으로 올린다**
|
||||
라우트마다 사람이 검토하고 서명해야 배포된다. 지금 상태에서는 모든 라우트가 막히므로 한 번에 올릴 수 없고, 라우트별로 나눠 올려야 한다.
|
||||
|
||||
**서명을 요구하지 않기로 적고 게이트에서 파일 요구를 뺀다**
|
||||
게이트가 세는 것이 무엇인지 분명해진다. 수동 검토를 하지 않기로 하는 결정이므로 그 결과를 따로 적어야 한다.
|
||||
|
||||
**게이트는 그대로 두고 서명 현황을 별도로 보고한다**
|
||||
배포는 막지 않고 서명되지 않은 라우트 수를 드러낸다. 막지 않는 지표가 읽히지 않게 되는 것을 감수한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 라우트 하나를 골라 실제로 사람이 검토하고 서명해, 그 검토에 얼마가 드는지 잰다
|
||||
2. 게이트가 서명 여부까지 보게 고치고 나머지 라우트에서 실제로 빨개지는지 확인한다
|
||||
3. 빨개지는 라우트 수를 세어 한 번에 올릴 수 있는지 판단한다
|
||||
|
||||
닫는 조건 : 서명이 게이트의 통과 조건이 되면 닫는다. 서명을 요구하지 않기로 정하고 게이트에서 그 파일 요구를 빼도 닫는다
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: derive-the-route-lists-from-the-route-contract
|
||||
title: 라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다
|
||||
topic: one-route-many-hand-kept-lists
|
||||
topicName: 라우트 하나가 울리는 손 목록
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§8.3
|
||||
- final/document.md#§8.2
|
||||
---
|
||||
|
||||
# 라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다
|
||||
|
||||
라우트를 하나 더하면 서빙 패턴·청크 이름·게이트 기준값이 함께 움직인다. 손으로 유지하는 목록은 같은 방식으로 두 번 실패했다. 유도할 수 있는 것은 라우트 계약에서 유도하고, 유도할 수 없는 상수는 옛 값을 재현한 뒤에 갱신한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **nginx 가 모르는 라우트는 새로고침에서 404 다**
|
||||
이 기준의 근거 사건이다.
|
||||
- **라우트 하나가 건드리는 여덟 곳과, 그것들이 우는 시점**
|
||||
유도할 수 있는 것과 없는 것이 그 기록에 갈려 있다.
|
||||
- **종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다**
|
||||
같은 병을 다른 유한 집합에서 다루는 기준이다.
|
||||
|
||||
## 목적
|
||||
|
||||
라우트를 더할 때 함께 움직여야 하는 목록이 빠지는 것을 막는다. 이 부류는 우는 시점이 제각각이라, 어떤 것은 배포한 뒤 방문자가 먼저 만난다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**서빙 패턴은 라우트 계약에서 유도한다**
|
||||
등록된 Public 라우트마다 정규식 하나를 만든다. 파라미터는 한 세그먼트만 잡고 슬래시는 잡지 않는다.
|
||||
|
||||
**빌드가 아는 목록을 서빙 계약의 근거로 쓰지 않는다**
|
||||
번들된 픽스처에 우연히 들어 있던 경로를 열거하면 빌드 이후에 게시된 기록이 엣지에서 404 가 된다.
|
||||
|
||||
**유도할 수 없는 목록에는 대조 검사를 둔다**
|
||||
vite chunk 이름 표가 그렇다. 이 표를 빠뜨리면 다섯 검사를 다 통과한 뒤 빌드 매니페스트 단계에서 멈춘다.
|
||||
|
||||
**기준값 상수는 옛 값을 먼저 재현한 뒤 갱신한다**
|
||||
그렇게 하지 않으면 계산 방법이 달라져 새 값이 나온 것과 파일이 바뀌어 새 값이 나온 것을 구분할 수 없다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
라우트 하나가 서빙 패턴·청크 이름·게이트 기준값 같은 목록을 함께 움직이는 프론트엔드. 라우트를 더하거나 지우는 변경에서 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
catch-all 라우트는 서빙 패턴으로 번역하지 않는다. 모든 미매치 URL 에 index.html 을 주면 엣지 404 가 soft 200 이 된다.
|
||||
|
||||
목록이 하나이고 그 목록을 빌드가 강제하면 유도 규칙을 따로 두지 않아도 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
`/studio/releases` 가 평문 404 였다. 라우트도 청크도 있었고 서빙 계약의 손 배열에만 없었다.
|
||||
|
||||
주제 편집 화면을 더하고 청크 이름 표를 빠뜨렸더니 빌드 매니페스트 단계에서 멈췄다.
|
||||
|
||||
라우트 셋을 더하면서 게이트 기준값을 빠뜨렸다. 게이트가 빨간 채로 여러 커밋을 지나갔다.
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-error-message-that-guessed
|
||||
title: 서버는 하나를 답했는데 화면은 추측 셋을 출력했다
|
||||
topic: one-thing-many-names
|
||||
topicName: 같은 것이 화면마다 다른 이름
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§13.4
|
||||
---
|
||||
|
||||
# 서버는 하나를 답했는데 화면은 추측 셋을 출력했다
|
||||
|
||||
작업본 삭제가 실패하면 화면이 「게시됐거나, 참조하는 곳이 있거나, 누가 먼저 고쳤을 수 있습니다」라고 적었다. 세 가지 추측이다. 서버는 정확히 하나를 답하고 있었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **삭제를 막는 이유 다섯 가지가 전부 같은 한 문장으로 나온다**
|
||||
이 화면 문구를 고친 뒤에도 남은 문제다.
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
화면이 모르는 것을 말할 때의 짝이 되는 규칙이다.
|
||||
- **한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다**
|
||||
같은 시기에 문구를 한 곳으로 모은 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
작업본 삭제가 실패하면 화면이 세 가지 추측을 나열했다.
|
||||
|
||||
서버는 정확히 하나를 답하고 있었다 — 「이 기록을 참조하는 곳이 있어 삭제할 수 없습니다」.
|
||||
|
||||
## 결론
|
||||
|
||||
화면이 서버의 답을 쓰지 않고 자기가 가능한 원인을 나열하고 있었다.
|
||||
|
||||
그래서 버전 충돌이 「사용 중」으로 읽혔다. 어떤 삭제는 되고 어떤 삭제는 안 되는 것을 지켜보는 작성자는 둘을 구분할 방법이 없었다.
|
||||
|
||||
게이트웨이가 서버의 클라이언트 안전 메시지를 실어 나르고 화면이 그것을 보이게 했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 1801414
|
||||
tech-log-backend : 857e6a9 — 삭제 거절 사유를 클라이언트 안전 메시지로 답함
|
||||
확인 방식 : 실패하는 삭제를 실제로 시도해 화면 문구와 서버 응답을 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 다른 기록이 참조하는 작업본을 지운다 — 서버가 참조 사유를 답한다
|
||||
2. 버전이 어긋난 상태로 작업본을 지운다 — 서버가 다른 사유를 답한다
|
||||
3. 두 경우의 화면 문구가 다른지 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 화면이 세 가지를 추측했다
|
||||
|
||||
화면 문구는 「게시됐거나, 참조하는 곳이 있거나, 누가 먼저 고쳤을 수 있습니다」였다. 세 가지 원인을 나열하고 어느 것인지는 말하지 않는다.
|
||||
|
||||
서버는 하나를 답하고 있었다. 게이트웨이가 그 답을 버리고 화면이 자기 목록을 그렸다.
|
||||
|
||||
## 무엇이 구분되지 않았나
|
||||
|
||||
버전 충돌은 「누가 먼저 고쳤다」이고 참조 존재는 「사용 중」이다. 문구가 셋을 함께 적으므로 작성자는 둘을 구분할 수 없다.
|
||||
|
||||
두 경우에 해야 할 일이 다르다. 버전 충돌이면 다시 받아서 지우면 되고, 참조가 있으면 그 참조를 먼저 풀어야 한다.
|
||||
|
||||
## 서버의 답을 실어 나른다
|
||||
|
||||
게이트웨이가 서버의 클라이언트 안전 메시지를 그대로 싣고 화면이 그것을 보인다. 서버가 답하지 않은 것은 화면이 만들지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
서버가 답하는 그 한 문장이 다섯 이유를 다 같은 말로 덮고 있다. 참조 검사가 다섯 테이블을 하나로 묶어 검사하기 때문이고, 그 문제는 아직 열려 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nine-names-for-five-kinds
|
||||
title: 한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다
|
||||
topic: one-thing-many-names
|
||||
topicName: 같은 것이 화면마다 다른 이름
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§13.1
|
||||
- final/document.md#§13.5
|
||||
---
|
||||
|
||||
# 한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다
|
||||
|
||||
홈 한 화면에 문서 종류 이름이 아홉 개 떠 있었다. 최근 기록 목록은 계약의 enum 이름을, 바로 아래 「종류별로 읽기」는 사람이 붙인 이름을 쓰고 있었다. 독자는 둘이 같은 것이라는 단서를 어디서도 받지 못했다. 원인은 종류 이름 표가 화면마다 복사되어 여섯 벌이었다는 것이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다**
|
||||
이 표를 한 곳으로 모은 뒤에 이름 자체를 다시 정한 기록이다.
|
||||
- **개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다**
|
||||
같은 종류 목록이 코드 쪽에서 갈라진 사건이다.
|
||||
- **톤을 지적받으면 고쳐 쓰지 말고 어떤 말을 쓸지 묻는다**
|
||||
같은 시기에 문구를 다룬 기준이다.
|
||||
|
||||
## 문제
|
||||
|
||||
홈 화면 하나에 종류 이름이 아홉 개 있었다.
|
||||
|
||||
```text
|
||||
최근 기록 목록: CASE · CONCEPT · OPEN QUESTION · REFERENCE
|
||||
바로 아래 「종류별로 읽기」: 검증 기록 · 동작 원리 · 적용 기준 · 열린 질문
|
||||
```
|
||||
|
||||
두 목록이 같은 다섯 종류를 가리키는데 이름이 겹치지 않는다. 독자는 그 둘이 같은 것이라는 단서를 받지 못한다.
|
||||
|
||||
## 결론
|
||||
|
||||
종류 이름 표가 화면마다 복사되어 여섯 벌이었고, 그래서 갈라졌다.
|
||||
|
||||
> 표가 화면마다 복사되어 **여섯 벌**이었고 그래서 갈라졌다: 같은 QUESTION 이 공개 화면에서 "Open Question", 작업본 목록과 게시 기록에서 "Question", 편집기 상태 줄에서 "QUESTION" 이었다. **쓰는 사람은 같은 문서를 화면마다 다른 이름으로 만난다.**
|
||||
|
||||
종류에서 이름으로 가는 표 하나로 모았다.
|
||||
|
||||
편집기 칸 이름도 공개 화면과 맞췄다. 쓰는 사람이 지금 채우는 칸이 공개 화면 어디로 가는지 외우지 않아도 된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : dc2fda7 · ca1cfa2 계열 · 82e992d
|
||||
확인 방식 : 한 화면에 동시에 뜨는 종류 이름을 세고, 표가 몇 벌인지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 홈 화면을 열고 최근 기록 목록과 「종류별로 읽기」를 함께 본다
|
||||
2. 두 목록에서 같은 종류를 가리키는 이름을 대조한다
|
||||
3. 작업본 목록·게시 기록·편집기 상태 줄에서 같은 종류의 이름을 확인한다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 한 화면에 아홉 개
|
||||
|
||||
```text
|
||||
최근 기록 목록: CASE · CONCEPT · OPEN QUESTION · REFERENCE
|
||||
바로 아래 「종류별로 읽기」: 검증 기록 · 동작 원리 · 적용 기준 · 열린 질문
|
||||
```
|
||||
|
||||
두 목록이 세로로 붙어 있다. 위는 계약의 enum 이름을, 아래는 사람이 붙인 이름을 쓴다.
|
||||
|
||||
이름을 바꾸기 전보다 나빠진 유일한 화면이었다. 바꾸기 전에는 양쪽이 다 enum 이름이라 적어도 같아 보였다.
|
||||
|
||||
## 표가 여섯 벌이었다
|
||||
|
||||
> 표가 화면마다 복사되어 **여섯 벌**이었고 그래서 갈라졌다: 같은 QUESTION 이 공개 화면에서 "Open Question", 작업본 목록과 게시 기록에서 "Question", 편집기 상태 줄에서 "QUESTION" 이었다. **쓰는 사람은 같은 문서를 화면마다 다른 이름으로 만난다.**
|
||||
|
||||
## 표 하나로 모았다
|
||||
|
||||
종류에서 표시 이름으로 가는 표를 하나 만들고 여섯 곳이 그것을 쓰게 했다. 종류가 늘면 그 표에 자리가 비었다고 컴파일러가 잡는다.
|
||||
|
||||
## 편집기 칸 이름도 맞췄다
|
||||
|
||||
같은 문제가 칸 이름에도 있었다. 편집기에서 「목적」이라 부른 칸이 공개 화면에서는 다른 이름으로 나왔다.
|
||||
|
||||
```text
|
||||
목적 → 이 기준을 쓰는 이유 규칙 → 판단 기준
|
||||
적용 조건 → 적용할 때 예외 → 예외와 주의
|
||||
사실 → 확인한 사실 미지수 → 남은 미지수
|
||||
선택지 → 검토한 선택지
|
||||
```
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이름을 바꾸기 전보다 나빠진 화면이 홈 하나였다는 것은 화면으로 확인했다. 다른 화면에서 두 이름이 같이 뜨는 곳을 전수로 세지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: renaming-the-kinds-twice
|
||||
title: 종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다
|
||||
topic: one-thing-many-names
|
||||
topicName: 같은 것이 화면마다 다른 이름
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§13.2
|
||||
---
|
||||
|
||||
# 종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다
|
||||
|
||||
이 저장소의 종류 이름은 글을 담아 둔 방식의 이름이었다. 독자는 그 말을 배우고 나서야 목록을 읽을 수 있었고, 뜻풀이는 홈 바닥 2,000px 아래에 있었다. 이름이 하는 일을 말하게 바꿨다가, 그 안이 기술 기록의 톤에 비해 가벼워 문어체로 다시 세웠다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다**
|
||||
이 이름을 한 곳으로 모은 뒤에 이 변경이 왔다.
|
||||
- **톤을 지적받으면 고쳐 쓰지 말고 어떤 말을 쓸지 묻는다**
|
||||
두 번째 안이 왜 필요했는지가 그 기준에 있다.
|
||||
- **slug 생성이 한글을 버려 주제 만들기가 간헐적으로 실패했다**
|
||||
이름을 주소로 옮기는 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
종류 이름이 Case, Reference, Open Question 이었다. 이것은 글을 담아 둔 방식의 이름이다.
|
||||
|
||||
독자는 그 말을 배우고 나서야 목록을 읽을 수 있었고, 정작 뜻풀이는 홈 바닥 2,000px 아래에 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 번 바꿨다.
|
||||
|
||||
1차 : 이름이 하는 일을 말하게 했다 — 직접 해보니 · 다음에 쓸 기준 · 아직 모르는 것 · 어떻게 동작하나 · 이렇게 하기로
|
||||
2차 : 역할은 그대로 말하되 문어체로 다시 세웠다 — 검증 기록 · 적용 기준 · 열린 질문 · 동작 원리 · 설계 결정
|
||||
|
||||
1차 안이 기술 기록의 톤에 비해 가벼웠다.
|
||||
|
||||
계약의 kind 는 그대로 뒀다. 바꾸는 것은 화면에 보이는 이름뿐이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : a6413d0 → af5a6bb
|
||||
계약 : RecordKind 다섯 값은 변경 없음
|
||||
확인 방식 : 화면의 표시 이름과 계약의 kind 를 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 공개 화면에서 종류 배지와 「종류별로 읽기」의 이름을 본다
|
||||
2. 편집기의 새 문서 화면에서 같은 종류의 이름을 본다
|
||||
3. 계약의 kind 값과 대조한다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 담아 둔 방식의 이름
|
||||
|
||||
Case, Reference, Open Question 은 그 글을 어떤 형식에 담았는지를 말한다. 그 글이 독자에게 무엇을 주는지는 말하지 않는다.
|
||||
|
||||
독자는 그 말을 먼저 배워야 목록을 읽을 수 있었다. 뜻풀이는 홈 바닥 2,000px 아래에 있었다.
|
||||
|
||||
## 1차 — 하는 일을 말하게 했다
|
||||
|
||||
```text
|
||||
Case → 직접 해보니 Reference → 다음에 쓸 기준
|
||||
Question → 아직 모르는 것 Concept → 어떻게 동작하나
|
||||
Decision → 이렇게 하기로
|
||||
```
|
||||
|
||||
## 2차 — 문어체로 다시 세웠다
|
||||
|
||||
1차 안이 기술 기록의 톤에 비해 가벼웠다. 역할은 그대로 말하되 문어체로 바꿨다.
|
||||
|
||||
```text
|
||||
CASE → 검증 기록 CONCEPT → 동작 원리
|
||||
REFERENCE → 적용 기준 DECISION → 설계 결정
|
||||
QUESTION → 열린 질문
|
||||
```
|
||||
|
||||
## 계약의 kind 는 그대로 뒀다
|
||||
|
||||
바꾼 것은 화면에 보이는 이름이다. 계약의 `RecordKind` 는 다섯 값 그대로이고, 주소도 그대로다.
|
||||
|
||||
표시 이름과 계약 값을 갈라 두면 이름을 다시 바꿀 때 계약을 건드리지 않아도 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
바꾼 이름이 읽기 쉬워졌는지는 재지 않았다. 1차 안이 가볍다는 판단은 사용자의 지적이고 측정이 아니다.
|
||||
|
||||
<!-- body:end -->
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: the-refusal-does-not-name-what-blocks-it
|
||||
title: 삭제를 막는 이유 다섯 가지가 전부 같은 한 문장으로 나온다
|
||||
topic: one-thing-many-names
|
||||
topicName: 같은 것이 화면마다 다른 이름
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/db/delete-blocked-by-project-link.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§16.1
|
||||
---
|
||||
|
||||
# 삭제를 막는 이유 다섯 가지가 전부 같은 한 문장으로 나온다
|
||||
|
||||
작업본 삭제가 막히는 이유는 다섯 가지인데 전부 같은 한 문장으로 나온다. 실제 사례에서 막은 것은 프로젝트 링크 한 행이었고, 문구는 「다른 기록이 참조한다」고 말했다. 문구가 잘못된 것을 가리키고 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서버는 하나를 답했는데 화면은 추측 셋을 출력했다**
|
||||
화면 쪽 문구를 고친 사건이고, 서버 쪽 문구는 그대로 남았다.
|
||||
- **그 SQL 은 한 번도 실행된 적이 없었다**
|
||||
이 참조 검사를 실제 DB 에서 돌리게 만든 사건이다.
|
||||
- **화면은 못 읽은 것을 없다고 말하지 않는다**
|
||||
화면이 무엇을 말해야 하는지를 다루는 기준이다.
|
||||
|
||||
## 사실
|
||||
|
||||
작업본 삭제 실패는 다섯 가지 이유가 전부 같은 한 문장으로 나온다 — `another record still links to this one; unlink it first`.
|
||||
|
||||
실제로 막는 것은 다섯 참조 중 하나다.
|
||||
|
||||
```sql
|
||||
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
|
||||
```
|
||||
|
||||
실제 사례에서 관계를 다 지워도 삭제가 안 됐다. 남아 있던 것은 프로젝트 링크 한 행이었다.
|
||||
|
||||
프로젝트 연결은 「관계」 편집기가 아니라 문서의 Project 필드다. 관계를 아무리 지워도 그 행은 남는다.
|
||||
|
||||
사용자는 Project 필드를 「미지정」으로 바꾸고 저장한 뒤 삭제했다.
|
||||
|
||||
## 가정
|
||||
|
||||
문구가 `another record` 라고 말하므로 사용자가 관계를 먼저 찾는다고 보고 있다. 실제 사례가 하나이고, 다른 사용자가 같은 순서로 움직이는지는 확인하지 않았다.
|
||||
|
||||
다섯 참조를 종류별로 갈라도 성능이 문제가 되지 않는다고 보고 있다. 다섯 개의 존재 검사를 따로 돌리는 비용은 재지 않았다.
|
||||
|
||||
## 미지수
|
||||
|
||||
무엇이 막는지 말하면서 내부 테이블 이름을 노출하지 않는 문구가 무엇인가.
|
||||
|
||||
사용자가 고칠 수 있는 곳의 이름으로 옮기면 다섯 참조가 몇 가지로 줄어드는가. 문서의 Project 필드와 주제의 대표 기록은 서로 다른 화면이다.
|
||||
|
||||
## 제약
|
||||
|
||||
클라이언트에 내보내는 메시지에 내부 테이블 이름이나 컬럼 이름을 넣지 않는다.
|
||||
|
||||
참조 검사는 삭제 경로에서 돈다. 이 경로의 응답 시간을 늘리지 않는다.
|
||||
|
||||
## 선택지
|
||||
|
||||
**참조 검사를 종류별로 갈라 어느 것이 걸렸는지 돌려준다**
|
||||
다섯 개의 존재 검사를 따로 돌리고 걸린 종류를 응답에 싣는다. 화면이 그 종류를 사용자가 고칠 수 있는 곳의 이름으로 옮긴다.
|
||||
|
||||
**막는 참조를 목록으로 돌려준다**
|
||||
어느 기록이 걸었는지까지 보인다. 관계는 이름을 보일 수 있지만 프로젝트 링크와 주제 대표 기록은 다른 화면이라 이름만으로는 어디를 고칠지 알기 어렵다.
|
||||
|
||||
**문구만 고쳐 프로젝트 연결을 함께 언급한다**
|
||||
가장 싸다. 다섯 중 어느 것인지는 여전히 말하지 못한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 참조 검사를 종류별로 갈라 걸린 종류를 응답에 실어 보고, 삭제 경로의 응답 시간이 얼마나 달라지는지 잰다
|
||||
2. 다섯 종류를 사용자가 고칠 수 있는 화면 이름으로 옮겨 적고 몇 가지로 줄어드는지 센다
|
||||
3. 실제로 막힌 작업본 하나로 새 문구를 보여 주고 어디를 고쳐야 하는지 문구만으로 찾을 수 있는지 확인한다
|
||||
|
||||
닫는 조건 : 삭제가 막혔을 때 어디를 고쳐야 하는지 문구만 보고 알 수 있으면 닫는다. 종류별로 가르는 비용이 응답 시간에 드러나면 문구만 고치는 쪽으로 정하고 Decision 으로 넘긴다
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: ask-which-words-to-use
|
||||
title: 톤을 지적받으면 고쳐 쓰지 말고 어떤 말을 쓸지 묻는다
|
||||
topic: one-thing-many-names
|
||||
topicName: 같은 것이 화면마다 다른 이름
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§13.3
|
||||
---
|
||||
|
||||
# 톤을 지적받으면 고쳐 쓰지 말고 어떤 말을 쓸지 묻는다
|
||||
|
||||
사용자가 프로필의 문구가 AI 스럽다고 지적했다. 고쳐 쓴 첫 번째 안도 거절당했고, 결국 사용자가 직접 쓴 텍스트를 그대로 실었다. 이 사이트의 글은 작성자가 자기 말로 쓴다. 더 나은 문장을 제안할 때와 그 사람의 말투로 쓸 때 필요한 것이 다르다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다**
|
||||
두 번째 안을 문어체로 세울 때 이 기준을 썼다.
|
||||
- **서버는 하나를 답했는데 화면은 추측 셋을 출력했다**
|
||||
그 문구는 목소리가 아니라 서버가 답한 사실을 실어야 한다.
|
||||
- **한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다**
|
||||
같은 시기에 종류 이름을 표 하나로 모았다.
|
||||
|
||||
## 목적
|
||||
|
||||
작성자의 목소리로 쓰인 글을 고쳐 쓰다 두 번 거절당하는 것을 막는다. 톤을 지적할 때 사용자가 가리키는 것은 문장의 품질이 아니라 그 말을 누가 쓰는가다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**톤을 지적받으면 고쳐 쓰기 전에 어떤 말을 쓸지 묻는다**
|
||||
「더 나은 문장」을 제안할 때와 그 사람의 말투로 쓸 때 필요한 것이 다르다.
|
||||
|
||||
**무엇이 AI 스러운지 구체적으로 받아 적는다**
|
||||
무엇을 하는지 말하지 않는 동사로 끝나는 것과 번역투가 실제로 지적된 두 가지였다.
|
||||
|
||||
**작성자가 이미 쓰는 말투를 따른다**
|
||||
Case 소제목에 쓰는 말이 「~한 것」 명사형이면 새 제목도 그 형태로 맞춘다. 의문형 꼬리와 이 기록에서 쓰지 않는 낱말은 쓰지 않는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
공개 화면의 글이 작성자의 목소리인 곳 — 프로필, 프로젝트 소개, 구역 제목, 기록의 소제목.
|
||||
|
||||
## 예외
|
||||
|
||||
오류 문구처럼 서버가 답한 사실을 그대로 실어야 하는 곳에서는 말투를 묻지 않는다. 무엇을 실을지를 먼저 정한다.
|
||||
|
||||
계약이나 코드가 정한 이름은 이 규칙에서 뺀다. 표시 이름만 바꾸고 계약 값은 그대로 둔다.
|
||||
|
||||
## 예시
|
||||
|
||||
「섞는다」·「함께 기록한다」·「흩어지지 않게」가 무엇을 하는지 말하지 않는 동사로 끝난다고 지적받았다.
|
||||
|
||||
「결론이 서는 조건」·「프로젝트를 답니다」·「접근을 나눠 견주고」가 번역투로 지적받았다.
|
||||
|
||||
「무엇을 견줬나」는 의문형 꼬리에 이 기록에서 쓰지 않는 낱말이었다. 작성자가 Case 소제목에 쓰는 말은 「이 구조에서 감수한 것」처럼 「~한 것」 명사형이라 그쪽에 맞췄다.
|
||||
|
||||
고쳐 쓴 첫 번째 안이 거절당한 뒤 사용자가 직접 쓴 텍스트를 그대로 실었다.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-build-argument-left-out
|
||||
title: 배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다
|
||||
topic: only-visible-after-deploying
|
||||
topicName: 배포해 봐야 드러난 것
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§12.2
|
||||
---
|
||||
|
||||
# 배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다
|
||||
|
||||
프론트 이미지를 빌드하면서 API 주소 인자를 넘기지 않았다. 배포본이 존재하지 않는 주소를 불렀다. Dockerfile 이 그 경고를 문자 그대로 적어 두고 있었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **레지스트리 없이 tar 를 import 하는 배포 경로**
|
||||
왜 이 인자가 이미지에 굳는지가 그 개념에 있다.
|
||||
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
|
||||
이 사건 뒤에 목록으로 굳혔다.
|
||||
- **컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다**
|
||||
같은 배포에서 드러난 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
배포본이 API 를 부르는데 존재하지 않는 주소로 나갔다. 화면은 데이터를 받지 못했다.
|
||||
|
||||
빌드는 성공했고 이미지도 정상적으로 올라왔다.
|
||||
|
||||
## 결론
|
||||
|
||||
프론트 이미지 빌드에 `RUNTIME_API_BASE_URL` 을 넘기지 않으면 기본값이 이미지에 굳는다. 그 기본값이 존재하지 않는 주소다.
|
||||
|
||||
Dockerfile 이 그 경고를 문자 그대로 적어 두고 있는데도 빠뜨렸다.
|
||||
|
||||
`kubectl rollout undo` 로 되돌리고 다시 빌드했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
프론트 이미지 : APP_PROFILE · VITE_ROUTER_BASE_PATH · RUNTIME_API_BASE_URL · VITE_BUILD_ID · VITE_COMMIT_SHA · RELEASE_ID · CI_RUNNER_IMAGE · SOURCE_DATE_EPOCH
|
||||
런타임 : k3s
|
||||
확인 방식 : 배포본의 네트워크 요청에서 실제로 나가는 주소 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 프론트 이미지를 API 주소 인자 없이 빌드한다
|
||||
2. 배포하고 사이트를 연다
|
||||
3. 개발자도구 네트워크에서 API 요청이 어느 호스트로 나가는지 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 빌드 인자는 이미지에 굳는다
|
||||
|
||||
이 프론트는 API 주소를 빌드 인자로 받는다. 런타임 환경 변수가 아니므로 배포한 뒤에는 바꿀 수 없고, 잘못 넣으면 다시 빌드해서 다시 올려야 한다.
|
||||
|
||||
인자를 넘기지 않으면 기본값이 들어간다. 그 기본값은 존재하지 않는 주소다.
|
||||
|
||||
## 요구하는 인자 전부
|
||||
|
||||
```text
|
||||
APP_PROFILE=production
|
||||
VITE_ROUTER_BASE_PATH=/
|
||||
RUNTIME_API_BASE_URL=https://hyeonworks.com/ ← 빠뜨리면 api.example.com
|
||||
VITE_BUILD_ID / VITE_COMMIT_SHA / RELEASE_ID
|
||||
CI_RUNNER_IMAGE=node@sha256:… ← 반드시 @sha256 다이제스트
|
||||
SOURCE_DATE_EPOCH
|
||||
```
|
||||
|
||||
## 되돌리고 다시 빌드했다
|
||||
|
||||
`kubectl rollout undo` 로 이전 리비전으로 되돌린 뒤 인자를 넣어 다시 빌드하고 다시 올렸다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
빌드가 이 인자를 요구하도록 막지 않았다. 빠뜨리면 여전히 빌드는 성공하고 배포본만 틀린다. 지금 남은 것은 인자 목록을 적어 둔 것뿐이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-healthy-container-that-served-one-403
|
||||
title: 컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다
|
||||
topic: only-visible-after-deploying
|
||||
topicName: 배포해 봐야 드러난 것
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§12.4
|
||||
- final/document.md#§12.5
|
||||
---
|
||||
|
||||
# 컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다
|
||||
|
||||
컨테이너는 healthy 로 올라왔는데 SPA 가 부팅되지 않았다. nginx 가 설정 파일 하나를 읽지 못해 그 파일만 403 을 돌려줬다. 빌드가 그 파일을 0600 으로 쓰고 있었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **레지스트리 없이 tar 를 import 하는 배포 경로**
|
||||
이미지 안의 상태가 왜 배포에서만 드러나는지가 그 개념에 있다.
|
||||
- **nginx 가 모르는 라우트는 새로고침에서 404 다**
|
||||
같은 웹 서버가 서빙 목록 때문에 낸 다른 사건이다.
|
||||
- **배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다**
|
||||
같은 배포에서 드러난 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
파드가 healthy 로 올라왔다. 사이트를 열면 화면이 그려지지 않았다.
|
||||
|
||||
컨테이너는 살아 있고 nginx 도 응답한다. SPA 가 부팅에 필요한 설정 파일 하나만 403 이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
빌드가 그 설정 파일을 0600 으로 쓴다. nginx 를 돌리는 사용자가 그 파일을 읽지 못한다.
|
||||
|
||||
healthy 판정은 헬스 엔드포인트를 본다. 그 엔드포인트는 이 파일을 읽지 않으므로 통과한다.
|
||||
|
||||
이미지가 권한을 정규화하도록 고쳤다.
|
||||
|
||||
같은 배포에서 favicon 도 404 였다. `index.html` 이 `public/favicon.svg` 를 참조한 적이 없다. 파일은 이미지에 들어 있었고 nginx 도 서빙했지만 브라우저는 `/favicon.ico` 를 물었고 404 를 받아 기본 아이콘으로 떨어졌다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 83409be
|
||||
런타임 : nginx 컨테이너
|
||||
확인 방식 : 배포본에서 그 파일 경로를 직접 요청해 상태 코드 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 빌드가 쓰는 설정 파일의 권한을 0600 으로 둔다
|
||||
2. 이미지를 배포한다 — 파드가 healthy 로 올라온다
|
||||
3. 그 파일 경로를 브라우저나 curl 로 요청한다 — 403 이 온다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## healthy 가 무엇을 확인했나
|
||||
|
||||
헬스 판정은 헬스 엔드포인트가 응답하는지를 본다. nginx 프로세스가 살아 있고 그 경로를 돌려주면 통과한다.
|
||||
|
||||
SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다. 그래서 컨테이너는 정상이고 사이트만 안 된다.
|
||||
|
||||
## 빌드가 쓴 권한
|
||||
|
||||
빌드가 그 파일을 0600 으로 쓴다. 파일을 만든 사용자만 읽을 수 있고, nginx 를 돌리는 사용자는 다른 사용자다.
|
||||
|
||||
이미지가 권한을 정규화하도록 고쳤다.
|
||||
|
||||
## 브라우저가 묻는 주소
|
||||
|
||||
같은 배포에서 favicon 도 404 였다. 이유가 달랐다 — `index.html` 이 `public/favicon.svg` 를 참조한 적이 없다. 파일은 이미지에 들어 있었고 nginx 도 서빙했지만, 브라우저는 참조가 없으면 `/favicon.ico` 를 묻는다. 그 이름의 파일이 없어 404 를 받고 기본 아이콘으로 떨어졌다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이미지 안의 다른 파일 권한을 전수로 확인하지 않았다. 고친 것은 빌드가 쓰는 한 곳이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: deploying-without-a-registry
|
||||
title: 레지스트리 없이 tar 를 import 하는 배포 경로
|
||||
topic: only-visible-after-deploying
|
||||
topicName: 배포해 봐야 드러난 것
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
basisVersion: k3s + containerd · hyeonworks.com 단일 배포 단위 · 2026-09 시점
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§1.3
|
||||
- final/document.md#§12.2
|
||||
---
|
||||
|
||||
# 레지스트리 없이 tar 를 import 하는 배포 경로
|
||||
|
||||
이 사이트는 이미지 레지스트리를 쓰지 않는다. 로컬에서 빌드한 이미지를 tar 로 말아 서버에 올리고, 클러스터 안에 일회성 Job 을 띄워 컨테이너 런타임으로 import 한 뒤 이미지 태그를 바꾼다. 이 경로 때문에 빌드 인자와 파일 권한이 배포에서만 드러난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다**
|
||||
이 경로에서 빌드 인자가 어떻게 새는지가 그 기록에 있다.
|
||||
- **컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다**
|
||||
이미지 안의 권한이 배포에서 드러난 사건이다.
|
||||
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
|
||||
이 경로에서 사람이 기억해야 하는 것들이 그 기준에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 레지스트리를 쓰지 않는다
|
||||
|
||||
```text
|
||||
로컬 docker build → docker save | gzip → scp dh-server:/tmp/deploy.tar.gz
|
||||
→ kube-system 의 containerd import Job → kubectl set image
|
||||
```
|
||||
|
||||
공개 Hub 는 소스가 들어간 이미지라 쓸 수 없다. k3s 의 containerd 소켓은 root 전용이라 사용자 셸에서 닿지 않는다. 그래서 클러스터 안에 일회성 Job 을 띄워 tar 를 import 한다.
|
||||
|
||||
## 배포 단위가 하나다
|
||||
|
||||
배포 단위는 `hyeonworks.com` 하나이고 서브도메인을 쓰지 않는다. 공개는 `/`, API 는 `/api` 다.
|
||||
|
||||
## 무엇이 이미지 안에서 굳는가
|
||||
|
||||
빌드 인자는 이미지에 굳는다. 런타임 환경 변수가 아니므로 배포한 뒤에 바꿀 수 없고, 잘못 넣으면 다시 빌드해서 다시 올려야 한다.
|
||||
|
||||
프론트 이미지가 요구하는 인자는 이만큼이다.
|
||||
|
||||
```text
|
||||
APP_PROFILE=production
|
||||
VITE_ROUTER_BASE_PATH=/
|
||||
RUNTIME_API_BASE_URL=https://hyeonworks.com/ ← 빠뜨리면 api.example.com
|
||||
VITE_BUILD_ID / VITE_COMMIT_SHA / RELEASE_ID
|
||||
CI_RUNNER_IMAGE=node@sha256:… ← 반드시 @sha256 다이제스트
|
||||
SOURCE_DATE_EPOCH
|
||||
```
|
||||
|
||||
백엔드 이미지는 Dockerfile 이 `src/` 아래에 있고 `RELEASE_VERSION`·`BUILD_VERSION`·`GIT_SHA`·`SOURCE_URL` 을 받는다. 태그는 짧은 SHA 일곱 자이고 배포된 것과 맞춰야 한다.
|
||||
|
||||
## 빌드 산출물에 커밋 해시가 들어간다
|
||||
|
||||
백엔드 빌드 산출물 이름에 커밋 해시가 들어간다. 작업 트리가 더러우면 해시가 달라져 stale 산출물 검사가 멈춘다. 커밋한 뒤에 빌드를 돌려야 한다.
|
||||
|
||||
## 이 경로가 늦게 알려 주는 것
|
||||
|
||||
이미지가 healthy 로 올라오는 것과 사이트가 동작하는 것은 다르다. 컨테이너 안의 파일 권한, nginx 가 서빙하기로 한 파일 목록, 빌드에 굳은 주소는 전부 이 단계 뒤에 드러난다.
|
||||
|
||||
<!-- body:end -->
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-composition-root-had-no-test
|
||||
title: 합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다
|
||||
topic: seams-no-test-crosses
|
||||
topicName: 테스트가 지나지 않는 이음매
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.4
|
||||
- final/document.md#§7.3
|
||||
---
|
||||
|
||||
# 합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다
|
||||
|
||||
공개 사이트 전체가 오류 화면이었다. 로그아웃 상태 방문자 — 공개 사이트의 전체 독자 — 가 브라우저에서 요청을 한 건도 내보내지 못했다. 세 결함이 겹쳐 있었고 각각이 다음 것을 가렸다. 게이트웨이 테스트도 화면 테스트도 이 이음매를 지나지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다**
|
||||
합성 루트가 그 목록의 한 줄이다.
|
||||
- **매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다**
|
||||
스텁 때문에 보이지 않던 다른 매핑 결함이다.
|
||||
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
|
||||
이 사건이 그 규칙의 근거 하나다.
|
||||
|
||||
## 문제
|
||||
|
||||
공개 소스가 HTTP 어댑터로 바뀐 뒤 로그아웃 상태 방문자가 요청을 하나도 내보내지 못했다. 화면은 전부 오류였다.
|
||||
|
||||
이 경로는 그 주까지 브라우저에서 한 번도 돌지 않았다. 이전에는 정적 소스를 쓰고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
세 결함이 겹쳐 있었고 앞의 것이 뒤의 것을 가리고 있었다.
|
||||
|
||||
1 : credential 을 붙이는 함수가 Studio 헬퍼에 먼저 묻고, 그 헬퍼가 자기 것이 아닌 프로파일에 null 을 준다. 아래 폴백이 세션을 읽고 인증되지 않은 것을 거절한다. 공개 읽기는 ANONYMOUS 프로파일이라 그 폴백으로 떨어졌다
|
||||
2 : 봉투 오류 생성기가 오류 코드를 Studio enum 에 고정해 세 표면이 공유했다. 공개와 관리는 각자 자기 계약에 enum 을 선언하므로 그들이 돌려준 모든 오류가 검증에 실패해 계약 위반으로 도착했다
|
||||
3 : not-found 경로가 봉투에 없는 필드를 읽고 있었다
|
||||
|
||||
엄격한 enum 을 잘못된 표면의 계약에 대고 검사해도 여전히 엄격해 보인다. 그래서 어떤 게이트도 잡지 못했다.
|
||||
|
||||
회귀 테스트가 실제 런타임 어댑터를 배포된 백엔드의 실제 404 본문에 대고 조립한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 03986da · 7600711
|
||||
공개 소스 : 정적 픽스처에서 HTTP 어댑터로 전환한 직후
|
||||
확인 방식 : 실제 어댑터를 배포된 백엔드의 실제 404 본문에 대고 조립하는 회귀 테스트
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 로그아웃 상태로 공개 사이트를 연다
|
||||
2. 네트워크 탭에서 요청이 나가는지 본다
|
||||
3. 나가지 않으면 credential 을 붙이는 경로에서 어느 프로파일이 어떤 판정을 받는지 따라간다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 세 결함이 서로를 가렸다
|
||||
|
||||
첫 번째만 고쳤을 때 요청이 나가기 시작했고, 그러자 두 번째가 드러났다. 두 번째를 고치니 세 번째가 나왔다.
|
||||
|
||||
`attachCredentials` 가 Studio 헬퍼에 먼저 묻는데, 그 헬퍼는 자기 것이 아닌 프로파일에 `null` 을 돌려준다. 그 아래 폴백이 세션을 읽고 인증되지 않은 것을 거절한다. 공개 읽기는 ANONYMOUS 프로파일을 선언하므로 그 폴백에 떨어졌다.
|
||||
|
||||
요청이 흐르자 두 번째가 나왔다. `envelopeError()` 가 `ApiError.code` 를 Studio enum 에 고정해 세 표면이 공유했다. 공개와 관리는 각자 자기 계약에 enum 을 선언하므로 그들이 돌려준 모든 오류가 검증에 실패해 `CONTRACT_VIOLATION` 으로 도착했다.
|
||||
|
||||
세 번째는 not-found 경로가 봉투에 없는 `status` 를 읽고 있던 것이다.
|
||||
|
||||
## 왜 어떤 게이트도 잡지 못했나
|
||||
|
||||
> 엄격한 enum 을 잘못된 표면의 계약에 대고 검사해도 여전히 엄격해 보인다.
|
||||
|
||||
검증은 돌고 있었고 통과하고 있었다. 다만 비교 대상이 틀렸다.
|
||||
|
||||
## 스텁이 이음매를 덮지 않는다
|
||||
|
||||
> 이 결함은 공개 소스가 HTTP 가 된 뒤에야 나타날 수 있었다. 이번 주까지 그 경로는 브라우저에서 한 번도 돌지 않았다. **스위트가 잡지 못한 이유는 게이트웨이와 화면을 검사할 뿐 합성 루트의 credential 결정은 검사하지 않기 때문이다 — 그 이음매에는 테스트가 없고, 이것이 그 대가다.**
|
||||
|
||||
같은 모양이 HTTP 매퍼에서도 났다. 게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠는데, 화면 테스트가 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않았다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣는 테스트를 넣으니 되돌려 보면 운영에서 난 것과 같은 오류로 실패한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 회귀 테스트가 덮는 것은 공개 읽기 프로파일이다. 다른 프로파일의 합성 루트에도 같은 구멍이 있는지는 세지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-generator-dropped-four-contract-fields
|
||||
title: 생성기가 계약 필드 넷을 조용히 빠뜨렸다 — 파생 스펙에 남은 YAML alias 34곳
|
||||
topic: seams-no-test-crosses
|
||||
topicName: 테스트가 지나지 않는 이음매
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.6
|
||||
---
|
||||
|
||||
# 생성기가 계약 필드 넷을 조용히 빠뜨렸다 — 파생 스펙에 남은 YAML alias 34곳
|
||||
|
||||
계약에 있는 필드 넷이 생성된 모델에서 사라져 있었다. 파생 단계의 YAML alias 때문에 파서가 스키마 15개를 거절했고, 거절당한 스키마들은 전부 타입을 명시하고 있어 계약 결함처럼 보이지 않았다. 검증을 끄면 생성이 성공했고, 그렇게 만든 모델에 그 넷이 없었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다**
|
||||
생성기가 그 목록의 한 줄이다.
|
||||
- **계약과 구현은 서버와 화면 양쪽에서 전수 대조한다**
|
||||
이 부류를 계약 쪽에서 막는 짝이 되는 기준이다.
|
||||
- **결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다**
|
||||
계약의 칸이 화면까지 오지 못한 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
파생 스펙을 파서에 넣으면 스키마 15개를 「is not of type `object`」로 거절했다. 그 스키마들은 전부 `type: object` 를 명시하고 있어서 계약 결함처럼 보이지 않았다.
|
||||
|
||||
`validateSpec` 을 끄면 생성이 성공한다. 그렇게 만든 모델을 컴파일하면 통과한다.
|
||||
|
||||
## 결론
|
||||
|
||||
거절의 원인은 계약이 아니라 파생 단계였다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고 snakeyaml 이 그 지점을 anchor 와 alias 로 덤프했다. 파생 스펙에 alias 가 34곳 있었다.
|
||||
|
||||
검증을 끄고 만든 모델에서 사라진 필드 넷 :
|
||||
LatestEntry.publishedAt
|
||||
ProjectListItem.updatedAt
|
||||
SearchResultItem.matchedFields
|
||||
ReleaseListItem.changeTypes
|
||||
|
||||
컴파일이 통과한 이유는 아직 그 필드를 쓰는 코드가 없어서다.
|
||||
|
||||
덤프 직전 deep copy 로 노드 identity 를 끊어 alias 를 원천 차단하고, 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀다. `validateSpec` 은 다시 켰다. 생성 모델 대조는 schema 이름에서 property 단위로 강화했다 — 이번 누락을 그 게이트가 통과시켰기 때문이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-backend : 365560e
|
||||
파생 : snakeyaml 덤프 · swagger-parser
|
||||
게이트 : verifyPublicGeneratedModels — schema 62개 · property 250개
|
||||
확인 방식 : 파생 스펙에서 alias 를 세고, 생성된 모델의 property 를 계약과 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 파생 스펙에서 anchor 와 alias 를 찾는다
|
||||
2. `validateSpec` 을 켜고 생성한다 — 그 스키마들이 거절된다
|
||||
3. 끄고 생성한 뒤 모델의 property 를 계약과 하나씩 맞춘다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 계약 결함처럼 보이지 않았다
|
||||
|
||||
파서가 거절한 스키마 15개는 전부 `type: object` 를 명시하고 있었다. 메시지는 「is not of type `object`」였다.
|
||||
|
||||
원인은 그 스키마가 아니라 파생 스펙의 표현이었다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고, snakeyaml 은 같은 인스턴스가 두 번 나오면 두 번째를 alias 로 덤프한다. 그 지점이 34곳이었다.
|
||||
|
||||
## 검증을 끄면 생성이 성공한다
|
||||
|
||||
`validateSpec` 을 끄고 생성하면 모델이 만들어진다. 다만 거절되던 스키마의 일부 필드가 빠진 채로 만들어진다.
|
||||
|
||||
빠진 것은 넷이었다 — `LatestEntry.publishedAt`, `ProjectListItem.updatedAt`, `SearchResultItem.matchedFields`, `ReleaseListItem.changeTypes`.
|
||||
|
||||
컴파일은 통과한다. 아직 아무도 그 필드를 안 쓰기 때문이다.
|
||||
|
||||
## 두 가지로 막았다
|
||||
|
||||
덤프 직전에 deep copy 로 노드 identity 를 끊었다. 같은 인스턴스가 두 번 나오지 않으면 alias 가 생기지 않는다. 그래도 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀고, `validateSpec` 은 다시 켰다.
|
||||
|
||||
생성 모델 대조도 바꿨다. schema 이름만 세던 것을 property 단위로 강화했다. 이번 누락을 이름 대조가 통과시켰기 때문이다. 지금은 schema 62개와 property 250개를 센다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
지금 세는 수는 사람이 갱신한다. 계약이 줄어드는 방향의 누락은 이 게이트가 잡지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-sql-had-never-been-executed
|
||||
title: 그 SQL 은 한 번도 실행된 적이 없었다
|
||||
topic: seams-no-test-crosses
|
||||
topicName: 테스트가 지나지 않는 이음매
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.2
|
||||
---
|
||||
|
||||
# 그 SQL 은 한 번도 실행된 적이 없었다
|
||||
|
||||
작업본 삭제가 500 을 돌려줬다. 참조 검사가 없는 컬럼을 조회하고 있었다. 진짜 문제는 그 SQL 이 한 번도 실행된 적이 없다는 것이었다 — 표준 `check` 는 Testcontainers 를 띄우지 않으므로 persistence SQL 을 한 줄도 돌리지 않고 빌드가 통과한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다**
|
||||
persistence SQL 이 그 목록의 한 줄이다.
|
||||
- **파드가 두 번 CrashLoopBackOff 로 들어갔다**
|
||||
같은 「지나지 않은 이음매」의 다른 예다.
|
||||
- **삭제를 막는 이유 다섯 가지가 전부 같은 한 문장으로 나온다**
|
||||
이 참조 검사가 지금도 남긴 문제다.
|
||||
|
||||
## 문제
|
||||
|
||||
작업본을 지우려 하면 500 이 났다. 참조 검사가 `public_resource_projection.document_id` 를 조회하는데 그런 컬럼이 없다.
|
||||
|
||||
이 테이블은 하나로 case·question·project·release 를 모두 담기 때문에 종류와 식별자 두 컬럼으로 기록을 가리킨다.
|
||||
|
||||
## 결론
|
||||
|
||||
컬럼 이름 하나가 틀렸고, 그 쿼리가 한 번도 실행된 적이 없었다.
|
||||
|
||||
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
|
||||
|
||||
표준 `check` 는 Testcontainers 를 띄우지 않는다. persistence SQL 은 한 번도 실행되지 않은 채 빌드가 통과하고, 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못한다.
|
||||
|
||||
삭제 경로 전용 통합 테스트 태스크를 만들고 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-backend : 37f474a 이후
|
||||
DB : 실제 PostgreSQL (Testcontainers)
|
||||
빌드 : 표준 check 는 Testcontainers 를 띄우지 않음
|
||||
확인 방식 : 삭제 경로 전용 통합 테스트 태스크에서 여덟 시나리오 실행
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 어댑터의 SQL 에서 컬럼 이름을 하나 틀리게 적는다
|
||||
2. `./gradlew check` 를 돌린다 — 통과한다
|
||||
3. 삭제 경로 통합 테스트 태스크를 돌린다 — 그 쿼리에서 멈춘다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 없는 컬럼을 조회했다
|
||||
|
||||
`public_resource_projection` 은 한 테이블이 case·question·project·release 를 모두 담는다. 그래서 기록을 가리킬 때 종류와 식별자 두 컬럼을 함께 쓴다. `document_id` 라는 컬럼은 없다.
|
||||
|
||||
참조 검사가 그 이름을 쓰고 있었고, 실행하면 500 이 났다.
|
||||
|
||||
## 다섯은 대조했고 하나는 가정했다
|
||||
|
||||
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
|
||||
|
||||
## 그 SQL 은 한 번도 실행되지 않았다
|
||||
|
||||
컬럼 이름보다 더 큰 문제는 검사 구조였다. 표준 `check` 는 Testcontainers 를 띄우지 않으므로 persistence SQL 이 한 줄도 실행되지 않은 채 빌드가 통과한다.
|
||||
|
||||
컴파일은 SQL 문자열 안을 보지 않는다. 단위 테스트는 어댑터를 스텁으로 바꾼다. 컬럼 이름이 맞는지 묻는 검사가 어디에도 없었다.
|
||||
|
||||
## 전용 태스크로 여덟 시나리오를 돌린다
|
||||
|
||||
삭제 경로 전용 통합 테스트 태스크를 만들었다. 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 태스크가 덮는 것은 삭제 경로다. 표준 `check` 는 여전히 Testcontainers 를 띄우지 않고, 새 어댑터 SQL 이 이 태스크에 등록되는지 보는 검사도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: two-pods-crashlooped-with-no-test-starting-the-context
|
||||
title: 파드가 두 번 CrashLoopBackOff 로 들어갔다 — 어떤 테스트도 애플리케이션 컨텍스트를 띄우지 않았다
|
||||
topic: seams-no-test-crosses
|
||||
topicName: 테스트가 지나지 않는 이음매
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.1
|
||||
- final/document.md#§6.5
|
||||
- final/document.md#§12.1
|
||||
---
|
||||
|
||||
# 파드가 두 번 CrashLoopBackOff 로 들어갔다 — 어떤 테스트도 애플리케이션 컨텍스트를 띄우지 않았다
|
||||
|
||||
파드가 두 번 CrashLoopBackOff 로 들어갔다. 한 번은 스캔되는 컴포넌트에 생성자가 둘이었고, 한 번은 이 빌드에 없는 Jackson 2 의 타입을 import 했다. 컴파일도 단위 테스트도 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다. 그중 어느 것도 애플리케이션 컨텍스트를 띄우지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다**
|
||||
이 사건이 그 목록의 첫 줄이다.
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
다른 언어에서 컴파일 통과가 반영의 증거가 아니었던 자매 사건이다.
|
||||
- **그 SQL 은 한 번도 실행된 적이 없었다**
|
||||
같은 「지나지 않은 이음매」의 다른 예다.
|
||||
|
||||
## 문제
|
||||
|
||||
컴포넌트 스캔이 생성자를 고르지 못하면 컨텍스트가 refresh 에 실패한다. 컨텍스트를 띄우는 테스트가 없으면 그 실패는 배포에서 처음 나타난다.
|
||||
|
||||
두 번째 사건은 import 였다. `JdbcProjectRepositoryAdapter` 가 Jackson 2 의 `ObjectMapper` 를 요구했는데 이 빌드는 Jackson 3 이다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 건 다 컨텍스트가 뜰 때 처음 드러났다.
|
||||
|
||||
첫 번째 : 스캔되는 컴포넌트에 생성자 둘, `@Autowired` 없음
|
||||
두 번째 : Jackson 2 `ObjectMapper` 를 요구, 이 빌드는 Jackson 3
|
||||
|
||||
두 번째가 컴파일을 통과한 이유는 Jackson 2 타입이 어떤 전이 의존성을 통해 클래스패스에 남아 있어서다. 잘못된 import 가 정상적으로 해석된다.
|
||||
|
||||
첫 번째는 ArchUnit 규칙으로 막았다. 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 `@Autowired` 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-backend : ca63d7d · 0da7c7e
|
||||
런타임 : k3s 위의 파드
|
||||
Jackson : 이 빌드는 Jackson 3, 클래스패스에 Jackson 2 타입이 전이 의존성으로 남아 있음
|
||||
확인 방식 : ArchUnit 규칙을 결함으로 되돌려 실제로 빨개지는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 스캔되는 컴포넌트에 생성자를 둘 만들고 `@Autowired` 를 붙이지 않는다
|
||||
2. `./gradlew check` 를 돌린다 — 통과한다
|
||||
3. ArchUnit D20 규칙을 켠 상태로 돌린다 — 그 컴포넌트를 짚는다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 어떤 테스트도 컨텍스트를 띄우지 않았다
|
||||
|
||||
새 활동 어댑터가 생성자를 둘 갖고 있었다. 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것이다. 둘 중 어느 것에도 `@Autowired` 가 없어 컴포넌트 스캔이 고르지 못했다.
|
||||
|
||||
> 컴파일도, 단위 테스트도, **실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다.** 운영에서 파드가 CrashLoopBackOff 로 들어갔고, 그때서야 드러났다.
|
||||
|
||||
## 클래스패스에 남은 옛 타입
|
||||
|
||||
두 번째 건은 import 였다. `JdbcProjectRepositoryAdapter` 가 `com.fasterxml.jackson.databind.ObjectMapper` 를 요구했다. 이 빌드는 `tools.jackson.databind` 를 쓰므로 그런 빈이 없고, 컨텍스트가 refresh 에 실패한다.
|
||||
|
||||
컴파일이 잡지 못한 이유는 어떤 전이 의존성이 Jackson 2 타입을 클래스패스에 올려 두어 import 가 정상적으로 해석되기 때문이다. 빈이 없다는 것은 컨텍스트를 띄워야 알 수 있다.
|
||||
|
||||
| 원인 | 왜 컴파일·테스트가 못 잡았나 | 커밋 |
|
||||
|---|---|---|
|
||||
| 스캔되는 컴포넌트에 생성자 둘, `@Autowired` 없음 | 어떤 테스트도 애플리케이션 컨텍스트를 띄우지 않는다 | `ca63d7d` |
|
||||
| Jackson 2 `ObjectMapper` 를 요구(이 빌드는 Jackson 3) | Jackson 2 타입이 전이 의존성으로 클래스패스에 남아 있어 import 가 정상 해석된다 | `0da7c7e` |
|
||||
|
||||
## 규칙 하나로 막은 절반
|
||||
|
||||
D20 규칙을 세웠다. 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 `@Autowired` 가 붙어야 한다. 결함을 되돌려 규칙이 실제로 멈추는 것을 확인한 뒤 커밋했다.
|
||||
|
||||
## 막지 못한 절반
|
||||
|
||||
D20 은 생성자 쪽만 본다. 클래스패스에 남은 옛 라이브러리 타입을 import 하는 것은 이 규칙이 잡지 않는다. 그 경로를 막는 검사는 아직 없고, 컨테이너가 뜰 때 알게 된다.
|
||||
|
||||
<!-- body:end -->
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: put-one-check-on-each-seam
|
||||
title: 이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다
|
||||
topic: seams-no-test-crosses
|
||||
topicName: 테스트가 지나지 않는 이음매
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.7
|
||||
---
|
||||
|
||||
# 이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다
|
||||
|
||||
「모든 검사가 통과했는데 운영에서 깨졌다」가 다섯 번 있었다. 매번 그 이음매를 아무 테스트도 지나지 않았다. 층을 스텁으로 나눠 시험하는 구조에서는 그 나눈 자국마다 검사가 하나씩 필요하다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **파드가 두 번 CrashLoopBackOff 로 들어갔다**
|
||||
스프링 컨텍스트 이음매의 근거 사건이다.
|
||||
- **그 SQL 은 한 번도 실행된 적이 없었다**
|
||||
persistence SQL 이음매의 근거 사건이다.
|
||||
- **합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다**
|
||||
합성 루트와 HTTP 매퍼 이음매의 근거 사건이다.
|
||||
- **생성기가 계약 필드 넷을 조용히 빠뜨렸다**
|
||||
생성기 이음매의 근거 사건이다.
|
||||
|
||||
## 목적
|
||||
|
||||
층을 스텁으로 나눠 시험하는 구조에서 그 나눈 자국이 검사되지 않고 남는 것을 막는다. 이 부류는 모든 검사가 초록불인 상태로 배포된다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**스프링 컨텍스트를 띄우는 검사를 하나 둔다**
|
||||
컴파일도 단위 테스트도 실제 DB 위의 통합 테스트도 컨텍스트를 띄우지 않을 수 있다. 스캔되는 컴포넌트의 생성자 규칙은 아키텍처 검사로 대신할 수 있다.
|
||||
|
||||
**persistence SQL 을 실제 DB 에서 돌리는 태스크를 둔다**
|
||||
표준 `check` 가 컨테이너를 띄우지 않으면 어댑터의 SQL 은 한 줄도 실행되지 않는다. 컬럼 이름은 실행해야만 검증된다.
|
||||
|
||||
**계약 모양 그대로의 응답을 진짜 게이트웨이에 넣는 검사를 둔다**
|
||||
화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않는다.
|
||||
|
||||
**실제 런타임 어댑터를 실제 응답 본문에 대고 조립하는 검사를 둔다**
|
||||
게이트웨이 테스트는 실행기를 스텁으로 바꾸고 화면 테스트는 게이트웨이를 스텁으로 바꾼다. 합성 루트의 credential 결정은 둘 다 덮지 않는다.
|
||||
|
||||
**생성기는 모델이 만들어졌는지가 아니라 property 가 계약과 같은지로 본다**
|
||||
모델은 필드가 빠져도 만들어진다. 아직 그 필드를 쓰는 코드가 없으면 컴파일도 통과한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
스텁으로 층을 나눠 시험하는 구조. 계약이 다른 저장소에 있고 생성기를 지나 들어오거나, 컨테이너가 필요한 검사를 별도 태스크로 뺀 저장소에서 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
그 층을 실제로 지나는 검사가 이미 있으면 더 두지 않는다.
|
||||
|
||||
스텁을 쓰는 테스트를 늘리는 것은 이 문제를 덮지 않는다. 스텁의 개수가 아니라 스텁이 대신한 층이 문제다.
|
||||
|
||||
## 예시
|
||||
|
||||
컨텍스트를 띄우지 않아 파드가 CrashLoopBackOff 로 들어간 것을 아키텍처 규칙으로 막았다.
|
||||
|
||||
삭제 경로의 SQL 이 한 번도 실행된 적이 없어서 전용 통합 테스트 태스크를 만들었다.
|
||||
|
||||
화면 테스트가 픽스처를 쓰므로 질문 상세의 매핑을 아무도 지나지 않았다. 계약 모양 응답을 진짜 게이트웨이에 넣으니 되돌려 보면 운영과 같은 오류로 실패한다.
|
||||
|
||||
생성 모델 대조를 schema 이름에서 property 로 바꿨다. 이름 대조는 필드 넷이 빠진 모델을 통과시켰다.
|
||||
@@ -87,7 +87,7 @@
|
||||
"kinds": {
|
||||
"case": [
|
||||
{
|
||||
"title": "개념을 하나 더하자 열세 자리가 그것을 조용히 삼켰다",
|
||||
"title": "개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다",
|
||||
"slug": "one-new-kind-fell-through-thirteen-places",
|
||||
"readiness": "READY",
|
||||
"source": [
|
||||
@@ -113,7 +113,15 @@
|
||||
"case:an-operation-you-can-see-but-cannot-call"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "hand-listed-kinds/case/case-one-new-kind-fell-through-thirteen-places.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/guards/kind-tables-now.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -138,12 +146,18 @@
|
||||
"concept:where-typescript-stops-checking"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "hand-listed-kinds/concept/concept-exhaustive-switch-and-record-tables.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [
|
||||
{
|
||||
"title": "종류를 나열하는 자리는 컴파일러나 계약 대조 검사가 세게 만든다",
|
||||
"title": "종류를 나열하는 곳은 컴파일러나 계약 대조 검사가 세게 만든다",
|
||||
"slug": "enumerate-kinds-where-the-compiler-sees-it",
|
||||
"readiness": "READY",
|
||||
"source": [
|
||||
@@ -160,12 +174,18 @@
|
||||
"reference:compare-the-contract-with-both-implementations"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "hand-listed-kinds/reference/reference-enumerate-kinds-where-the-compiler-sees-it.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [
|
||||
{
|
||||
"title": "종류를 세는 자리 둘이 아직 컴파일러의 보호 밖에 있다",
|
||||
"title": "종류를 세는 곳 둘이 아직 컴파일러의 보호 밖에 있다",
|
||||
"slug": "two-kind-tables-outside-the-compiler",
|
||||
"readiness": "OPEN",
|
||||
"source": [
|
||||
@@ -184,7 +204,13 @@
|
||||
"case:an-address-frozen-at-publish-time"
|
||||
],
|
||||
"kind": "question",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "hand-listed-kinds/question/openquestion-two-kind-tables-outside-the-compiler.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"decision": []
|
||||
@@ -219,7 +245,13 @@
|
||||
"case:it-said-there-were-no-open-questions"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "declared-but-not-implemented/case/case-five-screens-were-quietly-empty.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다",
|
||||
@@ -241,7 +273,13 @@
|
||||
"case:a-narrow-implementation-satisfied-a-wide-port"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "declared-but-not-implemented/case/case-an-operation-you-can-see-but-cannot-call.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -263,7 +301,13 @@
|
||||
"reference:enumerate-kinds-where-the-compiler-sees-it"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "declared-but-not-implemented/reference/reference-compare-the-contract-with-both-implementations.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -298,7 +342,13 @@
|
||||
"concept:where-typescript-stops-checking"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/case/case-a-public-reference-was-entirely-empty.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "관계의 요약이 경계 세 곳을 지나며 사라졌다",
|
||||
@@ -321,7 +371,13 @@
|
||||
"case:a-list-item-had-to-carry-the-whole-document"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/case/case-a-summary-vanished-at-three-boundaries.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다",
|
||||
@@ -343,7 +399,13 @@
|
||||
"case:a-summary-vanished-at-three-boundaries"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/case/case-a-list-item-had-to-carry-the-whole-document.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -367,7 +429,17 @@
|
||||
"reference:verify-at-the-end-of-the-value-journey"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/concept/concept-eleven-boundaries-a-value-crosses.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [
|
||||
"value-boundaries"
|
||||
],
|
||||
"assetFiles": [
|
||||
"value-boundaries"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [
|
||||
@@ -388,7 +460,13 @@
|
||||
"reference:verify-at-the-end-of-the-value-journey"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/reference/reference-a-missing-contract-field-has-a-signature.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "한 경계를 고쳤으면 값의 여정 끝에서 확인한다",
|
||||
@@ -408,7 +486,13 @@
|
||||
"concept:where-typescript-stops-checking"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "values-lost-between-boundaries/reference/reference-verify-at-the-end-of-the-value-journey.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -417,7 +501,7 @@
|
||||
"topic": "values-lost-between-boundaries"
|
||||
},
|
||||
"what-the-compiler-lets-through": {
|
||||
"title": "타입 검사가 통과시키는 자리",
|
||||
"title": "타입 검사가 통과시키는 곳",
|
||||
"readerQuestion": "「타입 검사가 통과했다」는 왜 반영의 증거가 아닌가?",
|
||||
"kinds": {
|
||||
"case": [
|
||||
@@ -441,10 +525,16 @@
|
||||
"reference:verify-at-the-end-of-the-value-journey"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "what-the-compiler-lets-through/case/case-a-narrow-implementation-satisfied-a-wide-port.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "`npx tsc --noEmit` 이 한 파일도 검사하지 않고 성공했다",
|
||||
"title": "npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다",
|
||||
"slug": "the-typecheck-command-checked-no-files",
|
||||
"readiness": "READY",
|
||||
"source": [
|
||||
@@ -462,12 +552,18 @@
|
||||
"reference:what-a-person-must-run-before-deploying"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "what-the-compiler-lets-through/case/case-the-typecheck-command-checked-no-files.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
{
|
||||
"title": "TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · `as` 단언 · `(input: never)` 캐스트 · 검사 대상을 갖지 않은 tsconfig",
|
||||
"title": "TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig",
|
||||
"slug": "where-typescript-stops-checking",
|
||||
"readiness": "READY",
|
||||
"source": [
|
||||
@@ -488,7 +584,13 @@
|
||||
"case:a-public-reference-was-entirely-empty"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "what-the-compiler-lets-through/concept/concept-where-typescript-stops-checking.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [],
|
||||
@@ -524,7 +626,13 @@
|
||||
"case:the-sql-had-never-been-executed"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "seams-no-test-crosses/case/case-two-pods-crashlooped-with-no-test-starting-the-context.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "그 SQL 은 한 번도 실행된 적이 없었다",
|
||||
@@ -546,7 +654,13 @@
|
||||
"question:the-refusal-does-not-name-what-blocks-it"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "seams-no-test-crosses/case/case-the-sql-had-never-been-executed.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다",
|
||||
@@ -569,7 +683,13 @@
|
||||
"reference:verify-at-the-end-of-the-value-journey"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "seams-no-test-crosses/case/case-the-composition-root-had-no-test.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "생성기가 계약 필드 넷을 조용히 빠뜨렸다 — 파생 스펙에 남은 YAML alias 34곳",
|
||||
@@ -592,7 +712,13 @@
|
||||
"case:a-list-item-had-to-carry-the-whole-document"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "seams-no-test-crosses/case/case-the-generator-dropped-four-contract-fields.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -614,7 +740,13 @@
|
||||
"case:the-generator-dropped-four-contract-fields"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "seams-no-test-crosses/reference/reference-put-one-check-on-each-seam.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -648,7 +780,13 @@
|
||||
"case:eight-places-a-single-route-touches"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-route-many-hand-kept-lists/case/case-a-route-the-web-server-never-heard-of.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "라우트 하나가 건드리는 여덟 자리와, 그것들이 우는 시점",
|
||||
@@ -672,7 +810,13 @@
|
||||
"case:the-guard-worked-and-i-did-not-run-it"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-route-many-hand-kept-lists/case/case-eight-places-a-single-route-touches.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -694,7 +838,13 @@
|
||||
"reference:enumerate-kinds-where-the-compiler-sees-it"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-route-many-hand-kept-lists/reference/reference-derive-the-route-lists-from-the-route-contract.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [
|
||||
@@ -716,7 +866,13 @@
|
||||
"case:the-guard-worked-and-i-did-not-run-it"
|
||||
],
|
||||
"kind": "question",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-route-many-hand-kept-lists/question/openquestion-nobody-signed-the-accessibility-evidence.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"decision": [
|
||||
@@ -740,14 +896,22 @@
|
||||
"reference:do-not-draw-a-link-that-does-not-resolve"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-route-many-hand-kept-lists/decision/decision-do-not-translate-the-catch-all-route.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/audit/dead-link-sweep.txt"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"topic": "one-route-many-hand-kept-lists"
|
||||
},
|
||||
"addresses-frozen-at-publish-time": {
|
||||
"title": "주소가 만들어지고 굳어지는 자리",
|
||||
"title": "주소가 만들어지고 굳어지는 곳",
|
||||
"readerQuestion": "공개 주소는 누가 언제 만들고, 그것이 틀리면 어디까지 번지는가?",
|
||||
"kinds": {
|
||||
"case": [
|
||||
@@ -776,7 +940,21 @@
|
||||
"case:a-link-that-pointed-at-itself"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/case/case-an-address-frozen-at-publish-time.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [
|
||||
"decision-path-404"
|
||||
],
|
||||
"assetFiles": [
|
||||
"decision-path-404"
|
||||
],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/db/decision-path-after-v15.txt",
|
||||
"../../../final/evidence/raw/api/decision-anchor-fixed.txt",
|
||||
"../../../final/evidence/raw/audit/dead-link-sweep.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다",
|
||||
@@ -798,7 +976,13 @@
|
||||
"concept:topic-variant-and-record-variant"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/case/case-a-link-that-pointed-at-itself.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "slug 생성이 한글을 버려 주제 만들기가 간헐적으로 실패했다",
|
||||
@@ -819,7 +1003,13 @@
|
||||
"reference:do-not-draw-a-link-that-does-not-resolve"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/case/case-a-slug-rule-that-threw-korean-away.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -840,7 +1030,13 @@
|
||||
"reference:derive-the-route-lists-from-the-route-contract"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/reference/reference-do-not-draw-a-link-that-does-not-resolve.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "우회를 남길 때는 되돌릴 조건을 함께 적는다",
|
||||
@@ -858,7 +1054,13 @@
|
||||
"decision:a-new-route-ships-frontend-first"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/reference/reference-write-down-what-would-undo-a-workaround.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -880,7 +1082,13 @@
|
||||
"reference:do-not-draw-a-link-that-does-not-resolve"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "addresses-frozen-at-publish-time/decision/decision-a-new-route-ships-frontend-first.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -910,7 +1118,13 @@
|
||||
"case:one-cell-failing-took-its-neighbour-down"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "failure-drawn-as-absence/case/case-it-said-there-were-no-open-questions.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "한 칸의 실패가 옆 칸을 끌고 내려갔다",
|
||||
@@ -930,7 +1144,13 @@
|
||||
"case:the-comparison-band-changed-three-times"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "failure-drawn-as-absence/case/case-one-cell-failing-took-its-neighbour-down.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다",
|
||||
@@ -952,7 +1172,13 @@
|
||||
"case:the-composition-root-had-no-test"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "failure-drawn-as-absence/case/case-records-disappeared-without-a-trace.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -974,7 +1200,13 @@
|
||||
"case:records-disappeared-without-a-trace"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "failure-drawn-as-absence/reference/reference-say-you-could-not-read-it.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "매번 우는 검사는 읽히지 않는다 — 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다",
|
||||
@@ -992,7 +1224,13 @@
|
||||
"case:an-address-frozen-at-publish-time"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "failure-drawn-as-absence/reference/reference-an-expected-failure-must-not-be-counted-as-a-failure.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -1027,7 +1265,13 @@
|
||||
"reference:measure-what-is-visible-not-a-proxy"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "css-rules-that-leak/case/case-a-section-wide-rule-caught-the-heading.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "규칙이 없었던 게 아니라 절반만 있었다",
|
||||
@@ -1049,7 +1293,13 @@
|
||||
"reference:scope-a-layout-rule-to-what-uses-it"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "css-rules-that-leak/case/case-the-rule-was-not-missing-it-was-half-there.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -1070,7 +1320,13 @@
|
||||
"reference:measure-what-is-visible-not-a-proxy"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "css-rules-that-leak/reference/reference-scope-a-layout-rule-to-what-uses-it.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "프록시 지표가 아니라 보이는 것을 측정한다",
|
||||
@@ -1093,7 +1349,15 @@
|
||||
"case:the-comparison-band-changed-three-times"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "css-rules-that-leak/reference/reference-measure-what-is-visible-not-a-proxy.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/browser/tab-metrics.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -1127,7 +1391,13 @@
|
||||
"case:a-build-argument-left-out"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "only-visible-after-deploying/case/case-a-healthy-container-that-served-one-403.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다",
|
||||
@@ -1149,7 +1419,13 @@
|
||||
"case:a-healthy-container-that-served-one-403"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "only-visible-after-deploying/case/case-a-build-argument-left-out.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -1174,7 +1450,13 @@
|
||||
"reference:what-a-person-must-run-before-deploying"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "only-visible-after-deploying/concept/concept-deploying-without-a-registry.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [],
|
||||
@@ -1207,7 +1489,13 @@
|
||||
"reference:ask-which-words-to-use"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-thing-many-names/case/case-nine-names-for-five-kinds.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다",
|
||||
@@ -1231,7 +1519,13 @@
|
||||
"case:a-slug-rule-that-threw-korean-away"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-thing-many-names/case/case-renaming-the-kinds-twice.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "서버는 하나를 답했는데 화면은 추측 셋을 출력했다",
|
||||
@@ -1251,7 +1545,13 @@
|
||||
"case:nine-names-for-five-kinds"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-thing-many-names/case/case-an-error-message-that-guessed.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -1272,7 +1572,13 @@
|
||||
"case:an-error-message-that-guessed"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-thing-many-names/reference/reference-ask-which-words-to-use.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [
|
||||
@@ -1296,7 +1602,15 @@
|
||||
"reference:say-you-could-not-read-it"
|
||||
],
|
||||
"kind": "question",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "one-thing-many-names/question/openquestion-the-refusal-does-not-name-what-blocks-it.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/db/delete-blocked-by-project-link.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decision": []
|
||||
@@ -1328,7 +1642,17 @@
|
||||
"case:one-cell-failing-took-its-neighbour-down"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "an-axis-inside-a-topic/case/case-the-comparison-band-changed-three-times.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/browser/home-tabs-grouped.png",
|
||||
"../../../final/evidence/browser/home-topic-tabs.png",
|
||||
"../../../final/evidence/browser/tab-metrics.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -1353,7 +1677,20 @@
|
||||
"case:the-comparison-band-changed-three-times"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "an-axis-inside-a-topic/concept/concept-topic-variant-and-record-variant.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [
|
||||
"topic-variant-model"
|
||||
],
|
||||
"assetFiles": [
|
||||
"topic-variant-model"
|
||||
],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/db/topic-variant-rows.txt",
|
||||
"../../../final/evidence/raw/db/record-variant-links.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"reference": [],
|
||||
@@ -1379,7 +1716,15 @@
|
||||
"reference:measure-what-is-visible-not-a-proxy"
|
||||
],
|
||||
"kind": "question",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "an-axis-inside-a-topic/question/openquestion-the-conclusion-line-does-not-follow-the-records.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/db/record-variant-links.txt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decision": [
|
||||
@@ -1405,7 +1750,16 @@
|
||||
"case:the-comparison-band-changed-three-times"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "an-axis-inside-a-topic/decision/decision-an-axis-inside-a-topic-not-four-topics.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/db/topic-variant-rows.txt",
|
||||
"../../../final/evidence/raw/db/record-variant-links.txt"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1437,7 +1791,13 @@
|
||||
"case:eight-places-a-single-route-touches"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "when-a-guard-can-be-trusted/case/case-the-guard-worked-and-i-did-not-run-it.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -1463,7 +1823,15 @@
|
||||
"reference:enumerate-kinds-where-the-compiler-sees-it"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "when-a-guard-can-be-trusted/reference/reference-revert-the-defect-and-watch-the-guard-fail.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": [
|
||||
"../../../final/evidence/raw/guards/guards-actually-fail.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "배포 전에 사람이 돌려야 하는 것과 그 함정",
|
||||
@@ -1485,7 +1853,13 @@
|
||||
"concept:deploying-without-a-registry"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "when-a-guard-can-be-trusted/reference/reference-what-a-person-must-run-before-deploying.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -1740,7 +2114,7 @@
|
||||
"sourceRefs": [
|
||||
"final/document.md#§6.3"
|
||||
],
|
||||
"summary": "(input: never) 로 받아 캐스팅하는 조립기",
|
||||
"summary": "TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "concept:where-typescript-stops-checking",
|
||||
@@ -1752,7 +2126,7 @@
|
||||
"sourceRefs": [
|
||||
"final/document.md#§6.4"
|
||||
],
|
||||
"summary": "루트 tsconfig 가 한 파일도 검사하지 않았다",
|
||||
"summary": "npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다",
|
||||
"disposition": "PROMOTE",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "case:the-typecheck-command-checked-no-files",
|
||||
@@ -1776,7 +2150,7 @@
|
||||
"sourceRefs": [
|
||||
"final/document.md#§6.6"
|
||||
],
|
||||
"summary": "타입 검사를 놓아 주는 네 자리",
|
||||
"summary": "TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig",
|
||||
"disposition": "PROMOTE",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "concept:where-typescript-stops-checking",
|
||||
@@ -2492,7 +2866,7 @@
|
||||
"sourceRefs": [
|
||||
"final/document.md#§16.7"
|
||||
],
|
||||
"summary": "종류 열거 두 곳이 아직 컴파일러의 보호를 못 받는다",
|
||||
"summary": "종류를 세는 곳 둘이 아직 컴파일러의 보호 밖에 있다",
|
||||
"disposition": "PROMOTE",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "question:two-kind-tables-outside-the-compiler",
|
||||
@@ -2586,8 +2960,8 @@
|
||||
"counts": {
|
||||
"topics": 13,
|
||||
"nodes": 56,
|
||||
"written": 0,
|
||||
"unwritten": 56,
|
||||
"written": 56,
|
||||
"unwritten": 0,
|
||||
"unlisted": 0,
|
||||
"candidates": 91
|
||||
},
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-list-item-had-to-carry-the-whole-document
|
||||
title: 결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§5.4
|
||||
---
|
||||
|
||||
# 결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다
|
||||
|
||||
공개 결정 화면에서 제목 자리에 결정문 전문이 나오고, 요약이 없고, 줄바꿈이 전부 접히고, 영향과 근거가 늘 비어 있었다. 네 증상이 한 구조에서 나왔다 — 결정에는 상세 화면이 없고 공개 주소가 목록 위의 앵커다. 그래서 화면이 그리는 칸이 전부 목록 항목에 있어야 했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다**
|
||||
이 사건이 그 신호의 예다.
|
||||
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
|
||||
같은 앵커 구조에서 난 주소 쪽 사건이다.
|
||||
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
|
||||
같은 시기에 계약의 빈칸으로 난 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
결정은 상세 endpoint 가 없다. 공개 주소가 `/projects/{slug}/decisions#{slug}` 로 목록 위의 앵커다.
|
||||
|
||||
상세가 없으면 화면이 그리는 칸이 전부 목록 항목에 있어야 한다. 목록 항목에는 `title`·`summary`·`consequences`·`evidence` 가 빠져 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
네 증상이 전부 목록 항목의 빈칸에서 나왔다.
|
||||
|
||||
제목 자리 : statement 를 대신 썼다
|
||||
요약 : 실을 칸이 없었다
|
||||
줄바꿈 : 접혔다
|
||||
영향과 근거 : 프론트가 빈 배열로 고정해 뒀다
|
||||
|
||||
DB 에는 작성자가 쓴 제목, 여러 줄 요약, 영향 4건이 그대로 있었다. 목록 항목이 화면이 그리는 칸을 전부 싣게 하고 네 곳을 이었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-design-package : 987c1b8 이후
|
||||
tech-log-backend : 026460f 이후
|
||||
tech-log-frontend : 31afb4d 이후
|
||||
확인 방식 : DB 조회로 저장된 값을 확인하고 공개 화면과 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Studio 에서 결정을 작성하고 제목·요약·영향을 채운다
|
||||
2. 게시한 뒤 프로젝트의 결정 목록을 공개 화면에서 연다
|
||||
3. 저장된 값과 화면에 그려진 값을 대조한다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 상세가 없으면 목록이 전부 실어야 한다
|
||||
|
||||
결정은 자기 화면을 갖지 않는다. 공개 라우트는 `/projects/{slug}/decisions` 하나이고, 개별 결정은 그 목록 위의 앵커로 간다.
|
||||
|
||||
이 구조에서는 화면이 그리는 칸이 전부 목록 항목에 있어야 한다. 상세를 부를 곳이 없기 때문이다.
|
||||
|
||||
## 네 증상이 한 원인이었다
|
||||
|
||||
`ProjectDecisionItem` 에 `title` 이 없어서 프론트가 `statement` 를 제목 자리에 썼다. 결정문은 한 문장이 아니라 문단일 수 있으므로 제목 자리에 전문이 들어갔다.
|
||||
|
||||
`summary` 가 없어서 요약 줄이 비었다. `consequences` 와 `evidence` 가 없어서 프론트가 그 둘을 빈 배열로 고정해 뒀다.
|
||||
|
||||
줄바꿈은 다른 이유였다. 마크다운이 아닌 칸의 줄바꿈을 화면이 접고 있었다.
|
||||
|
||||
## 저장된 값은 그대로 있었다
|
||||
|
||||
DB 를 조회하면 작성자가 쓴 제목과 여러 줄 요약과 영향 4건이 있었다. 어느 것도 화면까지 오지 못했다.
|
||||
|
||||
## 화면 쪽에서 역으로 확인한다
|
||||
|
||||
이 부류는 응답에서 출발하면 보이지 않는다. 응답에 없는 칸을 찾는 일이기 때문이다. 화면이 그리는 칸을 먼저 적고 그 칸이 응답에 있는지 하나씩 맞춰야 한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
저장된 값이 그대로였다는 것은 조회로 확인했다. 그 시점의 화면 캡처는 남기지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-public-reference-was-entirely-empty
|
||||
title: 공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§5.1
|
||||
- final/document.md#§6.2
|
||||
---
|
||||
|
||||
# 공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다
|
||||
|
||||
Reference 를 게시했더니 Studio 에서는 모든 칸이 보이는데 공개 화면만 통째로 비어 있었다. 원인이 둘 겹쳐 있었다. 게이트웨이가 읽던 칸 이름이 계약에 없는 것들이었고, Reference 의 본문이 `body_markdown` 이 아니라 별도 테이블에 있었다. 타입 검사는 `as` 단언 때문에 아무 말도 하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개**
|
||||
이 사건이 그 경계 중 어디에서 났는지가 그 개념에 있다.
|
||||
- **Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다**
|
||||
이 사건에서 굳힌 진단 규칙이다.
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
`as` 단언이 어긋남을 가린 것을 그 개념이 설명한다.
|
||||
|
||||
## 문제
|
||||
|
||||
Reference 를 공개했다. Studio 편집기에서는 목적·규칙·적용 조건·예외·예시가 다 보이는데 공개 화면은 제목만 있고 아래가 비어 있었다.
|
||||
|
||||
DB 에는 작성자가 쓴 값이 그대로 있었다. 두 화면이 같은 데이터를 보는데 한쪽만 비었다.
|
||||
|
||||
## 결론
|
||||
|
||||
원인이 둘이었고 서로 다른 경계에 있었다.
|
||||
|
||||
게이트웨이가 읽던 이름 : purposeSummary · applyWhenMarkdown · exceptionsMarkdown · examplesMarkdown
|
||||
계약이 주는 이름 : scopeSummary · appliesTo · excludedScope
|
||||
결과 : 전부 undefined 로 떨어졌고, as string 단언 때문에 타입 검사가 통과했다
|
||||
|
||||
Reference 의 본문은 `body_markdown` 이 아니라 `reference_detail` 의 규칙과 예시에 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문은 비워 두기 때문이다. 공개 조회는 `body_markdown` 만 보고 빈 문자열을 내보냈다.
|
||||
|
||||
고친 뒤에는 값이 아니라 이름을 지키는 테스트를 뒀다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저 깨진다. 값을 검사하는 테스트로는 이 결함이 잡히지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 7211dd1 이후
|
||||
tech-log-design-package : ff0c12a 이후
|
||||
tech-log-backend : a5f93b9 이후
|
||||
확인 방식 : 계약이 주는 이름과 게이트웨이가 읽는 이름을 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Studio 에서 Reference 를 작성하고 규칙과 예시를 채운 뒤 게시한다
|
||||
2. 공개 화면에서 그 Reference 를 연다 — 제목만 보이고 아래가 비어 있다
|
||||
3. 게이트웨이 매퍼에서 읽는 칸 이름을 계약의 스키마와 맞춰 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 계약에 없는 이름을 읽고 있었다
|
||||
|
||||
게이트웨이는 응답에서 네 칸을 꺼내고 있었다. 그중 계약에 있는 것은 하나도 없었다.
|
||||
|
||||
```ts
|
||||
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
|
||||
```
|
||||
|
||||
`as string` 이 붙어 있으므로 컴파일러는 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나오고 화면은 빈 문자열을 그린다.
|
||||
|
||||
계약이 주는 이름은 `scopeSummary`, `appliesTo`, `excludedScope` 다.
|
||||
|
||||
## 본문이 다른 테이블에 있었다
|
||||
|
||||
두 번째 원인은 저장 구조였다. Reference 의 본문은 문서 본문 칸이 아니라 `reference_detail` 의 규칙과 예시에 들어 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문을 비워 두기 때문이다.
|
||||
|
||||
공개 조회는 문서 본문만 읽고 `content: ""` 를 내보냈다. 첫 번째 원인을 고쳐도 본문은 여전히 비어 있었다.
|
||||
|
||||
## 값이 아니라 이름을 지킨다
|
||||
|
||||
고친 뒤에 둔 테스트는 값을 비교하지 않는다. 게이트웨이가 읽는 이름이 계약의 타입에 있는지를 `satisfies` 로 묻는다. 계약에서 그 칸이 사라지면 컴파일이 먼저 멈춘다.
|
||||
|
||||
값을 비교하는 테스트로는 이 결함이 잡히지 않았을 것이다. 픽스처를 게이트웨이가 읽는 이름으로 만들면 값이 그대로 나오기 때문이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
`as` 단언을 걷어낸 것은 이 매퍼 하나다. 같은 모양이 다른 매퍼에 남아 있는지 전수로 세지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-summary-vanished-at-three-boundaries
|
||||
title: 관계의 요약이 경계 세 곳을 지나며 사라졌다
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§5.2
|
||||
- final/document.md#§5.3
|
||||
---
|
||||
|
||||
# 관계의 요약이 경계 세 곳을 지나며 사라졌다
|
||||
|
||||
관계 목록의 라벨을 고쳤는데 요약은 여전히 비어 있었다. 한 경계를 고치고 확인했더니 다음 경계가 버리고 있었고, 그것을 고치니 그다음이 버렸다. 세 번째는 계약에 담을 칸 자체가 없었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개**
|
||||
세 경계가 그중 어디인지가 그 개념에 있다.
|
||||
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
|
||||
이 사건에서 굳힌 규칙이다.
|
||||
- **결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다**
|
||||
같은 시기에 계약의 빈칸으로 난 다른 사건이다.
|
||||
|
||||
## 문제
|
||||
|
||||
관계 목록은 한 줄에 대상의 종류와 작성자가 쓴 이유와 대상의 요약을 보인다. 라벨은 고쳤는데 요약 자리가 계속 비어 있었다.
|
||||
|
||||
계약에는 요약이 있었다. DB 에도 값이 있었다. 화면까지 오지 못했다.
|
||||
|
||||
## 결론
|
||||
|
||||
값이 세 경계를 지나며 사라지고 있었다.
|
||||
|
||||
flattenRelations : 담지 않음 — 1차로 고침
|
||||
렌더 모델로 변환 : 담을 칸 자체가 없었음
|
||||
화면 목록으로 전달 : 또 버림
|
||||
|
||||
렌더 모델 계약(`ResolvedRelation`)에 요약 칸이 없었고 `additionalProperties: false` 라 실을 수도 없었다. 계약에 `summary` 를 더하고 — 이미 나가 있는 응답을 깨지 않으려고 required 에는 넣지 않고 — 세 경계를 모두 이었다.
|
||||
|
||||
그 과정에서 한 칸에 뭉쳐 있던 셋을 갈랐다. 대상의 종류는 `label`, 작성자가 쓴 이유는 `note`, 대상의 요약은 `summary` 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : a3ed23e 이후
|
||||
tech-log-design-package : fa67a64 이후
|
||||
tech-log-backend : 92679f5 이후
|
||||
확인 방식 : 공개 화면의 관계 목록에서 세 값이 각각 나오는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 기록 둘을 관계로 잇고 이유를 적는다
|
||||
2. 게시한 뒤 공개 화면에서 관계 목록을 본다
|
||||
3. 라벨·이유·요약 세 값이 각각 나오는지 본다 — 하나라도 비면 그 값이 어느 경계에서 사라졌는지 역순으로 따라간다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 세 번 버려졌다
|
||||
|
||||
```text
|
||||
계약(요약 있음)
|
||||
└─ flattenRelations 가 담지 않음 ← 1차로 고침
|
||||
└─ 렌더 모델로 바꿀 때 버림 ← 담을 칸 자체가 없었다
|
||||
└─ 화면 목록으로 넘길 때 또 버림
|
||||
```
|
||||
|
||||
첫 번째를 고치고 화면을 봤을 때도 요약은 비어 있었다. 두 번째를 고치려고 보니 렌더 모델 계약에 담을 칸이 없었고, `additionalProperties: false` 라 계약을 고치지 않고는 실을 수 없었다.
|
||||
|
||||
## 계약에 칸을 더할 때 required 를 따로 판단한다
|
||||
|
||||
`ResolvedRelation` 에 `summary` 를 더했다. required 에는 넣지 않았다. 이미 나가 있는 응답에는 그 칸이 없으므로, required 로 올리면 배포 순서에 따라 검증이 깨진다.
|
||||
|
||||
## 한 칸에 셋이 뭉쳐 있었다
|
||||
|
||||
값을 잇고 나서 다른 문제가 보였다. 관계 한 줄이 답해야 하는 것이 셋인데 `reason` 한 칸을 지나고 있었다.
|
||||
|
||||
| 무엇 | 뜻 | 경로별로 어떻게 나왔나 |
|
||||
|---|---|---|
|
||||
| 대상의 종류 | 「근거」「관련 기준」 같은 분류 | 렌더 모델 경로: 작성자의 문장이 이 자리에 눌려 나옴 |
|
||||
| 작성자가 쓴 이유 | 「다음에 무엇을 읽을지」의 답 | 공개 조회 경로: **아예 버려짐** |
|
||||
| 대상의 요약 | 대상이 무엇인지 | — |
|
||||
|
||||
셋을 `label` · `note` · `summary` 로 갈랐다. 설명 자리에는 문장이 있으면 문장을, 없으면 요약을 보인다. 요약은 대상이 무엇인지 말하고, 문장은 왜 지금 그것을 읽어야 하는지 말한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
세 경로에서 무엇이 나오는지는 화면으로 확인했다. 세 경로 전부를 자동 검사로 고정하지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: eleven-boundaries-a-value-crosses
|
||||
title: 공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
basisVersion: tech-log-backend · tech-log-frontend 2026-09-02 · OpenAPI 3.1 계약 3종을 반입해 쓰는 구조
|
||||
assets:
|
||||
- key: value-boundaries
|
||||
file: ../../../final/assets/tech-log-studio/value-boundaries.svg
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§1.2
|
||||
- final/document.md#§17.1
|
||||
---
|
||||
|
||||
# 공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개
|
||||
|
||||
공개 화면의 한 줄은 PostgreSQL 의 투영 테이블에서 출발해 열한 번 모양을 바꾼 뒤에 그려진다. 그 사이 어느 한 곳이 값을 담지 않아도 오류가 나지 않는다. `undefined` 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다**
|
||||
이 경계 중 두 곳에서 값이 사라진 사건이다.
|
||||
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
|
||||
한 값이 연달아 세 경계에서 버려진 사건이다.
|
||||
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
|
||||
이 경계 수가 그 규칙의 근거다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 열한 번 모양이 바뀐다
|
||||
|
||||
```text
|
||||
PostgreSQL 테이블
|
||||
└─ public_resource_projection (게시 시점에 굳어진 투영)
|
||||
└─ JDBC 어댑터의 SQL (컬럼 이름을 컴파일러가 검사하지 않는다)
|
||||
└─ *View 레코드 (application-core)
|
||||
└─ *ResponseMapper (adapter/inbound/web)
|
||||
└─ 생성된 DTO (계약이 만든 모양)
|
||||
└─ HTTP envelope
|
||||
└─ openapi-typescript 타입
|
||||
└─ http-public-content-gateway 의 매퍼
|
||||
└─ 포트 타입 (application/ports)
|
||||
└─ 화면 컴포넌트
|
||||
```
|
||||
|
||||
:::evidence key="value-boundaries" alt="저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 세 저장소 구역으로 나눠 이은 흐름도" caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
저장 쪽에 둘, 백엔드 조립에 넷, 전선에 하나, 프론트엔드 조립에 셋, 화면에 하나다.
|
||||
|
||||
## 어디서 검사가 끊기는가
|
||||
|
||||
경계마다 무엇이 값을 지키는지가 다르다.
|
||||
|
||||
JDBC 어댑터의 SQL 은 컬럼 이름을 문자열로 적는다. 컬럼이 없거나 이름이 다르면 실행할 때 알게 된다.
|
||||
|
||||
계약이 만든 DTO 와 생성된 타입 사이는 생성기가 지킨다. 다만 생성기가 보는 것은 스키마의 모양이고, 그 칸에 값이 담기는지는 보지 않는다.
|
||||
|
||||
게이트웨이의 매퍼는 계약의 타입을 읽는다. `as` 단언을 쓰면 그 확인이 사라진다.
|
||||
|
||||
포트 타입과 화면 컴포넌트 사이는 TypeScript 가 지킨다. 포트와 어댑터가 타입을 따로 들고 있으면 그 확인도 사라진다.
|
||||
|
||||
## 값을 버려도 오류가 나지 않는다
|
||||
|
||||
이 경계들은 값을 담지 않았다고 말하지 않는다. 담지 않은 채 다음으로 넘긴다.
|
||||
|
||||
`undefined` 는 화면에서 빈 문자열이 된다. 빈 배열은 「항목이 없습니다」가 된다. 그래서 화면만 보면 값이 없는 것과 값을 잃은 것이 같아 보인다.
|
||||
|
||||
## 두 화면이 같은 데이터를 볼 때
|
||||
|
||||
Studio 와 공개 화면은 같은 DB 를 보지만 계약이 다르다. Studio 는 작성 계약을, 공개 화면은 조회 계약을 지난다. 한쪽에만 값이 보이면 그 사이의 계약에 칸이 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: a-missing-contract-field-has-a-signature
|
||||
title: Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§5.6
|
||||
- final/document.md#§5.5
|
||||
---
|
||||
|
||||
# Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다
|
||||
|
||||
Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있으면, 두 화면이 같은 DB 를 보고 있으므로 그 사이의 계약에 칸이 없다. 이 저장소에서 같은 신호가 여덟 번 같은 원인을 가리켰다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다**
|
||||
이 신호가 처음 잡힌 사건이다.
|
||||
- **결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다**
|
||||
화면 쪽에서 역으로 확인해야 했던 사건이다.
|
||||
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
|
||||
칸을 더한 뒤 무엇을 확인할지가 그 기준에 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
DB 에 값이 있는데 화면이 비어 있을 때, 어디를 먼저 볼지 정한다. 이 부류는 오류를 내지 않으므로 로그에서 출발하면 아무것도 나오지 않는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**Studio 에서는 보이고 공개 쪽만 비면 그 사이의 계약을 먼저 본다**
|
||||
두 화면이 같은 DB 를 보는데 한쪽만 비면, 다른 것은 그 사이에 놓인 계약이다.
|
||||
|
||||
**화면이 그리는 칸을 먼저 적고 응답에 있는지 하나씩 맞춘다**
|
||||
응답에서 출발하면 없는 칸은 보이지 않는다. 상세 endpoint 가 없는 종류에서 특히 그렇다 — 목록 항목이 문서 전체를 실어야 한다.
|
||||
|
||||
**칸을 더할 때 required 로 올릴지는 따로 판단한다**
|
||||
이미 나가 있는 응답에는 그 칸이 없다. required 로 올리면 배포 순서에 따라 검증이 깨진다.
|
||||
|
||||
**값이 아니라 이름을 지키는 검사를 둔다**
|
||||
게이트웨이가 읽는 이름이 계약의 타입에 있는지를 묻는다. 값을 비교하는 검사는 픽스처를 게이트웨이가 읽는 이름으로 만들면 그대로 통과한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
같은 데이터를 두 표면이 각자의 계약으로 읽고, 한쪽만 비어 보이는 화면. 작성 계약과 조회 계약이 나뉜 구조에서 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
두 표면이 같은 계약을 쓰면 이 신호는 성립하지 않는다. 그때는 매퍼나 질의를 먼저 본다.
|
||||
|
||||
저장 구조가 종류마다 다르면 계약이 아니라 조회가 원인일 수 있다. Reference 의 본문이 문서 본문 칸이 아니라 별도 테이블에 있던 것이 그 예다.
|
||||
|
||||
## 예시
|
||||
|
||||
공개 Reference 가 통째로 비었을 때 게이트웨이가 읽던 네 이름이 전부 계약에 없었다.
|
||||
|
||||
프로젝트의 「주요 주제」는 테이블도 조인도 가능했는데 응답에 실을 칸이 없었다.
|
||||
|
||||
질문 목록만 주제가 빠져 있어서 질문 줄의 맥락이 「· 프로젝트」로 시작했다. 지식 목록은 처음부터 그 칸을 싣고 있었다.
|
||||
|
||||
프로젝트 목록 행에 slug 가 없었다. 다른 목록이 프로젝트를 가리킬 때 쓰는 것은 id 가 아니라 slug 다.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: verify-at-the-end-of-the-value-journey
|
||||
title: 한 경계를 고쳤으면 값의 여정 끝에서 확인한다
|
||||
topic: values-lost-between-boundaries
|
||||
topicName: 값이 경계에서 사라진다
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§17.1
|
||||
- final/document.md#§5.2
|
||||
- final/document.md#§6.6
|
||||
---
|
||||
|
||||
# 한 경계를 고쳤으면 값의 여정 끝에서 확인한다
|
||||
|
||||
값이 여러 경계를 갈아타는 구조에서 한 경계를 고치고 「고쳤다」고 판단해 세 번 틀렸다. 타입 검사도 단위 테스트도 「코드를 읽어 보니 맞다」도 전부 중간 지점이다. 배포본에서 그 값이 실제로 그려지는 곳까지 가서 확인한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
|
||||
한 경계를 고치고 판단해 두 번 틀린 사건이다.
|
||||
- **공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개**
|
||||
왜 중간 확인이 부족한지가 그 개념에 있다.
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
타입 통과가 반영의 증거가 아닌 이유가 그 개념에 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
「고쳤다」는 판단이 틀리는 것을 막는다. 값이 열한 경계를 지나는 구조에서 한 경계만 보고 판단하면, 다음 경계가 같은 값을 다시 버려도 알 수 없다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**고친 값이 실제로 그려지는 곳까지 가서 본다**
|
||||
배포본에서 그 화면을 열거나, 실제 요청을 보내 응답을 읽는다.
|
||||
|
||||
**타입 검사 통과를 반영의 증거로 쓰지 않는다**
|
||||
메서드 매개변수의 bivariance, `as` 단언, 검사 대상이 없는 tsconfig 가 각각 통과시킨 사례가 있다.
|
||||
|
||||
**게이트웨이를 실제로 불러 어떤 연산이 나가는지 확인한다**
|
||||
등록을 빠뜨린 연산은 옆 분기로 떨어지므로 서버는 정상 응답을 준다. 나가는 경로를 봐야 알 수 있다.
|
||||
|
||||
**여정이 끝나는 곳을 먼저 정하고 시작한다**
|
||||
어디까지 가면 확인이 끝나는지 모르면 중간에서 멈추게 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
값이 계약·매퍼·포트를 여러 번 갈아타는 구조에서 「고쳤다」를 판단할 때. 계약을 소유한 저장소가 따로 있고 생성기를 지나 들어오면 특히 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
경계가 하나뿐이거나 고친 그 곳이 여정의 끝이면 중간 확인으로 충분하다.
|
||||
|
||||
값이 아니라 이름을 지키는 검사를 이미 뒀으면 그 검사가 여정의 한 구간을 대신한다. 다만 검사가 덮는 구간이 어디까지인지 적어 둔다.
|
||||
|
||||
## 예시
|
||||
|
||||
관계 요약을 세 번 고쳤다. 매번 화면을 보고 나서야 다음 경계가 버리는 것을 알았다.
|
||||
|
||||
개념 삭제가 계속 질문 삭제 경로로 나갔다. 타입 검사가 통과해서 반영된 줄 알았고, 배포된 번들의 서버 로그에서 404 를 보고 알았다.
|
||||
|
||||
CONCEPT 을 `deleteQuestion` 으로 되돌려 가드가 깨지는 것을 확인했다.
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-narrow-implementation-satisfied-a-wide-port
|
||||
title: 구현이 종류를 좁게 적어도 넓은 포트를 만족했다
|
||||
topic: what-the-compiler-lets-through
|
||||
topicName: 타입 검사가 통과시키는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§6.1
|
||||
---
|
||||
|
||||
# 구현이 종류를 좁게 적어도 넓은 포트를 만족했다
|
||||
|
||||
개념 삭제가 계속 질문 삭제 경로로 나갔다. 앞선 커밋이 게이트웨이를 고치지 못했는데 타입 검사가 통과해서 반영된 줄 알았다. TypeScript 에서 메서드 매개변수는 bivariant 라, 구현이 종류를 좁게 적어도 넓은 포트 시그니처를 만족한 것으로 통과한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
bivariance 가 그중 하나다.
|
||||
- **타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다**
|
||||
같은 삭제 경로에서 난 다른 부류의 결함이다.
|
||||
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
|
||||
이 사건이 그 규칙의 근거 하나다.
|
||||
|
||||
## 문제
|
||||
|
||||
포트는 네 종류를 받는다고 선언돼 있는데 구현은 세 종류만 적혀 있었다. 그 상태로 타입 검사가 통과했다.
|
||||
|
||||
CONCEPT 을 넘기면 구현의 삼항 사슬이 마지막 `else` 로 떨어뜨려 질문 삭제 경로를 부른다. 배포된 번들에서 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404` 가 계속 찍혔다.
|
||||
|
||||
## 결론
|
||||
|
||||
TypeScript 에서 메서드 매개변수는 bivariant 다. 구현이 매개변수를 좁게 적어도 넓은 포트 시그니처를 만족한 것으로 통과한다.
|
||||
|
||||
포트 : CASE · REFERENCE · QUESTION · CONCEPT
|
||||
구현 : CASE · REFERENCE · QUESTION
|
||||
타입 검사 : 통과
|
||||
실행 결과 : CONCEPT 이 질문 삭제로 나감
|
||||
|
||||
같은 병이 필터 타입에서도 났다. 포트와 정적 어댑터가 타입을 따로 들고 있어, 포트에 필터가 늘어도 어댑터는 모르는 상태가 됐다. `satisfies` 도 같은 이유로 잡지 못했다. 타입을 하나로 합쳐서 고쳤다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : 6429aee 이후 · 67a5491 에서 필터 타입 통합
|
||||
TypeScript : 메서드 매개변수 bivariance
|
||||
확인 방식 : 배포본의 서버 로그에서 실제로 나가는 경로를 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 포트에 네 값을 받는 메서드를 선언한다
|
||||
2. 구현에서 세 값만 적는다 — 타입 검사가 통과한다
|
||||
3. 네 번째 값을 넘겨 실제로 어떤 경로가 나가는지 본다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 좁은 구현이 넓은 포트를 만족한다
|
||||
|
||||
```ts
|
||||
// 포트 시그니처
|
||||
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string): Promise<void>;
|
||||
|
||||
// 구현이 이렇게 좁게 적혀 있어도 위 시그니처를 "만족"한다
|
||||
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string) { … }
|
||||
```
|
||||
|
||||
메서드 매개변수가 bivariant 이므로 이 둘은 호환된다고 판정된다. 구현 안의 삼항 사슬은 CONCEPT 을 마지막 `else` 로 떨어뜨린다.
|
||||
|
||||
## 왜 반영된 줄 알았나
|
||||
|
||||
앞선 커밋에서 게이트웨이를 고쳤다고 판단한 근거가 타입 검사 통과였다. 좁게 적힌 구현을 그 커밋이 건드리지 않았고, 검사는 그것을 묻지 않았다.
|
||||
|
||||
배포된 번들에서 서버 로그를 보고서야 알았다. 삭제 요청이 질문 경로로 나가고 있었다.
|
||||
|
||||
## 같은 병이 필터에서도 났다
|
||||
|
||||
포트와 정적 어댑터가 필터 타입을 따로 들고 있었다. 포트에 축 필터를 더해도 어댑터의 타입은 그대로였고, `satisfies` 도 같은 이유로 통과했다.
|
||||
|
||||
타입을 하나로 합쳐서 고쳤다. 포트가 아는 필터와 어댑터가 아는 필터가 같은 타입이면 한쪽만 늘어날 수 없다.
|
||||
|
||||
## 확인한 것과 확인하지 못한 것
|
||||
|
||||
CONCEPT 을 `deleteQuestion` 으로 되돌려 가드가 깨지는 것을 확인했다.
|
||||
|
||||
배포된 번들에 옛 삼항이 남아 있던 것은 서버 로그의 404 로 확인했다. 그 로그 원문은 이 저장소에 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-typecheck-command-checked-no-files
|
||||
title: npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다
|
||||
topic: what-the-compiler-lets-through
|
||||
topicName: 타입 검사가 통과시키는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§6.4
|
||||
---
|
||||
|
||||
# npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다
|
||||
|
||||
운영에서 릴리즈 목록이 `ReferenceError` 로 비었다. import 하나가 빠져 있었고 다른 변수는 아예 정의된 적이 없었다. `npx tsc --noEmit` 이 통과했기 때문에 그것을 보지 못했다. 루트 tsconfig 는 `"files": []` 에 project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **TypeScript 가 검사를 놓아 주는 네 곳**
|
||||
검사 대상을 갖지 않은 tsconfig 가 그중 하나다.
|
||||
- **가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다**
|
||||
가드를 넣고 돌리지 않은 다른 사건이다.
|
||||
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
|
||||
이 사건 뒤에 목록으로 굳혔다.
|
||||
|
||||
## 문제
|
||||
|
||||
운영에서 릴리즈 목록 화면이 비었고 콘솔에 `ReferenceError` 가 났다. 링크 컴포넌트 import 가 빠졌고, 이동 함수는 정의된 적이 없었다.
|
||||
|
||||
타입 검사를 돌렸을 때 통과했다. 그래서 이 오류가 배포까지 갔다.
|
||||
|
||||
## 결론
|
||||
|
||||
`npx tsc --noEmit` 은 루트 tsconfig 를 읽는다. 그 파일은 `"files": []` 에 project references 만 나열하므로 검사할 파일이 없고, 없는 채로 성공한다.
|
||||
|
||||
실제 검사는 `npm run check:types` 가 한다. 이 명령이 여섯 개 프로젝트를 돌며 검사한다.
|
||||
|
||||
그 명령으로 돌리자 저장소에 남아 있던 다른 오류도 함께 드러났다.
|
||||
|
||||
CatalogEntry 가 export 되지 않음
|
||||
라우트 파라미터가 unknown
|
||||
메시지 키가 파라미터를 받도록 등록되지 않음
|
||||
ReleaseIndexItem 에 summary 없음
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : e9b8661 이후
|
||||
tsconfig : 루트가 project references 만 나열
|
||||
확인 방식 : 두 명령을 같은 작업 트리에서 각각 돌려 결과를 대조
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 어느 프로젝트 파일에 정의되지 않은 변수를 하나 넣는다
|
||||
2. `npx tsc --noEmit` 을 돌린다 — 성공한다
|
||||
3. `npm run check:types` 를 돌린다 — 그 파일에서 멈춘다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 검사할 파일이 없는 tsconfig
|
||||
|
||||
루트 tsconfig 는 이 저장소에서 컴파일 대상을 갖지 않는다. `"files": []` 에 project references 만 나열한다. 각 프로젝트는 자기 tsconfig 를 갖고 있고, 루트는 그들을 가리키기만 한다.
|
||||
|
||||
`npx tsc --noEmit` 은 references 를 따라가지 않는다. 루트가 지목한 파일 집합만 보고, 그 집합이 비어 있으므로 아무것도 검사하지 않은 채 0 으로 끝난다.
|
||||
|
||||
## 통과가 무엇을 뜻했나
|
||||
|
||||
이 명령이 성공했을 때 확인된 것은 「루트 tsconfig 가 유효하다」까지다. 코드가 컴파일되는지는 확인되지 않았다.
|
||||
|
||||
## 올바른 명령으로 돌렸을 때
|
||||
|
||||
`npm run check:types` 는 여섯 프로젝트를 각각 돌린다. 그 명령으로 돌리자 릴리즈 목록의 두 오류에 더해 네 가지가 더 나왔다 — export 되지 않은 타입, `unknown` 인 라우트 파라미터, 파라미터를 받도록 등록되지 않은 메시지 키, 목록 항목에 없는 요약.
|
||||
|
||||
## 남은 것
|
||||
|
||||
루트 tsconfig 는 그대로 뒀다. 누군가 다시 `npx tsc --noEmit` 을 쓰는 것을 막는 검사는 없고, 배포 전에 돌릴 다섯 명령의 목록에 적어 둔 것이 지금의 대책이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: where-typescript-stops-checking
|
||||
title: TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig
|
||||
topic: what-the-compiler-lets-through
|
||||
topicName: 타입 검사가 통과시키는 곳
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
basisVersion: TypeScript 5.x · project references 로 나눈 여섯 프로젝트 구성 · 2026-09-02 시점의 tech-log-frontend
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§6.6
|
||||
- final/document.md#§6.2
|
||||
- final/document.md#§6.3
|
||||
---
|
||||
|
||||
# TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig
|
||||
|
||||
「타입 검사가 통과했으니 반영됐다」는 판단이 이 저장소에서 네 번 틀렸다. 매번 다른 이유였다 — 메서드 매개변수의 bivariance, `as` 단언, `never` 로 받아 캐스팅하는 조립기, 그리고 검사할 파일을 갖지 않은 tsconfig 다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **구현이 종류를 좁게 적어도 넓은 포트를 만족했다**
|
||||
bivariance 가 통과시킨 사건이다.
|
||||
- **npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다**
|
||||
검사 대상이 없는 tsconfig 가 통과시킨 사건이다.
|
||||
- **공개 Reference 가 통째로 비어 있었다**
|
||||
`as` 단언이 통과시킨 사건이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 메서드 매개변수는 bivariant 다
|
||||
|
||||
포트가 네 값을 받는다고 선언하고 구현이 세 값만 적어도 두 시그니처는 호환된다고 판정된다. TypeScript 는 메서드 문법으로 쓴 매개변수를 bivariant 로 다룬다.
|
||||
|
||||
```ts
|
||||
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string): Promise<void>;
|
||||
```
|
||||
|
||||
구현이 이 시그니처를 만족한다고 통과해도, 실행할 때 네 번째 값이 어디로 가는지는 구현 안의 분기가 정한다.
|
||||
|
||||
같은 이유로 포트와 어댑터가 타입을 따로 들고 있으면 `satisfies` 도 한쪽이 좁아진 것을 잡지 못한다.
|
||||
|
||||
## as 단언은 이름을 묻지 않는다
|
||||
|
||||
```ts
|
||||
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
|
||||
```
|
||||
|
||||
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이라, 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나온다.
|
||||
|
||||
모양이 다른 경우에는 더 나빠진다. 객체를 배열로 읽고 `.filter` 를 부르면 매핑이 통째로 터지는데, `as` 캐스트가 그 어긋남을 타입 검사에서 가린다.
|
||||
|
||||
## never 로 받으면 아무것도 요구하지 않는다
|
||||
|
||||
요청을 만드는 조립기가 입력을 `(input: never)` 로 받아 캐스팅하면, 계약에 인자를 더해도 컴파일러가 아무 말도 하지 않는다. 조립기가 손으로 나열한 질의 인자에 그 값이 없으면 요청에서 조용히 빠진다.
|
||||
|
||||
목록의 페이지 번호를 눌러도 쪽이 넘어가지 않던 것이 이 모양이었다. `page` 가 조립기의 목록에 없었다.
|
||||
|
||||
## 검사할 파일이 없는 tsconfig
|
||||
|
||||
루트 tsconfig 가 `"files": []` 에 project references 만 나열하면 `npx tsc --noEmit` 은 한 파일도 검사하지 않고 성공한다. references 를 따라가는 것은 별도 명령이다.
|
||||
|
||||
## 네 가지가 공통으로 하는 일
|
||||
|
||||
넷 다 「이 코드가 그 타입과 맞는가」라는 질문을 다른 질문으로 바꾼다. bivariance 는 시그니처 호환으로, `as` 는 작성자의 선언으로, `never` 는 검사 없음으로, 빈 tsconfig 는 대상 없음으로 바꾼다.
|
||||
|
||||
그래서 이 넷을 지난 통과는 「코드가 맞다」가 아니라 「검사가 그 질문을 하지 않았다」를 뜻한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-guard-worked-and-i-did-not-run-it
|
||||
title: 가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다
|
||||
topic: when-a-guard-can-be-trusted
|
||||
topicName: 가드를 언제 믿을 수 있는가
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§7.5
|
||||
- final/document.md#§8.5
|
||||
- final/document.md#§17.4
|
||||
---
|
||||
|
||||
# 가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다
|
||||
|
||||
가드를 넣고 제가 그것을 돌리지 않아 두 번 새어 나갔다. 화면 테스트를 다른 명령이 돌리는데 그 명령을 안 돌려서 23건이 빨간 채로 여러 커밋을 지나갔고, 주제 화면 셋을 더하면서 CI 게이트 기준값을 빠뜨려 게이트가 빨간 채로 여러 커밋을 지나갔다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다**
|
||||
가드가 무엇을 잡는지 확인하는 기준이다.
|
||||
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
|
||||
이 사건 뒤에 목록으로 굳혔다.
|
||||
- **라우트 하나가 건드리는 여덟 곳과, 그것들이 우는 시점**
|
||||
두 번째 사건에서 빠뜨린 목록이 그 기록에 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
가드는 있었고 작동했다. 실행되지 않았다.
|
||||
|
||||
화면 테스트는 단위 테스트 명령이 아니라 별도 명령이 돌린다. 그 명령을 돌리지 않으면 화면 테스트가 빨간 것을 아무도 모른다.
|
||||
|
||||
CI 게이트도 마찬가지였다. 라우트를 더하면서 기준값을 함께 올리지 않으면 게이트가 거절하는데, 그 게이트가 빨간 것을 확인하지 않은 채 커밋을 이어 갔다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 번 다 가드가 아니라 실행이 빠진 것이었다.
|
||||
|
||||
첫 번째 : 화면 테스트를 별도 명령이 돌리는데 그것을 안 돌려 23건이 빨간 채로 여러 커밋을 지나갔다
|
||||
두 번째 : 주제 화면 셋을 더하면서 게이트 기준값을 빠뜨려 게이트가 빨간 채로 여러 커밋을 지나갔다
|
||||
|
||||
> **가드는 CI 에 묶여야 의미가 있습니다.** 사람이 기억해서 돌리는 가드는 절반만 존재합니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
tech-log-frontend : fd73bc8 · fe6b56a
|
||||
명령 : check:types · lint · test:unit · test:component · test:tech-log 다섯
|
||||
확인 방식 : 다섯 명령을 각각 돌려 어느 것이 무엇을 잡는지 확인
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 화면 테스트가 깨지는 변경을 하나 넣는다
|
||||
2. 단위 테스트 명령만 돌린다 — 통과한다
|
||||
3. 화면 테스트 명령을 돌린다 — 깨진다
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 화면 테스트를 돌리지 않았다
|
||||
|
||||
> 화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위 두 결함과, 의도한 변경에 고정돼 있던 단언들이 **23건 빨간 채로 여러 커밋을 지나갔다.**
|
||||
|
||||
23건 중에는 실제 결함을 잡은 것도 있고 의도한 변경에 고정된 단언도 있었다. 둘을 구분하려면 그 명령을 돌려야 하는데, 돌리지 않으니 둘 다 그대로 남았다.
|
||||
|
||||
## 게이트 기준값을 빠뜨렸다
|
||||
|
||||
주제 화면 셋을 더할 때 CI 게이트가 요구하는 기준값 셋을 함께 올리지 않았다. 게이트는 정확히 그것을 거절한다.
|
||||
|
||||
게이트가 빨간 채로 여러 커밋을 지나갔고, 결정 링크 404 를 고치던 커밋에서야 함께 맞췄다.
|
||||
|
||||
## 가드가 아니라 실행이 빠졌다
|
||||
|
||||
두 번 다 가드는 정확했다. 화면 테스트는 실제 결함을 잡았고 게이트는 기준값이 어긋난 것을 정확히 거절했다.
|
||||
|
||||
> **가드는 CI 에 묶여야 의미가 있습니다.** 사람이 기억해서 돌리는 가드는 절반만 존재합니다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
다섯 명령을 CI 에 묶는 작업은 하지 않았다. 지금 남은 것은 메모리와 배포 전 검증 목록이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: revert-the-defect-and-watch-the-guard-fail
|
||||
title: 가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다
|
||||
topic: when-a-guard-can-be-trusted
|
||||
topicName: 가드를 언제 믿을 수 있는가
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/guards/guards-actually-fail.txt
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§15
|
||||
- final/document.md#§3.5
|
||||
- final/document.md#§17.4
|
||||
---
|
||||
|
||||
# 가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다
|
||||
|
||||
가드를 넣었다는 것과 그 가드가 무엇을 잡는다는 것은 다르다. 결함을 되돌려 실제로 빨개지는 것을 확인한 뒤에 커밋한다. 확인하지 않은 가드는 그 결함이 원래 없었는지 검사가 안 도는지 구별되지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다**
|
||||
가드가 정확해도 돌리지 않으면 소용없다는 사건이다.
|
||||
- **매번 우는 검사는 읽히지 않는다 — 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다**
|
||||
가드가 늘 빨간 상태로 남는 다른 실패 모양이다.
|
||||
- **이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다**
|
||||
어디에 가드를 둘지를 다루는 기준이다.
|
||||
|
||||
## 목적
|
||||
|
||||
무엇도 잡지 못하는 가드가 초록불로 남는 것을 막는다. 이 상태는 가드가 있는 것과 화면에서 구분되지 않는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**결함을 되돌려 그 가드가 실제로 멈추는 것을 확인한 뒤 커밋한다**
|
||||
계약에서 값을 빼고 대조 검사가 빨개지는지 본다. 매핑을 떼어 보고 그 연산 하나를 짚는지 본다.
|
||||
|
||||
**가드가 짚는 대상이 하나인지 본다**
|
||||
전부를 짚으면 어디가 문제인지 알 수 없고, 결과가 곧 읽히지 않는다.
|
||||
|
||||
**되돌릴 수 없는 것은 현재 상태를 대신 증거로 남긴다**
|
||||
이미 마이그레이션으로 고친 데이터는 실패 상태를 다시 만들 수 없다. 그럴 때는 지금 고쳐져 있다는 것을 남긴다.
|
||||
|
||||
**가드를 CI 에 묶는다**
|
||||
사람이 기억해서 돌리는 가드는 절반만 존재한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
재발 방지로 넣는 테스트·아키텍처 규칙·CI 게이트. 결함을 고치는 커밋에서 함께 넣을 때 걸린다.
|
||||
|
||||
## 예외
|
||||
|
||||
결함을 되돌릴 수 없는 것 — 이미 마이그레이션으로 고친 데이터, 지난 배포에서만 나던 상태 — 은 현재 상태가 고쳐져 있음을 대신 증거로 남긴다.
|
||||
|
||||
기존 가드를 옮기거나 이름만 바꾸는 변경은 되돌려 확인하지 않아도 된다. 다만 옮긴 뒤에 한 번은 돌린다.
|
||||
|
||||
## 예시
|
||||
|
||||
가드 셋을 각각 결함으로 되돌려 실제로 빨개지는 것을 확인한 기록을 남겼다.
|
||||
|
||||
계약에서 CONCEPT 을 빼자 백엔드의 계약 대조 테스트가 빨개졌다.
|
||||
|
||||
매핑을 떼어 보고 계약 대조 테스트가 그 연산 하나를 정확히 짚는 것을 확인했다.
|
||||
|
||||
굵기 선언을 빼 보고 제목 급 검사가 실제로 멈추는 것을 확인했다.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: what-a-person-must-run-before-deploying
|
||||
title: 배포 전에 사람이 돌려야 하는 것과 그 함정
|
||||
topic: when-a-guard-can-be-trusted
|
||||
topicName: 가드를 언제 믿을 수 있는가
|
||||
project: TechLog
|
||||
status: 게시 전
|
||||
verifiedOn: 2026-09-04
|
||||
sourceRevision: tech-log@2026-09-02
|
||||
source:
|
||||
- final/document.md#§15.4
|
||||
- final/document.md#§12.3
|
||||
- final/document.md#§12.7
|
||||
- final/document.md#§12.8
|
||||
- final/document.md#§16.6
|
||||
---
|
||||
|
||||
# 배포 전에 사람이 돌려야 하는 것과 그 함정
|
||||
|
||||
CI 에 묶이지 않은 검증이 남아 있으면 그것을 돌리는 것은 사람이다. 다섯 명령을 다 돌려야 하고, 그중 둘은 순서와 환경 때문에 그냥 돌리면 틀린 답을 준다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다**
|
||||
이 목록이 필요해진 사건이다.
|
||||
- **npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다**
|
||||
목록의 첫 줄이 왜 그 명령이 아닌지가 그 기록에 있다.
|
||||
- **레지스트리 없이 tar 를 import 하는 배포 경로**
|
||||
빌드 산출물에 커밋 해시가 들어가는 이유가 그 개념에 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
배포 전에 돌려야 하는 것을 사람이 기억에 의존해 고르는 것을 막는다. 두 번 빠뜨려 결함이 배포까지 갔다.
|
||||
|
||||
## 규칙
|
||||
|
||||
**프론트는 다섯 개를 다 돌린다**
|
||||
타입 검사 · lint · 단위 테스트 · 컴포넌트 테스트 · 화면 테스트다. 화면 테스트는 단위 테스트 명령이 돌리지 않는다.
|
||||
|
||||
**타입 검사는 프로젝트를 순회하는 명령으로 돌린다**
|
||||
루트 tsconfig 를 직접 부르는 명령은 한 파일도 검사하지 않고 성공한다.
|
||||
|
||||
**백엔드는 커밋한 뒤에 빌드한다**
|
||||
빌드 산출물 이름에 커밋 해시가 들어간다. 작업 트리가 더러우면 해시가 달라져 stale 산출물 검사가 멈춘다.
|
||||
|
||||
**테스트를 npm 이나 npx 로 감싸 돌리지 않는다**
|
||||
`npm_config_*` 환경 변수가 설정되어 CI 워크플로 생성 테스트가 실패한다. 그 변수를 지우고 실행기를 직접 부른다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
CI 에 묶이지 않은 검증이 남아 있는 저장소에서 배포 직전에 하는 일.
|
||||
|
||||
## 예외
|
||||
|
||||
CI 가 그 명령을 돌리면 이 목록에서 뺀다. 사람이 기억해서 돌리는 가드는 절반만 존재한다.
|
||||
|
||||
환경 때문에 실패하는 것은 실패로 세지 않는다. 하위 프로세스를 띄우는 세 케이스는 이 환경에서 실패하고 같은 리비전의 다른 실행에서도 똑같이 재현되므로 코드 변경과 무관하다.
|
||||
|
||||
## 예시
|
||||
|
||||
프론트 다섯 명령 :
|
||||
npm run check:types
|
||||
npm run lint
|
||||
단위 · 컴포넌트 · 화면 테스트
|
||||
|
||||
백엔드 : 커밋한 뒤 stale 산출물을 지우고 빌드한다. 이 순서를 몰라 두 번 헤맸다.
|
||||
|
||||
설계 패키지 : 계약 자체의 유효성 · 세 계약 사이의 정합 · 프론트와 백엔드가 아는 종류와 오류 코드가 같은지, 셋을 돌린다.
|
||||
|
||||
테스트 JVM 힙이 기본값이면 컨텍스트 캐시와 아키텍처 검사와 컨테이너가 겹치면서 메모리가 모자란다. 증상이 테스트 실패가 아니라 실행기를 완료할 수 없다는 메시지라 원인을 가린다.
|
||||
Reference in New Issue
Block a user