feat: 가상화 문서들 추가
This commit is contained in:
@@ -11,7 +11,8 @@ Produce a highly detailed, source-traceable engineering analysis. This stage dis
|
|||||||
|
|
||||||
## Required sequence
|
## Required sequence
|
||||||
|
|
||||||
1. **First read `<분석 대상 저장소>/analysis-queue.yaml`.** Before inspecting any project contents, apply `references/queue-contract.md`: reconcile newly discovered project directories, preserve queue order, and determine the single active project.
|
0. **Fix the two roots before anything else.** `<분석 대상 저장소>` is the repository being analyzed — an absolute path outside this repository, given by the caller. `docs/<프로젝트>/` is inside *this* repository. Never write into the analyzed repository, and never read analysis state from it.
|
||||||
|
1. **Read `<분석 대상 저장소>/analysis-queue.yaml` if it exists.** It exists only when several repositories are queued. Apply `references/queue-contract.md`: reconcile newly discovered project directories, preserve queue order, and determine the single active project. **If there is no queue, analyze the one repository the caller named and skip to step 3.** A missing queue is not a blocker — it means nothing is queued.
|
||||||
2. If an `IN_PROGRESS` project exists, analyze only that project. If none exists, activate the first `PENDING` project in queue order. Never preempt an active project because a new project appeared.
|
2. If an `IN_PROGRESS` project exists, analyze only that project. If none exists, activate the first `PENDING` project in queue order. Never preempt an active project because a new project appeared.
|
||||||
3. For the selected `<분석 대상 저장소>`, check the nearest `AGENTS.md` or equivalent repository instructions.
|
3. For the selected `<분석 대상 저장소>`, check the nearest `AGENTS.md` or equivalent repository instructions.
|
||||||
4. Record Git revision and `git status` when Git is available. Never modify or reset user source as part of analysis.
|
4. Record Git revision and `git status` when Git is available. Never modify or reset user source as part of analysis.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Primary evidence first
|
## Primary evidence first
|
||||||
|
|
||||||
Store command output, logs, generated query plans, benchmark results, browser observations, and other primary material under `docs/<프로젝트>/evidence/raw/` before creating presentation assets.
|
Store command output, logs, generated query plans, benchmark results, browser observations, and other primary material under `docs/<프로젝트>/final/evidence/raw/` before creating presentation assets.
|
||||||
|
|
||||||
## Terminal evidence
|
## Terminal evidence
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ For a command used as evidence, retain:
|
|||||||
- raw stdout/stderr;
|
- raw stdout/stderr;
|
||||||
- source revision when relevant.
|
- source revision when relevant.
|
||||||
|
|
||||||
Render terminal UI with `./tools/terminal-evidence/render_terminal.py`.
|
Render terminal UI with `scripts/terminal-evidence/render_terminal.py`.
|
||||||
|
|
||||||
The visual asset is explanatory. The raw evidence is the provenance.
|
The visual asset is explanatory. The raw evidence is the provenance.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 분석 기준 revision
|
## 분석 기준 revision
|
||||||
|
|
||||||
- repository: `/shared/codebase/<project>`
|
- repository: `<분석 대상 저장소의 절대 경로>`
|
||||||
- revision: `<git revision or non-git snapshot note>`
|
- revision: `<git revision or non-git snapshot note>`
|
||||||
|
|
||||||
## Build and module map
|
## Build and module map
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 2,
|
"schemaVersion": 2,
|
||||||
"project": "<project>",
|
"project": "<project>",
|
||||||
"codebasePath": "/shared/codebase/<project>",
|
"codebasePath": "<분석 대상 저장소의 절대 경로>",
|
||||||
"gitRevision": null,
|
"gitRevision": null,
|
||||||
"analysisStatus": "NOT_STARTED",
|
"analysisStatus": "NOT_STARTED",
|
||||||
"analysisCycle": 1,
|
"analysisCycle": 1,
|
||||||
|
|||||||
@@ -82,7 +82,28 @@ They do not hold candidates, and in a folded project they are gone.
|
|||||||
with the fields its kind requires. There is no second tree to keep in step.
|
with the fields its kind requires. There is no second tree to keep in step.
|
||||||
10. Run `references/decomposition-checklist.md`.
|
10. Run `references/decomposition-checklist.md`.
|
||||||
11. Record `candidateScope`, the source document hash, and the project revision.
|
11. Record `candidateScope`, the source document hash, and the project revision.
|
||||||
12. `python3 scripts/verify-tech-log-tree.py <project>` — errors must be 0.
|
12. `python3 scripts/build-tech-log-tree.py <project>` first, then
|
||||||
|
`python3 scripts/verify-tech-log-tree.py <project>` — errors must be 0. Build fills
|
||||||
|
`ssotSha256`; verify errors when it is absent, so verifying before building always fails.
|
||||||
|
|
||||||
|
## Three fields the verifier requires and this procedure does not otherwise name
|
||||||
|
|
||||||
|
Write them by hand. `verify-tech-log-tree.py` counts each as an error when missing.
|
||||||
|
|
||||||
|
| Field | What goes in it |
|
||||||
|
|---|---|
|
||||||
|
| `candidateScope` | the range candidates were found in — above |
|
||||||
|
| `sourceRepository` | `path` of the analyzed repository, its `revision`, and how that was established. Leave `revision` `null` rather than inventing one; when the work is split across branches, pair names and commits under `revisions` |
|
||||||
|
| `ssotSha256` | filled by `build-tech-log-tree.py`. Its job is to catch a tree whose SSOT changed after the candidates were chosen |
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sourceRepository": {
|
||||||
|
"path": "/absolute/path/to/analyzed-repo",
|
||||||
|
"revision": null,
|
||||||
|
"revisions": {"AP1 develop-pattern1": "64175266"},
|
||||||
|
"verified": "how the revision was established, or why it is null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Use `.agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md` as the
|
Use `.agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md` as the
|
||||||
output contract.
|
output contract.
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
name: publishing-tech-log-to-studio
|
||||||
|
description: Use when a finished Tech Log record .md must be put into Tech Log Studio through the browser with Playwright MCP — creating or opening the working copy, uploading assets, filling the per-kind fields, and saving. Save only; this skill never publishes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Studio 반입 — 저장까지만
|
||||||
|
|
||||||
|
## 이 스킬의 경계
|
||||||
|
|
||||||
|
기록 `.md` 하나를 Studio 편집 화면에 넣고 **저장**한다. 거기서 끝난다.
|
||||||
|
|
||||||
|
**게시하지 않는다.** 게시는 공개 사이트에 올리는 일이고, 되돌리려면 `unpublish` 를 해야 하며
|
||||||
|
그 사이에 누구나 본다. 더 중요한 것은 **한 번이라도 게시한 문서는 게시를 취소해도 지워지지
|
||||||
|
않는다는 것이다** — 삭제가 409 로 거절되고 「공개된 기록은 삭제할 수 없습니다」가 뜬다. 그래서
|
||||||
|
시험 삼아 게시하지 않는다. 게시는 사람이 미리보기를 읽고 판단한다.
|
||||||
|
|
||||||
|
편집 화면 오른쪽 `aside` 에 버튼이 `저장`·`게시` 둘뿐이다. **`게시` 를 누르면 저장·검증·
|
||||||
|
미리보기·게시가 한 번에 돈다.** 이 스킬은 `저장` 만 누른다.
|
||||||
|
|
||||||
|
## 들어가기 전 조건
|
||||||
|
|
||||||
|
| 조건 | 확인 |
|
||||||
|
|---|---|
|
||||||
|
| 기록 `.md` 가 파서를 통과했다 | `studio-body.py` 로 바꾼 파일에 `check_body.mjs`, error 0 |
|
||||||
|
| 문장 검사를 지났다 | `check_prose.mjs` error 0 |
|
||||||
|
| 인용이 SSOT 에 실재한다 | `check_evidence.mjs <프로젝트> --repo` |
|
||||||
|
| 브라우저가 이미 로그인돼 있다 | 아래 「인증」 |
|
||||||
|
|
||||||
|
**검사를 안 지난 초안을 넣지 않는다.** 저장은 빈 칸도 받아 주기 때문에(Studio 는 한 번에 다
|
||||||
|
쓰지 않아도 저장되게 만들어져 있다) 넣는 것 자체는 성공한다. 그래서 파서·문장 검사를 여기서
|
||||||
|
대신 잡아 주지 않는다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
|
||||||
|
Studio 는 조회에도 권한을 요구한다. 읽는 것이 게시 전 초안이기 때문이다.
|
||||||
|
|
||||||
|
**이 저장소에 자격증명을 두지 않는다.** 이미 로그인된 브라우저 세션을 쓴다. 편집 화면을 열었을
|
||||||
|
때 상태 `aside` 가 25초 안에 안 뜨면 **인증이 안 된 것으로 보고 멈춘다.** 로그인 화면을
|
||||||
|
자동으로 통과하려 들지 않는다 — 사용자에게 로그인해 달라고 말하고 기다린다.
|
||||||
|
|
||||||
|
```
|
||||||
|
aside[class*="studio-document-status"] ← 이것이 안 보이면 인증 실패
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주소
|
||||||
|
|
||||||
|
| 무엇 | 주소 |
|
||||||
|
|---|---|
|
||||||
|
| Studio | `https://hyeonworks.com/studio` |
|
||||||
|
| 편집 화면 | `https://hyeonworks.com/studio/documents/<id>/edit` |
|
||||||
|
| 새 문서 | Studio 에서 `새 문서` → 종류 선택 → `작업본 만들기` |
|
||||||
|
|
||||||
|
기록 frontmatter 의 `id` 와 `studio:` 가 이미 차 있으면 **새로 만들지 않는다.** 그 주소로 바로
|
||||||
|
간다. 비어 있으면 새로 만들고, 받은 uuid 와 편집 주소를 기록 frontmatter 에 적는다.
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
### 1. 기록을 읽고 무엇을 넣을지 정한다
|
||||||
|
|
||||||
|
`kind` 로 칸 목록이 정해진다. 어떤 `##` 제목이 Studio 의 어느 칸인지는
|
||||||
|
[references/studio-form-map.md](references/studio-form-map.md).
|
||||||
|
|
||||||
|
**frontmatter 는 메타데이터고, 본문의 `##` 가 Studio 의 칸이며, 제목 아래 첫 문단이 `요약`
|
||||||
|
이다.** 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다.
|
||||||
|
|
||||||
|
### 2. 그림이 있으면 Asset 을 먼저 올린다
|
||||||
|
|
||||||
|
frontmatter `assets:` 의 `file:` 이 올릴 파일이고 `key:` 는 저장소 쪽 이름이다. 올리면 서버가
|
||||||
|
`<이름>-<해시8>` 형태의 키를 준다.
|
||||||
|
|
||||||
|
**본문을 넣기 전에 올린다.** 순서를 뒤집으면 미리보기가 본문 전체를 막는다 —
|
||||||
|
`1:1 supported local evidence key not found: <asset-key>`. 다른 경로로 올렸으면 편집 화면을
|
||||||
|
한 번 새로 고쳐야 목록을 다시 받는다. 본문 문법 문제로 오해하기 쉽다.
|
||||||
|
|
||||||
|
본문은 서버가 준 키로 바꿔서 넣는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/studio-body.py <기록.md> --key <저장소 key>=<서버가 준 key> --body-only
|
||||||
|
```
|
||||||
|
|
||||||
|
저장소의 `.md` 는 마크다운 이미지로 두고 고치지 않는다. `:::evidence` 는 Studio 렌더러의
|
||||||
|
구문이라 저장소에 쓰면 편집기에서 그림이 안 보인다.
|
||||||
|
|
||||||
|
### 3. 칸을 채운다
|
||||||
|
|
||||||
|
되풀이되는 칸(`영향`·`선택지`·`사실`처럼 여러 줄인 것)은 **줄 수를 먼저 맞추고** 값을 넣는다.
|
||||||
|
줄이 모자란 채로 채우면 뒤엣것이 조용히 버려진다. 셀렉터와 배치 실행 방법은
|
||||||
|
[references/playwright-recipes.md](references/playwright-recipes.md).
|
||||||
|
|
||||||
|
**본문이 없는 세 종류(Reference·Question·Decision)의 칸은 평문으로 렌더링된다.** 백틱과
|
||||||
|
파이프가 글자 그대로 보이고, 줄바꿈은 `<br>` 로만 살아난다.
|
||||||
|
|
||||||
|
### 4. 저장한다
|
||||||
|
|
||||||
|
```
|
||||||
|
aside[class*="studio-document-status"] 안의 `저장` 버튼
|
||||||
|
→ 같은 aside 의 글자가 `저장됨` 으로 바뀔 때까지 기다린다 (최대 30초)
|
||||||
|
```
|
||||||
|
|
||||||
|
`저장됨` 을 못 보면 실패다. 그 `aside` 의 글자를 그대로 읽어서 보고한다. **버튼을 다시 누르지
|
||||||
|
않는다** — 검증 오류로 막힌 것을 연타로 뚫으려다 `게시` 를 누르게 된다.
|
||||||
|
|
||||||
|
버전은 같은 `aside` 의 첫 `dd` 에 있다. 저장 전후로 읽어 두면 실제로 올라갔는지 보인다.
|
||||||
|
|
||||||
|
### 5. 미리보기로 읽는다
|
||||||
|
|
||||||
|
`즉시 미리보기` 탭은 저장한 값이 아니라 **화면에 입력한 값**을 렌더링한다. 그래서 게시 없이도
|
||||||
|
공개 화면과 같은 블록 렌더러로 본문을 볼 수 있다.
|
||||||
|
|
||||||
|
읽을 항목은 `../writing-tech-log-records/references/studio-draft-review.md` 의 목록을 쓴다.
|
||||||
|
고칠 것이 있으면 `편집` 탭으로 돌아가 고치고 다시 저장한다.
|
||||||
|
|
||||||
|
### 6. 기록에 되적는다
|
||||||
|
|
||||||
|
새로 만들었으면 frontmatter 의 `id` 와 `studio:` 를 채우고 색인을 다시 만든다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/build-tech-log-tree.py <프로젝트>
|
||||||
|
python3 scripts/verify-tech-log-tree.py <프로젝트>
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-tech-log-tree.py` 는 frontmatter 의 `id` 가 있으면 `publication` 을 `게시됨` 으로
|
||||||
|
적는다. **이 칸은 「Studio 에 있다」는 뜻이지 「공개돼 있다」가 아니다.** 공개 여부는 기록의
|
||||||
|
`status` 와 `public:` 이 말한다.
|
||||||
|
|
||||||
|
## 시험용 초안을 남기지 않는다
|
||||||
|
|
||||||
|
확인하려고 만든 작업본은 지운다. 다만 **Decision 은 계약에 삭제 경로가 없다** — 확인용으로
|
||||||
|
만들지 않는 편이 낫다. 관계로 참조된 문서도 지워지지 않는다(`DOCUMENT_IN_USE`). 참조하는 쪽의
|
||||||
|
관계를 먼저 끊는다.
|
||||||
|
|
||||||
|
## 실패했을 때
|
||||||
|
|
||||||
|
| 증상 | 원인 |
|
||||||
|
|---|---|
|
||||||
|
| `aside` 가 안 뜬다 | 인증. 로그인 화면을 자동으로 넘기지 않는다 |
|
||||||
|
| 미리보기가 본문 전체를 막는다 | Asset 을 올리기 전에 본문을 넣었다. 화면을 새로 고친다 |
|
||||||
|
| 칸을 못 찾는다 (`label` 매치 0) | 그 종류에 없는 칸이다. `studio-form-map.md` 를 다시 본다 |
|
||||||
|
| 값이 잘렸다 | 되풀이 칸의 줄 수를 안 맞추고 채웠다 |
|
||||||
|
| `저장됨` 이 안 뜬다 | 검증이 막은 것이다. `aside` 글자를 읽어 보고한다 |
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Playwright MCP 로 편집 화면을 다루는 법
|
||||||
|
|
||||||
|
여기 적은 셀렉터는 지어낸 것이 아니라 이 저장소가 실제로 돌린 것이다. 원문은
|
||||||
|
`.playwright-mcp/_p1.mjs`·`_p2.mjs`·`_p3.mjs` 에 남아 있다.
|
||||||
|
|
||||||
|
## 두 가지 방식
|
||||||
|
|
||||||
|
| 방식 | 언제 |
|
||||||
|
|---|---|
|
||||||
|
| MCP 도구를 하나씩 (`browser_navigate` · `browser_snapshot` · `browser_fill_form` · `browser_click`) | 기록 한 건. 화면을 보면서 한다 |
|
||||||
|
| `browser_run_code_unsafe` 로 한 번에 | 여러 건을 같은 모양으로 채운다 |
|
||||||
|
|
||||||
|
**처음 넣는 기록은 하나씩 한다.** 배치는 이미 한 번 성공한 모양을 반복할 때만 쓴다. 화면을
|
||||||
|
안 보고 배치를 돌리면 못 찾은 칸이 조용히 버려진다.
|
||||||
|
|
||||||
|
## 새로 만들 때 — `/studio/documents/new`
|
||||||
|
|
||||||
|
종류를 라디오로 고르고 `작업본 만들기` 를 누른다. 라디오의 이름은 화면에 보이는 그대로다.
|
||||||
|
|
||||||
|
| 종류 | 라디오 이름 | 화면이 적어 놓은 칸 |
|
||||||
|
|---|---|---|
|
||||||
|
| Case | `Case` | 문제 · 결론 · 환경 · 재현 · 본문 |
|
||||||
|
| Reference | `Reference` | 목적 · 규칙 · 적용 조건 · 예외 · 예시 |
|
||||||
|
| Concept | **`개념`** | 기준 버전 · 본문 |
|
||||||
|
| Question | `Question` | 상태 · 사실 · 가정 · 미지수 · 선택지 |
|
||||||
|
| Decision | `Decision` | 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거 |
|
||||||
|
|
||||||
|
**Concept 만 라디오 이름이 한글(`개념`)이다.** 나머지 넷은 영어다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.goto('https://hyeonworks.com/studio/documents/new');
|
||||||
|
await page.getByRole('radio', { name: /^Reference/ }).check();
|
||||||
|
await page.getByRole('button', { name: '작업본 만들기' }).click();
|
||||||
|
// 주소가 /studio/documents/<uuid>/edit 로 바뀐다. 그 uuid 를 기록 frontmatter 에 적는다
|
||||||
|
```
|
||||||
|
|
||||||
|
화면에 이렇게 적혀 있다 — 「이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.」
|
||||||
|
**만들었으면 그 자리에서 칸을 채우고 저장한다.** 만들어 두고 나중에 돌아오지 않는다.
|
||||||
|
|
||||||
|
## 화면의 기준점
|
||||||
|
|
||||||
|
```js
|
||||||
|
const RAIL = 'aside[class*="studio-document-status"]';
|
||||||
|
```
|
||||||
|
|
||||||
|
이 `aside` 가 상태 레일이다. 여기에 버전(`dd` 첫 번째)과 저장 상태(`저장됨`)와 버튼
|
||||||
|
(`저장`·`게시`)이 있다.
|
||||||
|
|
||||||
|
**이것이 25초 안에 안 뜨면 인증이 안 된 것이다.** 로그인 화면을 자동으로 넘기려 들지 않는다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.goto('https://hyeonworks.com/studio/documents/' + id + '/edit');
|
||||||
|
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
|
||||||
|
catch { return { error: 'AUTH? ' + page.url() }; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 버전 읽기
|
||||||
|
|
||||||
|
```js
|
||||||
|
const version = await page.locator(RAIL + ' dd').first().innerText();
|
||||||
|
```
|
||||||
|
|
||||||
|
저장 전후로 읽는다. 안 올라갔으면 저장이 안 된 것이다.
|
||||||
|
|
||||||
|
## 칸 채우기
|
||||||
|
|
||||||
|
칸은 `label` 안의 `span` 이 이름을 들고 있다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const loc = page.locator('xpath=//label[./span[normalize-space(.)="' + label + '"]]')
|
||||||
|
.locator('textarea, input').first();
|
||||||
|
if (await loc.count() !== 1) { /* 그 종류에 없는 칸이다 — 채우지 말고 기록한다 */ }
|
||||||
|
if (await loc.inputValue() === value) { /* 이미 같다 — 건드리지 않는다 */ }
|
||||||
|
await loc.fill(value);
|
||||||
|
```
|
||||||
|
|
||||||
|
**매치가 1이 아니면 채우지 않고 못 찾았다고 적는다.** 비슷한 이름의 다른 칸에 넣는 것보다
|
||||||
|
안 넣는 쪽이 낫다.
|
||||||
|
|
||||||
|
**이미 같은 값이면 건드리지 않는다.** 안 그러면 바뀐 것이 없는데 버전만 올라간다.
|
||||||
|
|
||||||
|
## 되풀이되는 칸은 줄 수를 먼저 맞춘다
|
||||||
|
|
||||||
|
`영향`·`선택지`·`사실`처럼 여러 줄인 칸은 `fieldset` 의 `legend` 가 이름이고 줄이
|
||||||
|
`.studio-ordered-item` 이다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const fs = page.locator('xpath=//fieldset[./legend[normalize-space(.)="' + legend + '"]]').first();
|
||||||
|
let cur = await fs.locator('.studio-ordered-item').count();
|
||||||
|
while (cur > want) { // 줄이 남으면 뒤에서 지운다
|
||||||
|
await fs.locator('.studio-ordered-item').last().getByRole('button', { name: '삭제' }).click();
|
||||||
|
await page.waitForTimeout(60); cur--;
|
||||||
|
}
|
||||||
|
while (cur < want) { // 모자라면 더한다
|
||||||
|
await fs.getByRole('button', { name: legend + ' 추가' }).click();
|
||||||
|
await page.waitForTimeout(60); cur++;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**값을 넣기 전에 한다.** 줄이 모자란 채로 채우면 뒤엣것이 조용히 버려진다.
|
||||||
|
|
||||||
|
**행 클래스가 칸마다 다르다.** `관계` 는 `.studio-relation-item` 이고 나머지가
|
||||||
|
`.studio-ordered-item` 이다. 하나로 세면 관계는 늘 0으로 읽힌다.
|
||||||
|
|
||||||
|
**더한 뒤에는 카운트가 아니라 라벨 목록으로 검산한다.** 실제로 「판단 기준 추가」를 7번
|
||||||
|
눌렀는데 행이 9개 생겼다. 카운트만 믿으면 못 잡는다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 채우기 전에 그 fieldset 안의 라벨을 전부 읽어 몇 줄인지 다시 센다
|
||||||
|
const labels = await fs.locator('label span').allInnerTexts();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 칸 DOM 이 두 가지다
|
||||||
|
|
||||||
|
위 xpath 는 절반만 통한다. 실제 편집 화면에는 모양이 둘이다.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label class="studio-field"><span>문제</span><textarea></textarea></label> <!-- A -->
|
||||||
|
<div class="studio-field"><label for="studio-field-summary">요약</label>
|
||||||
|
<textarea id="studio-field-summary"></textarea></div> <!-- B -->
|
||||||
|
```
|
||||||
|
|
||||||
|
`요약` · `본문 Markdown` · `관계 N 이유` 가 B형이다. A형만 찾으면 이 칸들이 조용히 안 채워진다.
|
||||||
|
**둘 다 본다.**
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function field(page, name) {
|
||||||
|
const a = page.locator(`xpath=//label[./span[normalize-space(.)="${name}"]]`)
|
||||||
|
.locator('textarea, input').first();
|
||||||
|
if (await a.count()) return a;
|
||||||
|
const id = await page.locator(`xpath=//label[normalize-space(.)="${name}"]`)
|
||||||
|
.first().getAttribute('for');
|
||||||
|
return id ? page.locator('#' + id) : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**본문 칸의 이름은 `본문` 이 아니라 `본문 Markdown` 이다.**
|
||||||
|
|
||||||
|
## Asset 을 올릴 때
|
||||||
|
|
||||||
|
`Asset 업로드` 는 모달을 띄우고 **`filechooser` 이벤트를 내지 않는다.** 기다리면 타임아웃이다.
|
||||||
|
모달 안의 `input[type=file]` 에 직접 넣는다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.getByRole('button', { name: 'Asset 업로드' }).click();
|
||||||
|
await page.locator('input[type=file]').setInputFiles(svgPath);
|
||||||
|
// 대체 텍스트 칸에 기록 본문의 alt 를 그대로 넣는다
|
||||||
|
```
|
||||||
|
|
||||||
|
**올린 직후 목록이 갱신되지 않는다.** 화면의 `Asset 검색` 을 다시 눌러야 새 키가 나온다.
|
||||||
|
새로 올리면 서버가 새 키를 준다(`<이름>-<해시8>`) — 옛 키는 목록에 남는다. 다른 문서가
|
||||||
|
참조할 수 있으니 지우지 않는다.
|
||||||
|
|
||||||
|
## 값을 읽을 때는 `getByLabel` 을 쓰지 않는다
|
||||||
|
|
||||||
|
칸이 `<label><span>이름</span><textarea></label>` 모양이라 `getByLabel(...).inputValue()` 가
|
||||||
|
30초 타임아웃으로 실패한다. `fill` 은 되는데 읽기만 안 된다. 읽기는 `evaluate` 로 한다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const got = await page.evaluate((name) => {
|
||||||
|
const el = [...document.querySelectorAll('label')]
|
||||||
|
.find(l => l.querySelector('span')?.textContent.trim() === name);
|
||||||
|
return el?.querySelector('textarea, input')?.value ?? null;
|
||||||
|
}, '판단 기준');
|
||||||
|
```
|
||||||
|
|
||||||
|
**넣은 값을 다시 읽어 원문과 대조한다.** 넣었다는 것과 들어갔다는 것은 다르다.
|
||||||
|
|
||||||
|
## 저장
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.locator(RAIL).getByRole('button', { name: '저장' }).first().click();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const el = document.querySelector('aside[class*="studio-document-status"]');
|
||||||
|
return el && /저장됨/.test(el.innerText);
|
||||||
|
}, null, { timeout: 30000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
**버튼을 이름으로 고른다.** `aside button` 의 첫 번째로 고르면 화면이 바뀌었을 때 `게시` 를
|
||||||
|
누르게 된다.
|
||||||
|
|
||||||
|
`저장됨` 을 못 보면 실패다. 다시 누르지 말고 레일의 글자를 읽어서 보고한다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const why = (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 바뀐 것이 없으면 저장하지 않는다
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (filled.length === 0 && rowsChanged.length === 0) { /* CLEAN — 저장 버튼을 누르지 않는다 */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
누를 때마다 `version` 이 올라가고, 버전이 올라가면 앞서 만든 검증·미리보기 산출물이 무효가
|
||||||
|
된다(`validatedVersion == document.version` 이 깨진다).
|
||||||
|
|
||||||
|
## 미리보기
|
||||||
|
|
||||||
|
**`즉시 미리보기` 는 탭이 아니다.** 편집 화면 오른쪽에 나란히 놓인 `region` 이라
|
||||||
|
`getByRole('tab', ...)` 은 0개를 찾는다. 화면에 `[role="tab"]` 자체가 없다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.getByRole('region', { name: /즉시 미리보기/ }).scrollIntoViewIfNeeded();
|
||||||
|
```
|
||||||
|
|
||||||
|
저장한 값이 아니라 화면에 입력한 값을 렌더링한다. **Asset 을 방금 올렸으면 편집 화면을 한 번
|
||||||
|
새로 고친다** — 안 그러면 편집 화면이 들고 있는 옛 asset 목록에서 키를 못 찾아 본문 전체가
|
||||||
|
막힌다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
초안을 미리 볼 수 없습니다
|
||||||
|
1:1 supported local evidence key not found: <asset-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 배치로 돌릴 때 돌려받을 것
|
||||||
|
|
||||||
|
한 건마다 이만큼은 남긴다. 무엇이 채워졌고 무엇을 못 찾았는지 없이 「성공」만 돌려받으면
|
||||||
|
못 찾은 칸이 묻힌다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ name, version, saved: 'OK v13' | 'CLEAN' | 'FAIL <레일 글자>',
|
||||||
|
filled: ['문제','결론'], skipped: 2, miss: ['적용 조건'], rows: ['영향 2→3'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 절대로 하지 않는 것
|
||||||
|
|
||||||
|
- `게시` 버튼을 누르지 않는다
|
||||||
|
- 로그인 화면에 자격증명을 입력하지 않는다
|
||||||
|
- `저장됨` 이 안 뜬다고 연타하지 않는다
|
||||||
|
- 시험 삼아 문서를 만들지 않는다 — 한 번 게시한 문서는 취소해도 삭제가 409 로 거절된다
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 기록 `.md` 의 어디가 Studio 의 어느 칸인가
|
||||||
|
|
||||||
|
## 세 자리
|
||||||
|
|
||||||
|
| 기록 `.md` | Studio |
|
||||||
|
|---|---|
|
||||||
|
| frontmatter | 메타데이터. 화면 칸이 아니다 |
|
||||||
|
| 제목 바로 아래 첫 문단 | **`요약` 칸** |
|
||||||
|
| `## <이름>` | **같은 이름의 칸** |
|
||||||
|
|
||||||
|
`## 요약` 이라는 절을 만들지 않는다 — Studio 에 그런 칸이 없어 통째로 사라진다. `## 출처` 도
|
||||||
|
칸이 아니다. 원본 경로는 frontmatter 의 `source` 에 있다.
|
||||||
|
|
||||||
|
## 종류마다의 칸
|
||||||
|
|
||||||
|
| 종류 | 기록 `.md` 의 `##` 이름 | 본문 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Case** | `관계` · `문제` · `결론` · `검증 환경` · `재현 조건` · `본문` | 있음 |
|
||||||
|
| **Concept** | `관계` · `본문` | 있음 |
|
||||||
|
| **Reference** | `관계` · `목적` · `규칙` · `적용 조건` · `예외` · `예시` | 없음 |
|
||||||
|
| **Question** | `관계` · `사실` · `가정` · `미지수` · `제약` · `선택지` · `다음 검증` | 없음 |
|
||||||
|
| **Decision** | **`근거`** · `결정문` · `판단 이유` · `영향` | 없음 |
|
||||||
|
|
||||||
|
**Decision 만 관계 절 이름이 `근거` 다.** 그리고 근거가 1개 이상 없으면 게시가 거절된다.
|
||||||
|
|
||||||
|
## 화면의 라벨은 기록의 절 이름과 다르다
|
||||||
|
|
||||||
|
**이것이 이 문서에서 가장 자주 틀리는 자리다.** 위 표는 기록 `.md` 가 쓰는 이름이고,
|
||||||
|
편집 화면의 라벨은 다른 말을 쓴다. 그리고 `/studio/documents/new` 의 종류 카드에 적힌 요약
|
||||||
|
(`목적 · 규칙 · 적용 조건 · 예외 · 예시`)은 **카드 문구이지 편집 화면의 라벨이 아니다.**
|
||||||
|
|
||||||
|
Reference 에서 실제로 확인한 대응이다.
|
||||||
|
|
||||||
|
| 기록의 `##` | 편집 화면의 라벨 |
|
||||||
|
|---|---|
|
||||||
|
| `목적` | **`이 기준을 쓰는 이유`** |
|
||||||
|
| `규칙` | **`판단 기준`** — 제목과 본문 두 칸이 한 줄이다 |
|
||||||
|
| `적용 조건` | **`적용할 때`** |
|
||||||
|
| `예외` | **`예외와 주의`** |
|
||||||
|
| `예시` | `예시` |
|
||||||
|
| `관계` | `관계` |
|
||||||
|
|
||||||
|
종류 이름도 자리마다 다르다. 상태 레일은 Reference 를 **`적용 기준`** 이라고 부르고,
|
||||||
|
`새 문서` 화면의 라디오는 `Reference` 다.
|
||||||
|
|
||||||
|
**화면을 먼저 스냅샷으로 읽고 그 라벨을 쓴다.** 이 표를 외워서 넣지 않는다 — 화면이 바뀌면
|
||||||
|
표가 먼저 낡는다.
|
||||||
|
|
||||||
|
## 기록에 없는데 화면에 있는 칸
|
||||||
|
|
||||||
|
| 칸 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `축` | 주제 안의 변이(SPA · Mediator · BFF · Forward-Auth). **주제를 고른 뒤에 나타난다.** 안 고르면 주제 공통 기록이 된다 |
|
||||||
|
| `마지막 검증일` | 기록의 `verifiedOn` 이다. 없으면 비워 둔다 |
|
||||||
|
|
||||||
|
**근거가 없으면 비워 둔다.** `verifiedOn` 이 없는 채로 저장하면 미리보기에 「마지막 검증」 절이
|
||||||
|
값 없이 뜬다. 그것은 날짜를 지어내는 것보다 낫다 — 게시 전에 사람이 채울지 정한다.
|
||||||
|
|
||||||
|
## frontmatter 에서 화면으로 가는 값
|
||||||
|
|
||||||
|
| frontmatter | 어디로 |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 편집 주소 `/studio/documents/<id>/edit` |
|
||||||
|
| `kind` | 새 문서를 만들 때 고르는 종류 |
|
||||||
|
| `slug` · `title` | 화면 위쪽의 슬러그·제목 칸 |
|
||||||
|
| `topic` · `topicName` · `project` | 주제·프로젝트 선택 |
|
||||||
|
| `basisVersion` (Concept) | 기준 버전 칸 |
|
||||||
|
| `questionStatus` (Question) · `decisionStatus` (Decision) | 상태 선택 |
|
||||||
|
| `assets[].file` | 올릴 Asset 파일 |
|
||||||
|
| `assets[].key` | 본문 `:::evidence key` 의 저장소 쪽 이름. 올리면 서버 키로 바뀐다 |
|
||||||
|
|
||||||
|
**계약이 화면에 주는 상태와 도메인이 들고 있는 상태가 다르다.** `QuestionStatus` 는 화면에서
|
||||||
|
`OPEN`·`RESOLVED` 둘인데 도메인은 `OPEN`·`INVESTIGATING`·`PAUSED`·`RESOLVED` 넷이고,
|
||||||
|
Decision 의 도메인 `ACCEPTED` 가 화면에서는 `ADOPTED` 로 보인다. 화면에서 고른 값이 도메인
|
||||||
|
상태를 덮어쓰지는 않는다.
|
||||||
|
|
||||||
|
## 본문이 없는 세 종류
|
||||||
|
|
||||||
|
`Reference`·`Question`·`Decision` 의 칸은 **평문으로 렌더링된다.**
|
||||||
|
|
||||||
|
- 백틱과 파이프가 글자 그대로 보인다. 코드·표를 넣지 않는다
|
||||||
|
- 줄바꿈은 `<br>` 로만 살아난다
|
||||||
|
- 나열은 쉼표로 잇지 말고 `이름 : 값` 으로 줄을 나눈다
|
||||||
|
|
||||||
|
코드·표·그림이 필요하면 짝이 되는 Case 나 Concept 에 담고 `관계` 로 가리킨다.
|
||||||
|
|
||||||
|
## 본문을 넣기 전에
|
||||||
|
|
||||||
|
저장소의 `.md` 는 그림을 마크다운 이미지로 싣는다. Studio 는 `:::evidence` 를 쓴다. 바꾸는 것은
|
||||||
|
스크립트가 한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/studio-body.py <기록.md> --body-only # 저장소 key 그대로
|
||||||
|
python3 scripts/studio-body.py <기록.md> --key a=a-1a2b3c4d --body-only # 서버가 준 key 로
|
||||||
|
```
|
||||||
|
|
||||||
|
저장소 파일 자체는 고치지 않는다.
|
||||||
|
|
||||||
|
## 되돌아오는 값
|
||||||
|
|
||||||
|
저장이 끝나면 Studio 가 `id` 를 준다. 새로 만든 기록이면 frontmatter 의 `id` 와 `studio:` 를
|
||||||
|
채운다. 그 두 칸이 차면 색인이 `publication` 을 `게시됨` 으로 적는데, **그것은 「Studio 에
|
||||||
|
있다」는 뜻이고 공개됐다는 뜻이 아니다.** 공개 여부는 `status` 와 `public:` 이 말한다.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
name: running-tech-log-pipeline
|
||||||
|
description: Use when a codebase must go all the way to a saved Tech Log Studio draft — running the seven stages (SSOT, tree, record, diagram, prose, voice, Studio save) as separate subagents, one skill per stage, with a run ledger that records which skill each stage actually used and which gate it passed.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tech Log 파이프라인 실행
|
||||||
|
|
||||||
|
## 이 스킬이 하는 일
|
||||||
|
|
||||||
|
스킬 일곱 개를 **한 줄기로 잇는다.** 각 스킬은 자기 단계만 알고 앞뒤를 모른다. 이 스킬이
|
||||||
|
단계 사이의 인계물과 순서와 관문을 정하고, 단계마다 **서브에이전트를 하나씩 띄운다.**
|
||||||
|
|
||||||
|
한 실행이 남기는 것은 산출물과 **런 원장**(`runs/<프로젝트>/<runId>/run.json`) 둘이다.
|
||||||
|
원장은 단계마다 어떤 스킬을 실제로 읽었고 어떤 관문을 어떤 종료 코드로 지났는지를 적는다.
|
||||||
|
`python3 scripts/verify-pipeline-run.py <원장>` 이 그 원장을 검사한다.
|
||||||
|
|
||||||
|
**원장이 이 스킬의 존재 이유다.** 스킬을 안 읽고 쓴 글과 읽고 쓴 글은 결과물만 봐서는
|
||||||
|
구분되지 않는다. 원장은 그것을 기계가 구분할 수 있게 만든다.
|
||||||
|
|
||||||
|
## 왜 단계마다 서브에이전트인가
|
||||||
|
|
||||||
|
한 세션이 일곱 단계를 이어서 하면 앞 단계의 문맥이 뒤 단계로 새어 든다. 그러면 세 가지가
|
||||||
|
망가진다.
|
||||||
|
|
||||||
|
- **스킬을 안 읽고도 그럴듯하게 쓴다.** 3단계에서 본문 규칙을 읽었으니 5단계에서
|
||||||
|
`rewriting-technical-prose-naturally` 를 열지 않아도 문장이 나온다. 스킬이 적용됐는지
|
||||||
|
확인할 방법이 사라진다.
|
||||||
|
- **관문이 형식이 된다.** 자기가 쓴 글을 자기가 검사하면 실패를 「이건 예외」로 넘긴다.
|
||||||
|
- **어디서 어긋났는지 못 찾는다.** 결과가 이상할 때 일곱 단계 중 어디가 원인인지 가릴 수 없다.
|
||||||
|
|
||||||
|
서브에이전트는 **자기 단계의 입력 파일과 자기 스킬만** 받는다. 다른 단계가 무엇을 했는지
|
||||||
|
모른다. 그래서 스킬을 안 읽으면 못 쓰고, 못 쓰면 원장에 남는다.
|
||||||
|
|
||||||
|
## 일곱 단계
|
||||||
|
|
||||||
|
| # | 단계 | 스킬 | 산출물 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S1 | 코드베이스 → SSOT | `analyzing-codebase-for-tech-log` | `docs/<프로젝트>/final/document.md` |
|
||||||
|
| S2 | SSOT → 분해 계약 | `deriving-tech-log-root-tree` | `docs/<프로젝트>/tech-log-studio/tech-log-tree.json` |
|
||||||
|
| S3 | 글감 → 기록 | `writing-tech-log-records` | `.../<주제>/<종류>/<기록>.md` |
|
||||||
|
| S4 | 기록 → 그림 | `technical-visualizer` | `final/assets/<이름>/` · `final/.techviz/<이름>/` |
|
||||||
|
| S5 | AI 티 제거 | `rewriting-technical-prose-naturally` | 같은 기록 파일 (제자리 수정) |
|
||||||
|
| S6 | 일한 사람의 목소리 | `writing-as-the-person-who-did-it` | 같은 기록 파일 (제자리 수정) |
|
||||||
|
| S7 | Studio 저장 | `publishing-tech-log-to-studio` | Studio 작업본 + `studio:` URL. **게시하지 않는다** |
|
||||||
|
|
||||||
|
단계마다의 입력·관문·원장 칸은 [references/stage-contracts.md](references/stage-contracts.md).
|
||||||
|
서브에이전트에 그대로 넣는 프롬프트는 [references/subagent-prompts.md](references/subagent-prompts.md).
|
||||||
|
|
||||||
|
## 순서가 고정된 곳
|
||||||
|
|
||||||
|
세 자리는 바꾸면 결과가 틀어진다.
|
||||||
|
|
||||||
|
**S3 → S4.** 그림은 기록을 쓴 다음에 만든다. 무엇을 그릴지는 기록 본문이 정하고
|
||||||
|
(`writing-tech-log-records/references/choosing-a-diagram.md` 의 세 관문), 그림의 근거는 그
|
||||||
|
기록의 `source` 앵커가 가리키는 SSOT 절이다. **기록 본문이 그림의 소재를 고르고, SSOT 절이
|
||||||
|
그림의 사실을 댄다.** 기록 `.md` 를 `techviz prepare` 에 넣지 않는다 — 기록은 SSOT 의 인용이라
|
||||||
|
줄 번호가 근거가 되지 못한다.
|
||||||
|
|
||||||
|
**S4 → S5 → S6.** 문장 손질은 본문이 다 찬 뒤에 한다. 그림을 붙이면 본문에 그림을 읽는 문단이
|
||||||
|
한둘 늘고, 그 문단도 같은 손질을 받아야 한다. S5 가 먼저다 — 번역투와 반복 문형을 걷어낸 뒤라야
|
||||||
|
S6 이 채울 자리(선택·비교·어긋남)가 보인다. 순서를 뒤집으면 S5 가 S6 이 넣은 목소리를
|
||||||
|
「과한 대구」로 다시 깎는다.
|
||||||
|
|
||||||
|
**S6 → S7.** Studio 에는 문장 손질이 끝난 것만 넣는다. 저장한 뒤에 고치면 Studio 쪽 `version`
|
||||||
|
이 올라가고 검증 산출물이 무효가 된다.
|
||||||
|
|
||||||
|
## S5·S6 뒤에는 S3 관문을 다시 돌린다
|
||||||
|
|
||||||
|
문장을 고치면 본문 문법과 인용이 함께 움직인다. `check_prose` 가 error 0 이어도 `check_body`
|
||||||
|
가 깨지거나, 고쳐 쓴 코드블록 한 줄이 SSOT 와 달라져 `check_evidence` 가 걸린다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/studio-body.md
|
||||||
|
node --experimental-transform-types \
|
||||||
|
.agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/studio-body.md
|
||||||
|
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
|
||||||
|
```
|
||||||
|
|
||||||
|
원장에서는 이것이 S5·S6 의 관문이지 S3 의 재실행이 아니다.
|
||||||
|
|
||||||
|
## 건너뛰어도 되는 단계
|
||||||
|
|
||||||
|
이미 있는 것을 다시 만들지 않는다. 건너뛴 단계도 **원장에 `SKIPPED` 와 사유를 적는다.**
|
||||||
|
적지 않고 빠뜨린 것과 판단해서 건너뛴 것을 구분해야 한다.
|
||||||
|
|
||||||
|
| 단계 | 건너뛰는 조건 |
|
||||||
|
|---|---|
|
||||||
|
| S1 | `final/document.md` 가 있고 그 안에서 이 글감의 근거를 찾을 수 있다 |
|
||||||
|
| S2 | `tech-log-tree.json` 에 이 글감이 `PROMOTE` · `CONFIRMED` 로 이미 있다 |
|
||||||
|
| S4 | 그림이 필요 없다(세 관문에 걸린다) 또는 `ssot-assets` 가 배정한 그림이 이미 있다 |
|
||||||
|
| S7 | 사용자가 Studio 반입을 요청하지 않았다 |
|
||||||
|
|
||||||
|
**S3·S5·S6 은 건너뛰지 않는다.** 기록을 쓰는 단계와 문장을 고치는 두 단계는 이 파이프라인의
|
||||||
|
산출물 자체다.
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
|
||||||
|
### 0. 런을 연다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/verify-pipeline-run.py --init runs/<프로젝트>/<runId>/run.json \
|
||||||
|
--project <프로젝트> --record <기록 경로>
|
||||||
|
```
|
||||||
|
|
||||||
|
`runId` 는 `YYYY-MM-DD-HHMM` 이다. 원장의 틀은
|
||||||
|
[templates/run.json](templates/run.json) 이고, `--init` 이 그 틀을 채워 놓는다.
|
||||||
|
|
||||||
|
### 1. 단계마다 서브에이전트를 띄운다
|
||||||
|
|
||||||
|
프롬프트는 [references/subagent-prompts.md](references/subagent-prompts.md) 의 것을 쓴다.
|
||||||
|
프롬프트에 **반드시** 들어가야 하는 넷이 있다.
|
||||||
|
|
||||||
|
1. **스킬 이름과 「먼저 그 SKILL.md 를 끝까지 읽어라」** — 요약을 주지 않는다. 요약을 주면
|
||||||
|
에이전트가 스킬을 안 연다.
|
||||||
|
2. **자기 단계의 입력 파일 경로만.** 앞 단계가 무엇을 했는지 설명하지 않는다.
|
||||||
|
3. **관문 명령 원문과 「error 0 까지 고쳐라」.**
|
||||||
|
4. **스킬 영수증** — 그 SKILL.md 에서 자기 단계에 해당하는 규칙 한 줄을 **원문 그대로**
|
||||||
|
인용해 돌려보내게 한다. 이것이 스킬을 열었다는 기계 검증 가능한 증거다
|
||||||
|
(`verify-pipeline-run.py` 가 그 문자열이 실제 SKILL.md 안에 있는지 대조한다).
|
||||||
|
|
||||||
|
한 단계가 끝나면 그 결과를 원장에 적고 다음 단계를 띄운다. **단계를 병렬로 띄우지 않는다** —
|
||||||
|
S1~S7 은 앞 단계의 산출물이 뒤 단계의 입력이다. 병렬이 되는 것은 같은 단계 안에서
|
||||||
|
**서로 다른 기록 여러 건**을 처리할 때뿐이다.
|
||||||
|
|
||||||
|
### 2. 원장을 검사한다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/verify-pipeline-run.py runs/<프로젝트>/<runId>/run.json
|
||||||
|
```
|
||||||
|
|
||||||
|
error 0 이어야 런이 끝난 것이다. 이 검사기가 보는 것은 결과물의 품질이 아니라 **절차의
|
||||||
|
준수**다 — 단계가 빠졌는지, 스킬 영수증이 그 스킬의 실제 문장인지, 관문이 돌았고 종료 코드가
|
||||||
|
0 이었는지, 적어 낸 산출물이 디스크에 있는지.
|
||||||
|
|
||||||
|
### 3. 프로젝트 검사기를 돌린다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/verify-tech-log-tree.py <프로젝트>
|
||||||
|
python3 scripts/verify-project-layout.py <프로젝트>
|
||||||
|
python3 scripts/audit-records.py <프로젝트>
|
||||||
|
python3 scripts/check-figure-text.py <프로젝트>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 이 스킬이 하지 않는 것
|
||||||
|
|
||||||
|
- **판단을 대신하지 않는다.** 어떤 후보를 `PROMOTE` 할지, 그림이 필요한지, 어떤 종류인지는
|
||||||
|
각 단계의 스킬이 정한다. 이 스킬은 그 스킬이 실제로 불렸는지만 본다.
|
||||||
|
- **관문을 완화하지 않는다.** 관문이 error 를 내면 그 단계는 끝나지 않은 것이다. 원장에
|
||||||
|
`FAILED` 로 적고 멈춘다. 다음 단계로 넘기지 않는다.
|
||||||
|
- **게시하지 않는다.** S7 은 저장까지다. 게시는 사람이 공개 화면을 보고 판단한다.
|
||||||
|
|
||||||
|
## 자주 어긋나는 자리
|
||||||
|
|
||||||
|
| 증상 | 원인 | 원장에 남는 모습 |
|
||||||
|
|---|---|---|
|
||||||
|
| 문장이 여전히 AI 같다 | S5 를 같은 세션이 겸했다 | `skillEcho` 가 비었거나 SKILL.md 에 없는 문장 |
|
||||||
|
| 그림 안에 문장이 있다 | S4 가 `check-figure-text.py` 를 안 돌렸다 | 관문 목록에 그 명령이 없다 |
|
||||||
|
| SSOT 에 없는 인용이 있다 | S3 이 앞 기록에서 코드를 옮겨 적었다 | `check_evidence` 종료 코드 ≠ 0 |
|
||||||
|
| 기록이 색인에 없다 | S2 를 건너뛰고 S3 을 했다 | S2 가 `SKIPPED` 인데 사유가 없다 |
|
||||||
|
| Studio 에서 그림이 안 보인다 | S7 이 Asset 을 올리기 전에 본문을 넣었다 | S7 관문에 미리보기 확인이 없다 |
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 단계 계약
|
||||||
|
|
||||||
|
단계마다 넷을 정한다 — **입력 · 스킬 · 관문 · 산출물.** 원장에 적히는 것도 이 넷이다.
|
||||||
|
|
||||||
|
관문은 종료 코드가 0 이어야 지난 것이다. 0 이 아니면 그 단계는 `FAILED` 이고 다음 단계로
|
||||||
|
넘어가지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S1 — 코드베이스 → SSOT
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `analyzing-codebase-for-tech-log` |
|
||||||
|
| 입력 | 분석 대상 저장소 경로(사용자가 준다) · 그 저장소의 `AGENTS.md`(있으면) |
|
||||||
|
| 산출물 | `docs/<프로젝트>/final/document.md` |
|
||||||
|
| 관문 | `python3 scripts/verify-project-layout.py <프로젝트>` |
|
||||||
|
|
||||||
|
**분석 대상 저장소는 이 저장소 밖이다.** `docs/<프로젝트>` 는 이 저장소 기준이고, 분석 대상은
|
||||||
|
사용자가 준 절대 경로다. 두 저장소를 섞지 않는다 — 대상 저장소를 고치지 않는다.
|
||||||
|
|
||||||
|
**`analysis-queue.yaml` 은 여러 프로젝트를 줄 세울 때만 쓴다.** 사용자가 저장소 하나를
|
||||||
|
지목했으면 큐 없이 그 하나를 분석한다. 큐가 있으면 큐가 정한 활성 프로젝트를 따른다.
|
||||||
|
|
||||||
|
작업 재료(`analysis/` · `notes/` · `checkpoints/` · `state.json` · `source-index.md`)는 분석
|
||||||
|
중에만 있고, 끝나면 `final/document.md` 로 합치고 지운다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/fold-analysis-into-final.py <프로젝트>
|
||||||
|
```
|
||||||
|
|
||||||
|
**S2 가 무엇을 기대하는지 알고 쓴다.** S2 는 `final/document.md` 를 절 단위로 훑어 후보를
|
||||||
|
찾는다. 절 제목이 무엇을 다루는지 말하지 않으면 후보가 안 잡힌다. 접어 넣은 문서라면
|
||||||
|
제1부(통합 분석)가 후보 범위이고 제2·3부는 근거다.
|
||||||
|
|
||||||
|
산출물이 이미 있고 이 글감의 근거가 그 안에 있으면 `SKIPPED` 로 적고 사유를 남긴다.
|
||||||
|
|
||||||
|
### 이미 접어 넣은 프로젝트를 다시 볼 때 — 대조 모드
|
||||||
|
|
||||||
|
`final/document.md` 가 이미 있는 프로젝트에 S1 을 다시 돌리는 것은 **분석이 아니라 대조**다.
|
||||||
|
스킬의 절차(작업 재료를 만들고 → 분석하고 → 접어 넣는다)를 그대로 밟으면 두 곳이 깨진다.
|
||||||
|
|
||||||
|
- `docs/<프로젝트>/` 에 `state.json`·`analysis/` 를 만들면 배치 검사기가 error 로 센다.
|
||||||
|
완료된 프로젝트의 폴더는 `final/` 과 `tech-log-studio/` 뿐이다.
|
||||||
|
- `final/document.md` 를 고치면 `ssotSha256` 이 어긋나 분해 계약이 통째로 무효가 된다.
|
||||||
|
|
||||||
|
그래서 대조 모드는 이렇게 돈다.
|
||||||
|
|
||||||
|
| | 분석 모드 | 대조 모드 |
|
||||||
|
|---|---|---|
|
||||||
|
| 작업 재료 | `docs/<프로젝트>/analysis/` | `runs/<프로젝트>/<runId>/stage/S1/` |
|
||||||
|
| SSOT | 만들거나 접어 넣는다 | **고치지 않는다.** 보강 후보만 적는다 |
|
||||||
|
| 끝 조건 | fold 하고 재료를 지운다 | 어긋난 것·빠진 것을 목록으로 남긴다 |
|
||||||
|
|
||||||
|
SSOT 가 코드와 **어긋나는** 것을 찾으면 그것은 보강 후보가 아니다. 크게 적고 사람에게
|
||||||
|
올린다 — 이미 그 SSOT 를 근거로 쓴 기록이 있기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S2 — SSOT → 분해 계약
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `deriving-tech-log-root-tree` |
|
||||||
|
| 입력 | `docs/<프로젝트>/final/document.md` **하나** |
|
||||||
|
| 산출물 | `docs/<프로젝트>/tech-log-studio/tech-log-tree.json` |
|
||||||
|
| 관문 | `python3 scripts/build-tech-log-tree.py <프로젝트>` → `python3 scripts/verify-tech-log-tree.py <프로젝트>` (error 0) |
|
||||||
|
|
||||||
|
`analysis/**` 를 후보를 찾으려고 열지 않는다. 분석에만 있는 자료를 발견하면 `final/document.md`
|
||||||
|
를 먼저 보강한다.
|
||||||
|
|
||||||
|
**검사기나 계약이 요구하는데 스킬의 절차가 안 적는 칸이 있다.** 손으로 채운다.
|
||||||
|
|
||||||
|
| 칸 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `candidateScope` | 후보를 찾은 범위. 적지 않으면 모듈 분석 절 제목이 전부 글감이 된다 |
|
||||||
|
| `sourceRepository` | 분석한 저장소의 경로·리비전·그렇게 판단한 근거. 모르면 `null`, 지어내지 않는다 |
|
||||||
|
| `ssotSha256` | `build-tech-log-tree.py` 가 채운다. 그래서 build 를 먼저 돌리고 verify 를 돌린다 |
|
||||||
|
| `ssot-assets` · `ssot-evidence` | SSOT 가 이미 그린 그림과 이미 돌린 측정을 글감에 배정한다. 계약 문서에만 있고 절차에는 이 단계가 없다 |
|
||||||
|
| `candidateScope.excludedAnchorPattern` | 「범위 밖 글감」 검사가 이 칸으로 판정한다. 없으면 그 검사가 통째로 꺼진다 |
|
||||||
|
|
||||||
|
`assetLedger` 는 계약이 요구하지만 스크립트 어느 것도 읽지 않는다. 사람이 보는 칸이다.
|
||||||
|
|
||||||
|
**`source` 앵커의 형식이 어디에도 적혀 있지 않다.** keycloak 은 「h2 슬러그 + `-ap1`」 같은
|
||||||
|
합성 앵커를 쓰고, 제목 슬러그를 쓴 프로젝트도 있다. 검사기는 SSOT 경로를 포함하는지만 보고
|
||||||
|
실재하는 heading 으로 풀지 않는다. **한 프로젝트 안에서는 한 형식으로 통일한다** — S4 가
|
||||||
|
`--heading` 값을 이 앵커에서 옮기기 때문이다.
|
||||||
|
|
||||||
|
**검사기는 「후보 ↔ 글감」만 본다. 「SSOT ↔ 후보」는 안 본다.** 그래서 SSOT 에 있는 재료를
|
||||||
|
후보 대장에 올리지도 않고 지나쳐도 error 가 0 이다. 범위의 절을 끝까지 읽는 것은 사람의 일이다.
|
||||||
|
|
||||||
|
제외가 0 건인 분해는 선별하지 않은 분해다. 후보마다 처분(`PROMOTE`·`MERGE_INTO`·
|
||||||
|
`KEEP_IN_SSOT`·`NEEDS_EVIDENCE`·`NEEDS_DECISION`)을 적고, 사람이 다시 읽은 것만
|
||||||
|
`dispositionReview: CONFIRMED` 로 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S3 — 글감 → 기록
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `writing-tech-log-records` |
|
||||||
|
| 입력 | `tech-log-tree.json` 의 노드 하나 · 그 노드의 `source` 앵커가 가리키는 SSOT 절 |
|
||||||
|
| 산출물 | `docs/<프로젝트>/tech-log-studio/<주제>/<종류>/<기록>.md` |
|
||||||
|
| 관문 | 아래 셋 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/studio-body.md
|
||||||
|
node --experimental-transform-types \
|
||||||
|
.agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/studio-body.md
|
||||||
|
node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn <기록.md>
|
||||||
|
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
|
||||||
|
```
|
||||||
|
|
||||||
|
**노드가 `PROMOTE` 이고 `CONFIRMED` 인지 먼저 본다.** 아니면 쓰지 않는다.
|
||||||
|
|
||||||
|
**인용한 줄은 SSOT 에서 찾아 대조한다.** 앞선 기록에서 옮겨 적은 것은 확인한 것이 아니다 —
|
||||||
|
그렇게 게시된 기록에 SSOT 와 다른 redirect URI 가 네 곳 있었다.
|
||||||
|
|
||||||
|
그림이 필요해 보이면 **`final/assets/` 에 이미 있는지부터 본다.** 계약의 `ssot-assets` 가 이
|
||||||
|
글감에 배정한 그림이 있으면 그 파일을 그대로 가리킨다. 없을 때만 S4 로 넘긴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S4 — 기록 → 그림
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `technical-visualizer` |
|
||||||
|
| 입력 | S3 이 쓴 기록 `.md`(무엇을 그릴지) · 그 기록의 `source` 앵커가 가리키는 SSOT 절(그림의 사실) |
|
||||||
|
| 산출물 | `final/.techviz/<이름>/{context.json,prompt.md,spec.json}` · `final/assets/<이름>/` |
|
||||||
|
| 관문 | `techviz lint` · `check-figure-text.py` · `check-figure-overlap.py` · `preview-figure.py` 로 눈 확인 |
|
||||||
|
|
||||||
|
**입력이 둘이라는 것이 이 단계의 전부다.**
|
||||||
|
|
||||||
|
- **무엇을 그릴지는 기록 본문이 정한다.** 세 관문(자리가 Case·Concept 인가 · 표가 아닌가 ·
|
||||||
|
옆 문단이 이미 말하지 않았는가)을 지나야 그린다. 그리고 **그림이 주장하는 것을 기록 본문이
|
||||||
|
말해야 한다.** 본문이 안 적은 단계를 그림만 넣으면 설명 없는 주장이 남는다. 순서는
|
||||||
|
본문을 먼저 보강하고 그다음 그림을 붙인다.
|
||||||
|
- **그림의 사실은 SSOT 절이 댄다.** 기록은 SSOT 의 인용이라 줄 번호가 근거가 되지 못한다.
|
||||||
|
`techviz prepare` 에는 `final/document.md` 를 넣고, 절은 기록의 `source` 앵커로 지목한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/techviz prepare docs/<프로젝트>/final/document.md \
|
||||||
|
--heading "<기록의 source 앵커가 가리키는 절 제목>" \
|
||||||
|
-o docs/<프로젝트>/final/.techviz/<이름>/context.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--line` 은 쓰지 않는다. context 는 관리 블록을 접은 좌표를 쓰므로 파일 줄 번호와 어긋난다.
|
||||||
|
|
||||||
|
**그림 안에는 이름만 넣는다.** 문장은 `<desc>` 와 옆 문단에 둔다. lint 는 이것을 못 잡는다 —
|
||||||
|
`check-figure-text.py` 가 잡는다.
|
||||||
|
|
||||||
|
**lint 는 좌표를 안 본다.** 관계가 이어져 있는지만 본다. 그래서 구역 둘이 겹쳐 그려지거나
|
||||||
|
라벨이 상자에 먹혀도 통과한다. `check-figure-overlap.py` 가 그것을 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check-figure-overlap.py <프로젝트>
|
||||||
|
python3 scripts/check-figure-overlap.py --file 그림.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
**그래도 마지막에는 눈으로 본다.** 검사기가 보는 것은 배경 사각형의 좌표라, 글자가 상자
|
||||||
|
밖으로 조금 나가거나 화살표가 라벨을 지나는 것은 못 잡는다.
|
||||||
|
|
||||||
|
기록에 되적는다 — frontmatter `assets:` 에 `key` 와 `file`(=`final/assets/<이름>/<이름>.svg`)
|
||||||
|
을 적고, 본문에는 마크다운 이미지로 넣는다. `:::evidence` 는 저장소에 쓰지 않는다. 사본을
|
||||||
|
`tech-log-studio/` 쪽에 두지 않는다 — 사본에는 `.techviz/<이름>/` 이 없어 다시 못 만든다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S5 — AI 티 제거
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `rewriting-technical-prose-naturally` |
|
||||||
|
| 입력 | S3·S4 를 지난 기록 `.md` (제자리 수정) |
|
||||||
|
| 산출물 | 같은 파일 |
|
||||||
|
| 관문 | `check_prose.mjs` error 0 · `style_profile.mjs` · S3 관문 재실행 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=.agents/skills/rewriting-technical-prose-naturally/scripts
|
||||||
|
node $S/check_prose.mjs --warn <기록.md> # error 0 까지 고친다
|
||||||
|
node $S/style_profile.mjs <기록.md>
|
||||||
|
```
|
||||||
|
|
||||||
|
문서 전체를 다시 쓴 것이 아니면 `--doc` 을 빼고 부른다.
|
||||||
|
|
||||||
|
**`density.mjs` 는 본문이 있는 종류에만 건다.** 그 기준은 글 한 편(낱말 1277+ · 코드블록 1+ ·
|
||||||
|
수치 13+)을 잰 값이고, Reference·Question·Decision 은 칸이 평문이라 코드블록을 넣는 것 자체가
|
||||||
|
규칙 위반이다. 본문 없는 종류에 걸면 구조적으로 통과할 수 없는 관문이 되고, 통과시키려면
|
||||||
|
없는 측정값을 지어내야 한다.
|
||||||
|
|
||||||
|
**문체 수치를 맞추려고 문장을 넣지 않는다.** 검사기는 표면 패턴만 보고 뜻은 못 본다.
|
||||||
|
|
||||||
|
**보호 구간을 건드리지 않는다** — 수치·날짜·버전·단위·코드·명령어·URL·직접 인용·공식 명칭은
|
||||||
|
원문과 한 글자도 달라지면 안 된다. 그래서 문장을 고친 뒤 `check_evidence.mjs` 를 다시 돌린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S6 — 일한 사람의 목소리
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `writing-as-the-person-who-did-it` |
|
||||||
|
| 입력 | S5 를 지난 기록 `.md` · **그리고 그 기록의 상류 자료** (SSOT · 커밋 메시지 · 주석 · `확인하지 못한 것` 칸) |
|
||||||
|
| 산출물 | 같은 파일 |
|
||||||
|
| 관문 | `check_voice.mjs` · `check_prose.mjs` 재실행 · S3 관문 재실행 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs <기록.md>
|
||||||
|
node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn <기록.md>
|
||||||
|
```
|
||||||
|
|
||||||
|
**S5 다음이다.** 번역투와 반복 문형을 걷어낸 뒤라야 채울 자리가 보인다. 그리고 이 스킬이
|
||||||
|
넣은 문장을 `check_prose` 가 다시 본다 — 그래서 재실행이 관문이다.
|
||||||
|
|
||||||
|
**자료에 흔적이 없으면 이 단계는 여기서 끝난다.** 없는 사람을 만들지 않는다. 「처음에는」·
|
||||||
|
「고민 끝에」·「놀랍게도」를 자료 없이 쓰면 지어낸 것이다. 원장에는 `DONE` 에 「흔적 없음」을
|
||||||
|
적는다 — `SKIPPED` 가 아니다. 찾아봤다는 것이 이 단계의 일이다.
|
||||||
|
|
||||||
|
**`check_voice.mjs` 는 목소리가 모자란지 재지 않는다.** 지어낸 목소리를 잡는다. 조용하다고
|
||||||
|
목소리가 생긴 것은 아니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S7 — Studio 저장
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 스킬 | `publishing-tech-log-to-studio` |
|
||||||
|
| 입력 | S6 을 지난 기록 `.md` · frontmatter `assets:` 가 가리키는 SVG |
|
||||||
|
| 산출물 | Studio 작업본. 기록 frontmatter 의 `id`·`studio:` |
|
||||||
|
| 관문 | 상태 레일이 `저장됨` · `python3 scripts/build-tech-log-tree.py` → `verify-tech-log-tree.py` |
|
||||||
|
|
||||||
|
**저장까지다. 게시하지 않는다.**
|
||||||
|
|
||||||
|
Asset 을 본문보다 먼저 올린다. 순서를 뒤집으면 미리보기가 본문 전체를 막는다.
|
||||||
|
|
||||||
|
바뀐 것이 없으면 저장 버튼을 누르지 않는다 — 누를 때마다 `version` 이 올라가고 앞서 만든
|
||||||
|
검증·미리보기 산출물이 무효가 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관문 요약
|
||||||
|
|
||||||
|
| 단계 | 명령 |
|
||||||
|
|---|---|
|
||||||
|
| S1 | `verify-project-layout.py <프로젝트>` |
|
||||||
|
| S2 | `build-tech-log-tree.py` → `verify-tech-log-tree.py` (error 0) |
|
||||||
|
| S3 | `studio-body.py` → `check_body.mjs` · `check_prose.mjs` · `check_evidence.mjs --repo` |
|
||||||
|
| S4 | `techviz lint` · `check-figure-text.py` · `check-figure-overlap.py` · `preview-figure.py` (눈 확인) |
|
||||||
|
| S5 | `check_prose.mjs` (error 0) · `style_profile.mjs` · S3 관문 |
|
||||||
|
| S6 | `check_voice.mjs` · `check_prose.mjs` · S3 관문 |
|
||||||
|
| S7 | `저장됨` 확인 · `build-tech-log-tree.py` → `verify-tech-log-tree.py` |
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# 서브에이전트 프롬프트
|
||||||
|
|
||||||
|
단계마다 에이전트를 하나 띄운다. 아래를 그대로 쓰고 `<...>` 만 바꾼다.
|
||||||
|
|
||||||
|
## 모든 프롬프트에 들어가는 넷
|
||||||
|
|
||||||
|
1. **스킬 이름과 「SKILL.md 를 끝까지 먼저 읽어라」.** 요약을 주지 않는다. 요약을 주면
|
||||||
|
스킬을 안 연다.
|
||||||
|
2. **자기 단계의 입력 경로만.** 앞 단계가 무엇을 했는지 설명하지 않는다.
|
||||||
|
3. **관문 명령 원문.** 「검사해라」가 아니라 붙여 넣을 수 있는 명령을 준다.
|
||||||
|
4. **스킬 영수증** — SKILL.md 에서 한 줄을 **원문 그대로** 인용해 돌려보내게 한다.
|
||||||
|
`verify-pipeline-run.py` 가 그 문자열이 실제 파일 안에 있는지 대조한다.
|
||||||
|
|
||||||
|
## 돌려받는 형식 (모든 단계 공통)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "S3",
|
||||||
|
"skill": "writing-tech-log-records",
|
||||||
|
"skillEcho": "<SKILL.md 에서 그대로 옮긴 한 줄>",
|
||||||
|
"status": "DONE",
|
||||||
|
"outputs": ["docs/keycloak/tech-log-studio/.../case-x.md"],
|
||||||
|
"gates": [{"cmd": "node ... check_body.mjs /tmp/studio-body.md", "exit": 0}],
|
||||||
|
"notes": "<판단한 것과 못 한 것>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`skillEcho` 를 지어내지 말라고 프롬프트에 적는다. 파일에 없는 문장이면 검사기가 잡는다.
|
||||||
|
|
||||||
|
## 파일을 쓰라고 할 때는 방법을 함께 준다
|
||||||
|
|
||||||
|
서브에이전트의 `Write` 는 「보고는 파일이 아니라 글로 돌려라」는 기본 정책에 막힐 수 있다.
|
||||||
|
S1 처럼 산출물이 `.md` 인 단계는 그래서 한 줄을 더한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파일을 쓸 때 Write 툴이 막히면 Bash heredoc (`cat > 경로 <<'EOF'`) 을 써라.
|
||||||
|
```
|
||||||
|
|
||||||
|
이것을 안 적으면 에이전트가 산출물을 만들지 못하고 본문에 통째로 붙여 돌려준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S1 — 코드베이스 → SSOT
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 1단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/analyzing-codebase-for-tech-log/SKILL.md 를 끝까지 읽어라.
|
||||||
|
references/ 아래 문서도 그 스킬이 읽으라는 것을 읽어라. 요약본은 주지 않는다.
|
||||||
|
|
||||||
|
대상 저장소: <절대 경로>
|
||||||
|
쓸 곳: docs/<프로젝트>/
|
||||||
|
|
||||||
|
analysis-queue.yaml 이 없으면 이 저장소 하나만 분석한다. 큐가 없다는 이유로 멈추지 마라.
|
||||||
|
대상 저장소를 고치지 마라. 읽기만 한다.
|
||||||
|
|
||||||
|
끝나면 분석 재료를 SSOT 로 합쳐라:
|
||||||
|
python3 scripts/fold-analysis-into-final.py <프로젝트>
|
||||||
|
합친 뒤 analysis/ · notes/ · checkpoints/ · state.json · source-index.md 를 지운다.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
python3 scripts/verify-project-layout.py <프로젝트>
|
||||||
|
error 0 이 될 때까지 고쳐라.
|
||||||
|
|
||||||
|
돌려줄 것 (JSON):
|
||||||
|
stage, skill, skillEcho, status, outputs, gates, notes
|
||||||
|
skillEcho 는 방금 읽은 SKILL.md 에서 네 작업에 해당하는 규칙 한 줄을 원문 그대로 옮긴 것이다.
|
||||||
|
지어내지 마라 — 파일에 그 문자열이 있는지 기계가 대조한다.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S2 — SSOT → 분해 계약
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 2단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/deriving-tech-log-root-tree/SKILL.md 를 끝까지 읽어라.
|
||||||
|
references/candidate-disposition.md 와 references/decomposition-checklist.md 도 읽어라.
|
||||||
|
출력 계약은 .agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md 다.
|
||||||
|
|
||||||
|
입력: docs/<프로젝트>/final/document.md — 이것 하나다.
|
||||||
|
analysis/** 를 후보를 찾으려고 열지 마라.
|
||||||
|
|
||||||
|
출력: docs/<프로젝트>/tech-log-studio/tech-log-tree.json
|
||||||
|
|
||||||
|
검사기가 error 로 요구하는데 스킬 본문이 안 적는 칸 셋을 손으로 채워라:
|
||||||
|
candidateScope — 후보를 찾은 범위
|
||||||
|
sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 모르면 null 로 두고 지어내지 마라
|
||||||
|
ssotSha256 — build 가 채운다. 그래서 build 를 먼저 돌린다
|
||||||
|
|
||||||
|
제외가 0 건인 분해는 선별하지 않은 분해다. 후보마다 처분을 적고, 다시 읽은 것만
|
||||||
|
dispositionReview: CONFIRMED 로 둬라.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
python3 scripts/build-tech-log-tree.py <프로젝트>
|
||||||
|
python3 scripts/verify-tech-log-tree.py <프로젝트>
|
||||||
|
error 0 까지 고쳐라.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S3 — 글감 → 기록
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 3단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/writing-tech-log-records/SKILL.md 를 끝까지 읽어라.
|
||||||
|
그 스킬이 가리키는 references/ 중 네 종류에 해당하는 것을 읽어라 —
|
||||||
|
record-kinds.md · writing-each-kind.md · body-syntax.md · code-tables-diagrams.md ·
|
||||||
|
explaining.md · ai-tells.md · choosing-a-diagram.md.
|
||||||
|
|
||||||
|
글감: docs/<프로젝트>/tech-log-studio/tech-log-tree.json 의 <주제> / <종류> / "<제목>"
|
||||||
|
그 노드가 PROMOTE 이고 dispositionReview 가 CONFIRMED 인지 먼저 확인해라. 아니면 쓰지 마라.
|
||||||
|
|
||||||
|
근거: 그 노드의 source 앵커가 가리키는 docs/<프로젝트>/final/document.md 의 절.
|
||||||
|
인용하는 줄은 SSOT 에서 찾아 대조해라. 기억이나 다른 기록에서 옮겨 적지 마라.
|
||||||
|
|
||||||
|
쓸 곳: docs/<프로젝트>/tech-log-studio/<주제>/<종류>/<파일>.md
|
||||||
|
|
||||||
|
그림이 필요해 보이면 docs/<프로젝트>/final/assets/ 에 이미 있는지부터 봐라.
|
||||||
|
없으면 이 단계에서 만들지 말고 notes 에 "그림 필요: <무엇을>" 이라고 적어라. 4단계가 만든다.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/studio-body.md
|
||||||
|
node --experimental-transform-types \
|
||||||
|
.agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/studio-body.md
|
||||||
|
node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn <기록.md>
|
||||||
|
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
|
||||||
|
error 0 까지 고쳐라.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S4 — 기록 → 그림
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 4단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 두 개를 읽어라:
|
||||||
|
.agents/skills/technical-visualizer/SKILL.md — 끝까지
|
||||||
|
.agents/skills/writing-tech-log-records/references/choosing-a-diagram.md
|
||||||
|
|
||||||
|
기록: <기록.md>
|
||||||
|
이 기록을 읽고 무엇을 그릴지 정해라. 세 관문을 지나야 그린다 —
|
||||||
|
자리가 Case·Concept 인가 / 표로 될 것이 아닌가 / 옆 문단이 이미 말하지 않았는가.
|
||||||
|
그리고 그림이 주장하는 것을 이 기록 본문이 말하고 있어야 한다. 본문이 안 적은 단계를
|
||||||
|
그림만으로 넣지 마라.
|
||||||
|
|
||||||
|
그림의 근거는 기록이 아니라 기록의 source 앵커가 가리키는 SSOT 절이다:
|
||||||
|
./scripts/techviz prepare docs/<프로젝트>/final/document.md \
|
||||||
|
--heading "<그 절 제목>" \
|
||||||
|
-o docs/<프로젝트>/final/.techviz/<이름>/context.json
|
||||||
|
--line 은 쓰지 마라.
|
||||||
|
|
||||||
|
그림 안의 <text> 는 전부 이름이어야 한다. 문장은 <desc> 와 옆 문단에 둬라.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
./scripts/techviz lint docs/<프로젝트>/final/.techviz/<이름>/spec.json \
|
||||||
|
--context docs/<프로젝트>/final/.techviz/<이름>/context.json
|
||||||
|
python3 scripts/check-figure-text.py <프로젝트>
|
||||||
|
python3 scripts/check-figure-overlap.py <프로젝트>
|
||||||
|
python3 scripts/preview-figure.py <프로젝트> -o /tmp/figs
|
||||||
|
마지막 것은 PNG 로 떠서 라벨이 상자를 덮지 않는지 눈으로 봐라. lint 는 그것을 못 잡는다.
|
||||||
|
|
||||||
|
기록에 되적어라 — frontmatter assets: 에 key 와 file, 본문에는 마크다운 이미지.
|
||||||
|
:::evidence 를 저장소 .md 에 쓰지 마라.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함. 그리지 않기로 했으면 status 를 SKIPPED 로 하고
|
||||||
|
어느 관문에 걸렸는지 적어라.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S5 — AI 티 제거
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 5단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/rewriting-technical-prose-naturally/SKILL.md 를 끝까지 읽어라.
|
||||||
|
그 스킬이 "첫 rewrite 전에 읽으라"고 지정한 references 세 개도 읽어라 —
|
||||||
|
document-skeleton.md · article-shape.md · korean-tech-blog-register.md.
|
||||||
|
|
||||||
|
고칠 파일: <기록.md> (제자리에서 고친다)
|
||||||
|
|
||||||
|
너의 일은 문체다. 사실을 만들지 마라. 분류가 틀렸거나 근거가 모자란 것은 네 일이 아니다 —
|
||||||
|
발견하면 고치지 말고 notes 에 적어라.
|
||||||
|
|
||||||
|
보호 구간을 건드리지 마라: 수치 · 날짜 · 버전 · 단위 · 코드 · 명령어 · URL · 직접 인용 ·
|
||||||
|
공식 명칭. 한 글자도 달라지면 안 된다.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
S=.agents/skills/rewriting-technical-prose-naturally/scripts
|
||||||
|
node $S/check_prose.mjs --warn <기록.md> # error 0 까지
|
||||||
|
node $S/style_profile.mjs <기록.md>
|
||||||
|
그리고 문장을 고쳤으니 본문 문법과 인용을 다시 본다:
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/studio-body.md
|
||||||
|
node --experimental-transform-types \
|
||||||
|
.agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/studio-body.md
|
||||||
|
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
|
||||||
|
|
||||||
|
수치를 맞추려고 문장을 넣지 마라. 검사기는 표면 패턴만 본다.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S6 — 일한 사람의 목소리
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 6단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/writing-as-the-person-who-did-it/SKILL.md 를 끝까지 읽어라.
|
||||||
|
references/voice-moves.md 도 읽어라. 그 스킬이 "이것보다 먼저 본다"고 한
|
||||||
|
../rewriting-technical-prose-naturally/references/article-shape.md 를 먼저 읽어라.
|
||||||
|
|
||||||
|
고칠 파일: <기록.md> (제자리에서 고친다)
|
||||||
|
상류 자료: docs/<프로젝트>/final/document.md · <대상 저장소의 커밋 메시지·주석·README>
|
||||||
|
|
||||||
|
찾을 것은 자료에 남아 있는 사람의 흔적이다 — 무엇을 골랐고 무엇과 견주었나,
|
||||||
|
확인하지 못한 것이 무엇이고 그것이 어느 주장에 걸리나, 처음 생각과 어긋난 자리가 있나.
|
||||||
|
|
||||||
|
없는 사람을 만들지 마라. 「처음에는」·「고민 끝에」·「놀랍게도」를 자료 없이 쓰면 지어낸 것이다.
|
||||||
|
넣은 문장마다 그것이 어느 파일 어느 줄에서 왔는지 댈 수 있어야 한다.
|
||||||
|
자료에 흔적이 없으면 아무것도 넣지 말고 그렇게 보고해라. 그것도 이 단계를 한 것이다.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs <기록.md>
|
||||||
|
node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn <기록.md>
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/studio-body.md
|
||||||
|
node --experimental-transform-types \
|
||||||
|
.agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/studio-body.md
|
||||||
|
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
|
||||||
|
|
||||||
|
check_voice.mjs 는 목소리가 모자란지 재지 않는다. 지어낸 목소리를 잡는다.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함. 흔적이 없었으면 status DONE 에 notes 로 적어라.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S7 — Studio 저장
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 Tech Log 파이프라인의 7단계를 맡는다.
|
||||||
|
|
||||||
|
먼저 .agents/skills/publishing-tech-log-to-studio/SKILL.md 를 끝까지 읽어라.
|
||||||
|
references/studio-form-map.md 와 references/playwright-recipes.md 도 읽어라.
|
||||||
|
|
||||||
|
넣을 기록: <기록.md>
|
||||||
|
도구: Playwright MCP (mcp__playwright__browser_*)
|
||||||
|
|
||||||
|
저장까지만 한다. 게시 버튼을 누르지 마라. 한 번 게시한 문서는 취소해도 삭제가 409 로 거절된다.
|
||||||
|
|
||||||
|
상태 레일 aside[class*="studio-document-status"] 가 25초 안에 안 뜨면 인증이 안 된 것이다.
|
||||||
|
로그인 화면에 자격증명을 입력하지 말고 거기서 멈추고 사용자에게 알려라.
|
||||||
|
|
||||||
|
frontmatter 의 id 와 studio: 가 이미 있으면 그 주소로 가라. 새로 만들지 마라.
|
||||||
|
|
||||||
|
그림이 있으면 Asset 을 본문보다 먼저 올려라. 순서를 뒤집으면 미리보기가 본문을 막는다.
|
||||||
|
|
||||||
|
관문:
|
||||||
|
상태 레일의 글자가 저장됨 으로 바뀌는 것을 확인 (최대 30초)
|
||||||
|
python3 scripts/build-tech-log-tree.py <프로젝트>
|
||||||
|
python3 scripts/verify-tech-log-tree.py <프로젝트>
|
||||||
|
|
||||||
|
저장됨 이 안 뜨면 버튼을 다시 누르지 말고 레일의 글자를 그대로 보고해라.
|
||||||
|
|
||||||
|
돌려줄 것: 위 JSON 형식. skillEcho 포함. gates 에 저장 전후 version 을 적어라.
|
||||||
|
```
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"runId": "<YYYY-MM-DD-HHMM>",
|
||||||
|
"project": "<프로젝트>",
|
||||||
|
"record": "<docs/<프로젝트>/tech-log-studio/<주제>/<종류>/<기록>.md — 이 런이 만드는 기록>",
|
||||||
|
"startedAt": "<ISO-8601>",
|
||||||
|
"finishedAt": null,
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": "S1",
|
||||||
|
"name": "코드베이스 → SSOT",
|
||||||
|
"skill": "analyzing-codebase-for-tech-log",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S2",
|
||||||
|
"name": "SSOT → 분해 계약",
|
||||||
|
"skill": "deriving-tech-log-root-tree",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S3",
|
||||||
|
"name": "글감 → 기록",
|
||||||
|
"skill": "writing-tech-log-records",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S4",
|
||||||
|
"name": "기록 → 그림",
|
||||||
|
"skill": "technical-visualizer",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S5",
|
||||||
|
"name": "AI 티 제거",
|
||||||
|
"skill": "rewriting-technical-prose-naturally",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S6",
|
||||||
|
"name": "일한 사람의 목소리",
|
||||||
|
"skill": "writing-as-the-person-who-did-it",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S7",
|
||||||
|
"name": "Studio 저장",
|
||||||
|
"skill": "publishing-tech-log-to-studio",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "PENDING",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -192,6 +192,16 @@ Load supporting guidance only as needed:
|
|||||||
|
|
||||||
`techviz build ... --document docs/<프로젝트>/final/document.md` 가 그 블록을 갱신한다.
|
`techviz build ... --document docs/<프로젝트>/final/document.md` 가 그 블록을 갱신한다.
|
||||||
|
|
||||||
|
### Tech Log 파이프라인에서 불릴 때
|
||||||
|
|
||||||
|
`running-tech-log-pipeline` 의 4단계가 이 스킬이다. 입력이 둘이라는 것만 다르다.
|
||||||
|
|
||||||
|
- **무엇을 그릴지는 방금 쓴 기록 본문이 정한다.** 세 관문은
|
||||||
|
`../writing-tech-log-records/references/choosing-a-diagram.md` 에 있고, 그림이 주장하는
|
||||||
|
것을 기록 본문이 말하고 있어야 한다.
|
||||||
|
- **그림의 사실은 SSOT 절이 댄다.** 기록은 SSOT 의 인용이라 줄 번호가 근거가 되지 못한다.
|
||||||
|
`prepare` 에는 `final/document.md` 를 넣고 절은 기록의 `source` 앵커로 지목한다.
|
||||||
|
|
||||||
### Tech Log 기록으로 옮길 때
|
### Tech Log 기록으로 옮길 때
|
||||||
|
|
||||||
런의 `document.md` 는 마크다운 이미지로 그림을 싣지만, Studio 기록은 다르다. SVG 를 Studio 에 Asset 으로
|
런의 `document.md` 는 마크다운 이미지로 그림을 싣지만, Studio 기록은 다르다. SVG 를 Studio 에 Asset 으로
|
||||||
@@ -203,10 +213,45 @@ Load supporting guidance only as needed:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`references/code-tables-diagrams.md`(writing-tech-log-records)의 규칙이 함께 적용된다. 특히
|
`references/code-tables-diagrams.md`(writing-tech-log-records)의 규칙이 함께 적용된다. 특히
|
||||||
**`<text>` 는 이름만 담고 문장은 `<desc>` 와 옆 문단에 둔다.** 이 저장소의 기존 손그림 SVG 는 이 규칙을
|
**`<text>` 는 이름만 담고 문장은 `<desc>` 와 옆 문단에 둔다.** `render` 뒤에 반드시 돌린다 —
|
||||||
|
`spec.json` 의 `label`·`details`·edge `label` 이 그대로 `<text>` 가 되므로 스펙을 쓸 때부터 이름으로 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check-figure-text.py <프로젝트> # <text> 가 전부 이름인가
|
||||||
|
python3 scripts/check-figure-overlap.py <프로젝트> # 상자와 라벨이 서로를 덮지 않는가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴파일한 뒤 반드시 눈으로 본다
|
||||||
|
|
||||||
|
**lint 는 라벨이 상자를 덮는 것을 못 잡는다.** 엣지가 노드를 지나가는 것(`edge-through-node`)은
|
||||||
|
보지만 라벨은 앵커 점만 보고 폭을 재지 않는다. 그래서 `PASS` 인 그림에도 라벨이 상자에 먹히거나
|
||||||
|
경계선 위에 얹히는 일이 생긴다. SVG 를 PNG 로 떠서 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/preview-figure.py <프로젝트> -o /tmp/figs
|
||||||
|
python3 scripts/preview-figure.py --file 그림.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
지금까지 확인한 것:
|
||||||
|
|
||||||
|
| 증상 | 원인 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| 엣지 라벨이 옆 상자에 먹힌다 | 라벨이 길다 | 라벨을 짧은 이름으로. 자세한 것은 노드 `details` 로 |
|
||||||
|
| 라벨이 group 점선 위에 얹힌다 | `two-zone-pipeline` 은 지역 **안쪽** 엣지 라벨을 캔버스 top 에 고정한다 | 지역 안 엣지를 없애거나 `component-flow` + `groups` 로 바꾼다 |
|
||||||
|
| 원기둥이 제목·항목을 덮는다 | `shape: cylinder` 에 `details` 가 많다 | `details` 를 줄이거나 `shape: box` | 이 저장소의 기존 손그림 SVG 는 이 규칙을
|
||||||
어기고 문단과 각주 번호·커밋 해시를 캔버스 안에 넣어 두었다. 다시 만들 때 그 문장들은 본문으로 내린다.
|
어기고 문단과 각주 번호·커밋 해시를 캔버스 안에 넣어 두었다. 다시 만들 때 그 문장들은 본문으로 내린다.
|
||||||
|
|
||||||
### 그림을 만들기 전에
|
### 그림을 만들기 전에
|
||||||
|
|
||||||
`rewriting-technical-prose-naturally` 의 `## Figures` 를 먼저 읽는다. 옆 문단이 이미 말한 것을 상자와
|
`rewriting-technical-prose-naturally` 의 `## Figures` 를 먼저 읽는다. 옆 문단이 이미 말한 것을 상자와
|
||||||
화살표로 다시 그린 그림은 만들지 않는다. 그림이 담아야 할 것은 순서·구조·측정값·실제 산출물이다.
|
화살표로 다시 그린 그림은 만들지 않는다. 그림이 담아야 할 것은 순서·구조·측정값·실제 산출물이다.
|
||||||
|
|
||||||
|
**표로 되는 것을 그림으로 그리지 않는다.** `comparison` 프로필은 연결성 검사에서 빠지기 때문에
|
||||||
|
항목을 나란히 늘어놓기만 해도 lint 를 통과한다. 그것이 표다 — 표는 값을 비교하고 그림은
|
||||||
|
포함·순서·경계처럼 자리로만 보이는 것을 맡는다. 스펙을 쓰기 전에 묻는다.
|
||||||
|
|
||||||
|
> **관계선을 다 지워도 뜻이 남는가.** 남으면 표다. 마크다운 표로 쓴다.
|
||||||
|
|
||||||
|
`verify-project-layout.py` 의 「표로 되는 그림」이 관계선 없이 항목마다 같은 수의 `details` 를
|
||||||
|
늘어놓은 spec 을 센다. `comparison` 이 맞는 자리는 비교 자체가 자리로 드러나는 때다 — 겹치는
|
||||||
|
범위, 갈라지는 경계처럼.
|
||||||
|
|||||||
@@ -46,16 +46,21 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
|
|||||||
남겨 두고 나중에 걷어내는 순서가 아니다. **문체를 손보기 전에 문장을 고른다** — 설명이 끝난
|
남겨 두고 나중에 걷어내는 순서가 아니다. **문체를 손보기 전에 문장을 고른다** — 설명이 끝난
|
||||||
뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 먼저 뺀다(`ai-tells.md` 첫 절). 문체 규칙의
|
뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 먼저 뺀다(`ai-tells.md` 첫 절). 문체 규칙의
|
||||||
정본은 `ai-tells.md` 다.
|
정본은 `ai-tells.md` 다.
|
||||||
|
**그림이 필요한지, 필요하면 무엇을 그릴지는 `references/choosing-a-diagram.md`.** 자리(Case·
|
||||||
|
Concept 만) · 표가 아닌지 · 옆 문단이 이미 말하지 않았는지 세 관문을 지나야 그린다.
|
||||||
순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 **`final/assets/` 에 그
|
순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 **`final/assets/` 에 그
|
||||||
그림이 이미 있는지부터 본다.** SSOT 를 만들 때 그려 둔 것이 있고 계약의 `ssot-assets` 가 이
|
그림이 이미 있는지부터 본다.** SSOT 를 만들 때 그려 둔 것이 있고 계약의 `ssot-assets` 가 이
|
||||||
글감에 배정해 두었으면 그것을 쓴다 — `final/assets/tech-log-studio/` 로 복사하고 기록의
|
글감에 배정해 두었으면 기록의 `assets` 가 **그 파일을 그대로** 가리킨다. 사본을 따로 만들지
|
||||||
`assets` 가 그 사본을 가리킨다. **없을 때만** `technical-visualizer` 로 새로 만든다. 손으로
|
않는다 — 사본에는 `.techviz/<이름>/` 이 없어 다시 만들 수 없다. **없을 때만**
|
||||||
|
`technical-visualizer` 로 새로 만든다. 손으로
|
||||||
SVG 를 그리지 않고, 모든 글에 그림을 만들지도 않는다.
|
SVG 를 그리지 않고, 모든 글에 그림을 만들지도 않는다.
|
||||||
인용할 측정도 같다. `final/evidence/` 에 있는 원문을 가리키고 같은 것을 다시 돌리지 않는다.
|
인용할 측정도 같다. `final/evidence/` 에 있는 원문을 가리키고 같은 것을 다시 돌리지 않는다.
|
||||||
**그림과 측정을 그 자리에서 만들어 내기 전에 SSOT 가 이미 가진 것을 먼저 찾는다** — keycloak
|
**그림과 측정을 그 자리에서 만들어 내기 전에 SSOT 가 이미 가진 것을 먼저 찾는다** — keycloak
|
||||||
에서는 그러지 않아 정본까지 갖춘 그림 13 장이 남고 이름이 다른 그림 5 장이 새로 만들어졌다.
|
에서는 그러지 않아 정본까지 갖춘 그림 13 장이 남고 이름이 다른 그림 5 장이 새로 만들어졌다.
|
||||||
4. **검사** — 셋 다 돌린다. 파서와 문장과 증빙은 각각 다른 것을 본다.
|
4. **검사** — 셋 다 돌린다. 파서와 문장과 증빙은 각각 다른 것을 본다.
|
||||||
- `scripts/check_body.mjs` — Case 본문이 Studio 파서를 통과하는지. 같은 파서를 그대로 부른다.
|
- `scripts/check_body.mjs` — 본문이 Studio 파서를 통과하는지. 같은 파서를 그대로 부른다.
|
||||||
|
저장소의 그림은 마크다운 이미지이므로 `python3 scripts/studio-body.py <기록> -o /tmp/x.md`
|
||||||
|
로 바꾼 파일에 돌린다. 저장소 파일에 그대로 돌리면 `unsafe image URL` 로 실패한다.
|
||||||
- `rewriting-technical-prose-naturally/scripts/check_prose.mjs` — 문장 규범. **error 0 이 될 때까지 고친다.**
|
- `rewriting-technical-prose-naturally/scripts/check_prose.mjs` — 문장 규범. **error 0 이 될 때까지 고친다.**
|
||||||
칸 하나나 한 절만 고쳤으면 `--doc` 없이 부른다. 이어서 `style_profile.mjs` 로 문체 수치를 본다.
|
칸 하나나 한 절만 고쳤으면 `--doc` 없이 부른다. 이어서 `style_profile.mjs` 로 문체 수치를 본다.
|
||||||
- `scripts/check_evidence.mjs <프로젝트> --repo` — **인용한 것이 실재하는지.** 본문 코드블록의
|
- `scripts/check_evidence.mjs <프로젝트> --repo` — **인용한 것이 실재하는지.** 본문 코드블록의
|
||||||
@@ -77,7 +82,7 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `deriving-tech-log-root-tree` | 후보에 처분을 매기고 `PROMOTE` 를 글감으로 올린다 | 글을 쓰지 않는다 |
|
| `deriving-tech-log-root-tree` | 후보에 처분을 매기고 `PROMOTE` 를 글감으로 올린다 | 글을 쓰지 않는다 |
|
||||||
| `writing-tech-log-records` | 종류를 고르고 칸과 본문을 쓴다. `explaining.md`·`ai-tells.md` 를 처음부터 적용한다 | — |
|
| `writing-tech-log-records` | 종류를 고르고 칸과 본문을 쓴다. `explaining.md`·`ai-tells.md` 를 처음부터 적용한다 | — |
|
||||||
| `technical-visualizer` | 문장으로 따라가기 어려운 관계를 그림으로 만든다 | 모든 글에 그림을 붙이지 않는다 |
|
| `technical-visualizer` | 문장으로 따라가기 어려운 관계를 그림으로 만든다 | 무엇을 그릴지 정하지 않는다 — `choosing-a-diagram.md` 가 정한다 |
|
||||||
| `rewriting-technical-prose-naturally` | 사실과 구조가 이미 맞는 초안의 번역투·반복 문형·과한 대구를 고친다 | 분류가 틀렸거나 근거가 모자란 것은 못 고친다 |
|
| `rewriting-technical-prose-naturally` | 사실과 구조가 이미 맞는 초안의 번역투·반복 문형·과한 대구를 고친다 | 분류가 틀렸거나 근거가 모자란 것은 못 고친다 |
|
||||||
| `writing-as-the-person-who-did-it` | 자료에 남아 있는 선택·비교·어긋남·확인하지 못한 범위를 제자리에 놓는다 | 자료에 없는 「처음에는」·「고민 끝에」를 만들지 않는다 |
|
| `writing-as-the-person-who-did-it` | 자료에 남아 있는 선택·비교·어긋남·확인하지 못한 범위를 제자리에 놓는다 | 자료에 없는 「처음에는」·「고민 끝에」를 만들지 않는다 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 기록에 어떤 그림이 필요한지 정하는 기준
|
||||||
|
|
||||||
|
`code-tables-diagrams.md` 는 그림을 **어떻게** 그리는지를 말한다. 이 문서는 그 앞 단계 —
|
||||||
|
**이 기록에 그림이 필요한가, 필요하다면 무엇을 그리는가** 를 정한다.
|
||||||
|
|
||||||
|
## 세 관문
|
||||||
|
|
||||||
|
순서대로 통과해야 그림을 만든다. 하나라도 걸리면 그리지 않는다.
|
||||||
|
|
||||||
|
### 1. 자리가 있는가
|
||||||
|
|
||||||
|
`assets` 는 본문이 있는 두 종류만 갖는다 — **Case 와 Concept**. Reference·Question·Decision 의
|
||||||
|
칸은 평문으로 렌더링돼 그림이 들어갈 자리가 없다.
|
||||||
|
|
||||||
|
파생 기록에 그림이 필요해 보이면 그 그림은 **짝이 되는 Case 나 Concept 의 것**이다. 거기 담고
|
||||||
|
`관계`로 가리킨다. 담을 Case 나 Concept 이 없으면 그 그림은 아직 집이 없다 — 계약의
|
||||||
|
`assetLedger.unassigned` 에 그렇게 적고, 새 글감을 세울지는 따로 판단한다.
|
||||||
|
|
||||||
|
### 2. 표가 아닌가
|
||||||
|
|
||||||
|
> **관계선을 다 지워도 뜻이 남는가.** 남으면 표다.
|
||||||
|
|
||||||
|
표는 값을 비교하고, 그림은 **포함·순서·경계**처럼 자리로만 보이는 것을 맡는다. 항목을 같은
|
||||||
|
속성으로 늘어놓은 것은 마크다운 표로 쓴다. `verify-project-layout.py` 의 「표로 되는 그림」이
|
||||||
|
관계선 없이 항목마다 같은 수의 `details` 를 늘어놓은 spec 을 센다.
|
||||||
|
|
||||||
|
### 3. 옆 문단이 이미 말하지 않았는가
|
||||||
|
|
||||||
|
> **이 그림이 없으면 독자가 무엇을 못 보나.** 한 문장으로 답할 수 없으면 그리지 않는다.
|
||||||
|
|
||||||
|
기록에 이미 그 비교표가 있으면 그림은 중복이다. 실제로 그렇게 만든 그림 셋을 지웠다 —
|
||||||
|
`keyset-vs-offset` 을 넣은 기록에는 훑은 행·buffers·exec 까지 있는 플랜 비교표가 이미 있었다.
|
||||||
|
|
||||||
|
## 종류마다 무엇을 그리나
|
||||||
|
|
||||||
|
세 관문을 통과했을 때, 그 기록이 요구하는 그림은 종류마다 다르다.
|
||||||
|
|
||||||
|
| 종류 | 그림이 답하는 물음 | 흔한 profile |
|
||||||
|
|---|---|---|
|
||||||
|
| **Case** | 이 요청 한 번이 어떤 순서로 무엇을 지나갔나 | `sequence` · `component-flow` |
|
||||||
|
| **Case** (경계가 논지일 때) | 무엇이 어느 경계 안에 있고 무엇이 밖에 있나 | `two-zone-pipeline` |
|
||||||
|
| **Concept** | 남의 것이 어떤 순서·구조로 동작하나 | `sequence` · `component-flow` · `ports-adapters` |
|
||||||
|
|
||||||
|
Case 는 **내가 돌려서 본 것**이라 대개 순서가 논지다. Concept 은 **남의 것이 어떻게 동작하는지**라
|
||||||
|
구조나 변환 사슬이 논지다. 어느 쪽이든 「무엇이 무엇으로 바뀌는가」를 못 적으면 아직 그릴 것이
|
||||||
|
없다는 뜻이다.
|
||||||
|
|
||||||
|
**한 절에 그림 하나.** 같은 절에 구조 그림과 흐름 그림을 둘 다 넣으면 독자가 어느 쪽을 먼저
|
||||||
|
읽어야 하는지 알 수 없다. 둘 다 필요하면 절을 나눈다.
|
||||||
|
|
||||||
|
## 어디를 근거로 삼나 — 기록의 `source` 가 앵커다
|
||||||
|
|
||||||
|
`techviz prepare` 는 `final/document.md` 를 받는다. 기록은 `tech-log-studio/` 에 있지만 **그림의
|
||||||
|
근거는 기록이 아니라 기록이 가리키는 SSOT 절**이다. 기록의 `source` 앵커를 그대로 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기록의 source: final/document.md#선택의-이유와-지킨-경계-ap1 이면
|
||||||
|
./scripts/techviz prepare docs/<프로젝트>/final/document.md \
|
||||||
|
--heading "AP1: OAuth와 JWT 계약을 가장 가까이서 관찰한다" \
|
||||||
|
-o docs/<프로젝트>/final/.techviz/<id>/context.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--line` 은 쓰지 않는다. context 는 관리 블록을 접은 좌표를 쓰므로 파일의 줄 번호와 어긋난다.
|
||||||
|
`--heading` 이나 `--marker` 로 절을 지목한다.
|
||||||
|
|
||||||
|
**그림이 주장하는 것을 기록 본문이 말해야 한다.** SSOT 에 근거가 있어도 기록이 그 단계를 적지
|
||||||
|
않았으면 그림만 넣지 않는다 — 설명 없는 주장이 남는다. 순서는 하나다.
|
||||||
|
|
||||||
|
> 본문을 먼저 보강한다 → 그다음 그림을 붙인다.
|
||||||
|
|
||||||
|
`ap1-browser-bearer-flow` 가 그랬다. 마지막 단계인 `/api/me` 응답 4필드를 기록이 말하지 않아
|
||||||
|
붙이지 못하고 있다가, SSOT §474 를 근거로 본문에 한 줄을 더한 뒤에 붙였다.
|
||||||
|
|
||||||
|
## 만든 뒤
|
||||||
|
|
||||||
|
`code-tables-diagrams.md` 의 규범과 아래 둘을 함께 돌린다. lint 는 라벨이 상자를 덮는 것을
|
||||||
|
못 잡는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check-figure-text.py <프로젝트> # <text> 가 전부 이름인가
|
||||||
|
python3 scripts/preview-figure.py <프로젝트> -o /tmp/figs # PNG 로 떠서 눈으로 본다
|
||||||
|
```
|
||||||
@@ -160,6 +160,28 @@ SVG는 `image/svg+xml`로 올라가고 다른 이미지와 같게 다뤄진다.
|
|||||||
|
|
||||||
`READY`가 아닌 Asset은 게시 시 거절된다.
|
`READY`가 아닌 Asset은 게시 시 거절된다.
|
||||||
|
|
||||||
|
### 저장소의 `.md` 에는 `:::evidence` 를 쓰지 않는다
|
||||||
|
|
||||||
|
`:::evidence`는 Studio 렌더러의 구문이다. 저장소의 `.md`를 그 형태로 쓰면 편집기에서 그림이
|
||||||
|
보이지 않고 구문이 글자로 남는다. **저장소는 읽는 형태로 쓴다.**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
`alt`는 그림의 `alt`를 그대로 쓰고, 경로는 frontmatter `assets`의 `file`과 같아야 한다.
|
||||||
|
|
||||||
|
Studio 로 보낼 때만 `:::evidence` 로 바꾼다. 손으로 고치지 않는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/studio-body.py <기록.md> -o /tmp/x.md # check_body 는 이 파일에 돌린다
|
||||||
|
python3 scripts/studio-body.py <기록.md> --body-only # Studio 에 붙여넣을 본문
|
||||||
|
python3 scripts/studio-body.py <기록.md> --key a=a-1a2b3c4d # 서버가 준 키로
|
||||||
|
```
|
||||||
|
|
||||||
|
Studio 파서는 상대 경로 이미지를 `unsafe image URL`로 거절하므로 `check_body.mjs`는 **바꾼
|
||||||
|
파일**에 돌린다. 저장소 파일에 그대로 돌리면 그림 자리에서 실패한다.
|
||||||
|
|
||||||
## 일반 이미지
|
## 일반 이미지
|
||||||
|
|
||||||
Asset이 아닌 그림은 Markdown으로 쓴다.
|
Asset이 아닌 그림은 Markdown으로 쓴다.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ topicName: OAuth/OIDC 인증 경계
|
|||||||
```yaml
|
```yaml
|
||||||
assets:
|
assets:
|
||||||
- key: eager-lazy-query-sequence # 본문의 :::evidence key 와 같은 값
|
- key: eager-lazy-query-sequence # 본문의 :::evidence key 와 같은 값
|
||||||
file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg
|
file: ../../../final/assets/diagrams/eager-lazy-query-sequence/eager-lazy-query-sequence.svg
|
||||||
evidence:
|
evidence:
|
||||||
- ../../../final/evidence/explain/highlights-child-plan-A.txt
|
- ../../../final/evidence/explain/highlights-child-plan-A.txt
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
- [ ] 그림이 있는 문단에 글을 섞지 않았다
|
- [ ] 그림이 있는 문단에 글을 섞지 않았다
|
||||||
- [ ] `alt`가 무엇이 보이는지 말한다
|
- [ ] `alt`가 무엇이 보이는지 말한다
|
||||||
- [ ] 그림 안 `<text>`가 전부 이름이다. 문장도 숫자도 없다 (`<title>`·`<desc>`는 예외)
|
- [ ] 그림 안 `<text>`가 전부 이름이다. 문장도 숫자도 없다 (`<title>`·`<desc>`는 예외)
|
||||||
|
- [ ] 눈으로만 보지 않고 `python3 scripts/check-figure-text.py <프로젝트>` 를 돌렸다
|
||||||
|
- [ ] 관계선을 다 지워도 뜻이 남는 그림이 아니다 (남으면 표다 — 마크다운 표로 쓴다)
|
||||||
|
- [ ] `python3 scripts/preview-figure.py`로 PNG 를 떠서 눈으로 봤다 (lint 는 라벨이 상자를 덮는 것을 못 잡는다)
|
||||||
|
- [ ] 본문의 그림이 마크다운 이미지다 (`:::evidence` 는 Studio 로 보낼 때 `studio-body.py` 가 만든다)
|
||||||
- [ ] 코드에 자격증명·토큰·내부 호스트가 없다. 지운 자리가 보인다
|
- [ ] 코드에 자격증명·토큰·내부 호스트가 없다. 지운 자리가 보인다
|
||||||
- [ ] 제목 id와 표 id가 겹치지 않는다
|
- [ ] 제목 id와 표 id가 겹치지 않는다
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,31 @@ analysis material in one file. Only the first is candidate material.
|
|||||||
`excluded` names the parts that are evidence rather than candidates. A node may cite an
|
`excluded` names the parts that are evidence rather than candidates. A node may cite an
|
||||||
anchor from an excluded part in `source`; it may not exist because of one.
|
anchor from an excluded part in `source`; it may not exist because of one.
|
||||||
|
|
||||||
|
`excludedAnchorPattern` is optional and is the only field the verifier can act on. Without it
|
||||||
|
the rule above is a sentence nobody enforces — the check that a node did not come *only* from
|
||||||
|
outside the scope is switched off entirely.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"candidateScope": {
|
||||||
|
"document": "final/document.md",
|
||||||
|
"sections": ["§3", "§4"],
|
||||||
|
"excluded": ["제2부 — 모듈 분석 전문"],
|
||||||
|
"excludedAnchorPattern": "#a[0-9]+$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a regular expression matched against each `source` anchor. A node whose anchors *all*
|
||||||
|
match it is an error: it was promoted from evidence, not from the candidate scope.
|
||||||
|
|
||||||
|
## Anchors resolve to real sections
|
||||||
|
|
||||||
|
`source` and `sourceRefs` anchors are checked against the SSOT's own headings when the project
|
||||||
|
writes them as heading slugs (`#검토한-선택지와-막힌-지점-ap1` = the h2 slug plus a
|
||||||
|
discriminator). **Keep one anchor style per project.** A project that numbers its anchors
|
||||||
|
(`#§1.1`, `#10-2`, `#a18`) gets a warning instead — the verifier cannot tell whether the
|
||||||
|
section it names exists, and the diagram stage cannot translate the anchor into a
|
||||||
|
`techviz prepare --heading` value without a person reading it.
|
||||||
|
|
||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
Each Topic has `topic` (its key), `title`, **`readerQuestion`**, and `kinds` with the five
|
Each Topic has `topic` (its key), `title`, **`readerQuestion`**, and `kinds` with the five
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
|
|||||||
|
|
||||||
## 본문이 있는 두 종류의 공통 규칙
|
## 본문이 있는 두 종류의 공통 규칙
|
||||||
|
|
||||||
|
- **그림이 필요한지와 무엇을 그릴지는 `choosing-a-diagram.md` 가 정한다** — 종류마다 그림이
|
||||||
|
답해야 할 물음이 다르다. Case 는 순서, Concept 은 구조나 변환 사슬이 대개 논지다
|
||||||
- 그림은 `technical-visualizer` 로 만든다. 손으로 SVG 를 그리지 않는다
|
- 그림은 `technical-visualizer` 로 만든다. 손으로 SVG 를 그리지 않는다
|
||||||
- 그림 안에는 이름만 넣는다. 문장은 `<desc>` 와 옆 문단에 둔다
|
- 그림 안에는 이름만 넣는다. 문장은 `<desc>` 와 옆 문단에 둔다
|
||||||
- 그림과 증거는 frontmatter 의 `assets` · `evidence` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
|
- 그림과 증거는 frontmatter 의 `assets` · `evidence` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/publishing-tech-log-to-studio
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/running-tech-log-pipeline
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 563ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[ 240ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
|
[ 5732ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/assets?limit=60:0
|
||||||
|
[ 11698ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/assets?limit=60:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 179760ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/versions:0
|
||||||
|
[ 179790ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/revisions:0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 26888ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/documents/spa-browser-credential-boundary:0
|
||||||
|
[ 26915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/records/spa-browser-credential-boundary:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 275ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 349ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[ 27961ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/versions:0
|
||||||
|
[ 27984ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/history:0
|
||||||
|
[ 28008ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/previews:0
|
||||||
|
[ 28031ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/validations:0
|
||||||
|
[ 46083ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/cases/identity-header-trust:0
|
||||||
|
[ 46111ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/documents/identity-header-trust:0
|
||||||
|
[ 216423ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/revisions:0
|
||||||
|
[ 216453ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/versions/41:0
|
||||||
|
[ 216482ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/snapshots:0
|
||||||
|
[ 216563ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/publication:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 15511ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/references/036563a1-3a43-4931-a017-0e693b591e89:0
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[ 369ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
|
[ 70884ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/studio/topics:0
|
||||||
|
[ 70915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/studio/projects:0
|
||||||
|
[ 70946ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/topics:0
|
||||||
|
[ 70978ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/projects:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 677ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 36ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/studio/topics:0
|
||||||
|
[ 78ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 389876ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ https://hyeonworks.com/api/v1/studio/assets:0
|
||||||
|
[ 439129ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ https://hyeonworks.com/api/v1/studio/assets:0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[ 815920ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=PROJECT&limit=100:0
|
||||||
|
[ 815921ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=TOPIC&limit=100:0
|
||||||
|
[ 815970ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=EVIDENCE&limit=100:0
|
||||||
|
[ 815971ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=100:0
|
||||||
|
[ 853400ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=200:0
|
||||||
|
[ 853424ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=500:0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[ 27295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
|
[ 1459990ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/login?error:0
|
||||||
|
[ 1460025ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- generic [ref=e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- main [ref=e5]:
|
||||||
|
- generic [ref=e7]:
|
||||||
|
- generic [ref=e8]:
|
||||||
|
- paragraph [ref=e9]: STUDIO
|
||||||
|
- heading "세션을 복구하고 있습니다." [level=1] [ref=e10]
|
||||||
|
- paragraph [ref=e11]: 이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- button "세션 복구" [ref=e13] [cursor=pointer]
|
||||||
|
- paragraph [ref=e14]: 로그인하면 /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit 로 돌아옵니다.
|
||||||
|
- paragraph [ref=e15]
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
- generic [ref=f2e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f2e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f2e5]:
|
||||||
|
- generic [ref=f2e6]:
|
||||||
|
- link "TechLog Studio" [ref=f2e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f2e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f2e10]:
|
||||||
|
- link "작업본" [ref=f2e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f2e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f2e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f2e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f2e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f2e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f2e17]
|
||||||
|
- main [ref=f2e18]:
|
||||||
|
- generic [ref=f2e19]:
|
||||||
|
- generic [ref=f2e20]:
|
||||||
|
- generic [ref=f2e21]:
|
||||||
|
- paragraph [ref=f2e22]: WORKSPACE
|
||||||
|
- heading "작업 흐름" [level=1] [ref=f2e23]
|
||||||
|
- paragraph [ref=f2e24]: 작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.
|
||||||
|
- link "새 문서" [ref=f2e25] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- region "Studio 요약" [ref=f2e26]:
|
||||||
|
- generic [ref=f2e27]:
|
||||||
|
- generic [ref=f2e28]: 전체 작업본
|
||||||
|
- strong [ref=f2e29]: "47"
|
||||||
|
- generic [ref=f2e30]:
|
||||||
|
- generic [ref=f2e31]: 검증할 기록
|
||||||
|
- strong [ref=f2e32]: "5"
|
||||||
|
- generic [ref=f2e33]:
|
||||||
|
- generic [ref=f2e34]: 게시 준비
|
||||||
|
- strong [ref=f2e35]: "0"
|
||||||
|
- generic [ref=f2e36]:
|
||||||
|
- generic [ref=f2e37]: 게시 기록
|
||||||
|
- strong [ref=f2e38]: "21"
|
||||||
|
- generic [ref=f2e39]:
|
||||||
|
- generic [ref=f2e40]:
|
||||||
|
- heading "이어서 작성" [level=2] [ref=f2e41]
|
||||||
|
- link "전체 보기" [ref=f2e42] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- paragraph [ref=f2e44]: 이어서 작성할 문서가 없습니다.
|
||||||
|
- generic [ref=f2e45]:
|
||||||
|
- generic [ref=f2e46]:
|
||||||
|
- heading "검증과 미리보기" [level=2] [ref=f2e47]
|
||||||
|
- link "전체 보기" [ref=f2e48] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- generic [ref=f2e49]:
|
||||||
|
- article [ref=f2e50]:
|
||||||
|
- paragraph [ref=f2e51]: 검증 기록
|
||||||
|
- generic [ref=f2e52]:
|
||||||
|
- heading [level=3] [ref=f2e53]:
|
||||||
|
- link "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [ref=f2e54] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
|
||||||
|
- paragraph [ref=f2e55]: KeyCloak Patterns · 검증하기
|
||||||
|
- time [ref=f2e56]: 2026. 9. 7.
|
||||||
|
- article [ref=f2e57]:
|
||||||
|
- paragraph [ref=f2e58]: 열린 질문
|
||||||
|
- generic [ref=f2e59]:
|
||||||
|
- heading [level=3] [ref=f2e60]:
|
||||||
|
- link "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" [ref=f2e61] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
|
||||||
|
- paragraph [ref=f2e62]: Liner N + 1문제 · 검증하기
|
||||||
|
- time [ref=f2e63]: 2026. 9. 4.
|
||||||
|
- article [ref=f2e64]:
|
||||||
|
- paragraph [ref=f2e65]: 열린 질문
|
||||||
|
- generic [ref=f2e66]:
|
||||||
|
- heading [level=3] [ref=f2e67]:
|
||||||
|
- link "Round Trip과 Row Volume을 독립 측정할 것인가" [ref=f2e68] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/5159c415-232d-424a-970a-b0db52746767/edit
|
||||||
|
- paragraph [ref=f2e69]: Liner N + 1문제 · 검증하기
|
||||||
|
- time [ref=f2e70]: 2026. 9. 4.
|
||||||
|
- article [ref=f2e71]:
|
||||||
|
- paragraph [ref=f2e72]: 검증 기록
|
||||||
|
- generic [ref=f2e73]:
|
||||||
|
- heading [level=3] [ref=f2e74]:
|
||||||
|
- link "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f2e75] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit
|
||||||
|
- paragraph [ref=f2e76]: Liner N + 1문제 · 검증하기
|
||||||
|
- time [ref=f2e77]: 2026. 9. 4.
|
||||||
|
- article [ref=f2e78]:
|
||||||
|
- paragraph [ref=f2e79]: 적용 기준
|
||||||
|
- generic [ref=f2e80]:
|
||||||
|
- heading [level=3] [ref=f2e81]:
|
||||||
|
- link "JPA N+1 정량 진단 기준" [ref=f2e82] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit
|
||||||
|
- paragraph [ref=f2e83]: Liner N + 1문제 · 검증하기
|
||||||
|
- time [ref=f2e84]: 2026. 9. 4.
|
||||||
|
- generic [ref=f2e85]:
|
||||||
|
- generic [ref=f2e86]:
|
||||||
|
- heading "게시 준비" [level=2] [ref=f2e87]
|
||||||
|
- link "전체 보기" [ref=f2e88] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- paragraph [ref=f2e90]: 게시 준비가 끝난 문서가 없습니다.
|
||||||
|
- region [ref=f2e91]:
|
||||||
|
- generic [ref=f2e92]:
|
||||||
|
- paragraph [ref=f2e93]: PUBLIC HOME
|
||||||
|
- heading "지금 집중하는 것" [level=2] [ref=f2e94]
|
||||||
|
- paragraph [ref=f2e95]: 공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
|
||||||
|
- generic [ref=f2e96]:
|
||||||
|
- generic [ref=f2e97]:
|
||||||
|
- generic [ref=f2e98]:
|
||||||
|
- generic [ref=f2e99]: 현재 작업 (프로젝트)
|
||||||
|
- combobox "현재 작업 (프로젝트) 단계·현재 목표·다음 작업이 카드에 함께 나옵니다." [ref=f2e100]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "Liner N + 1문제"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns" [selected]
|
||||||
|
- generic [ref=f2e101]: 단계·현재 목표·다음 작업이 카드에 함께 나옵니다.
|
||||||
|
- generic [ref=f2e102]:
|
||||||
|
- generic [ref=f2e103]: 열린 질문
|
||||||
|
- combobox "열린 질문" [ref=f2e104]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected]
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- generic [ref=f2e105]:
|
||||||
|
- generic [ref=f2e106]: 최근 결정
|
||||||
|
- combobox "최근 결정" [ref=f2e107]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다. 브라우저에는 애플리케이션 session만 제공한다." [selected]
|
||||||
|
- option "외부 IdP 연동은 별도의 인증 구조가 아니다. Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수있는 토큰을 발급한다. SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다."
|
||||||
|
- generic [ref=f2e108]:
|
||||||
|
- button "홈 설정 저장" [ref=f2e109]
|
||||||
|
- paragraph [ref=f2e110]:
|
||||||
|
- text: 저장하면 공개 홈에 바로 반영됩니다.
|
||||||
|
- link "주제·프로젝트" [ref=f2e111] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- text: 에서 프로젝트를 만들고 게시할 수 있습니다.
|
||||||
|
- generic [ref=f2e112]:
|
||||||
|
- generic [ref=f2e113]:
|
||||||
|
- heading "최근 게시" [level=2] [ref=f2e114]
|
||||||
|
- link "게시 기록 보기" [ref=f2e115] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- generic [ref=f2e116]:
|
||||||
|
- article [ref=f2e117]:
|
||||||
|
- paragraph [ref=f2e118]: 게시
|
||||||
|
- generic [ref=f2e119]:
|
||||||
|
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=3] [ref=f2e120]
|
||||||
|
- paragraph [ref=f2e121]: Liner N + 1문제
|
||||||
|
- time [ref=f2e122]: 2026. 9. 1.
|
||||||
|
- article [ref=f2e123]:
|
||||||
|
- paragraph [ref=f2e124]: 게시
|
||||||
|
- generic [ref=f2e125]:
|
||||||
|
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=3] [ref=f2e126]
|
||||||
|
- paragraph [ref=f2e127]: Liner N + 1문제
|
||||||
|
- time [ref=f2e128]: 2026. 9. 1.
|
||||||
|
- article [ref=f2e129]:
|
||||||
|
- paragraph [ref=f2e130]: 게시
|
||||||
|
- generic [ref=f2e131]:
|
||||||
|
- heading "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [level=3] [ref=f2e132]
|
||||||
|
- paragraph [ref=f2e133]: Liner N + 1문제
|
||||||
|
- time [ref=f2e134]: 2026. 9. 1.
|
||||||
|
- article [ref=f2e135]:
|
||||||
|
- paragraph [ref=f2e136]: 게시
|
||||||
|
- generic [ref=f2e137]:
|
||||||
|
- heading "외부 IdP Brokering의 동작" [level=3] [ref=f2e138]
|
||||||
|
- paragraph [ref=f2e139]: KeyCloak Patterns
|
||||||
|
- time [ref=f2e140]: 2026. 9. 1.
|
||||||
|
- article [ref=f2e141]:
|
||||||
|
- paragraph [ref=f2e142]: 게시
|
||||||
|
- generic [ref=f2e143]:
|
||||||
|
- heading "BFF가 OAuth Token을 관리하는 조건" [level=3] [ref=f2e144]
|
||||||
|
- paragraph [ref=f2e145]: KeyCloak Patterns
|
||||||
|
- time [ref=f2e146]: 2026. 8. 31.
|
||||||
|
- paragraph [ref=f2e147]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
|
|||||||
|
- generic [ref=f3e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f3e5]:
|
||||||
|
- generic [ref=f3e6]:
|
||||||
|
- link "TechLog Studio" [ref=f3e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f3e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f3e10]:
|
||||||
|
- link "작업본" [ref=f3e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f3e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f3e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f3e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f3e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f3e17]
|
||||||
|
- main [ref=f3e18]:
|
||||||
|
- generic [ref=f3e19]:
|
||||||
|
- generic [ref=f3e20]:
|
||||||
|
- generic [ref=f3e21]:
|
||||||
|
- paragraph [ref=f3e22]: WORKSPACE
|
||||||
|
- heading "작업 흐름" [level=1] [ref=f3e23]
|
||||||
|
- paragraph [ref=f3e24]: 작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.
|
||||||
|
- link "새 문서" [ref=f3e25] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- region "Studio 요약" [ref=f3e26]:
|
||||||
|
- generic [ref=f3e27]:
|
||||||
|
- generic [ref=f3e28]: 전체 작업본
|
||||||
|
- strong [ref=f3e29]: "47"
|
||||||
|
- generic [ref=f3e30]:
|
||||||
|
- generic [ref=f3e31]: 검증할 기록
|
||||||
|
- strong [ref=f3e32]: "5"
|
||||||
|
- generic [ref=f3e33]:
|
||||||
|
- generic [ref=f3e34]: 게시 준비
|
||||||
|
- strong [ref=f3e35]: "0"
|
||||||
|
- generic [ref=f3e36]:
|
||||||
|
- generic [ref=f3e37]: 게시 기록
|
||||||
|
- strong [ref=f3e38]: "21"
|
||||||
|
- generic [ref=f3e39]:
|
||||||
|
- generic [ref=f3e40]:
|
||||||
|
- heading "이어서 작성" [level=2] [ref=f3e41]
|
||||||
|
- link "전체 보기" [ref=f3e42] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- paragraph [ref=f3e44]: 이어서 작성할 문서가 없습니다.
|
||||||
|
- generic [ref=f3e45]:
|
||||||
|
- generic [ref=f3e46]:
|
||||||
|
- heading "검증과 미리보기" [level=2] [ref=f3e47]
|
||||||
|
- link "전체 보기" [ref=f3e48] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- generic [ref=f3e49]:
|
||||||
|
- article [ref=f3e50]:
|
||||||
|
- paragraph [ref=f3e51]: 검증 기록
|
||||||
|
- generic [ref=f3e52]:
|
||||||
|
- heading [level=3] [ref=f3e53]:
|
||||||
|
- link "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" [ref=f3e54] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit
|
||||||
|
- paragraph [ref=f3e55]: KeyCloak Patterns · 검증하기
|
||||||
|
- time [ref=f3e56]: 2026. 9. 7.
|
||||||
|
- article [ref=f3e57]:
|
||||||
|
- paragraph [ref=f3e58]: 검증 기록
|
||||||
|
- generic [ref=f3e59]:
|
||||||
|
- heading [level=3] [ref=f3e60]:
|
||||||
|
- link "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f3e61] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit
|
||||||
|
- paragraph [ref=f3e62]: KeyCloak Patterns · 검증하기
|
||||||
|
- time [ref=f3e63]: 2026. 9. 7.
|
||||||
|
- article [ref=f3e64]:
|
||||||
|
- paragraph [ref=f3e65]: 검증 기록
|
||||||
|
- generic [ref=f3e66]:
|
||||||
|
- heading [level=3] [ref=f3e67]:
|
||||||
|
- link "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [ref=f3e68] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
|
||||||
|
- paragraph [ref=f3e69]: KeyCloak Patterns · 검증하기
|
||||||
|
- time [ref=f3e70]: 2026. 9. 7.
|
||||||
|
- article [ref=f3e71]:
|
||||||
|
- paragraph [ref=f3e72]: 검증 기록
|
||||||
|
- generic [ref=f3e73]:
|
||||||
|
- heading [level=3] [ref=f3e74]:
|
||||||
|
- link "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [ref=f3e75] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit
|
||||||
|
- paragraph [ref=f3e76]: KeyCloak Patterns · 검증하기
|
||||||
|
- time [ref=f3e77]: 2026. 9. 7.
|
||||||
|
- article [ref=f3e78]:
|
||||||
|
- paragraph [ref=f3e79]: 열린 질문
|
||||||
|
- generic [ref=f3e80]:
|
||||||
|
- heading [level=3] [ref=f3e81]:
|
||||||
|
- link "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" [ref=f3e82] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
|
||||||
|
- paragraph [ref=f3e83]: Liner N + 1문제 · 검증하기
|
||||||
|
- time [ref=f3e84]: 2026. 9. 4.
|
||||||
|
- generic [ref=f3e85]:
|
||||||
|
- generic [ref=f3e86]:
|
||||||
|
- heading "게시 준비" [level=2] [ref=f3e87]
|
||||||
|
- link "전체 보기" [ref=f3e88] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- paragraph [ref=f3e90]: 게시 준비가 끝난 문서가 없습니다.
|
||||||
|
- region [ref=f3e91]:
|
||||||
|
- generic [ref=f3e92]:
|
||||||
|
- paragraph [ref=f3e93]: PUBLIC HOME
|
||||||
|
- heading "지금 집중하는 것" [level=2] [ref=f3e94]
|
||||||
|
- paragraph [ref=f3e95]: 공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
|
||||||
|
- generic [ref=f3e96]:
|
||||||
|
- generic [ref=f3e97]:
|
||||||
|
- generic [ref=f3e98]:
|
||||||
|
- generic [ref=f3e99]: 현재 작업 (프로젝트)
|
||||||
|
- combobox "현재 작업 (프로젝트) 단계·현재 목표·다음 작업이 카드에 함께 나옵니다." [ref=f3e100]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "Liner N + 1문제"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns" [selected]
|
||||||
|
- generic [ref=f3e101]: 단계·현재 목표·다음 작업이 카드에 함께 나옵니다.
|
||||||
|
- generic [ref=f3e102]:
|
||||||
|
- generic [ref=f3e103]: 열린 질문
|
||||||
|
- combobox "열린 질문" [ref=f3e104]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected]
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- generic [ref=f3e105]:
|
||||||
|
- generic [ref=f3e106]: 최근 결정
|
||||||
|
- combobox "최근 결정" [ref=f3e107]:
|
||||||
|
- option "고르지 않음"
|
||||||
|
- option "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다. 브라우저에는 애플리케이션 session만 제공한다." [selected]
|
||||||
|
- option "외부 IdP 연동은 별도의 인증 구조가 아니다. Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수있는 토큰을 발급한다. SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다."
|
||||||
|
- generic [ref=f3e108]:
|
||||||
|
- button "홈 설정 저장" [ref=f3e109]
|
||||||
|
- paragraph [ref=f3e110]:
|
||||||
|
- text: 저장하면 공개 홈에 바로 반영됩니다.
|
||||||
|
- link "주제·프로젝트" [ref=f3e111] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- text: 에서 프로젝트를 만들고 게시할 수 있습니다.
|
||||||
|
- generic [ref=f3e112]:
|
||||||
|
- generic [ref=f3e113]:
|
||||||
|
- heading "최근 게시" [level=2] [ref=f3e114]
|
||||||
|
- link "게시 기록 보기" [ref=f3e115] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- generic [ref=f3e116]:
|
||||||
|
- article [ref=f3e117]:
|
||||||
|
- paragraph [ref=f3e118]: 게시
|
||||||
|
- generic [ref=f3e119]:
|
||||||
|
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=3] [ref=f3e120]
|
||||||
|
- paragraph [ref=f3e121]: Liner N + 1문제
|
||||||
|
- time [ref=f3e122]: 2026. 9. 1.
|
||||||
|
- article [ref=f3e123]:
|
||||||
|
- paragraph [ref=f3e124]: 게시
|
||||||
|
- generic [ref=f3e125]:
|
||||||
|
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=3] [ref=f3e126]
|
||||||
|
- paragraph [ref=f3e127]: Liner N + 1문제
|
||||||
|
- time [ref=f3e128]: 2026. 9. 1.
|
||||||
|
- article [ref=f3e129]:
|
||||||
|
- paragraph [ref=f3e130]: 게시
|
||||||
|
- generic [ref=f3e131]:
|
||||||
|
- heading "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [level=3] [ref=f3e132]
|
||||||
|
- paragraph [ref=f3e133]: Liner N + 1문제
|
||||||
|
- time [ref=f3e134]: 2026. 9. 1.
|
||||||
|
- article [ref=f3e135]:
|
||||||
|
- paragraph [ref=f3e136]: 게시
|
||||||
|
- generic [ref=f3e137]:
|
||||||
|
- heading "외부 IdP Brokering의 동작" [level=3] [ref=f3e138]
|
||||||
|
- paragraph [ref=f3e139]: KeyCloak Patterns
|
||||||
|
- time [ref=f3e140]: 2026. 9. 1.
|
||||||
|
- article [ref=f3e141]:
|
||||||
|
- paragraph [ref=f3e142]: 게시
|
||||||
|
- generic [ref=f3e143]:
|
||||||
|
- heading "BFF가 OAuth Token을 관리하는 조건" [level=3] [ref=f3e144]
|
||||||
|
- paragraph [ref=f3e145]: KeyCloak Patterns
|
||||||
|
- time [ref=f3e146]: 2026. 8. 31.
|
||||||
|
- paragraph [ref=f3e147]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,616 @@
|
|||||||
|
- generic [ref=f9e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f9e5]:
|
||||||
|
- generic [ref=f9e6]:
|
||||||
|
- link "TechLog Studio" [ref=f9e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f9e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f9e10]:
|
||||||
|
- link "작업본" [ref=f9e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f9e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f9e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f9e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f9e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f9e17]
|
||||||
|
- main [ref=f9e18]:
|
||||||
|
- generic [ref=f9e19]:
|
||||||
|
- generic [ref=f9e20]:
|
||||||
|
- region [ref=f9e21]:
|
||||||
|
- generic [ref=f9e22]:
|
||||||
|
- paragraph [ref=f9e23]: CONCEPT · VERSION 5
|
||||||
|
- heading "문서 편집" [level=1] [ref=f9e24]
|
||||||
|
- paragraph [ref=f9e25]: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||||
|
- region [ref=f9e26]:
|
||||||
|
- generic [ref=f9e27]:
|
||||||
|
- paragraph [ref=f9e28]: DOCUMENT
|
||||||
|
- heading "기본 정보" [level=2] [ref=f9e29]
|
||||||
|
- generic [ref=f9e30]:
|
||||||
|
- generic [ref=f9e31]:
|
||||||
|
- generic [ref=f9e32]: 제목
|
||||||
|
- textbox "제목" [ref=f9e33]: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||||
|
- generic [ref=f9e34]:
|
||||||
|
- generic [ref=f9e35]: slug
|
||||||
|
- textbox "slug" [ref=f9e36]:
|
||||||
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||||||
|
- text: cookie-auth-csrf
|
||||||
|
- generic [ref=f9e37]:
|
||||||
|
- generic [ref=f9e38]: 요약
|
||||||
|
- textbox "요약" [ref=f9e39]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e40]: 목록 카드에는 약 90자까지 보입니다 · 143 / 2000
|
||||||
|
- generic [ref=f9e41]:
|
||||||
|
- generic [ref=f9e42]: Topic
|
||||||
|
- combobox "Topic" [ref=f9e43]:
|
||||||
|
- option "선택하지 않음"
|
||||||
|
- option "JPA 피드 조회 성능"
|
||||||
|
- option "OAuth/OIDC 인증 경계" [selected]
|
||||||
|
- generic [ref=f9e44]:
|
||||||
|
- generic [ref=f9e45]: Project
|
||||||
|
- combobox "Project" [ref=f9e46]:
|
||||||
|
- option "미지정"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns" [selected]
|
||||||
|
- option "Liner N + 1문제"
|
||||||
|
- status [ref=f9e47]
|
||||||
|
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f9e48]:
|
||||||
|
- generic [ref=f9e50] [cursor=pointer]:
|
||||||
|
- checkbox "SPA" [ref=f9e51]
|
||||||
|
- generic [ref=f9e52]: SPA
|
||||||
|
- generic [ref=f9e53] [cursor=pointer]:
|
||||||
|
- checkbox "Mediator" [ref=f9e54]
|
||||||
|
- generic [ref=f9e55]: Mediator
|
||||||
|
- generic [ref=f9e56] [cursor=pointer]:
|
||||||
|
- checkbox "BFF" [ref=f9e57]
|
||||||
|
- generic [ref=f9e58]: BFF
|
||||||
|
- generic [ref=f9e59] [cursor=pointer]:
|
||||||
|
- checkbox "Forward-Auth" [ref=f9e60]
|
||||||
|
- generic [ref=f9e61]: Forward-Auth
|
||||||
|
- group "관계" [ref=f9e62]:
|
||||||
|
- generic [ref=f9e64]:
|
||||||
|
- generic [ref=f9e65]:
|
||||||
|
- generic [ref=f9e66]: 관계 1 대상
|
||||||
|
- combobox "관계 1 대상" [ref=f9e67]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled]
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준" [selected]
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준"
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f9e68]:
|
||||||
|
- generic [ref=f9e69]: 관계 1 이유
|
||||||
|
- textbox "관계 1 이유" [ref=f9e70]: 이 확인이 필요한 구조의 설계 항목이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f9e72]:
|
||||||
|
- button "위로" [disabled] [ref=f9e73]
|
||||||
|
- button "아래로" [ref=f9e74]
|
||||||
|
- button "삭제" [ref=f9e75]
|
||||||
|
- generic [ref=f9e76]:
|
||||||
|
- generic [ref=f9e77]:
|
||||||
|
- generic [ref=f9e78]: 관계 2 대상
|
||||||
|
- combobox "관계 2 대상" [ref=f9e79]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [selected]
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준" [disabled]
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준"
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f9e80]:
|
||||||
|
- generic [ref=f9e81]: 관계 2 이유
|
||||||
|
- textbox "관계 2 이유" [ref=f9e82]: 이 동작을 실제로 재현한 기록이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f9e84]:
|
||||||
|
- button "위로" [ref=f9e85]
|
||||||
|
- button "아래로" [ref=f9e86]
|
||||||
|
- button "삭제" [ref=f9e87]
|
||||||
|
- generic [ref=f9e88]:
|
||||||
|
- generic [ref=f9e89]:
|
||||||
|
- generic [ref=f9e90]: 관계 3 대상
|
||||||
|
- combobox "관계 3 대상" [ref=f9e91]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled]
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준" [disabled]
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준"
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f9e92]:
|
||||||
|
- generic [ref=f9e93]: 관계 3 이유
|
||||||
|
- textbox "관계 3 이유" [ref=f9e94]: session cookie와 CSRF token은 서로 다른 값이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f9e96]:
|
||||||
|
- button "위로" [ref=f9e97]
|
||||||
|
- button "아래로" [disabled] [ref=f9e98]
|
||||||
|
- button "삭제" [ref=f9e99]
|
||||||
|
- button "관계 추가" [ref=f9e100]
|
||||||
|
- region [ref=f9e101]:
|
||||||
|
- generic [ref=f9e102]:
|
||||||
|
- paragraph [ref=f9e103]: CONCEPT
|
||||||
|
- heading "개념" [level=2] [ref=f9e104]
|
||||||
|
- generic [ref=f9e105]:
|
||||||
|
- generic [ref=f9e106]:
|
||||||
|
- generic [ref=f9e107]: 기준 버전
|
||||||
|
- textbox "기준 버전 “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다." [ref=f9e108]: Spring Security 6 CSRF · AP3 BFF 구성
|
||||||
|
- generic [ref=f9e109]: “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다.
|
||||||
|
- generic [ref=f9e110]:
|
||||||
|
- generic [ref=f9e111]: 본문 Markdown
|
||||||
|
- group "Markdown 삽입" [ref=f9e112]:
|
||||||
|
- button "코드" [ref=f9e113] [cursor=pointer]
|
||||||
|
- button "표" [ref=f9e114] [cursor=pointer]
|
||||||
|
- button "목록" [ref=f9e115] [cursor=pointer]
|
||||||
|
- textbox "본문 Markdown" [ref=f9e116]: "## cookie가 credential이 되면 생기는 일 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다. 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다. ## token을 받아 오는 요청 브라우저가 먼저 CSRF material을 요청한다. ```http label=\"CSRF token 요청\" GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION=<opaque-session-id> ``` `CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다. ```json label=\"CsrfController가 반환하는 JSON\" { \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"<xor-masked-csrf-token>\" } ``` :::evidence key=\"ap3-csrf-boundary-971df81c\" alt=\"BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.\" caption=\" \" zoom=\"true\" ::: ## body의 token과 cookie의 값은 다르다 여기가 이 구조에서 가장 헷갈리는 지점이다. | 위치 | 값 | |---|---| | 응답 body의 `token` | XOR와 Base64로 mask된 값 | | `XSRF-TOKEN` cookie | raw 값 | | POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 | `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다. `SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다. 응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다. ## 검증이 controller보다 먼저 일어난다 정상 상태 변경 요청은 다음과 같다. ```http label=\"CSRF 검증을 통과하는 POST\" POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token> X-XSRF-TOKEN: <same-raw-csrf-token> theme=dark ``` Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다. ## SameSite와 CSRF token은 역할이 다르다 | | SameSite | CSRF token | |---|---|---| | 누가 판단하나 | 브라우저 | 서버 | | 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 | | 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 | 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다. 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다. | 입력 | cookie 동작 | CSRF 동작 | 결과 | |---|---|---|---| | same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 | | same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 | | 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 | | cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 | 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다. ## CSRF가 XSS를 대신하지 않는다 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다. 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다."
|
||||||
|
- generic [ref=f9e117]: “##” 소제목이 목차가 됩니다. 아키텍처 도식은 아래에서 삽입하세요.
|
||||||
|
- group [ref=f9e118]:
|
||||||
|
- paragraph [ref=f9e119]: EVIDENCE
|
||||||
|
- heading "본문에 Asset 삽입" [level=3] [ref=f9e120]
|
||||||
|
- paragraph [ref=f9e121]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||||||
|
- generic [ref=f9e122]:
|
||||||
|
- generic [ref=f9e123]:
|
||||||
|
- generic [ref=f9e124]: 업로드 종류
|
||||||
|
- combobox "업로드 종류" [ref=f9e125]:
|
||||||
|
- option "이미지" [selected]
|
||||||
|
- option "다이어그램"
|
||||||
|
- option "첨부파일"
|
||||||
|
- button "Asset 업로드" [ref=f9e126]
|
||||||
|
- generic [ref=f9e127]:
|
||||||
|
- search [ref=f9e128]:
|
||||||
|
- generic [ref=f9e129]: Asset 검색
|
||||||
|
- generic [ref=f9e130]:
|
||||||
|
- searchbox "Asset 검색" [ref=f9e131]
|
||||||
|
- button "검색" [ref=f9e132]
|
||||||
|
- generic [ref=f9e133]:
|
||||||
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f9e134]
|
||||||
|
- generic [ref=f9e135]: 삽입할 때 크게 보기 허용
|
||||||
|
- status [ref=f9e136]: 삽입할 수 있는 Asset 24개
|
||||||
|
- list [ref=f9e137]:
|
||||||
|
- listitem [ref=f9e138]:
|
||||||
|
- button "ap4-edge-trust-architecture-1a916e10" [ref=f9e139]
|
||||||
|
- button "삭제" [ref=f9e140]
|
||||||
|
- listitem [ref=f9e141]:
|
||||||
|
- button "ap3-bff-session-flow-1b005e15" [ref=f9e142]
|
||||||
|
- button "삭제" [ref=f9e143]
|
||||||
|
- listitem [ref=f9e144]:
|
||||||
|
- button "ap3-bff-architecture-a27ea91c" [ref=f9e145]
|
||||||
|
- button "삭제" [ref=f9e146]
|
||||||
|
- listitem [ref=f9e147]:
|
||||||
|
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f9e148]
|
||||||
|
- button "삭제" [ref=f9e149]
|
||||||
|
- listitem [ref=f9e150]:
|
||||||
|
- button "ap2-mediator-architecture-c95ed25f" [ref=f9e151]
|
||||||
|
- button "삭제" [ref=f9e152]
|
||||||
|
- listitem [ref=f9e153]:
|
||||||
|
- button "projection-row-over-fetch-f2b1943b" [ref=f9e154]
|
||||||
|
- button "삭제" [ref=f9e155]
|
||||||
|
- listitem [ref=f9e156]:
|
||||||
|
- button "cartesian-row-multiplication-dce2e166" [ref=f9e157]
|
||||||
|
- button "삭제" [ref=f9e158]
|
||||||
|
- listitem [ref=f9e159]:
|
||||||
|
- button "eager-lazy-query-sequence-47c12bda" [ref=f9e160]
|
||||||
|
- button "삭제" [ref=f9e161]
|
||||||
|
- listitem [ref=f9e162]:
|
||||||
|
- button "ap3-bff-session-flow-a8dfff6f" [ref=f9e163]
|
||||||
|
- button "삭제" [ref=f9e164]
|
||||||
|
- listitem [ref=f9e165]:
|
||||||
|
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f9e166]
|
||||||
|
- button "삭제" [ref=f9e167]
|
||||||
|
- listitem [ref=f9e168]:
|
||||||
|
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f9e169]
|
||||||
|
- button "삭제" [ref=f9e170]
|
||||||
|
- listitem [ref=f9e171]:
|
||||||
|
- button "ap3-csrf-boundary-971df81c" [ref=f9e172]
|
||||||
|
- button "삭제" [ref=f9e173]
|
||||||
|
- listitem [ref=f9e174]:
|
||||||
|
- button "login-api-phase-split-3e354274" [ref=f9e175]
|
||||||
|
- button "삭제" [ref=f9e176]
|
||||||
|
- listitem [ref=f9e177]:
|
||||||
|
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f9e178]
|
||||||
|
- button "삭제" [ref=f9e179]
|
||||||
|
- listitem [ref=f9e180]:
|
||||||
|
- button "ap1-direct-architecture-0adf4199" [ref=f9e181]
|
||||||
|
- button "삭제" [ref=f9e182]
|
||||||
|
- listitem [ref=f9e183]:
|
||||||
|
- button "nplus1-query-fanout-644febe6" [ref=f9e184]
|
||||||
|
- button "삭제" [ref=f9e185]
|
||||||
|
- listitem [ref=f9e186]:
|
||||||
|
- button "ap4-edge-trust-1cff2399" [ref=f9e187]
|
||||||
|
- button "삭제" [ref=f9e188]
|
||||||
|
- listitem [ref=f9e189]:
|
||||||
|
- button "ap3-csrf-split-501dd1f7" [ref=f9e190]
|
||||||
|
- button "삭제" [ref=f9e191]
|
||||||
|
- listitem [ref=f9e192]:
|
||||||
|
- button "ap3-bff-custody-82fa18bd" [ref=f9e193]
|
||||||
|
- button "삭제" [ref=f9e194]
|
||||||
|
- listitem [ref=f9e195]:
|
||||||
|
- button "ap2-split-custody-779cb791" [ref=f9e196]
|
||||||
|
- button "삭제" [ref=f9e197]
|
||||||
|
- listitem [ref=f9e198]:
|
||||||
|
- button "ap1-custody-v3-6e0376d2" [ref=f9e199]
|
||||||
|
- button "삭제" [ref=f9e200]
|
||||||
|
- listitem [ref=f9e201]:
|
||||||
|
- button "ap1-custody-v2-e110bd98" [ref=f9e202]
|
||||||
|
- button "삭제" [ref=f9e203]
|
||||||
|
- listitem [ref=f9e204]:
|
||||||
|
- button "ap1-credential-custody-f5e0c027" [ref=f9e205]
|
||||||
|
- button "삭제" [ref=f9e206]
|
||||||
|
- listitem [ref=f9e207]:
|
||||||
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f9e208]
|
||||||
|
- button "삭제" [ref=f9e209]
|
||||||
|
- region [ref=f9e210]:
|
||||||
|
- generic [ref=f9e211]:
|
||||||
|
- paragraph [ref=f9e212]: LIVE
|
||||||
|
- heading "즉시 미리보기" [level=2] [ref=f9e213]
|
||||||
|
- generic [ref=f9e216]:
|
||||||
|
- generic [ref=f9e217]:
|
||||||
|
- navigation "문서 경로" [ref=f9e218]:
|
||||||
|
- link "동작 원리" [ref=f9e219] [cursor=pointer]:
|
||||||
|
- /url: /explore/concepts
|
||||||
|
- generic [aria-hidden] [ref=f9e220]: /
|
||||||
|
- generic [ref=f9e221]: OAuth/OIDC 인증 경계
|
||||||
|
- generic [aria-hidden] [ref=f9e222]: /
|
||||||
|
- link "KeyCloak Patterns" [ref=f9e223] [cursor=pointer]:
|
||||||
|
- /url: /projects/keycloak-patterns
|
||||||
|
- heading "Cookie로 인증하는 요청에서 CSRF token이 하는 일" [level=1] [ref=f9e224]
|
||||||
|
- paragraph [ref=f9e225]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
|
||||||
|
- generic [ref=f9e226]:
|
||||||
|
- generic [ref=f9e227]:
|
||||||
|
- term [ref=f9e228]: 기준
|
||||||
|
- definition [ref=f9e229]:
|
||||||
|
- paragraph [ref=f9e230]: Spring Security 6 CSRF · AP3 BFF 구성
|
||||||
|
- generic [ref=f9e231]:
|
||||||
|
- term [ref=f9e232]: 기록
|
||||||
|
- definition [ref=f9e233]: 게시 게시 전
|
||||||
|
- group [ref=f9e235]:
|
||||||
|
- generic "목차 · SameSite와 CSRF token은 역할이 다르다" [ref=f9e236] [cursor=pointer]
|
||||||
|
- article [ref=f9e238]:
|
||||||
|
- region [ref=f9e239]:
|
||||||
|
- heading [level=2] [ref=f9e240]:
|
||||||
|
- link "cookie가 credential이 되면 생기는 일 바로가기" [ref=f9e241] [cursor=pointer]:
|
||||||
|
- /url: "#cookie가-credential이-되면-생기는-일"
|
||||||
|
- text: cookie가 credential이 되면 생기는 일
|
||||||
|
- generic [aria-hidden] [ref=f9e242]: "#"
|
||||||
|
- paragraph [ref=f9e243]: 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
|
||||||
|
- paragraph [ref=f9e244]:
|
||||||
|
- text: 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다.
|
||||||
|
- code [ref=f9e245]: GET /bff/api/me
|
||||||
|
- text: 만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
|
||||||
|
- region [ref=f9e246]:
|
||||||
|
- heading [level=2] [ref=f9e247]:
|
||||||
|
- link "token을 받아 오는 요청 바로가기" [ref=f9e248] [cursor=pointer]:
|
||||||
|
- /url: "#token을-받아-오는-요청"
|
||||||
|
- text: token을 받아 오는 요청
|
||||||
|
- generic [aria-hidden] [ref=f9e249]: "#"
|
||||||
|
- paragraph [ref=f9e250]: 브라우저가 먼저 CSRF material을 요청한다.
|
||||||
|
- figure "HTTP ·CSRF token 요청 코드 복사" [ref=f9e251]:
|
||||||
|
- generic [ref=f9e252]:
|
||||||
|
- generic [ref=f9e253]: HTTP
|
||||||
|
- generic [ref=f9e254]: ·CSRF token 요청
|
||||||
|
- button "코드 복사" [ref=f9e255] [cursor=pointer]: 복사
|
||||||
|
- region "CSRF token 요청 코드" [ref=f9e256]:
|
||||||
|
- code [ref=f9e257]: "GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION=<opaque-session-id>"
|
||||||
|
- paragraph [ref=f9e259]:
|
||||||
|
- code [ref=f9e260]: CookieCsrfTokenRepository.withHttpOnlyFalse()
|
||||||
|
- text: 는 JavaScript가 읽을 수 있는
|
||||||
|
- code [ref=f9e261]: XSRF-TOKEN
|
||||||
|
- text: cookie를 path
|
||||||
|
- code [ref=f9e262]: /
|
||||||
|
- text: 에 만든다. controller는 다음 JSON을 반환한다.
|
||||||
|
- figure "JSON ·CsrfController가 반환하는 JSON 코드 복사" [ref=f9e263]:
|
||||||
|
- generic [ref=f9e264]:
|
||||||
|
- generic [ref=f9e265]: JSON
|
||||||
|
- generic [ref=f9e266]: ·CsrfController가 반환하는 JSON
|
||||||
|
- button "코드 복사" [ref=f9e267] [cursor=pointer]: 복사
|
||||||
|
- region "CsrfController가 반환하는 JSON 코드" [ref=f9e268]:
|
||||||
|
- code [ref=f9e269]: "{ \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"<xor-masked-csrf-token>\" }"
|
||||||
|
- figure [ref=f9e271]:
|
||||||
|
- button "ap3-csrf-boundary-971df81c 이미지 크게 보기" [ref=f9e272]:
|
||||||
|
- img "BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름." [ref=f9e273]
|
||||||
|
- generic [ref=f9e274]: 크게 보기
|
||||||
|
- generic [ref=f9e275]: BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.
|
||||||
|
- region [ref=f9e276]:
|
||||||
|
- heading [level=2] [ref=f9e277]:
|
||||||
|
- link "body의 token과 cookie의 값은 다르다 바로가기" [ref=f9e278] [cursor=pointer]:
|
||||||
|
- /url: "#body의-token과-cookie의-값은-다르다"
|
||||||
|
- text: body의 token과 cookie의 값은 다르다
|
||||||
|
- generic [aria-hidden] [ref=f9e279]: "#"
|
||||||
|
- paragraph [ref=f9e280]: 여기가 이 구조에서 가장 헷갈리는 지점이다.
|
||||||
|
- region "표" [ref=f9e281]:
|
||||||
|
- table [ref=f9e282]:
|
||||||
|
- caption [ref=f9e283]
|
||||||
|
- rowgroup [ref=f9e284]:
|
||||||
|
- row [ref=f9e285]:
|
||||||
|
- columnheader "위치" [ref=f9e286]
|
||||||
|
- columnheader "값" [ref=f9e287]
|
||||||
|
- rowgroup [ref=f9e288]:
|
||||||
|
- row [ref=f9e289]:
|
||||||
|
- cell [ref=f9e290]:
|
||||||
|
- text: 응답 body의
|
||||||
|
- code [ref=f9e291]: token
|
||||||
|
- cell "XOR와 Base64로 mask된 값" [ref=f9e292]
|
||||||
|
- row [ref=f9e293]:
|
||||||
|
- cell [ref=f9e294]:
|
||||||
|
- code [ref=f9e295]: XSRF-TOKEN
|
||||||
|
- text: cookie
|
||||||
|
- cell "raw 값" [ref=f9e296]
|
||||||
|
- row [ref=f9e297]:
|
||||||
|
- cell [ref=f9e298]:
|
||||||
|
- text: POST의
|
||||||
|
- code [ref=f9e299]: X-XSRF-TOKEN
|
||||||
|
- text: 헤더
|
||||||
|
- cell "cookie와 같은 raw 값" [ref=f9e300]
|
||||||
|
- paragraph [ref=f9e301]:
|
||||||
|
- code [ref=f9e302]: XorCsrfTokenRequestAttributeHandler
|
||||||
|
- text: 가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서
|
||||||
|
- code [ref=f9e303]: headerName
|
||||||
|
- text: 만 읽고, 실제 값은
|
||||||
|
- code [ref=f9e304]: document.cookie
|
||||||
|
- text: 에서 raw
|
||||||
|
- code [ref=f9e305]: XSRF-TOKEN
|
||||||
|
- text: 을 찾아 쓴다.
|
||||||
|
- paragraph [ref=f9e306]:
|
||||||
|
- code [ref=f9e307]: SpaCsrfTokenRequestHandler
|
||||||
|
- text: 가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
|
||||||
|
- paragraph [ref=f9e308]:
|
||||||
|
- text: 응답 JSON의
|
||||||
|
- code [ref=f9e309]: token
|
||||||
|
- text: 을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
|
||||||
|
- region [ref=f9e310]:
|
||||||
|
- heading [level=2] [ref=f9e311]:
|
||||||
|
- link "검증이 controller보다 먼저 일어난다 바로가기" [ref=f9e312] [cursor=pointer]:
|
||||||
|
- /url: "#검증이-controller보다-먼저-일어난다"
|
||||||
|
- text: 검증이 controller보다 먼저 일어난다
|
||||||
|
- generic [aria-hidden] [ref=f9e313]: "#"
|
||||||
|
- paragraph [ref=f9e314]: 정상 상태 변경 요청은 다음과 같다.
|
||||||
|
- figure "HTTP ·CSRF 검증을 통과하는 POST 코드 복사" [ref=f9e315]:
|
||||||
|
- generic [ref=f9e316]:
|
||||||
|
- generic [ref=f9e317]: HTTP
|
||||||
|
- generic [ref=f9e318]: ·CSRF 검증을 통과하는 POST
|
||||||
|
- button "코드 복사" [ref=f9e319] [cursor=pointer]: 복사
|
||||||
|
- region "CSRF 검증을 통과하는 POST 코드" [ref=f9e320]:
|
||||||
|
- code [ref=f9e321]: "POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token> X-XSRF-TOKEN: <same-raw-csrf-token> theme=dark"
|
||||||
|
- paragraph [ref=f9e323]: Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
|
||||||
|
- region [ref=f9e324]:
|
||||||
|
- heading [level=2] [ref=f9e325]:
|
||||||
|
- link "SameSite와 CSRF token은 역할이 다르다 바로가기" [ref=f9e326] [cursor=pointer]:
|
||||||
|
- /url: "#samesite와-csrf-token은-역할이-다르다"
|
||||||
|
- text: SameSite와 CSRF token은 역할이 다르다
|
||||||
|
- generic [aria-hidden] [ref=f9e327]: "#"
|
||||||
|
- region "표" [ref=f9e328]:
|
||||||
|
- table [ref=f9e329]:
|
||||||
|
- caption [ref=f9e330]
|
||||||
|
- rowgroup [ref=f9e331]:
|
||||||
|
- row [ref=f9e332]:
|
||||||
|
- columnheader [ref=f9e333]
|
||||||
|
- columnheader "SameSite" [ref=f9e334]
|
||||||
|
- columnheader "CSRF token" [ref=f9e335]
|
||||||
|
- rowgroup [ref=f9e336]:
|
||||||
|
- row [ref=f9e337]:
|
||||||
|
- cell "누가 판단하나" [ref=f9e338]
|
||||||
|
- cell "브라우저" [ref=f9e339]
|
||||||
|
- cell "서버" [ref=f9e340]
|
||||||
|
- row [ref=f9e341]:
|
||||||
|
- cell "무엇을 정하나" [ref=f9e342]
|
||||||
|
- cell "cookie를 보낼지" [ref=f9e343]
|
||||||
|
- cell "요청을 받아들일지" [ref=f9e344]
|
||||||
|
- row [ref=f9e345]:
|
||||||
|
- cell "언제 작동하나" [ref=f9e346]
|
||||||
|
- cell "요청을 만들 때" [ref=f9e347]
|
||||||
|
- cell "요청을 처리할 때" [ref=f9e348]
|
||||||
|
- paragraph [ref=f9e349]: 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다.
|
||||||
|
- paragraph [ref=f9e350]: 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다.
|
||||||
|
- region "표" [ref=f9e351]:
|
||||||
|
- table [ref=f9e352]:
|
||||||
|
- caption [ref=f9e353]
|
||||||
|
- rowgroup [ref=f9e354]:
|
||||||
|
- row [ref=f9e355]:
|
||||||
|
- columnheader "입력" [ref=f9e356]
|
||||||
|
- columnheader "cookie 동작" [ref=f9e357]
|
||||||
|
- columnheader "CSRF 동작" [ref=f9e358]
|
||||||
|
- columnheader "결과" [ref=f9e359]
|
||||||
|
- rowgroup [ref=f9e360]:
|
||||||
|
- row [ref=f9e361]:
|
||||||
|
- cell "same-origin, CSRF 헤더 없음" [ref=f9e362]
|
||||||
|
- cell "session cookie 붙음" [ref=f9e363]
|
||||||
|
- cell "token 부재로 거부" [ref=f9e364]
|
||||||
|
- cell "403" [ref=f9e365]
|
||||||
|
- row [ref=f9e366]:
|
||||||
|
- cell "same-origin, raw cookie와 헤더 일치" [ref=f9e367]
|
||||||
|
- cell "session cookie 붙음" [ref=f9e368]
|
||||||
|
- cell "token 일치" [ref=f9e369]
|
||||||
|
- cell "200" [ref=f9e370]
|
||||||
|
- row [ref=f9e371]:
|
||||||
|
- cell "다른 port지만 same-site, 헤더 없음" [ref=f9e372]
|
||||||
|
- cell "cookie가 붙을 수 있음" [ref=f9e373]
|
||||||
|
- cell "token 부재로 거부" [ref=f9e374]
|
||||||
|
- cell "403" [ref=f9e375]
|
||||||
|
- row [ref=f9e376]:
|
||||||
|
- cell "cross-site POST" [ref=f9e377]
|
||||||
|
- cell "SameSite=Lax로 cookie 제외" [ref=f9e378]
|
||||||
|
- cell "이 지점 이후는 고정하지 않음" [ref=f9e379]
|
||||||
|
- cell "cookie omission이 확인 지점" [ref=f9e380]
|
||||||
|
- paragraph [ref=f9e381]: 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다.
|
||||||
|
- region [ref=f9e382]:
|
||||||
|
- heading [level=2] [ref=f9e383]:
|
||||||
|
- link "CSRF가 XSS를 대신하지 않는다 바로가기" [ref=f9e384] [cursor=pointer]:
|
||||||
|
- /url: "#csrf가-xss를-대신하지-않는다"
|
||||||
|
- text: CSRF가 XSS를 대신하지 않는다
|
||||||
|
- generic [aria-hidden] [ref=f9e385]: "#"
|
||||||
|
- paragraph [ref=f9e386]:
|
||||||
|
- text: 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는
|
||||||
|
- code [ref=f9e387]: XSRF-TOKEN
|
||||||
|
- text: 도 같이 읽을 수 있다.
|
||||||
|
- paragraph [ref=f9e388]: 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
|
||||||
|
- region [ref=f9e389]:
|
||||||
|
- paragraph [ref=f9e390]: Next
|
||||||
|
- heading "다음에 읽을 것" [level=2] [ref=f9e391]
|
||||||
|
- list [ref=f9e392]:
|
||||||
|
- listitem [ref=f9e393]:
|
||||||
|
- link "적용 기준 BFF 인증 구조 설계 기준" [ref=f9e394] [cursor=pointer]:
|
||||||
|
- /url: /references/bff-authentication-design-criteria
|
||||||
|
- generic [ref=f9e395]: 적용 기준
|
||||||
|
- generic [ref=f9e396]:
|
||||||
|
- strong [ref=f9e397]: BFF 인증 구조 설계 기준
|
||||||
|
- paragraph [aria-hidden] [ref=f9e398]: 이 확인이 필요한 구조의 설계 항목이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e399]: ↗
|
||||||
|
- listitem [ref=f9e400]:
|
||||||
|
- link "검증 기록 Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f9e401] [cursor=pointer]:
|
||||||
|
- /url: /cases/bff-session-csrf-responsibility
|
||||||
|
- generic [ref=f9e402]: 검증 기록
|
||||||
|
- generic [ref=f9e403]:
|
||||||
|
- strong [ref=f9e404]: Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정
|
||||||
|
- paragraph [aria-hidden] [ref=f9e405]: 이 동작을 실제로 재현한 기록이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e406]: ↗
|
||||||
|
- listitem [ref=f9e407]:
|
||||||
|
- link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f9e408] [cursor=pointer]:
|
||||||
|
- /url: /references/oauth-token-application-session-boundary
|
||||||
|
- generic [ref=f9e409]: 적용 기준
|
||||||
|
- generic [ref=f9e410]:
|
||||||
|
- strong [ref=f9e411]: OAuth Token과 Application Session을 구분하는 기준
|
||||||
|
- paragraph [aria-hidden] [ref=f9e412]: session cookie와 CSRF token은 서로 다른 값이다.
|
||||||
|
- generic [aria-hidden] [ref=f9e413]: ↗
|
||||||
|
- complementary [ref=f9e414]:
|
||||||
|
- heading "작업 상태" [level=2] [ref=f9e415]
|
||||||
|
- status "편집 상태" [ref=f9e416]: 저장됨
|
||||||
|
- generic [ref=f9e417]:
|
||||||
|
- generic [ref=f9e418]:
|
||||||
|
- term [ref=f9e419]: 저장 버전
|
||||||
|
- definition [ref=f9e420]: "5"
|
||||||
|
- generic [ref=f9e421]:
|
||||||
|
- term [ref=f9e422]: 종류
|
||||||
|
- definition [ref=f9e423]: 동작 원리
|
||||||
|
- paragraph [ref=f9e424]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||||||
|
- generic [ref=f9e425]:
|
||||||
|
- button "저장" [disabled] [ref=f9e426]
|
||||||
|
- button "게시" [ref=f9e427]
|
||||||
|
- paragraph [ref=f9e428]: 버전 5으로 저장했습니다.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,628 @@
|
|||||||
|
- generic [ref=f12e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f12e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f12e5]:
|
||||||
|
- generic [ref=f12e6]:
|
||||||
|
- link "TechLog Studio" [ref=f12e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f12e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f12e10]:
|
||||||
|
- link "작업본" [ref=f12e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f12e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f12e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f12e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f12e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f12e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f12e17]
|
||||||
|
- main [ref=f12e18]:
|
||||||
|
- generic [ref=f12e19]:
|
||||||
|
- generic [ref=f12e20]:
|
||||||
|
- region [ref=f12e21]:
|
||||||
|
- generic [ref=f12e22]:
|
||||||
|
- paragraph [ref=f12e23]: CASE · VERSION 36
|
||||||
|
- heading "문서 편집" [level=1] [ref=f12e24]
|
||||||
|
- paragraph [ref=f12e25]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||||||
|
- region [ref=f12e26]:
|
||||||
|
- generic [ref=f12e27]:
|
||||||
|
- paragraph [ref=f12e28]: DOCUMENT
|
||||||
|
- heading "기본 정보" [level=2] [ref=f12e29]
|
||||||
|
- generic [ref=f12e30]:
|
||||||
|
- generic [ref=f12e31]:
|
||||||
|
- generic [ref=f12e32]: 제목
|
||||||
|
- textbox "제목" [ref=f12e33]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||||||
|
- generic [ref=f12e34]:
|
||||||
|
- generic [ref=f12e35]: slug
|
||||||
|
- textbox "slug" [ref=f12e36]:
|
||||||
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||||||
|
- text: fetch-join-multibag-and-row-explosion
|
||||||
|
- generic [ref=f12e37]:
|
||||||
|
- generic [ref=f12e38]: 요약
|
||||||
|
- textbox "요약" [ref=f12e39]: "추가 쿼리를 줄이기 위해 필요한 연관 데이터를 `fetch join`으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에 `fetch join`하자 `MultipleBagFetchException`이 발생했다. 컬렉션 하나만 `fetch join`했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다. `fetch join`으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다."
|
||||||
|
- generic [aria-hidden] [ref=f12e40]: 목록 카드에는 약 90자까지 보입니다 · 312 / 2000
|
||||||
|
- generic [ref=f12e41]:
|
||||||
|
- generic [ref=f12e42]: Topic
|
||||||
|
- combobox "Topic" [ref=f12e43]:
|
||||||
|
- option "선택하지 않음"
|
||||||
|
- option "JPA 피드 조회 성능" [selected]
|
||||||
|
- option "OAuth/OIDC 인증 경계"
|
||||||
|
- generic [ref=f12e44]:
|
||||||
|
- generic [ref=f12e45]: Project
|
||||||
|
- combobox "Project" [ref=f12e46]:
|
||||||
|
- option "미지정"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns"
|
||||||
|
- option "Liner N + 1문제" [selected]
|
||||||
|
- status [ref=f12e47]
|
||||||
|
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f12e48]:
|
||||||
|
- generic [ref=f12e50] [cursor=pointer]:
|
||||||
|
- checkbox "파생 쿼리 그대로" [ref=f12e51]
|
||||||
|
- generic [ref=f12e52]: 파생 쿼리 그대로
|
||||||
|
- generic [ref=f12e53] [cursor=pointer]:
|
||||||
|
- checkbox "컬렉션 fetch join" [checked] [ref=f12e54]
|
||||||
|
- generic [ref=f12e55]: 컬렉션 fetch join
|
||||||
|
- generic [ref=f12e56] [cursor=pointer]:
|
||||||
|
- checkbox "fetch join + 페이징" [ref=f12e57]
|
||||||
|
- generic [ref=f12e58]: fetch join + 페이징
|
||||||
|
- group "관계" [ref=f12e59]:
|
||||||
|
- generic [ref=f12e61]:
|
||||||
|
- generic [ref=f12e62]:
|
||||||
|
- generic [ref=f12e63]: 관계 1 대상
|
||||||
|
- combobox "관계 1 대상" [ref=f12e64]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f12e65]:
|
||||||
|
- generic [ref=f12e66]: 관계 1 이유
|
||||||
|
- textbox "관계 1 이유" [ref=f12e67]: 이 실패에서 나온 선택 기준이다.
|
||||||
|
- generic [aria-hidden] [ref=f12e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f12e69]:
|
||||||
|
- button "위로" [disabled] [ref=f12e70]
|
||||||
|
- button "아래로" [ref=f12e71]
|
||||||
|
- button "삭제" [ref=f12e72]
|
||||||
|
- generic [ref=f12e73]:
|
||||||
|
- generic [ref=f12e74]:
|
||||||
|
- generic [ref=f12e75]: 관계 2 대상
|
||||||
|
- combobox "관계 2 대상" [ref=f12e76]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging" [selected]
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f12e77]:
|
||||||
|
- generic [ref=f12e78]: 관계 2 이유
|
||||||
|
- textbox "관계 2 이유" [ref=f12e79]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||||||
|
- generic [aria-hidden] [ref=f12e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f12e81]:
|
||||||
|
- button "위로" [ref=f12e82]
|
||||||
|
- button "아래로" [ref=f12e83]
|
||||||
|
- button "삭제" [ref=f12e84]
|
||||||
|
- generic [ref=f12e85]:
|
||||||
|
- generic [ref=f12e86]:
|
||||||
|
- generic [ref=f12e87]: 관계 3 대상
|
||||||
|
- combobox "관계 3 대상" [ref=f12e88]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [selected]
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f12e89]:
|
||||||
|
- generic [ref=f12e90]: 관계 3 이유
|
||||||
|
- textbox "관계 3 이유" [ref=f12e91]: 이 시도가 풀려던 문제다.
|
||||||
|
- generic [aria-hidden] [ref=f12e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f12e93]:
|
||||||
|
- button "위로" [ref=f12e94]
|
||||||
|
- button "아래로" [disabled] [ref=f12e95]
|
||||||
|
- button "삭제" [ref=f12e96]
|
||||||
|
- button "관계 추가" [ref=f12e97]
|
||||||
|
- region [ref=f12e98]:
|
||||||
|
- generic [ref=f12e99]:
|
||||||
|
- paragraph [ref=f12e100]: CASE
|
||||||
|
- heading "문제와 검증" [level=2] [ref=f12e101]
|
||||||
|
- generic [ref=f12e102]:
|
||||||
|
- generic [ref=f12e103]:
|
||||||
|
- generic [ref=f12e104]: 문제
|
||||||
|
- textbox "문제" [ref=f12e105]: "`@OneToMany`과 `@ManyToOne`에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서 `highlights`, `mentions`를 모두 `fetch join`해 한 번의 쿼리로 조회해 보았다. mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다."
|
||||||
|
- generic [ref=f12e106]:
|
||||||
|
- generic [ref=f12e107]: 결론
|
||||||
|
- textbox "결론" [ref=f12e108]: "두 `List` 컬렉션을 동시에 `fetch join`하면 `MultipleBagFetchException`이 발생했다. Hibernate는 순서 컬럼이 없는 두 `List`가 조인되면서 `highlights × mentions` 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다. `highlights` 하나만 `fetch join`하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다. Hibernate 6에서는 `fetch join` 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다. N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히 `User`와 `Page`를 조회하는 추가 쿼리였고, `highlights`를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다."
|
||||||
|
- generic [ref=f12e109]:
|
||||||
|
- generic [ref=f12e110]: 검증 환경
|
||||||
|
- textbox "검증 환경" [ref=f12e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) Query : JPQL Unique Constraint : (feed_item_id, mentioned_user_id)"
|
||||||
|
- generic [ref=f12e112]:
|
||||||
|
- generic [ref=f12e113]: 재현 조건
|
||||||
|
- textbox "재현 조건" [ref=f12e114]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다. 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다. 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다. 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다. 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
|
||||||
|
- generic [ref=f12e115]:
|
||||||
|
- generic [ref=f12e116]: 마지막 검증일
|
||||||
|
- textbox "마지막 검증일" [ref=f12e117]: 2026-09-01
|
||||||
|
- generic [ref=f12e118]:
|
||||||
|
- generic [ref=f12e119]: 본문 Markdown
|
||||||
|
- group "Markdown 삽입" [ref=f12e120]:
|
||||||
|
- button "코드" [ref=f12e121] [cursor=pointer]
|
||||||
|
- button "표" [ref=f12e122] [cursor=pointer]
|
||||||
|
- button "목록" [ref=f12e123] [cursor=pointer]
|
||||||
|
- textbox "본문 Markdown" [ref=f12e124]: "## 두 컬렉션을 동시 fetch join ```java label=\"컬렉션 둘을 같이 fetch join\" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` ```text label=\"예외 원인\" java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException ``` MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다. 전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다. ## 한 컬렉션만 fetch join :::evidence key=\"cartesian-row-multiplication-dce2e166\" alt=\"feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.\" caption=\" \" zoom=\"true\" ::: | FeedItem 수 | 조인된 행 수 | 반환된 Feed Item 수 | Highlight 수 | FeedItem 대비 조인 행 수 | |---:|---:|---:|---:|---:| | 10 | 1,285 | 10 | 1,285 | 128.5× | | 100 | 1,961 | 100 | 1,961 | 19.6× | | 1,000 | 2,917 | 1,000 | 2,917 | 2.9× | highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다. FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다. Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다. 하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다. FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다. ## 쿼리 수만 보면 개선처럼 보인다 | 구분 | 기존 조회 | highlights fetch join | 변화 | |---|---:|---:|---| | Feed Item 조회 | 1 | 1 | highlights를 같이 조회 | | count 조회 | 1 | 0 | JPQL로 조회 | | Highlight 조회 | 100 | 0 | 개별 조회 제거 | | User+Page 조회 | 120 | 120 | 변화 없음 | | 전체 | 222 | 121 | 101개 감소 | 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다. ## 조인이 행을 곱하는 것을 실행계획 ```text label=\"N=100일 때, fetch join EXPLAIN\" Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) Execution Time: 0.959 ms ``` Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다. 쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다. Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다. 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다."
|
||||||
|
- group [ref=f12e125]:
|
||||||
|
- paragraph [ref=f12e126]: EVIDENCE
|
||||||
|
- heading "본문에 Asset 삽입" [level=3] [ref=f12e127]
|
||||||
|
- paragraph [ref=f12e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||||||
|
- generic [ref=f12e129]:
|
||||||
|
- generic [ref=f12e130]:
|
||||||
|
- generic [ref=f12e131]: 업로드 종류
|
||||||
|
- combobox "업로드 종류" [ref=f12e132]:
|
||||||
|
- option "이미지" [selected]
|
||||||
|
- option "다이어그램"
|
||||||
|
- option "첨부파일"
|
||||||
|
- button "Asset 업로드" [ref=f12e133]
|
||||||
|
- generic [ref=f12e134]:
|
||||||
|
- search [ref=f12e135]:
|
||||||
|
- generic [ref=f12e136]: Asset 검색
|
||||||
|
- generic [ref=f12e137]:
|
||||||
|
- searchbox "Asset 검색" [ref=f12e138]
|
||||||
|
- button "검색" [ref=f12e139]
|
||||||
|
- generic [ref=f12e140]:
|
||||||
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f12e141]
|
||||||
|
- generic [ref=f12e142]: 삽입할 때 크게 보기 허용
|
||||||
|
- status [ref=f12e143]: 삽입할 수 있는 Asset 24개
|
||||||
|
- list [ref=f12e144]:
|
||||||
|
- listitem [ref=f12e145]:
|
||||||
|
- button "ap4-edge-trust-architecture-1a916e10" [ref=f12e146]
|
||||||
|
- button "삭제" [ref=f12e147]
|
||||||
|
- listitem [ref=f12e148]:
|
||||||
|
- button "ap3-bff-session-flow-1b005e15" [ref=f12e149]
|
||||||
|
- button "삭제" [ref=f12e150]
|
||||||
|
- listitem [ref=f12e151]:
|
||||||
|
- button "ap3-bff-architecture-a27ea91c" [ref=f12e152]
|
||||||
|
- button "삭제" [ref=f12e153]
|
||||||
|
- listitem [ref=f12e154]:
|
||||||
|
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f12e155]
|
||||||
|
- button "삭제" [ref=f12e156]
|
||||||
|
- listitem [ref=f12e157]:
|
||||||
|
- button "ap2-mediator-architecture-c95ed25f" [ref=f12e158]
|
||||||
|
- button "삭제" [ref=f12e159]
|
||||||
|
- listitem [ref=f12e160]:
|
||||||
|
- button "projection-row-over-fetch-f2b1943b" [ref=f12e161]
|
||||||
|
- button "삭제" [ref=f12e162]
|
||||||
|
- listitem [ref=f12e163]:
|
||||||
|
- button "cartesian-row-multiplication-dce2e166" [ref=f12e164]
|
||||||
|
- button "삭제" [ref=f12e165]
|
||||||
|
- listitem [ref=f12e166]:
|
||||||
|
- button "eager-lazy-query-sequence-47c12bda" [ref=f12e167]
|
||||||
|
- button "삭제" [ref=f12e168]
|
||||||
|
- listitem [ref=f12e169]:
|
||||||
|
- button "ap3-bff-session-flow-a8dfff6f" [ref=f12e170]
|
||||||
|
- button "삭제" [ref=f12e171]
|
||||||
|
- listitem [ref=f12e172]:
|
||||||
|
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f12e173]
|
||||||
|
- button "삭제" [ref=f12e174]
|
||||||
|
- listitem [ref=f12e175]:
|
||||||
|
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f12e176]
|
||||||
|
- button "삭제" [ref=f12e177]
|
||||||
|
- listitem [ref=f12e178]:
|
||||||
|
- button "ap3-csrf-boundary-971df81c" [ref=f12e179]
|
||||||
|
- button "삭제" [ref=f12e180]
|
||||||
|
- listitem [ref=f12e181]:
|
||||||
|
- button "login-api-phase-split-3e354274" [ref=f12e182]
|
||||||
|
- button "삭제" [ref=f12e183]
|
||||||
|
- listitem [ref=f12e184]:
|
||||||
|
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f12e185]
|
||||||
|
- button "삭제" [ref=f12e186]
|
||||||
|
- listitem [ref=f12e187]:
|
||||||
|
- button "ap1-direct-architecture-0adf4199" [ref=f12e188]
|
||||||
|
- button "삭제" [ref=f12e189]
|
||||||
|
- listitem [ref=f12e190]:
|
||||||
|
- button "nplus1-query-fanout-644febe6" [ref=f12e191]
|
||||||
|
- button "삭제" [ref=f12e192]
|
||||||
|
- listitem [ref=f12e193]:
|
||||||
|
- button "ap4-edge-trust-1cff2399" [ref=f12e194]
|
||||||
|
- button "삭제" [ref=f12e195]
|
||||||
|
- listitem [ref=f12e196]:
|
||||||
|
- button "ap3-csrf-split-501dd1f7" [ref=f12e197]
|
||||||
|
- button "삭제" [ref=f12e198]
|
||||||
|
- listitem [ref=f12e199]:
|
||||||
|
- button "ap3-bff-custody-82fa18bd" [ref=f12e200]
|
||||||
|
- button "삭제" [ref=f12e201]
|
||||||
|
- listitem [ref=f12e202]:
|
||||||
|
- button "ap2-split-custody-779cb791" [ref=f12e203]
|
||||||
|
- button "삭제" [ref=f12e204]
|
||||||
|
- listitem [ref=f12e205]:
|
||||||
|
- button "ap1-custody-v3-6e0376d2" [ref=f12e206]
|
||||||
|
- button "삭제" [ref=f12e207]
|
||||||
|
- listitem [ref=f12e208]:
|
||||||
|
- button "ap1-custody-v2-e110bd98" [ref=f12e209]
|
||||||
|
- button "삭제" [ref=f12e210]
|
||||||
|
- listitem [ref=f12e211]:
|
||||||
|
- button "ap1-credential-custody-f5e0c027" [ref=f12e212]
|
||||||
|
- button "삭제" [ref=f12e213]
|
||||||
|
- listitem [ref=f12e214]:
|
||||||
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f12e215]
|
||||||
|
- button "삭제" [ref=f12e216]
|
||||||
|
- region [ref=f12e217]:
|
||||||
|
- generic [ref=f12e218]:
|
||||||
|
- paragraph [ref=f12e219]: LIVE
|
||||||
|
- heading "즉시 미리보기" [level=2] [ref=f12e220]
|
||||||
|
- generic [ref=f12e223]:
|
||||||
|
- generic [ref=f12e224]:
|
||||||
|
- navigation "문서 경로" [ref=f12e225]:
|
||||||
|
- link "검증 기록" [ref=f12e226] [cursor=pointer]:
|
||||||
|
- /url: /explore/cases
|
||||||
|
- generic [aria-hidden] [ref=f12e227]: /
|
||||||
|
- generic [ref=f12e228]: JPA 피드 조회 성능
|
||||||
|
- generic [aria-hidden] [ref=f12e229]: /
|
||||||
|
- link "Liner N + 1문제" [ref=f12e230] [cursor=pointer]:
|
||||||
|
- /url: /projects/liner-n-plus-1
|
||||||
|
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=1] [ref=f12e231]
|
||||||
|
- paragraph [ref=f12e232]:
|
||||||
|
- text: 추가 쿼리를 줄이기 위해 필요한 연관 데이터를
|
||||||
|
- code [ref=f12e233]: fetch join
|
||||||
|
- text: 으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에
|
||||||
|
- code [ref=f12e234]: fetch join
|
||||||
|
- text: 하자
|
||||||
|
- code [ref=f12e235]: MultipleBagFetchException
|
||||||
|
- text: 이 발생했다.
|
||||||
|
- paragraph [ref=f12e236]:
|
||||||
|
- text: 컬렉션 하나만
|
||||||
|
- code [ref=f12e237]: fetch join
|
||||||
|
- text: 했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다.
|
||||||
|
- paragraph [ref=f12e238]:
|
||||||
|
- code [ref=f12e239]: fetch join
|
||||||
|
- text: 으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다.
|
||||||
|
- region "문제와 결론" [ref=f12e240]:
|
||||||
|
- generic [ref=f12e241]:
|
||||||
|
- paragraph [ref=f12e242]: 문제
|
||||||
|
- paragraph [ref=f12e243]:
|
||||||
|
- code [ref=f12e244]: "@OneToMany"
|
||||||
|
- text: 과
|
||||||
|
- code [ref=f12e245]: "@ManyToOne"
|
||||||
|
- text: 에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서
|
||||||
|
- code [ref=f12e246]: highlights
|
||||||
|
- text: ","
|
||||||
|
- code [ref=f12e247]: mentions
|
||||||
|
- text: 를 모두
|
||||||
|
- code [ref=f12e248]: fetch join
|
||||||
|
- text: 해 한 번의 쿼리로 조회해 보았다.
|
||||||
|
- paragraph [ref=f12e249]: mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다.
|
||||||
|
- generic [ref=f12e250]:
|
||||||
|
- paragraph [ref=f12e251]: 결론
|
||||||
|
- paragraph [ref=f12e252]:
|
||||||
|
- text: 두
|
||||||
|
- code [ref=f12e253]: List
|
||||||
|
- text: 컬렉션을 동시에
|
||||||
|
- code [ref=f12e254]: fetch join
|
||||||
|
- text: 하면
|
||||||
|
- code [ref=f12e255]: MultipleBagFetchException
|
||||||
|
- text: 이 발생했다. Hibernate는 순서 컬럼이 없는 두
|
||||||
|
- code [ref=f12e256]: List
|
||||||
|
- text: 가 조인되면서
|
||||||
|
- code [ref=f12e257]: highlights × mentions
|
||||||
|
- text: 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다.
|
||||||
|
- paragraph [ref=f12e258]:
|
||||||
|
- code [ref=f12e259]: highlights
|
||||||
|
- text: 하나만
|
||||||
|
- code [ref=f12e260]: fetch join
|
||||||
|
- text: 하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다.
|
||||||
|
- paragraph [ref=f12e261]:
|
||||||
|
- text: Hibernate 6에서는
|
||||||
|
- code [ref=f12e262]: fetch join
|
||||||
|
- text: 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다.
|
||||||
|
- paragraph [ref=f12e263]:
|
||||||
|
- text: N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히
|
||||||
|
- code [ref=f12e264]: User
|
||||||
|
- text: 와
|
||||||
|
- code [ref=f12e265]: Page
|
||||||
|
- text: 를 조회하는 추가 쿼리였고,
|
||||||
|
- code [ref=f12e266]: highlights
|
||||||
|
- text: 를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다.
|
||||||
|
- generic [ref=f12e267]:
|
||||||
|
- generic [ref=f12e268]:
|
||||||
|
- term [ref=f12e269]: 검증 환경
|
||||||
|
- definition [ref=f12e270]:
|
||||||
|
- paragraph [ref=f12e271]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)Query : JPQLUnique Constraint : (feed_item_id, mentioned_user_id)"
|
||||||
|
- generic [ref=f12e272]:
|
||||||
|
- term [ref=f12e273]: 검증 데이터
|
||||||
|
- definition [ref=f12e274]:
|
||||||
|
- paragraph [ref=f12e275]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다.
|
||||||
|
- paragraph [ref=f12e276]: 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다.
|
||||||
|
- paragraph [ref=f12e277]: 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다.
|
||||||
|
- paragraph [ref=f12e278]: 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다.
|
||||||
|
- paragraph [ref=f12e279]: 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
|
||||||
|
- generic [ref=f12e280]:
|
||||||
|
- term [ref=f12e281]: 기록
|
||||||
|
- definition [ref=f12e282]: 게시 2026.09.01 · 마지막 검증 2026.09.01
|
||||||
|
- group [ref=f12e284]:
|
||||||
|
- generic "목차 · 두 컬렉션을 동시 fetch join" [ref=f12e285] [cursor=pointer]
|
||||||
|
- article [ref=f12e287]:
|
||||||
|
- region [ref=f12e288]:
|
||||||
|
- heading [level=2] [ref=f12e289]:
|
||||||
|
- link "두 컬렉션을 동시 fetch join 바로가기" [ref=f12e290] [cursor=pointer]:
|
||||||
|
- /url: "#두-컬렉션을-동시-fetch-join"
|
||||||
|
- text: 두 컬렉션을 동시 fetch join
|
||||||
|
- generic [aria-hidden] [ref=f12e291]: "#"
|
||||||
|
- figure "JAVA ·컬렉션 둘을 같이 fetch join 코드 복사" [ref=f12e292]:
|
||||||
|
- generic [ref=f12e293]:
|
||||||
|
- generic [ref=f12e294]: JAVA
|
||||||
|
- generic [ref=f12e295]: ·컬렉션 둘을 같이 fetch join
|
||||||
|
- button "코드 복사" [ref=f12e296] [cursor=pointer]: 복사
|
||||||
|
- region "컬렉션 둘을 같이 fetch join 코드" [ref=f12e297]:
|
||||||
|
- code [ref=f12e298]: select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions
|
||||||
|
- figure "TEXT ·예외 원인 코드 복사" [ref=f12e300]:
|
||||||
|
- generic [ref=f12e301]:
|
||||||
|
- generic [ref=f12e302]: TEXT
|
||||||
|
- generic [ref=f12e303]: ·예외 원인
|
||||||
|
- button "코드 복사" [ref=f12e304] [cursor=pointer]: 복사
|
||||||
|
- region "예외 원인 코드" [ref=f12e305]:
|
||||||
|
- code [ref=f12e306]: java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
|
||||||
|
- paragraph [ref=f12e308]: MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다.전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다.
|
||||||
|
- region [ref=f12e309]:
|
||||||
|
- heading [level=2] [ref=f12e310]:
|
||||||
|
- link "한 컬렉션만 fetch join 바로가기" [ref=f12e311] [cursor=pointer]:
|
||||||
|
- /url: "#한-컬렉션만-fetch-join"
|
||||||
|
- text: 한 컬렉션만 fetch join
|
||||||
|
- generic [aria-hidden] [ref=f12e312]: "#"
|
||||||
|
- figure [ref=f12e313]:
|
||||||
|
- button "cartesian-row-multiplication-dce2e166 이미지 크게 보기" [ref=f12e314]:
|
||||||
|
- img "feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름." [ref=f12e315]
|
||||||
|
- generic [ref=f12e316]: 크게 보기
|
||||||
|
- generic [ref=f12e317]: feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.
|
||||||
|
- region "표" [ref=f12e318]:
|
||||||
|
- table [ref=f12e319]:
|
||||||
|
- caption [ref=f12e320]
|
||||||
|
- rowgroup [ref=f12e321]:
|
||||||
|
- row [ref=f12e322]:
|
||||||
|
- columnheader "FeedItem 수" [ref=f12e323]
|
||||||
|
- columnheader "조인된 행 수" [ref=f12e324]
|
||||||
|
- columnheader "반환된 Feed Item 수" [ref=f12e325]
|
||||||
|
- columnheader "Highlight 수" [ref=f12e326]
|
||||||
|
- columnheader "FeedItem 대비 조인 행 수" [ref=f12e327]
|
||||||
|
- rowgroup [ref=f12e328]:
|
||||||
|
- row [ref=f12e329]:
|
||||||
|
- cell "10" [ref=f12e330]
|
||||||
|
- cell "1,285" [ref=f12e331]
|
||||||
|
- cell "10" [ref=f12e332]
|
||||||
|
- cell "1,285" [ref=f12e333]
|
||||||
|
- cell "128.5×" [ref=f12e334]
|
||||||
|
- row [ref=f12e335]:
|
||||||
|
- cell "100" [ref=f12e336]
|
||||||
|
- cell "1,961" [ref=f12e337]
|
||||||
|
- cell "100" [ref=f12e338]
|
||||||
|
- cell "1,961" [ref=f12e339]
|
||||||
|
- cell "19.6×" [ref=f12e340]
|
||||||
|
- row [ref=f12e341]:
|
||||||
|
- cell "1,000" [ref=f12e342]
|
||||||
|
- cell "2,917" [ref=f12e343]
|
||||||
|
- cell "1,000" [ref=f12e344]
|
||||||
|
- cell "2,917" [ref=f12e345]
|
||||||
|
- cell "2.9×" [ref=f12e346]
|
||||||
|
- paragraph [ref=f12e347]: highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다.FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다.
|
||||||
|
- paragraph [ref=f12e348]: Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다.하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다.
|
||||||
|
- paragraph [ref=f12e349]: FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다.
|
||||||
|
- region [ref=f12e350]:
|
||||||
|
- heading [level=2] [ref=f12e351]:
|
||||||
|
- link "쿼리 수만 보면 개선처럼 보인다 바로가기" [ref=f12e352] [cursor=pointer]:
|
||||||
|
- /url: "#쿼리-수만-보면-개선처럼-보인다"
|
||||||
|
- text: 쿼리 수만 보면 개선처럼 보인다
|
||||||
|
- generic [aria-hidden] [ref=f12e353]: "#"
|
||||||
|
- region "표" [ref=f12e354]:
|
||||||
|
- table [ref=f12e355]:
|
||||||
|
- caption [ref=f12e356]
|
||||||
|
- rowgroup [ref=f12e357]:
|
||||||
|
- row [ref=f12e358]:
|
||||||
|
- columnheader "구분" [ref=f12e359]
|
||||||
|
- columnheader "기존 조회" [ref=f12e360]
|
||||||
|
- columnheader "highlights fetch join" [ref=f12e361]
|
||||||
|
- columnheader "변화" [ref=f12e362]
|
||||||
|
- rowgroup [ref=f12e363]:
|
||||||
|
- row [ref=f12e364]:
|
||||||
|
- cell "Feed Item 조회" [ref=f12e365]
|
||||||
|
- cell "1" [ref=f12e366]
|
||||||
|
- cell "1" [ref=f12e367]
|
||||||
|
- cell "highlights를 같이 조회" [ref=f12e368]
|
||||||
|
- row [ref=f12e369]:
|
||||||
|
- cell "count 조회" [ref=f12e370]
|
||||||
|
- cell "1" [ref=f12e371]
|
||||||
|
- cell "0" [ref=f12e372]
|
||||||
|
- cell "JPQL로 조회" [ref=f12e373]
|
||||||
|
- row [ref=f12e374]:
|
||||||
|
- cell "Highlight 조회" [ref=f12e375]
|
||||||
|
- cell "100" [ref=f12e376]
|
||||||
|
- cell "0" [ref=f12e377]
|
||||||
|
- cell "개별 조회 제거" [ref=f12e378]
|
||||||
|
- row [ref=f12e379]:
|
||||||
|
- cell "User+Page 조회" [ref=f12e380]
|
||||||
|
- cell "120" [ref=f12e381]
|
||||||
|
- cell "120" [ref=f12e382]
|
||||||
|
- cell "변화 없음" [ref=f12e383]
|
||||||
|
- row [ref=f12e384]:
|
||||||
|
- cell "전체" [ref=f12e385]
|
||||||
|
- cell "222" [ref=f12e386]
|
||||||
|
- cell "121" [ref=f12e387]
|
||||||
|
- cell "101개 감소" [ref=f12e388]
|
||||||
|
- paragraph [ref=f12e389]: 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다.
|
||||||
|
- region [ref=f12e390]:
|
||||||
|
- heading [level=2] [ref=f12e391]:
|
||||||
|
- link "조인이 행을 곱하는 것을 실행계획 바로가기" [ref=f12e392] [cursor=pointer]:
|
||||||
|
- /url: "#조인이-행을-곱하는-것을-실행계획"
|
||||||
|
- text: 조인이 행을 곱하는 것을 실행계획
|
||||||
|
- generic [aria-hidden] [ref=f12e393]: "#"
|
||||||
|
- figure "TEXT ·N=100일 때, fetch join EXPLAIN 코드 복사" [ref=f12e394]:
|
||||||
|
- generic [ref=f12e395]:
|
||||||
|
- generic [ref=f12e396]: TEXT
|
||||||
|
- generic [ref=f12e397]: ·N=100일 때, fetch join EXPLAIN
|
||||||
|
- button "코드 복사" [ref=f12e398] [cursor=pointer]: 복사
|
||||||
|
- region "N=100일 때, fetch join EXPLAIN 코드" [ref=f12e399]:
|
||||||
|
- code [ref=f12e400]: "Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) Execution Time: 0.959 ms"
|
||||||
|
- paragraph [ref=f12e402]: Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다.쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다.
|
||||||
|
- paragraph [ref=f12e403]: Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다.
|
||||||
|
- paragraph [ref=f12e404]: 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다.
|
||||||
|
- region [ref=f12e405]:
|
||||||
|
- paragraph [ref=f12e406]: Next
|
||||||
|
- heading "다음에 읽을 것" [level=2] [ref=f12e407]
|
||||||
|
- list [ref=f12e408]:
|
||||||
|
- listitem [ref=f12e409]:
|
||||||
|
- link "검증 기록 Collection Fetch Join Pagination의 In-memory Paging" [ref=f12e410] [cursor=pointer]:
|
||||||
|
- /url: /cases/collection-fetch-join-in-memory-paging
|
||||||
|
- generic [ref=f12e411]: 검증 기록
|
||||||
|
- generic [ref=f12e412]:
|
||||||
|
- strong [ref=f12e413]: Collection Fetch Join Pagination의 In-memory Paging
|
||||||
|
- paragraph [aria-hidden] [ref=f12e414]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||||||
|
- generic [aria-hidden] [ref=f12e415]: ↗
|
||||||
|
- listitem [ref=f12e416]:
|
||||||
|
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f12e417] [cursor=pointer]:
|
||||||
|
- /url: /cases/eager-toone-nplus1-without-access
|
||||||
|
- generic [ref=f12e418]: 검증 기록
|
||||||
|
- generic [ref=f12e419]:
|
||||||
|
- strong [ref=f12e420]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
||||||
|
- paragraph [aria-hidden] [ref=f12e421]: 이 시도가 풀려던 문제다.
|
||||||
|
- generic [aria-hidden] [ref=f12e422]: ↗
|
||||||
|
- complementary [ref=f12e423]:
|
||||||
|
- heading "작업 상태" [level=2] [ref=f12e424]
|
||||||
|
- status "편집 상태" [ref=f12e425]: 저장됨
|
||||||
|
- generic [ref=f12e426]:
|
||||||
|
- generic [ref=f12e427]:
|
||||||
|
- term [ref=f12e428]: 저장 버전
|
||||||
|
- definition [ref=f12e429]: "36"
|
||||||
|
- generic [ref=f12e430]:
|
||||||
|
- term [ref=f12e431]: 종류
|
||||||
|
- definition [ref=f12e432]: 검증 기록
|
||||||
|
- paragraph [ref=f12e433]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||||||
|
- generic [ref=f12e434]:
|
||||||
|
- button "저장" [disabled] [ref=f12e435]
|
||||||
|
- button "게시" [ref=f12e436]
|
||||||
|
- paragraph [ref=f12e437]: 버전 36으로 저장했습니다.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,564 @@
|
|||||||
|
- generic [ref=f14e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f14e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f14e5]:
|
||||||
|
- generic [ref=f14e6]:
|
||||||
|
- link "TechLog Studio" [ref=f14e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f14e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f14e10]:
|
||||||
|
- link "작업본" [ref=f14e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f14e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f14e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f14e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f14e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f14e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f14e17]
|
||||||
|
- main [ref=f14e18]:
|
||||||
|
- generic [ref=f14e19]:
|
||||||
|
- generic [ref=f14e20]:
|
||||||
|
- region [ref=f14e21]:
|
||||||
|
- generic [ref=f14e22]:
|
||||||
|
- paragraph [ref=f14e23]: CASE · VERSION 4
|
||||||
|
- heading "문서 편집" [level=1] [ref=f14e24]
|
||||||
|
- paragraph [ref=f14e25]: Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||||
|
- region [ref=f14e26]:
|
||||||
|
- generic [ref=f14e27]:
|
||||||
|
- paragraph [ref=f14e28]: DOCUMENT
|
||||||
|
- heading "기본 정보" [level=2] [ref=f14e29]
|
||||||
|
- generic [ref=f14e30]:
|
||||||
|
- generic [ref=f14e31]:
|
||||||
|
- generic [ref=f14e32]: 제목
|
||||||
|
- textbox "제목" [ref=f14e33]: Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||||
|
- generic [ref=f14e34]:
|
||||||
|
- generic [ref=f14e35]: slug
|
||||||
|
- textbox "slug" [ref=f14e36]:
|
||||||
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||||||
|
- text: projection-row-over-fetch
|
||||||
|
- generic [ref=f14e37]:
|
||||||
|
- generic [ref=f14e38]: 요약
|
||||||
|
- textbox "요약" [ref=f14e39]: DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로 줄고 쿼리도 2개로 고정됐다. 그런데 자식 IN 쿼리는 페이지 부모 20개의 하이라이트를 전부 가져와 1,509행이었다. 화면에 필요한 것은 부모당 최신 3개, 최대 60행이었다.
|
||||||
|
- generic [aria-hidden] [ref=f14e40]: 목록 카드에는 약 90자까지 보입니다 · 137 / 2000
|
||||||
|
- generic [ref=f14e41]:
|
||||||
|
- generic [ref=f14e42]: Topic
|
||||||
|
- combobox "Topic" [ref=f14e43]:
|
||||||
|
- option "선택하지 않음"
|
||||||
|
- option "JPA 피드 조회 성능" [selected]
|
||||||
|
- option "OAuth/OIDC 인증 경계"
|
||||||
|
- generic [ref=f14e44]:
|
||||||
|
- generic [ref=f14e45]: Project
|
||||||
|
- combobox "Project" [ref=f14e46]:
|
||||||
|
- option "미지정"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns"
|
||||||
|
- option "Liner N + 1문제" [selected]
|
||||||
|
- status [ref=f14e47]
|
||||||
|
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f14e48]:
|
||||||
|
- generic [ref=f14e50] [cursor=pointer]:
|
||||||
|
- checkbox "파생 쿼리 그대로" [ref=f14e51]
|
||||||
|
- generic [ref=f14e52]: 파생 쿼리 그대로
|
||||||
|
- generic [ref=f14e53] [cursor=pointer]:
|
||||||
|
- checkbox "컬렉션 fetch join" [ref=f14e54]
|
||||||
|
- generic [ref=f14e55]: 컬렉션 fetch join
|
||||||
|
- generic [ref=f14e56] [cursor=pointer]:
|
||||||
|
- checkbox "fetch join + 페이징" [ref=f14e57]
|
||||||
|
- generic [ref=f14e58]: fetch join + 페이징
|
||||||
|
- group "관계" [ref=f14e59]:
|
||||||
|
- generic [ref=f14e61]:
|
||||||
|
- generic [ref=f14e62]:
|
||||||
|
- generic [ref=f14e63]: 관계 1 대상
|
||||||
|
- combobox "관계 1 대상" [ref=f14e64]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준" [disabled]
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다" [selected]
|
||||||
|
- generic [ref=f14e65]:
|
||||||
|
- generic [ref=f14e66]: 관계 1 이유
|
||||||
|
- textbox "관계 1 이유" [ref=f14e67]: 이 관측에서 나온 결정이다.
|
||||||
|
- generic [aria-hidden] [ref=f14e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f14e69]:
|
||||||
|
- button "위로" [disabled] [ref=f14e70]
|
||||||
|
- button "아래로" [ref=f14e71]
|
||||||
|
- button "삭제" [ref=f14e72]
|
||||||
|
- generic [ref=f14e73]:
|
||||||
|
- generic [ref=f14e74]:
|
||||||
|
- generic [ref=f14e75]: 관계 2 대상
|
||||||
|
- combobox "관계 2 대상" [ref=f14e76]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준" [selected]
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다" [disabled]
|
||||||
|
- generic [ref=f14e77]:
|
||||||
|
- generic [ref=f14e78]: 관계 2 이유
|
||||||
|
- textbox "관계 2 이유" [ref=f14e79]: 남은 행 과조회를 푼 다음 단계의 기준이다.
|
||||||
|
- generic [aria-hidden] [ref=f14e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f14e81]:
|
||||||
|
- button "위로" [ref=f14e82]
|
||||||
|
- button "아래로" [ref=f14e83]
|
||||||
|
- button "삭제" [ref=f14e84]
|
||||||
|
- generic [ref=f14e85]:
|
||||||
|
- generic [ref=f14e86]:
|
||||||
|
- generic [ref=f14e87]: 관계 3 대상
|
||||||
|
- combobox "관계 3 대상" [ref=f14e88]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준" [disabled]
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다" [disabled]
|
||||||
|
- generic [ref=f14e89]:
|
||||||
|
- generic [ref=f14e90]: 관계 3 이유
|
||||||
|
- textbox "관계 3 이유" [ref=f14e91]: 왕복과 적재를 각각 어느 전략이 푸는지 정리한 기록이다.
|
||||||
|
- generic [aria-hidden] [ref=f14e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f14e93]:
|
||||||
|
- button "위로" [ref=f14e94]
|
||||||
|
- button "아래로" [disabled] [ref=f14e95]
|
||||||
|
- button "삭제" [ref=f14e96]
|
||||||
|
- button "관계 추가" [ref=f14e97]
|
||||||
|
- region [ref=f14e98]:
|
||||||
|
- generic [ref=f14e99]:
|
||||||
|
- paragraph [ref=f14e100]: CASE
|
||||||
|
- heading "문제와 검증" [level=2] [ref=f14e101]
|
||||||
|
- generic [ref=f14e102]:
|
||||||
|
- generic [ref=f14e103]:
|
||||||
|
- generic [ref=f14e104]: 문제
|
||||||
|
- textbox "문제" [ref=f14e105]: Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다. 화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
|
||||||
|
- generic [ref=f14e106]:
|
||||||
|
- generic [ref=f14e107]: 결론
|
||||||
|
- textbox "결론" [ref=f14e108]: 프로젝션은 하이드레이트한 엔티티를 0개로 만들었다. SELECT new 캐리어는 영속 엔티티 대신 스칼라 값으로 record를 만들므로 1차 캐시·더티체킹·지연 프록시도 생기지 않는다. join도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 발행 쿼리는 N과 관계없이 2개로 고정됐다. 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 페이지 부모가 최대 20개라 자식 IN도 한 번만 실행된다. 남은 문제는 행 수였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지에서 자식 행은 1,509개였고 화면에 필요한 것은 60개였다. 필요한 컬럼만 선택하면 EXPLAIN의 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
|
||||||
|
- generic [ref=f14e109]:
|
||||||
|
- generic [ref=f14e110]: 검증 환경
|
||||||
|
- textbox "검증 환경" [ref=f14e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 격리 프로젝션 측정은 배치 설정이 없는 별도 IT 클래스 loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드 측정 지표 entitiesLoaded : Statistics.getEntityLoadCount() prepared : Statistics.getPrepareStatementCount() collectionFetch : Statistics.getCollectionFetchCount()"
|
||||||
|
- generic [ref=f14e112]:
|
||||||
|
- generic [ref=f14e113]: 재현 조건
|
||||||
|
- textbox "재현 조건" [ref=f14e114]: "1. 부모 스칼라 프로젝션과 자식 IN 스칼라 프로젝션 두 쿼리로 loadFeedProjection을 구현한다. 2. seed 1,000에서 loadFeedProjection(0, 20)을 실행하고 getEntityLoadCount()를 읽는다. 3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다. 4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다. 5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다. 6. 부모 프로젝션과 엔티티 페이징의 EXPLAIN width를 비교한다."
|
||||||
|
- generic [ref=f14e115]:
|
||||||
|
- generic [ref=f14e116]: 마지막 검증일
|
||||||
|
- textbox "마지막 검증일" [ref=f14e117]
|
||||||
|
- generic [ref=f14e118]:
|
||||||
|
- generic [ref=f14e119]: 본문 Markdown
|
||||||
|
- group "Markdown 삽입" [ref=f14e120]:
|
||||||
|
- button "코드" [ref=f14e121] [cursor=pointer]
|
||||||
|
- button "표" [ref=f14e122] [cursor=pointer]
|
||||||
|
- button "목록" [ref=f14e123] [cursor=pointer]
|
||||||
|
- textbox "본문 Markdown" [ref=f14e124]: "## 두 개의 스칼라 프로젝션 ```java label=\"loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로\" // (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에 select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) from FeedItemJpaEntity f join f.user u join f.page p order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT // (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑 select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) from HighlightJpaEntity h where h.feedItem.id in (:pageIds) ``` FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다. ## 엔티티 로드가 0으로 줄어든다 | 지표 | 배치 | 프로젝션 | |---|---:|---:| | entitiesLoaded (seed 1,000) | 1,569 | 0 | | prepared (N=1,000) | 23 | 2 | | collectionFetch (N=1,000) | 10 | 0 | ## N이 늘어도 쿼리는 2개다 | N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) | |---:|---:|---:|---:| | 10 | 25 | 5 | 2 | | 100 | 222 | 5 | 2 | | 1,000 | 2,022 | 23 | 2 | 기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다. ## 남은 비용 — 페이지당 전량 :::evidence key=\"projection-row-over-fetch-f2b1943b\" alt=\"페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다.\" caption=\" \" zoom=\"true\" ::: | 항목 | 값 | |---|---:| | 페이지 부모 | 20 | | 자식 IN이 반환한 행 | 1,509 | | 화면에 필요한 행 | 60 (부모당 3) | 단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다. ## width는 좁아지지 않았다 ```text label=\"seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN\" -- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) -> Sort Sort Method: top-N heapsort Memory: 27kB -> Hash Join (fi.page_id = p.id) ← pages 조인 -> Hash Join (fi.user_id = u.id) ← users 조인 -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 -- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) Hash Semi Join (... rows=1509 loops=1) ``` 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다. ## 배치와 프로젝션은 다른 것을 줄인다 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다. 기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다."
|
||||||
|
- group [ref=f14e125]:
|
||||||
|
- paragraph [ref=f14e126]: EVIDENCE
|
||||||
|
- heading "본문에 Asset 삽입" [level=3] [ref=f14e127]
|
||||||
|
- paragraph [ref=f14e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||||||
|
- generic [ref=f14e129]:
|
||||||
|
- generic [ref=f14e130]:
|
||||||
|
- generic [ref=f14e131]: 업로드 종류
|
||||||
|
- combobox "업로드 종류" [ref=f14e132]:
|
||||||
|
- option "이미지" [selected]
|
||||||
|
- option "다이어그램"
|
||||||
|
- option "첨부파일"
|
||||||
|
- button "Asset 업로드" [ref=f14e133]
|
||||||
|
- generic [ref=f14e134]:
|
||||||
|
- search [ref=f14e135]:
|
||||||
|
- generic [ref=f14e136]: Asset 검색
|
||||||
|
- generic [ref=f14e137]:
|
||||||
|
- searchbox "Asset 검색" [ref=f14e138]
|
||||||
|
- button "검색" [ref=f14e139]
|
||||||
|
- generic [ref=f14e140]:
|
||||||
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f14e141]
|
||||||
|
- generic [ref=f14e142]: 삽입할 때 크게 보기 허용
|
||||||
|
- status [ref=f14e143]: 삽입할 수 있는 Asset 24개
|
||||||
|
- list [ref=f14e144]:
|
||||||
|
- listitem [ref=f14e145]:
|
||||||
|
- button "ap4-edge-trust-architecture-1a916e10" [ref=f14e146]
|
||||||
|
- button "삭제" [ref=f14e147]
|
||||||
|
- listitem [ref=f14e148]:
|
||||||
|
- button "ap3-bff-session-flow-1b005e15" [ref=f14e149]
|
||||||
|
- button "삭제" [ref=f14e150]
|
||||||
|
- listitem [ref=f14e151]:
|
||||||
|
- button "ap3-bff-architecture-a27ea91c" [ref=f14e152]
|
||||||
|
- button "삭제" [ref=f14e153]
|
||||||
|
- listitem [ref=f14e154]:
|
||||||
|
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f14e155]
|
||||||
|
- button "삭제" [ref=f14e156]
|
||||||
|
- listitem [ref=f14e157]:
|
||||||
|
- button "ap2-mediator-architecture-c95ed25f" [ref=f14e158]
|
||||||
|
- button "삭제" [ref=f14e159]
|
||||||
|
- listitem [ref=f14e160]:
|
||||||
|
- button "projection-row-over-fetch-f2b1943b" [ref=f14e161]
|
||||||
|
- button "삭제" [ref=f14e162]
|
||||||
|
- listitem [ref=f14e163]:
|
||||||
|
- button "cartesian-row-multiplication-dce2e166" [ref=f14e164]
|
||||||
|
- button "삭제" [ref=f14e165]
|
||||||
|
- listitem [ref=f14e166]:
|
||||||
|
- button "eager-lazy-query-sequence-47c12bda" [ref=f14e167]
|
||||||
|
- button "삭제" [ref=f14e168]
|
||||||
|
- listitem [ref=f14e169]:
|
||||||
|
- button "ap3-bff-session-flow-a8dfff6f" [ref=f14e170]
|
||||||
|
- button "삭제" [ref=f14e171]
|
||||||
|
- listitem [ref=f14e172]:
|
||||||
|
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f14e173]
|
||||||
|
- button "삭제" [ref=f14e174]
|
||||||
|
- listitem [ref=f14e175]:
|
||||||
|
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f14e176]
|
||||||
|
- button "삭제" [ref=f14e177]
|
||||||
|
- listitem [ref=f14e178]:
|
||||||
|
- button "ap3-csrf-boundary-971df81c" [ref=f14e179]
|
||||||
|
- button "삭제" [ref=f14e180]
|
||||||
|
- listitem [ref=f14e181]:
|
||||||
|
- button "login-api-phase-split-3e354274" [ref=f14e182]
|
||||||
|
- button "삭제" [ref=f14e183]
|
||||||
|
- listitem [ref=f14e184]:
|
||||||
|
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f14e185]
|
||||||
|
- button "삭제" [ref=f14e186]
|
||||||
|
- listitem [ref=f14e187]:
|
||||||
|
- button "ap1-direct-architecture-0adf4199" [ref=f14e188]
|
||||||
|
- button "삭제" [ref=f14e189]
|
||||||
|
- listitem [ref=f14e190]:
|
||||||
|
- button "nplus1-query-fanout-644febe6" [ref=f14e191]
|
||||||
|
- button "삭제" [ref=f14e192]
|
||||||
|
- listitem [ref=f14e193]:
|
||||||
|
- button "ap4-edge-trust-1cff2399" [ref=f14e194]
|
||||||
|
- button "삭제" [ref=f14e195]
|
||||||
|
- listitem [ref=f14e196]:
|
||||||
|
- button "ap3-csrf-split-501dd1f7" [ref=f14e197]
|
||||||
|
- button "삭제" [ref=f14e198]
|
||||||
|
- listitem [ref=f14e199]:
|
||||||
|
- button "ap3-bff-custody-82fa18bd" [ref=f14e200]
|
||||||
|
- button "삭제" [ref=f14e201]
|
||||||
|
- listitem [ref=f14e202]:
|
||||||
|
- button "ap2-split-custody-779cb791" [ref=f14e203]
|
||||||
|
- button "삭제" [ref=f14e204]
|
||||||
|
- listitem [ref=f14e205]:
|
||||||
|
- button "ap1-custody-v3-6e0376d2" [ref=f14e206]
|
||||||
|
- button "삭제" [ref=f14e207]
|
||||||
|
- listitem [ref=f14e208]:
|
||||||
|
- button "ap1-custody-v2-e110bd98" [ref=f14e209]
|
||||||
|
- button "삭제" [ref=f14e210]
|
||||||
|
- listitem [ref=f14e211]:
|
||||||
|
- button "ap1-credential-custody-f5e0c027" [ref=f14e212]
|
||||||
|
- button "삭제" [ref=f14e213]
|
||||||
|
- listitem [ref=f14e214]:
|
||||||
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f14e215]
|
||||||
|
- button "삭제" [ref=f14e216]
|
||||||
|
- region [ref=f14e217]:
|
||||||
|
- generic [ref=f14e218]:
|
||||||
|
- paragraph [ref=f14e219]: LIVE
|
||||||
|
- heading "즉시 미리보기" [level=2] [ref=f14e220]
|
||||||
|
- generic [ref=f14e223]:
|
||||||
|
- generic [ref=f14e224]:
|
||||||
|
- navigation "문서 경로" [ref=f14e225]:
|
||||||
|
- link "검증 기록" [ref=f14e226] [cursor=pointer]:
|
||||||
|
- /url: /explore/cases
|
||||||
|
- generic [aria-hidden] [ref=f14e227]: /
|
||||||
|
- generic [ref=f14e228]: JPA 피드 조회 성능
|
||||||
|
- generic [aria-hidden] [ref=f14e229]: /
|
||||||
|
- link "Liner N + 1문제" [ref=f14e230] [cursor=pointer]:
|
||||||
|
- /url: /projects/liner-n-plus-1
|
||||||
|
- heading "Projection 이후에도 1,509행을 읽은 Row Over-fetch" [level=1] [ref=f14e231]
|
||||||
|
- paragraph [ref=f14e232]: DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로 줄고 쿼리도 2개로 고정됐다. 그런데 자식 IN 쿼리는 페이지 부모 20개의 하이라이트를 전부 가져와 1,509행이었다. 화면에 필요한 것은 부모당 최신 3개, 최대 60행이었다.
|
||||||
|
- region "문제와 결론" [ref=f14e233]:
|
||||||
|
- generic [ref=f14e234]:
|
||||||
|
- paragraph [ref=f14e235]: 문제
|
||||||
|
- paragraph [ref=f14e236]: Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
|
||||||
|
- paragraph [ref=f14e237]: 화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
|
||||||
|
- generic [ref=f14e238]:
|
||||||
|
- paragraph [ref=f14e239]: 결론
|
||||||
|
- paragraph [ref=f14e240]: 프로젝션은 하이드레이트한 엔티티를 0개로 만들었다. SELECT new 캐리어는 영속 엔티티 대신 스칼라 값으로 record를 만들므로 1차 캐시·더티체킹·지연 프록시도 생기지 않는다. join도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다.
|
||||||
|
- paragraph [ref=f14e241]: 발행 쿼리는 N과 관계없이 2개로 고정됐다. 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 페이지 부모가 최대 20개라 자식 IN도 한 번만 실행된다.
|
||||||
|
- paragraph [ref=f14e242]: 남은 문제는 행 수였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지에서 자식 행은 1,509개였고 화면에 필요한 것은 60개였다.
|
||||||
|
- paragraph [ref=f14e243]: 필요한 컬럼만 선택하면 EXPLAIN의 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
|
||||||
|
- generic [ref=f14e244]:
|
||||||
|
- generic [ref=f14e245]:
|
||||||
|
- term [ref=f14e246]: 검증 환경
|
||||||
|
- definition [ref=f14e247]:
|
||||||
|
- paragraph [ref=f14e248]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
|
||||||
|
- paragraph [ref=f14e249]: 격리프로젝션 측정은 배치 설정이 없는 별도 IT 클래스loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드
|
||||||
|
- paragraph [ref=f14e250]: "측정 지표entitiesLoaded : Statistics.getEntityLoadCount()prepared : Statistics.getPrepareStatementCount()collectionFetch : Statistics.getCollectionFetchCount()"
|
||||||
|
- generic [ref=f14e251]:
|
||||||
|
- term [ref=f14e252]: 검증 데이터
|
||||||
|
- definition [ref=f14e253]:
|
||||||
|
- paragraph [ref=f14e254]: 1. 부모 스칼라 프로젝션과 자식 IN 스칼라 프로젝션 두 쿼리로 loadFeedProjection을 구현한다.
|
||||||
|
- paragraph [ref=f14e255]: 2. seed 1,000에서 loadFeedProjection(0, 20)을 실행하고 getEntityLoadCount()를 읽는다.
|
||||||
|
- paragraph [ref=f14e256]: "3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다."
|
||||||
|
- paragraph [ref=f14e257]: 4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다.
|
||||||
|
- paragraph [ref=f14e258]: 5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다.
|
||||||
|
- paragraph [ref=f14e259]: 6. 부모 프로젝션과 엔티티 페이징의 EXPLAIN width를 비교한다.
|
||||||
|
- generic [ref=f14e260]:
|
||||||
|
- term [ref=f14e261]: 기록
|
||||||
|
- definition [ref=f14e262]: 게시 게시 전 · 마지막 검증
|
||||||
|
- group [ref=f14e264]:
|
||||||
|
- generic "목차 · 두 개의 스칼라 프로젝션" [ref=f14e265] [cursor=pointer]
|
||||||
|
- article [ref=f14e267]:
|
||||||
|
- region [ref=f14e268]:
|
||||||
|
- heading [level=2] [ref=f14e269]:
|
||||||
|
- link "두 개의 스칼라 프로젝션 바로가기" [ref=f14e270] [cursor=pointer]:
|
||||||
|
- /url: "#두-개의-스칼라-프로젝션"
|
||||||
|
- text: 두 개의 스칼라 프로젝션
|
||||||
|
- generic [aria-hidden] [ref=f14e271]: "#"
|
||||||
|
- figure "JAVA ·loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로 코드 복사" [ref=f14e272]:
|
||||||
|
- generic [ref=f14e273]:
|
||||||
|
- generic [ref=f14e274]: JAVA
|
||||||
|
- generic [ref=f14e275]: ·loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로
|
||||||
|
- button "코드 복사" [ref=f14e276] [cursor=pointer]: 복사
|
||||||
|
- region "loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로 코드" [ref=f14e277]:
|
||||||
|
- code [ref=f14e278]: // (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에 select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) from FeedItemJpaEntity f join f.user u join f.page p order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT // (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑 select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
|
||||||
|
- paragraph [ref=f14e280]: FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
|
||||||
|
- region [ref=f14e281]:
|
||||||
|
- heading [level=2] [ref=f14e282]:
|
||||||
|
- link "엔티티 로드가 0으로 줄어든다 바로가기" [ref=f14e283] [cursor=pointer]:
|
||||||
|
- /url: "#엔티티-로드가-0으로-줄어든다"
|
||||||
|
- text: 엔티티 로드가 0으로 줄어든다
|
||||||
|
- generic [aria-hidden] [ref=f14e284]: "#"
|
||||||
|
- region "표" [ref=f14e285]:
|
||||||
|
- table [ref=f14e286]:
|
||||||
|
- caption [ref=f14e287]
|
||||||
|
- rowgroup [ref=f14e288]:
|
||||||
|
- row [ref=f14e289]:
|
||||||
|
- columnheader "지표" [ref=f14e290]
|
||||||
|
- columnheader "배치" [ref=f14e291]
|
||||||
|
- columnheader "프로젝션" [ref=f14e292]
|
||||||
|
- rowgroup [ref=f14e293]:
|
||||||
|
- row [ref=f14e294]:
|
||||||
|
- cell "entitiesLoaded (seed 1,000)" [ref=f14e295]
|
||||||
|
- cell "1,569" [ref=f14e296]
|
||||||
|
- cell "0" [ref=f14e297]
|
||||||
|
- row [ref=f14e298]:
|
||||||
|
- cell "prepared (N=1,000)" [ref=f14e299]
|
||||||
|
- cell "23" [ref=f14e300]
|
||||||
|
- cell "2" [ref=f14e301]
|
||||||
|
- row [ref=f14e302]:
|
||||||
|
- cell "collectionFetch (N=1,000)" [ref=f14e303]
|
||||||
|
- cell "10" [ref=f14e304]
|
||||||
|
- cell "0" [ref=f14e305]
|
||||||
|
- region [ref=f14e306]:
|
||||||
|
- heading [level=2] [ref=f14e307]:
|
||||||
|
- link "N이 늘어도 쿼리는 2개다 바로가기" [ref=f14e308] [cursor=pointer]:
|
||||||
|
- /url: "#n이-늘어도-쿼리는-2개다"
|
||||||
|
- text: N이 늘어도 쿼리는 2개다
|
||||||
|
- generic [aria-hidden] [ref=f14e309]: "#"
|
||||||
|
- region "표" [ref=f14e310]:
|
||||||
|
- table [ref=f14e311]:
|
||||||
|
- caption [ref=f14e312]
|
||||||
|
- rowgroup [ref=f14e313]:
|
||||||
|
- row [ref=f14e314]:
|
||||||
|
- columnheader "N" [ref=f14e315]
|
||||||
|
- columnheader "순진(1+N)" [ref=f14e316]
|
||||||
|
- columnheader "배치(1+ceil(N/batch)·연관)" [ref=f14e317]
|
||||||
|
- columnheader "프로젝션(상수)" [ref=f14e318]
|
||||||
|
- rowgroup [ref=f14e319]:
|
||||||
|
- row [ref=f14e320]:
|
||||||
|
- cell "10" [ref=f14e321]
|
||||||
|
- cell "25" [ref=f14e322]
|
||||||
|
- cell "5" [ref=f14e323]
|
||||||
|
- cell "2" [ref=f14e324]
|
||||||
|
- row [ref=f14e325]:
|
||||||
|
- cell "100" [ref=f14e326]
|
||||||
|
- cell "222" [ref=f14e327]
|
||||||
|
- cell "5" [ref=f14e328]
|
||||||
|
- cell "2" [ref=f14e329]
|
||||||
|
- row [ref=f14e330]:
|
||||||
|
- cell "1,000" [ref=f14e331]
|
||||||
|
- cell "2,022" [ref=f14e332]
|
||||||
|
- cell "23" [ref=f14e333]
|
||||||
|
- cell "2" [ref=f14e334]
|
||||||
|
- paragraph [ref=f14e335]: 기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
|
||||||
|
- region [ref=f14e336]:
|
||||||
|
- heading [level=2] [ref=f14e337]:
|
||||||
|
- link "남은 비용 — 페이지당 전량 바로가기" [ref=f14e338] [cursor=pointer]:
|
||||||
|
- /url: "#남은-비용-페이지당-전량"
|
||||||
|
- text: 남은 비용 — 페이지당 전량
|
||||||
|
- generic [aria-hidden] [ref=f14e339]: "#"
|
||||||
|
- figure [ref=f14e340]:
|
||||||
|
- button "projection-row-over-fetch-f2b1943b 이미지 크게 보기" [ref=f14e341]:
|
||||||
|
- img "페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다." [ref=f14e342]
|
||||||
|
- generic [ref=f14e343]: 크게 보기
|
||||||
|
- generic [ref=f14e344]: 페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다.
|
||||||
|
- region "표" [ref=f14e345]:
|
||||||
|
- table [ref=f14e346]:
|
||||||
|
- caption [ref=f14e347]
|
||||||
|
- rowgroup [ref=f14e348]:
|
||||||
|
- row [ref=f14e349]:
|
||||||
|
- columnheader "항목" [ref=f14e350]
|
||||||
|
- columnheader "값" [ref=f14e351]
|
||||||
|
- rowgroup [ref=f14e352]:
|
||||||
|
- row [ref=f14e353]:
|
||||||
|
- cell "페이지 부모" [ref=f14e354]
|
||||||
|
- cell "20" [ref=f14e355]
|
||||||
|
- row [ref=f14e356]:
|
||||||
|
- cell "자식 IN이 반환한 행" [ref=f14e357]
|
||||||
|
- cell "1,509" [ref=f14e358]
|
||||||
|
- row [ref=f14e359]:
|
||||||
|
- cell "화면에 필요한 행" [ref=f14e360]
|
||||||
|
- cell "60 (부모당 3)" [ref=f14e361]
|
||||||
|
- paragraph [ref=f14e362]: 단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다.
|
||||||
|
- region [ref=f14e363]:
|
||||||
|
- heading [level=2] [ref=f14e364]:
|
||||||
|
- link "width는 좁아지지 않았다 바로가기" [ref=f14e365] [cursor=pointer]:
|
||||||
|
- /url: "#width는-좁아지지-않았다"
|
||||||
|
- text: width는 좁아지지 않았다
|
||||||
|
- generic [aria-hidden] [ref=f14e366]: "#"
|
||||||
|
- figure "TEXT ·seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN 코드 복사" [ref=f14e367]:
|
||||||
|
- generic [ref=f14e368]:
|
||||||
|
- generic [ref=f14e369]: TEXT
|
||||||
|
- generic [ref=f14e370]: ·seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN
|
||||||
|
- button "코드 복사" [ref=f14e371] [cursor=pointer]: 복사
|
||||||
|
- region "seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN 코드" [ref=f14e372]:
|
||||||
|
- code [ref=f14e373]: "-- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) -> Sort Sort Method: top-N heapsort Memory: 27kB -> Hash Join (fi.page_id = p.id) ← pages 조인 -> Hash Join (fi.user_id = u.id) ← users 조인 -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 -- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) Hash Semi Join (... rows=1509 loops=1)"
|
||||||
|
- paragraph [ref=f14e375]: 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다.
|
||||||
|
- region [ref=f14e376]:
|
||||||
|
- heading [level=2] [ref=f14e377]:
|
||||||
|
- link "배치와 프로젝션은 다른 것을 줄인다 바로가기" [ref=f14e378] [cursor=pointer]:
|
||||||
|
- /url: "#배치와-프로젝션은-다른-것을-줄인다"
|
||||||
|
- text: 배치와 프로젝션은 다른 것을 줄인다
|
||||||
|
- generic [aria-hidden] [ref=f14e379]: "#"
|
||||||
|
- paragraph [ref=f14e380]: 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
|
||||||
|
- paragraph [ref=f14e381]: 기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다.
|
||||||
|
- complementary [ref=f14e382]:
|
||||||
|
- heading "작업 상태" [level=2] [ref=f14e383]
|
||||||
|
- status "편집 상태" [ref=f14e384]: 저장됨
|
||||||
|
- generic [ref=f14e385]:
|
||||||
|
- generic [ref=f14e386]:
|
||||||
|
- term [ref=f14e387]: 저장 버전
|
||||||
|
- definition [ref=f14e388]: "4"
|
||||||
|
- generic [ref=f14e389]:
|
||||||
|
- term [ref=f14e390]: 종류
|
||||||
|
- definition [ref=f14e391]: 검증 기록
|
||||||
|
- paragraph [ref=f14e392]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||||||
|
- generic [ref=f14e393]:
|
||||||
|
- button "저장" [disabled] [ref=f14e394]
|
||||||
|
- button "게시" [ref=f14e395]
|
||||||
|
- paragraph [ref=f14e396]: 버전 4으로 저장했습니다.
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
- generic [ref=f15e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f15e5]:
|
||||||
|
- generic [ref=f15e6]:
|
||||||
|
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
|
||||||
|
- /url: /studio
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f15e8]: Studio
|
||||||
|
- navigation "Studio 주 탐색" [ref=f15e10]:
|
||||||
|
- link "작업본" [ref=f15e11] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents
|
||||||
|
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
|
||||||
|
- /url: /studio/publications
|
||||||
|
- link "새 문서" [ref=f15e13] [cursor=pointer]:
|
||||||
|
- /url: /studio/documents/new
|
||||||
|
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
|
||||||
|
- /url: /studio/taxonomy
|
||||||
|
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
|
||||||
|
- /url: /studio/releases
|
||||||
|
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- button "로그아웃" [ref=f15e17]
|
||||||
|
- main [ref=f15e18]:
|
||||||
|
- generic [ref=f15e19]:
|
||||||
|
- generic [ref=f15e20]:
|
||||||
|
- region [ref=f15e21]:
|
||||||
|
- generic [ref=f15e22]:
|
||||||
|
- paragraph [ref=f15e23]: CASE · VERSION 45
|
||||||
|
- heading "문서 편집" [level=1] [ref=f15e24]
|
||||||
|
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
|
||||||
|
- region [ref=f15e26]:
|
||||||
|
- generic [ref=f15e27]:
|
||||||
|
- paragraph [ref=f15e28]: DOCUMENT
|
||||||
|
- heading "기본 정보" [level=2] [ref=f15e29]
|
||||||
|
- generic [ref=f15e30]:
|
||||||
|
- generic [ref=f15e31]:
|
||||||
|
- generic [ref=f15e32]: 제목
|
||||||
|
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
|
||||||
|
- generic [ref=f15e34]:
|
||||||
|
- generic [ref=f15e35]: slug
|
||||||
|
- textbox "slug" [ref=f15e36]:
|
||||||
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||||||
|
- text: collection-fetch-join-in-memory-paging
|
||||||
|
- generic [ref=f15e37]:
|
||||||
|
- generic [ref=f15e38]: 요약
|
||||||
|
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
|
||||||
|
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
|
||||||
|
- generic [ref=f15e41]:
|
||||||
|
- generic [ref=f15e42]: Topic
|
||||||
|
- combobox "Topic" [ref=f15e43]:
|
||||||
|
- option "선택하지 않음"
|
||||||
|
- option "JPA 피드 조회 성능" [selected]
|
||||||
|
- option "OAuth/OIDC 인증 경계"
|
||||||
|
- generic [ref=f15e44]:
|
||||||
|
- generic [ref=f15e45]: Project
|
||||||
|
- combobox "Project" [ref=f15e46]:
|
||||||
|
- option "미지정"
|
||||||
|
- option "Backend Clean Architecture"
|
||||||
|
- option "KeyCloak Patterns"
|
||||||
|
- option "Liner N + 1문제" [selected]
|
||||||
|
- status [ref=f15e47]
|
||||||
|
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
|
||||||
|
- generic [ref=f15e50] [cursor=pointer]:
|
||||||
|
- checkbox "파생 쿼리 그대로" [ref=f15e51]
|
||||||
|
- generic [ref=f15e52]: 파생 쿼리 그대로
|
||||||
|
- generic [ref=f15e53] [cursor=pointer]:
|
||||||
|
- checkbox "컬렉션 fetch join" [ref=f15e54]
|
||||||
|
- generic [ref=f15e55]: 컬렉션 fetch join
|
||||||
|
- generic [ref=f15e56] [cursor=pointer]:
|
||||||
|
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
|
||||||
|
- generic [ref=f15e58]: fetch join + 페이징
|
||||||
|
- group "관계" [ref=f15e59]:
|
||||||
|
- generic [ref=f15e61]:
|
||||||
|
- generic [ref=f15e62]:
|
||||||
|
- generic [ref=f15e63]: 관계 1 대상
|
||||||
|
- combobox "관계 1 대상" [ref=f15e64]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f15e65]:
|
||||||
|
- generic [ref=f15e66]: 관계 1 이유
|
||||||
|
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
|
||||||
|
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f15e69]:
|
||||||
|
- button "위로" [disabled] [ref=f15e70]
|
||||||
|
- button "아래로" [ref=f15e71]
|
||||||
|
- button "삭제" [ref=f15e72]
|
||||||
|
- generic [ref=f15e73]:
|
||||||
|
- generic [ref=f15e74]:
|
||||||
|
- generic [ref=f15e75]: 관계 2 대상
|
||||||
|
- combobox "관계 2 대상" [ref=f15e76]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f15e77]:
|
||||||
|
- generic [ref=f15e78]: 관계 2 이유
|
||||||
|
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
|
||||||
|
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f15e81]:
|
||||||
|
- button "위로" [ref=f15e82]
|
||||||
|
- button "아래로" [ref=f15e83]
|
||||||
|
- button "삭제" [ref=f15e84]
|
||||||
|
- generic [ref=f15e85]:
|
||||||
|
- generic [ref=f15e86]:
|
||||||
|
- generic [ref=f15e87]: 관계 3 대상
|
||||||
|
- combobox "관계 3 대상" [ref=f15e88]:
|
||||||
|
- option "대상 선택"
|
||||||
|
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||||||
|
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
|
||||||
|
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||||||
|
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
|
||||||
|
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
|
||||||
|
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
|
||||||
|
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
|
||||||
|
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
||||||
|
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
|
||||||
|
- option "Authorization Code와 PKCE가 보호하는 구간"
|
||||||
|
- option "Bearer JWT가 인증된 principal이 되기까지"
|
||||||
|
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
|
||||||
|
- option "브라우저가 credential을 보관하는 위치와 그 성질"
|
||||||
|
- option "Forward-Auth와 Nginx auth_request의 동작"
|
||||||
|
- option "외부 IdP Brokering의 동작"
|
||||||
|
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
|
||||||
|
- option "BFF 인증 구조 설계 기준"
|
||||||
|
- option "Feed Visibility Query Pattern"
|
||||||
|
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
|
||||||
|
- option "Fetch Type과 Fetch Strategy 구분"
|
||||||
|
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||||||
|
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||||||
|
- option "JPA N+1 정량 진단 기준"
|
||||||
|
- option "Keyset Pagination 설계 기준"
|
||||||
|
- option "OAuth/OIDC 인증 패턴 선택 기준"
|
||||||
|
- option "OAuth Token과 Application Session을 구분하는 기준"
|
||||||
|
- option "PostgreSQL Query Plan 측정 기준"
|
||||||
|
- option "Public Client와 Confidential Client 구분 기준"
|
||||||
|
- option "Top-N-per-group 선택 기준"
|
||||||
|
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
|
||||||
|
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
|
||||||
|
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
|
||||||
|
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
|
||||||
|
- option "feed_visible을 Production CQRS로 승격할 것인가"
|
||||||
|
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
|
||||||
|
- option "Highlight 없는 FeedItem을 허용할 것인가"
|
||||||
|
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
|
||||||
|
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
|
||||||
|
- option "BFF가 OAuth Token을 관리하는 조건"
|
||||||
|
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
|
||||||
|
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
|
||||||
|
- option "Feed Pagination은 Keyset을 사용한다"
|
||||||
|
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
|
||||||
|
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
|
||||||
|
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
|
||||||
|
- option "현재 Read Model은 CQRS-lite로 유지한다"
|
||||||
|
- option "화면 조회는 Read Projection을 사용한다"
|
||||||
|
- generic [ref=f15e89]:
|
||||||
|
- generic [ref=f15e90]: 관계 3 이유
|
||||||
|
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
|
||||||
|
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||||||
|
- generic [ref=f15e93]:
|
||||||
|
- button "위로" [ref=f15e94]
|
||||||
|
- button "아래로" [disabled] [ref=f15e95]
|
||||||
|
- button "삭제" [ref=f15e96]
|
||||||
|
- button "관계 추가" [ref=f15e97]
|
||||||
|
- region [ref=f15e98]:
|
||||||
|
- generic [ref=f15e99]:
|
||||||
|
- paragraph [ref=f15e100]: CASE
|
||||||
|
- heading "문제와 검증" [level=2] [ref=f15e101]
|
||||||
|
- generic [ref=f15e102]:
|
||||||
|
- generic [ref=f15e103]:
|
||||||
|
- generic [ref=f15e104]: 문제
|
||||||
|
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
|
||||||
|
- generic [ref=f15e106]:
|
||||||
|
- generic [ref=f15e107]: 결론
|
||||||
|
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
|
||||||
|
- generic [ref=f15e109]:
|
||||||
|
- generic [ref=f15e110]: 검증 환경
|
||||||
|
- textbox "검증 환경" [ref=f15e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 쿼리 : JPQL hibernate.query.fail_on_pagination_over _collection_fetch : false (기본값)"
|
||||||
|
- generic [ref=f15e112]:
|
||||||
|
- generic [ref=f15e113]: 재현 조건
|
||||||
|
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
|
||||||
|
- generic [ref=f15e115]:
|
||||||
|
- generic [ref=f15e116]: 마지막 검증일
|
||||||
|
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
|
||||||
|
- generic [ref=f15e118]:
|
||||||
|
- generic [ref=f15e119]: 본문 Markdown
|
||||||
|
- group "Markdown 삽입" [ref=f15e120]:
|
||||||
|
- button "코드" [ref=f15e121] [cursor=pointer]
|
||||||
|
- button "표" [ref=f15e122] [cursor=pointer]
|
||||||
|
- button "목록" [ref=f15e123] [cursor=pointer]
|
||||||
|
- textbox "본문 Markdown" [ref=f15e124]: "## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징 ``` 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다. ## 반환은 한 페이지인데 메모리에 전부 로드 | N | returned(페이지) | feedItemLoaded | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| | 10 | 6.184 ms | 6.566 ms | 약 1.5 MB | | 100 | 13.890 ms | 16.062 ms | 약 3.0 MB | | 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB | returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
|
||||||
|
- group [ref=f15e125]:
|
||||||
|
- paragraph [ref=f15e126]: EVIDENCE
|
||||||
|
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
|
||||||
|
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||||||
|
- generic [ref=f15e129]:
|
||||||
|
- generic [ref=f15e130]:
|
||||||
|
- generic [ref=f15e131]: 업로드 종류
|
||||||
|
- combobox "업로드 종류" [ref=f15e132]:
|
||||||
|
- option "이미지" [selected]
|
||||||
|
- option "다이어그램"
|
||||||
|
- option "첨부파일"
|
||||||
|
- button "Asset 업로드" [ref=f15e133]
|
||||||
|
- generic [ref=f15e134]:
|
||||||
|
- search [ref=f15e135]:
|
||||||
|
- generic [ref=f15e136]: Asset 검색
|
||||||
|
- generic [ref=f15e137]:
|
||||||
|
- searchbox "Asset 검색" [ref=f15e138]
|
||||||
|
- button "검색" [ref=f15e139]
|
||||||
|
- generic [ref=f15e140]:
|
||||||
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
|
||||||
|
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
|
||||||
|
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
|
||||||
|
- list [ref=f15e144]:
|
||||||
|
- listitem [ref=f15e145]:
|
||||||
|
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
|
||||||
|
- button "삭제" [ref=f15e147]
|
||||||
|
- listitem [ref=f15e148]:
|
||||||
|
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
|
||||||
|
- button "삭제" [ref=f15e150]
|
||||||
|
- listitem [ref=f15e151]:
|
||||||
|
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
|
||||||
|
- button "삭제" [ref=f15e153]
|
||||||
|
- listitem [ref=f15e154]:
|
||||||
|
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
|
||||||
|
- button "삭제" [ref=f15e156]
|
||||||
|
- listitem [ref=f15e157]:
|
||||||
|
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
|
||||||
|
- button "삭제" [ref=f15e159]
|
||||||
|
- listitem [ref=f15e160]:
|
||||||
|
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
|
||||||
|
- button "삭제" [ref=f15e162]
|
||||||
|
- listitem [ref=f15e163]:
|
||||||
|
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
|
||||||
|
- button "삭제" [ref=f15e165]
|
||||||
|
- listitem [ref=f15e166]:
|
||||||
|
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
|
||||||
|
- button "삭제" [ref=f15e168]
|
||||||
|
- listitem [ref=f15e169]:
|
||||||
|
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
|
||||||
|
- button "삭제" [ref=f15e171]
|
||||||
|
- listitem [ref=f15e172]:
|
||||||
|
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
|
||||||
|
- button "삭제" [ref=f15e174]
|
||||||
|
- listitem [ref=f15e175]:
|
||||||
|
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
|
||||||
|
- button "삭제" [ref=f15e177]
|
||||||
|
- listitem [ref=f15e178]:
|
||||||
|
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
|
||||||
|
- button "삭제" [ref=f15e180]
|
||||||
|
- listitem [ref=f15e181]:
|
||||||
|
- button "login-api-phase-split-3e354274" [ref=f15e182]
|
||||||
|
- button "삭제" [ref=f15e183]
|
||||||
|
- listitem [ref=f15e184]:
|
||||||
|
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
|
||||||
|
- button "삭제" [ref=f15e186]
|
||||||
|
- listitem [ref=f15e187]:
|
||||||
|
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
|
||||||
|
- button "삭제" [ref=f15e189]
|
||||||
|
- listitem [ref=f15e190]:
|
||||||
|
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
|
||||||
|
- button "삭제" [ref=f15e192]
|
||||||
|
- listitem [ref=f15e193]:
|
||||||
|
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
|
||||||
|
- button "삭제" [ref=f15e195]
|
||||||
|
- listitem [ref=f15e196]:
|
||||||
|
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
|
||||||
|
- button "삭제" [ref=f15e198]
|
||||||
|
- listitem [ref=f15e199]:
|
||||||
|
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
|
||||||
|
- button "삭제" [ref=f15e201]
|
||||||
|
- listitem [ref=f15e202]:
|
||||||
|
- button "ap2-split-custody-779cb791" [ref=f15e203]
|
||||||
|
- button "삭제" [ref=f15e204]
|
||||||
|
- listitem [ref=f15e205]:
|
||||||
|
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
|
||||||
|
- button "삭제" [ref=f15e207]
|
||||||
|
- listitem [ref=f15e208]:
|
||||||
|
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
|
||||||
|
- button "삭제" [ref=f15e210]
|
||||||
|
- listitem [ref=f15e211]:
|
||||||
|
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
|
||||||
|
- button "삭제" [ref=f15e213]
|
||||||
|
- listitem [ref=f15e214]:
|
||||||
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
|
||||||
|
- button "삭제" [ref=f15e216]
|
||||||
|
- dialog [ref=f15e217]:
|
||||||
|
- generic [ref=f15e218]:
|
||||||
|
- paragraph [ref=f15e219]: ASSET UPLOAD
|
||||||
|
- heading "Asset 업로드" [level=2] [ref=f15e220]
|
||||||
|
- paragraph [ref=f15e221]: 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 아니면 대체 텍스트가 필요합니다.
|
||||||
|
- generic [ref=f15e222]:
|
||||||
|
- generic [ref=f15e223]: Asset 파일
|
||||||
|
- button "Asset 파일" [active] [ref=f15e224]
|
||||||
|
- generic [ref=f15e225]:
|
||||||
|
- checkbox "장식용 이미지 (대체 텍스트 없음)" [ref=f15e226]
|
||||||
|
- generic [ref=f15e227]: 장식용 이미지 (대체 텍스트 없음)
|
||||||
|
- generic [ref=f15e228]:
|
||||||
|
- generic [ref=f15e229]: 대체 텍스트
|
||||||
|
- textbox "대체 텍스트" [ref=f15e230]
|
||||||
|
- status "업로드 상태" [ref=f15e231]
|
||||||
|
- generic [ref=f15e232]:
|
||||||
|
- button "닫기" [ref=f15e233]
|
||||||
|
- button "업로드" [ref=f15e234]
|
||||||
|
- region [ref=f15e235]:
|
||||||
|
- generic [ref=f15e236]:
|
||||||
|
- paragraph [ref=f15e237]: LIVE
|
||||||
|
- heading "즉시 미리보기" [level=2] [ref=f15e238]
|
||||||
|
- generic [ref=f15e241]:
|
||||||
|
- generic [ref=f15e242]:
|
||||||
|
- navigation "문서 경로" [ref=f15e243]:
|
||||||
|
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
|
||||||
|
- /url: /explore/cases
|
||||||
|
- generic [aria-hidden] [ref=f15e245]: /
|
||||||
|
- generic [ref=f15e246]: JPA 피드 조회 성능
|
||||||
|
- generic [aria-hidden] [ref=f15e247]: /
|
||||||
|
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
|
||||||
|
- /url: /projects/liner-n-plus-1
|
||||||
|
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
|
||||||
|
- paragraph [ref=f15e250]:
|
||||||
|
- text: 컬렉션 하나만
|
||||||
|
- code [ref=f15e251]: fetch join
|
||||||
|
- text: 하고
|
||||||
|
- code [ref=f15e252]: setMaxResults(20)
|
||||||
|
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
|
||||||
|
- paragraph [ref=f15e253]:
|
||||||
|
- text: 하지만 컬렉션
|
||||||
|
- code [ref=f15e254]: fetch join
|
||||||
|
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
|
||||||
|
- code [ref=f15e255]: LIMIT 20
|
||||||
|
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
|
||||||
|
- paragraph [ref=f15e256]:
|
||||||
|
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
|
||||||
|
- code [ref=f15e257]: setMaxResults(20)
|
||||||
|
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
|
||||||
|
- region "문제와 결론" [ref=f15e258]:
|
||||||
|
- generic [ref=f15e259]:
|
||||||
|
- paragraph [ref=f15e260]: 문제
|
||||||
|
- paragraph [ref=f15e261]:
|
||||||
|
- text: 컬렉션을
|
||||||
|
- code [ref=f15e262]: fetch join
|
||||||
|
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
|
||||||
|
- paragraph [ref=f15e263]:
|
||||||
|
- text: 실제로
|
||||||
|
- code [ref=f15e264]: setMaxResults(20)
|
||||||
|
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
|
||||||
|
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
|
||||||
|
- generic [ref=f15e266]:
|
||||||
|
- paragraph [ref=f15e267]: 결론
|
||||||
|
- paragraph [ref=f15e268]:
|
||||||
|
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
|
||||||
|
- code [ref=f15e269]: feedItemLoaded
|
||||||
|
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
|
||||||
|
- paragraph [ref=f15e270]:
|
||||||
|
- text: 컬렉션
|
||||||
|
- code [ref=f15e271]: fetch join
|
||||||
|
- text: 에서는 조인 결과에
|
||||||
|
- code [ref=f15e272]: LIMIT
|
||||||
|
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
|
||||||
|
- code [ref=f15e273]: LIMIT
|
||||||
|
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
|
||||||
|
- code [ref=f15e274]: Limit
|
||||||
|
- text: 노드가 없었다.
|
||||||
|
- generic [ref=f15e275]:
|
||||||
|
- generic [ref=f15e276]:
|
||||||
|
- term [ref=f15e277]: 검증 환경
|
||||||
|
- definition [ref=f15e278]:
|
||||||
|
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
|
||||||
|
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
|
||||||
|
- generic [ref=f15e281]:
|
||||||
|
- term [ref=f15e282]: 검증 데이터
|
||||||
|
- definition [ref=f15e283]:
|
||||||
|
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
|
||||||
|
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
|
||||||
|
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
|
||||||
|
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
|
||||||
|
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
|
||||||
|
- generic [ref=f15e289]:
|
||||||
|
- term [ref=f15e290]: 기록
|
||||||
|
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
|
||||||
|
- group [ref=f15e293]:
|
||||||
|
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
|
||||||
|
- article [ref=f15e296]:
|
||||||
|
- region [ref=f15e297]:
|
||||||
|
- heading [level=2] [ref=f15e298]:
|
||||||
|
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
|
||||||
|
- /url: "#컬렉션-fetch-join에-페이징"
|
||||||
|
- text: 컬렉션 Fetch join에 페이징
|
||||||
|
- generic [aria-hidden] [ref=f15e300]: "#"
|
||||||
|
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
|
||||||
|
- generic [ref=f15e302]:
|
||||||
|
- generic [ref=f15e303]: JAVA
|
||||||
|
- generic [ref=f15e304]: ·통합 테스트
|
||||||
|
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
|
||||||
|
- region "통합 테스트 코드" [ref=f15e306]:
|
||||||
|
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
|
||||||
|
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
|
||||||
|
- region [ref=f15e310]:
|
||||||
|
- heading [level=2] [ref=f15e311]:
|
||||||
|
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
|
||||||
|
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
|
||||||
|
- text: 반환은 한 페이지인데 메모리에 전부 로드
|
||||||
|
- generic [aria-hidden] [ref=f15e313]: "#"
|
||||||
|
- region "표" [ref=f15e314]:
|
||||||
|
- table [ref=f15e315]:
|
||||||
|
- caption [ref=f15e316]
|
||||||
|
- rowgroup [ref=f15e317]:
|
||||||
|
- row [ref=f15e318]:
|
||||||
|
- columnheader "N" [ref=f15e319]
|
||||||
|
- columnheader "returned(페이지)" [ref=f15e320]
|
||||||
|
- columnheader "feedItemLoaded" [ref=f15e321]
|
||||||
|
- columnheader "시드 하이라이트" [ref=f15e322]
|
||||||
|
- rowgroup [ref=f15e323]:
|
||||||
|
- row [ref=f15e324]:
|
||||||
|
- cell "10" [ref=f15e325]
|
||||||
|
- cell "10" [ref=f15e326]
|
||||||
|
- cell "10" [ref=f15e327]
|
||||||
|
- cell "1,285" [ref=f15e328]
|
||||||
|
- row [ref=f15e329]:
|
||||||
|
- cell "100" [ref=f15e330]
|
||||||
|
- cell "20" [ref=f15e331]
|
||||||
|
- cell "100" [ref=f15e332]
|
||||||
|
- cell "1,961" [ref=f15e333]
|
||||||
|
- row [ref=f15e334]:
|
||||||
|
- cell "1,000" [ref=f15e335]
|
||||||
|
- cell "20" [ref=f15e336]
|
||||||
|
- cell "1,000" [ref=f15e337]
|
||||||
|
- cell "2,917" [ref=f15e338]
|
||||||
|
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
|
||||||
|
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
|
||||||
|
- region [ref=f15e341]:
|
||||||
|
- heading [level=2] [ref=f15e342]:
|
||||||
|
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
|
||||||
|
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
|
||||||
|
- text: Fetch join에 페이징을 적용 했을 때의 경고
|
||||||
|
- generic [aria-hidden] [ref=f15e344]: "#"
|
||||||
|
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
|
||||||
|
- generic [ref=f15e346]:
|
||||||
|
- generic [ref=f15e347]: TEXT
|
||||||
|
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
|
||||||
|
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
|
||||||
|
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
|
||||||
|
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
|
||||||
|
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
|
||||||
|
- region [ref=f15e354]:
|
||||||
|
- heading [level=2] [ref=f15e355]:
|
||||||
|
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
|
||||||
|
- /url: "#페이지가-아닌-데이터셋에-비례한다"
|
||||||
|
- text: 페이지가 아닌 데이터셋에 비례한다
|
||||||
|
- generic [aria-hidden] [ref=f15e357]: "#"
|
||||||
|
- region "표" [ref=f15e358]:
|
||||||
|
- table [ref=f15e359]:
|
||||||
|
- caption [ref=f15e360]
|
||||||
|
- rowgroup [ref=f15e361]:
|
||||||
|
- row [ref=f15e362]:
|
||||||
|
- columnheader "N" [ref=f15e363]
|
||||||
|
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
|
||||||
|
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
|
||||||
|
- columnheader "스레드 누적 할당" [ref=f15e366]
|
||||||
|
- rowgroup [ref=f15e367]:
|
||||||
|
- row [ref=f15e368]:
|
||||||
|
- cell "10" [ref=f15e369]
|
||||||
|
- cell "6.184 ms" [ref=f15e370]
|
||||||
|
- cell "6.566 ms" [ref=f15e371]
|
||||||
|
- cell "약 1.5 MB" [ref=f15e372]
|
||||||
|
- row [ref=f15e373]:
|
||||||
|
- cell "100" [ref=f15e374]
|
||||||
|
- cell "13.890 ms" [ref=f15e375]
|
||||||
|
- cell "16.062 ms" [ref=f15e376]
|
||||||
|
- cell "약 3.0 MB" [ref=f15e377]
|
||||||
|
- row [ref=f15e378]:
|
||||||
|
- cell "1,000" [ref=f15e379]
|
||||||
|
- cell "79.452 ms" [ref=f15e380]
|
||||||
|
- cell "83.526 ms" [ref=f15e381]
|
||||||
|
- cell "약 10.0 MB" [ref=f15e382]
|
||||||
|
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
|
||||||
|
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
|
||||||
|
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
|
||||||
|
- region [ref=f15e386]:
|
||||||
|
- heading [level=2] [ref=f15e387]:
|
||||||
|
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
|
||||||
|
- /url: "#sql에-limit-노드가-없다"
|
||||||
|
- text: SQL에 LIMIT 노드가 없다
|
||||||
|
- generic [aria-hidden] [ref=f15e389]: "#"
|
||||||
|
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
|
||||||
|
- generic [ref=f15e391]:
|
||||||
|
- generic [ref=f15e392]: TEXT
|
||||||
|
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
|
||||||
|
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
|
||||||
|
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
|
||||||
|
- code [ref=f15e396]: "-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1)"
|
||||||
|
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
|
||||||
|
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
|
||||||
|
- region [ref=f15e400]:
|
||||||
|
- heading [level=2] [ref=f15e401]:
|
||||||
|
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
|
||||||
|
- /url: "#다음-선택"
|
||||||
|
- text: 다음 선택
|
||||||
|
- generic [aria-hidden] [ref=f15e403]: "#"
|
||||||
|
- paragraph [ref=f15e404]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
|
||||||
|
- region [ref=f15e405]:
|
||||||
|
- paragraph [ref=f15e406]: Next
|
||||||
|
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
|
||||||
|
- list [ref=f15e408]:
|
||||||
|
- listitem [ref=f15e409]:
|
||||||
|
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
|
||||||
|
- /url: /cases/fetch-join-multibag-and-row-explosion
|
||||||
|
- generic [ref=f15e411]: 검증 기록
|
||||||
|
- generic [ref=f15e412]:
|
||||||
|
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||||||
|
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
|
||||||
|
- generic [aria-hidden] [ref=f15e415]: ↗
|
||||||
|
- complementary [ref=f15e416]:
|
||||||
|
- heading "작업 상태" [level=2] [ref=f15e417]
|
||||||
|
- status "편집 상태" [ref=f15e418]: 저장됨
|
||||||
|
- generic [ref=f15e419]:
|
||||||
|
- generic [ref=f15e420]:
|
||||||
|
- term [ref=f15e421]: 저장 버전
|
||||||
|
- definition [ref=f15e422]: "45"
|
||||||
|
- generic [ref=f15e423]:
|
||||||
|
- term [ref=f15e424]: 종류
|
||||||
|
- definition [ref=f15e425]: 검증 기록
|
||||||
|
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||||||
|
- generic [ref=f15e427]:
|
||||||
|
- button "저장" [disabled] [ref=f15e428]
|
||||||
|
- button "게시" [ref=f15e429]
|
||||||
|
- paragraph [ref=f15e430]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user