refactor: 문서 개선 중
This commit is contained in:
+3490
-3489
File diff suppressed because one or more lines are too long
+15578
File diff suppressed because one or more lines are too long
+132
-38
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "redis-admission-stages",
|
||||
"title": "명령 입장의 단일 지점",
|
||||
"question": "명령은 어디서 걸러지는가?",
|
||||
"type": "dependency",
|
||||
"title": "의도된 guarded path와 실제 direct gateway 우회",
|
||||
"question": "Redis 명령은 guard를 통과하는가, semantic adapter에서 gateway로 우회하는가?",
|
||||
"type": "flow",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"이 저장소의 구조를 읽는 사람"
|
||||
"백엔드 엔지니어"
|
||||
],
|
||||
"summary": "카탈로그를 통과하면 실행이고 미분류나 BLOCKED 이면 fail-closed 로 거절된다.",
|
||||
"alt": "CommandPolicyGuard 에서 카탈로그 통과는 실행으로 미분류와 BLOCKED 는 거절로 갈린다.",
|
||||
"long_description": "SSOT 는 이 구조의 바닥이 카탈로그가 미분류 명령을 fail-closed 로 거부하는 것이라고 적는다.",
|
||||
"summary": "guarded command path에서는 CommandPolicyGuard가 admission을 담당하지만, 현재 semantic adapter 다섯은 gateway를 직접 호출해 이 경로를 우회한다.",
|
||||
"alt": "의도된 command path는 CommandPolicyGuard를 거쳐 실행 또는 fail-closed 거절로 갈리고, 별도의 semantic adapter 경로는 RedisCommandGateway를 직접 호출해 guard를 우회하는 흐름도",
|
||||
"long_description": "CommandPolicyGuard는 guarded command path의 admission 지점이다. 그러나 현재 semantic adapter 다섯은 RedisLease에서 gateway를 직접 얻어 호출하므로 catalog, permit, slot, budget, translation, observation 단계가 이 경로에 적용되지 않는다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
|
||||
"document": "docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
|
||||
"anchor": {
|
||||
"kind": "line",
|
||||
"value": 13463,
|
||||
"line": 13463
|
||||
"value": 13469,
|
||||
"line": 13469
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
@@ -26,20 +26,33 @@
|
||||
"reference_ids": [
|
||||
"payment-event-flow"
|
||||
],
|
||||
"rationale": "한 지점이 모든 명령을 두 결과로 가른다는 것이 논지다.",
|
||||
"focus_node": "guard"
|
||||
"rationale": "의도된 guarded path와 실제 bypass path를 한 화면에서 대비해야 전체 runtime의 single admission으로 오해하지 않는다.",
|
||||
"focus_node": "bypass"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "guard",
|
||||
"label": "CommandPolicyGuard",
|
||||
"id": "typed-entry",
|
||||
"label": "guarded command path",
|
||||
"kind": "service",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13463,
|
||||
"end_line": 13490
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "guard",
|
||||
"label": "CommandPolicyGuard",
|
||||
"kind": "service",
|
||||
"role": "service",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
@@ -47,69 +60,150 @@
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"label": "실행",
|
||||
"kind": "service",
|
||||
"label": "승인 후 실행",
|
||||
"kind": "result",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13463,
|
||||
"end_line": 13490
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"details": [
|
||||
"카탈로그 통과"
|
||||
]
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "deny",
|
||||
"label": "fail-closed 거절",
|
||||
"kind": "result",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "semantic",
|
||||
"label": "semantic adapters ×5",
|
||||
"kind": "service",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "gateway",
|
||||
"label": "RedisCommandGateway 직접 호출",
|
||||
"kind": "service",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13463,
|
||||
"end_line": 13497
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "bypass",
|
||||
"label": "guard stages 우회",
|
||||
"kind": "result",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"emphasis": "primary",
|
||||
"details": [
|
||||
"미분류 · BLOCKED"
|
||||
"catalog · permit · slot · budget",
|
||||
"translation · observation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "a",
|
||||
"id": "entry",
|
||||
"from": "typed-entry",
|
||||
"to": "guard",
|
||||
"label": "admission",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "pass",
|
||||
"from": "guard",
|
||||
"to": "run",
|
||||
"label": "통과",
|
||||
"kind": "request",
|
||||
"kind": "data",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13463,
|
||||
"end_line": 13490
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"emphasis": "primary"
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"id": "reject",
|
||||
"from": "guard",
|
||||
"to": "deny",
|
||||
"label": "거절",
|
||||
"kind": "data",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "direct",
|
||||
"from": "semantic",
|
||||
"to": "gateway",
|
||||
"label": "lease.gateway()",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13463,
|
||||
"end_line": 13497
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"style": "dashed"
|
||||
},
|
||||
{
|
||||
"id": "skips",
|
||||
"from": "gateway",
|
||||
"to": "bypass",
|
||||
"label": "guard 미경유",
|
||||
"kind": "data",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 13469,
|
||||
"end_line": 13503
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"style": "dashed",
|
||||
"emphasis": "primary"
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
|
||||
+3468
-3460
File diff suppressed because one or more lines are too long
+15566
File diff suppressed because one or more lines are too long
+90
-84
@@ -1,140 +1,146 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "rls-three-preconditions",
|
||||
"title": "격리가 성립하는 세 조건",
|
||||
"question": "RLS 격리는 무엇이 동시에 참이어야 성립하는가?",
|
||||
"type": "sequence",
|
||||
"direction": "LR",
|
||||
"title": "PostgreSQL RLS 적용 여부를 가르는 분기",
|
||||
"question": "현재 role과 table에서 RLS policy가 실제로 적용되는가?",
|
||||
"type": "data-flow",
|
||||
"direction": "TB",
|
||||
"audience": [
|
||||
"이 저장소의 구조를 읽는 사람"
|
||||
"백엔드 엔지니어"
|
||||
],
|
||||
"summary": "ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 런타임 롤 셋이 동시에 참이어야 격리가 성립한다.",
|
||||
"alt": "격리 판정이 ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 롤을 차례로 확인하는 순서.",
|
||||
"long_description": "SSOT 는 RlsPolicyVerifier.requireEnforced 가 런타임 롤의 BYPASSRLS 를 확인하고 current_schema() 의 실제 테이블을 순회하며 tenant-scoped 목록에 든 것만 검사한다고 적는다.",
|
||||
"summary": "RLS 활성 여부, 우회 role, owner와 FORCE RLS, applicable policy 유무를 차례로 구분한다.",
|
||||
"alt": "RLS 비활성은 policy 미적용으로, superuser와 BYPASSRLS는 우회로, owner는 FORCE 여부로 갈리고, policy 대상인데 applicable policy가 없으면 default deny가 되는 흐름도",
|
||||
"long_description": "PostgreSQL RLS를 세 개의 동시 전제로 보지 않는다. RLS가 활성화된 뒤 superuser 또는 BYPASSRLS인지, table owner인지와 FORCE RLS 여부를 확인한다. policy 대상 role에 applicable policy가 없으면 default deny이고, policy가 있으면 USING과 WITH CHECK를 평가한다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
|
||||
"document": "docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
|
||||
"anchor": {
|
||||
"kind": "line",
|
||||
"value": 7398,
|
||||
"line": 7398
|
||||
"value": 7400,
|
||||
"line": 7400
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "sequence",
|
||||
"profile": "two-zone-pipeline",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-approval-sequence"
|
||||
"localization-pipeline"
|
||||
],
|
||||
"rationale": "세 조건이 차례로 확인되어야 격리가 성립한다는 순서가 논지다.",
|
||||
"focus_node": "verifier"
|
||||
"rationale": "정책 적용 여부를 결정하는 검사 경로와 각 단계에서 빠져나가는 결과를 짧은 파이프라인으로 보여 준다.",
|
||||
"focus_node": "policy"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
"groups": [
|
||||
{
|
||||
"id": "start",
|
||||
"label": "격리 판정",
|
||||
"kind": "service",
|
||||
"role": "participant",
|
||||
"id": "role",
|
||||
"label": "policy 적용 대상 판정",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7420
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "verifier",
|
||||
"label": "RlsPolicyVerifier",
|
||||
"kind": "service",
|
||||
"role": "participant",
|
||||
"id": "policy-zone",
|
||||
"label": "policy 존재와 평가",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7420
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "rls",
|
||||
"label": "RLS 활성 여부",
|
||||
"kind": "process",
|
||||
"role": "stage",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"emphasis": "primary"
|
||||
"group": "role",
|
||||
"details": [
|
||||
"no → policy 미적용"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
"label": "데이터베이스",
|
||||
"kind": "database",
|
||||
"role": "participant",
|
||||
"id": "subject",
|
||||
"label": "policy 적용 대상",
|
||||
"kind": "process",
|
||||
"role": "stage",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7426
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
"assumption": false,
|
||||
"group": "role",
|
||||
"details": [
|
||||
"superuser / BYPASSRLS → 우회",
|
||||
"owner + FORCE off → 우회",
|
||||
"non-owner 또는 owner + FORCE on → 대상"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy",
|
||||
"label": "applicable policy",
|
||||
"kind": "process",
|
||||
"role": "stage",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"group": "policy-zone",
|
||||
"emphasis": "primary",
|
||||
"details": [
|
||||
"none → default deny",
|
||||
"exists → USING / WITH CHECK 평가"
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "m1",
|
||||
"from": "start",
|
||||
"to": "verifier",
|
||||
"label": "검증 요청",
|
||||
"id": "e1",
|
||||
"from": "rls",
|
||||
"to": "subject",
|
||||
"label": "RLS on",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7420
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"order": 1
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m2",
|
||||
"from": "verifier",
|
||||
"to": "db",
|
||||
"label": "ENABLE RLS 확인",
|
||||
"id": "e2",
|
||||
"from": "subject",
|
||||
"to": "policy",
|
||||
"label": "적용",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7420
|
||||
"start_line": 7400,
|
||||
"end_line": 7432
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"id": "m3",
|
||||
"from": "verifier",
|
||||
"to": "db",
|
||||
"label": "FORCE RLS 확인",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7420
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "m4",
|
||||
"from": "verifier",
|
||||
"to": "db",
|
||||
"label": "BYPASSRLS 없음 확인",
|
||||
"kind": "request",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 7396,
|
||||
"end_line": 7426
|
||||
}
|
||||
],
|
||||
"assumption": false,
|
||||
"order": 4,
|
||||
"emphasis": "primary"
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -1,20 +1,27 @@
|
||||
# 명령 입장의 단일 지점
|
||||
# 의도된 guarded path와 실제 direct gateway 우회
|
||||
|
||||
## Alternative text
|
||||
|
||||
CommandPolicyGuard 에서 카탈로그 통과는 실행으로 미분류와 BLOCKED 는 거절로 갈린다.
|
||||
의도된 command path는 CommandPolicyGuard를 거쳐 실행 또는 fail-closed 거절로 갈리고, 별도의 semantic adapter 경로는 RedisCommandGateway를 직접 호출해 guard를 우회하는 흐름도
|
||||
|
||||
## Long description
|
||||
|
||||
SSOT 는 이 구조의 바닥이 카탈로그가 미분류 명령을 fail-closed 로 거부하는 것이라고 적는다.
|
||||
CommandPolicyGuard는 guarded command path의 admission 지점이다. 그러나 현재 semantic adapter 다섯은 RedisLease에서 gateway를 직접 얻어 호출하므로 catalog, permit, slot, budget, translation, observation 단계가 이 경로에 적용되지 않는다.
|
||||
|
||||
## Elements and evidence
|
||||
|
||||
- **CommandPolicyGuard** (service): No additional description. Evidence: L13463–L13490.
|
||||
- **실행** (service): No additional description. Evidence: L13463–L13490.
|
||||
- **fail-closed 거절** (service): No additional description. Evidence: L13463–L13497.
|
||||
- **guarded command path** (service): No additional description. Evidence: L13469–L13503.
|
||||
- **CommandPolicyGuard** (service): No additional description. Evidence: L13469–L13503.
|
||||
- **승인 후 실행** (result): No additional description. Evidence: L13469–L13503.
|
||||
- **fail-closed 거절** (result): No additional description. Evidence: L13469–L13503.
|
||||
- **semantic adapters ×5** (service): No additional description. Evidence: L13469–L13503.
|
||||
- **RedisCommandGateway 직접 호출** (service): No additional description. Evidence: L13469–L13503.
|
||||
- **guard stages 우회** (result): No additional description. Evidence: L13469–L13503.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **CommandPolicyGuard → 실행:** 통과. Evidence: L13463–L13490.
|
||||
- **CommandPolicyGuard → fail-closed 거절:** 거절. Evidence: L13463–L13497.
|
||||
- **semantic adapters ×5 → RedisCommandGateway 직접 호출:** lease.gateway(). Evidence: L13469–L13503.
|
||||
- **guarded command path → CommandPolicyGuard:** admission. Evidence: L13469–L13503.
|
||||
- **CommandPolicyGuard → 승인 후 실행:** 통과. Evidence: L13469–L13503.
|
||||
- **CommandPolicyGuard → fail-closed 거절:** 거절. Evidence: L13469–L13503.
|
||||
- **RedisCommandGateway 직접 호출 → guard stages 우회:** guard 미경유. Evidence: L13469–L13503.
|
||||
|
||||
+22
-7
@@ -1,14 +1,29 @@
|
||||
# 명령 입장의 단일 지점
|
||||
# Question: 명령은 어디서 걸러지는가?
|
||||
# 의도된 guarded path와 실제 direct gateway 우회
|
||||
# Question: Redis 명령은 guard를 통과하는가, semantic adapter에서 gateway로 우회하는가?
|
||||
direction: right
|
||||
n0: "CommandPolicyGuard" {
|
||||
n0: "guarded command path" {
|
||||
shape: rectangle
|
||||
}
|
||||
n1: "실행" {
|
||||
n1: "CommandPolicyGuard" {
|
||||
shape: rectangle
|
||||
}
|
||||
n2: "fail-closed 거절" {
|
||||
n2: "승인 후 실행" {
|
||||
shape: rectangle
|
||||
}
|
||||
n0 -> n1: "통과"
|
||||
n0 -> n2: "거절"
|
||||
n3: "fail-closed 거절" {
|
||||
shape: rectangle
|
||||
}
|
||||
n4: "semantic adapters ×5" {
|
||||
shape: rectangle
|
||||
}
|
||||
n5: "RedisCommandGateway 직접 호출" {
|
||||
shape: rectangle
|
||||
}
|
||||
n6: "guard stages 우회" {
|
||||
shape: rectangle
|
||||
}
|
||||
n0 -> n1: "admission"
|
||||
n1 -> n2: "통과"
|
||||
n1 -> n3: "거절"
|
||||
n4 -> n5: "lease.gateway()"
|
||||
n5 -> n6: "guard 미경유"
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
digraph techviz {
|
||||
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
|
||||
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
|
||||
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
|
||||
n0 [label="guarded command path", shape=box, style="rounded,filled"];
|
||||
n1 [label="CommandPolicyGuard", shape=box, style="rounded,filled"];
|
||||
n2 [label="승인 후 실행", shape=box, style="rounded,filled"];
|
||||
n3 [label="fail-closed 거절", shape=box, style="rounded,filled"];
|
||||
n4 [label="semantic adapters ×5", shape=box, style="rounded,filled"];
|
||||
n5 [label="RedisCommandGateway 직접 호출", shape=box, style="rounded,filled"];
|
||||
n6 [label="guard stages 우회", shape=box, style="rounded,filled"];
|
||||
n0 -> n1 [label="admission", style=solid];
|
||||
n1 -> n2 [label="통과", style=solid];
|
||||
n1 -> n3 [label="거절", style=solid];
|
||||
n4 -> n5 [label="lease.gateway()", style=solid];
|
||||
n5 -> n6 [label="guard 미경유", style=solid];
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="redis-admission-stages" name="의도된 guarded path와 실제 direct gateway 우회">
|
||||
<mxGraphModel dx="1055" dy="465" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1055" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_typed-entry" value="guarded command path" tooltip="service | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="140.0" width="174.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_semantic" value="semantic adapters ×5" tooltip="service | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="276.0" width="174.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_guard" value="CommandPolicyGuard" tooltip="service | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="418.0" y="135.0" width="160.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_gateway" value="RedisCommandGateway 직접 호출" tooltip="service | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="404.0" y="271.0" width="188.0" height="74.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_deny" value="fail-closed 거절" tooltip="result | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="806.0" y="60.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_run" value="승인 후 실행" tooltip="result | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="806.0" y="196.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_bypass" value="guard stages 우회<br/>catalog · permit · slot · budget<br/>translation · observation" tooltip="result | Evidence: L13469-L13503" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="752.0" y="332.0" width="258.0" height="88.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_direct" value="lease.gateway()" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_semantic" target="n_gateway">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="280.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_entry" value="admission" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_typed-entry" target="n_guard">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="355.0" y="169.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_pass" value="통과" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_guard" target="n_run">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="716.0" y="202.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_reject" value="거절" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_guard" target="n_deny">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="716.0" y="125.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_skips" value="guard 미경유" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_gateway" target="n_bypass">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="696.0" y="342.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "techviz-harness",
|
||||
"elements": [
|
||||
{
|
||||
"id": "edge-direct",
|
||||
"type": "arrow",
|
||||
"x": 244.0,
|
||||
"y": 308.0,
|
||||
"width": 160.0,
|
||||
"height": 0.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 893186682,
|
||||
"version": 1,
|
||||
"versionNonce": 455485550,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
80.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
80.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
160.0,
|
||||
0.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-semantic",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-gateway",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-direct",
|
||||
"type": "text",
|
||||
"x": 264.0,
|
||||
"y": 268.0,
|
||||
"width": 120,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 687374111,
|
||||
"version": 1,
|
||||
"versionNonce": 1753930037,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "lease.gateway()",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "lease.gateway()",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-entry",
|
||||
"type": "arrow",
|
||||
"x": 244.0,
|
||||
"y": 167.0,
|
||||
"width": 174.0,
|
||||
"height": 5.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 1091082879,
|
||||
"version": 1,
|
||||
"versionNonce": 1327755587,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
87.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
87.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
174.0,
|
||||
0.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-typed-entry",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-guard",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-entry",
|
||||
"type": "text",
|
||||
"x": 310.0,
|
||||
"y": 157.5,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1236462406,
|
||||
"version": 1,
|
||||
"versionNonce": 215795817,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "admission",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "admission",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-pass",
|
||||
"type": "arrow",
|
||||
"x": 578.0,
|
||||
"y": 176.0,
|
||||
"width": 228.0,
|
||||
"height": 52.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 610606581,
|
||||
"version": 1,
|
||||
"versionNonce": 1519236829,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
114.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
114.0,
|
||||
52.0
|
||||
],
|
||||
[
|
||||
228.0,
|
||||
52.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-guard",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-run",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-pass",
|
||||
"type": "text",
|
||||
"x": 671.0,
|
||||
"y": 190.0,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 783585354,
|
||||
"version": 1,
|
||||
"versionNonce": 46708051,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "통과",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "통과",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-reject",
|
||||
"type": "arrow",
|
||||
"x": 578.0,
|
||||
"y": 92.0,
|
||||
"width": 228.0,
|
||||
"height": 66.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 1747658786,
|
||||
"version": 1,
|
||||
"versionNonce": 322244831,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
66.0
|
||||
],
|
||||
[
|
||||
114.0,
|
||||
66.0
|
||||
],
|
||||
[
|
||||
114.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
228.0,
|
||||
0.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-guard",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-deny",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-reject",
|
||||
"type": "text",
|
||||
"x": 671.0,
|
||||
"y": 113.0,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1904447632,
|
||||
"version": 1,
|
||||
"versionNonce": 1512441695,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "거절",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "거절",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-skips",
|
||||
"type": "arrow",
|
||||
"x": 592.0,
|
||||
"y": 308.0,
|
||||
"width": 160.0,
|
||||
"height": 68.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 1567695484,
|
||||
"version": 1,
|
||||
"versionNonce": 138426472,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
80.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
80.0,
|
||||
68.0
|
||||
],
|
||||
[
|
||||
160.0,
|
||||
68.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-gateway",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-bypass",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-skips",
|
||||
"type": "text",
|
||||
"x": 651.0,
|
||||
"y": 330.0,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 527325182,
|
||||
"version": 1,
|
||||
"versionNonce": 1660368872,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "guard 미경유",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "guard 미경유",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-typed-entry",
|
||||
"type": "rectangle",
|
||||
"x": 70.0,
|
||||
"y": 140.0,
|
||||
"width": 174.0,
|
||||
"height": 64.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1422067438,
|
||||
"version": 1,
|
||||
"versionNonce": 184283477,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-typed-entry",
|
||||
"type": "text",
|
||||
"x": 80.0,
|
||||
"y": 150.0,
|
||||
"width": 154.0,
|
||||
"height": 44.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1597371070,
|
||||
"version": 1,
|
||||
"versionNonce": 116582098,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "guarded command path",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "guarded command path",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-semantic",
|
||||
"type": "rectangle",
|
||||
"x": 70.0,
|
||||
"y": 276.0,
|
||||
"width": 174.0,
|
||||
"height": 64.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1337464297,
|
||||
"version": 1,
|
||||
"versionNonce": 101804627,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-semantic",
|
||||
"type": "text",
|
||||
"x": 80.0,
|
||||
"y": 286.0,
|
||||
"width": 154.0,
|
||||
"height": 44.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1868596939,
|
||||
"version": 1,
|
||||
"versionNonce": 1104547463,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "semantic adapters ×5",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "semantic adapters ×5",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-guard",
|
||||
"type": "rectangle",
|
||||
"x": 418.0,
|
||||
"y": 135.0,
|
||||
"width": 160.0,
|
||||
"height": 64.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 287186016,
|
||||
"version": 1,
|
||||
"versionNonce": 258088240,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-guard",
|
||||
"type": "text",
|
||||
"x": 428.0,
|
||||
"y": 145.0,
|
||||
"width": 140.0,
|
||||
"height": 44.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1734755542,
|
||||
"version": 1,
|
||||
"versionNonce": 1622291566,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "CommandPolicyGuard",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "CommandPolicyGuard",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-gateway",
|
||||
"type": "rectangle",
|
||||
"x": 404.0,
|
||||
"y": 271.0,
|
||||
"width": 188.0,
|
||||
"height": 74.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1264474515,
|
||||
"version": 1,
|
||||
"versionNonce": 568841533,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-gateway",
|
||||
"type": "text",
|
||||
"x": 414.0,
|
||||
"y": 281.0,
|
||||
"width": 168.0,
|
||||
"height": 54.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 222918616,
|
||||
"version": 1,
|
||||
"versionNonce": 1589548629,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "RedisCommandGateway 직접 호출",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "RedisCommandGateway 직접 호출",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-deny",
|
||||
"type": "rectangle",
|
||||
"x": 806.0,
|
||||
"y": 60.0,
|
||||
"width": 150.0,
|
||||
"height": 64.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 763196993,
|
||||
"version": 1,
|
||||
"versionNonce": 1443081913,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-deny",
|
||||
"type": "text",
|
||||
"x": 816.0,
|
||||
"y": 70.0,
|
||||
"width": 130.0,
|
||||
"height": 44.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 804770357,
|
||||
"version": 1,
|
||||
"versionNonce": 725474116,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "fail-closed 거절",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "fail-closed 거절",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-run",
|
||||
"type": "rectangle",
|
||||
"x": 806.0,
|
||||
"y": 196.0,
|
||||
"width": 150.0,
|
||||
"height": 64.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 993733559,
|
||||
"version": 1,
|
||||
"versionNonce": 1523859840,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-run",
|
||||
"type": "text",
|
||||
"x": 816.0,
|
||||
"y": 206.0,
|
||||
"width": 130.0,
|
||||
"height": 44.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1656583306,
|
||||
"version": 1,
|
||||
"versionNonce": 187733575,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "승인 후 실행",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "승인 후 실행",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-bypass",
|
||||
"type": "rectangle",
|
||||
"x": 752.0,
|
||||
"y": 332.0,
|
||||
"width": 258.0,
|
||||
"height": 88.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 994055316,
|
||||
"version": 1,
|
||||
"versionNonce": 1666152370,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-bypass",
|
||||
"type": "text",
|
||||
"x": 762.0,
|
||||
"y": 342.0,
|
||||
"width": 238.0,
|
||||
"height": 68.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1029604402,
|
||||
"version": 1,
|
||||
"versionNonce": 219747915,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "guard stages 우회\ncatalog · permit · slot · budget\ntranslation · observation",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "guard stages 우회\ncatalog · permit · slot · budget\ntranslation · observation",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
}
|
||||
],
|
||||
"appState": {
|
||||
"gridSize": 10,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"currentItemFontFamily": 5
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
+9
-5
@@ -2,19 +2,23 @@
|
||||
"harness_version": "0.2.0",
|
||||
"spec_id": "redis-admission-stages",
|
||||
"spec_version": "1.1",
|
||||
"spec_sha256": "fe6a87016b5442d9611f3a88010b837794bd92cbcb3f65504f5215dcccc4e187",
|
||||
"spec_sha256": "1f1dfcffe5be16d6b9303aab638c22acc1ae19d30bd309504f047dc9e673732b",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
|
||||
"document": "docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
|
||||
"anchor": {
|
||||
"kind": "line",
|
||||
"value": 13463,
|
||||
"line": 13463
|
||||
"value": 13469,
|
||||
"line": 13469
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
"redis-admission-stages.svg",
|
||||
"redis-admission-stages.drawio",
|
||||
"redis-admission-stages.mmd",
|
||||
"redis-admission-stages.d2",
|
||||
"redis-admission-stages.dot",
|
||||
"redis-admission-stages.excalidraw",
|
||||
"redis-admission-stages.alt.md"
|
||||
],
|
||||
"lint_issue_count": 0,
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
%% 의도된 guarded path와 실제 direct gateway 우회
|
||||
%% question: Redis 명령은 guard를 통과하는가, semantic adapter에서 gateway로 우회하는가?
|
||||
flowchart LR
|
||||
n0["guarded command path"]
|
||||
n1["CommandPolicyGuard"]
|
||||
n2["승인 후 실행"]
|
||||
n3["fail-closed 거절"]
|
||||
n4["semantic adapters ×5"]
|
||||
n5["RedisCommandGateway 직접 호출"]
|
||||
n6["guard stages 우회"]
|
||||
n0 -->|"admission"| n1
|
||||
n1 -->|"통과"| n2
|
||||
n1 -->|"거절"| n3
|
||||
n4 -->|"lease.gateway()"| n5
|
||||
n5 -->|"guard 미경유"| n6
|
||||
+46
-21
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="319" viewBox="0 0 680 319" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">명령 입장의 단일 지점</title>
|
||||
<desc id="diagram-description">SSOT 는 이 구조의 바닥이 카탈로그가 미분류 명령을 fail-closed 로 거부하는 것이라고 적는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"redis-admission-stages","profile":"component-flow"},"source_context":{"document":"/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md","document_sha256":"8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d","anchor":{"kind":"line","value":13463,"line":13463}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1055" height="465" viewBox="0 0 1055 465" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">의도된 guarded path와 실제 direct gateway 우회</title>
|
||||
<desc id="diagram-description">CommandPolicyGuard는 guarded command path의 admission 지점이다. 그러나 현재 semantic adapter 다섯은 RedisLease에서 gateway를 직접 얻어 호출하므로 catalog, permit, slot, budget, translation, observation 단계가 이 경로에 적용되지 않는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"redis-admission-stages","profile":"component-flow"},"source_context":{"document":"docs/clean-architecture-backend-template/final/document.md","document_sha256":"7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8","anchor":{"kind":"line","value":13469,"line":13469}},"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" />
|
||||
@@ -49,27 +49,52 @@
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="680" height="319" />
|
||||
<polyline class="edge kind-request style-solid emphasis-primary" points="230.0,176.0 310.0,176.0 310.0,238.5 390.0,238.5" data-evidence="13463-13490" />
|
||||
<rect class="edge-label-bg" x="312.0" y="193.2" width="44.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="334.0" y="208.2">통과</text>
|
||||
<polyline class="edge kind-request style-dashed emphasis-normal" points="230.0,158.0 310.0,158.0 310.0,95.5 390.0,95.5" data-evidence="13463-13497" />
|
||||
<rect class="edge-label-bg" x="312.0" y="112.8" width="44.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="334.0" y="127.8">거절</text>
|
||||
<rect class="canvas" width="1055" height="465" />
|
||||
<polyline class="edge kind-request style-dashed emphasis-normal" points="244.0,308.0 324.0,308.0 324.0,308.0 404.0,308.0" data-evidence="13469-13503" />
|
||||
<rect class="edge-label-bg" x="264.8" y="266.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="281.0">lease.gateway()</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="244.0,172.0 331.0,172.0 331.0,167.0 418.0,167.0" data-evidence="13469-13503" />
|
||||
<rect class="edge-label-bg" x="315.9" y="155.5" width="78.3" height="22" rx="3" />
|
||||
<text class="edge-label" x="355.0" y="170.5">admission</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="578.0,176.0 692.0,176.0 692.0,228.0 806.0,228.0" data-evidence="13469-13503" />
|
||||
<rect class="edge-label-bg" x="694.0" y="188.0" width="44.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="716.0" y="203.0">통과</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="578.0,158.0 692.0,158.0 692.0,92.0 806.0,92.0" data-evidence="13469-13503" />
|
||||
<rect class="edge-label-bg" x="694.0" y="111.0" width="44.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="716.0" y="126.0">거절</text>
|
||||
<polyline class="edge kind-data style-dashed emphasis-primary" points="592.0,308.0 672.0,308.0 672.0,376.0 752.0,376.0" data-evidence="13469-13503" />
|
||||
<rect class="edge-label-bg" x="656.9" y="328.0" width="78.3" height="22" rx="3" />
|
||||
<text class="edge-label" x="696.0" y="343.0">guard 미경유</text>
|
||||
<g id="node-typed-entry">
|
||||
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="13469-13503" x="70.0" y="140.0" width="174.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="157.0" y="170.0">guarded command path</text>
|
||||
</g>
|
||||
<g id="node-semantic">
|
||||
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="13469-13503" x="70.0" y="276.0" width="174.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="157.0" y="306.0">semantic adapters ×5</text>
|
||||
</g>
|
||||
<g id="node-guard">
|
||||
<rect class="node-shape kind-service emphasis-primary role-source" data-evidence="13463-13490" x="70.0" y="135.0" width="160.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="150.0" y="165.0">CommandPolicyGuard</text>
|
||||
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="13469-13503" x="418.0" y="135.0" width="160.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="498.0" y="165.0">CommandPolicyGuard</text>
|
||||
</g>
|
||||
<g id="node-gateway">
|
||||
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="13469-13503" x="404.0" y="271.0" width="188.0" height="74.0" rx="7" />
|
||||
<text class="node-label" x="498.0" y="298.0">RedisCommandGateway 직접</text>
|
||||
<text class="node-label" x="498.0" y="316.0">호출</text>
|
||||
</g>
|
||||
<g id="node-deny">
|
||||
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="13463-13497" x="390.0" y="60.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="465.0" y="87.0">fail-closed 거절</text>
|
||||
<line class="node-detail-divider" x1="404.0" y1="108.0" x2="526.0" y2="108.0" />
|
||||
<text class="node-detail" x="406.0" y="125.0">미분류 · BLOCKED</text>
|
||||
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="13469-13503" x="806.0" y="60.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="881.0" y="90.0">fail-closed 거절</text>
|
||||
</g>
|
||||
<g id="node-run">
|
||||
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="13463-13490" x="390.0" y="203.0" width="150.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="465.0" y="230.0">실행</text>
|
||||
<line class="node-detail-divider" x1="404.0" y1="251.0" x2="526.0" y2="251.0" />
|
||||
<text class="node-detail" x="406.0" y="268.0">카탈로그 통과</text>
|
||||
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="13469-13503" x="806.0" y="196.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="881.0" y="226.0">승인 후 실행</text>
|
||||
</g>
|
||||
<g id="node-bypass">
|
||||
<rect class="node-shape kind-result emphasis-primary role-sink" data-evidence="13469-13503" x="752.0" y="332.0" width="258.0" height="88.0" rx="7" />
|
||||
<text class="node-label" x="881.0" y="359.0">guard stages 우회</text>
|
||||
<line class="node-detail-divider" x1="766.0" y1="380.0" x2="996.0" y2="380.0" />
|
||||
<text class="node-detail" x="768.0" y="397.0">catalog · permit · slot · budget</text>
|
||||
<text class="node-detail" x="768.0" y="413.0">translation · observation</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 7.8 KiB |
+10
-10
@@ -1,22 +1,22 @@
|
||||
# 격리가 성립하는 세 조건
|
||||
# PostgreSQL RLS 적용 여부를 가르는 분기
|
||||
|
||||
## Alternative text
|
||||
|
||||
격리 판정이 ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 롤을 차례로 확인하는 순서.
|
||||
RLS 비활성은 policy 미적용으로, superuser와 BYPASSRLS는 우회로, owner는 FORCE 여부로 갈리고, policy 대상인데 applicable policy가 없으면 default deny가 되는 흐름도
|
||||
|
||||
## Long description
|
||||
|
||||
SSOT 는 RlsPolicyVerifier.requireEnforced 가 런타임 롤의 BYPASSRLS 를 확인하고 current_schema() 의 실제 테이블을 순회하며 tenant-scoped 목록에 든 것만 검사한다고 적는다.
|
||||
PostgreSQL RLS를 세 개의 동시 전제로 보지 않는다. RLS가 활성화된 뒤 superuser 또는 BYPASSRLS인지, table owner인지와 FORCE RLS 여부를 확인한다. policy 대상 role에 applicable policy가 없으면 default deny이고, policy가 있으면 USING과 WITH CHECK를 평가한다.
|
||||
|
||||
## Elements and evidence
|
||||
|
||||
- **격리 판정** (service): No additional description. Evidence: L7396–L7420.
|
||||
- **RlsPolicyVerifier** (service): No additional description. Evidence: L7396–L7420.
|
||||
- **데이터베이스** (database): No additional description. Evidence: L7396–L7426.
|
||||
- **Boundary: policy 적용 대상 판정** (boundary): No additional description. Evidence: L7400–L7432.
|
||||
- **Boundary: policy 존재와 평가** (boundary): No additional description. Evidence: L7400–L7432.
|
||||
- **RLS 활성 여부** (process): No additional description. Evidence: L7400–L7432.
|
||||
- **policy 적용 대상** (process): No additional description. Evidence: L7400–L7432.
|
||||
- **applicable policy** (process): No additional description. Evidence: L7400–L7432.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **격리 판정 → RlsPolicyVerifier:** 검증 요청. Evidence: L7396–L7420.
|
||||
- **RlsPolicyVerifier → 데이터베이스:** ENABLE RLS 확인. Evidence: L7396–L7420.
|
||||
- **RlsPolicyVerifier → 데이터베이스:** FORCE RLS 확인. Evidence: L7396–L7420.
|
||||
- **RlsPolicyVerifier → 데이터베이스:** BYPASSRLS 없음 확인. Evidence: L7396–L7426.
|
||||
- **RLS 활성 여부 → policy 적용 대상:** RLS on. Evidence: L7400–L7432.
|
||||
- **policy 적용 대상 → applicable policy:** 적용. Evidence: L7400–L7432.
|
||||
|
||||
+16
-14
@@ -1,16 +1,18 @@
|
||||
# 격리가 성립하는 세 조건
|
||||
# Question: RLS 격리는 무엇이 동시에 참이어야 성립하는가?
|
||||
direction: right
|
||||
n0: "격리 판정" {
|
||||
shape: rectangle
|
||||
# PostgreSQL RLS 적용 여부를 가르는 분기
|
||||
# Question: 현재 role과 table에서 RLS policy가 실제로 적용되는가?
|
||||
direction: down
|
||||
g0: "policy 적용 대상 판정" {
|
||||
n0: "RLS 활성 여부" {
|
||||
shape: rectangle
|
||||
}
|
||||
n1: "policy 적용 대상" {
|
||||
shape: rectangle
|
||||
}
|
||||
}
|
||||
n1: "RlsPolicyVerifier" {
|
||||
shape: rectangle
|
||||
g1: "policy 존재와 평가" {
|
||||
n2: "applicable policy" {
|
||||
shape: rectangle
|
||||
}
|
||||
}
|
||||
n2: "데이터베이스" {
|
||||
shape: sql_table
|
||||
}
|
||||
n0 -> n1: "검증 요청"
|
||||
n1 -> n2: "ENABLE RLS 확인"
|
||||
n1 -> n2: "FORCE RLS 확인"
|
||||
n1 -> n2: "BYPASSRLS 없음 확인"
|
||||
g0.n0 -> g0.n1: "RLS on"
|
||||
g0.n1 -> g1.n2: "적용"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
digraph techviz {
|
||||
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
|
||||
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
|
||||
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
|
||||
subgraph cluster_0 {
|
||||
label="policy 적용 대상 판정";
|
||||
style="rounded,dashed";
|
||||
color="#66788a";
|
||||
n0 [label="RLS 활성 여부", shape=box, style="rounded,filled"];
|
||||
n1 [label="policy 적용 대상", shape=box, style="rounded,filled"];
|
||||
}
|
||||
subgraph cluster_1 {
|
||||
label="policy 존재와 평가";
|
||||
style="rounded,dashed";
|
||||
color="#66788a";
|
||||
n2 [label="applicable policy", shape=box, style="rounded,filled"];
|
||||
}
|
||||
n0 -> n1 [label="RLS on", style=solid];
|
||||
n1 -> n2 [label="적용", style=solid];
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="rls-three-preconditions" name="PostgreSQL RLS 적용 여부를 가르는 분기">
|
||||
<mxGraphModel dx="860" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="860" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="g_role" value="policy 적용 대상 판정" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="45.0" y="49.0" width="470.0" height="177.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="g_policy-zone" value="policy 존재와 평가" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="565.0" y="49.0" width="250.0" height="160.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_rls" value="RLS 활성 여부<br/>no → policy 미적용" tooltip="process | Evidence: L7400-L7432" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="75.0" y="95.0" width="190.0" height="71.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_subject" value="policy 적용 대상<br/>superuser / BYPASSRLS → 우회<br/>owner + FORCE off → 우회<br/>non-owner 또는 owner + FORCE on → 대상" tooltip="process | Evidence: L7400-L7432" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="295.0" y="95.0" width="190.0" height="105.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_policy" value="applicable policy<br/>none → default deny<br/>exists → USING / WITH CHECK 평가" tooltip="process | Evidence: L7400-L7432" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="595.0" y="95.0" width="190.0" height="88.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_e1" value="RLS on" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_rls" target="n_subject">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="271.5" y="31.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_e2" value="적용" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_subject" target="n_policy">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="564.0" y="143.2" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "techviz-harness",
|
||||
"elements": [
|
||||
{
|
||||
"id": "group-role",
|
||||
"type": "rectangle",
|
||||
"x": 45.0,
|
||||
"y": 49.0,
|
||||
"width": 470.0,
|
||||
"height": 177.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#f8f9fa",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1777472244,
|
||||
"version": 1,
|
||||
"versionNonce": 1516900995,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "group-label-role",
|
||||
"type": "text",
|
||||
"x": 61.0,
|
||||
"y": 55.0,
|
||||
"width": 135,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 715621186,
|
||||
"version": 1,
|
||||
"versionNonce": 87917954,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 14,
|
||||
"fontFamily": 5,
|
||||
"text": "policy 적용 대상 판정",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "policy 적용 대상 판정",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "group-policy-zone",
|
||||
"type": "rectangle",
|
||||
"x": 565.0,
|
||||
"y": 49.0,
|
||||
"width": 250.0,
|
||||
"height": 160.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#f8f9fa",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 169725814,
|
||||
"version": 1,
|
||||
"versionNonce": 1032311414,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "group-label-policy-zone",
|
||||
"type": "text",
|
||||
"x": 581.0,
|
||||
"y": 55.0,
|
||||
"width": 117,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1265739546,
|
||||
"version": 1,
|
||||
"versionNonce": 1075054467,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 14,
|
||||
"fontFamily": 5,
|
||||
"text": "policy 존재와 평가",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "policy 존재와 평가",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-e1",
|
||||
"type": "arrow",
|
||||
"x": 265.0,
|
||||
"y": 59.0,
|
||||
"width": 30.0,
|
||||
"height": 88.5,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 71197658,
|
||||
"version": 1,
|
||||
"versionNonce": 618174464,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
71.5
|
||||
],
|
||||
[
|
||||
30.0,
|
||||
71.5
|
||||
],
|
||||
[
|
||||
30.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
88.5
|
||||
],
|
||||
[
|
||||
30.0,
|
||||
88.5
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-rls",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-subject",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-e1",
|
||||
"type": "text",
|
||||
"x": 226.5,
|
||||
"y": 19.0,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 246258267,
|
||||
"version": 1,
|
||||
"versionNonce": 768010045,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "RLS on",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "RLS on",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "edge-e2",
|
||||
"type": "arrow",
|
||||
"x": 485.0,
|
||||
"y": 139.0,
|
||||
"width": 110.0,
|
||||
"height": 8.5,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": null,
|
||||
"seed": 804950317,
|
||||
"version": 1,
|
||||
"versionNonce": 629875261,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
8.5
|
||||
],
|
||||
[
|
||||
55.0,
|
||||
8.5
|
||||
],
|
||||
[
|
||||
55.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
110.0,
|
||||
0.0
|
||||
]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "node-subject",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "node-policy",
|
||||
"focus": 0,
|
||||
"gap": 4
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true
|
||||
},
|
||||
{
|
||||
"id": "edge-label-e2",
|
||||
"type": "text",
|
||||
"x": 519.0,
|
||||
"y": 131.25,
|
||||
"width": 90,
|
||||
"height": 24,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 340809808,
|
||||
"version": 1,
|
||||
"versionNonce": 1429402641,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 13,
|
||||
"fontFamily": 5,
|
||||
"text": "적용",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "적용",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-rls",
|
||||
"type": "rectangle",
|
||||
"x": 75.0,
|
||||
"y": 95.0,
|
||||
"width": 190.0,
|
||||
"height": 71.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 46671207,
|
||||
"version": 1,
|
||||
"versionNonce": 1454808113,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-rls",
|
||||
"type": "text",
|
||||
"x": 85.0,
|
||||
"y": 105.0,
|
||||
"width": 170.0,
|
||||
"height": 51.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1139089945,
|
||||
"version": 1,
|
||||
"versionNonce": 19559202,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "RLS 활성 여부\nno → policy 미적용",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "RLS 활성 여부\nno → policy 미적용",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-subject",
|
||||
"type": "rectangle",
|
||||
"x": 295.0,
|
||||
"y": 95.0,
|
||||
"width": 190.0,
|
||||
"height": 105.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 809707623,
|
||||
"version": 1,
|
||||
"versionNonce": 308999550,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-subject",
|
||||
"type": "text",
|
||||
"x": 305.0,
|
||||
"y": 105.0,
|
||||
"width": 170.0,
|
||||
"height": 85.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 651634604,
|
||||
"version": 1,
|
||||
"versionNonce": 1863011863,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "policy 적용 대상\nsuperuser / BYPASSRLS → 우회\nowner + FORCE off → 우회\nnon-owner 또는 owner + FORCE on → 대상",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "policy 적용 대상\nsuperuser / BYPASSRLS → 우회\nowner + FORCE off → 우회\nnon-owner 또는 owner + FORCE on → 대상",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
},
|
||||
{
|
||||
"id": "node-policy",
|
||||
"type": "rectangle",
|
||||
"x": 595.0,
|
||||
"y": 95.0,
|
||||
"width": 190.0,
|
||||
"height": 88.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1953932615,
|
||||
"version": 1,
|
||||
"versionNonce": 1148359251,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "node-label-policy",
|
||||
"type": "text",
|
||||
"x": 605.0,
|
||||
"y": 105.0,
|
||||
"width": 170.0,
|
||||
"height": 68.0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": null,
|
||||
"roundness": {
|
||||
"type": 3
|
||||
},
|
||||
"seed": 1771766625,
|
||||
"version": 1,
|
||||
"versionNonce": 252892369,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 0,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"fontSize": 15,
|
||||
"fontFamily": 5,
|
||||
"text": "applicable policy\nnone → default deny\nexists → USING / WITH CHECK 평가",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"containerId": null,
|
||||
"originalText": "applicable policy\nnone → default deny\nexists → USING / WITH CHECK 평가",
|
||||
"autoResize": true,
|
||||
"lineHeight": 1.25
|
||||
}
|
||||
],
|
||||
"appState": {
|
||||
"gridSize": 10,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"currentItemFontFamily": 5
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
+11
-7
@@ -2,27 +2,31 @@
|
||||
"harness_version": "0.2.0",
|
||||
"spec_id": "rls-three-preconditions",
|
||||
"spec_version": "1.1",
|
||||
"spec_sha256": "f6268a244e162b30af9d251df67dbe2d8d3e23e84198f91656f865e0f5f96a1c",
|
||||
"spec_sha256": "f49733b25ed5a35fa7ebbe452adbf1a606aad7108643d443b43a9fae25d15ebf",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
|
||||
"document": "docs/clean-architecture-backend-template/final/document.md",
|
||||
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
|
||||
"anchor": {
|
||||
"kind": "line",
|
||||
"value": 7398,
|
||||
"line": 7398
|
||||
"value": 7400,
|
||||
"line": 7400
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
"rls-three-preconditions.svg",
|
||||
"rls-three-preconditions.drawio",
|
||||
"rls-three-preconditions.mmd",
|
||||
"rls-three-preconditions.d2",
|
||||
"rls-three-preconditions.dot",
|
||||
"rls-three-preconditions.excalidraw",
|
||||
"rls-three-preconditions.alt.md"
|
||||
],
|
||||
"lint_issue_count": 0,
|
||||
"assumption_count": 0,
|
||||
"assumptions_allowed": false,
|
||||
"composition_profile": "sequence",
|
||||
"composition_profile": "two-zone-pipeline",
|
||||
"reference_ids": [
|
||||
"payment-approval-sequence"
|
||||
"localization-pipeline"
|
||||
],
|
||||
"diagram_only": true
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
%% PostgreSQL RLS 적용 여부를 가르는 분기
|
||||
%% question: 현재 role과 table에서 RLS policy가 실제로 적용되는가?
|
||||
flowchart TB
|
||||
subgraph g_role["policy 적용 대상 판정"]
|
||||
n0["RLS 활성 여부"]
|
||||
n1["policy 적용 대상"]
|
||||
end
|
||||
subgraph g_policy_zone["policy 존재와 평가"]
|
||||
n2["applicable policy"]
|
||||
end
|
||||
n0 -->|"RLS on"| n1
|
||||
n1 -->|"적용"| n2
|
||||
+38
-26
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="418" viewBox="0 0 680 418" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">격리가 성립하는 세 조건</title>
|
||||
<desc id="diagram-description">SSOT 는 RlsPolicyVerifier.requireEnforced 가 런타임 롤의 BYPASSRLS 를 확인하고 current_schema() 의 실제 테이블을 순회하며 tenant-scoped 목록에 든 것만 검사한다고 적는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"rls-three-preconditions","profile":"sequence"},"source_context":{"document":"/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md","document_sha256":"8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d","anchor":{"kind":"line","value":7398,"line":7398}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="300" viewBox="0 0 860 300" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">PostgreSQL RLS 적용 여부를 가르는 분기</title>
|
||||
<desc id="diagram-description">PostgreSQL RLS를 세 개의 동시 전제로 보지 않는다. RLS가 활성화된 뒤 superuser 또는 BYPASSRLS인지, table owner인지와 FORCE RLS 여부를 확인한다. policy 대상 role에 applicable policy가 없으면 default deny이고, policy가 있으면 USING과 WITH CHECK를 평가한다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"rls-three-preconditions","profile":"two-zone-pipeline"},"source_context":{"document":"docs/clean-architecture-backend-template/final/document.md","document_sha256":"7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8","anchor":{"kind":"line","value":7400,"line":7400}},"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" />
|
||||
@@ -49,26 +49,38 @@
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="680" height="418" />
|
||||
<rect class="node-shape kind-service emphasis-normal role-participant" data-evidence="7396-7420" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="120.0" y="65.0">격리 판정</text>
|
||||
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="388.0" />
|
||||
<rect class="node-shape kind-service emphasis-primary role-participant" data-evidence="7396-7420" x="255.0" y="35.0" width="153.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="331.5" y="65.0">RlsPolicyVerifier</text>
|
||||
<line class="lifeline" x1="331.5" y1="99.0" x2="331.5" y2="388.0" />
|
||||
<rect class="node-shape kind-database emphasis-normal role-participant" data-evidence="7396-7426" x="465.0" y="48.0" width="150.0" height="52.0" /><ellipse class="node-shape kind-database emphasis-normal role-participant" cx="540.0" cy="48.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 465.0 100.0 A 75.0 13.0 0 0 0 615.0 100.0" />
|
||||
<text class="node-label" x="540.0" y="75.0">데이터베이스</text>
|
||||
<line class="lifeline" x1="540.0" y1="113.0" x2="540.0" y2="388.0" />
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 331.5,140.0" data-evidence="7396-7420" />
|
||||
<rect class="edge-label-bg" x="189.9" y="114.0" width="71.6" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.8" y="129.0">1. 검증 요청</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="331.5,202.0 540.0,202.0" data-evidence="7396-7420" />
|
||||
<rect class="edge-label-bg" x="373.1" y="176.0" width="125.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.8" y="191.0">2. ENABLE RLS 확인</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="331.5,264.0 540.0,264.0" data-evidence="7396-7420" />
|
||||
<rect class="edge-label-bg" x="376.5" y="238.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.8" y="253.0">3. FORCE RLS 확인</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-primary" points="331.5,326.0 540.0,326.0" data-evidence="7396-7426" />
|
||||
<rect class="edge-label-bg" x="366.4" y="300.0" width="138.6" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.8" y="315.0">4. BYPASSRLS 없음 확인</text>
|
||||
<rect class="canvas" width="860" height="300" />
|
||||
<rect class="group-box" x="45.0" y="49.0" width="470.0" height="177.0" rx="8" />
|
||||
<rect class="group-label-bg" x="59.0" y="39.0" width="127.0" height="22" />
|
||||
<text class="group-label" x="69.0" y="54.0">policy 적용 대상 판정</text>
|
||||
<rect class="group-box" x="565.0" y="49.0" width="250.0" height="160.0" rx="8" />
|
||||
<rect class="group-label-bg" x="579.0" y="39.0" width="113.0" height="22" />
|
||||
<text class="group-label" x="589.0" y="54.0">policy 존재와 평가</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="265.0,130.5 295.0,130.5 295.0,59.0 265.0,59.0 265.0,147.5 295.0,147.5" data-evidence="7400-7432" />
|
||||
<rect class="edge-label-bg" x="242.4" y="17.0" width="58.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="271.5" y="32.0">RLS on</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-primary" points="485.0,147.5 540.0,147.5 540.0,139.0 595.0,139.0" data-evidence="7400-7432" />
|
||||
<rect class="edge-label-bg" x="542.0" y="129.2" width="44.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="564.0" y="144.2">적용</text>
|
||||
<g id="node-rls">
|
||||
<rect class="node-shape kind-process emphasis-normal role-stage" data-evidence="7400-7432" x="75.0" y="95.0" width="190.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="170.0" y="122.0">RLS 활성 여부</text>
|
||||
<line class="node-detail-divider" x1="89.0" y1="143.0" x2="251.0" y2="143.0" />
|
||||
<text class="node-detail" x="91.0" y="160.0">no → policy 미적용</text>
|
||||
</g>
|
||||
<g id="node-subject">
|
||||
<rect class="node-shape kind-process emphasis-normal role-stage" data-evidence="7400-7432" x="295.0" y="95.0" width="190.0" height="105.0" rx="7" />
|
||||
<text class="node-label" x="390.0" y="122.0">policy 적용 대상</text>
|
||||
<line class="node-detail-divider" x1="309.0" y1="143.0" x2="471.0" y2="143.0" />
|
||||
<text class="node-detail" x="311.0" y="160.0">superuser / BYPASSRLS → 우회</text>
|
||||
<text class="node-detail" x="311.0" y="176.0">owner + FORCE off → 우회</text>
|
||||
<text class="node-detail" x="311.0" y="192.0">non-owner 또는 owner + FORCE on → 대상</text>
|
||||
</g>
|
||||
<g id="node-policy">
|
||||
<rect class="node-shape kind-process emphasis-primary role-stage" data-evidence="7400-7432" x="595.0" y="95.0" width="190.0" height="88.0" rx="7" />
|
||||
<text class="node-label" x="690.0" y="122.0">applicable policy</text>
|
||||
<line class="node-detail-divider" x1="609.0" y1="143.0" x2="771.0" y2="143.0" />
|
||||
<text class="node-detail" x="611.0" y="160.0">none → default deny</text>
|
||||
<text class="node-detail" x="611.0" y="176.0">exists → USING / WITH CHECK 평가</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -721,16 +721,20 @@ public InboxCleanupJob inboxCleanupJob(...)
|
||||
`JdbcInboxRepository`)을 **어떤 자동설정도 만들지 않는다.** 19 main 파일 / 2,818 LOC가 전부 조용히
|
||||
비어 있다.
|
||||
|
||||
**실패가 특히 조용하다.** Spring은 조건부 bean이 조건을 만족하지 못하는 것을 오류로 보고하지
|
||||
않는다. 즉 **"outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다.**
|
||||
**실패가 애플리케이션 오류나 health failure로 자동 승격되지는 않는다.** 따라서 기능을 호출하지 않는 한
|
||||
"outbox가 꺼져 있음"과 "outbox bean이 조건 불일치로 생성되지 않음"이 제품 동작에서는 비슷하게 보일 수 있다.
|
||||
다만 condition evaluation evidence가 사라지는 것은 아니다. Spring Boot의 `ConditionEvaluationReport`와,
|
||||
endpoint가 노출된 경우 `/actuator/conditions`에서 positive/negative match와 이유를 확인할 수 있다.
|
||||
같은 starter가 `MessageCodecRegistry`에는 `@ConditionalOnMissingBean` 기본 구현을 제공했다는 점이
|
||||
이것을 결함으로 만든다.
|
||||
이 조립 차이를 검토할 이유다.
|
||||
|
||||
**마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다** (`19` §7.2).
|
||||
|
||||
합성 루트의 Flyway 기본 위치는 `PostgreSqlPersistenceConfig:115`의
|
||||
`classpath:db/migration/postgresql`이고, `db/migration/messaging`을 이름으로 부르는 main 코드가
|
||||
저장소 전체에 **0건**이다. 그리고 두 leaf가 같은 리소스 디렉터리에 각자 번호를 매긴다:
|
||||
`classpath:db/migration/postgresql`이다. searched direct reference 기준으로 `db/migration/messaging`을
|
||||
이름으로 지정하는 main 코드는 찾지 못했다. 이 결과는 직접 지정 코드가 검색되지 않았다는 뜻이며,
|
||||
외부 설정·resource scanning·reflection·framework convention까지 포함해 runtime 사용이 없음을 단독으로 증명하지는 않는다.
|
||||
두 leaf가 같은 리소스 디렉터리에 각자 번호를 매긴다는 사실은 별개로 유지된다:
|
||||
|
||||
```
|
||||
messaging-inbox-jdbc-postgresql V2__messaging_inbox.sql (CREATE TABLE)
|
||||
@@ -7397,6 +7401,8 @@ Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experiment
|
||||
|
||||
`RlsPolicyVerifier.requireEnforced(runtimeDataSource, tenantScopedTables)`의 이름과 Javadoc은 caller가 지정한 tenant-scoped table들이 실제로 RLS에 의해 보호되는지 증명하는 contract다. 구현은 runtime role의 `BYPASSRLS`를 확인하고, `current_schema()`의 실제 table들을 순회하면서 이름이 `tenantScopedTables`에 포함된 row만 검사한다.
|
||||
|
||||
여기서 PostgreSQL 의미를 분리해서 읽어야 한다. RLS가 꺼져 있으면 policy가 적용되지 않는다. RLS가 켜져 있고 현재 role에 적용 가능한 policy가 없으면 일반 role에는 **default deny**가 적용된다. superuser와 `BYPASSRLS` role은 RLS를 우회한다. table owner도 기본적으로 우회하지만 `FORCE ROW LEVEL SECURITY`를 켜면 owner는 policy 대상이 된다. `FORCE`가 superuser나 `BYPASSRLS`의 우회를 없애는 것은 아니다. 따라서 이 값들을 항상 동시에 참이어야 하는 ‘세 전제’로 묶지 않는다.
|
||||
|
||||
문제는 반대 방향 검증이 없다는 것이다. 즉 caller가 요구한 table 이름이 실제 catalog 결과에 **한 번도 등장하지 않아도** 성공한다.
|
||||
|
||||
```text
|
||||
@@ -11438,7 +11444,7 @@ PROBE NUL byte then <html> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||||
PROBE plain text -> ACCEPT / NO_SCRIPTABLE_CONTENT
|
||||
```
|
||||
|
||||
세 가지가 통과한다. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 **UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고**, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 셋 다 브라우저는 HTML로 렌더링한다 — BOM 접두 HTML은 이국적인 우회가 아니라 여러 편집기의 기본 출력이다.
|
||||
세 가지가 detector를 통과한다. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 **UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고**, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 이 probe가 증명한 범위는 detector bypass까지다. 실제 대상 브라우저가 각 입력을 실행 가능한 콘텐츠로 해석하는지는 이번 evidence에서 확인하지 않았다.
|
||||
|
||||
형제 검증기와의 대비가 판정을 굳힌다. `MediaTypeVerifier`는 매직바이트를 접두사 **시작**에서 비교하는데, 그것은 시그니처의 정의가 파일 선두이므로 옳다. scriptable 마커는 시그니처가 아니라 **브라우저가 스니핑하는 패턴**이고, 브라우저는 선두 고정 매칭을 하지 않는다. 같은 "접두사 시작 비교"가 한쪽에서는 정확하고 다른 쪽에서는 우회 가능하다.
|
||||
|
||||
@@ -11485,7 +11491,7 @@ PROBE plain text -> ACCEPT / NO_SCRIPTABLE_CONTENT
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reaping·quota 네 능력에 대해 사실과 다르다 — 8개 port 구현과 app-bootstrap의 8개 bean으로 확정 | 이 문단으로 능력 유무를 판단하는 독자 |
|
||||
| **P2** | `ScriptableContentPolicy`가 마커를 접두사 **시작**에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 실행 가능 콘텐츠를 ACCEPT한다 (실행 probe 3건) | `inlineSafeProfile=false`이고 claimed 타입을 선언하지 않는 업로드 |
|
||||
| **P2** | `ScriptableContentPolicy`가 마커를 접두사 **시작**에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 입력을 ACCEPT한다 (detector probe 3건). 실제 브라우저 실행 가능성은 별도 검증하지 않음 | `inlineSafeProfile=false`이고 claimed 타입을 선언하지 않는 업로드 |
|
||||
| **P3/기록** | 실패 분류가 예외 메시지 텍스트("stale file handle", "timed out", "No space left on device")에 의존한다 — 문구가 달라지면 보수적 기본값으로 떨어지므로 안전한 방향 | 로케일/JDK 판본이 다른 배포 |
|
||||
|
||||
#### 46. Sub-scope 05 완료 조건
|
||||
@@ -13467,7 +13473,7 @@ RedisEphemeralFanoutAdapter implements EphemeralFanoutPort
|
||||
> `CommandPolicyGuard`: "**The single admission point every command passes through.**"
|
||||
> `RedisCommandGateway`: "Policy, permits, budgets, timeouts, and observability are not this interface's concern: **everything routed through it has already passed `CommandPolicyGuard`**."
|
||||
|
||||
의미 어댑터 다섯은 그 전제를 만족하지 않는다(`165-...` §8.1).
|
||||
이 문장은 현재 runtime 전체의 사실이 아니라 **의도된 guarded command path의 계약**으로 읽어야 한다. 의미 어댑터 다섯은 그 전제를 만족하지 않는다(`165-...` §8.1). 따라서 이후 admission 단계 설명도 guard를 통과하는 경로에 한정한다.
|
||||
|
||||
- `SyncRedisCommandExecutor`·`ReactiveRedisCommandExecutor`·`CommandPolicyGuard`·`CommandRequest`를 참조하는 파일 **0**(exit=1)
|
||||
- 타입 있는 API(`RedisValueOperations`·`RedisHashOperations`·`RedisKeyOperations`·`RedisOperations`)를 참조하는 파일 **0**(exit=1)
|
||||
@@ -17555,11 +17561,11 @@ private ExternalRequestContext externalRequest(ServerHttpRequest request) {
|
||||
|
||||
**권고** — 하나를 남긴다. `RequestLoggingFilter`가 `WebMvcRequestIdFilter`가 요청 속성에 넣은 값을 읽게 하면(`WebMvcRequestIdFilter.requestId(request)`가 이미 그 접근자다) 정책이 한 곳에 남고 MDC·로그·응답 헤더가 일치한다.
|
||||
|
||||
##### 32.2 P2 — forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고, 그것을 위해 쓴 Java 정책 421 LOC은 배선되지 않는다
|
||||
##### 32.2 P2 — 테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 trust boundary와 Java 정책 wiring은 미확인이다
|
||||
|
||||
`server.forward-headers-strategy=framework`(기본값)에서 Spring이 `X-Forwarded-Proto`·`X-Forwarded-Host`·`X-Forwarded-Port`·`X-Forwarded-Prefix`를 **보낸 피어가 누구든** 반영한다. 그 값이 `request.getURI()`를 바꾸고, 그것이 `ExternalRequestContext`가 되고(§31.4), 그것으로 `Location` 헤더와 페이지네이션 링크가 만들어진다.
|
||||
|
||||
스푸핑을 막는 것은 `nginxProxyTest` 레인이 증명하는 **Nginx 설정**이다:
|
||||
`nginxProxyTest` 레인에서는 다음 **Nginx 설정**이 클라이언트가 보낸 forwarded 헤더를 교체한다:
|
||||
|
||||
```
|
||||
NginxProxyContractIT:63 attackerCannotOverrideForwardedHost() X-Forwarded-Host: evil.example
|
||||
@@ -17569,15 +17575,15 @@ NginxProxyContractIT:143 clientCannotInjectAPrefix()
|
||||
// "X-Forwarded-Prefix is set per location, so a client's value is replaced."
|
||||
```
|
||||
|
||||
이 보증의 근거는 `nginxProxyTest/resources/nginx/proxy_headers.conf`가 location마다 헤더를 **덮어쓴다**는 사실이다. 애플리케이션은 검사하지 않는다.
|
||||
이 테스트 레인의 보증 근거는 `nginxProxyTest/resources/nginx/proxy_headers.conf`가 location마다 헤더를 **덮어쓴다**는 사실이다. 이 결과만으로 실제 운영 배포가 같은 설정을 사용한다고 보거나, 모든 운영 경로에서 애플리케이션 검사가 없다고 단정하지 않는다.
|
||||
|
||||
`TrustedProxyPolicy`(161줄, CIDR 기반 피어 허용목록)가 애플리케이션 쪽 검사를 위해 존재하고, 프로덕션에서 생성되지 않는다. testkit의 `ProxyFixtureController:53`이 `TrustedProxyPolicy.of("10.0.0.0/8", …)`를 직접 만들어 픽스처에 붙인다 — SS4·SS5와 같은 형태다.
|
||||
`TrustedProxyPolicy`(161줄, CIDR 기반 피어 허용목록)가 애플리케이션 쪽 검사를 위해 존재한다. searched direct reference와 확인한 자동설정 경로에서는 production construction을 찾지 못했고, testkit의 `ProxyFixtureController:53`은 `TrustedProxyPolicy.of("10.0.0.0/8", …)`를 직접 만들어 픽스처에 붙인다. reflection·framework lifecycle·외부 조립까지 포함한 전체 runtime wiring 부재는 이번 evidence로 확정하지 않았다.
|
||||
|
||||
**실패 시나리오** — 배포가 그 Nginx 설정을 쓰지 않거나(다른 인그레스, 서비스 메시, k8s 내부에서 파드 IP로 직접 도달), 인그레스를 우회하는 경로가 하나라도 있으면, 클라이언트가 `X-Forwarded-Host: evil.example`을 보내 그 요청이 만드는 모든 절대 URL을 자기 도메인으로 돌린다. 비밀번호 재설정 링크나 `Location` 헤더가 그 URL을 담으면 그대로 피싱 벡터가 된다.
|
||||
**조건부 실패 시나리오** — 운영 배포가 forwarded 헤더를 신뢰하면서도 앞단에서 값을 교체·검증하지 않는 경로가 있다면, 클라이언트가 `X-Forwarded-Host: evil.example` 같은 값을 주입해 절대 URL 생성에 영향을 줄 수 있다. 이번 검증은 그러한 운영 경로가 실제로 존재하는지까지 확인하지 않았다.
|
||||
|
||||
**이것을 방어로 쓰는 것 자체는 정당하다** — 인그레스에서 덮어쓰는 것이 표준 관행이다. 기록하는 것은 두 가지다: (1) 그 의존이 코드나 문서에 명시돼 있지 않고 레인의 `.conf` 파일에만 있다, (2) 애플리케이션 쪽 이중 방어로 쓰라고 421줄을 작성해 두고 연결하지 않았다.
|
||||
**인그레스에서 forwarded 헤더를 authoritative value로 교체하는 설계 자체는 가능하다.** 이번 evidence가 확인한 것은 테스트 `.conf`의 교체 동작과 Java trust-policy 코드의 존재다. 실제 운영이 이 설정에 의존하는지, Java 정책이 운영 lifecycle에서 정말 연결되지 않는지는 추가 조립·배포 evidence가 필요하다.
|
||||
|
||||
**권고** — `TrustedProxyPolicy`를 `forward-headers-strategy` 앞단에 배선하거나(피어가 목록 밖이면 forwarded 헤더를 버린다), 최소한 README에 "이 플랫폼은 인그레스가 `X-Forwarded-*`를 덮어쓴다고 전제한다"를 명시하고 `proxy` 패키지를 제거한다. 지금 상태는 그 전제를 아무 데도 적지 않은 채 그것을 대체할 코드를 갖고 있다.
|
||||
**권고** — 운영 trust boundary를 먼저 확정한다. 인그레스가 `X-Forwarded-*`를 authoritative value로 교체하는 구조라면 그 전제를 운영 문서와 계약 테스트에 명시한다. 애플리케이션에서도 피어 신뢰를 검증하려는 설계라면 `TrustedProxyPolicy`의 실제 lifecycle wiring을 확인하고 빠진 경로를 연결한다.
|
||||
|
||||
##### 32.3 P3/기록 — `ExternalRequestContext.prefix`가 항상 빈 문자열이고 `WebAuditPublisher`는 참조 0이다
|
||||
|
||||
@@ -18040,7 +18046,7 @@ web 쪽이 구조적으로 우월하다. `build.gradle`이 그 이유를 적는
|
||||
| P2 | 24.1 | 배선된 `CacheControlFilter`의 `no-store`가 배선된 조건부 읽기(ETag/304)를 무력화하고, 조정용 `cache` 패키지 310 LOC은 참조 0 |
|
||||
| P2 | 28.1 | `maxArrayElements`가 선언만 되고 강제되지 않으며 바이트 예산 백스톱(§16.1)도 없다 |
|
||||
| P2 | 32.1 | 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다 |
|
||||
| P2 | 32.2 | forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고 Java 정책 421 LOC은 미배선 |
|
||||
| P2 | 32.2 | 테스트 Nginx 설정은 forwarded 헤더를 교체한다. 운영 trust boundary와 Java 정책의 전체 lifecycle wiring은 이번 evidence로 확정하지 못함 |
|
||||
| P2 | 36.1 | 선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다 |
|
||||
| P3 ×9 | 8.2 · 20.3 · 24.3 · 25.3 · 25.4 · 36.2 · 44.1 · 44.2 · 28.3 외 | 죽은 메서드 · 구분자 기반 지문 · 미강제 한도 · 이름 불일치 · 참조 0인 138줄 · 실행되지 않는 시작 검증 등 |
|
||||
|
||||
@@ -22425,9 +22431,9 @@ they write inside the application's own transaction, against the application's o
|
||||
configuration.locations("classpath:db/migration/postgresql");
|
||||
```
|
||||
|
||||
조건부 스트림은 각자 자기 위치와 history table을 갖는다 — `NotificationSchemaStream.LOCATION = "classpath:db/migration/jpa/notification-platform"`, fileserver 스트림 등. **`db/migration/messaging`을 이름으로 부르는 main 코드는 저장소 전체에 0건이다.** 참조는 세 개의 IT(`InboxPostgresIT`, `OutboxPostgresIT`, `AdminOperationJournalPostgresIT`)가 자기 테스트 컨테이너에 직접 적용할 때뿐이다.
|
||||
조건부 스트림은 각자 자기 위치와 history table을 갖는다 — `NotificationSchemaStream.LOCATION = "classpath:db/migration/jpa/notification-platform"`, fileserver 스트림 등. searched direct reference 기준으로 **`db/migration/messaging`을 이름으로 지정하는 main 코드는 찾지 못했다.** 검색된 참조는 세 개의 IT(`InboxPostgresIT`, `OutboxPostgresIT`, `AdminOperationJournalPostgresIT`)가 자기 테스트 컨테이너에 직접 적용하는 경로다.
|
||||
|
||||
즉 `messaging_outbox` · `messaging_inbox` · admin operation journal 테이블은 **출하 배포 어디에서도 생성되지 않는다.** §7.1과 합치면 일관은 있다 — repository bean이 없으니 테이블도 필요 없다. 그러나 `persistence-jpa` leaf가 같은 모양의 결함을 세 번 고치고 그 이력을 javadoc에 남겨 두었다:
|
||||
이 정적 검색만으로 외부 설정·resource scanning·reflection·framework convention을 모두 배제할 수는 없다. 따라서 여기서 확정할 수 있는 것은 출하 코드 안의 직접 wiring을 찾지 못했다는 범위까지다. repository bean이 현재 확인한 자동설정 경로에서 만들어지지 않는다는 §7.1 결과와 함께 보면 runtime assembly가 닫혀 있지 않다는 신호는 강하지만, ‘어떤 출하 배포에서도 테이블이 생성되지 않는다’고 일반화하지 않는다. 그러나 `persistence-jpa` leaf가 같은 모양의 결함을 세 번 고치고 그 이력을 javadoc에 남겨 두었다:
|
||||
|
||||
> "`PostgreSqlSameStoreInboxAdapter` ... its tables live only in `db/migration/jpa/inbox`. **The bean existed, its tables did not**, and the failure arrived either at ..."
|
||||
> (같은 문장이 `PostgreSqlImmutableOutboxAppendAdapter`, `PostgreSqlPollingDeliveryAdapter`에도 있다)
|
||||
@@ -22444,7 +22450,7 @@ messaging-outbox-jdbc-postgresql : V1__messaging_outbox.sql
|
||||
V4__messaging_outbox_canonical_metadata.sql
|
||||
```
|
||||
|
||||
**`V2`가 두 개다.** 두 jar가 한 classpath에 있고 Flyway가 `classpath:db/migration/messaging`을 스캔하면 "Found more than one migration with version 2"로 실패한다. 지금 실패하지 않는 유일한 이유는 (a) — 아무도 그 위치를 Flyway에 주지 않기 때문이다.
|
||||
**`V2`가 두 개다.** 두 jar가 한 classpath에 있고 Flyway가 `classpath:db/migration/messaging`을 같은 location으로 스캔하면 duplicate version 오류가 된다. 현재 정적 검색에서는 그 location을 직접 지정하는 main 코드를 찾지 못했지만, 이것을 현재 실패하지 않는 ‘유일한 이유’로 단정하지 않는다. framework/external configuration 경로는 이번 정적 검색으로 닫지 못했다.
|
||||
|
||||
각 leaf의 IT는 자기 jar의 리소스만 보므로 이 충돌을 재현하지 못한다 — `InboxPostgresIT:199`는 `V2__messaging_inbox.sql`을 파일명으로 직접 읽고, `OutboxPostgresIT:249`는 자기 디렉터리를 나열한다. **두 leaf를 한 classpath에 올린 상태를 검증하는 테스트가 없다.**
|
||||
|
||||
@@ -23056,7 +23062,7 @@ grpc-spring-boot-starter/src/test/.../GrpcPlatformStartupValidatorTest.java (1
|
||||
grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java (선언 자신)
|
||||
```
|
||||
|
||||
**main 참조 0.** 클래스는 `final` + `private` 생성자 + static 메서드(`violations(...)`, `requireValid(...)`)이므로 bean이 될 수도 없다 — 누군가 `requireValid`를 호출해야 하고, 호출하는 곳이 없다.
|
||||
searched direct reference 기준으로 production caller를 찾지 못했다. 클래스가 `final` + `private` 생성자 + static 메서드(`violations(...)`, `requireValid(...)`)인 것도 자동 bean 등록 경로가 아니라는 강한 신호다. 다만 direct reference 0만으로 reflection·framework discovery까지 전부 배제했다고 말하지 않는다. 실제 실행 여부는 assembly/lifecycle 경로와 boot evidence를 함께 확인해야 한다.
|
||||
|
||||
**실행되지 않는 규칙이 13개다.** validator 본문을 읽어 전수 확인했다:
|
||||
|
||||
@@ -23076,7 +23082,7 @@ grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java (
|
||||
|
||||
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 거부"는 methods 그룹의 네 번째 규칙(`!policy.rpcType().stable()`)이고, §2.2의 runtime 강제는 advanced isolation 그룹의 유일한 규칙이다. **둘 다 실행되지 않는다.**
|
||||
|
||||
이 형태는 이 저장소에서 네 번째다 — 모듈 14 §44.2(`WebPlatformStartupValidator`), 모듈 17 §4.1(`WebSocketPlatformStartupValidator`), 모듈 19 §3.5(`KafkaTransactionProfileValidator`), 그리고 여기. 그리고 모듈 18에서 확립한 규칙이 다시 성립한다 — **시작 검증기가 도는지 여부는 그 능력에 자동설정 루트가 있는지와 일치한다**. 여기서는 루트가 **있는데도** 검증기를 부르지 않는 첫 사례다.
|
||||
이 형태는 이 저장소에서 반복해서 나타난다 — 모듈 14 §44.2(`WebPlatformStartupValidator`), 모듈 17 §4.1(`WebSocketPlatformStartupValidator`), 모듈 19 §3.5(`KafkaTransactionProfileValidator`), 그리고 여기다. 여기서 일반화할 수 있는 규칙은 ‘auto-configuration root 존재 여부와 실행 여부가 일치한다’가 아니다. **시작 검증기의 실행 여부는 direct caller뿐 아니라 `@Bean`/component scan/auto-configuration, lifecycle callback, event/post processor, framework discovery와 실제 boot evidence까지 따라가서 확인한다.** 이 사례에서는 확인한 auto-configuration이 검증기를 직접 부르지 않는다는 사실까지 확정했다.
|
||||
|
||||
**채택 시점 실패 시나리오.** 팀이 `runtime_memberships`에 런타임을 추가하고 `ca-skeleton.grpc.platform.enabled=true`로 켠다. Stable catalog에 client-streaming 메서드를 하나 등록한다(Stable 범위 밖이라는 것을 모른 채). 부팅은 성공한다. 그 메서드는 Stable이 보장하지 않는 경로로 실행되고, `grpc-advanced-streaming`의 세션·중복제거·체크포인트 기계는 조립돼 있지 않다. 거부했어야 할 검증기는 존재하고, 테스트도 12개 통과하며, 호출되지 않는다.
|
||||
|
||||
@@ -24404,6 +24410,8 @@ private static void appendField(StringBuilder canonical, String value) {
|
||||
*/
|
||||
```
|
||||
|
||||
위 javadoc의 ‘one byte at a time’ 표현은 timing 공격의 위험을 설명하려는 문구지만, Java API가 보장하는 성질보다 강하게 읽지 않는다. 핵심은 입력 내용이나 common prefix에 따라 일찍 끝나는 비교를 피하고, JDK `MessageDigest.isEqual`이 문서화한 comparison timing property를 사용하는 것이다.
|
||||
|
||||
세 가지가 코드로 지켜진다.
|
||||
|
||||
```java
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"assetKey": "rls-three-preconditions-diagram",
|
||||
"kind": "diagram",
|
||||
"svg": "assets/diagrams/rls-three-preconditions.svg",
|
||||
"svg": "assets/diagrams/rls-three-preconditions/rls-three-preconditions.svg",
|
||||
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
||||
"svgSha256": "8c5d78987d966d8e464ea5574e4353a7db57052afef57512ef8dc75179f8f771",
|
||||
"claim": "PostgreSQL RLS 가 실제로 격리하려면 정책 활성과 OWNER 강제와 런타임 롤의 BYPASSRLS 부재가 동시에 참이어야 한다",
|
||||
"svgSha256": "e270994f7481a8709f4b8841f310464258535fd30e9cacbed5b66c6ae2c51093",
|
||||
"claim": "PostgreSQL RLS 적용 여부는 RLS 활성 상태, superuser/BYPASSRLS 우회, table owner와 FORCE RLS, applicable policy 유무를 순서대로 구분하며 policy 대상 role에 applicable policy가 없으면 default deny가 적용된다",
|
||||
"authoredAt": "2026-09-01T08:56:28+00:00",
|
||||
"constraints": "상자 7개 이하 · 라벨은 이름 · 글자 2종 · 색 단독 의미 없음 · 숫자 없음"
|
||||
}
|
||||
|
||||
+5
-5
@@ -47,7 +47,7 @@ mongo 플랫폼 자동설정에서 트랜잭션 타입과 인과 세션 타입
|
||||
|
||||
데이터 위험은 없다. 없는 것을 쓸 수는 없기 때문이다. 이 플래그가 무엇을 켜는지 적힌 곳이 없고, 시작 검증이 통과한 것과 실행체가 조립된 것을 구분해 주는 신호도 없다.
|
||||
|
||||
수정은 셋 중 하나다. 트랜잭션 실행체를 조건부 빈으로 조립하거나, 플래그가 무엇을 켜는지를 문서에 적거나, 같은 생성자의 required-secondaries 처럼 값을 예외로 거부하는 것이다.
|
||||
수정 선택지는 셋이다. 트랜잭션 실행체를 조건부 빈으로 조립할 수 있고, 플래그가 실제로 무엇을 켜는지 문서화할 수 있으며, 같은 생성자의 `required-secondaries`처럼 지원하지 않는 값을 예외로 거부할 수도 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -87,9 +87,9 @@ Spring Boot : 4.0.8
|
||||
|
||||
## 그 검사 자체가 조건부다
|
||||
|
||||
검증기를 돌리는 빈은 `MongoTopologyProbe` 에 조건되어 있다. 그리고 이 저장소는 프로브를 출하하지 않는다 — 그 자리 javadoc 이 직접 적는다. 프로브는 연결을 소유한 조립 루트가 살아 있는 데이터 평면 클라이언트로 만드는 것이고, 그것은 fork 의 결정이라는 것이다.
|
||||
검증기를 돌리는 빈은 `MongoTopologyProbe`에 조건되어 있다. 이 저장소가 프로브를 출하하지 않는다는 사실은 `MongoTopologyProbe`의 javadoc에 적혀 있다. 연결을 소유한 조립 루트가 실제 데이터 평면 클라이언트로 프로브를 만들며, javadoc은 그 선택을 fork의 책임으로 둔다.
|
||||
|
||||
프로브 없이 플랫폼 프로파일만 설정한 배포는 별도의 빈이 시작을 거부한다. 그 예외 문구가 이유를 적는다 — 검사가 하필 자기 부재를 보고해야 할 바로 그 빈에 조건되어 있어서, 프로브 없이 시작하면 토폴로지도 Stable API 수준도 자격의 실제 능력도 아무것도 검사하지 않은 채 조용히 지나간다는 것이다.
|
||||
프로브 없이 플랫폼 프로파일만 설정한 배포는 별도의 빈이 시작을 거부한다. 예외 문구는 검증 빈 자체가 프로브에 조건되어 있어, 프로브가 없으면 토폴로지·Stable API 수준·자격의 실제 능력을 검사할 경로가 열리지 않는다고 설명한다.
|
||||
|
||||
그래서 이 플래그가 시작 요구를 만드는 것은 fork 가 프로브와 보안 프로파일과 관리 자격 참조와 스키마 버전 범위를 모두 공급했을 때다. 셰이프 그대로의 이 저장소에서는 검사가 열리지 않는다.
|
||||
|
||||
@@ -99,7 +99,7 @@ Spring Boot : 4.0.8
|
||||
|
||||
`profiles` 의 널은 빈 맵으로 흡수한다. `change-streams` 는 무엇이 오든 거짓으로 덮어쓴다. `required-secondaries` 에 음수가 오면 예외를 던져 거부한다.
|
||||
|
||||
`transactions` 는 이 생성자에 아예 등장하지 않는다. 값이 그대로 보존되는 이유가 그것이다.
|
||||
`transactions`는 이 생성자에 아예 등장하지 않는다. 생성자가 이 값을 덮어쓰거나 거부하지 않으므로 입력값이 그대로 보존된다.
|
||||
|
||||
덮어쓰기 쪽만 참으로 설정해도 예외도 로그도 발생하지 않는다. 그 줄에 붙은 주석은 값을 무시하면 적용된 것처럼 보이게 되니 저장하지 않고 거부한다고 적는데, 예외로 거부하는 것은 `required-secondaries` 가 하는 일이고 여기서 일어나는 것은 조용한 덮어쓰기다.
|
||||
|
||||
@@ -113,7 +113,7 @@ Spring Boot : 4.0.8
|
||||
|
||||
## 두 스위치가 반대 방향으로 같은 곳에서 끊겼다
|
||||
|
||||
트랜잭션은 스위치가 살아서 요구를 만드는데 그 요구를 갚을 코드가 조립되지 않는다. change stream 은 코드가 조립되는데 스위치가 죽어 있다. 방향은 반대이고 끊긴 자리는 같다.
|
||||
트랜잭션은 스위치가 요구를 만들지만 그 요구를 수행할 코드가 조립되지 않는다. change stream은 실행 코드가 조립되는데 스위치가 생성자에서 꺼진다. 방향은 반대지만 둘 다 설정 플래그와 실제 조립 경로가 분리되어 있다.
|
||||
|
||||
## 남는 것은 데이터 위험이 아니다
|
||||
|
||||
|
||||
+6
-6
@@ -65,10 +65,10 @@ kafka-clients : 4.1.2
|
||||
## 재현 조건
|
||||
|
||||
1. KafkaProfileValidator 에서 운영 프로파일에 거는 규칙 둘을 읽는다.
|
||||
2. 그 검증기를 설정에서 컴파일된 프로파일에 돌리는 자리와, 그것이 기동의 어느 단계인지 확인한다.
|
||||
2. 설정에서 컴파일한 프로파일을 검증기에 넘기는 호출 경로와, 그 호출이 기동의 어느 단계에서 실행되는지 확인한다.
|
||||
3. 그 거부를 고정하는 테스트를 찾는다.
|
||||
4. 두 조립부를 켜는 프로퍼티를 전부 찾고 출하 기본값을 읽는다.
|
||||
5. 프로덕션에서 KafkaProducer 를 만드는 자리를 전부 세고, 각 설정 맵의 원문을 그대로 읽는다.
|
||||
5. 프로덕션에서 `KafkaProducer`를 생성하는 코드를 전부 찾고, 각 생성 코드가 넘기는 설정 맵을 그대로 읽는다.
|
||||
6. security.protocol 을 상수명과 리터럴 양쪽으로 저장소 전체에서 찾는다.
|
||||
7. KafkaSecurityConfigurer.configure 가 자격 종류마다 무엇을 넣고 어디서 던지는지 읽는다.
|
||||
8. 그 클래스의 빈 팩토리에 붙은 조건을 따라가고, 사슬 끝의 인터페이스를 구현하는 main 클래스를 센다.
|
||||
@@ -95,7 +95,7 @@ kafka-clients : 4.1.2
|
||||
|
||||
## 조립되는 KafkaProducer 두 곳에 security.protocol 이 없다
|
||||
|
||||
프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이다.
|
||||
프로덕션에서 `KafkaProducer`를 생성하는 코드는 둘이다.
|
||||
|
||||
`KafkaMessagingAutoConfiguration.messagingKafkaProducer` 가 `:153` 에서 만든다. `:139` 에서 빈 `HashMap` 을 열고 `:140`\~`:152` 에 넣는 것은 `BOOTSTRAP_SERVERS_CONFIG`, 직렬화기 둘, `ACKS_CONFIG`, `ENABLE_IDEMPOTENCE_CONFIG` 다섯이다.
|
||||
|
||||
@@ -122,7 +122,7 @@ kafka-clients : 4.1.2
|
||||
|
||||
이 클래스를 만드는 팩토리는 `KafkaMessagingAutoConfiguration:109` 에 있는데, 조건이 두 단이다. `:107` 이 `@ConditionalOnBean(CredentialRuntimeRegistry.class)` 이고, 그 레지스트리를 내놓는 `MessagingCoreAutoConfiguration:317` 은 `:315` 의 `@ConditionalOnBean(CredentialProvider.class)` 뒤에 있다. 그런데 `CredentialProvider` 를 구현하는 main 클래스가 0 건이다. 유일한 구현은 `CredentialRuntimeRegistryTest:21` 의 시험용 클래스다.
|
||||
|
||||
그래서 출하되는 애플리케이션에서 이 빈은 만들어지지도 않는다. 만들어졌다 해도 받을 곳이 없다 — 그것을 파라미터나 필드로 받는 프로덕션 코드가 0 건이고, `getBean` 과 `ObjectProvider` 와 빈 이름 문자열로 가져가는 자리도 0 건이다.
|
||||
그래서 확인한 출하 조립 경로에서는 이 빈이 만들어지지 않는다. searched direct reference 기준으로 파라미터나 필드로 받는 프로덕션 코드가 0건이고, `getBean`·`ObjectProvider`·빈 이름 문자열로 조회하는 코드도 찾지 못했다.
|
||||
|
||||
## 조립 테스트가 설정 맵의 키를 단언하지 않는다
|
||||
|
||||
@@ -130,9 +130,9 @@ kafka-clients : 4.1.2
|
||||
|
||||
실 브로커에 붙는 `MessagingLiveRoundTripQualificationTest:66` 은 `new KafkaContainer("apache/kafka:4.1.0")` 를 쓴다. 보안 설정이 하나도 없는 컨테이너다. 그래서 이 테스트가 확인한 왕복은 보안 설정이 없는 브로커와의 왕복이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
## 원문과 다른 생산자 수
|
||||
|
||||
원문 §17.1 은 조립되는 생산자를 `messagingKafkaProducer` 하나로 적었다. 프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이고, `KafkaSenderConfig.kafkaSeamProducer` 도 같은 프로퍼티 조건에서 조립되며 그쪽에도 보안 키가 없다.
|
||||
원문 §17.1은 조립되는 생산자를 `messagingKafkaProducer` 하나로 적었다. 실제 프로덕션 생성 코드는 둘이며, `KafkaSenderConfig.kafkaSeamProducer`도 같은 프로퍼티 조건에서 조립된다. 이 두 번째 생산자 설정에도 보안 키가 없다.
|
||||
|
||||
원문이 `KafkaSecurityConfigurer` 가 만드는 성분을 다섯으로 센 것도 자격 종류를 하나로 놓았을 때다. `configure` 는 자격 종류마다 다른 SASL 메커니즘을 넣고, 상호 TLS 에서는 `sasl.jaas.config` 없이 `NONE` 만 넣으며, OAuth2 와 Nkey 에서는 아무것도 넣지 않고 던진다.
|
||||
|
||||
|
||||
+3
-3
@@ -26,13 +26,13 @@ messaging 플랫폼의 outbox 사슬은 조건이 참이 될 수 없어 조립
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
두 스택 중 하나만 컨텍스트에 들어간다는 것이 이 규칙의 사례다.
|
||||
이 사건에서는 두 스택 중 하나만 컨텍스트에 들어갔고, 빈 선언만으로 실제 조립 여부를 판단할 수 없었다.
|
||||
- **@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
|
||||
조건이 참이 될 수 없다는 관측이 이 규칙으로 이어진다.
|
||||
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
|
||||
조립하는 쪽을 읽지 않으면 중복을 조건 결함으로 오진한다.
|
||||
- **high-water mark가 본 위치를 뜻해서 재전달된 변경이 영구히 사라졌다**
|
||||
같은 신뢰성 계열에서 조용한 실패가 나타난 다른 사례다.
|
||||
같은 신뢰성 계열에서 high-water mark 해석이 재전달 이벤트를 삼킨 별도 실패를 다룬다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -66,7 +66,7 @@ messaging 스타터의 MessagingReliabilityAutoConfiguration 은 outbox 와 inbo
|
||||
구현 : JdbcOutboxRepository 2,276 LOC. 스프링 스테레오타입이 없다
|
||||
조립 : MessagingReliabilityAutoConfiguration 81행의 조건 뒤
|
||||
|
||||
측정으로 확정한 것은 이렇다. main 코드에서 JdbcOutboxRepository 나 JdbcInboxRepository 나 OutboxEnvelopeFactory 를 생성하는 곳이 0 이고, app-bootstrap 에서 관련 빈을 만드는 곳도 0 이다. OutboxEnvelopeFactory 를 @Bean 으로 만드는 곳은 스타터의 테스트 하나뿐이다.
|
||||
정적 검색에서는 main 코드가 `JdbcOutboxRepository`·`JdbcInboxRepository`·`OutboxEnvelopeFactory`를 직접 생성하지 않았고, app-bootstrap에서도 관련 빈 생성 코드를 찾지 못했다. `OutboxEnvelopeFactory`를 `@Bean`으로 만드는 코드는 스타터 테스트 하나에서만 확인했다.
|
||||
|
||||
이 차이가 중요한 이유는 수정 방향이 반대이기 때문이다. 조건이 만족되지 않는다고 읽으면 app-bootstrap 에 빈을 등록하는 수정이 된다. outbox 가 둘이라고 읽으면 어느 쪽이 정본인지 먼저 정해야 하는 문제가 된다.
|
||||
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-guard-is-on-and-the-service-is-not
|
||||
title: 가드와 journal 과 durability 검증기는 켜지고, 부를 서비스가 없었다
|
||||
topic: assembly-ownership
|
||||
topicName: 조립 소유권 — 통제와 그 의존을 같은 곳이 소유하기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:the-guard-is-on-and-the-service-is-not
|
||||
source:
|
||||
- final/document.md#a19
|
||||
- final/document.md#5-4
|
||||
- final/document.md#a19 §8.1
|
||||
- final/document.md#a19 §8.2
|
||||
---
|
||||
|
||||
# 가드와 journal 과 durability 검증기는 켜지고, 부를 서비스가 없었다
|
||||
|
||||
admin 경로의 보호 장치는 조립되는데 파괴적 작업을 수행할 서비스는 자동설정하지 않는 구조가 함께 존재한다. 현재 판정은 정적 조립 분석에 근거하며 실제 admin 활성 부팅은 재현하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **파괴적 admin 작업은 자동설정하지 않는다**
|
||||
이 부재를 프로젝트가 의도한 결정으로 설명한다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
보호 장치와 실제 실행 서비스를 분리해서 확인하는 기준이다.
|
||||
|
||||
## 문제
|
||||
|
||||
가드·journal·durability 검증 같은 주변 통제는 자동설정 경로에 올라가지만, 실제 파괴 작업을 수행하는 admin 서비스는 같은 방식으로 제공되지 않는다.
|
||||
|
||||
정적 분석에서 서비스 직접 참조와 조립 경로를 찾지 못했고, SSOT는 이 부재를 의도된 안전 경계로 기록한다. 문제는 보호 장치가 존재한다는 사실만 보고 admin 기능 전체가 제공된다고 오해할 수 있다는 점이다.
|
||||
|
||||
## 결론
|
||||
|
||||
이 구조는 “가드가 켜졌으니 서비스도 켜졌다”는 신호가 아니다. 파괴적 실행 서비스는 애플리케이션이 명시적으로 제공해야 하며, 플랫폼 자동설정은 그 서비스를 만들어 주지 않는다.
|
||||
|
||||
현재 머신에서 source repository를 다시 대조하지 못했으므로, 실제 admin 활성 부팅까지 확인했다고 주장하지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
sourceRevision : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
현재 source repository 재대조 : UNVERIFIABLE
|
||||
확인 방식 : SSOT에 기록된 조립 경로와 정적 참조 분석
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. SSOT의 admin 자동설정 절에서 생성되는 guard·journal·validator를 확인한다.
|
||||
2. 파괴적 admin service 타입의 main 직접 참조와 생성 경로를 확인한다.
|
||||
3. 자동설정이 실행 서비스를 직접 제공하는지 분리해서 본다.
|
||||
4. 실제 부팅 재현은 source repository가 있는 환경에서 별도로 수행한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 보호 장치와 실행 서비스는 같은 능력이 아니다
|
||||
|
||||
가드는 호출을 허용할지 판정하고 journal은 실행 이력을 남긴다. durability validator는 전제 조건을 검사한다. 이 셋이 조립돼도 실제 파괴 작업을 수행할 service가 자동으로 생기지는 않는다.
|
||||
|
||||
## 부재를 의도된 경계로 읽는다
|
||||
|
||||
SSOT는 destructive admin operation을 플랫폼이 자동설정하지 않는 방향을 별도 Decision 후보로 분리한다. 따라서 서비스 부재는 현재 분석에서 우연한 누락으로만 다루지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제로 admin 기능을 켠 애플리케이션을 부팅해 “호출 대상이 없다”는 런타임 결과를 재현하지 않았다. 현재 결론은 sourceRevision에 대한 기존 정적 분석 범위다.
|
||||
|
||||
<!-- body:end -->
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: thirteen-startup-rules-never-run
|
||||
title: 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
|
||||
title: 확인한 자동설정 경로가 시작 검증기 13개 규칙을 호출하지 않는다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
@@ -17,14 +17,14 @@ source:
|
||||
- 원본 분석 절은 final/document.md#5-3 · final/document.md#a20 §3.1 이다.
|
||||
---
|
||||
|
||||
# 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
|
||||
# 확인한 자동설정 경로가 시작 검증기 13개 규칙을 호출하지 않는다
|
||||
|
||||
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있다. 이 검증기를 부르는 것은 자기 테스트뿐이고, 유일한 자동설정 지점은 부르지 않는다.
|
||||
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있다. searched direct reference 기준으로 호출은 자기 테스트에서 확인했고, `GrpcPlatformAutoConfiguration`은 이 검증기를 직접 호출하지 않는다. 이 결과만으로 다른 lifecycle·framework discovery 경로까지 없다고 단정하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
|
||||
이 사례에서 끌어낸 확인 절차다.
|
||||
- **시작 검증기는 실제 lifecycle과 assembly 경로에서 실행 여부를 확인한다**
|
||||
이 사례의 direct-call 결과를 runtime 전체 미실행으로 과장하지 않기 위해 만든 확인 절차다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
검증기가 존재한다는 것과 그것이 도는 것은 별개다.
|
||||
|
||||
@@ -32,19 +32,19 @@ gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고
|
||||
|
||||
GrpcPlatformStartupValidator 는 188줄이고 violations 목록에 13개 항목을 추가한다. TLS 요구, 실행기 풀 크기, 채널 프로파일, 자격증명 누출 등을 검사한다.
|
||||
|
||||
이 검증기를 호출하는 곳을 저장소 전체에서 찾으면 자기 테스트 GrpcPlatformStartupValidatorTest 하나뿐이다.
|
||||
searched direct reference에서는 `GrpcPlatformStartupValidatorTest`의 테스트 호출만 확인된다.
|
||||
|
||||
같은 패키지의 GrpcPlatformAutoConfiguration 은 106줄이고 @Bean 이 9개인데, 그중 어느 것도 이 검증기를 부르지 않는다.
|
||||
|
||||
## 결론
|
||||
|
||||
검증기는 정확하고 잘 테스트되어 있으며 돌지 않는다.
|
||||
검증기는 규칙과 단위 테스트를 갖고 있지만, 확인한 자동설정 경로에서는 호출되지 않는다.
|
||||
|
||||
이 판정은 gRPC 블록 전체가 어떤 런타임 컴포지션에도 속하지 않는다는 더 큰 사실 안에 있다. 그 상태는 저장소가 문서로 인정하고 있으며 결함이 아니다. 다만 이 검증기의 경우, 블록이 배포되기 시작하는 날에도 자동으로 돌기 시작하지는 않는다는 점이 남는다. 조립 지점이 그것을 부르지 않기 때문이다.
|
||||
이 판정은 gRPC 블록 전체가 현재 확인한 런타임 컴포지션에 속하지 않는다는 범위 안에 있다. 저장소 문서도 그 상태를 인정하므로 현재 미실행 자체를 결함으로 보지 않는다. 다만 블록을 배포하기 시작할 때는 검증기를 lifecycle에 명시적으로 연결해야 한다. 확인한 자동설정 경로는 이 검증기를 호출하지 않는다.
|
||||
|
||||
13개 규칙은 테스트로 고정되어 있으므로 회귀는 잡힌다. 잡히지 않는 것은 그 규칙이 실행 시점에 적용되는가다.
|
||||
|
||||
확인 절차로 일반화하면 이렇다. 시작 검증기가 실제로 도는지는 그 능력에 자동설정 루트가 있고 그 루트가 검증기를 부르는지와 일치한다. 검증기 파일의 존재나 그 테스트의 통과는 답이 아니다.
|
||||
확인 절차로 일반화하면 direct caller에서 멈추지 않는다. `@Bean`·component scan·auto-configuration, lifecycle callback, application event·post processor, framework discovery를 차례로 확인하고, 실제 부팅이 가능한 환경에서는 condition report와 시작 로그까지 본다. 검증기 파일과 테스트의 존재만으로 runtime 실행 여부를 판정하지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -73,13 +73,13 @@ validator가 5개 그룹 13개 규칙을 갖고(transport·security 4 / executor
|
||||
:::evidence key="thirteen-startup-rules-never-run" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 유일한 조립 지점이 부르지 않는다
|
||||
## 확인한 자동설정 경로는 validator를 부르지 않는다
|
||||
|
||||
자동설정은 `@Bean` 9개를 만들면서 이 validator를 부르지 않고, static 메서드라 빈이 될 수도 없다.
|
||||
자동설정은 `@Bean` 9개를 만들면서 이 validator를 직접 부르지 않는다. validator 자체는 static 메서드 기반이라 일반적인 component bean 등록 경로도 보이지 않는다. 다만 다른 lifecycle·framework discovery 경로까지 이번 정적 검색으로 배제하지 않는다.
|
||||
|
||||
## 두 개의 강제가 이 하나를 통해서만 성립한다
|
||||
|
||||
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 startup을 거부한다"와 §2.2의 runtime 강제 둘 다이므로, 둘 다 실행되지 않는다.
|
||||
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 startup을 거부한다"와 §2.2의 runtime 강제는 이 validator의 규칙에 의존한다. 확인한 자동설정 경로만 보면 이 규칙을 호출하지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+2
-2
@@ -28,9 +28,9 @@ source:
|
||||
## 관계
|
||||
|
||||
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
|
||||
경로가 바뀌는 지점에서 소유권이 끊긴 사례다.
|
||||
스캔에서 제외된 뒤 자동설정도 여섯 컴포넌트를 만들지 않아 실행 컨텍스트에 들어오지 않았다.
|
||||
- **넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다**
|
||||
같은 형태가 퍼시스턴스 리프에서 나타난 사례다.
|
||||
퍼시스턴스 리프에서도 스캔 범위를 좁힌 뒤 여덟 컴포넌트의 조립 경로가 사라졌다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
이 개념을 확인 절차로 옮긴 규칙이다.
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: destructive-admin-operations-are-not-autoconfigured
|
||||
title: 파괴적 admin 작업은 자동설정하지 않는다
|
||||
topic: assembly-ownership
|
||||
topicName: 조립 소유권 — 통제와 그 의존을 같은 곳이 소유하기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
source:
|
||||
- final/document.md#a19
|
||||
- final/document.md#10-3
|
||||
- final/document.md#5-4
|
||||
- final/document.md#a19 §8.1
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# 파괴적 admin 작업은 자동설정하지 않는다
|
||||
|
||||
플랫폼은 파괴적 admin 작업의 보호 장치와 계약을 제공할 수 있지만, 실제 실행 서비스까지 기본 빈으로 만들지는 않는다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **가드와 journal 과 durability 검증기는 켜지고, 부를 서비스가 없었다**
|
||||
보호 장치와 실행 주체가 분리된 현재 조립 결과를 보여 준다.
|
||||
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
|
||||
능력 활성 여부를 조립 구조로 제한하는 기준이다.
|
||||
|
||||
## 결정문
|
||||
|
||||
파괴적 admin operation의 실제 실행 서비스는 자동설정하지 않는다. 애플리케이션이 사용 의도를 명시하고 필요한 의존성을 제공한 경우에만 별도 조립 경로에서 만든다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
파괴 작업은 잘못 노출됐을 때 복구 비용이 크다. 플랫폼이 클래스패스와 설정만 보고 실행 서비스를 자동 생성하면, 사용자가 기능을 선택하지 않았는데도 파괴 권한이 생길 수 있다.
|
||||
|
||||
가드와 journal을 자동설정하는 것은 실행 서비스 자동설정과 다르다. 보호 장치는 실행 서비스가 제공되는 경우 적용할 공통 규칙이고, 서비스 생성은 채택 애플리케이션의 책임으로 둔다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것 : 기능을 쓰는 애플리케이션이 명시적인 wiring을 추가해야 한다.
|
||||
|
||||
얻는 것 : 라이브러리를 추가했다는 이유만으로 파괴적 operation이 실행 가능한 상태가 되지 않는다.
|
||||
|
||||
얻는 것 : 자동설정의 존재와 admin 권한의 존재를 구분할 수 있다.
|
||||
+1
-1
@@ -37,7 +37,7 @@ source:
|
||||
|
||||
그리고 자식을 컴포넌트 스캔에서 빼는 것이 이 구조의 나머지 절반이다. 스캔이 자식 설정을 독립적으로 발견하면 루트를 우회하기 때문이다.
|
||||
|
||||
임포트 필터는 권한을 잃고 도구로 남는다. 프레임워크 자신의 자동설정을 후보 집합에서 빼는 일은 어떤 프로젝트 조건보다 먼저 일어나야 하므로 그 자리가 필요하지만, 능력이 켜졌는지 판정하는 것은 그 필터의 일이 아니다.
|
||||
임포트 필터는 마스터 스위치를 판정하지 않고 프레임워크 자동설정을 후보 집합에서 제거하는 역할만 맡는다. 이 제거는 프로젝트 조건을 평가하기 전에 실행되어야 하지만, 능력 활성 여부는 루트 자동설정이 판정한다.
|
||||
|
||||
## 영향
|
||||
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 애너테이션은 후보를 만들 뿐이다
|
||||
1. 애너테이션은 빈 후보만 등록한다
|
||||
Component 나 Repository 나 Bean 은 이 클래스가 빈이 될 수 있다는 뜻이지 빈이라는 뜻이 아니다. 스캔 범위 밖이거나 조건이 거짓이거나 소유자가 없으면 후보로 끝난다.
|
||||
|
||||
2. 이름은 아무것도 보장하지 않는다
|
||||
@@ -32,8 +32,8 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
4. 확인은 도달 경로로 한다
|
||||
세 경로 중 어느 것이 이 클래스를 소유하는지 묻는다. 스캔이면 범위와 제외를, 자동설정이면 imports 파일과 조건을, 명시 조립이면 그 생성 지점을 확인한다.
|
||||
|
||||
5. main 참조 0 은 강한 신호다
|
||||
프로덕션 소스에서 그 타입을 참조하는 파일이 자기 자신뿐이면, 테스트만 그것을 쓴다는 뜻이다.
|
||||
5. searched direct reference 0부터 확인하되 거기서 멈추지 않는다
|
||||
프로덕션 소스의 직접 참조 검색에서 선언 자신 외의 사용처를 찾지 못했다는 뜻까지가 증거다. 이것만으로 runtime 미사용을 확정하지 않는다. component scan, auto-configuration imports와 `@Bean`, lifecycle callback, event/post processor, ServiceLoader·reflection·configuration/resource discovery처럼 정적 직접 참조에 잡히지 않는 경로도 확인한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
# @ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다
|
||||
|
||||
조건부 빈을 선언해 두고 그 조건을 만족시킬 수 있는 경로가 있는지 확인하지 않아, 능력 전체가 조용히 없는 상태를 막는다. Spring 은 조건 불만족을 정상 동작으로 보므로 로그에도 액추에이터에도 신호가 남지 않는다.
|
||||
조건부 빈을 선언해 두고 그 조건을 만족시킬 수 있는 경로가 있는지 확인하지 않아 능력 전체가 조립되지 않는 상태를 막는다. 조건 불만족이 application failure나 health failure로 자동 승격되지 않을 수는 있지만, condition evaluation evidence 자체가 사라지는 것은 아니다. Spring Boot의 `ConditionEvaluationReport`와, endpoint가 노출된 경우 Actuator `/actuator/conditions`에서 match 여부와 이유를 확인할 수 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
@@ -29,8 +29,8 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
3. 플랫폼이 제공하지 않겠다고 선언한 경우 질문을 바꾼다
|
||||
애플리케이션이 제공해야 하는 계약이라면, 물을 것은 조건이 아니라 출하 애플리케이션이 그 계약을 이행하는가다.
|
||||
|
||||
4. 조건 불만족은 오류로 보고되지 않는다
|
||||
Spring 은 조건부 빈이 조건을 만족하지 못하는 것을 정상 동작으로 본다. 로그에도 액추에이터에도 신호가 없다.
|
||||
4. 조건 불만족과 관측 가능성을 구분한다
|
||||
조건이 맞지 않았다는 사실이 application failure나 health failure로 자동 승격되지 않을 수 있다. 그렇다고 condition evaluation evidence가 없어지는 것은 아니다. `ConditionEvaluationReport`와, endpoint가 노출된 경우 `/actuator/conditions`에서 positive/negative match와 이유를 확인한다.
|
||||
|
||||
5. 꺼진 것과 조립될 수 없는 것을 구별할 방법을 남긴다
|
||||
둘이 런타임에서 같아 보이면 운영자는 차이를 알 수 없다.
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: the-startup-validator-follows-the-autoconfiguration-root
|
||||
title: 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
|
||||
title: 시작 검증기는 실제 lifecycle과 assembly 경로에서 실행 여부를 확인한다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
@@ -10,7 +10,7 @@ rootTreeNode: reference:the-startup-validator-follows-the-autoconfiguration-root
|
||||
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
|
||||
---
|
||||
|
||||
# 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
|
||||
# 시작 검증기는 실제 lifecycle과 assembly 경로에서 실행 여부를 확인한다
|
||||
|
||||
검증기 파일이 존재하고 그 테스트가 통과한다는 사실을 검증이 실행된다는 증거로 읽는 것을 막는다. 규칙이 많고 잘 테스트되어 있다는 것은 품질의 증거이지 실행의 증거가 아니다.
|
||||
|
||||
@@ -20,11 +20,11 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 검증기의 호출자를 센다
|
||||
프로덕션 호출자가 0 이고 테스트 호출자만 있으면 그 검증은 실행 시점에 적용되지 않는다.
|
||||
1. searched direct caller를 확인한다
|
||||
프로덕션 직접 호출을 찾지 못한 것은 강한 신호지만 그것만으로 runtime 미실행을 확정하지 않는다.
|
||||
|
||||
2. 자동설정 루트가 부르는지 확인한다
|
||||
능력의 조립 지점이 검증기를 호출하지 않으면, 그 능력이 배포되기 시작해도 검증은 자동으로 시작되지 않는다.
|
||||
2. assembly와 lifecycle 경로를 함께 확인한다
|
||||
`@Bean`·component scan·auto-configuration, lifecycle callback, application event, post processor, framework discovery를 차례로 확인한다. 실제 부팅이 가능하면 condition report와 시작 로그도 함께 본다.
|
||||
|
||||
3. 규칙 수와 실행 여부를 분리해서 본다
|
||||
규칙이 많고 잘 테스트되어 있다는 것은 품질의 증거이지 실행의 증거가 아니다.
|
||||
@@ -44,7 +44,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
## 예시
|
||||
|
||||
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있고, 호출자는 자기 테스트뿐이다. 같은 패키지의 자동설정은 106줄에 Bean 이 9개인데 검증기를 부르지 않는다.
|
||||
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있다. 현재 분석에서는 searched direct caller가 테스트에 있고 같은 패키지의 자동설정이 검증기를 직접 부르지 않는다는 점을 확인했다. 이 사실을 runtime 미실행으로 확정하려면 다른 lifecycle·framework discovery 경로도 닫아야 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ metric tag·trace·retry policy의 키가 되는 문자열을 값 타입으로
|
||||
:::evidence key="cardinality-bounds-as-types-diagram" alt="등록된 이름과 정규식 통과 값이 값 타입 생성자 안에 놓이고 엔티티 id 와 SQL 조각이 바깥에 빗금으로 놓인다" caption="값 타입 생성자가 막는 것" zoom="false"
|
||||
:::
|
||||
|
||||
목적은 엔티티 id·tenant id·SQL 조각·요청 스코프 값이 그 자리에 올 수 없게 하는 것이다.
|
||||
목적은 엔티티 id·tenant id·SQL 조각·요청 스코프 값이 bounded identifier 생성자를 통과하지 못하게 하는 것이다.
|
||||
|
||||
## 같은 모양을 가진 일곱 값 타입
|
||||
|
||||
@@ -135,7 +135,7 @@ public JpaMetricTags {
|
||||
|
||||
:::tip
|
||||
|
||||
검증이 레지스트리가 아니라 생성자에 있다. javadoc 이 이유를 적는다 — 무한한 값이 대시보드가 로딩되지 않을 때까지 살아남는 대신, 그것이 도입된 자리에서 실패한다.
|
||||
검증은 레지스트리가 아니라 생성자에서 실행된다. javadoc은 무한한 값이 대시보드까지 전달되지 않고 bounded identifier를 만드는 순간 실패하도록 이 위치를 택했다고 설명한다.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
+6
-4
@@ -44,7 +44,7 @@ source:
|
||||
|
||||
1. 길이 검사가 substring/decode/MAC **이전 첫 줄**에 온다 — 페이징 엔드포인트는 public이고 그 아래 모든 코드가 caller가 보낸 크기에 비례해 할당한다.
|
||||
2. base64 확장률로 decode 후 크기를 할당 전에 bound한다.
|
||||
3. MAC 길이를 먼저 확인한다 — `MessageDigest.isEqual`은 같은 길이 입력에 대해서만 상수 시간이다.
|
||||
3. MAC 형태와 예상 길이를 먼저 검증해 비정상 입력을 일찍 거부한다. 비교 자체는 JDK의 `MessageDigest.isEqual`이 문서화한 timing 특성을 사용한다.
|
||||
4. 상수 시간 비교.
|
||||
5. **서명 검증 후에야** payload를 파싱한다.
|
||||
|
||||
@@ -90,12 +90,14 @@ MAC 이 버전까지 덮는 것이 이 구조의 첫 결정이다. 페이로드
|
||||
|
||||
페이로드는 읽을 수 있다. 숨기는 것이 목적이 아니다. 서명이 없으면 커서는 클라이언트가 통제하는 정렬 상태이고, 그것을 고쳐 임의의 키로 이동할 수 있다.
|
||||
|
||||
## 상수시간 비교
|
||||
## 비교 시간이 입력 내용의 common prefix에 따라 갈리지 않게 한다
|
||||
|
||||
일반적인 short-circuit 비교처럼 입력 내용이나 common prefix에 따라 비교 시간이 달라지는 구현은 timing side channel을 만들 수 있다. 이 구현은 JDK의 `MessageDigest.isEqual`이 제공하는 documented comparison property를 사용한다.
|
||||
|
||||
```java
|
||||
/**
|
||||
* <p>Verification is constant-time via {@link MessageDigest#isEqual}. A short-circuiting comparison
|
||||
* here leaks the correct MAC one byte at a time.
|
||||
* <p>Verification uses {@link MessageDigest#isEqual} rather than a content-dependent short-circuit
|
||||
* comparison. The JDK documents comparison timing in terms of the supplied digest arrays.
|
||||
*/
|
||||
```
|
||||
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: a-keyset-page-has-no-offset-field
|
||||
title: keyset 페이지에 offset 필드를 두지 않는다
|
||||
topic: bounding-by-type
|
||||
topicName: 타입으로 카디널리티와 개인정보를 막기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
source:
|
||||
- final/document.md#a05
|
||||
- final/document.md#10-4
|
||||
- final/document.md#a05 §2.4
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# keyset 페이지에 offset 필드를 두지 않는다
|
||||
|
||||
keyset pagination을 표현하는 타입에는 offset 값을 함께 넣지 않는다. 서로 다른 페이지 이동 모델을 한 요청 타입에서 동시에 표현하지 않게 한다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **카디널리티를 타입으로 막는다**
|
||||
사용할 수 없는 조합을 값 검증이 아니라 타입 표면에서 제거하는 기준이다.
|
||||
|
||||
## 결정문
|
||||
|
||||
keyset pagination 요청과 결과 타입에는 offset 필드를 두지 않는다. 다음 페이지 이동은 cursor 또는 keyset 값으로만 표현한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
offset과 keyset을 같은 타입에 넣으면 호출자가 둘을 동시에 채우거나 어느 쪽이 우선인지 해석해야 한다. 사용하지 않을 필드를 남겨 두면 잘못된 조합을 런타임 검증으로 되돌리게 된다.
|
||||
|
||||
필드를 제거하면 keyset 페이지를 사용하는 호출자는 offset 기반 이동을 표현할 수 없다. 잘못된 상태를 사후 거부하는 대신 타입이 그 상태를 만들지 못하게 한다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것 : offset 기반 UI가 필요하면 별도 요청 타입이나 별도 API가 필요하다.
|
||||
|
||||
얻는 것 : keyset API의 이동 기준이 하나로 고정되고 조합 우선순위 규칙이 사라진다.
|
||||
+1
-1
@@ -34,7 +34,7 @@ source:
|
||||
|
||||
서명 범위는 버전까지 포함한다. 페이로드만 서명하면 접두사를 고쳐 옛 커서 형식으로 강등할 수 있기 때문이다.
|
||||
|
||||
검증은 상수시간 비교를 쓴다. 단축 평가 비교는 올바른 MAC 을 한 바이트씩 흘린다.
|
||||
검증은 입력 내용에 따라 일찍 종료되는 비교를 피하고 `MessageDigest.isEqual`을 사용한다. short-circuit 비교는 입력 내용이나 common prefix에 따라 비교 시간이 달라질 수 있어 timing side channel을 만들 수 있다.
|
||||
|
||||
## 영향
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: no-type-metadata-inside-a-jsonb-document
|
||||
title: JSONB 문서 안에 타입 메타데이터를 넣지 않는다
|
||||
topic: bounding-by-type
|
||||
topicName: 타입으로 카디널리티와 개인정보를 막기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
source:
|
||||
- final/document.md#a05
|
||||
- final/document.md#10-4
|
||||
- final/document.md#a05 §7.5
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# JSONB 문서 안에 타입 메타데이터를 넣지 않는다
|
||||
|
||||
JSONB payload에는 Java 구현 타입을 복원하기 위한 클래스 메타데이터를 저장하지 않는다. 저장 형식은 애플리케이션 클래스 이름과 분리한다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **카디널리티를 타입으로 막는다**
|
||||
저장 표면에 불필요한 자유도를 만들지 않는 기준이다.
|
||||
- **mongo의 _class 정책**
|
||||
다른 저장 기술에서 타입 메타데이터를 다루는 선택과 비교할 수 있다.
|
||||
|
||||
## 결정문
|
||||
|
||||
JSONB document에는 클래스 이름이나 임의의 타입 식별자를 자동 삽입하지 않는다. 필요한 variant는 애플리케이션 계약이 정의한 bounded discriminator로 표현한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
구현 클래스 이름을 저장하면 리팩터링이 영속 데이터 형식 변경으로 번지고, 허용 타입 범위가 클래스패스에 따라 넓어질 수 있다.
|
||||
|
||||
계약이 필요한 variant만 이름 붙이면 저장 문서가 이해하는 타입 집합을 명시적으로 제한할 수 있다. Java 구현 타입과 저장 계약도 독립적으로 변경할 수 있다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것 : 새로운 variant를 추가할 때 계약의 discriminator와 변환 코드를 함께 수정해야 한다.
|
||||
|
||||
얻는 것 : 클래스 리네임이 JSONB 스키마를 암묵적으로 바꾸지 않는다.
|
||||
|
||||
얻는 것 : 문서 안에 허용되는 타입 집합을 애플리케이션 계약에서 검토할 수 있다.
|
||||
+2
-2
@@ -24,7 +24,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
식별자를 값 객체로 감싸고 등록 여부를 생성자에서 검사한다. 등록되지 않은 이름은 값이 만들어지지 않는다.
|
||||
|
||||
2. 검사는 사용처가 아니라 도입 지점에 둔다
|
||||
레지스트리나 대시보드에서 걸러내면 잘못된 값이 그때까지 살아남는다. 값이 만들어지는 자리에서 실패해야 한다.
|
||||
레지스트리나 대시보드까지 보내기 전에 값 타입 생성자가 잘못된 입력을 거부해야 한다.
|
||||
|
||||
3. 이름 집합은 닫혀 있어야 한다
|
||||
무엇이 등록되어 있는지 열거할 수 있어야 한다. 열거할 수 없으면 그것은 레지스트리가 아니라 관행이다.
|
||||
@@ -46,7 +46,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
## 예시
|
||||
|
||||
JPA 메트릭 태그 다섯 개는 전부 등록된 식별자이고, 생성자가 등록 여부를 검사한다. 검사가 레지스트리가 아니라 생성자에 있는 이유는 무한한 값이 대시보드가 로딩되지 않을 때까지 살아남는 대신 도입된 자리에서 실패하게 하기 위해서다.
|
||||
JPA 메트릭 태그 다섯 개는 전부 등록된 식별자이고 생성자가 등록 여부를 검사한다. 그래서 등록되지 않은 값은 레지스트리나 대시보드에 도달하기 전에 식별자를 만드는 순간 거부된다.
|
||||
|
||||
## 관계
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id:
|
||||
kind: CONCEPT
|
||||
slug: publish-evidence-and-completion
|
||||
title: 발행 증거와 완료 판정이 따로 있는 이유
|
||||
topic: commit-ambiguity-as-a-result
|
||||
topicName: 커밋 모호성 — 「모른다」를 결과로 유지하기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
studio: ""
|
||||
basisVersion: sourceRevision 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
source:
|
||||
- final/document.md#a19
|
||||
- final/document.md#3-3
|
||||
- final/document.md#a19 §3.2
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# 발행 증거와 완료 판정이 따로 있는 이유
|
||||
|
||||
실패가 발생했다는 사실과 외부 시스템이 작업을 받아들였다는 사실은 같은 정보가 아니다. 이 프로젝트는 전송·발행 증거를 먼저 기록하고, 그 증거를 바탕으로 완료 상태를 별도로 판정한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **증거를 먼저 기록하고 결론은 나중에 고른다**
|
||||
이 개념을 프로젝트 결정으로 고정한 기록이다.
|
||||
- **트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문**
|
||||
완료를 성공·실패 두 값으로 접지 않는 타입 모델을 설명한다.
|
||||
- **completion-unknown 은 자동으로도 수동으로도 재시도하지 않는다**
|
||||
완료를 모르는 상태가 재실행 권한으로 바뀌지 않게 한 결정이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 증거와 결론은 서로 다른 질문에 답한다
|
||||
|
||||
전송 증거는 프로세스 밖으로 무엇이 나갔는지를 답한다. 완료 판정은 그 증거를 바탕으로 호출자가 무엇을 주장해도 되는지를 답한다.
|
||||
|
||||
아무 바이트도 나가지 않은 실패라면 동일 작업을 다시 시도해도 외부 중복을 만들 가능성이 낮다. 반대로 요청이 wire에 올라갔거나 커밋 요청이 전달된 뒤 응답을 잃었다면 같은 예외 타입이라도 결과를 실패로 단정할 수 없다.
|
||||
|
||||
## 표현할 수 없는 조합을 타입에서 막는다
|
||||
|
||||
SSOT의 `JpaFailureContext`는 completion-unknown과 retryable이 동시에 참인 값을 거부한다. 이 제약은 정책 문구가 아니라 생성 가능한 상태 집합을 제한한다.
|
||||
|
||||
이 구조의 목적은 “실패 종류를 더 세밀하게 이름 붙이기”가 아니다. 먼저 관측한 사실을 보존하고, 그 다음 단계가 그 사실보다 강한 결론을 만들지 못하게 하는 것이다.
|
||||
|
||||
## 완료 판정은 증거를 소비한다
|
||||
|
||||
커밋 요청 전 실패, 커밋 요청 뒤 확인된 롤백, 커밋 확인, 결과 불명은 서로 다른 완료 상태가 된다. 같은 원칙은 메시지 발행에도 적용된다. 전송되지 않음과 전송됐을 가능성을 구분해야 retry나 reconciliation 정책이 사실보다 앞서가지 않는다.
|
||||
|
||||
## 이 개념이 보장하지 않는 것
|
||||
|
||||
증거를 분리했다고 실제 외부 상태를 자동으로 알아내는 것은 아니다. completion-unknown은 “모른다”를 정확히 표현할 뿐이다. 실제 결과 확인은 reconciliation이나 별도 운영 절차가 맡는다.
|
||||
|
||||
현재 머신에는 source repository가 없어 이 sourceRevision의 코드 경로를 다시 실행하지 않았다. 이 기록은 SSOT가 고정한 분석 결과를 설명한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id:
|
||||
kind: PROJECT_DECISION
|
||||
slug: record-the-evidence-first-choose-the-conclusion-later
|
||||
title: 증거를 먼저 기록하고 결론은 나중에 고른다
|
||||
topic: commit-ambiguity-as-a-result
|
||||
topicName: 커밋 모호성 — 「모른다」를 결과로 유지하기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
studio: ""
|
||||
decisionStatus: ADOPTED
|
||||
source:
|
||||
- final/document.md#a19
|
||||
- final/document.md#10-2
|
||||
- final/document.md#3-3
|
||||
- final/document.md#a19 §3.2
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# 증거를 먼저 기록하고 결론은 나중에 고른다
|
||||
|
||||
실패를 관측한 즉시 성공·실패·재시도로 접지 않는다. 먼저 전송·커밋 단계를 증거로 남기고, 완료 판정은 그 증거를 입력으로 별도 단계에서 정한다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **발행 증거와 완료 판정이 따로 있는 이유**
|
||||
증거와 결론이 답하는 질문이 다르다.
|
||||
- **트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문**
|
||||
완료를 다섯 상태로 유지하는 타입 모델이 이미 있다.
|
||||
- **completion-unknown 은 자동으로도 수동으로도 재시도하지 않는다**
|
||||
증거가 모호할 때 결론을 강하게 만들지 않는 후속 결정이다.
|
||||
|
||||
## 결정문
|
||||
|
||||
전송·커밋 진행 정도를 먼저 증거 값으로 기록한다. 성공·롤백·completion-unknown·재시도 가능 여부 같은 결론은 그 증거와 operation semantics를 읽는 후속 판정에서 정한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
예외 타입 하나에는 “아무것도 전송되지 않았다”와 “요청은 전달됐지만 결과 응답을 잃었다”가 함께 들어갈 수 있다. 이 둘을 같은 실패로 접으면 재시도 정책이 관측 사실보다 강한 주장을 하게 된다.
|
||||
|
||||
증거를 먼저 기록하면 후속 정책이 바뀌어도 최초 관측은 보존된다. 특히 completion-unknown을 retryable로 동시에 표현하지 못하게 만든 생성자 제약은 이 순서를 코드에서 강제한다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것 : 상태 타입과 판정 단계가 늘어나고 호출자는 단일 boolean보다 많은 경우를 처리해야 한다.
|
||||
|
||||
얻는 것 : 실패 원인과 완료 상태를 섞지 않으며, 결과를 모르는 작업을 자동 재실행하는 경로를 구조적으로 줄인다.
|
||||
|
||||
얻는 것 : 후속 reconciliation이 최초 관측을 다시 해석할 수 있다.
|
||||
+14
-14
@@ -29,7 +29,7 @@ source:
|
||||
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
|
||||
`rabbit` 은 등록 목록에 남고 `assemblableBrokerIds()` 에서는 빠진다. 그 필드 자바독이 이 이름이 언제 맵을 떠나는지까지 적는다.
|
||||
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
|
||||
그 기록이 경계 밖으로 지목한 셋 중 하나가 브로커 등급 표의 제한 칸이고, 이 사례가 그 칸에서 빠진 사실이다.
|
||||
그 기록은 브로커 등급 표의 제한 칸도 경계 밖으로 지목했고, 이 Case에서는 실제 선택 불가 조건이 그 칸에 기록되지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -45,7 +45,7 @@ source:
|
||||
|
||||
그 구현이 시험에는 둘 있는데 성격이 다르다. RabbitRuntimeTest:48 이 만드는 것은 아무 데도 붙지 않는 더블이다. RabbitBrokerIT:93 은 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 :187 의 channel.basicPublish 까지 위임하며, :61 의 Testcontainer 에 붙어 :154·:161 이 브로커의 큐 깊이를 읽는다.
|
||||
|
||||
채택자가 그 seam 을 채워도 소용이 없다. messaging-rabbit/build.gradle:12~:14 는 seam 을 구현할 소비자를 위해 spring-amqp 를 api 로 노출한다고 적는다. 그런데 :147 의 selectImports 가 selectedBroker 를 먼저 부르므로 :123 의 예외가 자동설정 import 이전에 터진다. KafkaMessagingAutoConfiguration:163 은 @ConditionalOnMissingBean 으로 자기 전송 빈에 탈출구를 두었는데 rabbit 에는 그 자리가 없다.
|
||||
채택자가 그 seam 을 채워도 소용이 없다. messaging-rabbit/build.gradle:12~:14 는 seam 을 구현할 소비자를 위해 spring-amqp 를 api 로 노출한다고 적는다. 그런데 :147 의 selectImports 가 selectedBroker 를 먼저 부르므로 :123 의 예외가 자동설정 import 이전에 터진다. KafkaMessagingAutoConfiguration:163은 `@ConditionalOnMissingBean`으로 자기 전송 빈을 대체할 수 있게 했지만 Rabbit 자동설정에는 같은 대체 조건이 없다.
|
||||
|
||||
RabbitMessagingAutoConfiguration 이 선언하는 빈은 :35·:48·:70·:94 넷이고 그중 전송이 없다. 선택이 먼저 던지므로 이 넷도 만들어지지 않는다.
|
||||
|
||||
@@ -57,21 +57,21 @@ RabbitMessagingAutoConfiguration 이 선언하는 빈은 :35·:48·:70·:94 넷
|
||||
|
||||
같은 문서 :23~:27 은 messaging 리프가 모두 runtime_memberships 가 비어 build-only 라는 단서를 표 전체에 붙인다. 그 단서도 지금은 맞지 않는다. configuration-reference.md:132 은 이 브로커의 설정 예시를 싣고, docs/registries/env-keys.yaml 은 이 프로퍼티에 허용값 목록도 검증 규칙도 걸지 않는다.
|
||||
|
||||
문서 계약 시험은 이 차이를 볼 수 없다. 여덟 중 두 개만 브로커 등급 표에 닿고, 그 둘이 확인하는 것은 어댑터 이름과 등급 낱말의 조합뿐이다.
|
||||
문서 계약 시험 여덟 중 브로커 등급 표를 보는 것은 둘뿐이며, 두 테스트는 어댑터 이름과 등급 낱말의 조합만 확인한다.
|
||||
|
||||
거절 로직 자체도 시험이 없다. 이름을 가진 시험 파일이 0 개이고, env-keys.yaml:3326 이 요구하는 adapter-contract:messaging-broker-selection 을 정의한 자리도 0 개다.
|
||||
거절 로직 자체도 시험이 없다. 이름을 가진 시험 파일이 0 개이고, `env-keys.yaml:3326`이 요구하는 `adapter-contract:messaging-broker-selection`의 정의도 저장소에서 찾지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 선택기의 거절 맵과 자바독과 예외 인용, 그 선택을 붙드는 시험 파일과 호출자 계수 및 레지스트리가 요구하는 시험 id 의 정의 여부, 채널 발행자가 나오는 자리 전수와 구현 형태별 계수, 두 익명 구현의 본문과 컨테이너 배선 인용, build.gradle 의 seam 공개 주석과 Kafka 쪽 조건 애너테이션과 selectImports 순서 대조, 두 자동설정의 빈 목록, 출하 파일 수와 물리적 줄과 빈 줄 제외 줄, 전송 클래스의 자기 호칭과 호환성 표의 등급, 지원 매트릭스 표와 그 전체에 붙은 단서와 startup 언급 전수, 문서 계약 시험의 단언 범위, 이 상태를 적는 문서와 운영 설정 문서 대조
|
||||
확인 방식 : 선택기의 거절 맵과 자바독과 예외 인용, 그 선택을 붙드는 시험 파일과 호출자 계수 및 레지스트리가 요구하는 시험 id 의 정의 여부, 채널 발행자 이름의 모든 사용 지점과 구현 형태별 계수, 두 익명 구현의 본문과 컨테이너 배선 인용, build.gradle 의 seam 공개 주석과 Kafka 쪽 조건 애너테이션과 selectImports 순서 대조, 두 자동설정의 빈 목록, 출하 파일 수와 물리적 줄과 빈 줄 제외 줄, 전송 클래스의 자기 호칭과 호환성 표의 등급, 지원 매트릭스 표와 그 전체에 붙은 단서와 startup 언급 전수, 문서 계약 시험의 단언 범위, 이 상태를 적는 문서와 운영 설정 문서 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 선택기의 거절 맵과 자바독, 그리고 예외를 던지는 자리를 인용한다.
|
||||
1. 선택기의 거절 맵과 javadoc, 그리고 예외를 생성하는 코드를 인용한다.
|
||||
2. 그 선택을 검증하는 시험 파일과 selectedBroker 호출자를 세고, 레지스트리가 요구하는 시험 id 가 정의돼 있는지 본다.
|
||||
3. 채널 발행자 이름이 나오는 자리를 전부 찾아 implements 와 익명 구현을 나눠 세고, 대조 타입 MessagingTransport 로 같은 검색을 걸어 0 이 아닌 수가 나오는지 확인한다.
|
||||
3. 채널 발행자 이름의 사용 지점을 전부 찾아 `implements`와 익명 구현을 나눠 세고, 대조 타입 `MessagingTransport`에도 같은 검색을 적용한다.
|
||||
4. 두 익명 구현의 본문과 그 파일의 컨테이너 배선을 인용한다.
|
||||
5. build.gradle 의 의존 노출 주석, Kafka 전송 빈의 조건 애너테이션, selectImports 의 호출 순서를 나란히 놓는다.
|
||||
6. 두 브로커의 자동설정이 만드는 빈을 대조한다.
|
||||
@@ -86,20 +86,20 @@ OpenJDK : 21.0.12
|
||||
|
||||
`MessagingProviderSelection` 은 `app.messaging.broker` 값 하나로 전송을 고른다. 등록되지 않은 이름, 클래스패스에 없는 클라이언트, 전송이 아직 없는 브로커를 각각 다른 메시지로 거절한다.
|
||||
|
||||
## 선택기가 rabbit 을 거절하는 자리
|
||||
## 선택기가 rabbit을 거절하는 흐름
|
||||
|
||||
:::evidence key="an-unselectable-broker-listed-with-features" alt="저장소 루트에서 돌린 정적 검색과 선택기 프로브의 출력 296줄. 먼저 MessagingProviderSelection 48~70번 줄이 실려 BROKERS_WITHOUT_A_TRANSPORT 맵과 그 필드 자바독이 보이는데, rabbit 어댑터가 검증기와 보안 설정은 출하하지만 전송이 없고 네이티브 채널 다리에 시험 구현만 있다는 것, 그리고 항목이 이 맵을 떠나는 날은 전송이 실제로 생기는 날이라는 것을 적는다. 118~132번 줄이 선택 시 던지는 예외를 만드는 코드다. 이어서 그 선택기를 직접 부른 프로브 결과가 나온다. 등록된 이름은 kafka 와 rabbit 이고 조립 가능한 이름은 kafka 뿐이며, broker=kafka 는 kafka 로 선택되고, broker=rabbit 은 IllegalStateException 과 함께 전송이 구현되지 않아 발행이 타고 갈 것이 없다는 메시지를 내며, broker=pulsar 는 등록되지 않은 전송이라는 다른 메시지를, 빈 값은 브로커를 지정하라는 또 다른 메시지를 낸다. 다음으로 그 거절을 붙드는 시험이 없다는 것이 나온다. MessagingProviderSelection 을 참조하는 파일은 넷인데 전부 main 이고, env-keys.yaml 3326번 줄이 required_test 로 adapter-contract:messaging-broker-selection 을 선언하는데 그 id 를 정의한 자리는 0 개다. RabbitChannelPublisher 18~41번 줄이 실려 추상 메서드가 nextPublishSequence 와 publish 둘이라는 것이 보인다. 그 이름이 나오는 자리는 여섯이고 implements 를 가진 파일은 0 개 익명 구현을 가진 파일은 2 개이며, 대조로 실은 implements MessagingTransport 목록은 여섯인데 넷이 main 어댑터이고 둘은 시험 클래스다. 그 두 익명 구현이 나란히 실린다. RabbitBrokerIT 89~108번 줄은 RabbitMessagingTransport 를 만들면서 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 그 시험 클래스의 publish 로 위임한다. RabbitRuntimeTest 44~63번 줄은 시퀀스를 AtomicLong 으로 세고 메시지를 리스트에 담는 인메모리 더블이다. 그 IT 가 붙는 브로커로 rabbitmq:4.3-management 컨테이너 선언과 basicPublish 호출과 messageCount 단언 줄이 나온다. 그 아래에 build.gradle 12~14번 줄의 seam 공개 주석, KafkaMessagingAutoConfiguration 163번 줄의 ConditionalOnMissingBean, MessagingProviderSelection 146~147번 줄의 selectImports 가 차례로 실린다. 두 자동설정이 선언하는 빈은 Kafka 일곱과 Rabbit 넷이다. 출하 여부로는 modules.json 417~431번 줄이 messaging-rabbit 의 runtime_memberships 를 app-bootstrap 으로 적고 app-bootstrap/gradle.lockfile 77번 줄이 amqp-client 를 productionRuntimeClasspath 에 싣는다. main 자바 20 개 파일 물리적 줄 2443 빈 줄 제외 2232 이고, RabbitMessagingTransport 27번 줄은 자기를 Stable RabbitMQ adapter 라 부르는데 CompatibilityMatrix 92번 줄은 같은 항목을 EXPERIMENTAL 로 적는다. 운영 문서로는 support-matrix.md 29~38번 줄의 등급 표와 22~27번 줄의 단서, configuration-reference.md 132~146번 줄의 RabbitMQ 설정 절, env-keys.yaml 의 allowed_values null 과 validation none 이 나온다. 문서 계약 시험은 여덟이고 그중 61번과 70번이 등급 이름을 단언하며 제한이나 선택 가능 여부를 담은 줄은 0 개다. 마지막으로 src/messaging/CLAUDE.md 56~63번 줄이 실려 대부분의 leaf 가 app-bootstrap 멤버십을 갖고 배포된 아티팩트가 싣고 있다는 것과 Rabbit 이 shipped, inactive, unqualified 라는 것을 적고, 코드 리뷰 문서도 같은 상태를 적는다." caption="선택기의 거절 맵과 그것을 직접 부른 프로브 네 경우 · 그 거절을 붙드는 시험 0 과 정의되지 않은 required_test · 추상 메서드 둘과 여섯 자리와 두 익명 구현의 본문 · seam 공개와 Kafka 의 탈출구와 selectImports 순서 · modules.json 의 runtime_memberships 와 락파일의 productionRuntimeClasspath · 자기 호칭과 호환성 등급 · 운영 문서 세 곳과 문서 계약 시험 여덟 · 이 상태를 적는 개발 문서 — 296줄 · exit 0" zoom="true"
|
||||
:::evidence key="an-unselectable-broker-listed-with-features" alt="저장소 루트에서 돌린 정적 검색과 선택기 프로브의 출력 296줄. 먼저 MessagingProviderSelection 48~70번 줄이 실려 BROKERS_WITHOUT_A_TRANSPORT 맵과 그 필드 자바독이 보이는데, rabbit 어댑터가 검증기와 보안 설정은 출하하지만 전송이 없고 네이티브 채널 다리에 시험 구현만 있다는 것, 그리고 항목이 이 맵을 떠나는 날은 전송이 실제로 생기는 날이라는 것을 적는다. 118~132번 줄이 선택 시 던지는 예외를 만드는 코드다. 이어서 그 선택기를 직접 부른 프로브 결과가 나온다. 등록된 이름은 kafka 와 rabbit 이고 조립 가능한 이름은 kafka 뿐이며, broker=kafka 는 kafka 로 선택되고, broker=rabbit 은 IllegalStateException 과 함께 전송이 구현되지 않아 발행이 타고 갈 것이 없다는 메시지를 내며, broker=pulsar 는 등록되지 않은 전송이라는 다른 메시지를, 빈 값은 브로커를 지정하라는 또 다른 메시지를 낸다. 다음으로 그 거절을 붙드는 시험이 없다는 것이 나온다. MessagingProviderSelection 을 참조하는 파일은 넷인데 전부 main 이고, env-keys.yaml 3326번 줄이 required_test 로 adapter-contract:messaging-broker-selection 을 선언하는데 그 id의 정의를 찾지 못한다. RabbitChannelPublisher 18~41번 줄이 실려 추상 메서드가 nextPublishSequence 와 publish 둘이라는 것이 보인다. 그 이름의 사용 지점은 여섯이고 implements 를 가진 파일은 0 개 익명 구현을 가진 파일은 2 개이며, 대조로 실은 implements MessagingTransport 목록은 여섯인데 넷이 main 어댑터이고 둘은 시험 클래스다. 그 두 익명 구현이 나란히 실린다. RabbitBrokerIT 89~108번 줄은 RabbitMessagingTransport 를 만들면서 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 그 시험 클래스의 publish 로 위임한다. RabbitRuntimeTest 44~63번 줄은 시퀀스를 AtomicLong 으로 세고 메시지를 리스트에 담는 인메모리 더블이다. 그 IT 가 붙는 브로커로 rabbitmq:4.3-management 컨테이너 선언과 basicPublish 호출과 messageCount 단언 줄이 나온다. 그 아래에 build.gradle 12~14번 줄의 seam 공개 주석, KafkaMessagingAutoConfiguration 163번 줄의 ConditionalOnMissingBean, MessagingProviderSelection 146~147번 줄의 selectImports 가 차례로 실린다. 두 자동설정이 선언하는 빈은 Kafka 일곱과 Rabbit 넷이다. 출하 여부로는 modules.json 417~431번 줄이 messaging-rabbit 의 runtime_memberships 를 app-bootstrap 으로 적고 app-bootstrap/gradle.lockfile 77번 줄이 amqp-client 를 productionRuntimeClasspath 에 싣는다. main 자바 20 개 파일 물리적 줄 2443 빈 줄 제외 2232 이고, RabbitMessagingTransport 27번 줄은 자기를 Stable RabbitMQ adapter 라 부르는데 CompatibilityMatrix 92번 줄은 같은 항목을 EXPERIMENTAL 로 적는다. 운영 문서로는 support-matrix.md 29~38번 줄의 등급 표와 22~27번 줄의 단서, configuration-reference.md 132~146번 줄의 RabbitMQ 설정 절, env-keys.yaml 의 allowed_values null 과 validation none 이 나온다. 문서 계약 시험은 여덟이고 그중 61번과 70번이 등급 이름을 단언하며 제한이나 선택 가능 여부를 담은 줄은 0 개다. 마지막으로 src/messaging/CLAUDE.md 56~63번 줄이 실려 대부분의 leaf 가 app-bootstrap 멤버십을 갖고 배포된 아티팩트가 싣고 있다는 것과 Rabbit 이 shipped, inactive, unqualified 라는 것을 적고, 코드 리뷰 문서도 같은 상태를 적는다." caption="선택기의 거절 맵과 그것을 직접 부른 프로브 네 경우 · 그 거절을 붙드는 시험 0 과 정의되지 않은 required_test · 추상 메서드 둘과 여섯 사용 지점과 두 익명 구현의 본문 · seam 공개와 Kafka 의 탈출구와 selectImports 순서 · modules.json 의 runtime_memberships 와 락파일의 productionRuntimeClasspath · 자기 호칭과 호환성 등급 · 운영 문서 세 곳과 문서 계약 시험 여덟 · 이 상태를 적는 개발 문서 — 296줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:63` 의 `BROKERS_WITHOUT_A_TRANSPORT` 는 `rabbit` 하나를 담고 값은 이유 문자열이다. `:121` 이 그 값을 꺼내고 `:123` 이 프로퍼티 이름과 이유와 오늘 조립 가능한 브로커 목록을 붙여 `IllegalStateException` 을 만든다.
|
||||
|
||||
자바독은 이 설계의 이유를 적는다. 거절하지 않으면 코어 설정 깊은 곳에서 `MessagingTransport` 의존이 충족되지 않아, 운영자에게는 자기가 고른 전송이 미완성이라는 사실 대신 빈이 없다는 말이 도달한다는 것이다.
|
||||
javadoc은 거절하지 않으면 코어 설정 깊은 곳에서 `MessagingTransport` 의존이 충족되지 않아, 운영자가 ‘선택한 전송이 미완성’이라는 설명 대신 빈이 없다는 오류를 받게 된다고 적는다.
|
||||
|
||||
## 그 거절을 붙드는 시험이 없다
|
||||
|
||||
`MessagingProviderSelection` 이나 브로커 선택을 이름에 가진 시험 파일은 0 개다. `selectedBroker` 를 부르는 자리는 `:93` 의 선언과 `:147` 의 호출 둘뿐이고 둘 다 main 이다.
|
||||
`MessagingProviderSelection` 이나 브로커 선택을 이름에 가진 시험 파일은 0 개다. `selectedBroker`의 사용 지점은 `:93`의 선언과 `:147`의 호출 둘뿐이고 둘 다 main이다.
|
||||
|
||||
`docs/registries/env-keys.yaml:3326` 은 이 프로퍼티의 `required_test` 로 `adapter-contract:messaging-broker-selection` 을 선언한다. 그 id 를 정의한 자리는 저장소에 0 개다.
|
||||
`docs/registries/env-keys.yaml:3326` 은 이 프로퍼티의 `required_test` 로 `adapter-contract:messaging-broker-selection` 을 선언한다. 그 id의 정의는 저장소에서 찾지 못했다.
|
||||
|
||||
맵을 비우거나 키를 고쳐도 실패하는 시험이 없다.
|
||||
|
||||
@@ -123,7 +123,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
`KafkaMessagingAutoConfiguration:163` 은 전송 빈에 `@ConditionalOnMissingBean(MessagingTransport.class)` 를 걸어 두었다. 애플리케이션이 자기 전송을 주면 양보한다.
|
||||
|
||||
rabbit 에는 그 자리가 없다. `:146`\~`:147` 의 `selectImports` 가 `PROVIDER_CONFIGURATIONS.get(selectedBroker(environment))` 를 부르므로, 자동설정이 import 되기도 전에 `:123` 의 예외가 터진다. `RabbitChannelPublisher` 를 직접 구현하고 `MessagingTransport` 빈까지 준 배포도 `broker=rabbit` 을 고를 수 없다.
|
||||
Rabbit 자동설정에는 같은 대체 조건이 없다. `:146`\~`:147`의 `selectImports`가 `PROVIDER_CONFIGURATIONS.get(selectedBroker(environment))`를 먼저 부르므로 자동설정이 import되기 전에 `:123`의 예외가 발생한다. `RabbitChannelPublisher` 를 직접 구현하고 `MessagingTransport` 빈까지 준 배포도 `broker=rabbit` 을 고를 수 없다.
|
||||
|
||||
## RabbitMessagingAutoConfiguration 에는 전송 빈이 없고 그 넷도 만들어지지 않는다
|
||||
|
||||
@@ -151,7 +151,7 @@ rabbit 에는 그 자리가 없다. `:146`\~`:147` 의 `selectImports` 가 `PROV
|
||||
|
||||
그 파일에서 제한 칸이나 선택 가능 여부를 담은 줄은 0 개다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
## 원문이 다루지 않은 범위
|
||||
|
||||
원문은 `RabbitChannelPublisher` 의 구현이 main·test 통틀어 0 건이라고 적었다. `implements` 로 센 것은 0 이 맞지만 시험 두 파일에 익명 구현이 있고, 그중 하나는 실 컨테이너에 붙는다. `BROKERS_WITHOUT_A_TRANSPORT` 의 자바독 자신이 시험 구현만 있다고 적어 이 상태를 정확히 서술한다.
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ source:
|
||||
|
||||
수치가 문서에 하드코딩되어 있고 그것을 붙드는 게이트가 없다.
|
||||
|
||||
빌드 설정에는 리프 수를 검사하는 코드가 없다. 레지스트리 항목이 늘어도 문서의 숫자는 그대로 남는다.
|
||||
빌드 설정에는 리프 수를 검사하거나 문서 숫자를 갱신하는 코드가 없다. 그래서 레지스트리 항목이 늘어나도 문서에 적힌 기존 숫자는 자동으로 바뀌지 않는다.
|
||||
|
||||
이 드리프트의 성질은 앞의 사례들과 다르다. 능력 표의 불일치는 동작에 대한 오해를 만들지만, 이 숫자는 동작을 바꾸지 않는다. 대신 문서 전체의 신뢰도를 깎는다. 19 라는 수를 근거로 삼은 서술 — 예를 들어 모듈 경계 설명이나 의존 그래프 서술 — 이 어느 시점의 것인지 알 수 없게 된다.
|
||||
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-boundary-that-leaks-only-under-load
|
||||
title: 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다
|
||||
topic: duplicate-mechanisms
|
||||
topicName: 중복 장치 — 조립된 쪽이 약한 쪽일 때
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-boundary-that-leaks-only-under-load
|
||||
source:
|
||||
- final/document.md#8-2
|
||||
- final/document.md#a20
|
||||
- final/document.md#5-5
|
||||
- final/document.md#8-2 항목 6
|
||||
- final/document.md#a20 §7
|
||||
---
|
||||
|
||||
# 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다
|
||||
|
||||
같은 가족의 구현 중 하나가 제한값 검사와 증가를 분리해서 수행하고, 다른 구현은 CAS loop로 두 동작을 하나의 원자적 갱신으로 묶는다. 정적 코드 비교로 경계 차이는 확인했지만 실제 경쟁 부하는 재현하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
같은 목적의 구현이 둘일 때 실제 호출 경로가 어느 쪽인지 확인하는 기준이다.
|
||||
- **CAS tuple과 update count**
|
||||
경쟁 상태에서 읽기와 쓰기를 분리하지 않는 상태 전이 규칙을 설명한다.
|
||||
|
||||
## 문제
|
||||
|
||||
제한값을 읽어 “아직 여유가 있다”고 확인한 뒤 별도 연산으로 값을 증가시키면 두 worker가 같은 이전 값을 동시에 읽을 수 있다. 각 worker는 개별적으로는 검사를 통과하지만 합산 결과는 경계를 넘을 수 있다.
|
||||
|
||||
같은 가족에는 CAS loop로 읽은 revision과 기대 값을 WHERE 조건에 포함해 한 worker만 갱신하도록 만든 구현이 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
이 경계는 단일 worker 테스트만으로는 충분히 검증되지 않는다. 제한 확인과 증가가 하나의 원자적 상태 전이가 아니면 경쟁 부하에서만 초과가 나타날 수 있다.
|
||||
|
||||
현재 판정은 두 구현의 코드 구조 비교다. 실제 concurrent load를 걸어 초과를 재현하지 않았으므로 발생 빈도나 임계 동시성은 주장하지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
sourceRevision : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
현재 source repository 재대조 : UNVERIFIABLE
|
||||
확인 방식 : 동일 가족 구현의 정적 비교
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 제한 확인과 증가가 분리된 구현의 read/write 순서를 확인한다.
|
||||
2. 같은 가족의 CAS 구현이 기대 값과 revision을 갱신 조건에 포함하는지 확인한다.
|
||||
3. source repository가 있는 환경에서는 두 worker 이상으로 같은 경계를 동시에 갱신해 실제 초과 여부를 측정한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 단일 요청에서 보이지 않는 이유
|
||||
|
||||
worker 하나만 실행하면 “읽기 → 검사 → 증가” 사이에 다른 쓰기가 끼어들지 않는다. 따라서 기능 테스트는 정상 범위만 관측할 수 있다.
|
||||
|
||||
## CAS 구현과 비교한다
|
||||
|
||||
대조 구현은 현재 값을 읽은 뒤 기대 revision을 포함한 갱신을 시도한다. 경쟁자가 먼저 값을 바꾸면 update count가 0이 되고 다시 읽어 판정한다. 이 차이가 두 구현의 concurrency 보장 차이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 부하에서 경계를 넘기는 실행은 이번 검토에서 재현하지 않았다. 코드 구조상 race 가능성을 확인한 상태다.
|
||||
|
||||
<!-- body:end -->
|
||||
+5
-5
@@ -26,7 +26,7 @@ source:
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
이 사례가 그 규칙을 요구하는 형태다.
|
||||
- **클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다**
|
||||
같은 형태가 알림 어댑터에서 나타난 사례다.
|
||||
알림 어댑터에서도 앞 단계가 정한 정책을 뒤 단계가 다시 덮어써 실제 관측값이 달라졌다.
|
||||
- **sanitize가 아니라 reject가 기본이다**
|
||||
두 필터가 서로 다른 답을 내는 규칙이다.
|
||||
|
||||
@@ -38,7 +38,7 @@ source:
|
||||
|
||||
## 결론
|
||||
|
||||
관측 가능한 자리에 도달하는 값은 전부 뒤 필터의 것이다. 응답 헤더와 MDC 와 접근 로그가 그렇다.
|
||||
최종 응답 헤더·MDC·접근 로그에는 앞 필터가 만든 값이 아니라 뒤 필터가 다시 쓴 값이 기록된다.
|
||||
|
||||
앞 필터가 남긴 요청 속성을 쓰는 곳이 없다. 접근자는 있는데 부르는 곳이 0 이다.
|
||||
|
||||
@@ -76,9 +76,9 @@ Spring Boot : 4.0.8
|
||||
|
||||
앞 필터는 자동설정이 조립한다. 서블릿 웹 애플리케이션 조건이 붙어 있고, 자동설정 등록 파일에 이름이 올라가 있다.
|
||||
|
||||
자동설정은 설정값을 그대로 넘긴다. 그 설정 레코드의 압축 생성자가 널을 거짓으로 접으므로, 아무것도 설정하지 않은 배포는 신뢰가 꺼진 상태로 돈다. 그 자리 주석이 이유를 적는다 — 자기 요청 id 를 고를 수 있는 호출자는 서로 다른 두 요청이 하나의 신원을 공유하게 만들 수 있고, 그것이 지원 조사가 남의 교신을 읽게 되는 경로라는 것이다.
|
||||
자동설정은 설정값을 그대로 넘긴다. 설정 record의 compact constructor는 null을 false로 바꾸므로 아무것도 설정하지 않은 배포에서는 신뢰 기능이 꺼진다. 해당 주석은 호출자가 요청 id를 직접 고르게 두면 서로 다른 두 요청이 하나의 신원을 공유할 수 있고, 지원 조사에서 다른 요청의 교신을 같은 요청으로 묶을 수 있다고 설명한다.
|
||||
|
||||
같은 기본값을 넘기는 무인자 생성자도 있지만 부르는 쪽은 테스트뿐이다.
|
||||
같은 기본값을 넘기는 무인자 생성자는 테스트에서만 호출된다.
|
||||
|
||||
## 같은 헤더 이름이 두 파일에 따로 있다
|
||||
|
||||
@@ -98,7 +98,7 @@ Spring Boot : 4.0.8
|
||||
|
||||
순서를 선언하지 않은 필터 빈에 Spring Boot 가 매기는 기본 순서는 가장 낮은 우선순위다. 분석 문서가 뒤 필터의 순서를 그 이름으로 적는 근거가 이것이다. 그래서 뒤 필터가 나중에 돌고, 두 필터가 쓰는 응답 헤더 설정은 덮어쓰기다.
|
||||
|
||||
## 관측 가능한 자리에는 뒤 필터의 값만 도달한다
|
||||
## 응답 헤더·MDC·접근 로그에는 뒤 필터 값이 기록된다
|
||||
|
||||
앞 필터는 자기 값을 요청 속성과 응답 헤더에 쓴다. 뒤 필터는 정화한 클라이언트 값을 같은 응답 헤더와 MDC 에 쓴다.
|
||||
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ source:
|
||||
## 관계
|
||||
|
||||
- **재시도 단위는 statement가 아니라 유스케이스 전체다**
|
||||
코디네이터가 구현하는 결정이고, 그 결정이 도는 자리는 다른 구현이다.
|
||||
코디네이터가 재시도 결정을 구현하지만 실제 배선된 호출 경로에서는 다른 구현이 실행된다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
이 사례가 그 확인 절차를 요구한 형태다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
@@ -59,9 +59,9 @@ source:
|
||||
|
||||
지워진 인터셉터 이름을 건드린 커밋은 넷이다. 하나가 설계 문서에 이름을 넣었고, 하나가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 커밋이 둘 다 지웠다. 지운 커밋의 제목에는 삭제가 드러나지 않는다.
|
||||
|
||||
어드바이스를 얹을 자리가 없어서가 아니다. 같은 트리의 배출기와 정리기가 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽은 이 애플리케이션이 직접 쓴 어드바이저를 갖고 있다. 빠진 것은 경로가 아니라 코디네이터를 그 경로에 얹는 클래스 하나다.
|
||||
AOP를 적용할 기반이 없는 것은 아니다. 같은 트리의 배출기와 정리기는 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽에는 이 애플리케이션이 직접 만든 어드바이저가 있다. 빠진 것은 retry coordinator를 실제 호출 경로에 연결하는 advisor 또는 wrapper다.
|
||||
|
||||
판정은 P1 이다. 안정 등급으로 선언한 능력의 구현이 도달 불가이고, 그 자리에서 실제로 도는 것은 다른 값과 좁은 정책을 가진 다른 구현이다.
|
||||
판정은 P1이다. 안정 등급으로 선언한 retry coordinator에는 확인한 프로덕션 호출 경로가 없고, 실제 wired path에서는 다른 설정값과 더 좁은 정책을 가진 구현이 실행된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -86,7 +86,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다.
|
||||
선언적 재시도 애너테이션은 삭제됐고, 인접한 javadoc에는 왜 애너테이션 기반 재시도를 쓰지 않는지 이유가 적혀 있다.
|
||||
|
||||
## 삭제는 사고가 아니었다
|
||||
|
||||
|
||||
+6
-6
@@ -31,7 +31,7 @@ source:
|
||||
- **sanitize가 아니라 reject가 기본이다**
|
||||
강한 쪽 함수가 따르는 규칙이다.
|
||||
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다**
|
||||
같은 형태가 웹 어댑터에서 나타난 사례다.
|
||||
웹 어댑터에서도 더 강한 공용 검증기가 있었지만 실제 호출 경로는 더 약한 private 검증을 사용했다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -47,7 +47,7 @@ source:
|
||||
|
||||
값 타입은 그 함수를 부르지 않는다. 같은 파일의 약한 공개 함수도 부르지 않는다. 약한 쪽과 같은 논리를 private 메서드로 다시 썼고, 루프백 호스트 집합까지 같다. 그래서 약한 함수 이름으로 검색해도 이 호출처는 나오지 않는다.
|
||||
|
||||
이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석이 고치면서 무엇이 남았는지까지 적는다. 강한 가드는 바로 그 호출처를 위해 쓰였고 한동안 아무 데서도 불리지 않았으며, 자기 테스트는 통과하고 있었다는 것이다.
|
||||
이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석에는 공용 guard가 그 호출처를 위해 추가됐지만 한동안 실제 호출되지 않았고 자체 테스트만 통과했다는 이력이 적혀 있다.
|
||||
|
||||
호출처 도달을 고정하려고 만든 테스트가 있는데, 그 클래스의 머리글이 대상을 웹훅과 SES 둘로 적는다. 가드 자신의 javadoc 이 적은 둘은 웹푸시와 웹훅이다. 두 목록이 어긋나 있고, 그 테스트 파일에 웹푸시를 언급하는 줄은 0 이다.
|
||||
|
||||
@@ -86,7 +86,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
## 그 검사는 자기 목적에 대해서는 옳다
|
||||
|
||||
메서드 javadoc 에는 다루는 위협이 경로상의 도청이고 루프백 엔드포인트에는 그 경로가 없다고 적혀 있다. 예외를 루프백으로 좁힌 것은 계약 시험이 진짜 소켓을 쓸 수 있게 하려는 것이다.
|
||||
메서드 javadoc은 경로상의 도청을 막기 위해 HTTPS를 요구하고, loopback endpoint는 그 위협 모델에서 제외한다고 적는다. loopback만 예외로 둔 덕분에 계약 테스트는 실제 소켓을 사용할 수 있다.
|
||||
|
||||
전송 보안 규칙으로서 이 판단은 유지된다. 다만 같은 모듈의 이웃 파일이 그 논리로 남은 결과를 주석에 적어 뒀고, 그 내용은 뒤에서 본다.
|
||||
|
||||
@@ -105,14 +105,14 @@ a server-side request forgery primitive
|
||||
|
||||
값 타입은 두 공개 함수 중 어느 것도 부르지 않는다. 약한 쪽과 같은 논리를 private 으로 다시 썼고, 루프백 호스트 집합 `127.0.0.1`, `::1`, `localhost` 까지 같다. 약한 함수 이름으로 검색하면 이 호출처는 드러나지 않는다.
|
||||
|
||||
호출처 도달을 고정하려고 만든 테스트도 있다. 그 클래스의 머리글이 대상을 웹훅 대상과 SES 엔드포인트 둘로 적는다. 가드 javadoc 이 적은 둘과 한 자리가 다르고, 그 파일에 웹푸시를 언급하는 줄은 0 이다.
|
||||
호출 경로를 고정하려고 만든 테스트도 있다. 클래스 머리글은 대상을 웹훅 target과 SES endpoint로 적고, 공용 guard javadoc의 대상 목록과 하나가 다르다. 해당 테스트 파일에서는 web push를 언급하지 않는다.
|
||||
|
||||
## 내부망 주소를 넣으면 실제로 통과한다
|
||||
|
||||
:::evidence key="a-weaker-private-copy-on-the-wired-path-probe" alt="컴파일된 값 타입의 생성자에 목적지 여덟 개를 직접 넣어 통과와 거절을 출력한 결과, 이웃 호출처가 같은 결함을 고치며 남긴 주석, 그 엔드포인트가 POST 대상이 되는 지점, 값을 만드는 유일한 main 코드와 그 코드가 있는 복호 경로, 접수 유스케이스의 채널 거절, 그리고 플랫폼 마스터 스위치의 출하 기본값을 출력한 터미널 기록." caption="목적지 여덟 개 투입 결과 · 이웃 호출처의 주석 · POST 대상 지점 · 값 생성은 복호 경로 한 곳 · 마스터 스위치 기본값 false — 49줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
컴파일된 값 타입에 여덟 개를 넣었다. https 인 여섯은 전부 통과한다. 클라우드 메타데이터 주소 둘, 사설 대역, 사설 IPv6, 그리고 `user:pw@` 를 단 주소까지 지난다. 거절된 둘은 http 이고, 메시지는 루프백 밖에서는 https 여야 한다는 것이다.
|
||||
컴파일된 값 타입에 여덟 입력을 넣었다. HTTPS인 여섯 입력은 모두 통과했고, 그 안에는 클라우드 메타데이터 주소 둘·사설 대역·사설 IPv6·`user:pw@`가 포함됐다. 거절된 둘은 HTTP였으며 메시지는 loopback 밖에서는 HTTPS를 요구했다.
|
||||
|
||||
이웃 호출처의 주석이 같은 결함을 고치며 무엇이 남았는지 적는다.
|
||||
|
||||
@@ -130,7 +130,7 @@ for kept the weaker check.
|
||||
|
||||
그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다.
|
||||
|
||||
고칠 자리는 두 모듈 사이가 아니라 값 타입의 생성자 안이다. 컴포지션 루트 편의 표가 이 건에 배선 지점이 없다고 따로 적어 둔다.
|
||||
수정은 두 모듈의 wiring을 바꾸는 일이 아니라 값 타입 생성자가 공용 guard와 같은 검증을 사용하도록 만드는 쪽이다. 컴포지션 루트 편의 표도 이 문제에 별도 배선 지점이 없다고 적는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+13
-13
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: trust-policy-lives-in-nginx-not-in-the-code
|
||||
title: forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
|
||||
title: 테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 경로는 확인하지 않았다
|
||||
topic: duplicate-mechanisms
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
@@ -17,16 +17,16 @@ source:
|
||||
- 원본 분석 절은 final/document.md#3-1 · final/document.md#a14 §32.2 이다.
|
||||
---
|
||||
|
||||
# forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
|
||||
# 테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 경로는 확인하지 않았다
|
||||
|
||||
웹 리프의 프록시 패키지는 421 줄로 신뢰 프록시 정책과 헤더 정화기와 정규화 타입을 갖는다. 실제 신뢰 판정은 Nginx 설정이 하고, 그 설정은 들어온 forwarded 헤더를 원격 주소로 교체한다.
|
||||
웹 리프의 프록시 패키지는 421줄로 신뢰 프록시 정책과 헤더 정화기와 정규화 타입을 갖는다. 이번 evidence에서 확인한 `nginxProxyTest` 설정은 들어온 forwarded 헤더를 authoritative value로 교체한다. 이 테스트 설정이 실제 운영 배포의 trust boundary인지까지는 확인하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
이 사례가 그 규칙의 인프라 판이다.
|
||||
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 다른 필터에서 뒤집힌다**
|
||||
같은 리프에서 같은 계열의 사례다.
|
||||
같은 웹 리프에서 Java 정책과 프록시 설정이 중복되어 실제 신뢰 경계를 어느 쪽이 결정하는지 다시 확인해야 했다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -37,15 +37,15 @@ NormalizedForwardedHeaders 158 줄
|
||||
ForwardedHeaderSanitizer 72 줄
|
||||
UntrustedForwardedHeaderException 30 줄
|
||||
|
||||
이 코드가 하는 일은 어떤 프록시를 신뢰할지 정하고 forwarded 헤더를 정규화하는 것이다.
|
||||
이 코드는 신뢰할 프록시를 판정하고 forwarded 헤더를 정규화한다.
|
||||
|
||||
문제는 이것이 실제 판정 경로인가다.
|
||||
|
||||
## 결론
|
||||
|
||||
Nginx 설정이 그 판정을 대신한다.
|
||||
확인한 테스트용 Nginx 설정은 Java 앞단에서 forwarded 헤더를 교체한다.
|
||||
|
||||
프록시 헤더 설정 파일의 주석이 자기 지위를 명시한다. 이것이 권위 있는 forwarded 헤더이며 모든 location 에서 include 된다는 것이다.
|
||||
프록시 헤더 설정 파일의 주석은 이 파일을 forwarded 헤더의 authoritative 설정으로 두고 모든 location에서 include하도록 요구한다.
|
||||
|
||||
설정 내용은 교체다.
|
||||
|
||||
@@ -56,7 +56,7 @@ X-Forwarded-Host 를 이 배포의 공개 이름으로 설정
|
||||
|
||||
주석이 모든 줄이 SET 이고 ADD 가 아니라고 못 박는다. 클라이언트가 보낸 X-Forwarded-For 는 remote_addr 로 교체되고 X-Forwarded-Host 는 이 배포의 공개 이름으로 교체된다.
|
||||
|
||||
즉 애플리케이션에 도달하는 시점에 그 헤더들은 이미 신뢰할 수 있는 값이다. Java 정책이 판정할 것이 남아 있지 않다.
|
||||
이 테스트 구성에서는 애플리케이션에 도달하기 전에 forwarded 헤더가 교체된다. 하지만 운영 배포가 같은 설정을 사용한다는 evidence는 없으므로 실제 운영에서 Java 정책이 불필요하다고 단정하지 않는다.
|
||||
|
||||
Java 쪽 참조 수도 그것과 맞는다.
|
||||
|
||||
@@ -65,9 +65,9 @@ TrustedProxyPolicy : main 참조 1, test 참조 2
|
||||
UntrustedForwardedHeaderException : main 참조 1, test 참조 1
|
||||
NormalizedForwardedHeaders : main 참조 2
|
||||
|
||||
같은 설정 파일의 주석이 왜 include 방식인지도 적는다. Nginx 의 배열 지시어 상속 규칙이 병합이 아니라 교체이기 때문이다. location 안의 proxy_set_header 하나가 server 수준에서 상속된 모든 proxy_set_header 를 버린다. 보안 헤더를 server 수준에 두고 location 마다 하나씩 추가하는 설정은 보안 헤더를 하나도 보내지 않으며, 유일한 증상은 애플리케이션이 조용히 클라이언트를 다시 신뢰하는 것이다.
|
||||
같은 설정 파일의 주석은 Nginx 배열 지시어가 병합되지 않고 교체되기 때문에 include 방식을 쓴다고 설명한다. location 안에서 `proxy_set_header`를 하나라도 다시 선언하면 server 수준에서 상속받던 같은 계열 지시어를 잃을 수 있다. 따라서 forwarded 헤더 설정을 location마다 부분적으로 재정의하면 애플리케이션이 받는 신뢰 입력이 달라질 수 있다.
|
||||
|
||||
그 주석이 이 사례의 위험을 정확히 서술한다. 신뢰 판정이 인프라에 있으면 인프라 설정 실수가 애플리케이션의 신뢰 정책을 조용히 되돌린다. 그리고 그때 되돌아갈 Java 정책은 배선되어 있지 않다.
|
||||
이 테스트 설정이 운영에서도 trust boundary라면 인프라 설정 실수가 애플리케이션이 받는 forwarded 헤더 의미를 바꿀 수 있다. 다만 이번 searched direct-reference evidence만으로는 운영 시 fallback이 될 Java trust policy의 실제 framework/lifecycle wiring을 확정하지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -86,16 +86,16 @@ Nginx 설정 : 웹 리프의 nginxProxyTest 소스셋 아래 proxy_headers.conf
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
forwarded 헤더를 어디까지 믿을지 판정하는 Java 정책이 421 LOC 작성돼 있고 배선되지 않는다. 실제 판정은 Nginx 설정이 한다.
|
||||
forwarded 헤더를 다루는 Java 정책이 421 LOC 있고 searched direct reference 기준으로 사용 지점이 매우 적다. 별도로 `nginxProxyTest` 설정은 forwarded 헤더를 authoritative value로 교체한다. 두 사실을 확인했지만 이 테스트 설정이 운영 배포의 실제 trust boundary인지와 Java 정책의 모든 framework/lifecycle wiring 부재까지는 확인하지 않았다.
|
||||
|
||||
## 판정을 실제로 하는 곳
|
||||
|
||||
:::evidence key="trust-policy-lives-in-nginx-not-in-the-code" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 리뷰가 닿지 않는 자리로 정책이 옮겨졌다
|
||||
## Java 코드와 프록시 설정을 함께 봐야 한다
|
||||
|
||||
두 곳이 어긋나면 코드 리뷰가 잡을 수 없고, Java 쪽을 고쳐도 동작이 바뀌지 않는다.
|
||||
운영 배포가 이 프록시 설정을 실제로 사용한다면 Java 코드만 검토해서는 forwarded 헤더 교체 정책을 검증할 수 없다. 반대로 운영 설정을 확인하지 않은 상태에서는 Java 변경이 동작에 영향을 주지 않는다고 단정할 수도 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+7
-7
@@ -19,7 +19,7 @@ source:
|
||||
|
||||
# scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다
|
||||
|
||||
브라우저가 실행할 수 있는 콘텐츠를 탐지하는 정책이 접두사 시작 매칭을 쓴다. 마커 앞에 바이트가 하나라도 있으면 탐지되지 않고, 브라우저는 그런 파일도 실행한다.
|
||||
scriptable 콘텐츠를 탐지하려는 정책이 접두사 시작 매칭을 쓴다. BOM·NUL·주석 같은 prefix를 앞에 두면 detector가 마커를 놓치는 것은 hermetic probe로 확인했다. 실제 대상 브라우저가 각 입력을 실행 가능한 콘텐츠로 해석하는지는 이번 evidence에서 확인하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
@@ -42,7 +42,7 @@ source:
|
||||
|
||||
앞의 1024 바이트를 읽고 그 안에서 마커를 찾는데, 마커가 콘텐츠의 시작에 있어야 한다.
|
||||
|
||||
브라우저는 그렇게 엄격하지 않다. 앞에 바이트가 있어도 콘텐츠를 스니핑해 실행한다.
|
||||
이 probe만으로 실제 브라우저의 스니핑·실행 동작까지 증명할 수는 없다. 확인된 것은 prefix가 붙은 입력이 detector를 우회한다는 사실이다.
|
||||
|
||||
그래서 우회가 여럿이다.
|
||||
|
||||
@@ -50,7 +50,7 @@ source:
|
||||
널 바이트를 앞에 넣어도 같다
|
||||
주석이나 공백을 앞에 두어도 같다
|
||||
|
||||
실행 탐침이 이 우회들을 확인했다.
|
||||
hermetic detector probe가 이 우회 입력들이 탐지를 통과한다는 사실을 확인했다.
|
||||
|
||||
시그니처 검사와 스니핑 패턴 검사는 다른 문제다. 시그니처는 파일 형식이 정의상 특정 바이트로 시작하므로 시작 매칭이 맞다. 브라우저 스니핑은 형식 정의가 아니라 관용적 해석이므로 시작 매칭이 맞지 않는다.
|
||||
|
||||
@@ -59,7 +59,7 @@ source:
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 실행 탐침으로 우회 입력 확인
|
||||
확인 방식 : hermetic detector probe로 탐지 우회 입력 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
@@ -83,15 +83,15 @@ javadoc이 목적을 "Detection is on content, not on the claimed type or the ex
|
||||
|
||||
## hermetic probe 가 통과시킨 셋
|
||||
|
||||
UTF-8 BOM + `<html>` · 선행 HTML 주석 후 `<script>` · NUL 바이트 후 `<html>`. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 BOM(U+FEFF)도 NUL도 지우지 않는다. 셋 다 브라우저는 HTML로 렌더링하고, BOM 접두 HTML은 여러 편집기의 기본 출력이다.
|
||||
UTF-8 BOM + `<html>` · 선행 HTML 주석 후 `<script>` · NUL 바이트 후 `<html>`. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 BOM(U+FEFF)도 NUL도 지우지 않는다. 세 입력 모두 detector를 통과했다. 실제 대상 브라우저가 이 입력을 HTML 또는 scriptable content로 해석하는지는 이번 evidence에서 확인하지 않았다.
|
||||
|
||||
## 형제 검증기와의 대비가 판정을 굳힌다
|
||||
|
||||
`MediaTypeVerifier`의 매직바이트 선두 매칭은 시그니처의 정의가 파일 선두이므로 옳지만, scriptable 마커는 시그니처가 아니라 브라우저가 스니핑하는 패턴이다.
|
||||
`MediaTypeVerifier`의 매직바이트 선두 매칭은 시그니처의 정의가 파일 선두이므로 적합하다. 반면 이 scriptable detector는 prefix가 붙은 입력을 놓쳤고, 그 입력을 실제 브라우저가 어떻게 해석하는지는 별도 브라우저 검증이 필요하다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 브라우저가 각 우회 입력을 실행하는지 확인하지 않았다. 브라우저의 스니핑 동작은 명세와 구현이 모두 관여하므로 별도 확인이 필요하다.
|
||||
실제 대상 브라우저가 각 우회 입력을 실행 가능한 콘텐츠로 해석하는지 확인하지 않았다. 이번 evidence가 증명하는 범위는 detector bypass까지다.
|
||||
|
||||
안전 프로파일이 켜진 배포에서의 동작을 확인하지 않았다.
|
||||
|
||||
|
||||
+5
-5
@@ -83,9 +83,9 @@ preferIPv4Stack 설정 : x
|
||||
|
||||
httpclient 어댑터의 mTLS 계약 테스트 다섯 건 중 세 건이 실패한다. 신뢰할 수 없는 CA, 만료된 인증서, 호스트명 불일치다.
|
||||
|
||||
## 멈추는 자리는 범주가 아니라 단계다
|
||||
## 실패는 범주 판정보다 두 번째 단계 단언에서 멈춘다
|
||||
|
||||
:::evidence key="a-red-test-misread-as-a-product-defect" alt="이 리비전에서 모듈 전체 테스트를 실행해 283건 중 3건이 같은 줄에서 실패하는 것을 보인 출력과, 멈추는 자리가 두 번째 단언임을 실제 행 번호로 보인 단언 블록, 픽스처가 호스트명을 돌려주는 메서드, 이 컨테이너의 hosts 항목과 자바가 그 호스트명에서 얻는 주소 둘과 IP 스택 선호 설정 수, 그리고 영구 범주 목록과 재시도 엔진의 가드 순서를 뽑은 출력 55줄. 실패가 범주가 아니라 단계 단언에서 나고 호스트명이 주소 둘로 풀린다는 것이 그 출력에 보인다." caption="모듈 전체 283 중 3 실패 · 멈추는 자리는 168행 단계 단언 · 픽스처의 호스트명 · 주소 둘 · 영구 범주 목록과 가드 순서 — 55줄" zoom="true"
|
||||
:::evidence key="a-red-test-misread-as-a-product-defect" alt="이 리비전에서 모듈 전체 테스트를 실행해 283건 중 3건이 같은 줄에서 실패하는 것을 보인 출력과, 실패 지점이 두 번째 단언임을 실제 행 번호로 보인 단언 블록, 픽스처가 호스트명을 돌려주는 메서드, 이 컨테이너의 hosts 항목과 자바가 그 호스트명에서 얻는 주소 둘과 IP 스택 선호 설정 수, 그리고 영구 범주 목록과 재시도 엔진의 가드 순서를 뽑은 출력 55줄. 출력은 실패가 범주 판정보다 단계 단언에서 먼저 발생하고 호스트명이 주소 둘로 해석된다는 점을 보여 준다." caption="모듈 전체 283 중 3 실패 · 실패 지점은 168행 단계 단언 · 픽스처의 호스트명 · 주소 둘 · 영구 범주 목록과 가드 순서 — 55줄" zoom="true"
|
||||
:::
|
||||
|
||||
단언 블록은 잡은 예외를 분류기에 넣고 넷을 차례로 확인한다. 첫 줄은 통과한다 — 증거가 "보내지 않음"인 것은 맞다.
|
||||
@@ -106,11 +106,11 @@ httpclient 어댑터의 mTLS 계약 테스트 다섯 건 중 세 건이 실패
|
||||
|
||||
분류기는 자기가 받은 것을 정확히 분류했다. 정보는 도착하기 전에 이미 사라졌다.
|
||||
|
||||
## 사라진 자리는 이름 해석이다
|
||||
## 이름 해석에서 첫 연결 실패가 다음 주소 시도로 넘어간다
|
||||
|
||||
픽스처의 URI 메서드가 호스트명 localhost 를 돌려준다. 이 컨테이너의 hosts 파일은 그 이름을 IPv4 와 IPv6 양쪽에 주고, 자바도 두 주소를 돌려준다. 저장소 어디에도 IP 스택 선호를 고정하는 설정이 없다.
|
||||
|
||||
목 서버는 IPv4 루프백에만 바인딩한다. Apache 의 연결 오퍼레이터는 해석된 주소를 차례로 시도하는데, 마지막 주소가 아니면 실패를 로그로만 남기고 다음으로 넘어간다. 그 자리 로그 문구가 두 갈래로 나뉜다 — 마지막이면 "작업을 종료한다", 아니면 "다음 주소로 연결을 재시도한다".
|
||||
목 서버는 IPv4 loopback에만 바인딩한다. Apache의 연결 오퍼레이터는 해석된 주소를 차례로 시도하고, 마지막 주소가 아니면 연결 실패를 기록한 뒤 다음 주소로 넘어간다. 로그 문구도 마지막 주소에서는 "작업을 종료한다", 중간 주소에서는 "다음 주소로 연결을 재시도한다"로 갈린다.
|
||||
|
||||
첫 주소에서 TCP 는 붙고 핸드셰이크가 깨진다. 그 실패가 버려진다. 두 번째 주소에는 듣는 소켓이 없어 TCP 가 거부되고, 마지막이므로 승격된다.
|
||||
|
||||
@@ -127,7 +127,7 @@ httpclient 어댑터의 mTLS 계약 테스트 다섯 건 중 세 건이 실패
|
||||
|
||||
클라이언트 인증서를 제시하는 건은 첫 주소에서 핸드셰이크가 성공하고 루프가 즉시 반환한다. 두 번째 주소를 시도하지 않는다.
|
||||
|
||||
클라이언트 인증서가 없는 건은 다르다. TLS 1.3 에서 클라이언트가 자기 몫을 끝낸 뒤 서버가 거절하므로 실패가 연결 루프 밖에서 터지고 핸드셰이크 예외가 그대로 남는다. 그리고 그 테스트는 예외의 종류가 아니라 예외가 났는지만 단언한다.
|
||||
클라이언트 인증서가 없는 건은 다르다. TLS 1.3에서 클라이언트 쪽 handshake 진행 뒤 서버가 거절하면서 실패가 주소 재시도 루프 밖에서 발생하고, 분류기는 handshake 예외를 그대로 받는다. 해당 테스트는 예외 타입까지 고정하지 않고 예외 발생 여부만 단언한다.
|
||||
|
||||
처음 눈에 띈 차이는 실패하는 세 건만 클라이언트 인증을 요구하지 않는 픽스처를 쓴다는 점이었다. 그것은 원인이 아니라 상관이었다.
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id:
|
||||
kind: CONCEPT
|
||||
slug: three-failure-vocabularies
|
||||
title: 실패 어휘 세 층과 그 사이를 잇는 SQLState 매트릭스
|
||||
topic: http-failure-classification
|
||||
topicName: HTTP 실패 분류와 재시도 안전성
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
studio: ""
|
||||
basisVersion: sourceRevision 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
source:
|
||||
- final/document.md#a02
|
||||
- final/document.md#a05
|
||||
- final/document.md#5-1
|
||||
- final/document.md#a05 §7.1
|
||||
- final/document.md#a02
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# 실패 어휘 세 층과 그 사이를 잇는 SQLState 매트릭스
|
||||
|
||||
저수준 driver 오류, 애플리케이션 failure category, 외부 HTTP 응답은 서로 다른 질문에 답한다. 이 프로젝트는 그 사이를 명시적 매핑으로 연결하고, 모르는 값은 추측하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **인식하지 못한 SQLSTATE 는 추측하지 않는다**
|
||||
매트릭스에 없는 값을 임의의 failure category로 바꾸지 않는 결정이다.
|
||||
- **같은 SQLState 를 둘이 등록하면 값이 같아도 시작을 실패시킨다**
|
||||
매핑 소유권을 한 곳으로 유지하는 규칙이다.
|
||||
- **전송 여부 판정은 evidence와 operation semantics를 함께 본다**
|
||||
failure category가 곧 retry eligibility가 아니라는 후속 결정이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 첫 번째 어휘는 공급자 오류다
|
||||
|
||||
JDBC나 HTTP client는 SQLSTATE, status, exception type처럼 공급자에 가까운 값을 준다. 이 값은 어떤 기술 경계에서 실패했는지를 말하지만 애플리케이션 정책을 직접 결정하지 않는다.
|
||||
|
||||
## 두 번째 어휘는 failure category다
|
||||
|
||||
애플리케이션은 transient, conflict, unavailable처럼 정책이 이해할 수 있는 범주로 변환한다. PostgreSQL 경로에서는 SQLSTATE 매트릭스가 이 변환을 소유한다.
|
||||
|
||||
매트릭스에 없는 SQLSTATE는 기존 항목과 비슷해 보인다는 이유로 추측하지 않는다. 새 매핑이 필요하면 그 소유자를 명시적으로 추가한다.
|
||||
|
||||
## 세 번째 어휘는 외부 계약이다
|
||||
|
||||
HTTP 응답은 클라이언트가 알아야 할 상태와 오류 코드를 표현한다. 내부 failure category와 1:1일 필요는 없고, 내부 구현 세부가 그대로 노출되어서도 안 된다.
|
||||
|
||||
## retry는 별도 판정이다
|
||||
|
||||
failure category는 retry 입력 중 하나다. 최종 retry eligibility는 전송 evidence, operation의 멱등성·의미, deadline과 retry budget을 함께 본다.
|
||||
|
||||
현재 source repository를 다시 실행하지 못했으므로 이 기록은 SSOT가 고정한 매핑 구조를 설명한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: an-unrecognised-sqlstate-is-not-guessed
|
||||
title: 인식하지 못한 SQLSTATE 는 추측하지 않는다
|
||||
topic: http-failure-classification
|
||||
topicName: HTTP 실패 분류와 재시도 안전성
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
source:
|
||||
- final/document.md#a05
|
||||
- final/document.md#10-2
|
||||
- final/document.md#5-1
|
||||
- final/document.md#a05 §7.1
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
---
|
||||
|
||||
# 인식하지 못한 SQLSTATE 는 추측하지 않는다
|
||||
|
||||
매트릭스에 등록되지 않은 SQLSTATE를 이름이나 class prefix가 비슷하다는 이유로 기존 failure category에 넣지 않는다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **실패 어휘 세 층과 그 사이를 잇는 SQLState 매트릭스**
|
||||
공급자 오류와 애플리케이션 범주 사이에 명시적 번역 계층을 둔다.
|
||||
- **같은 SQLState 를 둘이 등록하면 값이 같아도 시작을 실패시킨다**
|
||||
매핑의 값과 소유권을 둘 다 명시적으로 유지한다.
|
||||
|
||||
## 결정문
|
||||
|
||||
등록되지 않은 SQLSTATE는 기존 category로 추측해 분류하지 않는다. 정책에 필요한 상태라면 매트릭스에 명시적으로 등록하고 해당 매핑의 소유자를 정한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
SQLSTATE의 접두사나 주변 예외가 비슷해도 operation semantics와 retry 안전성이 같다는 보장은 없다. 추측 분류는 새로운 데이터베이스 상태를 기존 정책에 조용히 편입시킨다.
|
||||
|
||||
명시 등록을 요구하면 새 상태를 지원하는 순간 코드 리뷰와 테스트에 변경점이 생긴다. 알 수 없는 값을 아는 값처럼 다루지 않는 쪽을 선택한다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것 : 새로운 SQLSTATE가 나타나면 매핑을 추가하기 전까지 자동 복구 정책을 적용하지 못할 수 있다.
|
||||
|
||||
얻는 것 : 미지의 오류가 retryable 또는 permanent로 조용히 오분류되지 않는다.
|
||||
|
||||
얻는 것 : 매핑 추가 시 어떤 모듈이 그 의미를 소유하는지 함께 검토할 수 있다.
|
||||
+4
-4
@@ -21,15 +21,15 @@ source:
|
||||
|
||||
# 재시도 안전성은 증거에 기반해 판정한다
|
||||
|
||||
재시도 여부는 무엇이 실패했는지가 아니라 무엇이 관측됐는지로 판정한다. 요청이 서버에 닿지 않았으면 재시도는 첫 시도이고 닿았을지도 모르면 중복인데, 예외 타입은 그 구분을 담지 않는다.
|
||||
전송 여부와 commit ambiguity는 관측 evidence로 판정한다. 다만 최종 재시도 eligibility는 그 evidence만으로 정하지 않고 failure category, operation의 멱등성·의미, deadline·retry budget·policy를 함께 본다.
|
||||
|
||||
## 결정문
|
||||
|
||||
재시도 여부는 무엇이 실패했는지가 아니라 무엇이 관측됐는지로 판정한다.
|
||||
전송 여부 판정은 evidence에 기반하고, 최종 재시도 여부는 evidence와 failure category, operation semantics, retry budget을 함께 보고 정한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
같은 예외라도 요청이 서버에 닿았는지에 따라 재시도의 의미가 완전히 달라진다. 닿지 않았으면 재시도는 첫 시도이고, 닿았을지도 모르면 재시도는 중복이다. 예외 타입은 그 구분을 담지 않으므로, 관측된 진행 정도를 별도의 값으로 기록하고 그것을 판정 입력으로 삼는다.
|
||||
같은 예외라도 요청이 서버에 닿았는지에 따라 재시도의 의미가 달라진다. 닿지 않았다는 evidence가 있으면 transmission ambiguity가 줄고, 닿았을 가능성이 있으면 중복 실행 위험을 고려해야 한다. 예외 타입만으로는 그 구분이 부족하므로 관측된 진행 정도를 별도의 값으로 기록한다. 그 값은 최종 결정의 한 축이며 failure category와 멱등성·operation semantics, 남은 deadline과 retry budget도 함께 입력으로 들어간다.
|
||||
|
||||
이 판정을 보수적으로 유지하는 것이 핵심이다. 전송되지 않았다는 판정은 단계 실패가 그것을 증명할 때만 쓰고, 일반적인 엔진 입출력 실패는 결코 그 판정으로 승격되지 않는다. 모호한 것을 전송되지 않음으로 추측하는 것이 타임아웃을 중복 결제로 바꾸는 경로다.
|
||||
|
||||
@@ -49,7 +49,7 @@ source:
|
||||
|
||||
같은 실패에 대해 Apache JDK Reactor Netty Jetty 가 동일한 재시도와 관측 동작을 낸다.
|
||||
|
||||
재시도 정책이 HTTP 메서드 같은 간접 신호에 기대지 않는다.
|
||||
재시도 정책이 HTTP 메서드 하나 같은 간접 신호에만 기대지 않고 전송 evidence와 operation semantics를 함께 사용한다.
|
||||
|
||||
## 근거
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: one-sqlstate-with-two-contributors-fails-startup
|
||||
title: 같은 SQLState 를 둘이 등록하면 값이 같아도 시작을 실패시킨다
|
||||
topic: http-failure-classification
|
||||
topicName: HTTP 실패 분류와 재시도 안전성
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
source:
|
||||
- final/document.md#a05
|
||||
- final/document.md#5-1
|
||||
- final/document.md#a05 §7.1
|
||||
---
|
||||
|
||||
# 같은 SQLState 를 둘이 등록하면 값이 같아도 시작을 실패시킨다
|
||||
|
||||
여러 모듈이 같은 SQLSTATE 매트릭스에 기여할 때는 최종 값이 같더라도 중복 등록을 허용하지 않는다. 매핑 값뿐 아니라 어느 모듈이 그 항목을 소유하는지도 계약의 일부로 본다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **실패 어휘 세 층과 그 사이를 잇는 SQLState 매트릭스**
|
||||
이 규칙이 적용되는 매핑 계층을 설명한다.
|
||||
- **인식하지 못한 SQLSTATE 는 추측하지 않는다**
|
||||
매핑 누락과 중복을 모두 명시적으로 다루는 짝이 되는 결정이다.
|
||||
|
||||
## 목적
|
||||
|
||||
last-writer-wins 병합이 매핑 소유권 충돌을 숨기지 못하게 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 한 SQLSTATE는 한 contributor가 소유한다.
|
||||
2. 두 contributor가 같은 SQLSTATE를 등록하면 매핑 결과가 같아도 startup validation을 실패시킨다.
|
||||
3. 충돌을 해결하려면 한쪽 등록을 제거하거나 소유권을 하나로 합친다.
|
||||
4. map merge 결과만 비교하지 않고 contributor identity를 함께 검사한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
여러 모듈이 같은 SQLSTATE 또는 오류 코드 매트릭스에 항목을 기여할 때
|
||||
|
||||
여러 기여자의 등록 결과가 런타임 failure classification을 결정할 때
|
||||
|
||||
## 예외
|
||||
|
||||
기여자가 하나뿐인 매트릭스에는 중복 소유권 검사가 필요하지 않다.
|
||||
|
||||
중복 등록 자체를 계약으로 허용하려면 어떤 contributor가 우선하는지 별도 규칙과 검증이 있어야 한다. 현재 이 프로젝트는 그 방식을 선택하지 않는다.
|
||||
|
||||
## 예시
|
||||
|
||||
모듈 A와 B가 모두 같은 SQLSTATE를 같은 category로 등록해도 “결과가 같으니 괜찮다”고 병합하지 않는다. 둘 중 누가 이 매핑을 변경해야 하는지 알 수 없기 때문이다.
|
||||
+2
-2
@@ -21,7 +21,7 @@ source:
|
||||
|
||||
# 두 번째 플랫폼이 첫 번째의 bridge 부재는 막고 게이트 배선은 옮기지 않았다
|
||||
|
||||
gRPC 가족은 messaging 을 명시적으로 참조하며 만들어졌다. 옮겨진 것 셋은 전부 레지스트리와 빌드 파일로 표현되는 규칙이고, 옮겨지지 않은 것 셋은 전부 Gradle 태스크와 CI 설정으로 표현되는 규칙이다.
|
||||
gRPC 가족은 messaging을 명시적으로 참조하며 만들어졌다. 현재 두 가족을 비교하면 옮겨진 것 셋은 레지스트리와 빌드 파일에, 옮겨지지 않은 것 셋은 Gradle 태스크와 CI 설정에 나타난다. 이 분포는 관찰된 상관이며, 표현 형식 때문에 전이가 일어나거나 실패했다고 단정할 evidence는 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
@@ -56,7 +56,7 @@ gRPC 가족은 그 이후에 만들어졌고 messaging 을 명시적으로 참
|
||||
CI 가 돌리는 게이트. 28개 워크플로 중 gRPC 를 이름에 담은 것이 0 이다.
|
||||
조립에 연결된 시작 검증기. 검증기는 있고 자동설정이 부르지 않는다.
|
||||
|
||||
분류가 깨끗하다. 옮겨진 셋은 전부 다음 사람이 편집하게 되는 파일에 있고, 옮겨지지 않은 셋은 전부 그렇지 않은 파일에 있다.
|
||||
이번 두 가족에서는 분포가 뚜렷하다. 옮겨진 셋은 다음 변경자가 자주 만나는 레지스트리와 빌드 파일에 있고, 옮겨지지 않은 셋은 Gradle 태스크와 CI 설정 쪽에 있다. 다만 이 위치 차이를 누락의 원인으로 보지는 않는다.
|
||||
|
||||
운영 문서는 그 상태를 정확히 공시한다. 출시되지 않았고 빌드 전용이라고 적는다. 이 점에서 messaging 의 지원 매트릭스와 대비된다.
|
||||
|
||||
|
||||
+3
-3
@@ -23,16 +23,16 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
1. 규칙이 어디에 적혀 있는지가 전이 여부를 정한다
|
||||
다음 사람이 같은 파일을 편집하면서 마주치는 규칙은 따라 하게 된다. 마주치지 않는 규칙은 그렇지 않다.
|
||||
|
||||
2. 마주치는 자리는 레지스트리와 빌드 파일과 타입 시그니처다
|
||||
2. 새 모듈을 만들 때 반드시 여는 파일에 규칙을 둔다
|
||||
새 모듈을 만들려면 그 파일들을 반드시 연다.
|
||||
|
||||
3. 마주치지 않는 자리는 Gradle 태스크와 CI 설정이다
|
||||
3. 별도로 찾아야 하는 Gradle 태스크와 CI 규칙은 체크리스트에 넣는다
|
||||
새 모듈을 만들면서 워크플로 파일을 열 이유가 없다.
|
||||
|
||||
4. CI 로만 표현된 규칙은 복제 시 명시적으로 옮긴다
|
||||
전이되지 않는다고 단정하기보다, 복제 체크리스트에 그 항목을 두는 것이 안전한 형태다.
|
||||
|
||||
5. 이력이 남은 자리도 같은 기준으로 본다
|
||||
5. 결함 이력도 다음 구현자가 실제로 읽는 문서에 연결한다
|
||||
앞선 결함의 기록이 다른 가족의 테스트 파일에만 있으면 그 기록은 전이되지 않는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
+17
-19
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: three-ways-rls-does-nothing
|
||||
title: RLS가 아무것도 하지 않는 세 가지 방법
|
||||
title: RLS 검증에서 구분해야 할 우회와 미적용 경로
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
@@ -17,28 +17,26 @@ source:
|
||||
- 원본 분석 절은 final/document.md#4-1 · final/document.md#a05 §13.1, §17 P8 이다.
|
||||
---
|
||||
|
||||
# RLS가 아무것도 하지 않는 세 가지 방법
|
||||
# RLS 검증에서 구분해야 할 우회와 미적용 경로
|
||||
|
||||
정책 검증기가 행 수준 보안이 조용히 무력화되는 세 경로를 전부 확인한다. 다만 검증기 자신이 보호되어야 할 테이블의 부재를 성공으로 인정하는 결함을 갖는다.
|
||||
`RlsPolicyVerifier`는 RLS 활성 여부, 적용 가능한 policy, 런타임 role의 우회 속성, table owner 여부와 `FORCE ROW LEVEL SECURITY` 상태를 확인한다. 이 값들은 하나의 ‘세 전제’가 아니다. 일반 role에서 RLS가 활성화됐는데 적용 가능한 policy가 없으면 PostgreSQL은 default deny를 적용하고, owner는 FORCE 여부에 따라 policy 적용 여부가 갈린다. 검증기 자체에는 보호 대상 테이블의 부재를 성공으로 인정하는 별도 결함이 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **RLS가 성립하기 위한 세 전제**
|
||||
이 검증기가 확인하는 세 조건이다.
|
||||
- **PostgreSQL RLS 적용 여부를 가르는 분기**
|
||||
이 검증기가 읽는 값들을 PostgreSQL의 실제 적용 규칙에 맞춰 해석한 개념이다.
|
||||
- **Hibernate filter는 보안 경계가 아니다**
|
||||
같은 격리 문제를 ORM 기능으로 풀려 할 때의 규칙이다.
|
||||
- **tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다**
|
||||
같은 마이그레이션이 함께 다룬 축이다.
|
||||
- **tenant 내부 유일성과 전역 유일성은 구분한다**
|
||||
tenant 내부에서만 유일해야 하는 값은 tenant 식별자를 포함하고, 전역 유일성이 요구사항이면 global unique를 유지할 수 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
행 수준 보안은 설정이 올바르게 보이면서 아무것도 하지 않을 수 있다. 그 경로가 셋이다.
|
||||
행 수준 보안은 조건에 따라 서로 다른 결과를 만든다.
|
||||
|
||||
정책이 없거나 테이블에 RLS 가 활성화되지 않았다
|
||||
런타임 롤이 우회 속성을 갖는다
|
||||
런타임 롤이 그 테이블을 소유한다
|
||||
RLS가 비활성화되어 있으면 policy가 적용되지 않는다. RLS가 활성화됐지만 현재 role에 적용 가능한 policy가 없으면 일반 role에는 default deny가 적용된다. superuser나 `BYPASSRLS` role은 RLS를 우회한다. table owner는 기본적으로 policy를 우회하지만 `FORCE ROW LEVEL SECURITY`가 켜지면 policy 대상이 된다.
|
||||
|
||||
셋 다 같은 결과를 만든다. 모든 쿼리가 모든 테넌트의 행을 돌려주면서 정책은 올바르게 설정된 것처럼 보인다.
|
||||
따라서 이 검증에서 봐야 할 것은 ‘셋 중 하나가 틀리면 모두 허용’이 아니라 현재 role이 어느 분기에 속하고 그 분기에서 policy가 실제로 적용되는지다.
|
||||
|
||||
## 결론
|
||||
|
||||
@@ -48,19 +46,19 @@ source:
|
||||
|
||||
세 번째가 가장 놓치기 쉽다. 소유자는 기본적으로 자기 정책에서 면제되고, 소유자는 흔히 마이그레이션 롤이며, 그것이 사람들이 테스트하는 롤이다.
|
||||
|
||||
같은 축의 문제가 유니크 인덱스에도 있고 마이그레이션 주석이 그것을 적는다. 테넌트 격리가 컬럼에 의존하면 그 테이블의 모든 유일성 요구에 그 컬럼이 들어가야 한다. 값만으로 걸린 유니크 인덱스는 다른 테넌트가 그 값을 이미 썼다는 이유로 한 테넌트의 삽입을 실패시키고, 그것은 버그이자 정보 유출이다.
|
||||
같은 축의 문제가 유니크 인덱스에도 있고 마이그레이션 주석이 그것을 적는다. tenant 내부에서만 유일해야 하는 값은 tenant 식별자를 포함해야 한다. 반대로 시스템 전체에서 유일해야 하는 값은 global unique로 둘 수 있다. 따라서 `(value)`만의 unique index가 문제인지는 그 값의 유일성 범위가 tenant 내부인지 전역인지에 따라 판단한다.
|
||||
|
||||
다만 검증기 자신에게 결함이 있다. 반드시 보호되어야 할 테이블이 조회 결과에 없을 때 그것을 성공으로 인정한다. 즉 테이블 이름이 바뀌거나 조회 조건이 어긋나면 검증이 통과한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
데이터베이스 : PostgreSQL
|
||||
확인 방식 : 검증기가 검사하는 세 조건과 마이그레이션 주석 확인
|
||||
확인 방식 : 검증기가 읽는 RLS 분기 값과 마이그레이션 주석 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 정책 검증기의 클래스 javadoc 을 읽는다. 세 경로가 열거되어 있다.
|
||||
1. 정책 검증기의 클래스 javadoc을 읽어 기존 구현이 열거한 세 경우를 확인한다.
|
||||
2. 검증기가 실행하는 카탈로그 조회를 확인한다.
|
||||
3. 마이그레이션의 유니크 인덱스 주석을 읽는다.
|
||||
4. 보호 대상 테이블이 조회 결과에 없을 때의 처리를 확인한다.
|
||||
@@ -69,7 +67,7 @@ source:
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`RlsPolicyVerifier`가 RLS가 조용히 무력화되는 세 경로를 전부 확인한다 — policy 없음/RLS 미활성 · 런타임 롤이 `BYPASSRLS` 보유 · **런타임 롤이 테이블을 소유**(FORCE 없으면 면제).
|
||||
`RlsPolicyVerifier`는 RLS 활성 여부와 policy 상태, runtime role의 `BYPASSRLS`, table owner 여부와 `FORCE ROW LEVEL SECURITY`를 함께 본다. 이 값들은 PostgreSQL이 policy를 적용할지 결정하는 서로 다른 분기다.
|
||||
|
||||
## RlsPolicyVerifier 참조 위치
|
||||
|
||||
@@ -78,11 +76,11 @@ source:
|
||||
|
||||
## 세 번째가 가장 놓치기 쉽다
|
||||
|
||||
소유자는 정책을 우회하는 것이 아니라 애초에 적용 대상이 아니다.
|
||||
table owner는 기본적으로 RLS policy를 우회한다. `FORCE ROW LEVEL SECURITY`를 켜면 owner도 policy 적용 대상이 된다.
|
||||
|
||||
## unique index 에도 같은 축의 주석이 있다
|
||||
|
||||
tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요구**에 그 컬럼이 들어가야 하고, `(value)`만의 unique index는 다른 tenant가 그 값을 썼다는 이유로 insert를 실패시켜 **버그이자 정보 유출**이 된다.
|
||||
tenant 내부 유일성을 표현하는 uniqueness 요구에는 tenant 식별자를 포함해야 한다. `(value)`만의 unique index는 전역 유일성을 뜻하므로, 요구사항이 tenant 내부 유일성이라면 다른 tenant의 값 때문에 insert가 실패한다. 반대로 전역 유일성이 실제 요구사항이면 global unique 자체가 잘못은 아니다.
|
||||
|
||||
## verifier 자신에게도 결함이 있다
|
||||
|
||||
@@ -90,6 +88,6 @@ tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
세 조건을 하나씩 깨서 검증기가 실제로 잡는지 확인하지 않았다. 실제 RLS 환경을 세우지 않았다.
|
||||
RLS 비활성, 우회 role, owner/FORCE, applicable policy 부재를 실제 PostgreSQL 환경에서 각각 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
+27
-23
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: rls-three-preconditions
|
||||
title: RLS가 성립하기 위한 세 전제
|
||||
title: PostgreSQL RLS 적용 여부를 가르는 분기
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
@@ -20,14 +20,14 @@ source:
|
||||
- 원본 분석 절은 final/document.md#4-1 · final/document.md#a05 §13.1 이다.
|
||||
---
|
||||
|
||||
# RLS가 성립하기 위한 세 전제
|
||||
# PostgreSQL RLS 적용 여부를 가르는 분기
|
||||
|
||||
PostgreSQL 의 행 수준 보안이 실제로 격리하려면 세 가지가 동시에 참이어야 한다. 셋 중 하나만 어긋나도 정책은 올바르게 설정된 것처럼 보이면서 모든 쿼리가 모든 테넌트의 행을 돌려준다.
|
||||
PostgreSQL의 행 수준 보안은 `ENABLE ROW LEVEL SECURITY`, role의 우회 권한, table owner 여부, `FORCE ROW LEVEL SECURITY`, 적용 가능한 policy 유무에 따라 동작이 갈린다. 이 값들을 항상 동시에 참이어야 하는 세 전제로 묶으면 default deny와 owner 예외를 잘못 설명하게 된다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **RLS가 아무것도 하지 않는 세 가지 방법**
|
||||
세 전제를 검증기가 실제로 확인하는 사례다.
|
||||
- **RLS 검증에서 구분해야 할 우회와 미적용 경로**
|
||||
검증기가 읽는 값이 PostgreSQL의 어느 적용 분기에 해당하는지 확인한 사례다.
|
||||
- **Hibernate filter는 보안 경계가 아니다**
|
||||
ORM 기능을 격리 경계로 쓸 때의 문제를 다룬 규칙이다.
|
||||
- **격리 설정은 트랜잭션 로컬이어야 한다**
|
||||
@@ -37,18 +37,21 @@ PostgreSQL 의 행 수준 보안이 실제로 격리하려면 세 가지가 동
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
PostgreSQL RLS가 실제로 격리하려면 세 가지가 동시에 참이어야 한다.
|
||||
PostgreSQL RLS는 동시에 만족해야 할 세 조건이 아니라 적용 여부를 결정하는 분기로 보는 편이 정확하다.
|
||||
|
||||
## 격리가 성립하는 세 조건
|
||||
## RLS 적용 여부를 결정하는 순서
|
||||
|
||||
:::evidence key="rls-three-preconditions-diagram" alt="ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 롤이 격리 성립 조건 안에 나란히 놓인다" caption="격리가 성립하는 세 조건" zoom="false"
|
||||
:::evidence key="rls-three-preconditions-diagram" alt="RLS 활성 여부에서 시작해 BYPASSRLS·superuser 우회, table owner와 FORCE RLS, applicable policy 유무를 구분하는 분기" caption="PostgreSQL RLS 적용 여부를 가르는 분기" zoom="false"
|
||||
:::
|
||||
|
||||
1. `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다.
|
||||
2. `FORCE ROW LEVEL SECURITY` — **테이블 OWNER에게도** 적용한다. 없으면 owner는 자기 policy에서 면제되고, **owner는 흔히 마이그레이션 롤이며 그것이 사람들이 테스트하는 롤이다.**
|
||||
3. 런타임 롤이 `BYPASSRLS`를 갖지 않는다 — 이것은 테이블 속성이 아니라 롤 속성이라 startup에서 assert해야 한다.
|
||||
1. `ENABLE ROW LEVEL SECURITY`가 꺼져 있으면 policy는 적용되지 않는다.
|
||||
2. RLS가 켜져 있어도 superuser와 `BYPASSRLS` role은 우회한다.
|
||||
3. 일반 non-owner role은 policy 대상이다.
|
||||
4. table owner는 기본적으로 우회하지만 `FORCE ROW LEVEL SECURITY`를 켜면 policy 대상이 된다.
|
||||
5. policy 대상인데 적용 가능한 policy가 없으면 default deny가 적용되어 row가 보이거나 수정되지 않는다.
|
||||
6. 적용 가능한 policy가 있으면 `USING`과 `WITH CHECK`가 실제 row 가시성과 쓰기를 결정한다.
|
||||
|
||||
## 동시에 참이어야 하는 세 가지
|
||||
## 검증기가 읽는 값과 PostgreSQL 의미를 분리한다
|
||||
|
||||
:::evidence key="rls-three-preconditions" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
@@ -59,11 +62,11 @@ policy가 `current_setting('app.tenant_id', true)`를 쓴다 — 두 번째 인
|
||||
|
||||
:::note
|
||||
|
||||
실제 RLS 환경을 세워 세 전제를 하나씩 깨보지 않았다
|
||||
실제 RLS 환경에서 각 적용 분기를 하나씩 재현하지 않았다
|
||||
|
||||
:::
|
||||
|
||||
## 세 전제
|
||||
## 기존 verifier javadoc이 적은 세 경우
|
||||
|
||||
```java
|
||||
/**
|
||||
@@ -83,23 +86,24 @@ policy가 `current_setting('app.tenant_id', true)`를 쓴다 — 두 번째 인
|
||||
*/
|
||||
```
|
||||
|
||||
세 번째가 이 개념의 핵심이다.
|
||||
위 javadoc은 현재 검증기가 의도한 모델을 보여 주지만, ‘세 경우 모두 모든 row를 반환한다’는 일반화는 PostgreSQL 의미와 맞지 않는다. 특히 RLS가 켜진 상태에서 applicable policy가 없으면 일반 role에는 default deny가 적용된다.
|
||||
|
||||
| 전제 | 무엇인가 | 왜 놓치는가 |
|
||||
|---|---|---|
|
||||
| 정책과 RLS 활성화 | 테이블 속성 | 가장 명시적이라 잘 보인다 |
|
||||
| 런타임 롤이 `BYPASSRLS` 를 갖지 않음 | 롤 속성 | 테이블을 봐서는 알 수 없다 |
|
||||
| 테이블 소유자에게도 강제 | 테이블 속성 | 소유자가 대개 마이그레이션 롤이고, 사람들이 그 롤로 테스트한다 |
|
||||
| 확인 항목 | PostgreSQL에서의 의미 |
|
||||
|---|---|
|
||||
| RLS 활성 여부 | 꺼져 있으면 policy가 적용되지 않는다 |
|
||||
| superuser / `BYPASSRLS` | RLS를 우회한다 |
|
||||
| table owner | 기본적으로 우회하며 `FORCE ROW LEVEL SECURITY`가 owner 동작을 바꾼다 |
|
||||
| applicable policy | 없으면 policy 대상 role에는 default deny가 적용된다 |
|
||||
|
||||
## 소유자 면제가 왜 함정인가
|
||||
|
||||
테이블 소유자는 자기 정책에서 면제된다. `FORCE ROW LEVEL SECURITY` 를 켜야 소유자에게도 적용된다.
|
||||
테이블 소유자는 기본적으로 RLS policy를 우회한다. owner도 policy 대상이어야 한다면 `FORCE ROW LEVEL SECURITY`를 켠다. 이 설정은 superuser나 `BYPASSRLS` role의 우회를 없애는 옵션이 아니다.
|
||||
|
||||
그리고 소유자는 흔히 마이그레이션 롤이다. 그 롤이 스키마를 만들었기 때문이다. 검증할 때 쓰는 롤도 대개 그것이다.
|
||||
스키마를 만든 migration role이 해당 table owner가 되는 경우가 있다. 이때 중요한 것은 `migration role`이라는 이름이 아니라 owner 여부다. superuser나 `BYPASSRLS` 여부는 별도의 role 속성이고, 검증에 같은 owner role을 쓰면 owner bypass가 policy 적용 여부를 가릴 수 있다.
|
||||
|
||||
:::danger
|
||||
|
||||
마이그레이션 롤로 테스트하면 정책이 전혀 적용되지 않은 상태에서도 통과한다. 그리고 그 롤은 정책이 잘 붙어 있다고 보고한다.
|
||||
migration role이 해당 table owner이고 `FORCE ROW LEVEL SECURITY`가 꺼진 상태에서 그 롤로 테스트하면 owner bypass 때문에 policy가 적용되지 않은 채 테스트가 통과할 수 있다. `migration role`이라는 이름 자체는 bypass 조건이 아니다. superuser나 `BYPASSRLS`라면 그 속성 때문에 별도로 우회한다.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
+6
-6
@@ -29,7 +29,7 @@ source:
|
||||
- **legacy 채택은 서로 다른 두 승인자의 서명을 요구한다**
|
||||
이 경로가 요구하도록 설계된 결정이다.
|
||||
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
|
||||
검증기가 존재하는 것과 배선되는 것이 다르다는 사례다.
|
||||
승인 검증 타입은 존재하지만 실제 apply 경로가 그 검증기를 호출하지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -50,9 +50,9 @@ source:
|
||||
연산 키가 발행 요청의 연산 키와 같은지
|
||||
매니페스트 해시가 요청의 것과 같은지
|
||||
레거시 네임스페이스 다이제스트가 같은지
|
||||
대상 목적지가 같은지
|
||||
대상 네임스페이스 다이제스트가 같은지
|
||||
현재 시각이 유효 구간 안인지
|
||||
- 대상 목적지가 같은지
|
||||
- 대상 네임스페이스 다이제스트가 같은지
|
||||
- 현재 시각이 유효 구간 안인지
|
||||
|
||||
전부 일관성 검사다. 그 승인이 누가 발급한 것인지는 묻지 않는다.
|
||||
|
||||
@@ -75,8 +75,8 @@ Spring Boot : 4.0.8
|
||||
|
||||
1. 레거시 마이그레이션 설정 클래스의 조건과 빈 정의를 읽는다.
|
||||
2. 채택 서비스의 생성자 인자를 확인한다. 승인 검증기가 없다.
|
||||
3. 승인 확인 메서드가 무엇을 검사하는지 읽는다. 필드 일관성뿐이다.
|
||||
4. Ed25519 승인 검증기를 생성하는 코드를 저장소 전체에서 찾는다. 자기 테스트뿐이다.
|
||||
3. 승인 확인 메서드가 검사하는 항목을 읽는다. 현재 구현은 필드 일관성만 검사한다.
|
||||
4. Ed25519 승인 검증기의 생성 지점을 찾는다. 확인한 직접 생성 코드는 자기 테스트에만 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
|
||||
+6
-6
@@ -27,11 +27,11 @@ source:
|
||||
## 관계
|
||||
|
||||
- **APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없다**
|
||||
같은 리프에서 서명 검증이 배선되지 않은 사례다.
|
||||
같은 리프에서 서명 검증 타입은 존재하지만 apply 경로가 호출하지 않은 사건을 다룬다.
|
||||
- **직접 multipart의 마지막 part는 grant를 받을 수 없다**
|
||||
허가 발급 경로의 공백을 다룬 사례다.
|
||||
허가를 발급하는 주체가 조립되지 않은 경로를 다룬다.
|
||||
- **서명된 grant의 endpoint 검증이 upload 경로에만 있다**
|
||||
허가 검증 지점의 비대칭을 다룬 사례다.
|
||||
grant 발급과 검증이 서로 다른 경로에 놓인 비대칭을 다룬다.
|
||||
- **커서에 서명하는 이유는 기밀성이 아니라 무결성이다**
|
||||
같은 서명 원칙의 다른 적용이다.
|
||||
|
||||
@@ -67,17 +67,17 @@ opaque identity(클라이언트가 물리 위치를 알 수 없음), privilege s
|
||||
|
||||
## 왜 단계를 두는가
|
||||
|
||||
객체가 저장소에 있다는 것과 그것이 게시되었다는 것은 다른 사실이다. 업로드 중이거나 검증 중이거나 취소된 객체가 게시된 객체와 같은 자리에 있으면 구별할 방법이 없다.
|
||||
객체가 저장소에 존재하는 것과 게시 승인을 받은 것은 다른 상태다. 업로드 중·검증 중·취소 상태를 별도 lifecycle state로 기록하지 않으면 저장 객체만 보고 게시 가능 여부를 구분할 수 없다.
|
||||
|
||||
그래서 스테이징 단계를 거친다. 그 단계에 있는 객체는 저장소에 존재하지만 게시되지 않았다.
|
||||
|
||||
## 왜 허가에 서명하는가
|
||||
|
||||
클라이언트가 저장소에 직접 업로드하는 구성에서는 서버가 그 접근에 관여하지 않는다. 서버가 할 수 있는 것은 접근 범위를 미리 정하고 그것을 서명해 클라이언트에게 주는 것이다.
|
||||
클라이언트가 저장소에 직접 업로드하는 구성에서는 서버가 데이터 전송을 중계하지 않는다. 서버는 허용할 목적지·범위·유효 시간을 grant에 담아 서명하고 클라이언트에게 전달한다.
|
||||
|
||||
서명이 없으면 클라이언트가 범위를 고칠 수 있다. 목적지나 키나 유효 기간을 바꾸면 허가가 다른 것이 된다.
|
||||
|
||||
커서 서명과 같은 원리다. 페이로드를 숨기는 것이 아니라 변조를 감지하는 것이다.
|
||||
서명은 페이로드를 숨기지 않는다. 서버가 발급한 grant가 전송 중 바뀌었는지를 검증한다.
|
||||
|
||||
## 허가가 담는 것
|
||||
|
||||
|
||||
+5
-5
@@ -30,7 +30,7 @@ outbox 리스가 만료 시각만 담고 최종 상태 쓰기가 메시지 식
|
||||
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
||||
이 사례가 만든 개념이다.
|
||||
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
||||
이 결함의 수정 형태를 규칙으로 옮긴 것이다.
|
||||
이 사건에서 사용한 owner token·revision 조건을 재사용 가능한 CAS 규칙으로 정리한다.
|
||||
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
||||
만료된 claim 과 만료된 실행을 갈라 다루는 판단이다.
|
||||
|
||||
@@ -80,7 +80,7 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
|
||||
:::evidence key="a-lease-without-an-owner" alt="코드베이스에서 V2 마이그레이션이 더한 컬럼과 토큰 제약, 다섯 상태에서 여섯으로 바뀐 CHECK, 청구문이 소유자와 토큰을 쓰는 SET 절과 그 청구가 읽는 술어와 그에 맞춘 부분 인덱스, 최종 쓰기의 펜싱 술어와 0행을 보고한다는 주석, 릴레이가 부르는 다섯 전이, 펜싱 없는 옛 메서드의 선언과 그 자신의 javadoc 과 신세대 javadoc 의 폐기 문장과 @Deprecated 매치 수와 호출 수, 두 세대가 AMBIGUOUS 에서 남기는 컬럼의 차이, 그리고 파일서버 마이그레이션이 같은 결함을 다른 곳에서 지목하는 줄을 뽑은 출력. 옛 메서드의 javadoc 이 아직 안전을 주장하고 신세대 javadoc 이 그것을 폐기라 적는다는 것이 나란히 보인다." caption="V2 컬럼과 제약 · 청구 술어와 부분 인덱스 · 펜싱 술어와 0행 보고 · 옛 경로의 두 javadoc · AMBIGUOUS 에서의 세대 차이" zoom="true"
|
||||
:::
|
||||
|
||||
토큰에는 음수가 아니라는 제약이 붙는다. 백필은 하지 않는다 — 기본값이 0 이고 첫 청구가 그것을 올리므로 정확성에 필요하지 않으며, 제약은 코드가 의존하는 불변식을 스키마에 남기려는 것이다.
|
||||
토큰에는 음수가 아니라는 제약이 붙는다. 기본값이 0이고 첫 claim이 값을 증가시키므로 별도 backfill은 하지 않는다. 제약은 코드가 의존하는 불변식을 스키마에서도 검사하게 한다.
|
||||
|
||||
청구문이 토큰을 `o.lease_token + 1` 로 올린다. 청구를 내주는 바로 그 문장 안에서, 서버가 올린다. 그래서 같은 행을 두고 경쟁한 두 릴레이가 같은 번호를 받을 수 없다.
|
||||
|
||||
@@ -90,7 +90,7 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
|
||||
|
||||
최종 쓰기의 술어에 `lease_owner` 와 `lease_token` 이 들어간다. 지나간 획득의 쓰기는 걸릴 행이 없다.
|
||||
|
||||
그 자리 javadoc 이 0행을 어떻게 다루는지 적는다. 0행은 삼키지 않고 보고한다 — 지나간 쓰기가 있었다는 것은 이 작업자가 중복 발행을 만들었을 수 있다는 뜻이고, 그것이 운영자가 봐야 하는 사실이라는 것이다.
|
||||
최종 갱신 메서드의 javadoc은 update count가 0일 때 이를 삼키지 않고 보고하도록 요구한다. 소유권을 잃은 뒤 쓰기를 시도했다면 중복 발행 가능성을 조사해야 하기 때문이다.
|
||||
|
||||
술어를 붙이는 SQL 문자열은 두 개이고, 그 둘을 만드는 헬퍼 둘이 다섯 전이의 최종 쓰기를 전부 처리한다.
|
||||
|
||||
@@ -104,11 +104,11 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
|
||||
|
||||
이 저장소의 프로덕션 코드는 청구도 전이 넷도 전부 신세대만 부른다. 아래는 지금 일어나는 일이 아니라 포트가 두 형태를 나란히 둔 결과다.
|
||||
|
||||
옛 청구 메서드가 인터페이스에 그대로 있다. 그 메서드 자신의 javadoc 은 아직 이렇게 적는다 — 단순 조회가 아니라 리스를 잡는 것이 여러 릴레이를 안전하게 만들고, 한 릴레이가 청구한 레코드는 리스가 만료될 때까지 다른 릴레이에 보이지 않으므로 같은 메시지가 두 프로세스에서 동시에 발행되지 않는다는 것이다.
|
||||
옛 claim 메서드는 인터페이스에 남아 있다. 해당 javadoc은 단순 조회 대신 lease를 획득해, 한 relay가 claim한 레코드를 lease 만료 전까지 다른 relay가 다시 claim하지 못하게 한다고 설명한다.
|
||||
|
||||
이 사례가 반증한 문장이 그대로 있다.
|
||||
|
||||
폐기를 적은 것은 신세대 쪽 javadoc 이다. 옛 메서드는 토큰 없는 레코드를 돌려주므로 호출자가 자기 쓰기가 자기 청구에 속한다는 것을 증명할 수 없고, 조사 경로용으로 남기며 릴레이가 쓰기에는 폐기되었다는 것이다.
|
||||
신규 API javadoc은 옛 메서드를 relay 경로에서 사용하지 말라고 명시한다. 옛 메서드는 fencing token 없는 레코드를 반환하므로 후속 쓰기가 현재 claim에 속하는지 증명할 수 없고, 조사 용도로만 남긴다.
|
||||
|
||||
그 문장은 산문이다. messaging 트리 전체에 `@Deprecated` 가 하나도 없다.
|
||||
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-mark-that-meant-seen-not-projected
|
||||
title: 「본 적 있는 위치」를 「투영이 끝난 위치」로 쓴 mark 가 재전달된 이벤트를 삼켰다
|
||||
topic: owner-safe-state-machines
|
||||
topicName: owner-safe 상태 기계 — 소유권을 SQL에 적기
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-mark-that-meant-seen-not-projected
|
||||
source:
|
||||
- final/document.md#8-1
|
||||
- final/document.md#a06
|
||||
- final/document.md#4-2
|
||||
- final/document.md#8-1 항목 9
|
||||
- final/document.md#a06 §67
|
||||
---
|
||||
|
||||
# 「본 적 있는 위치」를 「투영이 끝난 위치」로 쓴 mark 가 재전달된 이벤트를 삼켰다
|
||||
|
||||
이벤트 위치를 “관측했다”는 시점에 mark를 전진시키면 projection이 끝나기 전에 failover가 발생했을 때 재전달 이벤트를 이미 처리한 것으로 오인할 수 있다. 기존 분석은 한 번의 failover probe에서 이 손실 경로를 재현했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **CAS tuple과 update count**
|
||||
상태 전이가 완료됐다는 조건을 owner와 revision으로 함께 검사하는 개념이다.
|
||||
- **lease에는 owner가 있어야 한다**
|
||||
worker가 바뀌는 경계에서 이전 소유자의 진행 상태를 그대로 신뢰하지 않는 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
mark가 이벤트를 읽은 직후 전진하고 실제 projection 반영은 그 뒤에 실행되면 두 상태 사이에 공백이 생긴다.
|
||||
|
||||
worker가 그 공백에서 중단되고 다른 worker가 mark부터 읽으면, 새 worker는 해당 위치를 이미 완료한 것으로 판단해 이벤트를 건너뛸 수 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
“마지막으로 본 위치”와 “projection을 완료한 위치”는 같은 상태가 아니다. 완료 mark는 projection이 성공적으로 반영된 뒤에만 전진해야 한다.
|
||||
|
||||
기존 probe는 worker 하나와 한 번의 평범한 failover만 다뤘다. 여러 worker나 반복 resume에서 손실 폭이 어떻게 달라지는지는 확인하지 않았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
sourceRevision : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
확인 방식 : SSOT에 기록된 failover probe 결과
|
||||
현재 source repository 재대조 : UNVERIFIABLE
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 이벤트 위치 mark가 갱신되는 시점과 projection 반영 시점을 분리해 확인한다.
|
||||
2. mark 전진 뒤 projection 완료 전에 worker를 중단한다.
|
||||
3. 다른 worker가 같은 stream을 resume했을 때 해당 이벤트가 다시 처리되는지 확인한다.
|
||||
4. 이번 검토에서는 기존 SSOT probe 결과만 사용했고 새 실행은 하지 않았다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 필요한 상태는 세 개다
|
||||
|
||||
아직 보지 않은 위치, 본 적은 있지만 projection이 끝나지 않은 위치, projection까지 완료한 위치를 구분해야 한다.
|
||||
|
||||
두 상태만 두고 “봤다”를 “끝났다”로 사용하면 failover 중간 상태를 표현할 수 없다.
|
||||
|
||||
## mark는 완료 뒤에 전진한다
|
||||
|
||||
projection 반영과 mark 갱신이 하나의 owner-safe 전이로 묶이거나, 적어도 완료가 증명된 뒤 mark를 갱신해야 한다. 새 worker는 완료 mark만 resume 기준으로 사용한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
기존 probe는 단일 worker의 한 번 failover다. 반복 failover와 다중 worker에서의 손실 폭은 측정하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+12
-12
@@ -58,7 +58,7 @@ IdempotencyCapabilityGuard 의 클래스 자바독(:12~:21)이 무엇이 틀렸
|
||||
OpenJDK : 21.0.12
|
||||
Spring Boot : 4.0.8
|
||||
근거 : 저장소의 자바독이 사후 기록으로 남긴 회귀
|
||||
확인 방식 : 가드 자바독의 사후 기록 확인, 필드와 생성자의 널 검사 유무 확인, 전제 검사 메서드의 세 검사와 각 실패 메시지 확인, 데이터소스를 정하는 메서드와 그 자바독 확인, 저장소를 만드는 자리 전수와 각각이 넘기는 값 확인, JdbcOperations 구현과 JdbcTemplate 하위 클래스 검색, 세 번째 검사의 메시지를 단언하는 시험 검색, 가드의 두 메서드를 부르는 자리 전수, 형제 어댑터 둘의 데이터소스 주입과 세 검사 형태 대조
|
||||
확인 방식 : 가드 javadoc의 사후 기록 확인, 필드와 생성자의 null 검사 확인, 전제 검사 메서드의 세 검사와 실패 메시지 확인, DataSource 추론 메서드 확인, 저장소 생성 지점과 전달 값 확인, JdbcOperations 구현 검색, 세 번째 검사 테스트 검색, 가드 호출 지점 전수 확인, 형제 어댑터의 DataSource 주입 방식과 검사 비교
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
@@ -68,26 +68,26 @@ Spring Boot : 4.0.8
|
||||
3. 전제를 검사하는 메서드의 본문을 끝까지 읽고 검사가 몇 개인지, 각각 어떤 메시지로 실패하는지 적는다.
|
||||
4. 데이터소스 검사에 붙은 조건을 읽고 그 값이 어디서 오는지 거슬러 올라간다.
|
||||
5. 그 값을 정하는 메서드와 자바독을 읽는다.
|
||||
6. 이 저장소를 만드는 자리를 전부 찾고 각각이 무엇을 넘기는지 확인한다.
|
||||
6. 이 저장소를 생성하는 코드를 전부 찾고 각 생성자가 무엇을 넘기는지 확인한다.
|
||||
7. 저장소에 그 인터페이스의 다른 구현이 있는지 찾는다.
|
||||
8. 세 번째 검사가 던지는 메시지를 저장소에서 찾아 그것을 단언하는 시험이 있는지 본다.
|
||||
9. 가드의 두 메서드를 부르는 자리를 전부 찾는다.
|
||||
9. 가드의 두 메서드를 호출하는 코드를 전부 찾는다.
|
||||
10. 형제인 outbox 와 inbox 어댑터가 데이터소스를 어떻게 받고 같은 세 검사를 어떤 형태로 거는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`IdempotencyCapabilityGuard` 는 멱등성 저장소가 SQL 을 돌리기 전에 만족해야 할 전제를 모아 둔 타입이다. 클래스 자바독이 이 타입이 따로 생긴 이유를 적는데, 전제가 서로 다른 세 질문이고 저장소가 세 번째를 틀리게 답했다는 것이다.
|
||||
`IdempotencyCapabilityGuard`는 멱등성 저장소가 SQL을 실행하기 전에 세 전제를 검사한다. 클래스 javadoc은 기존 저장소가 그중 DataSource 소유권 질문을 잘못 검사해 별도 guard로 분리했다고 기록한다.
|
||||
|
||||
## 자바독이 남긴 사후 기록
|
||||
|
||||
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소를 만드는 자리 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
|
||||
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소의 생성 지점 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
자바독 `:12`\~`:16` 은 세 질문을 나열한다. 스키마가 승인되었는가, 트랜잭션이 있는가, 그것이 이 저장소의 트랜잭션인가.
|
||||
|
||||
이어서 저장소가 세 번째를 틀리게 답했다고 적는다. 스레드에 활성 읽기 쓰기 트랜잭션이 있는지만 확인했는데 그 조건은 어느 데이터소스에서든 트랜잭션이 열려 있으면 참이고, 형제인 outbox 와 inbox 어댑터는 `hasResource(dataSource)` 를 확인하며 그것이 실제로 중요한 질문이라는 것이다.
|
||||
기존 코드는 스레드에 활성 read-write transaction이 있는지만 확인했다. 이 조건은 다른 DataSource의 transaction에도 참이 될 수 있다. 형제인 outbox·inbox 어댑터는 `hasResource(dataSource)`까지 검사해 현재 transaction이 같은 DataSource를 사용하고 있는지 확인한다.
|
||||
|
||||
`:18`\~`:21` 이 결과를 적는다. 데이터소스가 둘인 애플리케이션에서 다른 쪽의 트랜잭션 안에서 발행된 변경이 옛 검사를 통과했고, 이 저장소의 커넥션에서 트랜잭션 없이 실행됐으며, 원자적이어야 할 작업과 독립적으로 커밋됐다.
|
||||
|
||||
@@ -109,11 +109,11 @@ Spring Boot : 4.0.8
|
||||
|
||||
넣는 쪽은 `PostgreSqlOwnerSafeIdempotencyStore:91` 이다. 생성자가 `dataSourceOf(jdbc)` 로 값을 만드는데, `:105`\~`:109` 의 그 메서드는 `jdbc` 가 `JdbcTemplate` 이면 `template.getDataSource()` 를 돌려주고 아니면 널을 돌려준다.
|
||||
|
||||
그 자바독(`:98`\~`:104`)이 절충을 인정한다. `JdbcTemplate` 은 자기 데이터소스를 알지만 손으로 만든 `JdbcOperations` 는 모를 수 있고, 가드는 알 수 없는 데이터소스를 "검사할 수 없음" 으로 다루는데 그것은 outbox 어댑터의 정확한 검사보다 약하고 이전보다는 강하며 협력자를 정말로 식별할 수 없을 때의 정직한 답이라는 것이다.
|
||||
해당 javadoc(`:98`\~`:104`)은 `JdbcTemplate`에서는 DataSource를 식별할 수 있지만 임의의 `JdbcOperations` 구현에서는 식별하지 못할 수 있음을 적는다. guard는 DataSource를 알 수 없을 때 이 검사를 생략하므로 outbox 어댑터보다 약한 보장이다.
|
||||
|
||||
## 그 널 경로가 실제로 열리는 곳
|
||||
## DataSource 추론이 null이 될 수 있는 생성 경로
|
||||
|
||||
이 저장소를 만드는 자리는 셋이다.
|
||||
이 저장소의 생성 지점은 셋이다.
|
||||
|
||||
프로덕션은 `PostgreSqlIdempotencyProviderConfig:60` 하나이고 `JdbcOperations` 빈을 받는다. 그 메서드의 자바독 `:55`\~`:56` 은 저장소의 트랜잭션 가드가 그것으로부터 자기 데이터소스를 식별하므로 다른 데이터소스에서 연 트랜잭션은 통과할 수 없다고 적는다.
|
||||
|
||||
@@ -135,11 +135,11 @@ Spring Boot : 4.0.8
|
||||
|
||||
갈리는 것은 데이터소스를 얻는 방법이다. 형제 둘은 생성자가 `DataSource` 를 직접 받고 `:158` 과 `:210` 이 `Objects.requireNonNull` 로 거른다. 널일 수 없으므로 널 가드가 필요 없다. 가드는 `JdbcOperations` 에서 추론하고, 추론이 실패하면 널이 된다.
|
||||
|
||||
## 이 가드를 부르는 자리
|
||||
## 가드를 호출하는 메서드
|
||||
|
||||
`PostgreSqlOwnerSafeIdempotencyStore` 의 여섯 자리 — `:122`, `:176`, `:211`, `:255`, `:303`, `:360` — 가 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction` 으로 넘긴다. `requireActiveCapability` 를 부르는 자리는 `:123` 하나다.
|
||||
`PostgreSqlOwnerSafeIdempotencyStore`의 여섯 호출 지점 — `:122`, `:176`, `:211`, `:255`, `:303`, `:360` — 이 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction`으로 넘긴다. `requireActiveCapability`는 `:123`에서 한 번 호출한다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
## 원문과 다른 검사 순서
|
||||
|
||||
원문은 이 수정이 세 번째 질문을 형제와 같은 형태로 바꾼 것이라고 적었다. 바꾼 것이 아니라 더한 것이다. `:93` 의 옛 검사가 그대로 첫 줄에 있다.
|
||||
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ source:
|
||||
|
||||
독립 Flyway 스트림 설계의 접착제다. 각 capability 스트림의 V1이 세 단계를 밟는다 — (1) 선행조건 검사(`DO $$ ... RAISE EXCEPTION`으로 core epoch가 ACTIVE인지), (2) 테이블 생성, (3) **자기를 `INSTALLED_INACTIVE`로 등록**.
|
||||
|
||||
## 적용과 사용 승인이 갈리는 자리
|
||||
## 설치 상태와 사용 승인을 분리한다
|
||||
|
||||
:::evidence key="capability-schema-registry-diagram" alt="선행조건 검사에서 테이블 생성으로 core epoch 확인이 건너가고 테이블 생성에서 레지스트리 등록으로 INSTALLED_INACTIVE 가 건너간다" caption="적용과 사용 승인이 갈리는 자리" zoom="false"
|
||||
:::evidence key="capability-schema-registry-diagram" alt="선행조건 검사에서 테이블 생성으로 core epoch 확인이 건너가고 테이블 생성에서 레지스트리 등록으로 INSTALLED_INACTIVE 가 건너간다" caption="설치 상태와 사용 승인 분리" zoom="false"
|
||||
:::
|
||||
|
||||
## 어댑터가 런타임에 다시 묻는다
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ update idempotency_record
|
||||
|
||||
전이마다 리비전이 오른다. 그래서 같은 소유자라도 자기가 본 리비전이 아니면 갱신이 0 건이 된다. 소유권만으로는 부족하고 시점까지 맞아야 한다.
|
||||
|
||||
## 한 자리에 모으는 이유
|
||||
## CAS 조건을 공통 헬퍼에 모으는 이유
|
||||
|
||||
```java
|
||||
/**
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
4. 전이마다 상태 리비전을 올린다
|
||||
소유권만으로는 부족하다. 자기가 본 시점까지 맞아야 한다.
|
||||
|
||||
5. 같은 가드를 쓰는 문장을 한 자리에 모은다
|
||||
흩어져 있으면 그중 하나가 조건을 짧게 쓰는 것을 막을 수 없다.
|
||||
5. 같은 CAS 가드는 공통 SQL 생성 코드에서 만든다
|
||||
전이마다 WHERE 조건을 따로 작성하면 한 전이만 owner token이나 revision을 빠뜨릴 수 있다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
|
||||
+5
-5
@@ -12,7 +12,7 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
# 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
|
||||
|
||||
애플리케이션 시계로 리스 만료를 판단하거나 잠그기 전의 시각으로 판단해, 서로 다른 노드가 같은 행에 대해 다른 답을 내는 것을 막는다. 시각은 데이터베이스에서 읽고, 행을 잠근 다음에 읽으며, 판정과 갱신을 한 문장 안에 둔다.
|
||||
애플리케이션 시계로 리스 만료를 판단하거나 lock wait 이전의 시각으로 판단해 서로 다른 노드가 같은 행에 대해 다른 답을 내는 것을 막는다. 이 규칙에서는 행 lock을 획득한 뒤 PostgreSQL `clock_timestamp()`를 읽는다. transaction 시작 시각인 `now()`·`CURRENT_TIMESTAMP`·`transaction_timestamp()`는 lock wait 이후의 실제 시각을 반영하지 않으므로 이 용도의 대체재로 쓰지 않는다.
|
||||
|
||||
## 목적
|
||||
|
||||
@@ -20,11 +20,11 @@ verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 시각은 데이터베이스에서 읽는다
|
||||
여러 노드의 시계는 서로 다르다. 리스 만료 판정의 기준 시각이 노드마다 다르면 두 노드가 동시에 소유자가 될 수 있다.
|
||||
1. 행 lock 뒤 `clock_timestamp()`를 읽는다
|
||||
여러 노드의 애플리케이션 시계 대신 DB 시계를 쓰고, lock wait가 끝난 뒤 호출 시점의 실제 시각을 읽는다. `now()`와 `CURRENT_TIMESTAMP`는 transaction start time이라 이 용도에는 맞지 않는다.
|
||||
|
||||
2. 행을 잠근 다음에 읽는다
|
||||
잠그기 전의 시각으로 판단하면 잠금을 기다리는 동안 리스가 만료될 수 있다.
|
||||
2. lock 이전에 읽은 시각을 재사용하지 않는다
|
||||
잠금을 기다리는 동안 lease가 만료될 수 있으므로 판정에 쓰는 시각은 lock 획득 후 다시 얻는다.
|
||||
|
||||
3. 판정과 갱신을 한 문장 안에 둔다
|
||||
시각 비교를 where 절에 넣으면 판정과 갱신 사이에 시간이 흐르지 않는다.
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ source:
|
||||
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
|
||||
시작 검증기가 도는지를 자동설정 루트로 확인하는 절차다.
|
||||
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
|
||||
플래그와 실행의 어긋남이 반대 방향으로 나타난 사례다.
|
||||
Redis에서는 설정이 보증을 요구하지만 startup probe가 자동설정 경로에 연결되지 않아 실행되지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -77,7 +77,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
확인 메서드가 두 호출로 넷을 덮는다. 앞의 호출이 서버 버전과 배포 모드와 데이터베이스 번호와 명령 목록으로 능력을 판정하고, 뒤의 호출이 복제 배포의 쓰기 내구성을 요구한다.
|
||||
|
||||
클래스 javadoc 이 왜 서버에 묻는지 적는다. 설정은 배포가 무엇을 의도하는지 말하고 서버만이 무엇이 참인지 말한다는 것, 관리형 Redis 가 광고하는 버전이 모듈의 존재를 뜻하지 않는다는 것, 그리고 복제 배포의 쓰기 내구성은 어떤 클라이언트도 보상할 수 없는 서버 설정이라는 것이다.
|
||||
클래스 javadoc은 설정이 배포 의도를 표현할 뿐 실제 서버 상태를 증명하지 못한다고 설명한다. 관리형 Redis의 광고 버전만으로 모듈 존재를 확정할 수 없고, 복제 쓰기 내구성은 서버 설정을 직접 읽어 확인해야 한다.
|
||||
|
||||
같은 문단이 시점의 값도 적는다. 빠진 능력을 첫 요청에서 발견하면 장애이고, 여기서 발견하면 실패한 배포다.
|
||||
|
||||
@@ -95,7 +95,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
복제 최소 개수와 상한이 걸린 최대 지연이 그 상황을 호출자가 대응할 수 있는 거절로 바꾼다. 같은 승격이 그때는 2,086건 대신 한 건을 잃었다.
|
||||
|
||||
그래서 그 설정 없는 복제 배포는 경고가 아니라 기동 실패라고 적는다. 보증을 무의미하게 만드는 설정은 조용히 성능을 낮추는 대신 컨텍스트를 멈춘다는 것이 이 SDK 의 원칙이라는 것이다.
|
||||
javadoc은 요구한 복제 보증을 무효화하는 서버 설정이 확인되면 경고만 남기지 않고 기동을 거부하도록 설계했다고 적는다.
|
||||
|
||||
면제는 가능하다. 배포가 정말로 쓰기 하나를 잃어도 괜찮을 수 있고 이 SDK 가 서버를 소유하지 않기 때문이다. 다만 면제에는 명시적 설정이 필요해서, 그 거래가 사고 중에 발견되는 대신 기록으로 남는다.
|
||||
|
||||
@@ -105,7 +105,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
복제 개수만으로는 몇 개가 연결되어 있어야 하는지만 정해진다. 얼마나 뒤처져도 되는지는 최대 지연이 정하고, Redis 는 그 값 0 을 지연 요구 없음으로 다룬다.
|
||||
|
||||
그래서 복제 둘을 요구하면서 지연 상한을 0 으로 둔 배포는, 임의로 뒤처진 복제 둘이 붙어 있기만 하면 쓰기를 받는다. 개수 요구가 없애려던 바로 그 노출이 그대로 남는다.
|
||||
그래서 복제 둘을 요구하면서 지연 상한을 0으로 둔 배포는, 두 replica가 연결돼 있기만 하면 지연 정도와 무관하게 쓰기를 받을 수 있다. replica 개수만 검사해서는 뒤처진 replica를 허용하는 위험을 막지 못한다.
|
||||
|
||||
개수만 검사하던 동안 그 설정이 통과했다. 그러면서 실패 메시지는 운영자에게 지연 상한을 설정하라고 말하고 있었다.
|
||||
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ source:
|
||||
|
||||
# 명령 카탈로그와 admission 아홉 단계
|
||||
|
||||
모든 Redis 명령이 하나의 승인 지점을 지나고, 그 지점은 정해진 순서로 검사한다. 순서의 기준은 비용이다. 명백히 거부될 명령은 무엇도 인코딩되거나 전송되기 전에 거부된다.
|
||||
이 문서는 `CommandPolicyGuard`를 통과하도록 설계된 guarded command path의 admission 순서를 설명한다. 실제 코드에는 semantic adapter가 gateway를 직접 호출해 이 경로를 우회하는 사례가 있으므로 현재 runtime 전체의 모든 Redis 명령이 하나의 승인 지점을 지난다고 말하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
@@ -39,12 +39,12 @@ source:
|
||||
|
||||
이 SDK가 명령 하나를 내보내기 전에 지나는 단계의 설명이다 — 카탈로그 분류(BLOCKED·R3·R4 거부) · capability/최소 버전 확인 · permit provenance 검증 · 네임스페이스 검사 · Cluster 동일 슬롯 검사 · 요청 예산 · 정책 기반 레인·타임아웃 유도 · 실패 번역 · 관측.
|
||||
|
||||
## 명령 입장의 단일 지점
|
||||
## 의도된 guarded command path의 입구
|
||||
|
||||
:::evidence key="redis-admission-stages-diagram" alt="CommandPolicyGuard 에서 카탈로그를 통과하면 실행이고 미분류나 BLOCKED 이면 거절인 두 갈래가 나온다" caption="명령 입장의 단일 지점" zoom="false"
|
||||
:::
|
||||
|
||||
그 위에 얹힌 계약은 gateway가 "everything routed through it has already passed `CommandPolicyGuard`"를 전제한다는 것이다 — 그래서 정책·permit·예산·타임아웃·관측을 자기 관심사로 두지 않는다.
|
||||
gateway 계약은 자신에게 도달한 호출이 이미 `CommandPolicyGuard`를 지났다고 전제한다. 그래서 guarded path 안에서는 정책·permit·예산·타임아웃·관측을 guard 쪽 책임으로 둔다. 다만 직접 gateway 호출이 존재하면 그 전제 자체가 깨지므로 이 그림은 전체 runtime topology가 아니라 의도된 guarded path를 나타낸다.
|
||||
|
||||
## CommandPolicyGuard 참조 위치
|
||||
|
||||
|
||||
+2
-2
@@ -76,9 +76,9 @@ Flyway 에서 중복 버전은 병합해 해결하는 충돌이 아니다. 기
|
||||
:::evidence key="messaging-migrations-collide-at-v2" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 지금 실패하지 않는 유일한 이유
|
||||
## 현재 정적 검색에서 확인한 범위
|
||||
|
||||
**그 위치를 아무도 Flyway에 주지 않기 때문**이다(main 코드에서 `db/migration/messaging`을 부르는 곳 0건). 각 leaf의 IT는 자기 jar 리소스만 보므로 재현하지 못한다.
|
||||
searched direct reference 기준으로 main 코드에서 `db/migration/messaging` 문자열을 사용하는 지점을 찾지 못했다. 이것은 해당 위치를 직접 지정하는 코드가 검색되지 않았다는 증거이지 framework configuration·resource scanning·reflection·외부 설정까지 포함해 runtime에서 절대 스캔되지 않는다는 증거는 아니다. 각 leaf의 IT는 자기 jar 리소스만 보므로 두 leaf가 함께 있는 충돌도 재현하지 못한다.
|
||||
|
||||
## 원 계획은 달랐다
|
||||
|
||||
|
||||
+5
-5
@@ -26,13 +26,13 @@ source:
|
||||
- **등급표 13행 중 일곱을 스스로 강등하고 한 행만 관측과 어긋났다**
|
||||
등급표가 이 능력을 모형 단계로 적고 있다.
|
||||
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
|
||||
같은 형태가 mongo 어댑터에서 나타난 사례다.
|
||||
mongo 어댑터에서도 startup validator가 요구한 능력과 실제 조립된 실행 경로가 어긋났다.
|
||||
- **커서에 서명하는 이유는 기밀성이 아니라 무결성이다**
|
||||
이 커서 서명이 지키려는 성질이다.
|
||||
|
||||
## 문제
|
||||
|
||||
시작 검증기가 프로덕션 배포에서 커서 서명 키를 요구한다. 없으면 기동을 거절하고 이유를 적는다 — 서명되지 않은 커서는 클라이언트가 편집할 수 있다는 것이다.
|
||||
시작 검증기는 프로덕션 배포에서 cursor signing key가 없으면 기동을 거부한다. 오류 메시지는 서명되지 않은 cursor를 클라이언트가 수정할 수 있다는 위험을 설명한다.
|
||||
|
||||
이 검증기는 자동설정이 실제로 부른다.
|
||||
|
||||
@@ -46,7 +46,7 @@ source:
|
||||
|
||||
그런데 릴리스 게이트가 읽는 매니페스트는 이 능력을 안정 등급 승인 목록에 넣어 두었다.
|
||||
|
||||
즉 결함은 능력이 미완이라는 것이 아니다. 미완인 능력에 대해 플랫폼이 요구하고 승인해 주는 신호를 이미 켜 두었다는 것이다.
|
||||
문제는 능력이 미완인 상태 자체가 아니라, 실제 signing path가 없는데도 startup validator와 지원 신호가 이미 signing key를 필수 요건처럼 취급한다는 점이다.
|
||||
|
||||
닫는 것은 배선 결정이 아니라 설계 결정이다. 키링 팩토리는 키 재료를 인자로 받는데, 설정은 키 자체가 설정에 나타나지 않는다고 일부러 정해 두었다.
|
||||
|
||||
@@ -90,7 +90,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
커넥션 조립기를 만드는 프로덕션 코드가 0 이다.
|
||||
|
||||
배포되는 스키마는 상태 확인 필드 하나다. 그 파일의 주석이 왜 그런지 적는다 — 이 모듈은 기능 없이도 독립으로 부팅해야 하고, Spring for GraphQL 은 빈 스키마로는 시작하지 않으며, 스켈레톤은 기능 타입 이름을 절대 대면 안 된다는 것이다.
|
||||
배포되는 스키마에는 상태 확인 필드 하나만 있다. 파일 주석은 모듈이 기능 없이도 독립 부팅되어야 하고 Spring for GraphQL은 빈 스키마로 시작할 수 없기 때문에 이 최소 필드를 둔다고 설명한다. 기능 타입 이름은 여기서 노출하지 않는다.
|
||||
|
||||
커서는 발급되지도 소비되지도 않는다. 검증기 문구가 막는다고 말한 상태는 지금 존재하지 않는다.
|
||||
|
||||
@@ -126,7 +126,7 @@ websocket 어댑터에 재개 토큰 코덱과 키링이 있다. 이 둘도 조
|
||||
|
||||
그동안 할 수 있는 일이 없는 것은 아니다. 요구는 남기고 문구를 사실에 맞추면 된다.
|
||||
|
||||
테스트의 네 번째 케이스가 그것을 못 박는다. 요구는 지우지 말고 남기라는 것, 요구 자체는 옳고 빠진 절반은 구현이라는 것, 커서 서명이 요청 경로에 닿으면 앞의 두 케이스는 뒤집히고 이 케이스만 그대로 남는다는 것이다.
|
||||
테스트의 네 번째 케이스는 signing key 요구 자체를 제거하지 않는다. 빠진 것은 요청 경로의 signing 구현이며, 그 구현이 추가되면 ‘키만 요구하고 사용하지 않는다’는 앞의 불일치는 해소된다. 키가 없을 때 기동을 거부하는 검증은 그 이후에도 유지된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+1
-1
@@ -120,6 +120,6 @@ graphql leaf가 자기 능력을 네 등급으로 공시하는 체계의 설명
|
||||
|
||||
네 등급 중 어느 것도 아니고 미달성이다. 그리고 그 증거가 없으면 릴리스 게이트가 거부한다.
|
||||
|
||||
증거 자체는 실제 부하 인프라를 요구하므로 이 리프 밖에서 생성한다. 레인이 그 자리를 예약해 둔다.
|
||||
성능 증거는 실제 부하 인프라에서 생성해야 하므로 이 리프 안에서 만들지 않는다. 인증 레인은 외부에서 생성된 측정 결과를 입력으로 받도록 구성한다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"revision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
||||
"verified": "git rev-parse HEAD 가 이 값과 같다 (2026-09-07 확인)"
|
||||
},
|
||||
"ssotSha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
|
||||
"ssotSha256": "3c899a787af8468951023130b3ae9366e101ca17102a6c2da171e3fddfe901d3",
|
||||
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
||||
"generatedAt": "2026-09-11",
|
||||
"generatedAt": "2026-09-18",
|
||||
"candidateScope": {
|
||||
"document": "final/document.md",
|
||||
"sections": [
|
||||
@@ -67,8 +67,8 @@
|
||||
"counts": {
|
||||
"topics": 16,
|
||||
"nodes": 123,
|
||||
"written": 112,
|
||||
"unwritten": 11,
|
||||
"written": 123,
|
||||
"unwritten": 0,
|
||||
"unlisted": 0,
|
||||
"candidates": 1088
|
||||
},
|
||||
@@ -166,7 +166,13 @@
|
||||
"concept:transaction-result-algebra"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "commit-ambiguity-as-a-result/concept/concept-publish-evidence-and-completion.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [
|
||||
@@ -272,7 +278,13 @@
|
||||
"reference:unknown-is-a-third-result"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "commit-ambiguity-as-a-result/decision/decision-record-the-evidence-first-choose-the-conclusion-later.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -406,7 +418,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다",
|
||||
"title": "확인한 자동설정 경로가 시작 검증기 13개 규칙을 호출하지 않는다",
|
||||
"kind": "case",
|
||||
"slug": "thirteen-startup-rules-never-run",
|
||||
"readiness": "READY",
|
||||
@@ -421,7 +433,7 @@
|
||||
"evidence": [
|
||||
"`evidence/raw/267-grpc-family-reachability.txt`"
|
||||
],
|
||||
"classification": "validator가 5개 그룹 13개 규칙을 갖고(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1) javadoc이 그 13개를 고른 기준을 \"None of them fails a smoke test\"로 적는다. 유일한 조립 지점인 자동설정은 `@Bean` 9개를 만들면서 이 validator를 부르지 않고, static 메서드라 빈이 될 수도 없다. CLAUDE.md가 인용한 \"streaming method가 Stable catalog에 등록되면 startup을 거부한다\"와 §2.2의 runtime 강제가 둘 다 이 validator를 통해서만 성립하므로 둘 다 실행되지 않는다.",
|
||||
"classification": "gRPC 시작 검증기는 13개 규칙을 갖지만, searched direct caller와 확인한 auto-configuration 경로에서는 실행 연결을 찾지 못했다. direct reference 0만으로 runtime 전체 미실행을 확정하지 않고 lifecycle·framework discovery와 실제 boot evidence를 별도로 확인해야 한다.",
|
||||
"missing-verification": "build-only 가족이라 부팅 확인이 불가능하다 — 채택 시점에만 관측 가능",
|
||||
"relations": [
|
||||
"`reference:the-startup-validator-follows-the-autoconfiguration-root`",
|
||||
@@ -542,7 +554,13 @@
|
||||
"case:scan-exclusion-without-an-owner"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "assembly-ownership/case/case-the-guard-is-on-and-the-service-is-not.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -651,7 +669,7 @@
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다",
|
||||
"title": "시작 검증기는 실제 lifecycle과 assembly 경로에서 실행 여부를 확인한다",
|
||||
"kind": "reference",
|
||||
"slug": "the-startup-validator-follows-the-autoconfiguration-root",
|
||||
"readiness": "READY",
|
||||
@@ -659,12 +677,12 @@
|
||||
"`final/document.md#5-3`",
|
||||
"`final/document.md#a18` (SRC-158)"
|
||||
],
|
||||
"classification": "루트가 있으면 검증기를 매달 자리가 있고, 없으면 검증기는 컴포넌트 스캔이 닿기를 기대하는데 그 스캔이 그 패키지를 제외하고 있을 수 있다. 기준은 \"이 검증기를 부르는 조립 지점이 어디인가\"를 능력 단위로 묻는 것이다.",
|
||||
"classification": "시작 검증기의 실행 여부는 direct caller 하나로 확정하지 않는다. @Bean·component scan·auto-configuration, lifecycle callback, application event·post processor, framework discovery와 실제 boot evidence를 따라가며 누가 검증기를 실행하는지 확인한다. 확인한 auto-configuration이 직접 호출하지 않는다는 사실과 runtime 전체 미실행 주장은 구분한다.",
|
||||
"scope": [
|
||||
"자동설정과 컴포넌트 스캔을 함께 쓰는 조합. 확인 결과: app-bootstrap 12종 배선(고아 0), messaging 6종 배선, web·websocket 미배선."
|
||||
"자동설정과 컴포넌트 스캔을 함께 쓰는 조합. direct reference 0은 출발점이고 lifecycle/assembly 경로를 함께 닫아야 runtime 미실행을 말할 수 있다."
|
||||
],
|
||||
"exceptions": [
|
||||
"**루트가 있는데도 부르지 않는 경우**가 둘 있다(`WebPlatformStartupValidator`, `GrpcPlatformStartupValidator`). 필요조건이지 충분조건이 아니다."
|
||||
"자동설정 루트가 있어도 검증기를 호출하지 않을 수 있고, 반대로 다른 lifecycle 경로가 실행할 수도 있다. auto-configuration root 존재는 필요충분조건이 아니다."
|
||||
],
|
||||
"relations": [
|
||||
"`case:thirteen-startup-rules-never-run`",
|
||||
@@ -894,7 +912,13 @@
|
||||
"reference:off-must-be-structural"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "assembly-ownership/decision/decision-destructive-admin-operations-are-not-autoconfigured.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1769,7 +1793,13 @@
|
||||
"reference:register-paths-bind-values"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "bounding-by-type/decision/decision-a-keyset-page-has-no-offset-field.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
"title": "JSONB 문서 안에 타입 메타데이터를 넣지 않는다",
|
||||
@@ -1789,7 +1819,13 @@
|
||||
"case:mongo-default-throws-on-first-write"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "bounding-by-type/decision/decision-no-type-metadata-inside-a-jsonb-document.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1916,7 +1952,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다",
|
||||
"title": "테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 경로는 확인하지 않았다",
|
||||
"kind": "case",
|
||||
"slug": "trust-policy-lives-in-nginx-not-in-the-code",
|
||||
"readiness": "READY",
|
||||
@@ -1931,8 +1967,8 @@
|
||||
"evidence": [
|
||||
"없음 — 도달성 확인과 설정 파일 대조"
|
||||
],
|
||||
"classification": "forwarded 헤더를 어디까지 믿을지 판정하는 Java 정책이 421 LOC 작성돼 있고 배선되지 않는다. 실제 판정은 Nginx 설정이 한다. 두 곳이 어긋나면 코드 리뷰가 잡을 수 없고, Java 쪽을 고쳐도 동작이 바뀌지 않는다.",
|
||||
"missing-verification": "Nginx 설정이 실제로 어떤 hop을 신뢰하는지 런타임에서 확인하지 않았다",
|
||||
"classification": "확인한 nginxProxyTest 설정은 들어온 forwarded 헤더를 authoritative value로 교체한다. Java 쪽 정책은 searched direct reference 기준으로 사용 지점이 매우 적다. 다만 이 테스트 설정이 실제 운영 배포의 trust boundary인지와 Java 정책의 모든 framework/lifecycle wiring 부재까지는 이번 evidence로 확인하지 않았다.",
|
||||
"missing-verification": "테스트 Nginx 설정이 실제 운영 배포에도 사용되는지, Java 정책에 정적 직접 참조 밖의 lifecycle/framework wiring이 있는지 확인하지 않았다",
|
||||
"relations": [
|
||||
"`reference:check-which-duplicate-is-wired`",
|
||||
"`reference:a-bean-is-not-composition-evidence`"
|
||||
@@ -2013,7 +2049,13 @@
|
||||
"reference:check-which-duplicate-is-wired"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "duplicate-mechanisms/case/case-a-boundary-that-leaks-only-under-load.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -2723,7 +2765,13 @@
|
||||
"reference:read-the-clock-after-the-lock"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "owner-safe-state-machines/case/case-a-mark-that-meant-seen-not-projected.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -3792,7 +3840,13 @@
|
||||
"concept:transport-failure-stage-and-category"
|
||||
],
|
||||
"kind": "concept",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "http-failure-classification/concept/concept-three-failure-vocabularies.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"reference": [
|
||||
@@ -3813,7 +3867,13 @@
|
||||
"decision:an-unrecognised-sqlstate-is-not-guessed"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "http-failure-classification/reference/reference-one-sqlstate-with-two-contributors-fails-startup.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
],
|
||||
"question": [],
|
||||
@@ -3871,7 +3931,13 @@
|
||||
"reference:unknown-is-a-third-result"
|
||||
],
|
||||
"kind": "decision",
|
||||
"publication": "미작성"
|
||||
"publication": "초안",
|
||||
"file": "http-failure-classification/decision/decision-an-unrecognised-sqlstate-is-not-guessed.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4351,7 +4417,7 @@
|
||||
"kinds": {
|
||||
"case": [
|
||||
{
|
||||
"title": "RLS가 아무것도 하지 않는 세 가지 방법",
|
||||
"title": "RLS 검증에서 구분해야 할 우회와 미적용 경로",
|
||||
"kind": "case",
|
||||
"slug": "three-ways-rls-does-nothing",
|
||||
"readiness": "READY",
|
||||
@@ -4364,15 +4430,16 @@
|
||||
"`db/experimental-rls/V1__tenant_rls.sql`"
|
||||
],
|
||||
"evidence": [
|
||||
"없음 — verifier가 검사하는 세 조건과 마이그레이션 주석"
|
||||
"없음 — verifier가 읽는 catalog 값과 migration 주석을 PostgreSQL RLS 의미에 맞춰 재해석"
|
||||
],
|
||||
"classification": "`RlsPolicyVerifier`가 RLS가 조용히 무력화되는 세 경로를 전부 확인한다 — policy 없음/RLS 미활성 · 런타임 롤이 `BYPASSRLS` 보유 · **런타임 롤이 테이블을 소유**(FORCE 없으면 면제). 세 번째가 가장 놓치기 쉽다. 그리고 unique index 하나에도 같은 축의 주석이 있다 — tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요구**에 그 컬럼이 들어가야 하고, `(value)`만의 unique index는 다른 tenant가 그 값을 썼다는 이유로 insert를 실패시켜 **버그이자 정보 유출**이 된다. 다만 `final/document.md#a05` §97이 기록하듯 verifier 자신이 \"반드시 보호돼야 하는 table\"의 부재를 성공으로 인정하는 결함을 갖는다.",
|
||||
"classification": "RLS 활성 여부, runtime role의 superuser/BYPASSRLS, table owner와 FORCE ROW LEVEL SECURITY, applicable policy 유무는 서로 다른 분기다. RLS가 활성화된 일반 role에 applicable policy가 없으면 default deny가 적용된다. owner는 기본 우회하지만 FORCE RLS로 policy 대상이 될 수 있다. tenant 내부 유일성과 global 유일성도 구분한다. verifier가 required table 부재를 성공으로 인정하는 별도 coverage 결함은 유지된다.",
|
||||
"missing-verification": "세 조건을 하나씩 깨서 verifier가 실제로 잡는지 확인하지 않았다",
|
||||
"relations": [
|
||||
"`concept:rls-three-preconditions`",
|
||||
"`reference:hibernate-filter-is-not-a-security-boundary`",
|
||||
"`reference:tenant-column-belongs-in-every-unique-constraint`"
|
||||
],
|
||||
"missing_verification": "실제 PostgreSQL 환경에서 RLS off, BYPASSRLS, owner/FORCE, policy 없음 분기를 각각 실행 재현하지 않았다",
|
||||
"publication": "초안",
|
||||
"file": "multitenancy-isolation/case/case-three-ways-rls-does-nothing.md",
|
||||
"status": "게시 전",
|
||||
@@ -4390,7 +4457,7 @@
|
||||
],
|
||||
"concept": [
|
||||
{
|
||||
"title": "RLS가 성립하기 위한 세 전제",
|
||||
"title": "PostgreSQL RLS 적용 여부를 가르는 분기",
|
||||
"kind": "concept",
|
||||
"slug": "rls-three-preconditions",
|
||||
"readiness": "READY",
|
||||
@@ -4403,8 +4470,8 @@
|
||||
"`.../experimental/multitenancy/RlsPolicyVerifier.java`",
|
||||
"`.../RlsTenantSessionBinder.java`"
|
||||
],
|
||||
"classification": "PostgreSQL RLS가 실제로 격리하려면 세 가지가 동시에 참이어야 한다는 설명이다. (1) `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다. (2) `FORCE ROW LEVEL SECURITY` — **테이블 OWNER에게도** 적용한다. 없으면 owner는 자기 policy에서 면제되고 **owner는 흔히 마이그레이션 롤이며 그것이 사람들이 테스트하는 롤이다.** (3) 런타임 롤이 `BYPASSRLS`를 갖지 않는다 — 이것은 테이블 속성이 아니라 롤 속성이라 startup에서 assert해야 한다. policy가 `current_setting('app.tenant_id', true)`를 쓰는 이유(두 번째 인자 `true`가 미설정 시 raise 대신 NULL을 반환하고, NULL은 절대 `tenant_id`와 같지 않으므로 fail-closed)도 함께 다룬다.",
|
||||
"missing-verification": "실제 RLS 환경을 세워 세 전제를 하나씩 깨보지 않았다",
|
||||
"classification": "PostgreSQL RLS를 세 개의 동시 전제로 설명하지 않는다. RLS가 꺼져 있으면 policy가 적용되지 않고, superuser/BYPASSRLS는 우회한다. table owner는 기본 우회하지만 FORCE ROW LEVEL SECURITY가 켜지면 policy 대상이 된다. policy 대상 role에 applicable policy가 없으면 default deny이고, policy가 있으면 USING/WITH CHECK를 평가한다. current_setting('app.tenant_id', true)의 fail-closed 성질은 이 분기와 별도로 유지한다.",
|
||||
"missing-verification": "실제 PostgreSQL 환경에서 각 분기를 하나씩 재현하지 않았다",
|
||||
"relations": [
|
||||
"`case:three-ways-rls-does-nothing`",
|
||||
"`reference:hibernate-filter-is-not-a-security-boundary`",
|
||||
|
||||
+7
-7
@@ -30,7 +30,7 @@ source:
|
||||
- **로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다**
|
||||
통과한 레인이 다른 값을 보고 있었다.
|
||||
- **validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다**
|
||||
같은 app-bootstrap 모듈에서 시작 검증기가 다른 것을 보고 있던 사례다.
|
||||
같은 app-bootstrap 모듈에서 startup validator가 실제 바인딩 단계와 다른 파서를 사용해 형식 오류를 잡지 못했다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -42,7 +42,7 @@ source:
|
||||
|
||||
프로파일 오버라이드가 형식 결함을 가렸다. 로컬에는 오버라이드가 있었고 나머지에는 없었다.
|
||||
|
||||
풀 제약 검증기는 같은 키를 DurationStyle 로 읽어서 5s 를 5000 밀리초로 받는다. 다만 이 검증기는 모든 싱글턴이 만들어진 뒤에 도는 자리에 있어서, 이 값으로는 그 전에 빈 생성이 실패한다.
|
||||
풀 제약 검증기는 같은 키를 `DurationStyle`로 읽어서 `5s`를 5000밀리초로 해석한다. 하지만 이 검증기는 `SmartInitializingSingleton` 단계에서 실행되므로, `HikariDataSource` 빈 바인딩이 먼저 실패하면 검증기까지 도달하지 않는다.
|
||||
|
||||
지금은 출하 기본값이 정수로 고쳐져 있고, 밀리초 키 일곱 개의 출하 기본값이 정수인지 확인하는 정적 가드가 있다.
|
||||
|
||||
@@ -65,7 +65,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`spring.datasource.hikari.connection-timeout` 은 `HikariConfig#setConnectionTimeout(long)` 에 바인딩된다. 밀리초 정수만 받는 자리다.
|
||||
`spring.datasource.hikari.connection-timeout`은 `HikariConfig#setConnectionTimeout(long)`에 바인딩되므로 Spring은 이 값을 `long` 밀리초 값으로 변환해야 한다.
|
||||
|
||||
기본값이 `5s` 로 출하됐다. 지금은 `${APP_DATASOURCE_CONNECTION_TIMEOUT:5000}` 로 고쳐져 있다.
|
||||
|
||||
@@ -80,11 +80,11 @@ OpenJDK : 21.0.12
|
||||
:::evidence key="a-five-second-string-that-broke-every-prod-deploy-bind" alt="저장소가 쓰는 Spring Boot 와 HikariCP 산출물로 같은 속성 키에 두 형식을 넣어 실제로 바인딩한 실행 결과. 지속 시간 문자열은 예외 사슬 세 단계로 거절되고 정수는 값이 그대로 들어간다." caption="Spring Boot 4.0.8 · HikariCP 7.0.2 — 같은 키, 두 형식, 실제 바인딩" zoom="true"
|
||||
:::
|
||||
|
||||
예외가 세 단계로 내려간다. 바인딩 실패, 문자열에서 `long` 으로의 변환 실패, 그리고 `"5s"` 에 대한 숫자 형식 예외다. 같은 자리에 `5000` 을 넣으면 값이 그대로 들어간다.
|
||||
예외는 바인딩 실패 → 문자열을 `long`으로 변환하는 실패 → `"5s"` 숫자 형식 예외 순으로 이어진다. 같은 프로퍼티에 `5000`을 넣으면 `long` 값으로 정상 바인딩된다.
|
||||
|
||||
## 같은 파일에 같은 이름이 두 형식으로 있다
|
||||
|
||||
:::evidence key="a-five-second-string-that-broke-every-prod-deploy" alt="코드베이스에서 출하 설정 파일의 두 네임스페이스에 같은 이름의 키가 다른 형식으로 있는 것과, 앞쪽이 바인딩되는 세터의 long 시그니처, 같은 키를 읽는 시작 검증기의 인터페이스와 파싱 함수, 그리고 그 형식을 막는 정적 가드의 키 목록과 그 가드가 한 번 헛짚었던 자리를 뽑은 출력. 한 파일 안에서 같은 마지막 단어가 정수와 지속 시간 두 형식으로 쓰인다는 것이 그 출력에 그대로 보인다." caption="같은 파일의 두 네임스페이스 · long 세터 · 검증기의 다른 파서 · 정적 가드의 키 목록" zoom="true"
|
||||
:::evidence key="a-five-second-string-that-broke-every-prod-deploy" alt="코드베이스에서 출하 설정 파일의 두 네임스페이스에 같은 이름의 키가 다른 형식으로 있는 것과, 앞쪽이 바인딩되는 세터의 long 시그니처, 같은 키를 읽는 시작 검증기의 인터페이스와 파싱 함수, 그리고 그 형식을 막는 정적 가드의 키 목록과 그 가드가 한 번 잘못 탐지했던 설정을 뽑은 출력. 한 파일 안에서 같은 마지막 단어가 정수와 지속 시간 두 형식으로 쓰인다는 것이 그 출력에 그대로 보인다." caption="같은 파일의 두 네임스페이스 · long 세터 · 검증기의 다른 파서 · 정적 가드의 키 목록" zoom="true"
|
||||
:::
|
||||
|
||||
`hikari` 아래의 키는 정수를 받고, `tomcat` 아래의 키는 `20s` 로 적혀 있다. 전체 경로는 다르고 마지막 단어만 같다.
|
||||
@@ -93,7 +93,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
풀 제약 검증기가 같은 키를 읽는다. 파서는 `DurationStyle.detectAndParse` 다. 그 규칙에서 `5s` 는 5000 밀리초이고 하한을 넘으므로 유효한 값이다.
|
||||
|
||||
다만 이 검증기는 `SmartInitializingSingleton` 이라 모든 싱글턴이 만들어진 뒤에 돈다. `5s` 로는 그 전에 `HikariDataSource` 빈 생성에서 바인딩이 죽는다. 검증기가 잘못된 값을 통과시킨 것이 아니라, 이 값에 대해 발언할 수 있는 자리에 있지 않았다.
|
||||
이 검증기는 `SmartInitializingSingleton`이라 모든 singleton 생성 뒤에 실행된다. `5s`는 그보다 앞선 `HikariDataSource` 빈 생성의 바인딩 단계에서 실패하므로 검증기가 이 값을 판정할 기회가 없다.
|
||||
|
||||
## 두 문서가 같은 관용을 반대로 부른다
|
||||
|
||||
@@ -109,7 +109,7 @@ OpenJDK : 21.0.12
|
||||
|
||||
부팅이 아니라 스캔이라 타입 목록을 손으로 들고 있다. 밀리초 키가 새로 생기면 그 목록에 줄을 더하는 것이 유일한 편입 경로다.
|
||||
|
||||
가드 자신의 주석이 초기 버전의 실패도 적어 둔다. 키의 마지막 단어를 정규식으로 맞춰서, 진짜 지속 시간인 `server.tomcat.connection-timeout: 20s` 를 결함으로 보고했다는 것이다. 실제 로더로 평탄화하도록 고친 이유가 그것이다.
|
||||
가드 주석에는 초기 구현이 키의 마지막 단어만 정규식으로 비교해 실제 duration 설정인 `server.tomcat.connection-timeout: 20s`까지 잘못 거부했다고 적혀 있다. 이후 실제 설정 로더로 값을 펼쳐 검사하도록 바꿨다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+3
-5
@@ -42,11 +42,9 @@ source:
|
||||
|
||||
중첩 깊이 1 이면 스레드당 두 개다. 바깥 하나와 안쪽 하나다.
|
||||
|
||||
그래서 풀 크기가 동시 스레드 수보다 커야 한다. 그 제약이 설정 파일 주석에 수식으로 적혀 있다.
|
||||
그래서 이 프로젝트는 동시 worker 수와 최대 `inNew` 중첩 깊이를 이용한 보수적 풀 사이징 규칙을 설정 주석에 둔다. 이것은 Spring의 보편 불변식이 아니라 이 프로젝트의 concurrency model과 안전 여유를 반영한 운영 규칙이다.
|
||||
|
||||
최대 풀 크기가 동시 스레드 수 곱하기 중첩 깊이에 1 을 더한 값 이상, 그리고 거기에 1 을 더한 값 이상이어야 한다는 형태다.
|
||||
|
||||
이 수식이 지켜지지 않으면 데드락이 난다. 모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다리는 상태가 되고, 아무도 반납하지 않으므로 풀 획득 타임아웃까지 전부 대기한다.
|
||||
위험은 조건부다. 모든 worker가 outer connection을 점유한 채 inner connection을 기다리고 pool에 추가 connection 여유가 없으면 pool exhaustion이 발생한다. 이 대기가 서로가 놓을 connection을 기다리는 형태가 되면 교착처럼 진행되고 acquisition timeout까지 이어질 수 있다.
|
||||
|
||||
같은 주석 블록이 풀 사이징의 다른 근거도 함께 적는다. 작은 풀 공리와 PostgreSQL 의 시작점 수식이다. 코어 수의 두 배에 유효 스핀들 수를 더한 값에서 시작해 부하 테스트로 조정하라는 것이고, 고정 크기 풀을 권장한다.
|
||||
|
||||
@@ -78,7 +76,7 @@ Spring Boot : 4.0.8
|
||||
|
||||
## 무엇이 금지되고 무엇으로 대신하나
|
||||
|
||||
풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프가 금지이며(풀 고갈 + 데드락), 배치로 묶거나 루프를 트랜잭션 밖으로 빼야 한다.
|
||||
이 프로젝트의 보수적 풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프는 pool exhaustion과 교착 위험을 키우므로 금지한다. 배치로 묶거나 루프를 트랜잭션 밖으로 빼는 쪽을 택한다.
|
||||
|
||||
## 같은 이유의 다른 결정
|
||||
|
||||
|
||||
+7
-9
@@ -35,7 +35,7 @@ source:
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다. 그래서 풀 사이징 제약이 곱셈이 된다 — `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`.
|
||||
`REQUIRES_NEW`는 바깥 트랜잭션의 리소스를 유지한 채 독립적인 inner transaction 리소스를 요구할 수 있다. 이 프로젝트는 그 concurrency model을 기준으로 `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`을 보수적 sizing rule로 사용한다. Spring API 전체에 성립하는 보편 법칙으로 읽어서는 안 된다.
|
||||
|
||||
## 커넥션 비용이 곱셈이 되는 이유
|
||||
|
||||
@@ -44,7 +44,7 @@ source:
|
||||
|
||||
## 레코드마다 inNew 를 도는 루프가 금지인 이유
|
||||
|
||||
풀 고갈과 데드락이다.
|
||||
모든 worker가 outer connection을 잡은 채 inner connection을 기다리고 추가 여유가 없을 때 pool exhaustion이 생기며, 대기가 서로의 반납을 전제로 하면 교착 형태로 진행할 수 있기 때문이다.
|
||||
|
||||
## 같은 곱셈 함정이 database-per-tenant에서 반복된다
|
||||
|
||||
@@ -70,13 +70,11 @@ source:
|
||||
maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
|
||||
```
|
||||
|
||||
중첩 깊이 1 이면 스레드당 두 커넥션이다. 동시 스레드가 100 이면 최소 201 이 필요하다.
|
||||
이 프로젝트가 가정한 모델에서 중첩 깊이 1이면 worker 하나가 outer와 inner connection을 동시에 필요로 할 수 있다. 예를 들어 동시 worker 100을 최대 동시성으로 잡고 여유 1을 두는 이 규칙이라면 201을 산정한다. 실제 필요한 값은 transaction manager 동작과 workload의 동시성으로 검증해야 한다.
|
||||
|
||||
## 지켜지지 않으면 데드락이다
|
||||
## 여유가 없으면 pool exhaustion과 교착 위험이 생긴다
|
||||
|
||||
모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다린다. 아무도 반납하지 않으므로 전부 풀 획득 타임아웃까지 대기한다.
|
||||
|
||||
이 상태는 부하가 임계를 넘는 순간 한꺼번에 나타난다. 그 전까지는 아무 증상이 없다.
|
||||
모든 worker가 바깥 커넥션을 잡고 안쪽 커넥션을 기다리는 동시에 pool에 남은 connection이 없으면 획득 대기가 누적된다. 이 대기가 서로가 반납해야 할 connection을 기다리는 구조가 되면 acquisition timeout과 교착 형태로 이어질 수 있다. 실제 발생 여부와 임계점은 workload와 pool 설정으로 확인해야 한다.
|
||||
|
||||
## 풀 사이징의 다른 근거와 충돌한다
|
||||
|
||||
@@ -92,13 +90,13 @@ test). Fixed-size pool recommended (minimumIdle = maximumPoolSize).
|
||||
|
||||
:::warning
|
||||
|
||||
두 근거가 같은 손잡이를 반대 방향으로 민다. 작은 풀 공리는 값을 줄이라 하고 중첩 하한은 늘리라 한다. 해소하는 방법은 풀을 키우는 것이 아니라 중첩 깊이를 줄이는 것이다.
|
||||
두 기준은 서로 다른 질문에 답한다. 일반적인 pool sizing은 불필요하게 큰 pool을 피하려는 기준이고, 중첩 transaction 규칙은 특정 동시성에서 추가 connection 수요가 생길 수 있음을 반영한다. 먼저 불필요한 `REQUIRES_NEW` 중첩을 줄이고, 남은 동시성 모델을 부하 테스트로 확인한 뒤 `maximumPoolSize`를 정한다.
|
||||
|
||||
:::
|
||||
|
||||
## 고정 크기 풀
|
||||
|
||||
최소 유휴를 최대 크기와 같게 두는 것을 권장한다. 풀이 줄었다가 늘어나는 동안 중첩 하한이 일시적으로 깨지는 것을 막는다.
|
||||
`minimumIdle = maximumPoolSize`인 fixed-size 설정은 connection creation 지연을 피하고 응답성을 예측하기 쉽게 하려는 운영 선택이다. `minimumIdle`이 더 작다고 `maximumPoolSize` 용량 자체가 사라지는 것은 아니다. `REQUIRES_NEW` 안전 여유는 `maximumPoolSize`와 실제 concurrency model로 판단한다.
|
||||
|
||||
## 멀티테넌시에서의 확장
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ source:
|
||||
|
||||
템플릿 경합이 구조적으로 사라진다.
|
||||
|
||||
각 모드가 무엇으로 설정되어 있는지 한 자리에서 보인다.
|
||||
모드별 템플릿 설정은 하나의 생성 함수에서 구성하므로 mode마다 적용되는 timeout·retry 설정을 같은 코드에서 비교할 수 있다.
|
||||
|
||||
## 근거
|
||||
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ Gradle : 9.0.0
|
||||
|
||||
이 레인에서 사라진 것이지 저장소에서 사라진 것이 아니다. 같은 형태의 게이트가 두 곳에 더 있고, 그중 하나는 기본값이 꺼짐이다.
|
||||
|
||||
## 이름이 약속한 것과 레인이 관측하는 것의 간격은 아직 남아 있다
|
||||
## 새 이름이 약속하는 세 성질 중 하나는 아직 단언하지 않는다
|
||||
|
||||
주석과 야간 워크플로가 이 레인의 검사 대상을 셋으로 적는다.
|
||||
|
||||
|
||||
+13
-13
@@ -28,11 +28,11 @@ gRPC 안정 릴리스 게이트는 증거 객체를 읽어 판정한다. 그 객
|
||||
## 관계
|
||||
|
||||
- **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다**
|
||||
이 사례에서 끌어낸 규칙이다.
|
||||
이 사건에서 레인이 실행되지 않으면 게이트가 실제 결과 대신 이전 상태나 손으로 만든 입력만 보게 된다는 규칙을 정리했다.
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
증거를 만드는 쪽이 없을 때 게이트가 통과로 끝나면 안 된다는 규칙이다.
|
||||
증거 생산자가 연결되지 않은 상태를 성공으로 처리하지 않도록 하는 규칙이다.
|
||||
- **시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다**
|
||||
같은 블록에서 나왔고, 둘 다 검증 코드는 있는데 그것을 부르는 자리가 없다.
|
||||
같은 gRPC 블록에서 검증 로직은 존재하지만 확인한 조립·실행 경로가 이를 호출하지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
@@ -42,7 +42,7 @@ gRPC 안정 릴리스 게이트는 증거 객체를 읽어 판정한다. 그 객
|
||||
|
||||
## 결론
|
||||
|
||||
증거 객체를 생성하는 곳은 게이트 자신의 테스트 네 군데뿐이다.
|
||||
searched direct construction 기준으로 증거 객체를 만드는 코드는 게이트 자신의 테스트 네 군데에서만 확인했다.
|
||||
|
||||
등급 넷에 대응하는 증거 레인 넷은 존재한다. 리프의 build.gradle 이 아니라 컨벤션 플러그인이 등록하고, 지원 매트릭스가 등급마다 그 이름을 적는다.
|
||||
|
||||
@@ -83,15 +83,15 @@ Gradle : 9.0.0
|
||||
|
||||
증거 레코드는 실행한 등급 집합과 인증된 능력 집합, 그리고 런북과 결정 기록과 지원 매트릭스의 존재 여부 셋을 담는다.
|
||||
|
||||
셋은 불리언이다. 파일이 있는지 보고 채우는 코드가 아니라, 넘겨받은 값을 그대로 담는 자리다. 앞의 두 집합도 호출자가 만든다.
|
||||
문서 존재 여부 셋은 파일 시스템을 직접 확인하지 않고 호출자가 넘긴 boolean을 그대로 저장한다. 실행 등급 집합과 인증 능력 집합도 호출자가 구성한다.
|
||||
|
||||
## 그 객체를 만드는 곳은 게이트의 테스트뿐이다
|
||||
## 확인한 직접 생성 지점은 게이트 테스트 네 곳이다
|
||||
|
||||
생성 지점이 넷이고 전부 같은 파일, 게이트 자신의 단위 테스트다.
|
||||
|
||||
그 테스트에는 태그가 붙어 있지 않다. 그래서 기본 test 태스크에 들어가고, check 그래프 안에 있고, 매 PR 마다 도는 워크플로가 그 check 를 돌린다.
|
||||
|
||||
판정 로직은 그러니까 실제로 돈다. 그때 게이트가 읽는 다섯 성분이 테스트가 손으로 채운 값일 뿐이다.
|
||||
판정 로직 자체는 기본 테스트에서 실행된다. 다만 그 실행에서 게이트가 읽는 다섯 성분은 실제 lane 산출물이 아니라 테스트가 직접 구성한 값이다.
|
||||
|
||||
## 레인은 등록돼 있는데 아무 게이트에도 붙어 있지 않다
|
||||
|
||||
@@ -101,7 +101,7 @@ Gradle : 9.0.0
|
||||
|
||||
그 레인 넷 중 check 그래프에 들어 있는 것은 0 이다. 28개 워크플로 중 grpc 를 이름이나 내용에 담은 것도 0 이다.
|
||||
|
||||
레인은 손으로 부르면 돈다. 부르는 곳이 없을 뿐이다.
|
||||
레인 태스크는 직접 실행할 수 있지만 check 그래프와 확인한 CI workflow에서는 호출되지 않는다.
|
||||
|
||||
## 없는 것은 레인과 증거 사이의 코드다
|
||||
|
||||
@@ -115,21 +115,21 @@ messaging 쪽은 세 층이다.
|
||||
|
||||
레인이 실행 결과를 증거 파일로 쓴다. Gradle 태스크가 그 산출물과 커밋된 매니페스트를 대조하고, 커밋본이 레인이 내지 않은 시나리오를 주장하면 실패한다. 워크플로가 실제 브로커를 띄워 그 태스크를 돌리고 결과를 산출물로 올린다.
|
||||
|
||||
그 태스크에는 캐시를 끄는 한 줄과 이유가 붙어 있다. 이전 실행의 결과로 답할 수 있는 게이트는 그 실행에 대한 증거라는 것이다.
|
||||
해당 태스크는 결과 캐시를 끄고, 인증 판단에는 이번 실행에서 생성한 증거가 필요하다는 이유를 주석으로 남긴다.
|
||||
|
||||
워크플로 단계에도 한 줄이 있다. 커밋 해시를 레인이 읽어 모든 증거 줄에 적는다는 것이고, 인증이 하나의 소스 트리에 대한 주장이기 때문이라는 것이다.
|
||||
워크플로는 레인이 현재 커밋 해시를 읽어 증거에 기록하도록 한다. 인증 결과를 특정 소스 트리와 연결하기 위해서다.
|
||||
|
||||
## 게이트의 설계가 차단 사유를 만드는 방식
|
||||
|
||||
게이트는 빠진 결과와 빠진 등급과 스키마 판정과 문서 존재를 합쳐 차단 사유 목록을 만든다.
|
||||
|
||||
문서를 후속 과제가 아니라 차단 사유로 둔 근거도 적혀 있다. 동작을 먼저 출하하고 런북을 나중에 쓰면, 그것을 처음 만나는 사람이 새벽 세 시에 스스로 알아내야 한다는 것이다.
|
||||
문서 누락도 차단 사유로 둔다. 런북 없이 동작만 출하하면 운영자가 장애 시점에 절차를 즉석에서 추론해야 하기 때문이다.
|
||||
|
||||
## 출하되지 않는다는 사실은 결함이 아니다
|
||||
|
||||
같은 지원 매트릭스가 그 상태를 적는다. grpc 리프는 전부 런타임 소속이 비어 있어서 이 플랫폼은 빌드 전용이다. 컴파일되고 레인이 돌지만 배포되는 산출물이 담지 않는다.
|
||||
지원 매트릭스는 모든 gRPC leaf의 runtime membership을 비워 두고 build-only로 표시한다. 이 상태에서는 코드는 컴파일되고 증거 lane도 실행할 수 있지만 현재 배포 산출물에는 포함되지 않는다.
|
||||
|
||||
같은 문서가 릴리스까지 남은 게이트 입력 두 가지도 적는다. 성능 기준선과 스키마 코드 생성이다.
|
||||
같은 문서는 안정 릴리스 전에 추가로 필요한 입력으로 성능 측정 결과와 스키마 코드 생성 검증을 적는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
|
||||
+5
-5
@@ -24,13 +24,13 @@ source:
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
통과의 이유가 의도한 것과 다른 형태다.
|
||||
테스트는 폴백 검증까지 도달하지 않고 앞단의 SINGLE 전략 가드 예외로 통과했다.
|
||||
- **계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다**
|
||||
계약 테스트가 어댑터의 실제 statement 를 돌려야 한다는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
경로 형태 검증기 안에서 전략을 보는 자리가 한 메서드에 둘이다.
|
||||
경로 형태 검증기의 한 메서드는 전략을 두 번 검사한다.
|
||||
|
||||
첫 번째는 가드다. 전략이 SINGLE 이 아니면 초기 카탈로그는 SINGLE 만 지원한다는 예외를 던진다.
|
||||
|
||||
@@ -46,7 +46,7 @@ source:
|
||||
|
||||
이름이 약속한 두 성질 중 하나는 확인되지 않는다.
|
||||
|
||||
첫 가드와 switch 사이에는 전략을 보지 않는 검사가 둘 있다. 타깃 수 일치와 증폭 한계다. 도달 불가를 만든 것은 위치가 아니라 switch 갈래 안에 들어갔다는 사실이다.
|
||||
첫 가드와 switch 사이에는 전략과 무관한 검사 둘이 있다. 타깃 수 일치와 증폭 한계를 확인한다. 나머지 전략 분기가 도달 불가인 이유는 이 검사들의 위치가 아니라 앞단 가드가 SINGLE 이외의 전략을 먼저 거부하기 때문이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -75,7 +75,7 @@ Gradle : 9.0.0
|
||||
:::evidence key="a-test-that-passed-on-the-wrong-guard" alt="코드베이스에서 경로 형태 검증기의 첫 가드와 그 뒤 전략을 보지 않는 검사 둘, switch 네 갈래 전체와 기본 갈래의 문구, 폴백 그래프 검사가 던질 수 있는 두 문구의 위치, 순환을 거부한다는 테스트의 이름과 그 테스트가 만든 팬아웃 라우트와 순환 라우트의 전략과 범위 값과 두 단언, 이 두 분기를 태울 라우트를 만드는 곳의 저장소 전수 목록과 설정 파일의 비-SINGLE 전략 수, 그리고 그 테스트를 돌린 결과를 뽑은 출력 106줄. 순환 라우트의 범위가 그 갈래의 검사를 통과하는 값이라는 것이 그 출력에 보인다." caption="첫 가드와 전략을 보지 않는 검사 둘 · switch 네 갈래 · 폴백 그래프의 두 문구 · 두 라우트의 전략과 범위 · 전수 목록 · 5건 통과 — 106줄" zoom="true"
|
||||
:::
|
||||
|
||||
첫 가드는 전략이 SINGLE 이 아니면 예외를 던진다. 문구는 초기 카탈로그가 SINGLE 만 지원한다는 것이다.
|
||||
첫 가드는 전략이 SINGLE이 아니면 초기 카탈로그가 SINGLE만 지원한다는 메시지로 예외를 던진다.
|
||||
|
||||
그 뒤 switch 에 네 갈래가 있다. SINGLE 은 타깃 하나와 폴백 없음을 요구하고, 팬아웃은 폴백 정의를 금지하고, 순서 폴백은 타깃 둘 이상과 활성화 범위를 요구하고, 기본 갈래는 알 수 없는 전략을 거부한다.
|
||||
|
||||
@@ -123,7 +123,7 @@ SINGLE 갈래는 폴백이 하나라도 있으면 그 앞에서 거부한다.
|
||||
|
||||
## 고치는 방법
|
||||
|
||||
단언을 메시지 전문이나 그 갈래에만 있는 부분 문자열로 좁히면 된다. 순환이라는 단어나 잘못된 범위라는 문구가 그것이다.
|
||||
단언은 각 분기에서만 나오는 메시지 전문이나 부분 문자열을 확인하도록 좁힌다. 예를 들어 순환 분기는 `순환`, 범위 오류 분기는 해당 범위 오류 문구를 확인한다.
|
||||
|
||||
지금 그 테스트를 돌리면 다섯 건이 통과한다.
|
||||
|
||||
|
||||
+11
-11
@@ -36,20 +36,20 @@ MessagingDocumentationContractTest 가 존재하고, 클래스 javadoc 이 목
|
||||
|
||||
단언은 의도적으로 좁다. 읽는 사람이 행동의 근거로 삼을 주장만 검사하고 산문은 검사하지 않는다. 표현을 단언하면 모든 편집이 테스트 실패가 되고 그 검사는 삭제될 것이기 때문이다.
|
||||
|
||||
의도도 이유도 명확하다. 문제는 그 경계가 어디인지를 아무 데도 적어 두지 않았다는 점이다.
|
||||
좁은 단언을 택한 의도는 분명하다. 다만 테스트가 검증하는 항목과 검증하지 않는 항목을 문서나 테스트 이름에서 구분하지 않아 통과 의미가 실제 범위보다 넓게 읽힌다.
|
||||
|
||||
## 결론
|
||||
|
||||
단언 여덟 개는 이렇다.
|
||||
|
||||
지원 매트릭스가 약속하는 문서 아홉 개의 존재
|
||||
STABLE 어댑터 이름의 정확한 일치
|
||||
EXPERIMENTAL 어댑터가 Stable 로 적히지 않음
|
||||
문서화된 Kafka 버전 문자열의 일치
|
||||
존재하지 않는 상수 두 개가 미지원 목록에 여전히 있음
|
||||
그 두 상수가 코드에 실제로 없음
|
||||
실험 정책 문서가 기본값 꺼짐을 명시하는지
|
||||
각 문서가 500자를 넘는지
|
||||
- 지원 매트릭스가 약속하는 문서 아홉 개의 존재
|
||||
- STABLE 어댑터 이름의 정확한 일치
|
||||
- EXPERIMENTAL 어댑터가 Stable로 적히지 않음
|
||||
- 문서화된 Kafka 버전 문자열의 일치
|
||||
- 존재하지 않는 상수 두 개가 미지원 목록에 여전히 있음
|
||||
- 그 두 상수가 코드에 실제로 없음
|
||||
- 실험 정책 문서가 기본값 꺼짐을 명시하는지
|
||||
- 각 문서가 500자를 넘는지
|
||||
|
||||
앞의 여섯은 정확하고 강하다. 뒤의 둘은 문자열 포함과 길이 검사라서 약하다.
|
||||
|
||||
@@ -63,7 +63,7 @@ EXPERIMENTAL 어댑터가 Stable 로 적히지 않음
|
||||
|
||||
이것이 이 테스트를 결함으로 만들지는 않는다. 좁게 만든 것은 의도이고 그 이유도 타당하다.
|
||||
|
||||
결함은 그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다는 것이다. 이 테스트를 통과한 문서는 코드와 일치하도록 검증됐다고 읽힌다. 실제로 검증된 것은 여덟 항목뿐이다.
|
||||
현재 테스트 이름과 문서는 검증 범위를 명시하지 않는다. 그래서 전체 문서가 코드와 일치한다고 읽힐 수 있지만 실제 단언은 여덟 항목에 한정된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -96,7 +96,7 @@ doc rot를 막기 위해 존재하는 계약 테스트의 단언 여덟 개가
|
||||
|
||||
capability 표 60칸·runtime membership 문장·브로커 등급표의 "제한" 칸이다. 테스트 javadoc은 좁은 단언을 고른 이유까지 옳게 적는다.
|
||||
|
||||
## 결함은 경계가 적혀 있지 않다는 점이다
|
||||
## 테스트가 검증하는 범위를 문서에 명시해야 한다
|
||||
|
||||
좁게 고른 것이 결함이 아니다.
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ JPA 는 사고로 배웠다. 성능 레인이 릴리스 게이트에 걸려 있
|
||||
|
||||
수정은 레인을 없애는 것이 아니라 이름을 사실로 바꾸는 것이었다. 그 레인이 진짜로 검증하는 것은 동작 계약이고, 그것은 어떤 기계에서도 참이므로 플래그가 필요 없다.
|
||||
|
||||
두 경우 모두 조건을 남겼다. 진짜 성능 게이트는 전용 러너와 워밍업과 표본 수와 기록된 기준선을 필요로 하고, 그것이 생기면 별도 레인으로 만든다.
|
||||
성능을 릴리스 차단 조건으로 다시 도입하려면 전용 runner, warm-up, 표본 수, 비교할 임계값과 과거 측정값을 명시적으로 갖춘 별도 lane을 만든다.
|
||||
|
||||
## 영향
|
||||
|
||||
|
||||
Reference in New Issue
Block a user