refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일
@@ -23,16 +23,16 @@ OpenAPI 계약을 소유하고, 백엔드가 그것을 반입해 구현하고,
|
||||
>
|
||||
> | 파일 | 무엇 |
|
||||
> |---|---|
|
||||
> | [`evidence/terminal/db/topic-variant-rows.txt`](./evidence/terminal/db/topic-variant-rows.txt) | 주제·축의 실제 행 |
|
||||
> | [`evidence/terminal/db/record-variant-links.txt`](./evidence/terminal/db/record-variant-links.txt) | 축에 걸린 기록과 공통 기록 |
|
||||
> | [`evidence/terminal/db/decision-path-after-v15.txt`](./evidence/terminal/db/decision-path-after-v15.txt) | 결정 주소가 앵커로 고쳐진 상태 · V15 적용 확인 |
|
||||
> | [`evidence/terminal/db/delete-blocked-by-project-link.txt`](./evidence/terminal/db/delete-blocked-by-project-link.txt) | 삭제를 막던 참조와 그 해소 |
|
||||
> | [`evidence/terminal/api/decision-anchor-fixed.txt`](./evidence/terminal/api/decision-anchor-fixed.txt) | 그 링크가 실제로 200 인가 |
|
||||
> | [`evidence/terminal/audit/dead-link-sweep.txt`](./evidence/terminal/audit/dead-link-sweep.txt) | 서버가 내보내는 주소 35개 전수 감사 |
|
||||
> | [`evidence/terminal/audit/link-audit.py`](./evidence/terminal/audit/link-audit.py) | 그 감사를 다시 돌리는 스크립트 |
|
||||
> | [`evidence/terminal/guards/guards-actually-fail.txt`](./evidence/terminal/guards/guards-actually-fail.txt) | 가드 셋을 되돌려 실제로 빨개지는 것을 확인 |
|
||||
> | [`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt) | 손 목록이 표로 바뀌었는지 · **남은 구멍 둘** |
|
||||
> | [`evidence/screens/`](./evidence/screens/) | 홈 주제 탭 세 단계의 화면과 실측값 |
|
||||
> | [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) | 주제·축의 실제 행 |
|
||||
> | [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) | 축에 걸린 기록과 공통 기록 |
|
||||
> | [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) | 결정 주소가 앵커로 고쳐진 상태 · V15 적용 확인 |
|
||||
> | [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt) | 삭제를 막던 참조와 그 해소 |
|
||||
> | [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) | 그 링크가 실제로 200 인가 |
|
||||
> | [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) | 서버가 내보내는 주소 35개 전수 감사 |
|
||||
> | [`evidence/raw/audit/link-audit.py`](./evidence/raw/audit/link-audit.py) | 그 감사를 다시 돌리는 스크립트 |
|
||||
> | [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt) | 가드 셋을 되돌려 실제로 빨개지는 것을 확인 |
|
||||
> | [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt) | 손 목록이 표로 바뀌었는지 · **남은 구멍 둘** |
|
||||
> | [`evidence/browser/`](./evidence/browser/) | 홈 주제 탭 세 단계의 화면과 실측값 |
|
||||
>
|
||||
> 도식 셋은 `assets/diagrams/` 아래에 SVG·`.drawio` 편집 원본·`.alt.md`·`.manifest.json` 로
|
||||
> 있고, `.techviz/<id>/spec.json` 이 각 도식이 답하는 질문과 근거 목록을 적어 둡니다.
|
||||
@@ -222,7 +222,7 @@ const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = {
|
||||
enum"을 전부 뽑아** 확인했습니다 (`2c25ccc`). 눈으로 찾을 일이 아니었습니다.
|
||||
|
||||
> **근거** — 지금 코드에서 표로 바뀐 자리와 **아직 남은 구멍 둘**:
|
||||
> [`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt).
|
||||
> [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt).
|
||||
> `PublicSql.pathOf` 는 sealed enum 이 아니라 String 으로 switch 하므로 여전히 `default -> null`
|
||||
> 이 남아 있고, `validate-working-copy.ts` 의 `stringFields` 도 아직 삼항 사슬입니다.
|
||||
|
||||
@@ -723,9 +723,9 @@ FE-GATE-009 는 **설치된 라우트마다 수동 접근성 증거를 하나씩
|
||||
확인했습니다.
|
||||
|
||||
> **근거** —
|
||||
> [`evidence/terminal/db/decision-path-after-v15.txt`](./evidence/terminal/db/decision-path-after-v15.txt) (저장된 주소가 앵커로 바뀌고 V15 가 적용된 것) ·
|
||||
> [`evidence/terminal/api/decision-anchor-fixed.txt`](./evidence/terminal/api/decision-anchor-fixed.txt) (그 링크가 실제로 200) ·
|
||||
> [`evidence/terminal/audit/dead-link-sweep.txt`](./evidence/terminal/audit/dead-link-sweep.txt) (35개 전수 200)
|
||||
> [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) (저장된 주소가 앵커로 바뀌고 V15 가 적용된 것) ·
|
||||
> [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) (그 링크가 실제로 200) ·
|
||||
> [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) (35개 전수 200)
|
||||
|
||||
### 9.3 주제가 없는 기록이 죽은 링크를 달았다 (`23efcf0`)
|
||||
|
||||
@@ -1134,9 +1134,9 @@ topic (주제)
|
||||
|
||||
### 14.3 축이 무엇을 기준으로 묶이나 (실제 데이터)
|
||||
|
||||
> **근거** — [`evidence/terminal/db/topic-variant-rows.txt`](./evidence/terminal/db/topic-variant-rows.txt) ·
|
||||
> [`evidence/terminal/db/record-variant-links.txt`](./evidence/terminal/db/record-variant-links.txt) ·
|
||||
> 화면과 실측값은 [`evidence/screens/`](./evidence/screens/)
|
||||
> **근거** — [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) ·
|
||||
> [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) ·
|
||||
> 화면과 실측값은 [`evidence/browser/`](./evidence/browser/)
|
||||
|
||||
두 주제가 **같은 구조**를 씁니다. 다만 내용의 양이 달라 다르게 보입니다.
|
||||
|
||||
@@ -1172,7 +1172,7 @@ jpa-feed-query-performance 축 이름 「조회 전략」 축 3개
|
||||
|
||||
> **근거** — 가드 셋(`public-path-reachability` · `section-heading-rank` · `route-chunk-names`)을
|
||||
> 각각 결함으로 되돌려 실제로 빨개지는 것을 확인한 기록:
|
||||
> [`evidence/terminal/guards/guards-actually-fail.txt`](./evidence/terminal/guards/guards-actually-fail.txt)
|
||||
> [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt)
|
||||
|
||||
### 15.1 프론트엔드
|
||||
|
||||
@@ -1240,7 +1240,7 @@ python3 scripts/check-openapi.py && python3 scripts/check-consistency.py \
|
||||
|
||||
### 16.1 삭제를 막는 이유를 문구가 말하지 않는다
|
||||
|
||||
> **근거** — [`evidence/terminal/db/delete-blocked-by-project-link.txt`](./evidence/terminal/db/delete-blocked-by-project-link.txt)
|
||||
> **근거** — [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt)
|
||||
> (진단 당시 캡처 + 사용자가 조치한 뒤의 사후 확인)
|
||||
|
||||
작업본 삭제 실패는 다섯 가지 이유가 **전부 같은 한 문장**으로 나옵니다:
|
||||
@@ -1299,7 +1299,7 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
|
||||
### 16.7 종류 열거 두 곳이 아직 컴파일러의 보호를 못 받는다
|
||||
|
||||
이 문서를 쓰며 근거를 모으다가 새로 확인한 것입니다
|
||||
([`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt)).
|
||||
([`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt)).
|
||||
|
||||
- **`PublicSql.pathOf`** — `RecordKind` 가 아니라 `String`(공개 투영의 `resource_type`)으로
|
||||
switch 합니다. 그 칸은 `RecordKind` 에 없는 값(`PROJECT`, `RELEASE`)도 담기 때문입니다.
|
||||
@@ -1317,7 +1317,7 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
|
||||
|
||||
`3bb724b`·`2b2f443` 에서 `git add -A` 로 홈 탭 검토용 PNG 를 프론트 저장소 루트에 커밋했습니다
|
||||
— `home-tabs-keycloak.png`, `home-topic-tabs.png`, `home-topic-tabs-2.png`. 소스에 들어갈
|
||||
파일이 아닙니다. (이 문서의 `evidence/screens/` 에는 사본을 뒀습니다.)
|
||||
파일이 아닙니다. (이 문서의 `evidence/browser/` 에는 사본을 뒀습니다.)
|
||||
|
||||
### 16.9 주제 논지·축 결론의 출처
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,9 @@
|
||||
# <project>
|
||||
|
||||
- codebase: `/shared/codebase/<project>`
|
||||
- analysis owner: `/shared/document-detail/<project>`
|
||||
- Tech Log output: `/shared/Tech-Log-Document/<project>`
|
||||
|
||||
## Current analysis scope
|
||||
|
||||
`state.json`을 정본으로 사용한다. 대형 코드베이스에서는 한 실행에 한 module/subsystem을 우선한다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Project Overview
|
||||
|
||||
## 분석 기준 revision
|
||||
|
||||
- repository: `/shared/codebase/<project>`
|
||||
- revision: `<git revision or non-git snapshot note>`
|
||||
|
||||
## Build and module map
|
||||
|
||||
## Dependency direction
|
||||
|
||||
## Runtime entry points
|
||||
|
||||
## Persistence / messaging / external systems
|
||||
|
||||
## Test topology
|
||||
|
||||
## Configuration and operational surfaces
|
||||
|
||||
## 분석할 bounded scopes
|
||||
|
||||
| scope | why separate | status | analysis file |
|
||||
|---|---|---|---|
|
||||
|
||||
## 아직 단정하지 않는 것
|
||||
@@ -0,0 +1,60 @@
|
||||
# <module/subsystem> 완전 해부
|
||||
|
||||
> 상태: IN_PROGRESS | COMPLETE
|
||||
> 기준 revision: <git-sha>
|
||||
> 분석 범위: <path/module>
|
||||
|
||||
## 0. 커버리지와 숫자 지도
|
||||
|
||||
- production files:
|
||||
- production LOC:
|
||||
- packages/directories:
|
||||
- tests by lane:
|
||||
- migrations/config/build files:
|
||||
- runtime membership:
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| | | FULL_READ / STRUCTURAL_ONLY / EXCLUDED | |
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
## 12. 실제 사용 여부와 dead/unwired/duplicate 경로
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
## 14. 런타임·터미널·브라우저 Evidence
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
각 항목: 우선순위 → 사실 → 근거 → 왜 문제인가 → 확인 방법 → 후보/다음 단계.
|
||||
|
||||
## Source anchors
|
||||
|
||||
모든 핵심 주장에 source-index의 파일/심볼/테스트/evidence anchor를 연결한다.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "<evidence-id>",
|
||||
"kind": "terminal|browser|query-plan|benchmark|other",
|
||||
"sourceRevision": "<git revision or snapshot>",
|
||||
"executedAt": "<ISO-8601>",
|
||||
"command": "<command without secrets or null>",
|
||||
"cwd": "<working directory or null>",
|
||||
"exitCode": null,
|
||||
"rawPath": "evidence/raw/<file>",
|
||||
"presentationPath": "evidence/terminal|browser|svg/<file>",
|
||||
"proves": "<bounded claim>",
|
||||
"doesNotProve": "<important limitation>"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# <project> 상세 분석
|
||||
|
||||
> 이 문서는 `analysis/`의 완료된 bounded analysis를 통합한 정본이다. 새로운 사실을 이 문서에서 처음 만들어내지 않는다.
|
||||
|
||||
## 1. Project map
|
||||
|
||||
## 2. Architectural boundaries
|
||||
|
||||
## 3. Representative execution paths
|
||||
|
||||
## 4. Data and state
|
||||
|
||||
## 5. Failure and operational behavior
|
||||
|
||||
## 6. Tests and verification coverage
|
||||
|
||||
## 7. Confirmed problems / incidents
|
||||
|
||||
## 8. Reusable criteria and rules
|
||||
|
||||
## 9. Unresolved questions
|
||||
|
||||
## 10. Explicit project decisions
|
||||
|
||||
## 11. Evidence index
|
||||
|
||||
## 12. Limits of this analysis
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
schemaVersion: 1
|
||||
project: <project>
|
||||
sourceDocument: final/document.md
|
||||
sourceDocumentSha256: <sha256>
|
||||
sourceRevision: <git-revision-or-snapshot>
|
||||
generatedAt: <ISO-8601>
|
||||
---
|
||||
|
||||
# Root Tree
|
||||
|
||||
PROJECT
|
||||
<project>
|
||||
|
||||
TOPIC
|
||||
<Topic title>
|
||||
<topic-slug>
|
||||
|
||||
├── CASE
|
||||
│ └── <Case title>
|
||||
├── REFERENCE
|
||||
│ └── <Reference title>
|
||||
├── OPEN QUESTION
|
||||
│ └── <Open Question title>
|
||||
└── DECISION
|
||||
└── <Decision title>
|
||||
|
||||
# Node Specifications
|
||||
|
||||
## CASE — <Case title>
|
||||
|
||||
- slug: `<slug>`
|
||||
- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED`
|
||||
- source:
|
||||
- `final/document.md#<anchor>`
|
||||
- code:
|
||||
- `/shared/codebase/<project>/<path>:<line-or-symbol>`
|
||||
- evidence:
|
||||
- `evidence/raw/<file>`
|
||||
- classification: `<specific incident/experiment/diagnosis that makes this a Case>`
|
||||
- missing-verification: `<none or concrete missing check>`
|
||||
- relations:
|
||||
- `<kind>:<slug> — <reason>`
|
||||
|
||||
## REFERENCE — <Reference title>
|
||||
|
||||
- slug: `<slug>`
|
||||
- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED`
|
||||
- source:
|
||||
- `final/document.md#<anchor>`
|
||||
- classification: `<reusable criterion rather than a retelling of one Case>`
|
||||
- scope: `<where the rule applies>`
|
||||
- exceptions: `<known exceptions or none>`
|
||||
- relations:
|
||||
- `<kind>:<slug> — <reason>`
|
||||
|
||||
## OPEN QUESTION — <Open Question title>
|
||||
|
||||
- slug: `<slug>`
|
||||
- readiness: `OPEN | BLOCKED | REJECTED`
|
||||
- source:
|
||||
- `final/document.md#<anchor>`
|
||||
- known:
|
||||
- `<grounded fact>`
|
||||
- unknown:
|
||||
- `<unresolved fact>`
|
||||
- next-verification: `<what would reduce the uncertainty>`
|
||||
- decision-criterion: `<what would allow this question to close>`
|
||||
- relations:
|
||||
- `<kind>:<slug> — <reason>`
|
||||
|
||||
## DECISION — <Decision title>
|
||||
|
||||
- slug: `<slug>`
|
||||
- readiness: `READY | NEEDS_DECISION | BLOCKED | REJECTED`
|
||||
- decision-status: `PROPOSED | ADOPTED | SUPERSEDED | NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#<anchor>`
|
||||
- decision-evidence:
|
||||
- `<commit/ADR/config/history/user-provided decision record>`
|
||||
- grounds:
|
||||
- `<case/reference relation>`
|
||||
- classification: `<why this is an actual project decision, not advice>`
|
||||
- relations:
|
||||
- `<kind>:<slug> — <reason>`
|
||||
@@ -0,0 +1,7 @@
|
||||
# Source Index
|
||||
|
||||
상세 문서의 주장과 근거를 다시 찾을 수 있게 code/config/test/git/runtime source를 색인한다.
|
||||
|
||||
| id | kind | path / command / URL | revision or time | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| SRC-001 | code | `<path>` | `<revision>` | `<grounded fact>` | `<limit>` |
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"project": "<project>",
|
||||
"codebasePath": "/shared/codebase/<project>",
|
||||
"gitRevision": null,
|
||||
"analysisStatus": "NOT_STARTED",
|
||||
"analysisCycle": 1,
|
||||
"scopes": [],
|
||||
"reanalysis": {
|
||||
"baselineRevision": null,
|
||||
"targetRevision": null,
|
||||
"mode": null,
|
||||
"changedPaths": [],
|
||||
"impactedScopes": [],
|
||||
"requestedAt": null,
|
||||
"completedAt": null
|
||||
},
|
||||
"finalDocument": {
|
||||
"path": "final/document.md",
|
||||
"status": "NOT_STARTED",
|
||||
"sourceRevision": null
|
||||
},
|
||||
"rootTree": {
|
||||
"path": "root-tree.md",
|
||||
"status": "NOT_STARTED",
|
||||
"sourceDocumentHash": null
|
||||
},
|
||||
"evidenceTasks": [],
|
||||
"lastRunAt": null
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"project": "clean-architecture-backend-template",
|
||||
"ssot": "final/document.md",
|
||||
"ssotSha256": "57ce748b3dc8fb3f2b11539aced2666fd7715a2f774e2727949aa5a1c02748b6",
|
||||
"generatedAt": "2026-09-04",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
|
||||
"readinessValues": [
|
||||
"READY",
|
||||
"NEEDS_EVIDENCE",
|
||||
"BLOCKED",
|
||||
"REJECTED"
|
||||
],
|
||||
"topics": {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"project": "keycloak",
|
||||
"ssot": "final/document.md",
|
||||
"ssotSha256": "0625bc875ab31ca6f331e6f64bd93f26397d54f3ac6e0162f4128a53a1d97e2d",
|
||||
"generatedAt": "2026-09-04",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
|
||||
"readinessValues": [
|
||||
"READY",
|
||||
"NEEDS_EVIDENCE",
|
||||
"BLOCKED",
|
||||
"REJECTED"
|
||||
],
|
||||
"topics": {
|
||||
"oauth-oidc-auth-boundary": {
|
||||
"topic": "oauth-oidc-auth-boundary",
|
||||
@@ -12,37 +20,74 @@
|
||||
"title": "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"slug": "split-custody-access-token",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "488ce49b-afa4-42a5-a2ce-de2e0653cd82",
|
||||
"assets": 1,
|
||||
"evidence": 0
|
||||
"assets": [
|
||||
"ap2-split-custody-779cb791"
|
||||
],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Public Client와 Confidential Client 구분 기준",
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"OAuth/OIDC 인증 패턴 선택 기준",
|
||||
"Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식",
|
||||
"slug": "bff-session-csrf-responsibility",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-ap3-bff-session-csrf.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "d85bd6af-7599-4ef7-9407-6609927d5b5c",
|
||||
"assets": 2,
|
||||
"evidence": 0
|
||||
"assets": [
|
||||
"ap3-bff-custody-82fa18bd",
|
||||
"ap3-csrf-split-501dd1f7"
|
||||
],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"BFF 인증 구조 설계 기준",
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"OAuth/OIDC 인증 패턴 선택 기준",
|
||||
"BFF가 OAuth Token을 관리하는 조건",
|
||||
"서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
|
||||
"BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
|
||||
"slug": "identity-header-trust",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-ap4-identity-header-trust.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b",
|
||||
"assets": 1,
|
||||
"evidence": 0
|
||||
"assets": [
|
||||
"ap4-edge-trust-1cff2399"
|
||||
],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"OAuth/OIDC 인증 패턴 선택 기준",
|
||||
"Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우",
|
||||
"slug": "spa-browser-credential-boundary",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "bf675775-4f3e-4744-8014-f0efff51422a",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준",
|
||||
"Public Client와 Confidential Client 구분 기준",
|
||||
"OAuth Token과 Application Session을 구분하는 기준"
|
||||
]
|
||||
}
|
||||
],
|
||||
"concept": [
|
||||
@@ -50,55 +95,91 @@
|
||||
"title": "Authorization Code와 PKCE가 보호하는 구간",
|
||||
"slug": "authorization-code-and-pkce",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "75c6c657-3e03-47a0-a9d0-5637fce9dd3f",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준",
|
||||
"Public Client와 Confidential Client 구분 기준",
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Bearer JWT가 인증된 principal이 되기까지",
|
||||
"slug": "bearer-jwt-validation-chain",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "87000d59-b69f-4010-9481-0b71c8bde32d",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준",
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "브라우저가 credential을 보관하는 위치와 그 성질",
|
||||
"slug": "browser-credential-storage",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"BFF 인증 구조 설계 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cookie로 인증하는 요청에서 CSRF token이 하는 일",
|
||||
"slug": "cookie-auth-csrf",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "5c8f12d5-1ead-469b-8e91-2de69401df48",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"BFF 인증 구조 설계 기준",
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"OAuth Token과 Application Session을 구분하는 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Forward-Auth와 Nginx auth_request의 동작",
|
||||
"slug": "forward-auth-and-auth-request",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-forward-auth-and-auth-request.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "a3493786-d3fb-4b01-b1c5-ecb23c3d5497",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
|
||||
"Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
|
||||
"OAuth/OIDC 인증 패턴 선택 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "외부 IdP Brokering의 동작",
|
||||
"slug": "idp-brokering",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"외부 IdP 연동과 Application 인증 구조의 경계",
|
||||
"OAuth Token과 Application Session을 구분하는 기준",
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||
]
|
||||
}
|
||||
],
|
||||
"reference": [
|
||||
@@ -106,64 +187,109 @@
|
||||
"title": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
|
||||
"slug": "authorization-code-endpoint-credential-movement",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "39fdf472-82c4-43ed-abec-73de672f08ae",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"Public Client와 Confidential Client 구분 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "BFF 인증 구조 설계 기준",
|
||||
"slug": "bff-authentication-design-criteria",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-bff-auth-design.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "97eddd97-1096-426a-a2c6-a6c5bf1cd09f",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
|
||||
"BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
|
||||
"BFF가 OAuth Token을 관리하는 조건"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
|
||||
"slug": "forward-auth-identity-header-trust",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-forward-auth-header-trust.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "004dd0a2-5fb3-4f25-80c9-576f709de331",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
|
||||
"Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
|
||||
"OAuth Token과 Application Session을 구분하는 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "외부 IdP 연동과 Application 인증 구조의 경계",
|
||||
"slug": "external-idp-federation-application-boundary",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-idp-federation-boundary.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "1a00a640-8987-4075-a9e4-7ec023cdffbb",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.",
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "OAuth/OIDC 인증 패턴 선택 기준",
|
||||
"slug": "oauth-oidc-pattern-selection-criteria",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "3f886154-1b85-407b-bda4-57d28370e745",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Public Client와 Confidential Client 구분 기준",
|
||||
"slug": "public-confidential-client-boundary",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-public-confidential-client.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "ede6b9ce-eeed-40c8-9175-9e8116029395",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "OAuth Token과 Application Session을 구분하는 기준",
|
||||
"slug": "oauth-token-application-session-boundary",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-token-vs-session.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "66c18e42-116c-459f-86bd-b7e4bf394866",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||
]
|
||||
}
|
||||
],
|
||||
"question": [
|
||||
@@ -171,37 +297,64 @@
|
||||
"title": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
|
||||
"slug": "bff-session-authorized-client-store",
|
||||
"file": "oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "18a5cde2-dd1e-4bff-9f1c-997577ae438f",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"BFF 인증 구조 설계 기준",
|
||||
"Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
|
||||
"slug": "edge-authorization-scope",
|
||||
"file": "oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "7ff40767-a00b-4db2-98f6-0cdfce8c8936",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
|
||||
"Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
|
||||
"BFF 인증 구조 설계 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
|
||||
"slug": "server-session-pattern-multi-instance",
|
||||
"file": "oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "c72656b5-842d-45d9-b5f6-82b66b09d0b9",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"BFF 인증 구조 설계 기준",
|
||||
"BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
|
||||
"slug": "refresh-rotation-replica-contention",
|
||||
"file": "oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "9ae4ec71-a32e-49a7-88c2-f7368541c28d",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
"서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
|
||||
"BFF 인증 구조 설계 기준"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decision": [
|
||||
@@ -209,19 +362,32 @@
|
||||
"title": "BFF가 OAuth Token을 관리하는 조건",
|
||||
"slug": "bff-owns-token-when-browser-must-not",
|
||||
"file": "oauth-oidc-auth-boundary/decision/decision-bff-owns-token.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "19b55c39-c583-4161-9775-df954280a568",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정",
|
||||
"BFF 인증 구조 설계 기준",
|
||||
"OAuth/OIDC 인증 패턴 선택 기준",
|
||||
"Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.",
|
||||
"slug": "federation-is-not-an-application-pattern",
|
||||
"file": "oauth-oidc-auth-boundary/decision/decision-federation-not-a-pattern.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 중",
|
||||
"studioId": "8c1ebea7-204e-445c-9812-0421d9eb0e9c",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"외부 IdP 연동과 Application 인증 구조의 경계",
|
||||
"SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
||||
"OAuth Token과 Application Session을 구분하는 기준"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ highlightCount(i) = max(1, round(500 / (i+1)^1.15)) // 상한 500, 하한 1
|
||||
|
||||
Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고 floor를 적용한 truncated Zipf-like 분포에 가깝습니다.
|
||||
|
||||
공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv)):
|
||||
공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/raw/metrics/l1-skew-distribution.csv`](./evidence/raw/metrics/l1-skew-distribution.csv)):
|
||||
|
||||
| 순위(rank) | 1 | 2 | 3 | 5 | 10 | 50 | 100 | 꼬리(≈150위~) |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -553,7 +553,7 @@ EAGER의 secondary SELECT 구조가 추가 조회의 가능성을 만듭니다.
|
||||
### 6.4 각 조회는 "빠르다" — 그런데도 느리다
|
||||
|
||||
반복되는 하이라이트 조회 하나를 실행계획으로 확인했습니다. 아래는 **Plan A — 대량 시드 직후,
|
||||
`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt)).
|
||||
`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt)).
|
||||
|
||||
```text
|
||||
Index Scan using ix_highlights_feed_items_created on highlights
|
||||
@@ -634,7 +634,7 @@ EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다.
|
||||
| 100 | **100** | 20 | 120 | 100 | 222 |
|
||||
| 1,000 | **1,000** | 20 | 1,020 | 1,000 | 2,022 |
|
||||
|
||||
성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv).
|
||||
성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/raw/metrics/l2-toone-split.csv`](./evidence/raw/metrics/l2-toone-split.csv).
|
||||
|
||||
검산(6.3절 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. 앞서 6.3절에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했습니다 — 파생 예측이 실측으로 확정됐습니다.
|
||||
|
||||
@@ -669,7 +669,7 @@ EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY
|
||||
|
||||
6.4절에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리**
|
||||
(`SELECT * FROM pages WHERE id = ?`, `… FROM users WHERE id = ?`)를 실행계획으로
|
||||
확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt)).
|
||||
확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt)).
|
||||
|
||||
```text
|
||||
-- pages
|
||||
@@ -805,7 +805,7 @@ java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchExcep
|
||||
> `SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id`를
|
||||
> 측정했습니다. 이 문제는 EXPLAIN actual rows나 조인 count로 확인해야 합니다.
|
||||
|
||||
**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv).
|
||||
**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv).
|
||||
|
||||
| N | 전송 행수(★조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement |
|
||||
|---:|---:|---:|---:|---:|---:|---:|
|
||||
@@ -842,7 +842,7 @@ fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처
|
||||
|
||||
앞서 6.4절에서는 반복되는 자식 단건 쿼리를, 7.4절에서는 부모 단건 쿼리를 확인했습니다. 이번
|
||||
차례는 fetch join이 만든 조인 하나입니다. 아래는 seed(100) 직후 같은 형태의 쿼리를
|
||||
EXPLAIN한 결과입니다(원문: [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt)).
|
||||
EXPLAIN한 결과입니다(원문: [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt)).
|
||||
|
||||
```text
|
||||
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
|
||||
@@ -906,7 +906,7 @@ join한 상태에서 페이징을 적용해 보았습니다.
|
||||
**N개 전부** 로드되었습니다. `EntityStatistics.getLoadCount()`로 FeedItem 로드 수를 따로
|
||||
읽어 응답 크기와 실제 적재량을 비교했습니다.
|
||||
|
||||
**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv).
|
||||
**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv).
|
||||
|
||||
| N | returned(페이지) | feedItemLoaded(★ = N) | over-fetch 배수 | 시드 하이라이트 |
|
||||
|---:|---:|---:|---:|---:|
|
||||
@@ -942,7 +942,7 @@ join한 상태에서 페이징을 적용해 보았습니다.
|
||||
|
||||
응답은 한 페이지인데 비용은 N에 비례하는지 측정했습니다. 아래 값은 문서 첫머리에서 밝힌 대로
|
||||
**단일 스레드·warm-cache 상대값**입니다. 절대값이 아니라 N에 따른 변화 방향만 비교했습니다
|
||||
(원본: [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv)).
|
||||
(원본: [`evidence/raw/metrics/l4-cost-curve.csv`](./evidence/raw/metrics/l4-cost-curve.csv)).
|
||||
|
||||
| N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 |
|
||||
|---:|---:|---:|---:|
|
||||
@@ -972,7 +972,7 @@ join한 상태에서 페이징을 적용해 보았습니다.
|
||||
### 10.4 발행 SQL엔 LIMIT이 없다 — 인메모리 페이징의 스모킹건
|
||||
|
||||
인메모리 페이징을 실행계획에서도 확인했습니다. fetch join이 발행한 SQL(a)과 엔티티만 페이징한
|
||||
SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt)).
|
||||
SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt)).
|
||||
|
||||
```text
|
||||
-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음
|
||||
@@ -1035,7 +1035,7 @@ LAZY 연관에 접근합니다. 앞서 N+1을 만들었던 그 코드가 이 설
|
||||
|
||||
### 11.2 실측 — 배치 적용 전후의 쿼리 수
|
||||
|
||||
`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv).
|
||||
`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv).
|
||||
|
||||
| N | before: 순진 총 PreparedStatement | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch | after: 컬렉션 fetch |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
@@ -1069,7 +1069,7 @@ fetch join에서는 `feedItemLoaded`가 N까지 늘었지만 배치 적용 뒤
|
||||
|
||||
### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (카테시안·인메모리 페이징 둘 다 해소)
|
||||
|
||||
앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt)).
|
||||
앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt)).
|
||||
|
||||
```text
|
||||
-- (a) 엔티티만 페이징 — Limit 노드 존재 (fetch join 조인엔 없었다)
|
||||
@@ -1102,7 +1102,7 @@ join 대신 배치를 사용하기로 했습니다.
|
||||
배치로 쿼리 수와 페이징 문제는 풀었지만 엔티티는 여전히 통째로 하이드레이트했습니다.
|
||||
`FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities`에서 seed 1,000의 첫 페이지 20건을
|
||||
조회하자 FeedItem·User·Page·Highlight를 합해 **1,569개 엔티티**가 영속 객체로 올라왔습니다
|
||||
(원본: [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)).
|
||||
(원본: [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv)).
|
||||
화면에는 일부 컬럼만 필요했으므로 다음에는 DTO 프로젝션으로 적재 대상을 줄였습니다.
|
||||
|
||||
---
|
||||
@@ -1142,7 +1142,7 @@ CQRS-lite 계약으로 노출합니다.
|
||||
|
||||
seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 앞 절의 배치 조회와 비교했습니다.
|
||||
프로젝션은 하이드레이트한 엔티티가 0개였습니다. 원본:
|
||||
[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv).
|
||||
[`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv).
|
||||
|
||||
| 지표 | before: 배치 | after: 프로젝션 |
|
||||
|---|---:|---:|
|
||||
@@ -1172,7 +1172,7 @@ N을 10, 100, 1,000으로 바꿔 다시 측정해도 prepared는 **항상 2개**
|
||||
|
||||
### 12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정)
|
||||
|
||||
앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt)).
|
||||
앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt)).
|
||||
|
||||
```text
|
||||
-- (a) 부모 스칼라 프로젝션 — Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다)
|
||||
@@ -1187,7 +1187,7 @@ Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라
|
||||
|
||||
> **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만
|
||||
> 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸습니다(원본:
|
||||
> [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)).
|
||||
> [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv)).
|
||||
> `users`와 `pages` 조인의 행폭이 반영되고 PostgreSQL의 `width`가 실제 전송 바이트가 아니라
|
||||
> 컬럼 타입의 평균폭 추정치이기 때문입니다. 프로젝션의 효과는 SQL 플랜의 width가 아니라
|
||||
> `Statistics.getEntityLoadCount()`에서 확인했습니다.
|
||||
@@ -1204,7 +1204,7 @@ Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차
|
||||
|
||||
프로젝션은 엔티티 과적재를 없앴지만 자식 IN 쿼리는 페이지 부모의 하이라이트를 **전부**
|
||||
가져왔습니다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였습니다(원본:
|
||||
[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)).
|
||||
[`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)).
|
||||
화면에는 부모당 최신 3개, 최대 60개만 필요했습니다. 단순한 `IN` 쿼리의 `LIMIT`은 부모별로
|
||||
적용되지 않으므로 다음 단계에서 Top-N-per-group을 SQL로 구현했습니다.
|
||||
|
||||
@@ -1248,7 +1248,7 @@ SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
|
||||
|
||||
### 13.2 실측 — 세 방법의 결과와 단순 LIMIT의 오작동
|
||||
|
||||
`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv).
|
||||
`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv).
|
||||
|
||||
| 전략 | 반환 행 | 커버한 부모 | 부모당 최대 |
|
||||
|---|---:|---:|---:|
|
||||
@@ -1264,10 +1264,10 @@ SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
|
||||
### 13.3 결과는 같지만 I/O는 달랐다
|
||||
|
||||
세 SQL은 캐시 상태를 맞추기 위해 같은 테스트 실행에서 `EXPLAIN (ANALYZE, BUFFERS)`로
|
||||
측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) ·
|
||||
[`l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) ·
|
||||
[`l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt). 요약:
|
||||
[`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv).
|
||||
측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) ·
|
||||
[`l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) ·
|
||||
[`l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt). 요약:
|
||||
[`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv).
|
||||
|
||||
| 전략 | 최상위 노드 (스캔·조인) | 반환 행 | buffers shared hit | exec |
|
||||
|---|---|---:|---:|---:|
|
||||
@@ -1297,8 +1297,8 @@ WindowAgg Run Condition: (row_number() OVER (?) <= 3) Buffers: shared hit=43
|
||||
|
||||
LATERAL의 buffers가 작은 이유가 복합 인덱스인지 확인했습니다. 같은 쿼리를 두고 인덱스를
|
||||
제거한 뒤 다시 만들면서 측정했습니다. 원본:
|
||||
[`l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) ·
|
||||
[`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv).
|
||||
[`l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) ·
|
||||
[`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv).
|
||||
|
||||
| variant | 자식 접근 | buffers shared hit | exec |
|
||||
|---|---|---:|---:|
|
||||
@@ -1314,7 +1314,7 @@ buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472
|
||||
### 13.5 그룹 크기가 승자를 가른다 — K 곡선
|
||||
|
||||
세 방식의 차이가 그룹 크기에 따라 달라지는지도 확인했습니다. seed 1,000에서 top-K를
|
||||
3·50·500으로 바꿔 측정했습니다(원본: [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)).
|
||||
3·50·500으로 바꿔 측정했습니다(원본: [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv)).
|
||||
|
||||
| K | 윈도우 반환 | 윈도우 buffers | LATERAL 반환 | LATERAL buffers |
|
||||
|---:|---:|---:|---:|---:|
|
||||
@@ -1377,7 +1377,7 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
### 14.2 실측 — OFFSET은 깊이에 비례하고 keyset은 일정하다
|
||||
|
||||
seed 2,000에서 두 방식에 같은 정렬키 인덱스를 사용했습니다. "훑은 행"은 `Limit` 하위의
|
||||
actual rows로 계산했습니다(원본: [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)).
|
||||
actual rows로 계산했습니다(원본: [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv)).
|
||||
|
||||
| 페이지 (offset) | OFFSET 훑은 행 | keyset 훑은 행 |
|
||||
|---:|---:|---:|
|
||||
@@ -1391,7 +1391,7 @@ keyset은 20행만 읽었습니다. 무한 스크롤의 뒤쪽 페이지가 느
|
||||
|
||||
### 14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제
|
||||
|
||||
`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt). 요약: [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv).
|
||||
`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt). 요약: [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv).
|
||||
|
||||
| 변형 | 플랜 | 훑은 행 | buffers | exec |
|
||||
|---|---|---:|---:|---:|
|
||||
@@ -1426,7 +1426,7 @@ range scan합니다. `first_highlighted_at`이 같은 행도 안정적으로 넘
|
||||
|
||||
### 14.5 keyset이 못 푸는 것 — 가시성 OR
|
||||
|
||||
keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt).
|
||||
keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt).
|
||||
|
||||
```text
|
||||
-- 가시성 OR 을 얹으면: 정렬키 Index Only Scan 이 사라지고 BitmapOr + 별도 Sort 로
|
||||
@@ -1477,7 +1477,7 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방식이 같은 20개 feed_item을
|
||||
반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인한 뒤 가시성 조건을
|
||||
처리하는 실행계획을 비교했습니다(원본:
|
||||
[`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)).
|
||||
[`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv)).
|
||||
|
||||
| 안 | 최상위/스캔 | Sort | 멘션 | 훑는 후보 | buffers |
|
||||
|---|---|---|---|---:|---:|
|
||||
@@ -1503,7 +1503,7 @@ Limit -> Merge Append
|
||||
Limit -> Index Only Scan using ix_feed_visible (Index Cond: viewer_id=:me) Heap Fetches: 20
|
||||
```
|
||||
|
||||
원문: [`l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt).
|
||||
원문: [`l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt).
|
||||
|
||||
### 15.4 UNION과 사전계산의 차이
|
||||
|
||||
@@ -1552,7 +1552,7 @@ SELECT p.pid, top3.color, top3.text, top3.created_at
|
||||
|
||||
### 16.2 실측 — 세 기법을 합친 실행계획
|
||||
|
||||
`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt).
|
||||
`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt).
|
||||
|
||||
```text
|
||||
Nested Loop (rows=60) ← LATERAL (상관 조인)
|
||||
@@ -1568,7 +1568,7 @@ Nested Loop (rows=60) ← LATERAL
|
||||
|
||||
### 16.3 간섭 시험 — 사전계산 위에선 겹치고, 단일 OR 위에선 매 페이지 재해소
|
||||
|
||||
`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt).
|
||||
`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt).
|
||||
|
||||
| 부모선택 | 최상위 | 훑는 행 | feed_visible | 부모 buffers |
|
||||
|---|---|---:|---|---:|
|
||||
@@ -1673,76 +1673,76 @@ cd src
|
||||
```
|
||||
|
||||
- 곡선(N1): `l1CollectionNPlusOneGrowsLinearlyWithN` (N=10/100/1000), `collectionFetches == N` 확인.
|
||||
- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt)).
|
||||
- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt)).
|
||||
- 곡선(N2): `l2ToOneEagerHiddenNPlusOneCurve` (N=10/100/1000), `pageFetch == N`(선형)·`userFetch ≤ 20`(평탄)·`entityFetch == pageFetch + userFetch` 확인.
|
||||
- 접근 0 증명(N2): `l2EagerToOneFiresEvenWithZeroFieldAccess`, 접근 0인데 `pageFetch == 100`·`collectionFetch == 0`(EAGER는 나가고 LAZY는 안 나감).
|
||||
- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt)).
|
||||
- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt)).
|
||||
- 다중 컬렉션 실패(9절): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인.
|
||||
- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)).
|
||||
- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt)).
|
||||
- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)).
|
||||
- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv)).
|
||||
- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt)).
|
||||
- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv)).
|
||||
- HHH000104 경고(10절): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭).
|
||||
- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt)).
|
||||
- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)).
|
||||
- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt)).
|
||||
- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)).
|
||||
- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)).
|
||||
- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)).
|
||||
- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)).
|
||||
- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)).
|
||||
- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)).
|
||||
- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)).
|
||||
- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)).
|
||||
- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt)).
|
||||
- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv)).
|
||||
- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt)).
|
||||
- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv)).
|
||||
- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)).
|
||||
- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt) · [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv)).
|
||||
- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)).
|
||||
- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv)).
|
||||
- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt) · [`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv)).
|
||||
- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) · [`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv)).
|
||||
- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv)).
|
||||
- 잔여 비용(13절): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ keyset 페이징 단계).
|
||||
- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)).
|
||||
- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)).
|
||||
- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv)).
|
||||
- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt) · [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv)).
|
||||
- 정확성(14절): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서).
|
||||
- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt)).
|
||||
- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)).
|
||||
- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt)).
|
||||
- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt)).
|
||||
- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt)).
|
||||
- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)).
|
||||
- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt)).
|
||||
- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt)).
|
||||
- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt)).
|
||||
- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv)).
|
||||
- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt)).
|
||||
- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt)).
|
||||
- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt)).
|
||||
- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/raw/metrics/crown-unified-plan.csv`](./evidence/raw/metrics/crown-unified-plan.csv)).
|
||||
- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt)).
|
||||
- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt)).
|
||||
- CQRS-lite 읽기 모델(17절): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(12절 프로젝션 + 13절 window 결합, 12절 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN.
|
||||
|
||||
> 개별 테스트만 돌릴 때는 Gradle 와일드카드가 `*`임에 주의(`...`은 매칭 0). 예) `--tests '*FeedPersistenceIT.l2*'`. 초록불을 다시 돌리려면 `--rerun-tasks`(안 그러면 UP-TO-DATE로 건너뜀). 콘솔 측정 라인(`>>> LAB …`)은 `build/lab-results/feed-nplus1.md`에도 표로 적재됩니다.
|
||||
|
||||
원시 데이터 자산:
|
||||
|
||||
- [`evidence/metrics/l1-query-growth.csv`](./evidence/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫.
|
||||
- [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수.
|
||||
- [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정).
|
||||
- [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문.
|
||||
- [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`evidence/terminal/explain/toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문.
|
||||
- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안).
|
||||
- [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights).
|
||||
- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor).
|
||||
- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음).
|
||||
- [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`evidence/terminal/explain/l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문.
|
||||
- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor).
|
||||
- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계).
|
||||
- [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`evidence/terminal/explain/l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문.
|
||||
- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor).
|
||||
- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다).
|
||||
- [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`evidence/terminal/explain/l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문.
|
||||
- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor).
|
||||
- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만).
|
||||
- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist).
|
||||
- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist).
|
||||
- [`evidence/terminal/explain/l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) · [`evidence/terminal/explain/l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) · [`evidence/terminal/explain/l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join).
|
||||
- [`evidence/terminal/explain/l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증).
|
||||
- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist).
|
||||
- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist).
|
||||
- [`evidence/terminal/explain/l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`evidence/terminal/explain/l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`evidence/terminal/explain/l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문.
|
||||
- [`evidence/terminal/explain/l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계).
|
||||
- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist).
|
||||
- [`evidence/terminal/explain/l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`evidence/terminal/explain/l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`evidence/terminal/explain/l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문.
|
||||
- [`evidence/terminal/explain/l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문.
|
||||
- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist).
|
||||
- [`evidence/terminal/explain/crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법).
|
||||
- [`evidence/terminal/explain/crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`evidence/terminal/explain/crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행).
|
||||
- [`evidence/raw/metrics/l1-query-growth.csv`](./evidence/raw/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫.
|
||||
- [`evidence/raw/metrics/l1-skew-distribution.csv`](./evidence/raw/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수.
|
||||
- [`evidence/raw/metrics/l2-toone-split.csv`](./evidence/raw/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정).
|
||||
- [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문.
|
||||
- [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`evidence/raw/explain/toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문.
|
||||
- [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안).
|
||||
- [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights).
|
||||
- [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor).
|
||||
- [`evidence/raw/metrics/l4-cost-curve.csv`](./evidence/raw/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음).
|
||||
- [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`evidence/raw/explain/l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문.
|
||||
- [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor).
|
||||
- [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계).
|
||||
- [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`evidence/raw/explain/l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문.
|
||||
- [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor).
|
||||
- [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다).
|
||||
- [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`evidence/raw/explain/l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문.
|
||||
- [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor).
|
||||
- [`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만).
|
||||
- [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist).
|
||||
- [`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist).
|
||||
- [`evidence/raw/explain/l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) · [`evidence/raw/explain/l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) · [`evidence/raw/explain/l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join).
|
||||
- [`evidence/raw/explain/l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증).
|
||||
- [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist).
|
||||
- [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist).
|
||||
- [`evidence/raw/explain/l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`evidence/raw/explain/l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`evidence/raw/explain/l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문.
|
||||
- [`evidence/raw/explain/l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계).
|
||||
- [`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist).
|
||||
- [`evidence/raw/explain/l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`evidence/raw/explain/l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`evidence/raw/explain/l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문.
|
||||
- [`evidence/raw/explain/l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문.
|
||||
- [`evidence/raw/metrics/crown-unified-plan.csv`](./evidence/raw/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist).
|
||||
- [`evidence/raw/explain/crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법).
|
||||
- [`evidence/raw/explain/crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`evidence/raw/explain/crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행).
|
||||
|
||||
### B. 측정 환경·출처(provenance)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ assets:
|
||||
- key: in-memory-paging
|
||||
file: ../../../final/assets/tech-log-studio/in-memory-paging.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/terminal/explain/l4-entity-paging-limit.txt
|
||||
- ../../../final/evidence/terminal/explain/l5-entity-paging-limit.txt
|
||||
- ../../../final/evidence/raw/explain/l4-entity-paging-limit.txt
|
||||
- ../../../final/evidence/raw/explain/l5-entity-paging-limit.txt
|
||||
---
|
||||
|
||||
# Collection Fetch Join Pagination의 In-memory Paging
|
||||
|
||||
@@ -11,9 +11,9 @@ assets:
|
||||
- key: eager-lazy-query-sequence
|
||||
file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/terminal/explain/highlights-child-plan-A.txt
|
||||
- ../../../final/evidence/terminal/explain/toone-pages-plan.txt
|
||||
- ../../../final/evidence/terminal/explain/toone-users-plan.txt
|
||||
- ../../../final/evidence/raw/explain/highlights-child-plan-A.txt
|
||||
- ../../../final/evidence/raw/explain/toone-pages-plan.txt
|
||||
- ../../../final/evidence/raw/explain/toone-users-plan.txt
|
||||
---
|
||||
|
||||
# Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1
|
||||
|
||||
@@ -11,7 +11,7 @@ assets:
|
||||
- key: cartesian-row-multiplication
|
||||
file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/terminal/explain/l3-cartesian-join-plan.txt
|
||||
- ../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt
|
||||
---
|
||||
|
||||
# Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
|
||||
|
||||
@@ -11,7 +11,7 @@ assets:
|
||||
- key: projection-row-over-fetch
|
||||
file: ../../../final/assets/tech-log-studio/projection-row-over-fetch.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/terminal/explain/l6-parent-projection.txt
|
||||
- ../../../final/evidence/raw/explain/l6-parent-projection.txt
|
||||
---
|
||||
|
||||
# Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||
|
||||
@@ -11,8 +11,8 @@ assets:
|
||||
- key: keyset-vs-offset
|
||||
file: ../../../final/assets/tech-log-studio/keyset-vs-offset.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/terminal/explain/l15-keyset-no-index.txt
|
||||
- ../../../final/evidence/terminal/explain/l15-offset-deep-page.txt
|
||||
- ../../../final/evidence/raw/explain/l15-keyset-no-index.txt
|
||||
- ../../../final/evidence/raw/explain/l15-offset-deep-page.txt
|
||||
---
|
||||
|
||||
# Visibility OR이 Keyset Index를 깨뜨린 문제
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"project": "n+1liner",
|
||||
"ssot": "final/document.md",
|
||||
"ssotSha256": "a32d9e8ba07129fc26deeef2ce07e3624ce6f34888d6cf503d0b7f226bbb4b6b",
|
||||
"generatedAt": "2026-09-04",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
|
||||
"readinessValues": [
|
||||
"READY",
|
||||
"NEEDS_EVIDENCE",
|
||||
"BLOCKED",
|
||||
"REJECTED"
|
||||
],
|
||||
"topics": {
|
||||
"jpa-feed-query-performance": {
|
||||
"topic": "jpa-feed-query-performance",
|
||||
@@ -12,46 +20,100 @@
|
||||
"title": "Collection Fetch Join Pagination의 In-memory Paging",
|
||||
"slug": "collection-fetch-join-in-memory-paging",
|
||||
"file": "jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "c1158754-e3d2-47b8-bb41-81787c0ca84b",
|
||||
"assets": 1,
|
||||
"evidence": 2
|
||||
"assets": [
|
||||
"in-memory-paging"
|
||||
],
|
||||
"evidence": [
|
||||
"../../../final/evidence/raw/explain/l4-entity-paging-limit.txt",
|
||||
"../../../final/evidence/raw/explain/l5-entity-paging-limit.txt"
|
||||
],
|
||||
"relations": [
|
||||
"Collection Fetch Join과 Pagination을 같이 사용하지 않는다",
|
||||
"Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증",
|
||||
"Fetch Join · Batch · Projection 선택 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
|
||||
"slug": "eager-toone-nplus1-without-access",
|
||||
"file": "jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "32d0be7d-d88e-4760-8d91-35d3a233a99a",
|
||||
"assets": 1,
|
||||
"evidence": 3
|
||||
"assets": [
|
||||
"eager-lazy-query-sequence"
|
||||
],
|
||||
"evidence": [
|
||||
"../../../final/evidence/raw/explain/highlights-child-plan-A.txt",
|
||||
"../../../final/evidence/raw/explain/toone-pages-plan.txt",
|
||||
"../../../final/evidence/raw/explain/toone-users-plan.txt"
|
||||
],
|
||||
"relations": [
|
||||
"Fetch Type과 Fetch Strategy 구분",
|
||||
"Projection 이후에도 1,509행을 읽은 Row Over-fetch",
|
||||
"JPA N+1 정량 진단 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증",
|
||||
"slug": "fetch-join-multibag-and-row-explosion",
|
||||
"file": "jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "7ed75172-fd56-42bf-956a-8f9fc1cca235",
|
||||
"assets": 1,
|
||||
"evidence": 1
|
||||
"assets": [
|
||||
"cartesian-row-multiplication"
|
||||
],
|
||||
"evidence": [
|
||||
"../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt"
|
||||
],
|
||||
"relations": [
|
||||
"Fetch Join · Batch · Projection 선택 기준",
|
||||
"Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
|
||||
"Collection Fetch Join Pagination의 In-memory Paging"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Projection 이후에도 1,509행을 읽은 Row Over-fetch",
|
||||
"slug": "projection-row-over-fetch",
|
||||
"file": "jpa-feed-query-performance/case/case-projection-row-over-fetch.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "4c9c3b90-bc89-4300-9334-088ea95d37d8",
|
||||
"assets": 1,
|
||||
"evidence": 1
|
||||
"assets": [
|
||||
"projection-row-over-fetch"
|
||||
],
|
||||
"evidence": [
|
||||
"../../../final/evidence/raw/explain/l6-parent-projection.txt"
|
||||
],
|
||||
"relations": [
|
||||
"화면 조회는 Read Projection을 사용한다",
|
||||
"Top-N-per-group 선택 기준",
|
||||
"Fetch Join · Batch · Projection 선택 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Visibility OR이 Keyset Index를 깨뜨린 문제",
|
||||
"slug": "visibility-or-breaks-keyset-index",
|
||||
"file": "jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "e6715e81-6dbd-4287-8e19-946c334f38fb",
|
||||
"assets": 1,
|
||||
"evidence": 2
|
||||
"assets": [
|
||||
"keyset-vs-offset"
|
||||
],
|
||||
"evidence": [
|
||||
"../../../final/evidence/raw/explain/l15-keyset-no-index.txt",
|
||||
"../../../final/evidence/raw/explain/l15-offset-deep-page.txt"
|
||||
],
|
||||
"relations": [
|
||||
"Feed Visibility Query Pattern",
|
||||
"Keyset Pagination 설계 기준",
|
||||
"feed_visible을 Production CQRS로 승격할 것인가"
|
||||
]
|
||||
}
|
||||
],
|
||||
"concept": [],
|
||||
@@ -60,64 +122,105 @@
|
||||
"title": "Feed Visibility Query Pattern",
|
||||
"slug": "feed-visibility-query-pattern",
|
||||
"file": "jpa-feed-query-performance/reference/reference-feed-visibility-query-pattern.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "635fcedd-d402-4297-bcf3-9fcdf4200d28",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제",
|
||||
"feed_visible을 Production CQRS로 승격할 것인가",
|
||||
"Keyset Pagination 설계 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fetch Join · Batch · Projection 선택 기준",
|
||||
"slug": "fetch-strategy-selection",
|
||||
"file": "jpa-feed-query-performance/reference/reference-fetch-strategy-selection.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "db99cbc5-9123-4599-b368-39ff3170e81d",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증",
|
||||
"Collection Fetch Join Pagination의 In-memory Paging",
|
||||
"Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fetch Type과 Fetch Strategy 구분",
|
||||
"slug": "fetch-type-vs-fetch-strategy",
|
||||
"file": "jpa-feed-query-performance/reference/reference-fetch-type-vs-fetch-strategy.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "51095f6e-2cc8-439c-8648-065033614215",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"필드 접근 없이 발생한 EAGER ToOne N+1",
|
||||
"Fetch Join · Batch · Projection 선택 기준",
|
||||
"JPA N+1 정량 진단 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Keyset Pagination 설계 기준",
|
||||
"slug": "keyset-pagination-design",
|
||||
"file": "jpa-feed-query-performance/reference/reference-keyset-pagination-design.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "06788903-3dfa-4f70-b159-f1224384fd0b",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제",
|
||||
"Feed Pagination은 Keyset을 사용한다",
|
||||
"PostgreSQL Query Plan 측정 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "JPA N+1 정량 진단 기준",
|
||||
"slug": "nplus1-quantitative-diagnosis",
|
||||
"file": "jpa-feed-query-performance/reference/reference-nplus1-quantitative-diagnosis.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "b0b55ac9-c0a3-4c01-ba84-0aa478923ace",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
|
||||
"PostgreSQL Query Plan 측정 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "PostgreSQL Query Plan 측정 기준",
|
||||
"slug": "postgresql-query-plan-measurement",
|
||||
"file": "jpa-feed-query-performance/reference/reference-postgresql-query-plan-measurement.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "e8c2e9ea-cd87-46f8-9469-849dbd433d86",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Query Plan은 실제 PostgreSQL에서 측정한다",
|
||||
"JPA N+1 정량 진단 기준",
|
||||
"ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Top-N-per-group 선택 기준",
|
||||
"slug": "top-n-per-group-selection",
|
||||
"file": "jpa-feed-query-performance/reference/reference-top-n-per-group-selection.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "bf5f2462-0e94-4723-bdc8-f7dd709b2dbb",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Projection 이후에도 1,509행을 읽은 Row Over-fetch",
|
||||
"PostgreSQL Query Plan 측정 기준",
|
||||
"Fetch Join · Batch · Projection 선택 기준"
|
||||
]
|
||||
}
|
||||
],
|
||||
"question": [
|
||||
@@ -125,46 +228,72 @@
|
||||
"title": "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가",
|
||||
"slug": "cardinality-estimate-after-analyze",
|
||||
"file": "jpa-feed-query-performance/question/question-cardinality-estimate-after-analyze.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"PostgreSQL Query Plan 측정 기준",
|
||||
"Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "실제 동시 트래픽에서도 이 구조가 안정적인가",
|
||||
"slug": "concurrency-stability",
|
||||
"file": "jpa-feed-query-performance/question/question-concurrency-stability.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "6cbe963f-86f8-4df6-be5a-900712970d01",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"PostgreSQL Query Plan 측정 기준",
|
||||
"feed_visible을 Production CQRS로 승격할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Round Trip과 Row Volume을 독립 측정할 것인가",
|
||||
"slug": "isolate-round-trip-and-row-volume",
|
||||
"file": "jpa-feed-query-performance/question/question-isolate-round-trip-and-row-volume.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "5159c415-232d-424a-970a-b0db52746767",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"JPA N+1 정량 진단 기준",
|
||||
"Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Highlight 없는 FeedItem을 허용할 것인가",
|
||||
"slug": "nullable-first-highlighted-at",
|
||||
"file": "jpa-feed-query-performance/question/question-nullable-first-highlighted-at.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "b099ca65-bf9f-4d61-814c-74722453fa3c",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Keyset Pagination 설계 기준",
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "feed_visible을 Production CQRS로 승격할 것인가",
|
||||
"slug": "promote-feed-visible-to-cqrs",
|
||||
"file": "jpa-feed-query-performance/question/question-promote-feed-visible-to-cqrs.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "5088ce14-b096-41d3-abba-64b7afb48bb9",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Feed Visibility Query Pattern",
|
||||
"현재 Read Model은 CQRS-lite로 유지한다",
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decision": [
|
||||
@@ -172,64 +301,106 @@
|
||||
"title": "Entity Graph 조회에는 Batch Fetch를 사용한다",
|
||||
"slug": "batch-fetch-for-entity-graph",
|
||||
"file": "jpa-feed-query-performance/decision/decision-batch-fetch-for-entity-graph.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "08a74b35-10c3-4874-8fbc-209b0b6e942e",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Fetch Join · Batch · Projection 선택 기준",
|
||||
"Collection Fetch Join Pagination의 In-memory Paging",
|
||||
"Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "현재 Read Model은 CQRS-lite로 유지한다",
|
||||
"slug": "keep-read-model-as-cqrs-lite",
|
||||
"file": "jpa-feed-query-performance/decision/decision-keep-read-model-as-cqrs-lite.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "7f248f68-ce2b-43ec-94ce-82324d0bd1a7",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"feed_visible을 Production CQRS로 승격할 것인가",
|
||||
"화면 조회는 Read Projection을 사용한다",
|
||||
"Feed Visibility Query Pattern"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Feed Pagination은 Keyset을 사용한다",
|
||||
"slug": "keyset-for-feed-pagination",
|
||||
"file": "jpa-feed-query-performance/decision/decision-keyset-for-feed-pagination.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "1dbce381-f0dc-4d49-ad68-bd31d205677e",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Keyset Pagination 설계 기준",
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제",
|
||||
"Highlight 없는 FeedItem을 허용할 것인가"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Query Plan은 실제 PostgreSQL에서 측정한다",
|
||||
"slug": "measure-plan-on-real-postgresql",
|
||||
"file": "jpa-feed-query-performance/decision/decision-measure-plan-on-real-postgresql.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "ae6c9bea-d3a3-46e1-bbd4-8d580d336394",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"PostgreSQL Query Plan 측정 기준",
|
||||
"Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
|
||||
"Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Collection Fetch Join과 Pagination을 같이 사용하지 않는다",
|
||||
"slug": "no-collection-fetch-join-with-pagination",
|
||||
"file": "jpa-feed-query-performance/decision/decision-no-collection-fetch-join-with-pagination.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "5e4d033c-d6fe-4257-a4dc-1ade44473c72",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Collection Fetch Join Pagination의 In-memory Paging",
|
||||
"Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증",
|
||||
"Fetch Join · Batch · Projection 선택 기준"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Query Strategy는 FeedQueryPort 뒤에서 소유한다",
|
||||
"slug": "query-strategy-behind-port",
|
||||
"file": "jpa-feed-query-performance/decision/decision-query-strategy-behind-port.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "4e3200c8-eff5-4442-ae84-ae7b7fa92c8b",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Fetch Join · Batch · Projection 선택 기준",
|
||||
"Collection Fetch Join Pagination의 In-memory Paging",
|
||||
"화면 조회는 Read Projection을 사용한다"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "화면 조회는 Read Projection을 사용한다",
|
||||
"slug": "read-projection-for-screen-query",
|
||||
"file": "jpa-feed-query-performance/decision/decision-read-projection-for-screen-query.md",
|
||||
"readiness": "READY",
|
||||
"status": "게시 전",
|
||||
"studioId": "30a37f34-b406-4061-b924-e22e0be0c3bf",
|
||||
"assets": 0,
|
||||
"evidence": 0
|
||||
"assets": [],
|
||||
"evidence": [],
|
||||
"relations": [
|
||||
"Projection 이후에도 1,509행을 읽은 Row Over-fetch",
|
||||
"Fetch Join · Batch · Projection 선택 기준",
|
||||
"Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL
|
||||
|
||||
> **Redis 코드 상세 시리즈 13/20** · [전체 지도](./redis-backend-policy-boundary.md) · 이전: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) · 다음: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md)
|
||||
|
||||
`ca-skeleton.capabilities.cache.bindings.default=redis`로 켠 애플리케이션에서 캐시 조회 한 번이 어디에서 시작해 어떤 Redis 명령을 거치고 언제 원본 저장소로 내려가는지를 코드로 따라간 기록입니다. Redis와 Spring은 알지만 이 저장소의 캐시 코드는 처음 보는 분을 대상으로 합니다. Spring이 조립하는 `CacheRegionPort<String, byte[]>` 빈과 애플리케이션 쪽 `CacheAsideExecutor`를 함께 읽습니다.
|
||||
|
||||
값 하나를 감싸는 봉투와 리전 세대를 먼저 정의하고, 빈이 만들어지는 조건, 조회 한 번의 호출 순서, 무효화, 갱신과 실패 분기, 테스트가 고정한 범위 순서로 살펴보겠습니다.
|
||||
|
||||
## 값을 감싸는 봉투와 리전 세대
|
||||
|
||||
`CacheEnvelope`는 캐시에 넣을 값을 그대로 저장하지 않고 앞에 머리말을 붙여 감싸는 형식입니다. 머리말과 페이로드는 `|` 경계 여섯 개로 나뉘고, 스키마 버전·원본 리비전·세대·소프트 만료 시각·하드 만료 시각·부재 표시가 차례로 들어간 뒤 마지막에 페이로드 바이트가 옵니다. 두 만료 시각은 절대 에폭 밀리초로 적습니다. 현행 스키마는 v1입니다. [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29), [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104)
|
||||
|
||||
세대(generation)는 리전마다 Redis에 두는 카운터입니다. 값을 기록할 때 그 시점의 세대를 봉투에 함께 적어 두고, 나중에 읽을 때 봉투의 세대가 현재 세대와 다르면 그 값을 지나간 값으로 처리합니다.
|
||||
|
||||
만료는 두 단계로 나뉩니다. 소프트 TTL이 지나면 값은 아직 남아 있되 갱신 후보가 되고, 하드 TTL이 지나면 만료로 처리됩니다. 이 글에서는 소프트와 하드 사이에 있는 값을 '묵은 값'이라고 부르겠습니다. 원본에 값이 없다는 사실 자체를 적어 두는 항목은 부재 표시를 켜서 기록하고, 여기에는 별도의 네거티브 TTL을 씁니다.
|
||||
|
||||
기본값은 소프트 TTL 30초, 하드 TTL 5분, 네거티브 TTL 10초, 명령 타임아웃 200ms입니다. 시작 시점에 `positiveSoftTtl <= positiveHardTtl`, 설정된 하드 TTL 하한, 양수 명령 타임아웃, 양수 키 버전을 검사합니다. [`RedisCapabilitySettings.Cache.validate`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70)
|
||||
|
||||
## 캐시 리전 빈이 만들어지는 조건
|
||||
|
||||
전역 `app.redis.enabled=true`이고 기본 캐시 바인딩이 `redis`일 때만 `redisDefaultCacheRegion` 빈이 생깁니다. 이 메서드는 `RedisRuntimeOwner`, 네임스페이스, 캐시 설정, `Secret`, `Clock`을 받아 캐시 설정을 검증하고, 공통 `app.redis.namespace` 아래의 `CacheKeys`를 만든 다음, `RedisCacheRegionAdapter` 생성자에 넘겨 `CacheRegionPort<String, byte[]>` 빈을 내놓습니다. [`RedisCapabilityConfig.redisDefaultCacheRegion`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95)
|
||||
|
||||
`CacheRegionPort`는 애플리케이션이 넘긴 원래 키와 값을 받아 조회·기록·무효화 결과를 타입으로 구분해 돌려주는 계약입니다. 실제 동작은 공급자 어댑터가 맡습니다. [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7)
|
||||
|
||||
Redis 키에는 원래 키가 들어가지 않습니다. 빈을 만들 때 원래 키를 `HMAC-SHA-256`으로 바꾸는 함수를 함께 주입하는데, 해시 재료에 환경·서비스·도메인이 같이 들어가기 때문에 같은 식별자라도 네임스페이스가 다르면 다이제스트도 달라집니다. 출력은 `hv1:<hex>`입니다. [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312) 실제로 Redis에 들어가는 항목 키는 공통 네임스페이스, `cache` 기능 이름, 키 배치 버전, 리전, 다이제스트를 이어 붙여 만듭니다. [`CacheKeys.entryKey`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383)
|
||||
|
||||
`CacheAsideExecutor`는 생성 시점에 리전별 정책으로 `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 둘 다 프로세스 안에서만 돕니다. 2인자 생성자는 갱신 조정자를 주입하지 않고, 4인자 생성자만 조정자와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25)
|
||||
|
||||
Redis 조회와 기록은 여기까지 운영 빈으로 조립됩니다. [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)은 Redis에 연결하지 않고 캐시 빈이 한 개만 생기는지를 검사합니다. 다만 이 빈과 `CacheAsideExecutor`를 묶어 실제 유스케이스에 주입하는 운영 조립은 찾지 못했으므로, 아래 호출 순서는 두 클래스를 이어 읽은 결과입니다.
|
||||
|
||||
## 조회 한 번의 호출 순서
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Use case
|
||||
participant E as CacheAsideExecutor
|
||||
participant C as RedisCacheRegionAdapter
|
||||
participant R as Redis
|
||||
participant S as Source loader
|
||||
U->>E: getOrLoad(key, region, loader)
|
||||
E->>C: lookup(key)
|
||||
opt 이 CacheKeys의 generation이 unresolved
|
||||
C->>R: INCRBY generation 0
|
||||
end
|
||||
C->>R: GET entryKey(HMAC(key))
|
||||
alt fresh 또는 negative hit
|
||||
C-->>E: Hit / NegativeHit
|
||||
E-->>U: 즉시 결과
|
||||
else future schema
|
||||
C-->>E: QUARANTINE_AND_RELOAD + unusable token
|
||||
E-->>U: FAIL_FAST (source 미호출)
|
||||
else stale/miss/unavailable
|
||||
C-->>E: typed lookup
|
||||
E->>E: local single-flight + source bulkhead
|
||||
E->>S: load(key, cancellation)
|
||||
S-->>E: loaded / absent / failure
|
||||
E->>C: record 또는 recordAbsent
|
||||
C->>R: SET envelope [NX/none] PX hardTTL
|
||||
E-->>U: LoadedFromSource 등 typed result
|
||||
end
|
||||
```
|
||||
|
||||
### 1단계: 세대를 한 번만 읽습니다
|
||||
|
||||
`lookup`은 `REGULAR` 갈래를 빌린 뒤 `resolveGeneration`을 부릅니다. 서버 값을 읽는 시점은 `CacheKeys`마다 최초 접근 한 번뿐이어서, `resolved`가 `true`가 되면 이후 조회와 기록은 Redis 카운터를 다시 읽지 않고 프로세스 안에 남은 세대 값을 씁니다. 최초 호출의 `INCRBY generationKey 0`은 키가 없으면 0을 만들고 그 시점의 출발값을 맞추지만, 그 뒤 다른 인스턴스가 올린 값까지 가져오지는 않습니다. [`resolveGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:322), [`CacheKeys.resolved`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:358)
|
||||
|
||||
다른 인스턴스가 세대를 올리면 어떻게 될까요? 인스턴스 A와 B가 모두 세대 0을 읽어 둔 뒤 A가 리전 세대를 1로 올리면 A의 `CacheKeys`만 1로 갱신됩니다. B는 계속 0을 쓰기 때문에 세대 0으로 적힌 항목을 그대로 맞히거나, 세대 0으로 다시 기록할 수 있습니다. 지금의 리전 무효화를 모든 인스턴스에 즉시 반영되는 무효화로 읽을 수 없는 이유입니다.
|
||||
|
||||
이 상황을 재현하는 테스트도 없습니다. [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)은 어댑터 하나와 `CacheKeys` 하나로 기록→무효화→조회만 검사하고, 어댑터 둘이 세대를 따로 읽어 둔 뒤 한쪽만 무효화하는 회귀 테스트는 없습니다.
|
||||
|
||||
### 2단계: `GET` 결과를 다섯 갈래로 나눕니다
|
||||
|
||||
`lookup`이 원래 키 하나를 받아 돌려주는 값은 `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` 다섯 가지입니다. [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) 저장된 값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 `|` 경계 여섯 개를 찾아 스키마 버전, 원본 리비전, 세대, 소프트·하드 만료 시각, 부재 표시와 페이로드를 되살립니다.
|
||||
|
||||
해석 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다.
|
||||
|
||||
1. 지금보다 높은 스키마 버전은 어댑터에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 다만 여기에 쓰는 2인자 `IncompatibleSchema`에는 쓸 수 있는 관측 토큰과 쓰기 조건이 없습니다.
|
||||
2. 폐기된 버전, 모르는 버전, 깨진 봉투는 `FAIL_FAST`입니다.
|
||||
3. 봉투의 세대가 현재 세대와 다르면 `Miss(INVALIDATED)`입니다.
|
||||
4. 하드 만료 시각이 지났으면 `Miss(EXPIRED)`입니다.
|
||||
5. 부재 표시가 있으면 `NegativeHit`입니다.
|
||||
6. 그 밖에는 소프트 만료 전이면 `FRESH`, 소프트와 하드 사이면 `STALE`입니다.
|
||||
|
||||
높은 스키마 버전을 보통의 미스로 바꾸지 않는 이유는 구버전 인스턴스가 신버전 값을 덮어쓰는 일을 막기 위해서입니다.
|
||||
|
||||
`CacheAsideExecutor`까지 따라가면 결과가 달라집니다. 어댑터가 높은 스키마 버전에 쓰는 2인자 생성자는 관측 토큰과 쓰기 조건을 모두 `unavailable()`로 채우고, 실행기는 정책이 `QUARANTINE_AND_RELOAD`여도 관측 토큰을 쓸 수 없으면 정책을 `FAIL_FAST`로 바꾼 `IncompatibleSchema`를 즉시 돌려줍니다. 원본 로더는 부르지 않습니다. 지금 조합에서 실제로 일어나는 일은 `FUTURE_VERSION` → `QUARANTINE_AND_RELOAD` 라벨 → 실행기의 `FAIL_FAST`입니다. [`CacheLookup.IncompatibleSchema`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 스키마 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81)
|
||||
|
||||
[`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 어댑터의 분류와 정책 라벨만 검사하고, 어댑터와 실행기를 결합해 원본을 다시 읽는지는 확인하지 않습니다.
|
||||
|
||||
### 3단계: 신선한 값과 부재 표시는 원본을 부르지 않습니다
|
||||
|
||||
`CacheAsideExecutor.getOrLoad`는 키, 리전, 원본 로더를 받아 `CacheResult<V>`를 돌려줍니다. [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) `FRESH`는 `FreshHit`로 바꾸고 `NegativeHit`는 그대로 돌려주므로 원본 호출이 없습니다. 묵은 값은 하드 만료 시각과 관측 토큰을 가진 후보로 남겨 두고, 미스와 `Unavailable`은 원본에서 다시 채울 대상으로 넘깁니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59)
|
||||
|
||||
같은 프로세스에서 같은 키를 요청하면 `CacheSingleFlight`가 하나로 합칩니다. 동시에 진행할 수 있는 키 수, 키 하나당 대기자 수, 대기 시간을 넘기면 각각 `MAXIMUM_IN_FLIGHT_KEYS`, `MAXIMUM_WAITERS`, `WAIT_TIMEOUT`으로 거절됩니다. `CacheSourceBulkhead`가 차면 `SOURCE_OVERLOADED`, 마감 시각을 넘기면 `LOAD_TIMEOUT`입니다.
|
||||
|
||||
### 4단계: 원본 결과를 봉투에 담아 기록합니다
|
||||
|
||||
원본이 `Loaded`를 주면 `region.record`를, `AuthoritativeAbsent`를 주면 `recordAbsent`를 부릅니다. 일시적 실패와 영구 실패는 캐시에 쓰지 않습니다. 원본 결과는 `RetryableNoEffect` 같은 멱등성 의미를 가져다 쓰지 않고 캐시 전용 `SourceLoadOutcome`으로 나뉘어 있습니다. [`invokeSourceDirect`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206)
|
||||
|
||||
`write`는 값 또는 부재, 원본 리비전, 쓰기 의도를 받아 조건을 확인한 뒤 `SET`에 TTL을 붙여 실행하고 `CacheRecordOutcome`을 돌려줍니다. 새로 쓰는 항목에는 `CacheEnvelope.CURRENT_SCHEMA_VERSION`, 원본 리비전, 현재 세대, `now + effectiveSoft`, `now + ttl`, 부재 여부, 페이로드가 들어갑니다. Redis에 거는 실제 TTL은 하드 TTL과 같고, 값이 있는 항목은 하드 TTL을, 부재를 적는 항목은 별도의 네거티브 TTL을 씁니다. [`write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:230)
|
||||
|
||||
`ONLY_IF_ABSENT`는 `SET ... NX`에 대응합니다. `ONLY_IF_OBSERVED`에서는 조회 시점의 항목 바이트로 `CacheObservationToken`과 `CacheWriteCondition`을 모두 만들고, 실행기도 두 값을 `CacheRecordMetadata`에 실어 보냅니다. 그런데 Redis 어댑터의 `write`는 `metadata.writeCondition()`을 읽지 않고, 지금 저장된 항목 바이트의 `SHA-256` 앞 16바이트와 `metadata.observedToken()`만 비교합니다. [`CacheRecordMetadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [실행기가 넘기는 `metadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:231), [`write`의 조건 비교](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208)
|
||||
|
||||
그래서 이 비교가 잡아내는 것은 항목 바이트가 통째로 바뀐 경우뿐입니다. 리전 세대를 올려도 기존 항목 바이트는 바뀌지 않기 때문에, 원본을 읽는 도중에 무효화가 일어나도 비교는 통과합니다. 같은 어댑터라면 새 세대로 원본 결과를 써서 무효화 직후 값을 다시 채울 수 있고, 다른 인스턴스라면 앞서 읽어 둔 이전 세대로 쓸 수 있습니다. 세대와 바이트 관측을 한 번의 원자적 비교·교환(`CAS`)으로 묶지 않았고, 비교용 `GET`과 최종 `SET`도 Lua나 트랜잭션으로 묶지 않았습니다.
|
||||
|
||||
[`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 항목 바이트 자체가 바뀐 경우를 검사하고, 세대 올리기와 진행 중인 `ONLY_IF_OBSERVED`를 함께 놓지는 않습니다. [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)은 조건을 직접 바꿔 놓고 `metadata.writeCondition()`을 확인하는 가짜 리전으로 `application-core` 쪽 계약을 고정한 것이어서, Redis 어댑터가 이 조건을 실제로 읽는다는 근거는 되지 않습니다.
|
||||
|
||||
## 무효화는 키를 지우거나 세대를 올립니다
|
||||
|
||||
키 하나를 무효화할 때는 값을 읽으면서 그 자리에서 지우는 `GETDEL`을 부르는데, 지워진 값이 있었으면 `INVALIDATED`를, 처음부터 없었으면 `ALREADY_ABSENT`를 돌려줍니다. 리전 무효화는 `KEYS`나 `SCAN`으로 항목을 훑어 지우지 않고, 세대 키에 `INCRBY 1`을 적용한 뒤 이 호출에 쓰인 `CacheKeys`만 반환값으로 갱신합니다. 기존 항목은 Redis에 그대로 남아서 하드 TTL이 지나야 사라집니다. 무효화를 실행한 인스턴스에서는 다음 조회가 세대 불일치가 되지만, 이미 이전 세대를 읽어 둔 다른 인스턴스에는 이 결론이 적용되지 않습니다. [`invalidateRegion`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:290), [`observeGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:412)
|
||||
|
||||
## 묵은 값 갱신과 실패 분기
|
||||
|
||||
묵은 값을 갱신하러 간 원본 호출이 일시적 실패로 끝나고 정책이 허용하며 하드 만료 전이면 `CacheAsideExecutor`는 `StaleFallbackAfterTransientFailure`를 돌려줍니다. 영구 실패에는 묵은 값을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346)
|
||||
|
||||
갱신 조정자를 주입한 경우에는 묵은 값이거나 설정된 하드 미스일 때 선점을 시도합니다. `CacheRefreshCoordinationPort`는 키, 시도, 리스 TTL을 받아 `claimed`·`contended`·`unavailable`·`indeterminate` 중 하나를 돌려주고, 실행기는 그 결과로 원본 갱신을 허용할지 정합니다. [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) `Indeterminate`는 같은 시도로 한 번만 다시 부릅니다. 선점에 밀린 쪽이거나 `unavailable`·`indeterminate`를 받은 쪽이 묵은 값을 갖고 있으면 원본을 부르지 않고 `StaleRefreshDeferred`를 돌려줍니다. 선점한 쪽은 캐시를 다시 읽어 다른 인스턴스가 이미 채웠는지 확인하고, 자기 원본 호출을 마친 뒤 `finally`에서 반납합니다. [`invokeSource`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144)
|
||||
|
||||
이 경로는 계약과 테스트 대역까지만 있습니다. `CacheRefreshCoordinationPort`의 운영 구현은 없고 `DisabledCacheRefreshCoordinationPort`와 테스트 안의 가짜 조정자만 확인됩니다. [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)도 실행기 둘과 테스트용 조정자로 한쪽만 원본을 부르는 계약을 고정할 뿐 Redis 구현을 검증하지는 않습니다. 그래서 분산 갱신이 Redis 리스로 동작한다고 말할 근거는 없습니다.
|
||||
|
||||
이 실행기는 비동기 백그라운드 갱신 스케줄러가 아닙니다. 선점한 쪽이 동기로 갱신하고 밀린 쪽만 묵은 값을 즉시 받습니다. 하드 미스에서 정해진 시간만 기다리는 부분도 `Thread.sleep` 뒤에 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300) `CacheEnvelope` 주석에는 백그라운드 갱신이라는 표현이 있지만, 실제로 도는 것은 메서드 본문에 있는 동기 갱신입니다.
|
||||
|
||||
캐시에서는 Redis 장애를 성능 저하로 다룹니다. `lookup`은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`를, 기록과 무효화는 `DEGRADED_UNAVAILABLE`을 돌려주고, 부른 쪽은 캐시 미스처럼 원본으로 내려가도 된다는 정책입니다. `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`도 정의되어 있지만, 이 어댑터에서 예외를 모두 받는 자리는 이 값을 돌려주지 않습니다. [`unavailable`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:335)
|
||||
|
||||
## 테스트가 고정한 범위
|
||||
|
||||
- [`RedisCacheRegionAdapterTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:72)는 인메모리 게이트웨이 위에서 부재→기록→신선한 적중, 소프트·하드 만료, 네거티브 만료, 스키마 라벨, 같은 어댑터 안에서의 세대 무효화, 항목 바이트 조건부 기록, Redis 장애 시 성능 저하 처리를 고정합니다.
|
||||
- [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 신선한 값과 부재 표시가 원본을 건너뛰는 것과 타입으로 구분된 원본 결과를 검사합니다.
|
||||
- [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 `standalone`·`cluster` 실서버 갈래에서 애플리케이션 ACL 계정으로 기록과 읽기가 동작하는지 확인하도록 태그되어 있습니다.
|
||||
|
||||
다만 이번 문서 작업에서는 실서버 갈래를 돌리지 않았습니다. 위 설명은 코드와 이전에 남겨 둔 근거의 범위이지 지금 `HEAD`에서 다시 실행한 결과가 아닙니다.
|
||||
|
||||
## 마무리
|
||||
|
||||
지금까지 캐시 조회 한 번이 `CacheAsideExecutor.getOrLoad`에서 시작해 세대 확인과 `GET`을 거쳐 봉투 해석으로 갈래가 나뉘고, 신선하지 않은 값만 원본으로 내려간 뒤 `SET`으로 다시 적히는 과정을 살펴봤습니다. 세대는 인스턴스마다 한 번만 읽고, 조건부 기록은 항목 바이트만 보며, 갱신 조정은 계약과 테스트 대역까지만 있습니다. Redis 조회와 기록은 운영 빈으로 조립되어 있지만, 이 빈과 실행기를 묶는 유스케이스 조립과 분산 갱신 구현은 코드에서 확인되지 않습니다.
|
||||
|
||||
## 시리즈에서 이어 읽기
|
||||
|
||||
- 이전 글: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md)
|
||||
- 다음 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md)
|
||||
- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](./redis-backend-policy-boundary.md)
|
||||
- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](./redis-platform-sre-operations.md)
|
||||
@@ -1,159 +0,0 @@
|
||||
# Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL
|
||||
|
||||
> **Redis 코드 상세 시리즈 13/20** · [전체 지도](./redis-backend-policy-boundary.md) · 이전: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) · 다음: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md)
|
||||
|
||||
## 이 글이 답하는 코드 질문
|
||||
|
||||
`ca-skeleton.capabilities.cache.bindings.default=redis`인 애플리케이션에서 캐시 조회 한 번은 어디에서 시작하고, 어떤 Redis 명령을 거쳐, 언제 원본 저장소로 내려갑니까? 이 글은 Spring이 만드는 `CacheRegionPort<String, byte[]>`와 애플리케이션의 `CacheAsideExecutor`를 함께 읽습니다.
|
||||
|
||||
먼저 결론을 구분해야 합니다.
|
||||
|
||||
- Redis cache region adapter는 production bean으로 조립됩니다.
|
||||
- `CacheAsideExecutor`의 local single-flight, source bulkhead, stale fallback도 구현되어 있습니다.
|
||||
- 그러나 두 객체를 묶는 production use-case bean은 확인되지 않습니다.
|
||||
- 분산 refresh용 `CacheRefreshCoordinationPort`는 계약과 테스트 대역만 있고 Redis production 구현·bean은 확인되지 않습니다.
|
||||
- adapter 안에서도 region generation은 instance-local로 한 번만 읽고, conditional write는 generation과 `CacheWriteCondition`을 보존하지 않습니다. future schema의 `QUARANTINE_AND_RELOAD`도 executor에서는 실제 reload가 아니라 `FAIL_FAST`로 끝납니다.
|
||||
|
||||
따라서 아래 흐름 중 Redis 조회·기록은 현재 조립된 capability이고, distributed refresh 흐름은 구현된 오케스트레이션 계약이지만 production 조립은 미완성입니다.
|
||||
|
||||
## 먼저 보는 클래스·리소스 지도
|
||||
|
||||
| 코드 | 입력 | 출력 | 다음 호출 |
|
||||
| --- | --- | --- | --- |
|
||||
| [`RedisCapabilityConfig.redisDefaultCacheRegion`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95) | `RedisRuntimeOwner`, namespace, cache 설정, Secret, `Clock` | `CacheRegionPort<String, byte[]>` bean | `RedisCacheRegionAdapter` 생성자 |
|
||||
| [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7) | semantic key/value | typed lookup·record·invalidate 결과 | provider adapter |
|
||||
| [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) | key, region, source loader | `CacheResult<V>` | lookup, single-flight, source load, record |
|
||||
| [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) | semantic key | `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` | generation 확인, `GET`, envelope 해석 |
|
||||
| [`RedisCacheRegionAdapter.write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) | value/absence, source revision, write intent | `CacheRecordOutcome` | 조건 확인 후 `SET` + TTL |
|
||||
| [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29) | schema, revision, generation, 두 expiry, absence, payload | pipe header + payload bytes | `interpret` |
|
||||
| [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) | key, attempt, lease TTL | claimed/contended/unavailable/indeterminate | source refresh admission |
|
||||
|
||||
## 객체가 만들어지는 시점
|
||||
|
||||
전역 `app.redis.enabled=true`이고 default cache binding이 `redis`일 때만 `redisDefaultCacheRegion` bean이 생깁니다. 이 메서드는 cache 설정을 검증하고, 공통 `app.redis.namespace` 아래의 `CacheKeys`를 만들며, semantic key를 HMAC-SHA-256으로 바꾸는 함수를 주입합니다. HMAC material에는 environment/service/domain이 함께 들어가므로 같은 identifier라도 namespace가 다르면 digest도 달라집니다. 출력은 `hv1:<hex>`입니다. 근거는 [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312)에서 확인할 수 있습니다.
|
||||
|
||||
기본 설정은 soft TTL 30초, hard TTL 5분, negative TTL 10초, command timeout 200ms입니다. `positiveSoftTtl <= positiveHardTtl`, hard TTL의 configured floor, 양수 command timeout, 양수 key version을 startup에 검사합니다. [`RedisCapabilitySettings.Cache.validate`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70)
|
||||
|
||||
`CacheAsideExecutor`는 생성 시 region별 정책으로 local `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 2인자 생성자는 refresh coordinator를 주입하지 않습니다. 4인자 생성자만 coordinator와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25)
|
||||
|
||||
## 요청 시 호출 순서
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Use case
|
||||
participant E as CacheAsideExecutor
|
||||
participant C as RedisCacheRegionAdapter
|
||||
participant R as Redis
|
||||
participant S as Source loader
|
||||
U->>E: getOrLoad(key, region, loader)
|
||||
E->>C: lookup(key)
|
||||
opt 이 CacheKeys의 generation이 unresolved
|
||||
C->>R: INCRBY generation 0
|
||||
end
|
||||
C->>R: GET entryKey(HMAC(key))
|
||||
alt fresh 또는 negative hit
|
||||
C-->>E: Hit / NegativeHit
|
||||
E-->>U: 즉시 결과
|
||||
else future schema
|
||||
C-->>E: QUARANTINE_AND_RELOAD + unusable token
|
||||
E-->>U: FAIL_FAST (source 미호출)
|
||||
else stale/miss/unavailable
|
||||
C-->>E: typed lookup
|
||||
E->>E: local single-flight + source bulkhead
|
||||
E->>S: load(key, cancellation)
|
||||
S-->>E: loaded / absent / failure
|
||||
E->>C: record 또는 recordAbsent
|
||||
C->>R: SET envelope [NX/none] PX hardTTL
|
||||
E-->>U: LoadedFromSource 등 typed result
|
||||
end
|
||||
```
|
||||
|
||||
### 1. generation을 먼저 확정합니다
|
||||
|
||||
`lookup`은 REGULAR lane을 빌린 뒤 `resolveGeneration`을 호출합니다. 다만 서버 값을 읽는 시점은 각 `CacheKeys`의 최초 접근 한 번뿐입니다. `resolved`가 `true`가 되면 이후 lookup과 write는 Redis counter를 다시 읽지 않고 process-local `generation`을 사용합니다. 최초 호출의 `INCRBY generationKey 0`은 키가 없을 때 0을 만들고 그 시점의 출발값을 맞추지만, instance 사이의 이후 변경을 전파하지는 않습니다. [`resolveGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:322), [`CacheKeys.resolved`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:358)
|
||||
|
||||
예를 들어 instance A와 B가 모두 generation 0을 resolve한 뒤 A가 region을 1로 올리면, A의 `CacheKeys`만 1로 갱신됩니다. B는 계속 0을 사용하므로 generation-0 entry를 hit하거나 generation 0으로 다시 기록할 수 있습니다. 현행 region invalidation을 multi-instance 전체에 즉시 적용되는 semantic invalidation으로 읽을 수 없는 이유입니다.
|
||||
|
||||
entry key는 공통 namespace, capability `cache`, key layout version, region, HMAC digest로 렌더링됩니다. 원래 semantic key는 Redis key에 들어가지 않습니다. [`CacheKeys.entryKey`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383)
|
||||
|
||||
### 2. `GET` 결과를 다섯 종류로 나눕니다
|
||||
|
||||
저장값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 여섯 개의 `|` 경계를 찾고 schema version, source revision, generation, soft/hard absolute epoch millis, absence marker와 payload를 복원합니다. 현행 schema는 v1입니다. [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104)
|
||||
|
||||
해석 순서는 다음과 같습니다.
|
||||
|
||||
1. future schema는 adapter에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 그러나 이 2인자 `IncompatibleSchema`에는 usable observation token과 write condition이 없습니다.
|
||||
2. retired, unknown, corrupt envelope는 `FAIL_FAST`입니다.
|
||||
3. envelope generation이 현재 generation과 다르면 `Miss(INVALIDATED)`입니다.
|
||||
4. hard expiry가 지났으면 `Miss(EXPIRED)`입니다.
|
||||
5. absence marker가 있으면 `NegativeHit`입니다.
|
||||
6. 그 밖에는 soft expiry 전이면 `FRESH`, soft와 hard 사이면 `STALE`입니다.
|
||||
|
||||
이 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다. future schema를 보통 miss로 바꾸지 않는 이유는 구버전 instance가 신버전 값을 덮어쓰는 일을 막기 위해서입니다.
|
||||
|
||||
여기서 typed label과 end-to-end 동작을 구분해야 합니다. `CacheAsideExecutor`는 policy가 `QUARANTINE_AND_RELOAD`여도 observation token이 usable하지 않으면 policy를 `FAIL_FAST`로 바꾼 `IncompatibleSchema`를 즉시 반환합니다. source loader는 호출하지 않습니다. Redis adapter가 future schema에 쓰는 2인자 생성자는 observation token과 write condition을 모두 `unavailable()`로 채우므로, 현행 조합의 실제 흐름은 `FUTURE_VERSION` → `QUARANTINE_AND_RELOAD` label → executor `FAIL_FAST`입니다. [`CacheLookup.IncompatibleSchema`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 schema 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81)
|
||||
|
||||
### 3. fresh와 negative는 source를 호출하지 않습니다
|
||||
|
||||
`CacheAsideExecutor.getOrLoad`는 `FRESH`를 `FreshHit`로, `NegativeHit`를 그대로 반환합니다. stale 값은 hard expiry와 observation token을 가진 후보로 보존합니다. miss와 unavailable은 source refill 대상으로 넘어갑니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59)
|
||||
|
||||
같은 process의 같은 key는 local single-flight로 합쳐집니다. maximum in-flight key, key당 waiter, wait duration을 넘으면 각각 `MAXIMUM_IN_FLIGHT_KEYS`, `MAXIMUM_WAITERS`, `WAIT_TIMEOUT`으로 거절됩니다. source bulkhead가 차면 `SOURCE_OVERLOADED`, deadline을 넘으면 `LOAD_TIMEOUT`입니다.
|
||||
|
||||
### 4. source 결과에 따라 positive 또는 negative를 기록합니다
|
||||
|
||||
`Loaded`는 `region.record`, `AuthoritativeAbsent`는 `recordAbsent`를 호출합니다. transient/permanent failure는 캐시에 쓰지 않습니다. source가 `RetryableNoEffect` 같은 idempotency 의미를 주는 구조가 아니라, cache 전용 `SourceLoadOutcome`으로 분리되어 있습니다. [`invokeSourceDirect`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206)
|
||||
|
||||
새 entry는 `CacheEnvelope.CURRENT_SCHEMA_VERSION`, source revision, 현재 generation, `now + effectiveSoft`, `now + ttl`, absence, payload를 가집니다. physical Redis TTL은 hard TTL과 같습니다. positive entry는 hard TTL, negative entry는 별도 negative TTL을 사용합니다. [`write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:230)
|
||||
|
||||
`ONLY_IF_ABSENT`는 `SET ... NX`에 대응합니다. `ONLY_IF_OBSERVED`에서는 lookup 시점의 entry bytes로 `CacheObservationToken`과 `CacheWriteCondition`을 모두 만듭니다. executor도 두 값을 `CacheRecordMetadata`에 실어 보냅니다. 그러나 Redis adapter의 write는 `metadata.writeCondition()`을 읽지 않고, 현재 entry bytes의 SHA-256 앞 16바이트와 `metadata.observedToken()`만 비교합니다. [`CacheRecordMetadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [`executor의 metadata 전달`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:231), [`write`의 조건 비교](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208)
|
||||
|
||||
따라서 감지 범위는 entry bytes 교체에 한정됩니다. region generation bump는 기존 entry bytes를 바꾸지 않으므로 source load 중 invalidate가 일어나도 비교가 통과합니다. 같은 adapter라면 새 local generation으로 load 결과를 써서 invalidation 직후 값을 다시 채울 수 있고, 다른 instance라면 앞서 캐시한 이전 generation으로 쓸 수 있습니다. generation과 byte observation을 하나의 atomic CAS에 넣지 않았고, bytes 비교용 `GET`과 최종 `SET`도 Lua나 transaction으로 묶지 않았습니다.
|
||||
|
||||
## invalidation은 삭제와 세대 교체로 나뉩니다
|
||||
|
||||
단일 key invalidation은 `GETDEL`을 호출해 `INVALIDATED`와 `ALREADY_ABSENT`를 구분합니다. region invalidation은 `KEYS`나 `SCAN`으로 entry를 지우지 않고 generation key에 `INCRBY 1`을 적용한 뒤, 이 호출에 사용된 `CacheKeys`만 반환값으로 갱신합니다. 기존 entry는 Redis에 남아 hard TTL로 사라집니다. invalidate를 수행한 instance에서는 다음 lookup이 generation mismatch가 되지만, 이미 이전 generation을 resolve한 다른 instance에는 이 결론이 적용되지 않습니다. [`invalidateRegion`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:290), [`observeGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:412)
|
||||
|
||||
## stale refresh와 실패 분기
|
||||
|
||||
`CacheAsideExecutor`는 stale source load가 transient failure이고 policy가 허용하며 hard expiry 전이면 `StaleFallbackAfterTransientFailure`를 반환합니다. permanent failure에는 stale을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346)
|
||||
|
||||
optional refresh coordinator가 주입된 경우에는 stale 또는 configured hard miss에서 claim을 시도합니다. `Indeterminate` claim은 같은 attempt로 한 번만 다시 호출합니다. contender나 unavailable/indeterminate가 stale을 갖고 있으면 source를 호출하지 않고 `StaleRefreshDeferred`를 반환합니다. owner는 claim 후 cache를 다시 읽어 다른 instance가 이미 채웠는지 확인하고, 자기 source load를 마친 뒤 `finally`에서 release합니다. [`invokeSource`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144)
|
||||
|
||||
이 executor는 비동기 background refresh scheduler가 아닙니다. owner가 동기 refresh를 수행하고 contender만 stale을 즉시 받습니다. hard miss의 bounded wait는 `Thread.sleep` 뒤 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300)
|
||||
|
||||
Redis 장애는 cache에 한해 degraded로 처리됩니다. lookup은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`, record와 invalidation은 `DEGRADED_UNAVAILABLE`을 반환합니다. cache miss처럼 source로 내려갈 수 있다는 정책입니다. 다만 `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`가 정의되어 있어도 이 adapter의 catch-all은 이를 반환하지 않습니다. [`unavailable`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:335)
|
||||
|
||||
## 테스트가 고정하는 계약
|
||||
|
||||
- [`RedisCacheRegionAdapterTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:72)는 absent→record→fresh hit, soft/hard expiry, negative expiry, schema label, 같은 adapter의 generation invalidation, entry-byte 조건부 기록과 Redis 장애 degradation을 in-memory gateway에서 고정합니다.
|
||||
- 같은 테스트의 [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)는 하나의 adapter와 하나의 `CacheKeys`로 record→invalidate→lookup을 검사합니다. 두 adapter가 generation을 각각 resolve한 뒤 한쪽만 invalidate하는 regression test는 없습니다.
|
||||
- [`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 entry bytes 자체가 바뀐 경우를 검사합니다. generation bump와 in-flight `ONLY_IF_OBSERVED`를 결합하지 않습니다.
|
||||
- [`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 adapter의 category와 policy label만 검사합니다. 실제 adapter와 executor를 결합해 source reload를 확인하지 않습니다.
|
||||
- [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 fresh/negative의 source bypass와 typed source 결과를 검사합니다.
|
||||
- 같은 테스트의 [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)는 condition을 직접 교체하고 `metadata.writeCondition()`을 검사하는 fake region의 application-core 계약입니다. Redis adapter가 이 condition을 소비한다는 증거는 아닙니다.
|
||||
- 같은 테스트의 [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)는 두 executor와 test coordinator로 owner 하나만 source를 호출하는 계약을 고정합니다. Redis 구현을 검증하는 테스트는 아닙니다.
|
||||
- [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 standalone/cluster real-server lane에서 application ACL account로 record/read가 동작함을 확인하도록 태그되어 있습니다.
|
||||
- [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)는 연결하지 않고 cache bean 한 개만 생기는지를 검사합니다.
|
||||
|
||||
## 현재 구현 공백과 잘못 읽기 쉬운 지점
|
||||
|
||||
1. semantic Redis composition은 cache, rate-limit, lease, idempotency V2 네 개가 있고 Session이 빠진 4/5입니다.
|
||||
2. `CacheRegionPort` bean은 있지만 `CacheAsideExecutor`를 이 bean과 묶어 실제 use case에 주입하는 production 조립은 검색되지 않습니다.
|
||||
3. `CacheRefreshCoordinationPort` production 구현은 없습니다. `DisabledCacheRefreshCoordinationPort`와 테스트 내부 fake coordinator만 확인됩니다. 따라서 “분산 refresh가 Redis lease로 동작한다”고 말할 근거는 없습니다.
|
||||
4. 각 instance는 region generation을 최초 한 번만 읽습니다. 다른 instance의 bump를 관찰하지 못하므로 multi-instance semantic invalidation은 완성되지 않았고, 이를 재현하는 test도 없습니다.
|
||||
5. Redis adapter의 `ONLY_IF_OBSERVED`는 `CacheWriteCondition`과 generation을 조건에 포함하지 않습니다. entry-byte 비교만 하며 `GET`과 `SET`도 원자적이지 않습니다. application-core의 invalidation-during-load fake test를 Redis 구현 증거로 확대할 수 없습니다.
|
||||
6. future schema의 `QUARANTINE_AND_RELOAD`는 adapter label입니다. unusable observation 때문에 executor는 `FAIL_FAST`를 반환하고 source를 호출하지 않습니다.
|
||||
7. `CacheEnvelope` 주석에는 background refresh 표현이 있으나 executor 구현은 동기 owner refresh입니다. 현행 method body가 우선 근거입니다.
|
||||
8. 이번 문서 작업에서는 real-server lane을 실행하지 않았습니다. 위 live test 설명은 코드와 historical evidence의 범위이며 현재 HEAD 재실행 결과가 아닙니다.
|
||||
|
||||
## 다음에 열어볼 source 순서
|
||||
|
||||
다음 읽기 순서는 `RedisCapabilityConfig` → `CacheAsideExecutor` → `RedisCacheRegionAdapter` → `CacheEnvelope` → 두 test class가 적절합니다. SDK의 command admission과 connection lane은 별도 문서가 소유할 범위입니다.
|
||||
|
||||
## 시리즈에서 이어 읽기
|
||||
|
||||
- 이전 글: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md)
|
||||
- 다음 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md)
|
||||
- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](./redis-backend-policy-boundary.md)
|
||||
- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](./redis-platform-sre-operations.md)
|
||||
|
||||