Compare commits

...
16 Commits
Author SHA1 Message Date
DongHyeonka 43e1aadef0 feat: 가상화 문서들 추가 2026-09-10 08:54:05 +09:00
DongHyeonkaandClaude Opus 5 e9f6a93327 docs(TechLog): 도메인 규칙을 기록에 엮는다
§1.4 로 세운 도메인·비즈니스 규칙을 그것이 실제로 설명하는 기록에 넣었다.

  프로젝트가 문서 게시 파이프라인을 안 타는 이유 → 화면 다섯이 비어 있던 Case
  홈 focus 설정이 FK 없이 사는 설계 → 「열린 질문이 없습니다」 Case
  결정이 자기 화면을 안 갖는 이유 → 목록이 문서 전체를 실어야 했던 Case
  게시가 단계마다 다른 코드로 거절하는 설계 → 화면이 추측 셋을 출력한 Case (반대 사례)
  종류마다 애그리거트와 테이블이 다르다 → 매퍼가 종류를 판정해야 하는 Case
  축을 지우면 연결만 끊고 주제를 지우면 거절하는 이유 → 축 Concept
  개념이 문서 테이블에 얹힌다 → 열세 곳 Case
  화면 상태와 도메인 상태가 원래 갈려 있었다 → 이름을 두 번 바꾼 Case

Case 본문 중앙값 675 → 1,342 자. 검사 넷 전부 통과한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:37:13 +09:00
DongHyeonkaandClaude Opus 5 4769e52e48 docs(TechLog): SSOT 에 도메인과 비즈니스 규칙을 넣는다
이 문서는 결함 카탈로그로만 있었고 이 시스템이 무엇을 하는지가 없었다. 두 저장소의
도메인·유스케이스에서 확인해 §1.4 를 세웠다.

  종류 다섯이 각자 자기 애그리거트와 테이블을 갖는다 (ADR-003) — 개념은 문서 테이블에
  얹히고 concept_detail 에 기준 버전만 따로 둔다. §3 의 열세 건과 §14 의 외래키 없는
  축 표가 여기서 나온다
  화면 상태와 도메인 상태가 다르다 — 질문의 OPEN·INVESTIGATING·PAUSED 가 화면에서 하나로
  접히고 결정의 ACCEPTED 가 ADOPTED 로 보인다
  NextAction 판정 순서 — 서버가 조회 시점에 계산하고 저장하지 않는다. 프론트가 여러
  endpoint 를 조합해 workflow 를 재추론하지 않게 하려는 것이다
  검증·미리보기가 버려지지 않는 산출물인 이유와 그것이 유효한 조건 셋
  게시가 단계마다 다른 코드로 거절하는 이유 — 고칠 것과 다시 검증할 것이 다르다
  저장할 때와 공개할 때의 요구가 다르다 · 문서가 아닌 것은 다른 경로로 공개된다 ·
  주제는 참조가 있으면 안 지우고 축은 연결만 끊는다 · 없는 것을 가리키는 설정을 막는다

SSOT 62,643 → 71,870 자. 인용은 전부 저장소의 javadoc 과 코드에서 옮겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:35:11 +09:00
DongHyeonkaandClaude Opus 5 fd221353a3 docs(TechLog): 얇은 Case 열 편과 Question 셋을 저장소 실물로 채운다
SSOT 를 저장소에서 확인해 더 보강하고 그것으로 다시 썼다.

  §7.2   참조 검사 SQL 을 문자열로 조립하는 실제 코드 — 컴파일러가 표 이름도
         컬럼 이름도 보지 않는다는 것이 그 모양에서 드러난다
  §11.2  section-heading-rank 가 미디어 쿼리 값을 먼저 걷어내는 이유(테스트 주석)
  §16.1  질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다는 대조

Case 열 편과 Question 셋을 다시 썼다. Question 은 사실·가정·미지수·제약을 갈라
채우고 선택지마다 무엇을 감수하는지 적었다 — 오류 코드를 나누면 계약과 반입한 두
저장소가 함께 움직인다는 것처럼.

SSOT 62,643 → 68,319 자. 검사 넷 전부 통과한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:18:37 +09:00
DongHyeonkaandClaude Opus 5 6917ce2420 docs(TechLog): 남은 주제를 다시 쓰고 SSOT 를 저장소 실물로 더 보강한다
주제 11~13 을 다시 쓰고, Case 가 얇은 것들을 저장소에서 실물을 확인해 채웠다.

  §13.4  ManagementClientSafeMessages — 삭제 관련 코드 여섯의 고정 문구와
         원문 메시지를 내보내지 않는 이유(javadoc)
  §16.1  다섯 참조가 전부 DOCUMENT_IN_USE 하나로 나가고, SSOT 가 인용한 영어 문장은
         DeleteDocumentDraftUseCase 안에 남는 진단 메시지라 밖으로 나가지 않는다
  §13.6  romanizeSyllable 실물과 음운 변동을 뺀 이유, 문서 slug 와 같은 정규식을 쓰는 이유
  §15.4  check:types 가 도는 tsconfig 여섯 — app·node·test·recipes·web-worker·service-worker

SSOT 62,643 → 67,526 자. 인용한 코드는 전부 저장소에서 찾아 대조했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:12:37 +09:00
DongHyeonkaandClaude Opus 5 b1653dbba8 docs(TechLog): 주제 7~10 을 다시 쓴다
주소가 게시 시점에 굳어 저장되는 구조, 축 링크를 두 번 옮긴 순서, 한글 slug 가
간헐적으로 보인 두 가지 어긋남을 표로 갈랐다. 화면이 실패를 없음으로 그릴 때 작성
도구에서 왜 더 오래 숨는지, Promise.all 이 거절과 던짐에서 다른 경로를 타는 이유를
채웠다. CSS module 이 왜 전역 규칙에 닿지 않는지, 403 과 404 가 원인을 어떻게
좁혔는지도 적었다.

link-audit.py 를 감사 Case 의 evidence 로 걸어 배정한 증거 하나를 메웠다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:06:20 +09:00
DongHyeonkaandClaude Opus 5 193da20d09 docs(TechLog): Reference 15편의 규칙 표기를 게시된 기록에 맞추고 주제 6 을 다시 쓴다
게시된 Reference 15편이 전부 규칙을 `### N. 제목` 으로 쓰고 적용 조건·예외·예시를 항목으로
쓴다. 내 15편은 규칙을 `**굵게**` 로, 나머지 셋을 문단으로 쓰고 있었다 — Studio 의
rules[]·applyWhen[]·exceptions[]·examples[] 는 배열이라 문단으로 두면 항목이 하나로 접힌다.

  규칙 68개를 `### N. 제목` 으로 바꿨다 (편당 3~7개, 게시된 것은 4~10개)
  적용 조건·예외·예시를 항목으로 갈랐다. 한 항목뿐이던 아홉 편은 조건을 나눠 적었다

주제 6 은 본문을 다시 썼다 — location = 이 정확히 일치하는 경로만 잡아 27개가 얼어붙은
구조, 여덟 곳이 우는 시점을 셋으로 가른 표, digest 를 다시 계산할 때 옛 값을 먼저
재현하는 이유.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:01:44 +09:00
DongHyeonkaandClaude Opus 5 53537e37e8 docs(TechLog): 주제 4·5 를 스킬대로 다시 쓴다
what-the-compiler-lets-through  중앙값 2,096 → 2,590 자
  seams-no-test-crosses                    → 3,091 자

bivariance 가 왜 느슨한 판정을 받는지, never 캐스트가 왜 아무것도 요구하지 않는지처럼
「이름을 댔으면 왜 있는지도 댄다」를 채웠다. 네 가지가 각각 무엇을 통과시키고 어디서
드러났는지를 표로 갈랐다. 검사 넷 중 무엇이 SQL 을 실제로 돌리는지도 표로 세웠다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:57:59 +09:00
DongHyeonkaandClaude Opus 5 a8ce0dda07 docs(TechLog): 주제 셋을 스킬대로 다시 쓰고 SSOT 를 저장소 실물로 고친다
건너뛴 참조 다섯을 읽고 나서 다시 썼다 — from-ssot-to-records.md 의 「그림과 증거는
배정 대상이다」, code-tables-diagrams.md 의 표·코드 규칙, explaining.md 의 「이름을
댔으면 왜 있는지도 댄다」.

**SSOT 를 먼저 고쳤다.** §3.3 의 코드블록이 저장소와 달랐다 — PATH_PREFIX_KINDS 는
Record 표가 아니라 튜플 배열이고, 진짜 경로 표는 EXPLORE_KIND_PATHS 다. 저장소에서
확인해 실물로 바꾸고, javadoc 이 적어 둔 이유를 함께 옮겼다. §16.7 에 BRANCH_FIELDS 와
pathOf 실물을, §4.3 에 ContractRouteCoverageTest 의 javadoc 과 면제 상수 둘을 더했다.
62,643 → 65,737 자.

**계약에 ssot-assets·ssot-evidence 를 배정했다.** 그 절차를 건너뛰어서 SSOT 가 이미
가진 그림과 측정이 글감에 배정되지 않은 채였다. TechLog 12 글감, keycloak-session-store
는 그림 21장·증거 19건을 배정하고 붙일 글감이 없는 그림 4장은 이유를 계약에 적었다.
배정하자 검사기가 「배정한 증거를 기록이 쓰지 않는다」 4건을 드러냈다.

**주제 셋을 다시 썼다.**
  hand-listed-kinds            중앙값 1,925 → 3,760 자
  declared-but-not-implemented          → 2,608 자
  values-lost-between-boundaries        → 2,602 자

게시된 기록은 keycloak 4,546 · n+1liner 3,190 이다. 표와 코드를 SSOT 에서 옮기고,
Reference 에 담을 수 없던 표(§5.5 의 여덟 자리)를 짝이 되는 Case 로 내렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:54:45 +09:00
DongHyeonkaandClaude Opus 5 6feee5ba57 docs(TechLog): 자료에 남아 있던 사람의 흔적을 제자리에 놓는다
writing-as-the-person-who-did-it 을 서브에이전트 셋으로 나눠 56편에 적용했다.
56편 중 20편만 고쳤다 — 나머지 36편은 SSOT 를 절 단위로 대조했을 때 옮길 흔적이
이미 옮겨져 있었거나 없었다. 없는 목소리를 채우지 않는다.

옮긴 것은 전부 SSOT 의 어느 절에서 왔는지 댈 수 있다.

  §3.4  「눈으로 찾을 일이 아니었다」— 세 계약을 파싱해 뽑은 이유
  §4.3  구현하지 않기로 한 것과 빠뜨린 것은 다르다
  §8.5  표의 마지막 줄을 더할 때 이 목록을 또 빠뜨렸다
  §9.4  「왜 주제 링크가 탐색으로 가지?」— 우회를 남겨 두면 계속 나온 질문
  §11.3 「세 버튼」을 실제 이름으로 되돌리고 두 언어가 섞인 것을 그 자리에
  §12.2 막지 않은 대신 메모리에 남긴 것
  §13.1 여섯 벌 인용이 어느 커밋이 짚은 말인지
  §13.3 고쳐 쓴 첫 안이 거절당한 것과 사용자가 고른 말 두 쌍
  §14.3 「문서가 그대로 나온다」는 구조 차이가 아니라 내용 양의 차이라는 정정
  §16.1 삭제가 막힌 실제 기록 이름과 그것을 막은 프로젝트 링크
  §16.9 주제 논지와 축 결론이 AI 가 써서 DB 에 직접 넣은 미검토 초안이라는 것
  §17.5 바운딩 박스로 잘못 지목한 대상이 「판단 기준」이었다는 것

검증 환경의 커밋 해시 하나가 틀려 있었다(ca1cfa2 → ca1fc92). SSOT §13.1 과
부록 A 가 적은 값이고 저장소에 그 커밋이 있다.

검사 넷 전부 통과한다 — check_prose 56편 error 0 · check_body PASS ·
check_evidence --repo 문제 없음 · verify-tech-log-tree 프로젝트 5 error 0 warn 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:31:48 +09:00
DongHyeonkaandClaude Opus 5 0650d91def docs(TechLog): 설명 뒤에 붙은 평가·예고·되풀이를 걷어낸다
rewriting-technical-prose-naturally 를 서브에이전트 셋으로 나눠 56편에 적용했다.
ai-tells.md 의 첫 절대로 다른 표현으로 바꾸는 대신 문장을 통째로 지웠다.

  설명한 것의 중요성을 다시 평가하는 꼬리   19
  이미 설명한 것을 추상어로 되풀이           19
  독자에게 읽는 법을 지시하거나 오해를 가정   9
  자료가 뒷받침하지 않는 덧붙인 이득          4

문서군 전체의 문형 편중도 풀었다 — 함께 27→7(한 묶음), 그대로 22→12(두 묶음),
하게 된다 1→0. 한 편에서 세 번 반복되던 「같은 병이 ~에서도 났다」와 두 기록에
같은 문장으로 있던 세 쌍을 갈랐다.

계약 제목 「여덟 자리」가 본문의 「여덟 곳」과 어긋나 있었다. 제목이 spatial-metaphor
규칙에도 걸리므로 계약과 기록을 함께 「여덟 곳」으로 맞췄다.

검사 넷 전부 통과한다 — check_prose 56편 error 0 · check_body PASS ·
check_evidence --repo 문제 없음 · verify-tech-log-tree error 0 warn 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:03:16 +09:00
DongHyeonkaandClaude Opus 5 fc23660871 docs(keycloak-session-store): research the 42 concepts the lab used and write them up
Replaces the to-do list with the work itself. Eight layers, 42 concepts,
1200 lines, each with what it is, why it turns up here, how it fails, and
the command to check it.

Most values were read off the running system rather than recalled:

  conntrack ESTABLISHED timeout   86400s — why A-1's injection sat unmatched
                                  for 25 minutes was normal, not a fault
  FORWARD chain position 1        KUBE-ROUTER-FORWARD — why -I FORWARD 1
                                  counted zero packets
  cgroup version                  v2, and the numbers in systemctl status
                                  are read straight out of those files
  nginx restart policy            on-failure, 100ms, and it gives up after
                                  5 failures in 10 seconds
  Type and KillMode               five units on this host, four different
                                  combinations

Two facts could not be read locally and carry sources: Let's Encrypt
backdates notBefore by exactly one hour to tolerate client clock skew, and
Keycloak invalidates the whole SSO session on refresh token reuse. The
second one explains B-3 — the winning request's new token was not itself
rejected, the session it belonged to had just been deleted.

The systemd layer also explains the one CGroup line in systemctl status that
D-4 spent ps commands establishing: master 585 kept, worker replaced.

One item is marked as read rather than measured. Restart=on-failure comes
from the unit file; nginx has not been killed to watch it come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:32:54 +09:00
DongHyeonkaandClaude Opus 5 f6c825e858 docs(TechLog): 글감 56개를 기록으로 쓴다
주제 13개 · Case 28 · Concept 5 · Reference 15 · Question 4 · Decision 4.
계약의 노드마다 종류가 요구하는 칸을 채우고, 본문이 있는 두 종류에는 SSOT 가 이미
그려 둔 도식 셋(value-boundaries · decision-path-404 · topic-variant-model)을
tech-log-studio/ 로 옮겨 붙였다. 새로 그린 그림은 없다.

검사 셋 전부 통과한다.
  check_body.mjs      56 편 중 본문이 있는 33 편 PASS
  check_prose.mjs     56 편 error 0
  check_evidence.mjs  --repo 포함 문제 없음
  verify-tech-log-tree.py  프로젝트 5 · error 0 · warn 0

인용한 코드블록은 전부 SSOT 에서 찾아 대조했다. check_evidence.mjs 가 본문의 각 줄과
source 앵커와 계약 제목을 다시 확인한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:29:33 +09:00
DongHyeonkaandClaude Opus 5 6955611439 docs(keycloak-session-store): list every concept this lab used but never explained
The lab wrote 26 experiments on top of names it never defined. Comparing the
terms used against the places that actually explain them: 24 have no
explanation anywhere. conntrack appears 35 times across the experiment
documents, refresh token rotation 17, JWKS and Liquibase 11 each.

The list groups them in eight layers with, for each, where it came up,
whether it is explained centrally, only inside one experiment, or nowhere,
and what specifically needs to be learned. It is mapped onto the six topics
of the decomposition contract rather than standing beside it — two glam of
operations-that-report-success cannot be written without systemd, and
Restart=, journald and Type=forking are all in the "nowhere" column.

The bottom layer is the worst of it. systemctl has been typed eight times
as a command and never explained, and the unit file, cgroup, restart policy
and crash-loop limit under it are unwritten.

Building the list turned up a piece of evidence the lab missed. B-7 recorded
that it narrowed the 502 by splitting layers and calling traefik directly to
bypass nginx. The host nginx journal had already written the cause in a
sentence — "upstream sent too big header ... /oauth2/callback" — and none of
the 147 evidence files contain it. That is what journald being in the
"nowhere" column cost.

Also fixes ten spatial-metaphor errors the tightened check_prose now
catches, nine of which predate this section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:17:32 +09:00
DongHyeonkaandClaude Opus 5 1f04117bbf docs(clean-architecture-backend-template): 제1부가 채택한 것만 글감으로 남기고 다시 고른다
글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.

  주제      44 → 16   (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
  글감   1,001 → 123  (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
  후보      965 → 1,088 · PENDING 905 → 0
  error   3,042 → 0

내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.

제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.

Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:02:25 +09:00
DongHyeonkaandClaude Opus 5 a0ca2bb72a docs: 계약 없이 남아 있던 두 프로젝트에 주제를 갈라 계약을 세운다
keycloak-session-store 와 TechLog 는 SSOT 만 있고 분해 계약이 없었다. 두 프로젝트
모두 후보를 처분과 함께 남기고 PROMOTE 만 주제로 올린다.

  keycloak-session-store  주제 6 · 글감 33 · 후보 42 (제외 9)
  TechLog                 주제 13 · 글감 56 · 후보 91 (제외 35)

TechLog 는 저장소가 셋이라 sourceRepository 를 목록으로 적는다. 설계 패키지는 이
기계에 체크아웃이 없고 반입한 쪽의 MANIFEST.sha256 이 세 계약의 원본 리비전을
고정하므로 그것을 리비전으로 적었다. 검사기 둘이 그 모양을 읽게 고친다 —
verify-tech-log-tree.py 는 목록의 항목마다 path·revision·verified 를 보고,
check_evidence.mjs 는 체크아웃이면 git 으로, 매니페스트면 그 파일이 리비전을
적고 있는지로 대조한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:30:12 +09:00
1551 changed files with 169756 additions and 102969 deletions
@@ -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.
@@ -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": ""
}
]
}
+46 -1
View File
@@ -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` 이 맞는 자리는 비교 자체가 자리로 드러나는 때다 — 겹치는
범위, 갈라지는 경계처럼.
@@ -35,7 +35,8 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
**`tech-log-tree.json` 에 노드가 없는 글은 쓰지 않는다** — 트리에 먼저 올리고, 그 노드의 **`tech-log-tree.json` 에 노드가 없는 글은 쓰지 않는다** — 트리에 먼저 올리고, 그 노드의
후보가 `PROMOTE` 이면서 `dispositionReview: CONFIRMED` 인지 확인한 뒤에 쓴다. `PENDING` 후보가 `PROMOTE` 이면서 `dispositionReview: CONFIRMED` 인지 확인한 뒤에 쓴다. `PENDING`
사람이 다시 읽지 않았다는 뜻이라, 그 위에 쓴 글은 과분류를 그대로 물려받는다. 계약 밖에서 쓴 사람이 다시 읽지 않았다는 뜻이라, 그 위에 쓴 글은 과분류를 그대로 물려받는다. 계약 밖에서 쓴
기록은 색인에 `unlisted` 로 남는다. 기록은 색인에 `unlisted` 로 남는다. 그 노드의 `ssot-assets`·`ssot-evidence` 도 함께 본다 —
SSOT 가 이미 그린 그림과 이미 돌린 측정 가운데 이 글감에 배정된 것이 거기 적혀 있다.
1. **종류 선택** — 위 표. 애매하면 "재현했나"를 묻는다. 1. **종류 선택** — 위 표. 애매하면 "재현했나"를 묻는다.
2. **칸 채우기** — 칸과 게시 조건은 `references/record-kinds.md`. 2. **칸 채우기** — 칸과 게시 조건은 `references/record-kinds.md`.
3. **본문 작성**(Case·Concept) — **종류마다 무엇을 어떤 순서로 쓰는지는 3. **본문 작성**(Case·Concept) — **종류마다 무엇을 어떤 순서로 쓰는지는
@@ -45,11 +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·
`technical-visualizer` 로 그림을 만든다. 손으로 SVG 를 그리지 않고, 모든 글에 그림을 만들지도 Concept 만) · 표가 아닌지 · 옆 문단이 이미 말하지 않았는지 세 관문을 지나야 그린다.
않는다. 순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 **`final/assets/` 에 그
그림이 이미 있는지부터 본다.** SSOT 를 만들 때 그려 둔 것이 있고 계약의 `ssot-assets` 가 이
글감에 배정해 두었으면 기록의 `assets`**그 파일을 그대로** 가리킨다. 사본을 따로 만들지
않는다 — 사본에는 `.techviz/<이름>/` 이 없어 다시 만들 수 없다. **없을 때만**
`technical-visualizer` 로 새로 만든다. 손으로
SVG 를 그리지 않고, 모든 글에 그림을 만들지도 않는다.
인용할 측정도 같다. `final/evidence/` 에 있는 원문을 가리키고 같은 것을 다시 돌리지 않는다.
**그림과 측정을 그 자리에서 만들어 내기 전에 SSOT 가 이미 가진 것을 먼저 찾는다** — keycloak
에서는 그러지 않아 정본까지 갖춘 그림 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`**인용한 것이 실재하는지.** 본문 코드블록의
@@ -71,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` | 자료에 남아 있는 선택·비교·어긋남·확인하지 못한 범위를 제자리에 놓는다 | 자료에 없는 「처음에는」·「고민 끝에」를 만들지 않는다 |
@@ -116,6 +127,8 @@ SSOT 에 없는데 필요한 인용이라면 순서가 반대다 — `final/docu
| 문서마다 같은 문형·같은 길이 | `references/ai-tells.md` | | 문서마다 같은 문형·같은 길이 | `references/ai-tells.md` |
| 표를 `:::table`로 감쌈 | 그냥 파이프로 쓴다 | | 표를 `:::table`로 감쌈 | 그냥 파이프로 쓴다 |
| `![](https://…외부)` | Asset으로 올려 `/api/v1/public/media/…` | | `![](https://…외부)` | Asset으로 올려 `/api/v1/public/media/…` |
| SSOT 에 있는 그림을 두고 새로 그림 | `final/assets/` 를 먼저 본다. `ssot-assets` 가 배정한 것을 쓴다 |
| SSOT 에 있는 측정을 두고 다시 돌림 | `final/evidence/` 의 원문을 가리킨다 |
| Decision에 근거 없음 | 관계 1개 이상 연결 | | Decision에 근거 없음 | 관계 1개 이상 연결 |
| 측정 안 한 검증일 | 비워 둔다 | | 측정 안 한 검증일 | 비워 둔다 |
| 설명 직후에 「~증거다」「~가 아니다」로 평가 | 지운다. 앞 문장이 사실을 말했으면 거기서 끝낸다 | | 설명 직후에 「~증거다」「~가 아니다」로 평가 | 지운다. 앞 문장이 사실을 말했으면 거기서 끝낸다 |
@@ -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
![브라우저 SPA, Keycloak, Resource Server 사이에서 …](../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg)
```
`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으로 쓴다.
@@ -10,6 +10,8 @@
|---|---| |---|---|
| 코드·설정·실행 증거 | 사실의 근거 | | 코드·설정·실행 증거 | 사실의 근거 |
| `final/document.md` | **글감 범위의 SSOT** — 후보를 발견하는 유일한 입력 | | `final/document.md` | **글감 범위의 SSOT** — 후보를 발견하는 유일한 입력 |
| `final/assets/` · `final/.techviz/` | 이미 그린 그림과 그 정본. 새 후보를 내지 않고 글감에 배정된다 |
| `final/evidence/` | 이미 실행한 측정의 원문. 마찬가지로 배정된다 |
| `analysis/**/*.md` | final이 이미 채택한 주장을 상세히 확인하는 보조 근거 | | `analysis/**/*.md` | final이 이미 채택한 주장을 상세히 확인하는 보조 근거 |
| `tech-log-tree.json` | 사람이 고른 글감. 분해 계약이자 색인이고 이 파일이 정본이다 | | `tech-log-tree.json` | 사람이 고른 글감. 분해 계약이자 색인이고 이 파일이 정본이다 |
@@ -36,6 +38,37 @@
범위 밖의 앵커는 후보가 아니라 근거다. 제1부에서 나온 글감의 `source`로 건다. 범위 밖의 앵커는 후보가 아니라 근거다. 제1부에서 나온 글감의 `source`로 건다.
## 그림과 증거는 후보가 아니라 배정 대상이다
`final/assets/`의 그림과 `final/evidence/`의 측정은 글감을 새로 만들지 않는다. 이미 정해진
글감에 붙는다. 그래서 처분을 매기는 자리가 아니라 **배정하는 자리**이고, 계약의
`ssot-assets`·`ssot-evidence`가 그 자리다.
글감을 다 고른 뒤 두 폴더를 한 번 훑는다. 물음은 하나다.
> **이 그림이나 이 측정은 어느 글감의 것인가. 붙을 글감이 없으면 왜 없는가.**
```json
"ssot-assets": ["ap3-bff-session-flow"],
"ssot-evidence": ["raw/explain/l3-cartesian-join-plan.txt"]
```
배정한 것은 기록의 `assets`·`evidence`가 실제로 가리켜야 한다. 배정해 놓고 쓰지 않으면
`verify-tech-log-tree.py`가 error로 센다.
**배정하지 않으면 글을 쓸 때 같은 그림을 새로 그린다.** keycloak이 그렇게 됐다. SSOT에
`ap3-bff-session-flow`, `ap4-edge-forward-auth-flow`를 포함한 그림 13장이 `.techviz` 정본까지
갖춘 채 있었는데, 기록 24편은 그중 한 장도 가리키지 않고 이름이 다른 그림 5장을 새로 만들어
썼다. 새로 만든 5장에는 정본이 없어서 고칠 수도 없다.
붙을 글감이 없는 그림도 있다. 패턴 넷을 나란히 놓고 비교하는 그림은 Reference에 붙어야 맞는데
Reference에는 본문이 없다. 그런 그림은 그대로 두고, 왜 두는지 계약에 적는다 — 「Reference에만
쓸 자리가 있어 본문 있는 종류에 담지 못한다」처럼. `verify-project-layout.py`가 「기록이 쓰지
않는 SSOT 그림」으로 세므로, 센 숫자가 설명되지 않은 채 남지 않게 한다.
증거도 같다. 재료로만 쓰고 인용하지 않기로 한 측정은 정상이다. 「기록이 인용하지 않는 raw 증거」가
전부 설명되는지만 본다.
## 왜 먼저 나누는가 ## 왜 먼저 나누는가
긴 글을 앞에서부터 잘라 기록으로 만들면 절 하나가 기록 하나가 된다. 그러면 Case의 칸도 긴 글을 앞에서부터 잘라 기록으로 만들면 절 하나가 기록 하나가 된다. 그러면 Case의 칸도
@@ -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가 겹치지 않는다
@@ -127,4 +131,6 @@
- [ ] Decision 에 근거가 하나 이상 있고, 무엇을 보고 정했는지가 적혀 있는가 - [ ] Decision 에 근거가 하나 이상 있고, 무엇을 보고 정했는지가 적혀 있는가
- [ ] 지어낸 경험·실패·동기·감정이 없는가 - [ ] 지어낸 경험·실패·동기·감정이 없는가
- [ ] 그림이 실제 asset 파일을 가리키고, 있어야 할 이유가 있는가 - [ ] 그림이 실제 asset 파일을 가리키고, 있어야 할 이유가 있는가
- [ ] `final/assets/` 에 이미 있는 그림을 두고 같은 것을 새로 그리지 않았는가
- [ ] 계약이 `ssot-assets`·`ssot-evidence` 로 배정한 것을 기록이 가리키는가
- [ ] 그림이 관측하지 않은 사건을 만들어 내지 않았는가 - [ ] 그림이 관측하지 않은 사건을 만들어 내지 않았는가
@@ -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
@@ -83,7 +108,55 @@ whose candidate is `PROMOTE` and `CONFIRMED`.
`readiness` · `source` · `code` · `evidence` · `classification` · `relations` and the rest `readiness` · `source` · `code` · `evidence` · `classification` · `relations` and the rest
of each kind's fields are written by a person. `build-tech-log-tree.py` never touches them. of each kind's fields are written by a person. `build-tech-log-tree.py` never touches them.
It refreshes only what it can read from the record files — `file`, `publication`, `status`, It refreshes only what it can read from the record files — `file`, `publication`, `status`,
`studioId`, `assets`, `evidenceFiles` — and lists records that have no node in `unlisted`. `studioId`, `assets`, `assetFiles`, `evidenceFiles` — and lists records that have no node in
`unlisted`.
### `ssot-assets` · `ssot-evidence`
The SSOT is not only `final/document.md`. `final/assets/` holds diagrams that were already
drawn, each with its canonical `final/.techviz/<name>/`, and `final/evidence/` holds
measurements that were already run. Neither produces candidates — both are **assigned** to
candidates that already exist, and these two fields hold the assignment.
```json
"ssot-assets": ["ap3-bff-session-flow"],
"ssot-evidence": ["raw/explain/l3-cartesian-join-plan.txt"]
```
`ssot-assets` names diagrams by file stem; the file must exist somewhere under
`final/assets/`. `ssot-evidence` takes paths relative to `final/evidence/`. Both are
written by a person and both are optional — a node that needs no picture and cites no
measurement leaves them out.
What they are not optional about is follow-through. Once a node is assigned a diagram and
its record is written, the record's `assets` must point at that file and its `evidence` at
that path; `verify-tech-log-tree.py` reports the gap as an error. Assigning and then not
using is the failure these fields exist to catch — without them a writer draws the picture
again instead of finding the one that is already there.
`verify-project-layout.py` counts the other direction: SSOT diagrams and raw evidence that
no record cites at all. Some of that count is correct — a four-pattern comparison diagram
belongs to a Reference, and Reference has no body to render it in. The count is meant to be
explained, not driven to zero.
### `assetLedger`
That explanation lives at the top level of the index, next to `candidateScope`. It names
what was assigned and, for everything left over, why it is left over.
```json
"assetLedger": {
"assigned": ["ap3-bff-session-flow", "ap3-csrf-boundary"],
"unassigned": [
{"asset": ["four-pattern-request-boundaries"],
"reason": "네 패턴을 비교하는 그림이라 붙을 자리가 Reference 인데 Reference 에는 본문이 없다"}
]
}
```
A diagram left out for a reason is a normal outcome, the same way `KEEP_IN_SSOT` is. What
is not normal is a leftover nobody looked at — that is the state where the next writer
draws the picture again. Write the ledger when the count first appears, not when it grows.
### Case ### Case
@@ -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` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
@@ -107,17 +107,28 @@ for (const topicDir of readdirSync(studio)) {
// 4. (--repo) 저장소가 실재하고 리비전이 맞는가 // 4. (--repo) 저장소가 실재하고 리비전이 맞는가
if (withRepo) { if (withRepo) {
const repo = tree.sourceRepository || {}; // 저장소가 여럿인 프로젝트는 목록으로 적는다
if (!repo.path) findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", ""]); const declared = tree.sourceRepository || {};
else if (!existsSync(repo.path)) findings.push(["tech-log-tree.json", "저장소 경로가 없다", repo.path]); const repos = Array.isArray(declared) ? declared : [declared];
else { for (const repo of repos) {
const name = repo.name || project;
if (!repo.path) { findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", name]); continue; }
if (!existsSync(repo.path)) { findings.push(["tech-log-tree.json", "저장소 경로가 없다", `${name}${repo.path}`]); continue; }
// 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다 // 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다
const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {}); const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {});
// 체크아웃이 없는 저장소는 반입한 쪽의 매니페스트가 리비전을 고정한다. 그럴 때는
// path 가 그 파일이고, git 대신 그 파일이 리비전을 적고 있는지 본다
const isCheckout = statSync(repo.path).isDirectory();
const manifest = isCheckout ? "" : readFileSync(repo.path, "utf8");
for (const [label, rev] of Object.entries(revs)) { for (const [label, rev] of Object.entries(revs)) {
try { if (isCheckout) {
execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" }); try {
} catch { execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" });
findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${label} = ${rev}`]); } catch {
findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${name} · ${label} = ${rev}`]);
}
} else if (!manifest.includes(rev)) {
findings.push(["tech-log-tree.json", "매니페스트가 그 리비전을 적고 있지 않다", `${name} · ${label} = ${rev}`]);
} }
} }
} }
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/publishing-tech-log-to-studio
+1
View File
@@ -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으로 저장했습니다.

Some files were not shown because too many files have changed in this diff Show More