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

This commit is contained in:
DongHyeonka
2026-09-04 18:56:01 +09:00
parent 4b9e7148b5
commit 43bccd08a8
121 changed files with 2861 additions and 534 deletions
+21 -21
View File
@@ -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

+9
View File
@@ -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을 우선한다.
+25
View File
@@ -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 |
|---|---|---|---|
## 아직 단정하지 않는 것
+60
View 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를 연결한다.
+13
View File
@@ -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>"
}
+27
View File
@@ -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
+85
View File
@@ -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>`
+7
View File
@@ -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>` |
+30
View File
@@ -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": {}
}
+213 -47
View File
@@ -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을 구분하는 기준"
]
}
]
}
+89 -89
View File
@@ -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``25102=13` · `2221002=120` · `202210002=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`(가장 깊은 페이지, 커서 = visible20). 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`(가장 깊은 페이지, 커서 = visible20). 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를 깨뜨린 문제
+220 -49
View File
@@ -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 뒤에서 소유한다"
]
}
]
}
-141
View File
@@ -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)
-159
View File
@@ -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)