feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
@@ -0,0 +1 @@
[ 563ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1,3 @@
[ 240ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
[ 5732ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/assets?limit=60:0
[ 11698ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/assets?limit=60:0
@@ -0,0 +1 @@
[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1,2 @@
[ 179760ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/versions:0
[ 179790ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/revisions:0
@@ -0,0 +1,2 @@
[ 26888ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/documents/spa-browser-credential-boundary:0
[ 26915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/records/spa-browser-credential-boundary:0
@@ -0,0 +1 @@
[ 275ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1 @@
[ 349ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1,10 @@
[ 27961ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/versions:0
[ 27984ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/history:0
[ 28008ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/previews:0
[ 28031ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/validations:0
[ 46083ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/cases/identity-header-trust:0
[ 46111ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/public/documents/identity-header-trust:0
[ 216423ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/revisions:0
[ 216453ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/versions/41:0
[ 216482ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/snapshots:0
[ 216563ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/v1/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/publication:0
@@ -0,0 +1 @@
[ 15511ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/references/036563a1-3a43-4931-a017-0e693b591e89:0
@@ -0,0 +1,5 @@
[ 369ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
[ 70884ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/studio/topics:0
[ 70915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/studio/projects:0
[ 70946ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/topics:0
[ 70978ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/api/projects:0
@@ -0,0 +1 @@
[ 677ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1,2 @@
[ 36ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/studio/topics:0
[ 78ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
@@ -0,0 +1,2 @@
[ 389876ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ https://hyeonworks.com/api/v1/studio/assets:0
[ 439129ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ https://hyeonworks.com/api/v1/studio/assets:0
@@ -0,0 +1,6 @@
[ 815920ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=PROJECT&limit=100:0
[ 815921ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=TOPIC&limit=100:0
[ 815970ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=EVIDENCE&limit=100:0
[ 815971ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=100:0
[ 853400ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=200:0
[ 853424ms] [ERROR] Failed to load resource: the server responded with a status of 422 (Unprocessable Entity) @ https://hyeonworks.com/api/v1/studio/catalog?type=RELATION&limit=500:0
@@ -0,0 +1,3 @@
[ 27295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
[ 1459990ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/login?error:0
[ 1460025ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
@@ -0,0 +1,13 @@
- generic [ref=e3]:
- link "본문으로 건너뛰기" [ref=e4] [cursor=pointer]:
- /url: "#main-content"
- main [ref=e5]:
- generic [ref=e7]:
- generic [ref=e8]:
- paragraph [ref=e9]: STUDIO
- heading "세션을 복구하고 있습니다." [level=1] [ref=e10]
- paragraph [ref=e11]: 이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.
- generic [ref=e12]:
- button "세션 복구" [ref=e13] [cursor=pointer]
- paragraph [ref=e14]: 로그인하면 /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit 로 돌아옵니다.
- paragraph [ref=e15]
@@ -0,0 +1,176 @@
- generic [ref=f2e3]:
- link "본문으로 건너뛰기" [ref=f2e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f2e5]:
- generic [ref=f2e6]:
- link "TechLog Studio" [ref=f2e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f2e8]: Studio
- navigation "Studio 주 탐색" [ref=f2e10]:
- link "작업본" [ref=f2e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f2e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f2e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f2e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f2e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f2e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f2e17]
- main [ref=f2e18]:
- generic [ref=f2e19]:
- generic [ref=f2e20]:
- generic [ref=f2e21]:
- paragraph [ref=f2e22]: WORKSPACE
- heading "작업 흐름" [level=1] [ref=f2e23]
- paragraph [ref=f2e24]: 작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.
- link "새 문서" [ref=f2e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "Studio 요약" [ref=f2e26]:
- generic [ref=f2e27]:
- generic [ref=f2e28]: 전체 작업본
- strong [ref=f2e29]: "47"
- generic [ref=f2e30]:
- generic [ref=f2e31]: 검증할 기록
- strong [ref=f2e32]: "5"
- generic [ref=f2e33]:
- generic [ref=f2e34]: 게시 준비
- strong [ref=f2e35]: "0"
- generic [ref=f2e36]:
- generic [ref=f2e37]: 게시 기록
- strong [ref=f2e38]: "21"
- generic [ref=f2e39]:
- generic [ref=f2e40]:
- heading "이어서 작성" [level=2] [ref=f2e41]
- link "전체 보기" [ref=f2e42] [cursor=pointer]:
- /url: /studio/documents
- paragraph [ref=f2e44]: 이어서 작성할 문서가 없습니다.
- generic [ref=f2e45]:
- generic [ref=f2e46]:
- heading "검증과 미리보기" [level=2] [ref=f2e47]
- link "전체 보기" [ref=f2e48] [cursor=pointer]:
- /url: /studio/documents
- generic [ref=f2e49]:
- article [ref=f2e50]:
- paragraph [ref=f2e51]: 검증 기록
- generic [ref=f2e52]:
- heading [level=3] [ref=f2e53]:
- link "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [ref=f2e54] [cursor=pointer]:
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
- paragraph [ref=f2e55]: KeyCloak Patterns · 검증하기
- time [ref=f2e56]: 2026. 9. 7.
- article [ref=f2e57]:
- paragraph [ref=f2e58]: 열린 질문
- generic [ref=f2e59]:
- heading [level=3] [ref=f2e60]:
- link "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" [ref=f2e61] [cursor=pointer]:
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
- paragraph [ref=f2e62]: Liner N + 1문제 · 검증하기
- time [ref=f2e63]: 2026. 9. 4.
- article [ref=f2e64]:
- paragraph [ref=f2e65]: 열린 질문
- generic [ref=f2e66]:
- heading [level=3] [ref=f2e67]:
- link "Round Trip과 Row Volume을 독립 측정할 것인가" [ref=f2e68] [cursor=pointer]:
- /url: /studio/documents/5159c415-232d-424a-970a-b0db52746767/edit
- paragraph [ref=f2e69]: Liner N + 1문제 · 검증하기
- time [ref=f2e70]: 2026. 9. 4.
- article [ref=f2e71]:
- paragraph [ref=f2e72]: 검증 기록
- generic [ref=f2e73]:
- heading [level=3] [ref=f2e74]:
- link "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f2e75] [cursor=pointer]:
- /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit
- paragraph [ref=f2e76]: Liner N + 1문제 · 검증하기
- time [ref=f2e77]: 2026. 9. 4.
- article [ref=f2e78]:
- paragraph [ref=f2e79]: 적용 기준
- generic [ref=f2e80]:
- heading [level=3] [ref=f2e81]:
- link "JPA N+1 정량 진단 기준" [ref=f2e82] [cursor=pointer]:
- /url: /studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit
- paragraph [ref=f2e83]: Liner N + 1문제 · 검증하기
- time [ref=f2e84]: 2026. 9. 4.
- generic [ref=f2e85]:
- generic [ref=f2e86]:
- heading "게시 준비" [level=2] [ref=f2e87]
- link "전체 보기" [ref=f2e88] [cursor=pointer]:
- /url: /studio/documents
- paragraph [ref=f2e90]: 게시 준비가 끝난 문서가 없습니다.
- region [ref=f2e91]:
- generic [ref=f2e92]:
- paragraph [ref=f2e93]: PUBLIC HOME
- heading "지금 집중하는 것" [level=2] [ref=f2e94]
- paragraph [ref=f2e95]: 공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
- generic [ref=f2e96]:
- generic [ref=f2e97]:
- generic [ref=f2e98]:
- generic [ref=f2e99]: 현재 작업 (프로젝트)
- combobox "현재 작업 (프로젝트) 단계·현재 목표·다음 작업이 카드에 함께 나옵니다." [ref=f2e100]:
- option "고르지 않음"
- option "Liner N + 1문제"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- generic [ref=f2e101]: 단계·현재 목표·다음 작업이 카드에 함께 나옵니다.
- generic [ref=f2e102]:
- generic [ref=f2e103]: 열린 질문
- combobox "열린 질문" [ref=f2e104]:
- option "고르지 않음"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- generic [ref=f2e105]:
- generic [ref=f2e106]: 최근 결정
- combobox "최근 결정" [ref=f2e107]:
- option "고르지 않음"
- option "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다. 브라우저에는 애플리케이션 session만 제공한다." [selected]
- option "외부 IdP 연동은 별도의 인증 구조가 아니다. Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수있는 토큰을 발급한다. SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다."
- generic [ref=f2e108]:
- button "홈 설정 저장" [ref=f2e109]
- paragraph [ref=f2e110]:
- text: 저장하면 공개 홈에 바로 반영됩니다.
- link "주제·프로젝트" [ref=f2e111] [cursor=pointer]:
- /url: /studio/taxonomy
- text: 에서 프로젝트를 만들고 게시할 수 있습니다.
- generic [ref=f2e112]:
- generic [ref=f2e113]:
- heading "최근 게시" [level=2] [ref=f2e114]
- link "게시 기록 보기" [ref=f2e115] [cursor=pointer]:
- /url: /studio/publications
- generic [ref=f2e116]:
- article [ref=f2e117]:
- paragraph [ref=f2e118]: 게시
- generic [ref=f2e119]:
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=3] [ref=f2e120]
- paragraph [ref=f2e121]: Liner N + 1문제
- time [ref=f2e122]: 2026. 9. 1.
- article [ref=f2e123]:
- paragraph [ref=f2e124]: 게시
- generic [ref=f2e125]:
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=3] [ref=f2e126]
- paragraph [ref=f2e127]: Liner N + 1문제
- time [ref=f2e128]: 2026. 9. 1.
- article [ref=f2e129]:
- paragraph [ref=f2e130]: 게시
- generic [ref=f2e131]:
- heading "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [level=3] [ref=f2e132]
- paragraph [ref=f2e133]: Liner N + 1문제
- time [ref=f2e134]: 2026. 9. 1.
- article [ref=f2e135]:
- paragraph [ref=f2e136]: 게시
- generic [ref=f2e137]:
- heading "외부 IdP Brokering의 동작" [level=3] [ref=f2e138]
- paragraph [ref=f2e139]: KeyCloak Patterns
- time [ref=f2e140]: 2026. 9. 1.
- article [ref=f2e141]:
- paragraph [ref=f2e142]: 게시
- generic [ref=f2e143]:
- heading "BFF가 OAuth Token을 관리하는 조건" [level=3] [ref=f2e144]
- paragraph [ref=f2e145]: KeyCloak Patterns
- time [ref=f2e146]: 2026. 8. 31.
- paragraph [ref=f2e147]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
- generic [ref=f3e3]:
- link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e5]:
- generic [ref=f3e6]:
- link "TechLog Studio" [ref=f3e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f3e8]: Studio
- navigation "Studio 주 탐색" [ref=f3e10]:
- link "작업본" [ref=f3e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f3e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f3e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f3e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f3e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f3e17]
- main [ref=f3e18]:
- generic [ref=f3e19]:
- generic [ref=f3e20]:
- generic [ref=f3e21]:
- paragraph [ref=f3e22]: WORKSPACE
- heading "작업 흐름" [level=1] [ref=f3e23]
- paragraph [ref=f3e24]: 작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.
- link "새 문서" [ref=f3e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "Studio 요약" [ref=f3e26]:
- generic [ref=f3e27]:
- generic [ref=f3e28]: 전체 작업본
- strong [ref=f3e29]: "47"
- generic [ref=f3e30]:
- generic [ref=f3e31]: 검증할 기록
- strong [ref=f3e32]: "5"
- generic [ref=f3e33]:
- generic [ref=f3e34]: 게시 준비
- strong [ref=f3e35]: "0"
- generic [ref=f3e36]:
- generic [ref=f3e37]: 게시 기록
- strong [ref=f3e38]: "21"
- generic [ref=f3e39]:
- generic [ref=f3e40]:
- heading "이어서 작성" [level=2] [ref=f3e41]
- link "전체 보기" [ref=f3e42] [cursor=pointer]:
- /url: /studio/documents
- paragraph [ref=f3e44]: 이어서 작성할 문서가 없습니다.
- generic [ref=f3e45]:
- generic [ref=f3e46]:
- heading "검증과 미리보기" [level=2] [ref=f3e47]
- link "전체 보기" [ref=f3e48] [cursor=pointer]:
- /url: /studio/documents
- generic [ref=f3e49]:
- article [ref=f3e50]:
- paragraph [ref=f3e51]: 검증 기록
- generic [ref=f3e52]:
- heading [level=3] [ref=f3e53]:
- link "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" [ref=f3e54] [cursor=pointer]:
- /url: /studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit
- paragraph [ref=f3e55]: KeyCloak Patterns · 검증하기
- time [ref=f3e56]: 2026. 9. 7.
- article [ref=f3e57]:
- paragraph [ref=f3e58]: 검증 기록
- generic [ref=f3e59]:
- heading [level=3] [ref=f3e60]:
- link "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f3e61] [cursor=pointer]:
- /url: /studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit
- paragraph [ref=f3e62]: KeyCloak Patterns · 검증하기
- time [ref=f3e63]: 2026. 9. 7.
- article [ref=f3e64]:
- paragraph [ref=f3e65]: 검증 기록
- generic [ref=f3e66]:
- heading [level=3] [ref=f3e67]:
- link "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [ref=f3e68] [cursor=pointer]:
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
- paragraph [ref=f3e69]: KeyCloak Patterns · 검증하기
- time [ref=f3e70]: 2026. 9. 7.
- article [ref=f3e71]:
- paragraph [ref=f3e72]: 검증 기록
- generic [ref=f3e73]:
- heading [level=3] [ref=f3e74]:
- link "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [ref=f3e75] [cursor=pointer]:
- /url: /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit
- paragraph [ref=f3e76]: KeyCloak Patterns · 검증하기
- time [ref=f3e77]: 2026. 9. 7.
- article [ref=f3e78]:
- paragraph [ref=f3e79]: 열린 질문
- generic [ref=f3e80]:
- heading [level=3] [ref=f3e81]:
- link "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" [ref=f3e82] [cursor=pointer]:
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
- paragraph [ref=f3e83]: Liner N + 1문제 · 검증하기
- time [ref=f3e84]: 2026. 9. 4.
- generic [ref=f3e85]:
- generic [ref=f3e86]:
- heading "게시 준비" [level=2] [ref=f3e87]
- link "전체 보기" [ref=f3e88] [cursor=pointer]:
- /url: /studio/documents
- paragraph [ref=f3e90]: 게시 준비가 끝난 문서가 없습니다.
- region [ref=f3e91]:
- generic [ref=f3e92]:
- paragraph [ref=f3e93]: PUBLIC HOME
- heading "지금 집중하는 것" [level=2] [ref=f3e94]
- paragraph [ref=f3e95]: 공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
- generic [ref=f3e96]:
- generic [ref=f3e97]:
- generic [ref=f3e98]:
- generic [ref=f3e99]: 현재 작업 (프로젝트)
- combobox "현재 작업 (프로젝트) 단계·현재 목표·다음 작업이 카드에 함께 나옵니다." [ref=f3e100]:
- option "고르지 않음"
- option "Liner N + 1문제"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- generic [ref=f3e101]: 단계·현재 목표·다음 작업이 카드에 함께 나옵니다.
- generic [ref=f3e102]:
- generic [ref=f3e103]: 열린 질문
- combobox "열린 질문" [ref=f3e104]:
- option "고르지 않음"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- generic [ref=f3e105]:
- generic [ref=f3e106]: 최근 결정
- combobox "최근 결정" [ref=f3e107]:
- option "고르지 않음"
- option "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다. 브라우저에는 애플리케이션 session만 제공한다." [selected]
- option "외부 IdP 연동은 별도의 인증 구조가 아니다. Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수있는 토큰을 발급한다. SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다."
- generic [ref=f3e108]:
- button "홈 설정 저장" [ref=f3e109]
- paragraph [ref=f3e110]:
- text: 저장하면 공개 홈에 바로 반영됩니다.
- link "주제·프로젝트" [ref=f3e111] [cursor=pointer]:
- /url: /studio/taxonomy
- text: 에서 프로젝트를 만들고 게시할 수 있습니다.
- generic [ref=f3e112]:
- generic [ref=f3e113]:
- heading "최근 게시" [level=2] [ref=f3e114]
- link "게시 기록 보기" [ref=f3e115] [cursor=pointer]:
- /url: /studio/publications
- generic [ref=f3e116]:
- article [ref=f3e117]:
- paragraph [ref=f3e118]: 게시
- generic [ref=f3e119]:
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=3] [ref=f3e120]
- paragraph [ref=f3e121]: Liner N + 1문제
- time [ref=f3e122]: 2026. 9. 1.
- article [ref=f3e123]:
- paragraph [ref=f3e124]: 게시
- generic [ref=f3e125]:
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=3] [ref=f3e126]
- paragraph [ref=f3e127]: Liner N + 1문제
- time [ref=f3e128]: 2026. 9. 1.
- article [ref=f3e129]:
- paragraph [ref=f3e130]: 게시
- generic [ref=f3e131]:
- heading "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [level=3] [ref=f3e132]
- paragraph [ref=f3e133]: Liner N + 1문제
- time [ref=f3e134]: 2026. 9. 1.
- article [ref=f3e135]:
- paragraph [ref=f3e136]: 게시
- generic [ref=f3e137]:
- heading "외부 IdP Brokering의 동작" [level=3] [ref=f3e138]
- paragraph [ref=f3e139]: KeyCloak Patterns
- time [ref=f3e140]: 2026. 9. 1.
- article [ref=f3e141]:
- paragraph [ref=f3e142]: 게시
- generic [ref=f3e143]:
- heading "BFF가 OAuth Token을 관리하는 조건" [level=3] [ref=f3e144]
- paragraph [ref=f3e145]: KeyCloak Patterns
- time [ref=f3e146]: 2026. 8. 31.
- paragraph [ref=f3e147]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,616 @@
- generic [ref=f9e3]:
- link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f9e5]:
- generic [ref=f9e6]:
- link "TechLog Studio" [ref=f9e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f9e8]: Studio
- navigation "Studio 주 탐색" [ref=f9e10]:
- link "작업본" [ref=f9e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f9e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f9e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f9e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f9e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f9e17]
- main [ref=f9e18]:
- generic [ref=f9e19]:
- generic [ref=f9e20]:
- region [ref=f9e21]:
- generic [ref=f9e22]:
- paragraph [ref=f9e23]: CONCEPT · VERSION 5
- heading "문서 편집" [level=1] [ref=f9e24]
- paragraph [ref=f9e25]: Cookie로 인증하는 요청에서 CSRF token이 하는 일
- region [ref=f9e26]:
- generic [ref=f9e27]:
- paragraph [ref=f9e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f9e29]
- generic [ref=f9e30]:
- generic [ref=f9e31]:
- generic [ref=f9e32]: 제목
- textbox "제목" [ref=f9e33]: Cookie로 인증하는 요청에서 CSRF token이 하는 일
- generic [ref=f9e34]:
- generic [ref=f9e35]: slug
- textbox "slug" [ref=f9e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: cookie-auth-csrf
- generic [ref=f9e37]:
- generic [ref=f9e38]: 요약
- textbox "요약" [ref=f9e39]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
- generic [aria-hidden] [ref=f9e40]: 목록 카드에는 약 90자까지 보입니다 · 143 / 2000
- generic [ref=f9e41]:
- generic [ref=f9e42]: Topic
- combobox "Topic" [ref=f9e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f9e44]:
- generic [ref=f9e45]: Project
- combobox "Project" [ref=f9e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- status [ref=f9e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f9e48]:
- generic [ref=f9e50] [cursor=pointer]:
- checkbox "SPA" [ref=f9e51]
- generic [ref=f9e52]: SPA
- generic [ref=f9e53] [cursor=pointer]:
- checkbox "Mediator" [ref=f9e54]
- generic [ref=f9e55]: Mediator
- generic [ref=f9e56] [cursor=pointer]:
- checkbox "BFF" [ref=f9e57]
- generic [ref=f9e58]: BFF
- generic [ref=f9e59] [cursor=pointer]:
- checkbox "Forward-Auth" [ref=f9e60]
- generic [ref=f9e61]: Forward-Auth
- group "관계" [ref=f9e62]:
- generic [ref=f9e64]:
- generic [ref=f9e65]:
- generic [ref=f9e66]: 관계 1 대상
- combobox "관계 1 대상" [ref=f9e67]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled]
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [selected]
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f9e68]:
- generic [ref=f9e69]: 관계 1 이유
- textbox "관계 1 이유" [ref=f9e70]: 이 확인이 필요한 구조의 설계 항목이다.
- generic [aria-hidden] [ref=f9e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f9e72]:
- button "위로" [disabled] [ref=f9e73]
- button "아래로" [ref=f9e74]
- button "삭제" [ref=f9e75]
- generic [ref=f9e76]:
- generic [ref=f9e77]:
- generic [ref=f9e78]: 관계 2 대상
- combobox "관계 2 대상" [ref=f9e79]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [selected]
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f9e80]:
- generic [ref=f9e81]: 관계 2 이유
- textbox "관계 2 이유" [ref=f9e82]: 이 동작을 실제로 재현한 기록이다.
- generic [aria-hidden] [ref=f9e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f9e84]:
- button "위로" [ref=f9e85]
- button "아래로" [ref=f9e86]
- button "삭제" [ref=f9e87]
- generic [ref=f9e88]:
- generic [ref=f9e89]:
- generic [ref=f9e90]: 관계 3 대상
- combobox "관계 3 대상" [ref=f9e91]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled]
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f9e92]:
- generic [ref=f9e93]: 관계 3 이유
- textbox "관계 3 이유" [ref=f9e94]: session cookie와 CSRF token은 서로 다른 값이다.
- generic [aria-hidden] [ref=f9e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f9e96]:
- button "위로" [ref=f9e97]
- button "아래로" [disabled] [ref=f9e98]
- button "삭제" [ref=f9e99]
- button "관계 추가" [ref=f9e100]
- region [ref=f9e101]:
- generic [ref=f9e102]:
- paragraph [ref=f9e103]: CONCEPT
- heading "개념" [level=2] [ref=f9e104]
- generic [ref=f9e105]:
- generic [ref=f9e106]:
- generic [ref=f9e107]: 기준 버전
- textbox "기준 버전 “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다." [ref=f9e108]: Spring Security 6 CSRF · AP3 BFF 구성
- generic [ref=f9e109]: “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다.
- generic [ref=f9e110]:
- generic [ref=f9e111]: 본문 Markdown
- group "Markdown 삽입" [ref=f9e112]:
- button "코드" [ref=f9e113] [cursor=pointer]
- button "표" [ref=f9e114] [cursor=pointer]
- button "목록" [ref=f9e115] [cursor=pointer]
- textbox "본문 Markdown" [ref=f9e116]: "## cookie가 credential이 되면 생기는 일 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다. 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다. ## token을 받아 오는 요청 브라우저가 먼저 CSRF material을 요청한다. ```http label=\"CSRF token 요청\" GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION=<opaque-session-id> ``` `CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다. ```json label=\"CsrfController가 반환하는 JSON\" { \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"<xor-masked-csrf-token>\" } ``` :::evidence key=\"ap3-csrf-boundary-971df81c\" alt=\"BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.\" caption=\" \" zoom=\"true\" ::: ## body의 token과 cookie의 값은 다르다 여기가 이 구조에서 가장 헷갈리는 지점이다. | 위치 | 값 | |---|---| | 응답 body의 `token` | XOR와 Base64로 mask된 값 | | `XSRF-TOKEN` cookie | raw 값 | | POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 | `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다. `SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다. 응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다. ## 검증이 controller보다 먼저 일어난다 정상 상태 변경 요청은 다음과 같다. ```http label=\"CSRF 검증을 통과하는 POST\" POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token> X-XSRF-TOKEN: <same-raw-csrf-token> theme=dark ``` Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다. ## SameSite와 CSRF token은 역할이 다르다 | | SameSite | CSRF token | |---|---|---| | 누가 판단하나 | 브라우저 | 서버 | | 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 | | 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 | 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다. 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다. | 입력 | cookie 동작 | CSRF 동작 | 결과 | |---|---|---|---| | same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 | | same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 | | 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 | | cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 | 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다. ## CSRF가 XSS를 대신하지 않는다 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다. 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다."
- generic [ref=f9e117]: “##” 소제목이 목차가 됩니다. 아키텍처 도식은 아래에서 삽입하세요.
- group [ref=f9e118]:
- paragraph [ref=f9e119]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f9e120]
- paragraph [ref=f9e121]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f9e122]:
- generic [ref=f9e123]:
- generic [ref=f9e124]: 업로드 종류
- combobox "업로드 종류" [ref=f9e125]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f9e126]
- generic [ref=f9e127]:
- search [ref=f9e128]:
- generic [ref=f9e129]: Asset 검색
- generic [ref=f9e130]:
- searchbox "Asset 검색" [ref=f9e131]
- button "검색" [ref=f9e132]
- generic [ref=f9e133]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f9e134]
- generic [ref=f9e135]: 삽입할 때 크게 보기 허용
- status [ref=f9e136]: 삽입할 수 있는 Asset 24개
- list [ref=f9e137]:
- listitem [ref=f9e138]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f9e139]
- button "삭제" [ref=f9e140]
- listitem [ref=f9e141]:
- button "ap3-bff-session-flow-1b005e15" [ref=f9e142]
- button "삭제" [ref=f9e143]
- listitem [ref=f9e144]:
- button "ap3-bff-architecture-a27ea91c" [ref=f9e145]
- button "삭제" [ref=f9e146]
- listitem [ref=f9e147]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f9e148]
- button "삭제" [ref=f9e149]
- listitem [ref=f9e150]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f9e151]
- button "삭제" [ref=f9e152]
- listitem [ref=f9e153]:
- button "projection-row-over-fetch-f2b1943b" [ref=f9e154]
- button "삭제" [ref=f9e155]
- listitem [ref=f9e156]:
- button "cartesian-row-multiplication-dce2e166" [ref=f9e157]
- button "삭제" [ref=f9e158]
- listitem [ref=f9e159]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f9e160]
- button "삭제" [ref=f9e161]
- listitem [ref=f9e162]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f9e163]
- button "삭제" [ref=f9e164]
- listitem [ref=f9e165]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f9e166]
- button "삭제" [ref=f9e167]
- listitem [ref=f9e168]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f9e169]
- button "삭제" [ref=f9e170]
- listitem [ref=f9e171]:
- button "ap3-csrf-boundary-971df81c" [ref=f9e172]
- button "삭제" [ref=f9e173]
- listitem [ref=f9e174]:
- button "login-api-phase-split-3e354274" [ref=f9e175]
- button "삭제" [ref=f9e176]
- listitem [ref=f9e177]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f9e178]
- button "삭제" [ref=f9e179]
- listitem [ref=f9e180]:
- button "ap1-direct-architecture-0adf4199" [ref=f9e181]
- button "삭제" [ref=f9e182]
- listitem [ref=f9e183]:
- button "nplus1-query-fanout-644febe6" [ref=f9e184]
- button "삭제" [ref=f9e185]
- listitem [ref=f9e186]:
- button "ap4-edge-trust-1cff2399" [ref=f9e187]
- button "삭제" [ref=f9e188]
- listitem [ref=f9e189]:
- button "ap3-csrf-split-501dd1f7" [ref=f9e190]
- button "삭제" [ref=f9e191]
- listitem [ref=f9e192]:
- button "ap3-bff-custody-82fa18bd" [ref=f9e193]
- button "삭제" [ref=f9e194]
- listitem [ref=f9e195]:
- button "ap2-split-custody-779cb791" [ref=f9e196]
- button "삭제" [ref=f9e197]
- listitem [ref=f9e198]:
- button "ap1-custody-v3-6e0376d2" [ref=f9e199]
- button "삭제" [ref=f9e200]
- listitem [ref=f9e201]:
- button "ap1-custody-v2-e110bd98" [ref=f9e202]
- button "삭제" [ref=f9e203]
- listitem [ref=f9e204]:
- button "ap1-credential-custody-f5e0c027" [ref=f9e205]
- button "삭제" [ref=f9e206]
- listitem [ref=f9e207]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f9e208]
- button "삭제" [ref=f9e209]
- region [ref=f9e210]:
- generic [ref=f9e211]:
- paragraph [ref=f9e212]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f9e213]
- generic [ref=f9e216]:
- generic [ref=f9e217]:
- navigation "문서 경로" [ref=f9e218]:
- link "동작 원리" [ref=f9e219] [cursor=pointer]:
- /url: /explore/concepts
- generic [aria-hidden] [ref=f9e220]: /
- generic [ref=f9e221]: OAuth/OIDC 인증 경계
- generic [aria-hidden] [ref=f9e222]: /
- link "KeyCloak Patterns" [ref=f9e223] [cursor=pointer]:
- /url: /projects/keycloak-patterns
- heading "Cookie로 인증하는 요청에서 CSRF token이 하는 일" [level=1] [ref=f9e224]
- paragraph [ref=f9e225]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
- generic [ref=f9e226]:
- generic [ref=f9e227]:
- term [ref=f9e228]: 기준
- definition [ref=f9e229]:
- paragraph [ref=f9e230]: Spring Security 6 CSRF · AP3 BFF 구성
- generic [ref=f9e231]:
- term [ref=f9e232]: 기록
- definition [ref=f9e233]: 게시 게시 전
- group [ref=f9e235]:
- generic "목차 · SameSite와 CSRF token은 역할이 다르다" [ref=f9e236] [cursor=pointer]
- article [ref=f9e238]:
- region [ref=f9e239]:
- heading [level=2] [ref=f9e240]:
- link "cookie가 credential이 되면 생기는 일 바로가기" [ref=f9e241] [cursor=pointer]:
- /url: "#cookie가-credential이-되면-생기는-일"
- text: cookie가 credential이 되면 생기는 일
- generic [aria-hidden] [ref=f9e242]: "#"
- paragraph [ref=f9e243]: 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
- paragraph [ref=f9e244]:
- text: 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다.
- code [ref=f9e245]: GET /bff/api/me
- text: 만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
- region [ref=f9e246]:
- heading [level=2] [ref=f9e247]:
- link "token을 받아 오는 요청 바로가기" [ref=f9e248] [cursor=pointer]:
- /url: "#token을-받아-오는-요청"
- text: token을 받아 오는 요청
- generic [aria-hidden] [ref=f9e249]: "#"
- paragraph [ref=f9e250]: 브라우저가 먼저 CSRF material을 요청한다.
- figure "HTTP ·CSRF token 요청 코드 복사" [ref=f9e251]:
- generic [ref=f9e252]:
- generic [ref=f9e253]: HTTP
- generic [ref=f9e254]: ·CSRF token 요청
- button "코드 복사" [ref=f9e255] [cursor=pointer]: 복사
- region "CSRF token 요청 코드" [ref=f9e256]:
- code [ref=f9e257]: "GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION=<opaque-session-id>"
- paragraph [ref=f9e259]:
- code [ref=f9e260]: CookieCsrfTokenRepository.withHttpOnlyFalse()
- text: 는 JavaScript가 읽을 수 있는
- code [ref=f9e261]: XSRF-TOKEN
- text: cookie를 path
- code [ref=f9e262]: /
- text: 에 만든다. controller는 다음 JSON을 반환한다.
- figure "JSON ·CsrfController가 반환하는 JSON 코드 복사" [ref=f9e263]:
- generic [ref=f9e264]:
- generic [ref=f9e265]: JSON
- generic [ref=f9e266]: ·CsrfController가 반환하는 JSON
- button "코드 복사" [ref=f9e267] [cursor=pointer]: 복사
- region "CsrfController가 반환하는 JSON 코드" [ref=f9e268]:
- code [ref=f9e269]: "{ \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"<xor-masked-csrf-token>\" }"
- figure [ref=f9e271]:
- button "ap3-csrf-boundary-971df81c 이미지 크게 보기" [ref=f9e272]:
- img "BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름." [ref=f9e273]
- generic [ref=f9e274]: 크게 보기
- generic [ref=f9e275]: BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.
- region [ref=f9e276]:
- heading [level=2] [ref=f9e277]:
- link "body의 token과 cookie의 값은 다르다 바로가기" [ref=f9e278] [cursor=pointer]:
- /url: "#body의-token과-cookie의-값은-다르다"
- text: body의 token과 cookie의 값은 다르다
- generic [aria-hidden] [ref=f9e279]: "#"
- paragraph [ref=f9e280]: 여기가 이 구조에서 가장 헷갈리는 지점이다.
- region "표" [ref=f9e281]:
- table [ref=f9e282]:
- caption [ref=f9e283]
- rowgroup [ref=f9e284]:
- row [ref=f9e285]:
- columnheader "위치" [ref=f9e286]
- columnheader "값" [ref=f9e287]
- rowgroup [ref=f9e288]:
- row [ref=f9e289]:
- cell [ref=f9e290]:
- text: 응답 body의
- code [ref=f9e291]: token
- cell "XOR와 Base64로 mask된 값" [ref=f9e292]
- row [ref=f9e293]:
- cell [ref=f9e294]:
- code [ref=f9e295]: XSRF-TOKEN
- text: cookie
- cell "raw 값" [ref=f9e296]
- row [ref=f9e297]:
- cell [ref=f9e298]:
- text: POST의
- code [ref=f9e299]: X-XSRF-TOKEN
- text: 헤더
- cell "cookie와 같은 raw 값" [ref=f9e300]
- paragraph [ref=f9e301]:
- code [ref=f9e302]: XorCsrfTokenRequestAttributeHandler
- text: 가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서
- code [ref=f9e303]: headerName
- text: 만 읽고, 실제 값은
- code [ref=f9e304]: document.cookie
- text: 에서 raw
- code [ref=f9e305]: XSRF-TOKEN
- text: 을 찾아 쓴다.
- paragraph [ref=f9e306]:
- code [ref=f9e307]: SpaCsrfTokenRequestHandler
- text: 가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
- paragraph [ref=f9e308]:
- text: 응답 JSON의
- code [ref=f9e309]: token
- text: 을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
- region [ref=f9e310]:
- heading [level=2] [ref=f9e311]:
- link "검증이 controller보다 먼저 일어난다 바로가기" [ref=f9e312] [cursor=pointer]:
- /url: "#검증이-controller보다-먼저-일어난다"
- text: 검증이 controller보다 먼저 일어난다
- generic [aria-hidden] [ref=f9e313]: "#"
- paragraph [ref=f9e314]: 정상 상태 변경 요청은 다음과 같다.
- figure "HTTP ·CSRF 검증을 통과하는 POST 코드 복사" [ref=f9e315]:
- generic [ref=f9e316]:
- generic [ref=f9e317]: HTTP
- generic [ref=f9e318]: ·CSRF 검증을 통과하는 POST
- button "코드 복사" [ref=f9e319] [cursor=pointer]: 복사
- region "CSRF 검증을 통과하는 POST 코드" [ref=f9e320]:
- code [ref=f9e321]: "POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token> X-XSRF-TOKEN: <same-raw-csrf-token> theme=dark"
- paragraph [ref=f9e323]: Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
- region [ref=f9e324]:
- heading [level=2] [ref=f9e325]:
- link "SameSite와 CSRF token은 역할이 다르다 바로가기" [ref=f9e326] [cursor=pointer]:
- /url: "#samesite와-csrf-token은-역할이-다르다"
- text: SameSite와 CSRF token은 역할이 다르다
- generic [aria-hidden] [ref=f9e327]: "#"
- region "표" [ref=f9e328]:
- table [ref=f9e329]:
- caption [ref=f9e330]
- rowgroup [ref=f9e331]:
- row [ref=f9e332]:
- columnheader [ref=f9e333]
- columnheader "SameSite" [ref=f9e334]
- columnheader "CSRF token" [ref=f9e335]
- rowgroup [ref=f9e336]:
- row [ref=f9e337]:
- cell "누가 판단하나" [ref=f9e338]
- cell "브라우저" [ref=f9e339]
- cell "서버" [ref=f9e340]
- row [ref=f9e341]:
- cell "무엇을 정하나" [ref=f9e342]
- cell "cookie를 보낼지" [ref=f9e343]
- cell "요청을 받아들일지" [ref=f9e344]
- row [ref=f9e345]:
- cell "언제 작동하나" [ref=f9e346]
- cell "요청을 만들 때" [ref=f9e347]
- cell "요청을 처리할 때" [ref=f9e348]
- paragraph [ref=f9e349]: 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다.
- paragraph [ref=f9e350]: 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다.
- region "표" [ref=f9e351]:
- table [ref=f9e352]:
- caption [ref=f9e353]
- rowgroup [ref=f9e354]:
- row [ref=f9e355]:
- columnheader "입력" [ref=f9e356]
- columnheader "cookie 동작" [ref=f9e357]
- columnheader "CSRF 동작" [ref=f9e358]
- columnheader "결과" [ref=f9e359]
- rowgroup [ref=f9e360]:
- row [ref=f9e361]:
- cell "same-origin, CSRF 헤더 없음" [ref=f9e362]
- cell "session cookie 붙음" [ref=f9e363]
- cell "token 부재로 거부" [ref=f9e364]
- cell "403" [ref=f9e365]
- row [ref=f9e366]:
- cell "same-origin, raw cookie와 헤더 일치" [ref=f9e367]
- cell "session cookie 붙음" [ref=f9e368]
- cell "token 일치" [ref=f9e369]
- cell "200" [ref=f9e370]
- row [ref=f9e371]:
- cell "다른 port지만 same-site, 헤더 없음" [ref=f9e372]
- cell "cookie가 붙을 수 있음" [ref=f9e373]
- cell "token 부재로 거부" [ref=f9e374]
- cell "403" [ref=f9e375]
- row [ref=f9e376]:
- cell "cross-site POST" [ref=f9e377]
- cell "SameSite=Lax로 cookie 제외" [ref=f9e378]
- cell "이 지점 이후는 고정하지 않음" [ref=f9e379]
- cell "cookie omission이 확인 지점" [ref=f9e380]
- paragraph [ref=f9e381]: 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다.
- region [ref=f9e382]:
- heading [level=2] [ref=f9e383]:
- link "CSRF가 XSS를 대신하지 않는다 바로가기" [ref=f9e384] [cursor=pointer]:
- /url: "#csrf가-xss를-대신하지-않는다"
- text: CSRF가 XSS를 대신하지 않는다
- generic [aria-hidden] [ref=f9e385]: "#"
- paragraph [ref=f9e386]:
- text: 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는
- code [ref=f9e387]: XSRF-TOKEN
- text: 도 같이 읽을 수 있다.
- paragraph [ref=f9e388]: 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
- region [ref=f9e389]:
- paragraph [ref=f9e390]: Next
- heading "다음에 읽을 것" [level=2] [ref=f9e391]
- list [ref=f9e392]:
- listitem [ref=f9e393]:
- link "적용 기준 BFF 인증 구조 설계 기준" [ref=f9e394] [cursor=pointer]:
- /url: /references/bff-authentication-design-criteria
- generic [ref=f9e395]: 적용 기준
- generic [ref=f9e396]:
- strong [ref=f9e397]: BFF 인증 구조 설계 기준
- paragraph [aria-hidden] [ref=f9e398]: 이 확인이 필요한 구조의 설계 항목이다.
- generic [aria-hidden] [ref=f9e399]:
- listitem [ref=f9e400]:
- link "검증 기록 Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f9e401] [cursor=pointer]:
- /url: /cases/bff-session-csrf-responsibility
- generic [ref=f9e402]: 검증 기록
- generic [ref=f9e403]:
- strong [ref=f9e404]: Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정
- paragraph [aria-hidden] [ref=f9e405]: 이 동작을 실제로 재현한 기록이다.
- generic [aria-hidden] [ref=f9e406]:
- listitem [ref=f9e407]:
- link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f9e408] [cursor=pointer]:
- /url: /references/oauth-token-application-session-boundary
- generic [ref=f9e409]: 적용 기준
- generic [ref=f9e410]:
- strong [ref=f9e411]: OAuth Token과 Application Session을 구분하는 기준
- paragraph [aria-hidden] [ref=f9e412]: session cookie와 CSRF token은 서로 다른 값이다.
- generic [aria-hidden] [ref=f9e413]:
- complementary [ref=f9e414]:
- heading "작업 상태" [level=2] [ref=f9e415]
- status "편집 상태" [ref=f9e416]: 저장됨
- generic [ref=f9e417]:
- generic [ref=f9e418]:
- term [ref=f9e419]: 저장 버전
- definition [ref=f9e420]: "5"
- generic [ref=f9e421]:
- term [ref=f9e422]: 종류
- definition [ref=f9e423]: 동작 원리
- paragraph [ref=f9e424]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f9e425]:
- button "저장" [disabled] [ref=f9e426]
- button "게시" [ref=f9e427]
- paragraph [ref=f9e428]: 버전 5으로 저장했습니다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,628 @@
- generic [ref=f12e3]:
- link "본문으로 건너뛰기" [ref=f12e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f12e5]:
- generic [ref=f12e6]:
- link "TechLog Studio" [ref=f12e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f12e8]: Studio
- navigation "Studio 주 탐색" [ref=f12e10]:
- link "작업본" [ref=f12e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f12e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f12e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f12e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f12e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f12e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f12e17]
- main [ref=f12e18]:
- generic [ref=f12e19]:
- generic [ref=f12e20]:
- region [ref=f12e21]:
- generic [ref=f12e22]:
- paragraph [ref=f12e23]: CASE · VERSION 36
- heading "문서 편집" [level=1] [ref=f12e24]
- paragraph [ref=f12e25]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- region [ref=f12e26]:
- generic [ref=f12e27]:
- paragraph [ref=f12e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f12e29]
- generic [ref=f12e30]:
- generic [ref=f12e31]:
- generic [ref=f12e32]: 제목
- textbox "제목" [ref=f12e33]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- generic [ref=f12e34]:
- generic [ref=f12e35]: slug
- textbox "slug" [ref=f12e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: fetch-join-multibag-and-row-explosion
- generic [ref=f12e37]:
- generic [ref=f12e38]: 요약
- textbox "요약" [ref=f12e39]: "추가 쿼리를 줄이기 위해 필요한 연관 데이터를 `fetch join`으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에 `fetch join`하자 `MultipleBagFetchException`이 발생했다. 컬렉션 하나만 `fetch join`했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다. `fetch join`으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다."
- generic [aria-hidden] [ref=f12e40]: 목록 카드에는 약 90자까지 보입니다 · 312 / 2000
- generic [ref=f12e41]:
- generic [ref=f12e42]: Topic
- combobox "Topic" [ref=f12e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f12e44]:
- generic [ref=f12e45]: Project
- combobox "Project" [ref=f12e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f12e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f12e48]:
- generic [ref=f12e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f12e51]
- generic [ref=f12e52]: 파생 쿼리 그대로
- generic [ref=f12e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [checked] [ref=f12e54]
- generic [ref=f12e55]: 컬렉션 fetch join
- generic [ref=f12e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [ref=f12e57]
- generic [ref=f12e58]: fetch join + 페이징
- group "관계" [ref=f12e59]:
- generic [ref=f12e61]:
- generic [ref=f12e62]:
- generic [ref=f12e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f12e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f12e65]:
- generic [ref=f12e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f12e67]: 이 실패에서 나온 선택 기준이다.
- generic [aria-hidden] [ref=f12e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f12e69]:
- button "위로" [disabled] [ref=f12e70]
- button "아래로" [ref=f12e71]
- button "삭제" [ref=f12e72]
- generic [ref=f12e73]:
- generic [ref=f12e74]:
- generic [ref=f12e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f12e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging" [selected]
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f12e77]:
- generic [ref=f12e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f12e79]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
- generic [aria-hidden] [ref=f12e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f12e81]:
- button "위로" [ref=f12e82]
- button "아래로" [ref=f12e83]
- button "삭제" [ref=f12e84]
- generic [ref=f12e85]:
- generic [ref=f12e86]:
- generic [ref=f12e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f12e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [selected]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f12e89]:
- generic [ref=f12e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f12e91]: 이 시도가 풀려던 문제다.
- generic [aria-hidden] [ref=f12e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f12e93]:
- button "위로" [ref=f12e94]
- button "아래로" [disabled] [ref=f12e95]
- button "삭제" [ref=f12e96]
- button "관계 추가" [ref=f12e97]
- region [ref=f12e98]:
- generic [ref=f12e99]:
- paragraph [ref=f12e100]: CASE
- heading "문제와 검증" [level=2] [ref=f12e101]
- generic [ref=f12e102]:
- generic [ref=f12e103]:
- generic [ref=f12e104]: 문제
- textbox "문제" [ref=f12e105]: "`@OneToMany`과 `@ManyToOne`에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서 `highlights`, `mentions`를 모두 `fetch join`해 한 번의 쿼리로 조회해 보았다. mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다."
- generic [ref=f12e106]:
- generic [ref=f12e107]: 결론
- textbox "결론" [ref=f12e108]: "두 `List` 컬렉션을 동시에 `fetch join`하면 `MultipleBagFetchException`이 발생했다. Hibernate는 순서 컬럼이 없는 두 `List`가 조인되면서 `highlights × mentions` 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다. `highlights` 하나만 `fetch join`하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다. Hibernate 6에서는 `fetch join` 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다. N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히 `User`와 `Page`를 조회하는 추가 쿼리였고, `highlights`를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다."
- generic [ref=f12e109]:
- generic [ref=f12e110]: 검증 환경
- textbox "검증 환경" [ref=f12e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) Query : JPQL Unique Constraint : (feed_item_id, mentioned_user_id)"
- generic [ref=f12e112]:
- generic [ref=f12e113]: 재현 조건
- textbox "재현 조건" [ref=f12e114]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다. 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다. 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다. 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다. 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
- generic [ref=f12e115]:
- generic [ref=f12e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f12e117]: 2026-09-01
- generic [ref=f12e118]:
- generic [ref=f12e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f12e120]:
- button "코드" [ref=f12e121] [cursor=pointer]
- button "표" [ref=f12e122] [cursor=pointer]
- button "목록" [ref=f12e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f12e124]: "## 두 컬렉션을 동시 fetch join ```java label=\"컬렉션 둘을 같이 fetch join\" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` ```text label=\"예외 원인\" java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException ``` MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다. 전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다. ## 한 컬렉션만 fetch join :::evidence key=\"cartesian-row-multiplication-dce2e166\" alt=\"feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.\" caption=\" \" zoom=\"true\" ::: | FeedItem 수 | 조인된 행 수 | 반환된 Feed Item 수 | Highlight 수 | FeedItem 대비 조인 행 수 | |---:|---:|---:|---:|---:| | 10 | 1,285 | 10 | 1,285 | 128.5× | | 100 | 1,961 | 100 | 1,961 | 19.6× | | 1,000 | 2,917 | 1,000 | 2,917 | 2.9× | highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다. FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다. Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다. 하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다. FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다. ## 쿼리 수만 보면 개선처럼 보인다 | 구분 | 기존 조회 | highlights fetch join | 변화 | |---|---:|---:|---| | Feed Item 조회 | 1 | 1 | highlights를 같이 조회 | | count 조회 | 1 | 0 | JPQL로 조회 | | Highlight 조회 | 100 | 0 | 개별 조회 제거 | | User+Page 조회 | 120 | 120 | 변화 없음 | | 전체 | 222 | 121 | 101개 감소 | 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다. ## 조인이 행을 곱하는 것을 실행계획 ```text label=\"N=100일 때, fetch join EXPLAIN\" Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) Execution Time: 0.959 ms ``` Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다. 쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다. Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다. 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다."
- group [ref=f12e125]:
- paragraph [ref=f12e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f12e127]
- paragraph [ref=f12e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f12e129]:
- generic [ref=f12e130]:
- generic [ref=f12e131]: 업로드 종류
- combobox "업로드 종류" [ref=f12e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f12e133]
- generic [ref=f12e134]:
- search [ref=f12e135]:
- generic [ref=f12e136]: Asset 검색
- generic [ref=f12e137]:
- searchbox "Asset 검색" [ref=f12e138]
- button "검색" [ref=f12e139]
- generic [ref=f12e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f12e141]
- generic [ref=f12e142]: 삽입할 때 크게 보기 허용
- status [ref=f12e143]: 삽입할 수 있는 Asset 24개
- list [ref=f12e144]:
- listitem [ref=f12e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f12e146]
- button "삭제" [ref=f12e147]
- listitem [ref=f12e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f12e149]
- button "삭제" [ref=f12e150]
- listitem [ref=f12e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f12e152]
- button "삭제" [ref=f12e153]
- listitem [ref=f12e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f12e155]
- button "삭제" [ref=f12e156]
- listitem [ref=f12e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f12e158]
- button "삭제" [ref=f12e159]
- listitem [ref=f12e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f12e161]
- button "삭제" [ref=f12e162]
- listitem [ref=f12e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f12e164]
- button "삭제" [ref=f12e165]
- listitem [ref=f12e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f12e167]
- button "삭제" [ref=f12e168]
- listitem [ref=f12e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f12e170]
- button "삭제" [ref=f12e171]
- listitem [ref=f12e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f12e173]
- button "삭제" [ref=f12e174]
- listitem [ref=f12e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f12e176]
- button "삭제" [ref=f12e177]
- listitem [ref=f12e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f12e179]
- button "삭제" [ref=f12e180]
- listitem [ref=f12e181]:
- button "login-api-phase-split-3e354274" [ref=f12e182]
- button "삭제" [ref=f12e183]
- listitem [ref=f12e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f12e185]
- button "삭제" [ref=f12e186]
- listitem [ref=f12e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f12e188]
- button "삭제" [ref=f12e189]
- listitem [ref=f12e190]:
- button "nplus1-query-fanout-644febe6" [ref=f12e191]
- button "삭제" [ref=f12e192]
- listitem [ref=f12e193]:
- button "ap4-edge-trust-1cff2399" [ref=f12e194]
- button "삭제" [ref=f12e195]
- listitem [ref=f12e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f12e197]
- button "삭제" [ref=f12e198]
- listitem [ref=f12e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f12e200]
- button "삭제" [ref=f12e201]
- listitem [ref=f12e202]:
- button "ap2-split-custody-779cb791" [ref=f12e203]
- button "삭제" [ref=f12e204]
- listitem [ref=f12e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f12e206]
- button "삭제" [ref=f12e207]
- listitem [ref=f12e208]:
- button "ap1-custody-v2-e110bd98" [ref=f12e209]
- button "삭제" [ref=f12e210]
- listitem [ref=f12e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f12e212]
- button "삭제" [ref=f12e213]
- listitem [ref=f12e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f12e215]
- button "삭제" [ref=f12e216]
- region [ref=f12e217]:
- generic [ref=f12e218]:
- paragraph [ref=f12e219]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f12e220]
- generic [ref=f12e223]:
- generic [ref=f12e224]:
- navigation "문서 경로" [ref=f12e225]:
- link "검증 기록" [ref=f12e226] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f12e227]: /
- generic [ref=f12e228]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f12e229]: /
- link "Liner N + 1문제" [ref=f12e230] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=1] [ref=f12e231]
- paragraph [ref=f12e232]:
- text: 추가 쿼리를 줄이기 위해 필요한 연관 데이터를
- code [ref=f12e233]: fetch join
- text: 으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에
- code [ref=f12e234]: fetch join
- text: 하자
- code [ref=f12e235]: MultipleBagFetchException
- text: 이 발생했다.
- paragraph [ref=f12e236]:
- text: 컬렉션 하나만
- code [ref=f12e237]: fetch join
- text: 했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다.
- paragraph [ref=f12e238]:
- code [ref=f12e239]: fetch join
- text: 으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다.
- region "문제와 결론" [ref=f12e240]:
- generic [ref=f12e241]:
- paragraph [ref=f12e242]: 문제
- paragraph [ref=f12e243]:
- code [ref=f12e244]: "@OneToMany"
- text:
- code [ref=f12e245]: "@ManyToOne"
- text: 에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서
- code [ref=f12e246]: highlights
- text: ","
- code [ref=f12e247]: mentions
- text: 를 모두
- code [ref=f12e248]: fetch join
- text: 해 한 번의 쿼리로 조회해 보았다.
- paragraph [ref=f12e249]: mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다.
- generic [ref=f12e250]:
- paragraph [ref=f12e251]: 결론
- paragraph [ref=f12e252]:
- text:
- code [ref=f12e253]: List
- text: 컬렉션을 동시에
- code [ref=f12e254]: fetch join
- text: 하면
- code [ref=f12e255]: MultipleBagFetchException
- text: 이 발생했다. Hibernate는 순서 컬럼이 없는 두
- code [ref=f12e256]: List
- text: 가 조인되면서
- code [ref=f12e257]: highlights × mentions
- text: 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다.
- paragraph [ref=f12e258]:
- code [ref=f12e259]: highlights
- text: 하나만
- code [ref=f12e260]: fetch join
- text: 하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다.
- paragraph [ref=f12e261]:
- text: Hibernate 6에서는
- code [ref=f12e262]: fetch join
- text: 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다.
- paragraph [ref=f12e263]:
- text: N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히
- code [ref=f12e264]: User
- text:
- code [ref=f12e265]: Page
- text: 를 조회하는 추가 쿼리였고,
- code [ref=f12e266]: highlights
- text: 를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다.
- generic [ref=f12e267]:
- generic [ref=f12e268]:
- term [ref=f12e269]: 검증 환경
- definition [ref=f12e270]:
- paragraph [ref=f12e271]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)Query : JPQLUnique Constraint : (feed_item_id, mentioned_user_id)"
- generic [ref=f12e272]:
- term [ref=f12e273]: 검증 데이터
- definition [ref=f12e274]:
- paragraph [ref=f12e275]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다.
- paragraph [ref=f12e276]: 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다.
- paragraph [ref=f12e277]: 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다.
- paragraph [ref=f12e278]: 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다.
- paragraph [ref=f12e279]: 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
- generic [ref=f12e280]:
- term [ref=f12e281]: 기록
- definition [ref=f12e282]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f12e284]:
- generic "목차 · 두 컬렉션을 동시 fetch join" [ref=f12e285] [cursor=pointer]
- article [ref=f12e287]:
- region [ref=f12e288]:
- heading [level=2] [ref=f12e289]:
- link "두 컬렉션을 동시 fetch join 바로가기" [ref=f12e290] [cursor=pointer]:
- /url: "#두-컬렉션을-동시-fetch-join"
- text: 두 컬렉션을 동시 fetch join
- generic [aria-hidden] [ref=f12e291]: "#"
- figure "JAVA ·컬렉션 둘을 같이 fetch join 코드 복사" [ref=f12e292]:
- generic [ref=f12e293]:
- generic [ref=f12e294]: JAVA
- generic [ref=f12e295]: ·컬렉션 둘을 같이 fetch join
- button "코드 복사" [ref=f12e296] [cursor=pointer]: 복사
- region "컬렉션 둘을 같이 fetch join 코드" [ref=f12e297]:
- code [ref=f12e298]: select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions
- figure "TEXT ·예외 원인 코드 복사" [ref=f12e300]:
- generic [ref=f12e301]:
- generic [ref=f12e302]: TEXT
- generic [ref=f12e303]: ·예외 원인
- button "코드 복사" [ref=f12e304] [cursor=pointer]: 복사
- region "예외 원인 코드" [ref=f12e305]:
- code [ref=f12e306]: java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
- paragraph [ref=f12e308]: MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다.전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다.
- region [ref=f12e309]:
- heading [level=2] [ref=f12e310]:
- link "한 컬렉션만 fetch join 바로가기" [ref=f12e311] [cursor=pointer]:
- /url: "#한-컬렉션만-fetch-join"
- text: 한 컬렉션만 fetch join
- generic [aria-hidden] [ref=f12e312]: "#"
- figure [ref=f12e313]:
- button "cartesian-row-multiplication-dce2e166 이미지 크게 보기" [ref=f12e314]:
- img "feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름." [ref=f12e315]
- generic [ref=f12e316]: 크게 보기
- generic [ref=f12e317]: feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.
- region "표" [ref=f12e318]:
- table [ref=f12e319]:
- caption [ref=f12e320]
- rowgroup [ref=f12e321]:
- row [ref=f12e322]:
- columnheader "FeedItem 수" [ref=f12e323]
- columnheader "조인된 행 수" [ref=f12e324]
- columnheader "반환된 Feed Item 수" [ref=f12e325]
- columnheader "Highlight 수" [ref=f12e326]
- columnheader "FeedItem 대비 조인 행 수" [ref=f12e327]
- rowgroup [ref=f12e328]:
- row [ref=f12e329]:
- cell "10" [ref=f12e330]
- cell "1,285" [ref=f12e331]
- cell "10" [ref=f12e332]
- cell "1,285" [ref=f12e333]
- cell "128.5×" [ref=f12e334]
- row [ref=f12e335]:
- cell "100" [ref=f12e336]
- cell "1,961" [ref=f12e337]
- cell "100" [ref=f12e338]
- cell "1,961" [ref=f12e339]
- cell "19.6×" [ref=f12e340]
- row [ref=f12e341]:
- cell "1,000" [ref=f12e342]
- cell "2,917" [ref=f12e343]
- cell "1,000" [ref=f12e344]
- cell "2,917" [ref=f12e345]
- cell "2.9×" [ref=f12e346]
- paragraph [ref=f12e347]: highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다.FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다.
- paragraph [ref=f12e348]: Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다.하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다.
- paragraph [ref=f12e349]: FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다.
- region [ref=f12e350]:
- heading [level=2] [ref=f12e351]:
- link "쿼리 수만 보면 개선처럼 보인다 바로가기" [ref=f12e352] [cursor=pointer]:
- /url: "#쿼리-수만-보면-개선처럼-보인다"
- text: 쿼리 수만 보면 개선처럼 보인다
- generic [aria-hidden] [ref=f12e353]: "#"
- region "표" [ref=f12e354]:
- table [ref=f12e355]:
- caption [ref=f12e356]
- rowgroup [ref=f12e357]:
- row [ref=f12e358]:
- columnheader "구분" [ref=f12e359]
- columnheader "기존 조회" [ref=f12e360]
- columnheader "highlights fetch join" [ref=f12e361]
- columnheader "변화" [ref=f12e362]
- rowgroup [ref=f12e363]:
- row [ref=f12e364]:
- cell "Feed Item 조회" [ref=f12e365]
- cell "1" [ref=f12e366]
- cell "1" [ref=f12e367]
- cell "highlights를 같이 조회" [ref=f12e368]
- row [ref=f12e369]:
- cell "count 조회" [ref=f12e370]
- cell "1" [ref=f12e371]
- cell "0" [ref=f12e372]
- cell "JPQL로 조회" [ref=f12e373]
- row [ref=f12e374]:
- cell "Highlight 조회" [ref=f12e375]
- cell "100" [ref=f12e376]
- cell "0" [ref=f12e377]
- cell "개별 조회 제거" [ref=f12e378]
- row [ref=f12e379]:
- cell "User+Page 조회" [ref=f12e380]
- cell "120" [ref=f12e381]
- cell "120" [ref=f12e382]
- cell "변화 없음" [ref=f12e383]
- row [ref=f12e384]:
- cell "전체" [ref=f12e385]
- cell "222" [ref=f12e386]
- cell "121" [ref=f12e387]
- cell "101개 감소" [ref=f12e388]
- paragraph [ref=f12e389]: 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다.
- region [ref=f12e390]:
- heading [level=2] [ref=f12e391]:
- link "조인이 행을 곱하는 것을 실행계획 바로가기" [ref=f12e392] [cursor=pointer]:
- /url: "#조인이-행을-곱하는-것을-실행계획"
- text: 조인이 행을 곱하는 것을 실행계획
- generic [aria-hidden] [ref=f12e393]: "#"
- figure "TEXT ·N=100일 때, fetch join EXPLAIN 코드 복사" [ref=f12e394]:
- generic [ref=f12e395]:
- generic [ref=f12e396]: TEXT
- generic [ref=f12e397]: ·N=100일 때, fetch join EXPLAIN
- button "코드 복사" [ref=f12e398] [cursor=pointer]: 복사
- region "N=100일 때, fetch join EXPLAIN 코드" [ref=f12e399]:
- code [ref=f12e400]: "Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) Execution Time: 0.959 ms"
- paragraph [ref=f12e402]: Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다.쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다.
- paragraph [ref=f12e403]: Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다.
- paragraph [ref=f12e404]: 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다.
- region [ref=f12e405]:
- paragraph [ref=f12e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f12e407]
- list [ref=f12e408]:
- listitem [ref=f12e409]:
- link "검증 기록 Collection Fetch Join Pagination의 In-memory Paging" [ref=f12e410] [cursor=pointer]:
- /url: /cases/collection-fetch-join-in-memory-paging
- generic [ref=f12e411]: 검증 기록
- generic [ref=f12e412]:
- strong [ref=f12e413]: Collection Fetch Join Pagination의 In-memory Paging
- paragraph [aria-hidden] [ref=f12e414]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
- generic [aria-hidden] [ref=f12e415]:
- listitem [ref=f12e416]:
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f12e417] [cursor=pointer]:
- /url: /cases/eager-toone-nplus1-without-access
- generic [ref=f12e418]: 검증 기록
- generic [ref=f12e419]:
- strong [ref=f12e420]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
- paragraph [aria-hidden] [ref=f12e421]: 이 시도가 풀려던 문제다.
- generic [aria-hidden] [ref=f12e422]:
- complementary [ref=f12e423]:
- heading "작업 상태" [level=2] [ref=f12e424]
- status "편집 상태" [ref=f12e425]: 저장됨
- generic [ref=f12e426]:
- generic [ref=f12e427]:
- term [ref=f12e428]: 저장 버전
- definition [ref=f12e429]: "36"
- generic [ref=f12e430]:
- term [ref=f12e431]: 종류
- definition [ref=f12e432]: 검증 기록
- paragraph [ref=f12e433]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f12e434]:
- button "저장" [disabled] [ref=f12e435]
- button "게시" [ref=f12e436]
- paragraph [ref=f12e437]: 버전 36으로 저장했습니다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,564 @@
- generic [ref=f14e3]:
- link "본문으로 건너뛰기" [ref=f14e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f14e5]:
- generic [ref=f14e6]:
- link "TechLog Studio" [ref=f14e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f14e8]: Studio
- navigation "Studio 주 탐색" [ref=f14e10]:
- link "작업본" [ref=f14e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f14e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f14e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f14e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f14e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f14e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f14e17]
- main [ref=f14e18]:
- generic [ref=f14e19]:
- generic [ref=f14e20]:
- region [ref=f14e21]:
- generic [ref=f14e22]:
- paragraph [ref=f14e23]: CASE · VERSION 4
- heading "문서 편집" [level=1] [ref=f14e24]
- paragraph [ref=f14e25]: Projection 이후에도 1,509행을 읽은 Row Over-fetch
- region [ref=f14e26]:
- generic [ref=f14e27]:
- paragraph [ref=f14e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f14e29]
- generic [ref=f14e30]:
- generic [ref=f14e31]:
- generic [ref=f14e32]: 제목
- textbox "제목" [ref=f14e33]: Projection 이후에도 1,509행을 읽은 Row Over-fetch
- generic [ref=f14e34]:
- generic [ref=f14e35]: slug
- textbox "slug" [ref=f14e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: projection-row-over-fetch
- generic [ref=f14e37]:
- generic [ref=f14e38]: 요약
- textbox "요약" [ref=f14e39]: DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로 줄고 쿼리도 2개로 고정됐다. 그런데 자식 IN 쿼리는 페이지 부모 20개의 하이라이트를 전부 가져와 1,509행이었다. 화면에 필요한 것은 부모당 최신 3개, 최대 60행이었다.
- generic [aria-hidden] [ref=f14e40]: 목록 카드에는 약 90자까지 보입니다 · 137 / 2000
- generic [ref=f14e41]:
- generic [ref=f14e42]: Topic
- combobox "Topic" [ref=f14e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f14e44]:
- generic [ref=f14e45]: Project
- combobox "Project" [ref=f14e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f14e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f14e48]:
- generic [ref=f14e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f14e51]
- generic [ref=f14e52]: 파생 쿼리 그대로
- generic [ref=f14e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f14e54]
- generic [ref=f14e55]: 컬렉션 fetch join
- generic [ref=f14e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [ref=f14e57]
- generic [ref=f14e58]: fetch join + 페이징
- group "관계" [ref=f14e59]:
- generic [ref=f14e61]:
- generic [ref=f14e62]:
- generic [ref=f14e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f14e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준" [disabled]
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다" [selected]
- generic [ref=f14e65]:
- generic [ref=f14e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f14e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f14e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f14e69]:
- button "위로" [disabled] [ref=f14e70]
- button "아래로" [ref=f14e71]
- button "삭제" [ref=f14e72]
- generic [ref=f14e73]:
- generic [ref=f14e74]:
- generic [ref=f14e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f14e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준" [selected]
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다" [disabled]
- generic [ref=f14e77]:
- generic [ref=f14e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f14e79]: 남은 행 과조회를 푼 다음 단계의 기준이다.
- generic [aria-hidden] [ref=f14e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f14e81]:
- button "위로" [ref=f14e82]
- button "아래로" [ref=f14e83]
- button "삭제" [ref=f14e84]
- generic [ref=f14e85]:
- generic [ref=f14e86]:
- generic [ref=f14e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f14e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준" [disabled]
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다" [disabled]
- generic [ref=f14e89]:
- generic [ref=f14e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f14e91]: 왕복과 적재를 각각 어느 전략이 푸는지 정리한 기록이다.
- generic [aria-hidden] [ref=f14e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f14e93]:
- button "위로" [ref=f14e94]
- button "아래로" [disabled] [ref=f14e95]
- button "삭제" [ref=f14e96]
- button "관계 추가" [ref=f14e97]
- region [ref=f14e98]:
- generic [ref=f14e99]:
- paragraph [ref=f14e100]: CASE
- heading "문제와 검증" [level=2] [ref=f14e101]
- generic [ref=f14e102]:
- generic [ref=f14e103]:
- generic [ref=f14e104]: 문제
- textbox "문제" [ref=f14e105]: Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다. 화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
- generic [ref=f14e106]:
- generic [ref=f14e107]: 결론
- textbox "결론" [ref=f14e108]: 프로젝션은 하이드레이트한 엔티티를 0개로 만들었다. SELECT new 캐리어는 영속 엔티티 대신 스칼라 값으로 record를 만들므로 1차 캐시·더티체킹·지연 프록시도 생기지 않는다. join도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 발행 쿼리는 N과 관계없이 2개로 고정됐다. 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 페이지 부모가 최대 20개라 자식 IN도 한 번만 실행된다. 남은 문제는 행 수였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지에서 자식 행은 1,509개였고 화면에 필요한 것은 60개였다. 필요한 컬럼만 선택하면 EXPLAIN의 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
- generic [ref=f14e109]:
- generic [ref=f14e110]: 검증 환경
- textbox "검증 환경" [ref=f14e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 격리 프로젝션 측정은 배치 설정이 없는 별도 IT 클래스 loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드 측정 지표 entitiesLoaded : Statistics.getEntityLoadCount() prepared : Statistics.getPrepareStatementCount() collectionFetch : Statistics.getCollectionFetchCount()"
- generic [ref=f14e112]:
- generic [ref=f14e113]: 재현 조건
- textbox "재현 조건" [ref=f14e114]: "1. 부모 스칼라 프로젝션과 자식 IN 스칼라 프로젝션 두 쿼리로 loadFeedProjection을 구현한다. 2. seed 1,000에서 loadFeedProjection(0, 20)을 실행하고 getEntityLoadCount()를 읽는다. 3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다. 4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다. 5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다. 6. 부모 프로젝션과 엔티티 페이징의 EXPLAIN width를 비교한다."
- generic [ref=f14e115]:
- generic [ref=f14e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f14e117]
- generic [ref=f14e118]:
- generic [ref=f14e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f14e120]:
- button "코드" [ref=f14e121] [cursor=pointer]
- button "표" [ref=f14e122] [cursor=pointer]
- button "목록" [ref=f14e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f14e124]: "## 두 개의 스칼라 프로젝션 ```java label=\"loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로\" // (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에 select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) from FeedItemJpaEntity f join f.user u join f.page p order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT // (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑 select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) from HighlightJpaEntity h where h.feedItem.id in (:pageIds) ``` FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다. ## 엔티티 로드가 0으로 줄어든다 | 지표 | 배치 | 프로젝션 | |---|---:|---:| | entitiesLoaded (seed 1,000) | 1,569 | 0 | | prepared (N=1,000) | 23 | 2 | | collectionFetch (N=1,000) | 10 | 0 | ## N이 늘어도 쿼리는 2개다 | N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) | |---:|---:|---:|---:| | 10 | 25 | 5 | 2 | | 100 | 222 | 5 | 2 | | 1,000 | 2,022 | 23 | 2 | 기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다. ## 남은 비용 — 페이지당 전량 :::evidence key=\"projection-row-over-fetch-f2b1943b\" alt=\"페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다.\" caption=\" \" zoom=\"true\" ::: | 항목 | 값 | |---|---:| | 페이지 부모 | 20 | | 자식 IN이 반환한 행 | 1,509 | | 화면에 필요한 행 | 60 (부모당 3) | 단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다. ## width는 좁아지지 않았다 ```text label=\"seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN\" -- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) -> Sort Sort Method: top-N heapsort Memory: 27kB -> Hash Join (fi.page_id = p.id) ← pages 조인 -> Hash Join (fi.user_id = u.id) ← users 조인 -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 -- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) Hash Semi Join (... rows=1509 loops=1) ``` 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다. ## 배치와 프로젝션은 다른 것을 줄인다 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다. 기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다."
- group [ref=f14e125]:
- paragraph [ref=f14e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f14e127]
- paragraph [ref=f14e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f14e129]:
- generic [ref=f14e130]:
- generic [ref=f14e131]: 업로드 종류
- combobox "업로드 종류" [ref=f14e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f14e133]
- generic [ref=f14e134]:
- search [ref=f14e135]:
- generic [ref=f14e136]: Asset 검색
- generic [ref=f14e137]:
- searchbox "Asset 검색" [ref=f14e138]
- button "검색" [ref=f14e139]
- generic [ref=f14e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f14e141]
- generic [ref=f14e142]: 삽입할 때 크게 보기 허용
- status [ref=f14e143]: 삽입할 수 있는 Asset 24개
- list [ref=f14e144]:
- listitem [ref=f14e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f14e146]
- button "삭제" [ref=f14e147]
- listitem [ref=f14e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f14e149]
- button "삭제" [ref=f14e150]
- listitem [ref=f14e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f14e152]
- button "삭제" [ref=f14e153]
- listitem [ref=f14e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f14e155]
- button "삭제" [ref=f14e156]
- listitem [ref=f14e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f14e158]
- button "삭제" [ref=f14e159]
- listitem [ref=f14e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f14e161]
- button "삭제" [ref=f14e162]
- listitem [ref=f14e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f14e164]
- button "삭제" [ref=f14e165]
- listitem [ref=f14e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f14e167]
- button "삭제" [ref=f14e168]
- listitem [ref=f14e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f14e170]
- button "삭제" [ref=f14e171]
- listitem [ref=f14e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f14e173]
- button "삭제" [ref=f14e174]
- listitem [ref=f14e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f14e176]
- button "삭제" [ref=f14e177]
- listitem [ref=f14e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f14e179]
- button "삭제" [ref=f14e180]
- listitem [ref=f14e181]:
- button "login-api-phase-split-3e354274" [ref=f14e182]
- button "삭제" [ref=f14e183]
- listitem [ref=f14e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f14e185]
- button "삭제" [ref=f14e186]
- listitem [ref=f14e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f14e188]
- button "삭제" [ref=f14e189]
- listitem [ref=f14e190]:
- button "nplus1-query-fanout-644febe6" [ref=f14e191]
- button "삭제" [ref=f14e192]
- listitem [ref=f14e193]:
- button "ap4-edge-trust-1cff2399" [ref=f14e194]
- button "삭제" [ref=f14e195]
- listitem [ref=f14e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f14e197]
- button "삭제" [ref=f14e198]
- listitem [ref=f14e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f14e200]
- button "삭제" [ref=f14e201]
- listitem [ref=f14e202]:
- button "ap2-split-custody-779cb791" [ref=f14e203]
- button "삭제" [ref=f14e204]
- listitem [ref=f14e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f14e206]
- button "삭제" [ref=f14e207]
- listitem [ref=f14e208]:
- button "ap1-custody-v2-e110bd98" [ref=f14e209]
- button "삭제" [ref=f14e210]
- listitem [ref=f14e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f14e212]
- button "삭제" [ref=f14e213]
- listitem [ref=f14e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f14e215]
- button "삭제" [ref=f14e216]
- region [ref=f14e217]:
- generic [ref=f14e218]:
- paragraph [ref=f14e219]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f14e220]
- generic [ref=f14e223]:
- generic [ref=f14e224]:
- navigation "문서 경로" [ref=f14e225]:
- link "검증 기록" [ref=f14e226] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f14e227]: /
- generic [ref=f14e228]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f14e229]: /
- link "Liner N + 1문제" [ref=f14e230] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Projection 이후에도 1,509행을 읽은 Row Over-fetch" [level=1] [ref=f14e231]
- paragraph [ref=f14e232]: DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로 줄고 쿼리도 2개로 고정됐다. 그런데 자식 IN 쿼리는 페이지 부모 20개의 하이라이트를 전부 가져와 1,509행이었다. 화면에 필요한 것은 부모당 최신 3개, 최대 60행이었다.
- region "문제와 결론" [ref=f14e233]:
- generic [ref=f14e234]:
- paragraph [ref=f14e235]: 문제
- paragraph [ref=f14e236]: Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
- paragraph [ref=f14e237]: 화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
- generic [ref=f14e238]:
- paragraph [ref=f14e239]: 결론
- paragraph [ref=f14e240]: 프로젝션은 하이드레이트한 엔티티를 0개로 만들었다. SELECT new 캐리어는 영속 엔티티 대신 스칼라 값으로 record를 만들므로 1차 캐시·더티체킹·지연 프록시도 생기지 않는다. join도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다.
- paragraph [ref=f14e241]: 발행 쿼리는 N과 관계없이 2개로 고정됐다. 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 페이지 부모가 최대 20개라 자식 IN도 한 번만 실행된다.
- paragraph [ref=f14e242]: 남은 문제는 행 수였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지에서 자식 행은 1,509개였고 화면에 필요한 것은 60개였다.
- paragraph [ref=f14e243]: 필요한 컬럼만 선택하면 EXPLAIN의 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
- generic [ref=f14e244]:
- generic [ref=f14e245]:
- term [ref=f14e246]: 검증 환경
- definition [ref=f14e247]:
- paragraph [ref=f14e248]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f14e249]: 격리프로젝션 측정은 배치 설정이 없는 별도 IT 클래스loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드
- paragraph [ref=f14e250]: "측정 지표entitiesLoaded : Statistics.getEntityLoadCount()prepared : Statistics.getPrepareStatementCount()collectionFetch : Statistics.getCollectionFetchCount()"
- generic [ref=f14e251]:
- term [ref=f14e252]: 검증 데이터
- definition [ref=f14e253]:
- paragraph [ref=f14e254]: 1. 부모 스칼라 프로젝션과 자식 IN 스칼라 프로젝션 두 쿼리로 loadFeedProjection을 구현한다.
- paragraph [ref=f14e255]: 2. seed 1,000에서 loadFeedProjection(0, 20)을 실행하고 getEntityLoadCount()를 읽는다.
- paragraph [ref=f14e256]: "3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다."
- paragraph [ref=f14e257]: 4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다.
- paragraph [ref=f14e258]: 5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다.
- paragraph [ref=f14e259]: 6. 부모 프로젝션과 엔티티 페이징의 EXPLAIN width를 비교한다.
- generic [ref=f14e260]:
- term [ref=f14e261]: 기록
- definition [ref=f14e262]: 게시 게시 전 · 마지막 검증
- group [ref=f14e264]:
- generic "목차 · 두 개의 스칼라 프로젝션" [ref=f14e265] [cursor=pointer]
- article [ref=f14e267]:
- region [ref=f14e268]:
- heading [level=2] [ref=f14e269]:
- link "두 개의 스칼라 프로젝션 바로가기" [ref=f14e270] [cursor=pointer]:
- /url: "#두-개의-스칼라-프로젝션"
- text: 두 개의 스칼라 프로젝션
- generic [aria-hidden] [ref=f14e271]: "#"
- figure "JAVA ·loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로 코드 복사" [ref=f14e272]:
- generic [ref=f14e273]:
- generic [ref=f14e274]: JAVA
- generic [ref=f14e275]: ·loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로
- button "코드 복사" [ref=f14e276] [cursor=pointer]: 복사
- region "loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로 코드" [ref=f14e277]:
- code [ref=f14e278]: // (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에 select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) from FeedItemJpaEntity f join f.user u join f.page p order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT // (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑 select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
- paragraph [ref=f14e280]: FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
- region [ref=f14e281]:
- heading [level=2] [ref=f14e282]:
- link "엔티티 로드가 0으로 줄어든다 바로가기" [ref=f14e283] [cursor=pointer]:
- /url: "#엔티티-로드가-0으로-줄어든다"
- text: 엔티티 로드가 0으로 줄어든다
- generic [aria-hidden] [ref=f14e284]: "#"
- region "표" [ref=f14e285]:
- table [ref=f14e286]:
- caption [ref=f14e287]
- rowgroup [ref=f14e288]:
- row [ref=f14e289]:
- columnheader "지표" [ref=f14e290]
- columnheader "배치" [ref=f14e291]
- columnheader "프로젝션" [ref=f14e292]
- rowgroup [ref=f14e293]:
- row [ref=f14e294]:
- cell "entitiesLoaded (seed 1,000)" [ref=f14e295]
- cell "1,569" [ref=f14e296]
- cell "0" [ref=f14e297]
- row [ref=f14e298]:
- cell "prepared (N=1,000)" [ref=f14e299]
- cell "23" [ref=f14e300]
- cell "2" [ref=f14e301]
- row [ref=f14e302]:
- cell "collectionFetch (N=1,000)" [ref=f14e303]
- cell "10" [ref=f14e304]
- cell "0" [ref=f14e305]
- region [ref=f14e306]:
- heading [level=2] [ref=f14e307]:
- link "N이 늘어도 쿼리는 2개다 바로가기" [ref=f14e308] [cursor=pointer]:
- /url: "#n이-늘어도-쿼리는-2개다"
- text: N이 늘어도 쿼리는 2개다
- generic [aria-hidden] [ref=f14e309]: "#"
- region "표" [ref=f14e310]:
- table [ref=f14e311]:
- caption [ref=f14e312]
- rowgroup [ref=f14e313]:
- row [ref=f14e314]:
- columnheader "N" [ref=f14e315]
- columnheader "순진(1+N)" [ref=f14e316]
- columnheader "배치(1+ceil(N/batch)·연관)" [ref=f14e317]
- columnheader "프로젝션(상수)" [ref=f14e318]
- rowgroup [ref=f14e319]:
- row [ref=f14e320]:
- cell "10" [ref=f14e321]
- cell "25" [ref=f14e322]
- cell "5" [ref=f14e323]
- cell "2" [ref=f14e324]
- row [ref=f14e325]:
- cell "100" [ref=f14e326]
- cell "222" [ref=f14e327]
- cell "5" [ref=f14e328]
- cell "2" [ref=f14e329]
- row [ref=f14e330]:
- cell "1,000" [ref=f14e331]
- cell "2,022" [ref=f14e332]
- cell "23" [ref=f14e333]
- cell "2" [ref=f14e334]
- paragraph [ref=f14e335]: 기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
- region [ref=f14e336]:
- heading [level=2] [ref=f14e337]:
- link "남은 비용 — 페이지당 전량 바로가기" [ref=f14e338] [cursor=pointer]:
- /url: "#남은-비용-페이지당-전량"
- text: 남은 비용 — 페이지당 전량
- generic [aria-hidden] [ref=f14e339]: "#"
- figure [ref=f14e340]:
- button "projection-row-over-fetch-f2b1943b 이미지 크게 보기" [ref=f14e341]:
- img "페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다." [ref=f14e342]
- generic [ref=f14e343]: 크게 보기
- generic [ref=f14e344]: 페이지 부모에서 자식 IN 조회를 거쳐 자식 행 전량이 나오고, 화면이 그중 부모별 최신 몇 개만 쓰는 흐름. IN 조회 상자에는 엔티티 적재가 없다는 표시가 붙어 있다.
- region "표" [ref=f14e345]:
- table [ref=f14e346]:
- caption [ref=f14e347]
- rowgroup [ref=f14e348]:
- row [ref=f14e349]:
- columnheader "항목" [ref=f14e350]
- columnheader "값" [ref=f14e351]
- rowgroup [ref=f14e352]:
- row [ref=f14e353]:
- cell "페이지 부모" [ref=f14e354]
- cell "20" [ref=f14e355]
- row [ref=f14e356]:
- cell "자식 IN이 반환한 행" [ref=f14e357]
- cell "1,509" [ref=f14e358]
- row [ref=f14e359]:
- cell "화면에 필요한 행" [ref=f14e360]
- cell "60 (부모당 3)" [ref=f14e361]
- paragraph [ref=f14e362]: 단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다.
- region [ref=f14e363]:
- heading [level=2] [ref=f14e364]:
- link "width는 좁아지지 않았다 바로가기" [ref=f14e365] [cursor=pointer]:
- /url: "#width는-좁아지지-않았다"
- text: width는 좁아지지 않았다
- generic [aria-hidden] [ref=f14e366]: "#"
- figure "TEXT ·seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN 코드 복사" [ref=f14e367]:
- generic [ref=f14e368]:
- generic [ref=f14e369]: TEXT
- generic [ref=f14e370]: ·seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN
- button "코드 복사" [ref=f14e371] [cursor=pointer]: 복사
- region "seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN 코드" [ref=f14e372]:
- code [ref=f14e373]: "-- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) -> Sort Sort Method: top-N heapsort Memory: 27kB -> Hash Join (fi.page_id = p.id) ← pages 조인 -> Hash Join (fi.user_id = u.id) ← users 조인 -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 -- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) Hash Semi Join (... rows=1509 loops=1)"
- paragraph [ref=f14e375]: 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다.
- region [ref=f14e376]:
- heading [level=2] [ref=f14e377]:
- link "배치와 프로젝션은 다른 것을 줄인다 바로가기" [ref=f14e378] [cursor=pointer]:
- /url: "#배치와-프로젝션은-다른-것을-줄인다"
- text: 배치와 프로젝션은 다른 것을 줄인다
- generic [aria-hidden] [ref=f14e379]: "#"
- paragraph [ref=f14e380]: 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
- paragraph [ref=f14e381]: 기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다.
- complementary [ref=f14e382]:
- heading "작업 상태" [level=2] [ref=f14e383]
- status "편집 상태" [ref=f14e384]: 저장됨
- generic [ref=f14e385]:
- generic [ref=f14e386]:
- term [ref=f14e387]: 저장 버전
- definition [ref=f14e388]: "4"
- generic [ref=f14e389]:
- term [ref=f14e390]: 종류
- definition [ref=f14e391]: 검증 기록
- paragraph [ref=f14e392]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f14e393]:
- button "저장" [disabled] [ref=f14e394]
- button "게시" [ref=f14e395]
- paragraph [ref=f14e396]: 버전 4으로 저장했습니다.
@@ -0,0 +1,617 @@
- generic [ref=f15e3]:
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f15e5]:
- generic [ref=f15e6]:
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f15e8]: Studio
- navigation "Studio 주 탐색" [ref=f15e10]:
- link "작업본" [ref=f15e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f15e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f15e17]
- main [ref=f15e18]:
- generic [ref=f15e19]:
- generic [ref=f15e20]:
- region [ref=f15e21]:
- generic [ref=f15e22]:
- paragraph [ref=f15e23]: CASE · VERSION 45
- heading "문서 편집" [level=1] [ref=f15e24]
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
- region [ref=f15e26]:
- generic [ref=f15e27]:
- paragraph [ref=f15e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f15e29]
- generic [ref=f15e30]:
- generic [ref=f15e31]:
- generic [ref=f15e32]: 제목
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
- generic [ref=f15e34]:
- generic [ref=f15e35]: slug
- textbox "slug" [ref=f15e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-fetch-join-in-memory-paging
- generic [ref=f15e37]:
- generic [ref=f15e38]: 요약
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
- generic [ref=f15e41]:
- generic [ref=f15e42]: Topic
- combobox "Topic" [ref=f15e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f15e44]:
- generic [ref=f15e45]: Project
- combobox "Project" [ref=f15e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f15e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
- generic [ref=f15e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f15e51]
- generic [ref=f15e52]: 파생 쿼리 그대로
- generic [ref=f15e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f15e54]
- generic [ref=f15e55]: 컬렉션 fetch join
- generic [ref=f15e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
- generic [ref=f15e58]: fetch join + 페이징
- group "관계" [ref=f15e59]:
- generic [ref=f15e61]:
- generic [ref=f15e62]:
- generic [ref=f15e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f15e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e65]:
- generic [ref=f15e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e69]:
- button "위로" [disabled] [ref=f15e70]
- button "아래로" [ref=f15e71]
- button "삭제" [ref=f15e72]
- generic [ref=f15e73]:
- generic [ref=f15e74]:
- generic [ref=f15e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f15e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e77]:
- generic [ref=f15e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e81]:
- button "위로" [ref=f15e82]
- button "아래로" [ref=f15e83]
- button "삭제" [ref=f15e84]
- generic [ref=f15e85]:
- generic [ref=f15e86]:
- generic [ref=f15e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f15e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e89]:
- generic [ref=f15e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e93]:
- button "위로" [ref=f15e94]
- button "아래로" [disabled] [ref=f15e95]
- button "삭제" [ref=f15e96]
- button "관계 추가" [ref=f15e97]
- region [ref=f15e98]:
- generic [ref=f15e99]:
- paragraph [ref=f15e100]: CASE
- heading "문제와 검증" [level=2] [ref=f15e101]
- generic [ref=f15e102]:
- generic [ref=f15e103]:
- generic [ref=f15e104]: 문제
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
- generic [ref=f15e106]:
- generic [ref=f15e107]: 결론
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
- generic [ref=f15e109]:
- generic [ref=f15e110]: 검증 환경
- textbox "검증 환경" [ref=f15e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 쿼리 : JPQL hibernate.query.fail_on_pagination_over _collection_fetch : false (기본값)"
- generic [ref=f15e112]:
- generic [ref=f15e113]: 재현 조건
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
- generic [ref=f15e115]:
- generic [ref=f15e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
- generic [ref=f15e118]:
- generic [ref=f15e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f15e120]:
- button "코드" [ref=f15e121] [cursor=pointer]
- button "표" [ref=f15e122] [cursor=pointer]
- button "목록" [ref=f15e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f15e124]: "## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징 ``` 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다. ## 반환은 한 페이지인데 메모리에 전부 로드 | N | returned(페이지) | feedItemLoaded | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| | 10 | 6.184 ms | 6.566 ms | 약 1.5 MB | | 100 | 13.890 ms | 16.062 ms | 약 3.0 MB | | 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB | returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
- group [ref=f15e125]:
- paragraph [ref=f15e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f15e129]:
- generic [ref=f15e130]:
- generic [ref=f15e131]: 업로드 종류
- combobox "업로드 종류" [ref=f15e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f15e133]
- generic [ref=f15e134]:
- search [ref=f15e135]:
- generic [ref=f15e136]: Asset 검색
- generic [ref=f15e137]:
- searchbox "Asset 검색" [ref=f15e138]
- button "검색" [ref=f15e139]
- generic [ref=f15e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
- list [ref=f15e144]:
- listitem [ref=f15e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
- button "삭제" [ref=f15e147]
- listitem [ref=f15e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
- button "삭제" [ref=f15e150]
- listitem [ref=f15e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
- button "삭제" [ref=f15e153]
- listitem [ref=f15e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
- button "삭제" [ref=f15e156]
- listitem [ref=f15e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
- button "삭제" [ref=f15e159]
- listitem [ref=f15e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
- button "삭제" [ref=f15e162]
- listitem [ref=f15e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
- button "삭제" [ref=f15e165]
- listitem [ref=f15e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
- button "삭제" [ref=f15e168]
- listitem [ref=f15e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
- button "삭제" [ref=f15e171]
- listitem [ref=f15e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
- button "삭제" [ref=f15e174]
- listitem [ref=f15e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
- button "삭제" [ref=f15e177]
- listitem [ref=f15e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
- button "삭제" [ref=f15e180]
- listitem [ref=f15e181]:
- button "login-api-phase-split-3e354274" [ref=f15e182]
- button "삭제" [ref=f15e183]
- listitem [ref=f15e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
- button "삭제" [ref=f15e186]
- listitem [ref=f15e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
- button "삭제" [ref=f15e189]
- listitem [ref=f15e190]:
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
- button "삭제" [ref=f15e192]
- listitem [ref=f15e193]:
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
- button "삭제" [ref=f15e195]
- listitem [ref=f15e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
- button "삭제" [ref=f15e198]
- listitem [ref=f15e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
- button "삭제" [ref=f15e201]
- listitem [ref=f15e202]:
- button "ap2-split-custody-779cb791" [ref=f15e203]
- button "삭제" [ref=f15e204]
- listitem [ref=f15e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
- button "삭제" [ref=f15e207]
- listitem [ref=f15e208]:
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
- button "삭제" [ref=f15e210]
- listitem [ref=f15e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
- button "삭제" [ref=f15e213]
- listitem [ref=f15e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
- button "삭제" [ref=f15e216]
- dialog [ref=f15e217]:
- generic [ref=f15e218]:
- paragraph [ref=f15e219]: ASSET UPLOAD
- heading "Asset 업로드" [level=2] [ref=f15e220]
- paragraph [ref=f15e221]: 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 아니면 대체 텍스트가 필요합니다.
- generic [ref=f15e222]:
- generic [ref=f15e223]: Asset 파일
- button "Asset 파일" [active] [ref=f15e224]
- generic [ref=f15e225]:
- checkbox "장식용 이미지 (대체 텍스트 없음)" [ref=f15e226]
- generic [ref=f15e227]: 장식용 이미지 (대체 텍스트 없음)
- generic [ref=f15e228]:
- generic [ref=f15e229]: 대체 텍스트
- textbox "대체 텍스트" [ref=f15e230]
- status "업로드 상태" [ref=f15e231]
- generic [ref=f15e232]:
- button "닫기" [ref=f15e233]
- button "업로드" [ref=f15e234]
- region [ref=f15e235]:
- generic [ref=f15e236]:
- paragraph [ref=f15e237]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f15e238]
- generic [ref=f15e241]:
- generic [ref=f15e242]:
- navigation "문서 경로" [ref=f15e243]:
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f15e245]: /
- generic [ref=f15e246]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f15e247]: /
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
- paragraph [ref=f15e250]:
- text: 컬렉션 하나만
- code [ref=f15e251]: fetch join
- text: 하고
- code [ref=f15e252]: setMaxResults(20)
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
- paragraph [ref=f15e253]:
- text: 하지만 컬렉션
- code [ref=f15e254]: fetch join
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
- code [ref=f15e255]: LIMIT 20
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
- paragraph [ref=f15e256]:
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
- code [ref=f15e257]: setMaxResults(20)
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
- region "문제와 결론" [ref=f15e258]:
- generic [ref=f15e259]:
- paragraph [ref=f15e260]: 문제
- paragraph [ref=f15e261]:
- text: 컬렉션을
- code [ref=f15e262]: fetch join
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
- paragraph [ref=f15e263]:
- text: 실제로
- code [ref=f15e264]: setMaxResults(20)
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
- generic [ref=f15e266]:
- paragraph [ref=f15e267]: 결론
- paragraph [ref=f15e268]:
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
- code [ref=f15e269]: feedItemLoaded
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
- paragraph [ref=f15e270]:
- text: 컬렉션
- code [ref=f15e271]: fetch join
- text: 에서는 조인 결과에
- code [ref=f15e272]: LIMIT
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
- code [ref=f15e273]: LIMIT
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
- code [ref=f15e274]: Limit
- text: 노드가 없었다.
- generic [ref=f15e275]:
- generic [ref=f15e276]:
- term [ref=f15e277]: 검증 환경
- definition [ref=f15e278]:
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
- generic [ref=f15e281]:
- term [ref=f15e282]: 검증 데이터
- definition [ref=f15e283]:
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
- generic [ref=f15e289]:
- term [ref=f15e290]: 기록
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f15e293]:
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
- article [ref=f15e296]:
- region [ref=f15e297]:
- heading [level=2] [ref=f15e298]:
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
- /url: "#컬렉션-fetch-join에-페이징"
- text: 컬렉션 Fetch join에 페이징
- generic [aria-hidden] [ref=f15e300]: "#"
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
- generic [ref=f15e302]:
- generic [ref=f15e303]: JAVA
- generic [ref=f15e304]: ·통합 테스트
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
- region "통합 테스트 코드" [ref=f15e306]:
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
- region [ref=f15e310]:
- heading [level=2] [ref=f15e311]:
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
- text: 반환은 한 페이지인데 메모리에 전부 로드
- generic [aria-hidden] [ref=f15e313]: "#"
- region "표" [ref=f15e314]:
- table [ref=f15e315]:
- caption [ref=f15e316]
- rowgroup [ref=f15e317]:
- row [ref=f15e318]:
- columnheader "N" [ref=f15e319]
- columnheader "returned(페이지)" [ref=f15e320]
- columnheader "feedItemLoaded" [ref=f15e321]
- columnheader "시드 하이라이트" [ref=f15e322]
- rowgroup [ref=f15e323]:
- row [ref=f15e324]:
- cell "10" [ref=f15e325]
- cell "10" [ref=f15e326]
- cell "10" [ref=f15e327]
- cell "1,285" [ref=f15e328]
- row [ref=f15e329]:
- cell "100" [ref=f15e330]
- cell "20" [ref=f15e331]
- cell "100" [ref=f15e332]
- cell "1,961" [ref=f15e333]
- row [ref=f15e334]:
- cell "1,000" [ref=f15e335]
- cell "20" [ref=f15e336]
- cell "1,000" [ref=f15e337]
- cell "2,917" [ref=f15e338]
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
- region [ref=f15e341]:
- heading [level=2] [ref=f15e342]:
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
- text: Fetch join에 페이징을 적용 했을 때의 경고
- generic [aria-hidden] [ref=f15e344]: "#"
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
- generic [ref=f15e346]:
- generic [ref=f15e347]: TEXT
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
- region [ref=f15e354]:
- heading [level=2] [ref=f15e355]:
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
- /url: "#페이지가-아닌-데이터셋에-비례한다"
- text: 페이지가 아닌 데이터셋에 비례한다
- generic [aria-hidden] [ref=f15e357]: "#"
- region "표" [ref=f15e358]:
- table [ref=f15e359]:
- caption [ref=f15e360]
- rowgroup [ref=f15e361]:
- row [ref=f15e362]:
- columnheader "N" [ref=f15e363]
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
- columnheader "스레드 누적 할당" [ref=f15e366]
- rowgroup [ref=f15e367]:
- row [ref=f15e368]:
- cell "10" [ref=f15e369]
- cell "6.184 ms" [ref=f15e370]
- cell "6.566 ms" [ref=f15e371]
- cell "약 1.5 MB" [ref=f15e372]
- row [ref=f15e373]:
- cell "100" [ref=f15e374]
- cell "13.890 ms" [ref=f15e375]
- cell "16.062 ms" [ref=f15e376]
- cell "약 3.0 MB" [ref=f15e377]
- row [ref=f15e378]:
- cell "1,000" [ref=f15e379]
- cell "79.452 ms" [ref=f15e380]
- cell "83.526 ms" [ref=f15e381]
- cell "약 10.0 MB" [ref=f15e382]
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
- region [ref=f15e386]:
- heading [level=2] [ref=f15e387]:
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
- /url: "#sql에-limit-노드가-없다"
- text: SQL에 LIMIT 노드가 없다
- generic [aria-hidden] [ref=f15e389]: "#"
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
- generic [ref=f15e391]:
- generic [ref=f15e392]: TEXT
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
- code [ref=f15e396]: "-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1)"
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
- region [ref=f15e400]:
- heading [level=2] [ref=f15e401]:
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
- /url: "#다음-선택"
- text: 다음 선택
- generic [aria-hidden] [ref=f15e403]: "#"
- paragraph [ref=f15e404]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
- region [ref=f15e405]:
- paragraph [ref=f15e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
- list [ref=f15e408]:
- listitem [ref=f15e409]:
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
- /url: /cases/fetch-join-multibag-and-row-explosion
- generic [ref=f15e411]: 검증 기록
- generic [ref=f15e412]:
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e415]:
- complementary [ref=f15e416]:
- heading "작업 상태" [level=2] [ref=f15e417]
- status "편집 상태" [ref=f15e418]: 저장됨
- generic [ref=f15e419]:
- generic [ref=f15e420]:
- term [ref=f15e421]: 저장 버전
- definition [ref=f15e422]: "45"
- generic [ref=f15e423]:
- term [ref=f15e424]: 종류
- definition [ref=f15e425]: 검증 기록
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f15e427]:
- button "저장" [disabled] [ref=f15e428]
- button "게시" [ref=f15e429]
- paragraph [ref=f15e430]
@@ -0,0 +1,617 @@
- generic [ref=f15e3]:
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f15e5]:
- generic [ref=f15e6]:
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f15e8]: Studio
- navigation "Studio 주 탐색" [ref=f15e10]:
- link "작업본" [ref=f15e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f15e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f15e17]
- main [ref=f15e18]:
- generic [ref=f15e19]:
- generic [ref=f15e20]:
- region [ref=f15e21]:
- generic [ref=f15e22]:
- paragraph [ref=f15e23]: CASE · VERSION 45
- heading "문서 편집" [level=1] [ref=f15e24]
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
- region [ref=f15e26]:
- generic [ref=f15e27]:
- paragraph [ref=f15e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f15e29]
- generic [ref=f15e30]:
- generic [ref=f15e31]:
- generic [ref=f15e32]: 제목
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
- generic [ref=f15e34]:
- generic [ref=f15e35]: slug
- textbox "slug" [ref=f15e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-fetch-join-in-memory-paging
- generic [ref=f15e37]:
- generic [ref=f15e38]: 요약
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
- generic [ref=f15e41]:
- generic [ref=f15e42]: Topic
- combobox "Topic" [ref=f15e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f15e44]:
- generic [ref=f15e45]: Project
- combobox "Project" [ref=f15e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f15e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
- generic [ref=f15e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f15e51]
- generic [ref=f15e52]: 파생 쿼리 그대로
- generic [ref=f15e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f15e54]
- generic [ref=f15e55]: 컬렉션 fetch join
- generic [ref=f15e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
- generic [ref=f15e58]: fetch join + 페이징
- group "관계" [ref=f15e59]:
- generic [ref=f15e61]:
- generic [ref=f15e62]:
- generic [ref=f15e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f15e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e65]:
- generic [ref=f15e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e69]:
- button "위로" [disabled] [ref=f15e70]
- button "아래로" [ref=f15e71]
- button "삭제" [ref=f15e72]
- generic [ref=f15e73]:
- generic [ref=f15e74]:
- generic [ref=f15e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f15e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e77]:
- generic [ref=f15e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e81]:
- button "위로" [ref=f15e82]
- button "아래로" [ref=f15e83]
- button "삭제" [ref=f15e84]
- generic [ref=f15e85]:
- generic [ref=f15e86]:
- generic [ref=f15e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f15e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e89]:
- generic [ref=f15e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e93]:
- button "위로" [ref=f15e94]
- button "아래로" [disabled] [ref=f15e95]
- button "삭제" [ref=f15e96]
- button "관계 추가" [ref=f15e97]
- region [ref=f15e98]:
- generic [ref=f15e99]:
- paragraph [ref=f15e100]: CASE
- heading "문제와 검증" [level=2] [ref=f15e101]
- generic [ref=f15e102]:
- generic [ref=f15e103]:
- generic [ref=f15e104]: 문제
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
- generic [ref=f15e106]:
- generic [ref=f15e107]: 결론
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
- generic [ref=f15e109]:
- generic [ref=f15e110]: 검증 환경
- textbox "검증 환경" [ref=f15e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 쿼리 : JPQL hibernate.query.fail_on_pagination_over _collection_fetch : false (기본값)"
- generic [ref=f15e112]:
- generic [ref=f15e113]: 재현 조건
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
- generic [ref=f15e115]:
- generic [ref=f15e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
- generic [ref=f15e118]:
- generic [ref=f15e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f15e120]:
- button "코드" [ref=f15e121] [cursor=pointer]
- button "표" [ref=f15e122] [cursor=pointer]
- button "목록" [ref=f15e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f15e124]: "## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징 ``` 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다. ## 반환은 한 페이지인데 메모리에 전부 로드 | N | returned(페이지) | feedItemLoaded | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| | 10 | 6.184 ms | 6.566 ms | 약 1.5 MB | | 100 | 13.890 ms | 16.062 ms | 약 3.0 MB | | 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB | returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
- group [ref=f15e125]:
- paragraph [ref=f15e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f15e129]:
- generic [ref=f15e130]:
- generic [ref=f15e131]: 업로드 종류
- combobox "업로드 종류" [ref=f15e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f15e133]
- generic [ref=f15e134]:
- search [ref=f15e135]:
- generic [ref=f15e136]: Asset 검색
- generic [ref=f15e137]:
- searchbox "Asset 검색" [ref=f15e138]
- button "검색" [ref=f15e139]
- generic [ref=f15e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
- list [ref=f15e144]:
- listitem [ref=f15e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
- button "삭제" [ref=f15e147]
- listitem [ref=f15e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
- button "삭제" [ref=f15e150]
- listitem [ref=f15e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
- button "삭제" [ref=f15e153]
- listitem [ref=f15e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
- button "삭제" [ref=f15e156]
- listitem [ref=f15e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
- button "삭제" [ref=f15e159]
- listitem [ref=f15e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
- button "삭제" [ref=f15e162]
- listitem [ref=f15e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
- button "삭제" [ref=f15e165]
- listitem [ref=f15e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
- button "삭제" [ref=f15e168]
- listitem [ref=f15e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
- button "삭제" [ref=f15e171]
- listitem [ref=f15e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
- button "삭제" [ref=f15e174]
- listitem [ref=f15e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
- button "삭제" [ref=f15e177]
- listitem [ref=f15e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
- button "삭제" [ref=f15e180]
- listitem [ref=f15e181]:
- button "login-api-phase-split-3e354274" [ref=f15e182]
- button "삭제" [ref=f15e183]
- listitem [ref=f15e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
- button "삭제" [ref=f15e186]
- listitem [ref=f15e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
- button "삭제" [ref=f15e189]
- listitem [ref=f15e190]:
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
- button "삭제" [ref=f15e192]
- listitem [ref=f15e193]:
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
- button "삭제" [ref=f15e195]
- listitem [ref=f15e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
- button "삭제" [ref=f15e198]
- listitem [ref=f15e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
- button "삭제" [ref=f15e201]
- listitem [ref=f15e202]:
- button "ap2-split-custody-779cb791" [ref=f15e203]
- button "삭제" [ref=f15e204]
- listitem [ref=f15e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
- button "삭제" [ref=f15e207]
- listitem [ref=f15e208]:
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
- button "삭제" [ref=f15e210]
- listitem [ref=f15e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
- button "삭제" [ref=f15e213]
- listitem [ref=f15e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
- button "삭제" [ref=f15e216]
- dialog [ref=f15e217]:
- generic [ref=f15e218]:
- paragraph [ref=f15e219]: ASSET UPLOAD
- heading "Asset 업로드" [level=2] [ref=f15e220]
- paragraph [ref=f15e221]: 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 아니면 대체 텍스트가 필요합니다.
- generic [ref=f15e222]:
- generic [ref=f15e223]: Asset 파일
- button "Asset 파일" [ref=f15e224]
- generic [ref=f15e225]:
- checkbox "장식용 이미지 (대체 텍스트 없음)" [ref=f15e226]
- generic [ref=f15e227]: 장식용 이미지 (대체 텍스트 없음)
- generic [ref=f15e228]:
- generic [ref=f15e229]: 대체 텍스트
- textbox "대체 텍스트" [active] [ref=f15e230]
- status "업로드 상태" [ref=f15e231]
- generic [ref=f15e232]:
- button "닫기" [ref=f15e233]
- button "업로드" [ref=f15e234]
- region [ref=f15e235]:
- generic [ref=f15e236]:
- paragraph [ref=f15e237]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f15e238]
- generic [ref=f15e241]:
- generic [ref=f15e242]:
- navigation "문서 경로" [ref=f15e243]:
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f15e245]: /
- generic [ref=f15e246]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f15e247]: /
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
- paragraph [ref=f15e250]:
- text: 컬렉션 하나만
- code [ref=f15e251]: fetch join
- text: 하고
- code [ref=f15e252]: setMaxResults(20)
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
- paragraph [ref=f15e253]:
- text: 하지만 컬렉션
- code [ref=f15e254]: fetch join
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
- code [ref=f15e255]: LIMIT 20
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
- paragraph [ref=f15e256]:
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
- code [ref=f15e257]: setMaxResults(20)
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
- region "문제와 결론" [ref=f15e258]:
- generic [ref=f15e259]:
- paragraph [ref=f15e260]: 문제
- paragraph [ref=f15e261]:
- text: 컬렉션을
- code [ref=f15e262]: fetch join
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
- paragraph [ref=f15e263]:
- text: 실제로
- code [ref=f15e264]: setMaxResults(20)
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
- generic [ref=f15e266]:
- paragraph [ref=f15e267]: 결론
- paragraph [ref=f15e268]:
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
- code [ref=f15e269]: feedItemLoaded
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
- paragraph [ref=f15e270]:
- text: 컬렉션
- code [ref=f15e271]: fetch join
- text: 에서는 조인 결과에
- code [ref=f15e272]: LIMIT
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
- code [ref=f15e273]: LIMIT
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
- code [ref=f15e274]: Limit
- text: 노드가 없었다.
- generic [ref=f15e275]:
- generic [ref=f15e276]:
- term [ref=f15e277]: 검증 환경
- definition [ref=f15e278]:
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
- generic [ref=f15e281]:
- term [ref=f15e282]: 검증 데이터
- definition [ref=f15e283]:
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
- generic [ref=f15e289]:
- term [ref=f15e290]: 기록
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f15e293]:
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
- article [ref=f15e296]:
- region [ref=f15e297]:
- heading [level=2] [ref=f15e298]:
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
- /url: "#컬렉션-fetch-join에-페이징"
- text: 컬렉션 Fetch join에 페이징
- generic [aria-hidden] [ref=f15e300]: "#"
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
- generic [ref=f15e302]:
- generic [ref=f15e303]: JAVA
- generic [ref=f15e304]: ·통합 테스트
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
- region "통합 테스트 코드" [ref=f15e306]:
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
- region [ref=f15e310]:
- heading [level=2] [ref=f15e311]:
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
- text: 반환은 한 페이지인데 메모리에 전부 로드
- generic [aria-hidden] [ref=f15e313]: "#"
- region "표" [ref=f15e314]:
- table [ref=f15e315]:
- caption [ref=f15e316]
- rowgroup [ref=f15e317]:
- row [ref=f15e318]:
- columnheader "N" [ref=f15e319]
- columnheader "returned(페이지)" [ref=f15e320]
- columnheader "feedItemLoaded" [ref=f15e321]
- columnheader "시드 하이라이트" [ref=f15e322]
- rowgroup [ref=f15e323]:
- row [ref=f15e324]:
- cell "10" [ref=f15e325]
- cell "10" [ref=f15e326]
- cell "10" [ref=f15e327]
- cell "1,285" [ref=f15e328]
- row [ref=f15e329]:
- cell "100" [ref=f15e330]
- cell "20" [ref=f15e331]
- cell "100" [ref=f15e332]
- cell "1,961" [ref=f15e333]
- row [ref=f15e334]:
- cell "1,000" [ref=f15e335]
- cell "20" [ref=f15e336]
- cell "1,000" [ref=f15e337]
- cell "2,917" [ref=f15e338]
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
- region [ref=f15e341]:
- heading [level=2] [ref=f15e342]:
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
- text: Fetch join에 페이징을 적용 했을 때의 경고
- generic [aria-hidden] [ref=f15e344]: "#"
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
- generic [ref=f15e346]:
- generic [ref=f15e347]: TEXT
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
- region [ref=f15e354]:
- heading [level=2] [ref=f15e355]:
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
- /url: "#페이지가-아닌-데이터셋에-비례한다"
- text: 페이지가 아닌 데이터셋에 비례한다
- generic [aria-hidden] [ref=f15e357]: "#"
- region "표" [ref=f15e358]:
- table [ref=f15e359]:
- caption [ref=f15e360]
- rowgroup [ref=f15e361]:
- row [ref=f15e362]:
- columnheader "N" [ref=f15e363]
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
- columnheader "스레드 누적 할당" [ref=f15e366]
- rowgroup [ref=f15e367]:
- row [ref=f15e368]:
- cell "10" [ref=f15e369]
- cell "6.184 ms" [ref=f15e370]
- cell "6.566 ms" [ref=f15e371]
- cell "약 1.5 MB" [ref=f15e372]
- row [ref=f15e373]:
- cell "100" [ref=f15e374]
- cell "13.890 ms" [ref=f15e375]
- cell "16.062 ms" [ref=f15e376]
- cell "약 3.0 MB" [ref=f15e377]
- row [ref=f15e378]:
- cell "1,000" [ref=f15e379]
- cell "79.452 ms" [ref=f15e380]
- cell "83.526 ms" [ref=f15e381]
- cell "약 10.0 MB" [ref=f15e382]
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
- region [ref=f15e386]:
- heading [level=2] [ref=f15e387]:
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
- /url: "#sql에-limit-노드가-없다"
- text: SQL에 LIMIT 노드가 없다
- generic [aria-hidden] [ref=f15e389]: "#"
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
- generic [ref=f15e391]:
- generic [ref=f15e392]: TEXT
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
- code [ref=f15e396]: "-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1)"
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
- region [ref=f15e400]:
- heading [level=2] [ref=f15e401]:
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
- /url: "#다음-선택"
- text: 다음 선택
- generic [aria-hidden] [ref=f15e403]: "#"
- paragraph [ref=f15e404]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
- region [ref=f15e405]:
- paragraph [ref=f15e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
- list [ref=f15e408]:
- listitem [ref=f15e409]:
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
- /url: /cases/fetch-join-multibag-and-row-explosion
- generic [ref=f15e411]: 검증 기록
- generic [ref=f15e412]:
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e415]:
- complementary [ref=f15e416]:
- heading "작업 상태" [level=2] [ref=f15e417]
- status "편집 상태" [ref=f15e418]: 저장됨
- generic [ref=f15e419]:
- generic [ref=f15e420]:
- term [ref=f15e421]: 저장 버전
- definition [ref=f15e422]: "45"
- generic [ref=f15e423]:
- term [ref=f15e424]: 종류
- definition [ref=f15e425]: 검증 기록
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f15e427]:
- button "저장" [disabled] [ref=f15e428]
- button "게시" [ref=f15e429]
- paragraph [ref=f15e430]
@@ -0,0 +1,601 @@
- generic [ref=f15e3]:
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f15e5]:
- generic [ref=f15e6]:
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f15e8]: Studio
- navigation "Studio 주 탐색" [ref=f15e10]:
- link "작업본" [ref=f15e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f15e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f15e17]
- main [ref=f15e18]:
- generic [ref=f15e19]:
- generic [ref=f15e20]:
- region [ref=f15e21]:
- generic [ref=f15e22]:
- paragraph [ref=f15e23]: CASE · VERSION 45
- heading "문서 편집" [level=1] [ref=f15e24]
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
- region [ref=f15e26]:
- generic [ref=f15e27]:
- paragraph [ref=f15e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f15e29]
- generic [ref=f15e30]:
- generic [ref=f15e31]:
- generic [ref=f15e32]: 제목
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
- generic [ref=f15e34]:
- generic [ref=f15e35]: slug
- textbox "slug" [ref=f15e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-fetch-join-in-memory-paging
- generic [ref=f15e37]:
- generic [ref=f15e38]: 요약
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
- generic [ref=f15e41]:
- generic [ref=f15e42]: Topic
- combobox "Topic" [ref=f15e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f15e44]:
- generic [ref=f15e45]: Project
- combobox "Project" [ref=f15e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f15e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
- generic [ref=f15e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f15e51]
- generic [ref=f15e52]: 파생 쿼리 그대로
- generic [ref=f15e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f15e54]
- generic [ref=f15e55]: 컬렉션 fetch join
- generic [ref=f15e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
- generic [ref=f15e58]: fetch join + 페이징
- group "관계" [ref=f15e59]:
- generic [ref=f15e61]:
- generic [ref=f15e62]:
- generic [ref=f15e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f15e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e65]:
- generic [ref=f15e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e69]:
- button "위로" [disabled] [ref=f15e70]
- button "아래로" [ref=f15e71]
- button "삭제" [ref=f15e72]
- generic [ref=f15e73]:
- generic [ref=f15e74]:
- generic [ref=f15e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f15e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e77]:
- generic [ref=f15e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e81]:
- button "위로" [ref=f15e82]
- button "아래로" [ref=f15e83]
- button "삭제" [ref=f15e84]
- generic [ref=f15e85]:
- generic [ref=f15e86]:
- generic [ref=f15e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f15e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e89]:
- generic [ref=f15e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e93]:
- button "위로" [ref=f15e94]
- button "아래로" [disabled] [ref=f15e95]
- button "삭제" [ref=f15e96]
- button "관계 추가" [ref=f15e97]
- region [ref=f15e98]:
- generic [ref=f15e99]:
- paragraph [ref=f15e100]: CASE
- heading "문제와 검증" [level=2] [ref=f15e101]
- generic [ref=f15e102]:
- generic [ref=f15e103]:
- generic [ref=f15e104]: 문제
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
- generic [ref=f15e106]:
- generic [ref=f15e107]: 결론
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
- generic [ref=f15e109]:
- generic [ref=f15e110]: 검증 환경
- textbox "검증 환경" [ref=f15e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 쿼리 : JPQL hibernate.query.fail_on_pagination_over _collection_fetch : false (기본값)"
- generic [ref=f15e112]:
- generic [ref=f15e113]: 재현 조건
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
- generic [ref=f15e115]:
- generic [ref=f15e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
- generic [ref=f15e118]:
- generic [ref=f15e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f15e120]:
- button "코드" [ref=f15e121] [cursor=pointer]
- button "표" [ref=f15e122] [cursor=pointer]
- button "목록" [ref=f15e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f15e124]: ":::evidence key=\"batch-fetch-in-clause-cb44066a\" alt=\"조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도.\" caption=\"\" zoom=\"false\" ::: ## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징 ``` 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다. ## 반환은 한 페이지인데 메모리에 전부 로드 | N | returned(페이지) | feedItemLoaded | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| | 10 | 6.184 ms | 6.566 ms | 약 1.5 MB | | 100 | 13.890 ms | 16.062 ms | 약 3.0 MB | | 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB | returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
- group [ref=f15e125]:
- paragraph [ref=f15e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f15e129]:
- generic [ref=f15e130]:
- generic [ref=f15e131]: 업로드 종류
- combobox "업로드 종류" [ref=f15e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f15e133]
- generic [ref=f15e134]:
- search [ref=f15e135]:
- generic [ref=f15e136]: Asset 검색
- generic [ref=f15e137]:
- searchbox "Asset 검색" [ref=f15e138]
- button "검색" [ref=f15e139]
- generic [ref=f15e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
- list [ref=f15e144]:
- listitem [ref=f15e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
- button "삭제" [ref=f15e147]
- listitem [ref=f15e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
- button "삭제" [ref=f15e150]
- listitem [ref=f15e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
- button "삭제" [ref=f15e153]
- listitem [ref=f15e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
- button "삭제" [ref=f15e156]
- listitem [ref=f15e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
- button "삭제" [ref=f15e159]
- listitem [ref=f15e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
- button "삭제" [ref=f15e162]
- listitem [ref=f15e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
- button "삭제" [ref=f15e165]
- listitem [ref=f15e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
- button "삭제" [ref=f15e168]
- listitem [ref=f15e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
- button "삭제" [ref=f15e171]
- listitem [ref=f15e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
- button "삭제" [ref=f15e174]
- listitem [ref=f15e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
- button "삭제" [ref=f15e177]
- listitem [ref=f15e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
- button "삭제" [ref=f15e180]
- listitem [ref=f15e181]:
- button "login-api-phase-split-3e354274" [ref=f15e182]
- button "삭제" [ref=f15e183]
- listitem [ref=f15e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
- button "삭제" [ref=f15e186]
- listitem [ref=f15e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
- button "삭제" [ref=f15e189]
- listitem [ref=f15e190]:
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
- button "삭제" [ref=f15e192]
- listitem [ref=f15e193]:
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
- button "삭제" [ref=f15e195]
- listitem [ref=f15e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
- button "삭제" [ref=f15e198]
- listitem [ref=f15e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
- button "삭제" [ref=f15e201]
- listitem [ref=f15e202]:
- button "ap2-split-custody-779cb791" [ref=f15e203]
- button "삭제" [ref=f15e204]
- listitem [ref=f15e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
- button "삭제" [ref=f15e207]
- listitem [ref=f15e208]:
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
- button "삭제" [ref=f15e210]
- listitem [ref=f15e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
- button "삭제" [ref=f15e213]
- listitem [ref=f15e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
- button "삭제" [ref=f15e216]
- region [ref=f15e235]:
- generic [ref=f15e236]:
- paragraph [ref=f15e237]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f15e238]
- generic [ref=f15e241]:
- generic [ref=f15e242]:
- navigation "문서 경로" [ref=f15e243]:
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f15e245]: /
- generic [ref=f15e246]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f15e247]: /
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
- paragraph [ref=f15e250]:
- text: 컬렉션 하나만
- code [ref=f15e251]: fetch join
- text: 하고
- code [ref=f15e252]: setMaxResults(20)
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
- paragraph [ref=f15e253]:
- text: 하지만 컬렉션
- code [ref=f15e254]: fetch join
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
- code [ref=f15e255]: LIMIT 20
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
- paragraph [ref=f15e256]:
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
- code [ref=f15e257]: setMaxResults(20)
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
- region "문제와 결론" [ref=f15e258]:
- generic [ref=f15e259]:
- paragraph [ref=f15e260]: 문제
- paragraph [ref=f15e261]:
- text: 컬렉션을
- code [ref=f15e262]: fetch join
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
- paragraph [ref=f15e263]:
- text: 실제로
- code [ref=f15e264]: setMaxResults(20)
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
- generic [ref=f15e266]:
- paragraph [ref=f15e267]: 결론
- paragraph [ref=f15e268]:
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
- code [ref=f15e269]: feedItemLoaded
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
- paragraph [ref=f15e270]:
- text: 컬렉션
- code [ref=f15e271]: fetch join
- text: 에서는 조인 결과에
- code [ref=f15e272]: LIMIT
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
- code [ref=f15e273]: LIMIT
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
- code [ref=f15e274]: Limit
- text: 노드가 없었다.
- generic [ref=f15e275]:
- generic [ref=f15e276]:
- term [ref=f15e277]: 검증 환경
- definition [ref=f15e278]:
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
- generic [ref=f15e281]:
- term [ref=f15e282]: 검증 데이터
- definition [ref=f15e283]:
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
- generic [ref=f15e289]:
- term [ref=f15e290]: 기록
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f15e293]:
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
- article [ref=f15e296]:
- figure [ref=f15e431]:
- img "조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도." [ref=f15e432]
- region [ref=f15e297]:
- heading [level=2] [ref=f15e298]:
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
- /url: "#컬렉션-fetch-join에-페이징"
- text: 컬렉션 Fetch join에 페이징
- generic [aria-hidden] [ref=f15e300]: "#"
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
- generic [ref=f15e302]:
- generic [ref=f15e303]: JAVA
- generic [ref=f15e304]: ·통합 테스트
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
- region "통합 테스트 코드" [ref=f15e306]:
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
- region [ref=f15e310]:
- heading [level=2] [ref=f15e311]:
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
- text: 반환은 한 페이지인데 메모리에 전부 로드
- generic [aria-hidden] [ref=f15e313]: "#"
- region "표" [ref=f15e314]:
- table [ref=f15e315]:
- caption [ref=f15e316]
- rowgroup [ref=f15e317]:
- row [ref=f15e318]:
- columnheader "N" [ref=f15e319]
- columnheader "returned(페이지)" [ref=f15e320]
- columnheader "feedItemLoaded" [ref=f15e321]
- columnheader "시드 하이라이트" [ref=f15e322]
- rowgroup [ref=f15e323]:
- row [ref=f15e324]:
- cell "10" [ref=f15e325]
- cell "10" [ref=f15e326]
- cell "10" [ref=f15e327]
- cell "1,285" [ref=f15e328]
- row [ref=f15e329]:
- cell "100" [ref=f15e330]
- cell "20" [ref=f15e331]
- cell "100" [ref=f15e332]
- cell "1,961" [ref=f15e333]
- row [ref=f15e334]:
- cell "1,000" [ref=f15e335]
- cell "20" [ref=f15e336]
- cell "1,000" [ref=f15e337]
- cell "2,917" [ref=f15e338]
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
- region [ref=f15e341]:
- heading [level=2] [ref=f15e342]:
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
- text: Fetch join에 페이징을 적용 했을 때의 경고
- generic [aria-hidden] [ref=f15e344]: "#"
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
- generic [ref=f15e346]:
- generic [ref=f15e347]: TEXT
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
- region [ref=f15e354]:
- heading [level=2] [ref=f15e355]:
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
- /url: "#페이지가-아닌-데이터셋에-비례한다"
- text: 페이지가 아닌 데이터셋에 비례한다
- generic [aria-hidden] [ref=f15e357]: "#"
- region "표" [ref=f15e358]:
- table [ref=f15e359]:
- caption [ref=f15e360]
- rowgroup [ref=f15e361]:
- row [ref=f15e362]:
- columnheader "N" [ref=f15e363]
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
- columnheader "스레드 누적 할당" [ref=f15e366]
- rowgroup [ref=f15e367]:
- row [ref=f15e368]:
- cell "10" [ref=f15e369]
- cell "6.184 ms" [ref=f15e370]
- cell "6.566 ms" [ref=f15e371]
- cell "약 1.5 MB" [ref=f15e372]
- row [ref=f15e373]:
- cell "100" [ref=f15e374]
- cell "13.890 ms" [ref=f15e375]
- cell "16.062 ms" [ref=f15e376]
- cell "약 3.0 MB" [ref=f15e377]
- row [ref=f15e378]:
- cell "1,000" [ref=f15e379]
- cell "79.452 ms" [ref=f15e380]
- cell "83.526 ms" [ref=f15e381]
- cell "약 10.0 MB" [ref=f15e382]
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
- region [ref=f15e386]:
- heading [level=2] [ref=f15e387]:
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
- /url: "#sql에-limit-노드가-없다"
- text: SQL에 LIMIT 노드가 없다
- generic [aria-hidden] [ref=f15e389]: "#"
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
- generic [ref=f15e391]:
- generic [ref=f15e392]: TEXT
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
- code [ref=f15e396]: "-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1)"
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
- region [ref=f15e400]:
- heading [level=2] [ref=f15e401]:
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
- /url: "#다음-선택"
- text: 다음 선택
- generic [aria-hidden] [ref=f15e403]: "#"
- paragraph [ref=f15e404]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
- region [ref=f15e405]:
- paragraph [ref=f15e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
- list [ref=f15e408]:
- listitem [ref=f15e409]:
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
- /url: /cases/fetch-join-multibag-and-row-explosion
- generic [ref=f15e411]: 검증 기록
- generic [ref=f15e412]:
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e415]:
- complementary [ref=f15e416]:
- heading "작업 상태" [level=2] [ref=f15e417]
- status "편집 상태" [ref=f15e418]: 저장되지 않음
- generic [ref=f15e419]:
- generic [ref=f15e420]:
- term [ref=f15e421]: 저장 버전
- definition [ref=f15e422]: "45"
- generic [ref=f15e423]:
- term [ref=f15e424]: 종류
- definition [ref=f15e425]: 검증 기록
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f15e427]:
- button "저장" [ref=f15e428]
- button "게시" [ref=f15e429]
- paragraph [ref=f15e430]
@@ -0,0 +1,604 @@
- generic [ref=f15e3]:
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f15e5]:
- generic [ref=f15e6]:
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f15e8]: Studio
- navigation "Studio 주 탐색" [ref=f15e10]:
- link "작업본" [ref=f15e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f15e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f15e17]
- main [ref=f15e18]:
- generic [ref=f15e19]:
- generic [ref=f15e20]:
- region [ref=f15e21]:
- generic [ref=f15e22]:
- paragraph [ref=f15e23]: CASE · VERSION 46
- heading "문서 편집" [level=1] [ref=f15e24]
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
- region [ref=f15e26]:
- generic [ref=f15e27]:
- paragraph [ref=f15e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f15e29]
- generic [ref=f15e30]:
- generic [ref=f15e31]:
- generic [ref=f15e32]: 제목
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
- generic [ref=f15e34]:
- generic [ref=f15e35]: slug
- textbox "slug" [ref=f15e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-fetch-join-in-memory-paging
- generic [ref=f15e37]:
- generic [ref=f15e38]: 요약
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
- generic [ref=f15e41]:
- generic [ref=f15e42]: Topic
- combobox "Topic" [ref=f15e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f15e44]:
- generic [ref=f15e45]: Project
- combobox "Project" [ref=f15e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f15e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
- generic [ref=f15e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f15e51]
- generic [ref=f15e52]: 파생 쿼리 그대로
- generic [ref=f15e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f15e54]
- generic [ref=f15e55]: 컬렉션 fetch join
- generic [ref=f15e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
- generic [ref=f15e58]: fetch join + 페이징
- group "관계" [ref=f15e59]:
- generic [ref=f15e61]:
- generic [ref=f15e62]:
- generic [ref=f15e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f15e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e65]:
- generic [ref=f15e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e69]:
- button "위로" [disabled] [ref=f15e70]
- button "아래로" [ref=f15e71]
- button "삭제" [ref=f15e72]
- generic [ref=f15e73]:
- generic [ref=f15e74]:
- generic [ref=f15e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f15e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e77]:
- generic [ref=f15e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e81]:
- button "위로" [ref=f15e82]
- button "아래로" [ref=f15e83]
- button "삭제" [ref=f15e84]
- generic [ref=f15e85]:
- generic [ref=f15e86]:
- generic [ref=f15e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f15e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e89]:
- generic [ref=f15e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e93]:
- button "위로" [ref=f15e94]
- button "아래로" [disabled] [ref=f15e95]
- button "삭제" [ref=f15e96]
- button "관계 추가" [ref=f15e97]
- region [ref=f15e98]:
- generic [ref=f15e99]:
- paragraph [ref=f15e100]: CASE
- heading "문제와 검증" [level=2] [ref=f15e101]
- generic [ref=f15e102]:
- generic [ref=f15e103]:
- generic [ref=f15e104]: 문제
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
- generic [ref=f15e106]:
- generic [ref=f15e107]: 결론
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
- generic [ref=f15e109]:
- generic [ref=f15e110]: 검증 환경
- textbox "검증 환경" [ref=f15e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 쿼리 : JPQL hibernate.query.fail_on_pagination_over _collection_fetch : false (기본값)"
- generic [ref=f15e112]:
- generic [ref=f15e113]: 재현 조건
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
- generic [ref=f15e115]:
- generic [ref=f15e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
- generic [ref=f15e118]:
- generic [ref=f15e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f15e120]:
- button "코드" [ref=f15e121] [cursor=pointer]
- button "표" [ref=f15e122] [cursor=pointer]
- button "목록" [ref=f15e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f15e124]: "## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징 ``` 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다. ## 반환은 한 페이지인데 메모리에 전부 로드 | N | returned(페이지) | feedItemLoaded | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| | 10 | 6.184 ms | 6.566 ms | 약 1.5 MB | | 100 | 13.890 ms | 16.062 ms | 약 3.0 MB | | 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB | returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 :::evidence key=\"batch-fetch-in-clause-cb44066a\" alt=\"조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도.\" caption=\" \" zoom=\"true\" ::: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
- group [ref=f15e125]:
- paragraph [ref=f15e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f15e129]:
- generic [ref=f15e130]:
- generic [ref=f15e131]: 업로드 종류
- combobox "업로드 종류" [ref=f15e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f15e133]
- generic [ref=f15e134]:
- search [ref=f15e135]:
- generic [ref=f15e136]: Asset 검색
- generic [ref=f15e137]:
- searchbox "Asset 검색" [ref=f15e138]
- button "검색" [ref=f15e139]
- generic [ref=f15e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
- list [ref=f15e144]:
- listitem [ref=f15e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
- button "삭제" [ref=f15e147]
- listitem [ref=f15e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
- button "삭제" [ref=f15e150]
- listitem [ref=f15e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
- button "삭제" [ref=f15e153]
- listitem [ref=f15e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
- button "삭제" [ref=f15e156]
- listitem [ref=f15e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
- button "삭제" [ref=f15e159]
- listitem [ref=f15e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
- button "삭제" [ref=f15e162]
- listitem [ref=f15e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
- button "삭제" [ref=f15e165]
- listitem [ref=f15e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
- button "삭제" [ref=f15e168]
- listitem [ref=f15e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
- button "삭제" [ref=f15e171]
- listitem [ref=f15e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
- button "삭제" [ref=f15e174]
- listitem [ref=f15e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
- button "삭제" [ref=f15e177]
- listitem [ref=f15e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
- button "삭제" [ref=f15e180]
- listitem [ref=f15e181]:
- button "login-api-phase-split-3e354274" [ref=f15e182]
- button "삭제" [ref=f15e183]
- listitem [ref=f15e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
- button "삭제" [ref=f15e186]
- listitem [ref=f15e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
- button "삭제" [ref=f15e189]
- listitem [ref=f15e190]:
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
- button "삭제" [ref=f15e192]
- listitem [ref=f15e193]:
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
- button "삭제" [ref=f15e195]
- listitem [ref=f15e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
- button "삭제" [ref=f15e198]
- listitem [ref=f15e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
- button "삭제" [ref=f15e201]
- listitem [ref=f15e202]:
- button "ap2-split-custody-779cb791" [ref=f15e203]
- button "삭제" [ref=f15e204]
- listitem [ref=f15e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
- button "삭제" [ref=f15e207]
- listitem [ref=f15e208]:
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
- button "삭제" [ref=f15e210]
- listitem [ref=f15e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
- button "삭제" [ref=f15e213]
- listitem [ref=f15e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
- button "삭제" [ref=f15e216]
- region [ref=f15e235]:
- generic [ref=f15e236]:
- paragraph [ref=f15e237]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f15e238]
- generic [ref=f15e241]:
- generic [ref=f15e242]:
- navigation "문서 경로" [ref=f15e243]:
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f15e245]: /
- generic [ref=f15e246]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f15e247]: /
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
- paragraph [ref=f15e250]:
- text: 컬렉션 하나만
- code [ref=f15e251]: fetch join
- text: 하고
- code [ref=f15e252]: setMaxResults(20)
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
- paragraph [ref=f15e253]:
- text: 하지만 컬렉션
- code [ref=f15e254]: fetch join
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
- code [ref=f15e255]: LIMIT 20
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
- paragraph [ref=f15e256]:
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
- code [ref=f15e257]: setMaxResults(20)
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
- region "문제와 결론" [ref=f15e258]:
- generic [ref=f15e259]:
- paragraph [ref=f15e260]: 문제
- paragraph [ref=f15e261]:
- text: 컬렉션을
- code [ref=f15e262]: fetch join
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
- paragraph [ref=f15e263]:
- text: 실제로
- code [ref=f15e264]: setMaxResults(20)
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
- generic [ref=f15e266]:
- paragraph [ref=f15e267]: 결론
- paragraph [ref=f15e268]:
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
- code [ref=f15e269]: feedItemLoaded
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
- paragraph [ref=f15e270]:
- text: 컬렉션
- code [ref=f15e271]: fetch join
- text: 에서는 조인 결과에
- code [ref=f15e272]: LIMIT
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
- code [ref=f15e273]: LIMIT
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
- code [ref=f15e274]: Limit
- text: 노드가 없었다.
- generic [ref=f15e275]:
- generic [ref=f15e276]:
- term [ref=f15e277]: 검증 환경
- definition [ref=f15e278]:
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
- generic [ref=f15e281]:
- term [ref=f15e282]: 검증 데이터
- definition [ref=f15e283]:
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
- generic [ref=f15e289]:
- term [ref=f15e290]: 기록
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f15e293]:
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
- article [ref=f15e296]:
- region [ref=f15e297]:
- heading [level=2] [ref=f15e298]:
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
- /url: "#컬렉션-fetch-join에-페이징"
- text: 컬렉션 Fetch join에 페이징
- generic [aria-hidden] [ref=f15e300]: "#"
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
- generic [ref=f15e302]:
- generic [ref=f15e303]: JAVA
- generic [ref=f15e304]: ·통합 테스트
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
- region "통합 테스트 코드" [ref=f15e306]:
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
- region [ref=f15e310]:
- heading [level=2] [ref=f15e311]:
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
- text: 반환은 한 페이지인데 메모리에 전부 로드
- generic [aria-hidden] [ref=f15e313]: "#"
- region "표" [ref=f15e314]:
- table [ref=f15e315]:
- caption [ref=f15e316]
- rowgroup [ref=f15e317]:
- row [ref=f15e318]:
- columnheader "N" [ref=f15e319]
- columnheader "returned(페이지)" [ref=f15e320]
- columnheader "feedItemLoaded" [ref=f15e321]
- columnheader "시드 하이라이트" [ref=f15e322]
- rowgroup [ref=f15e323]:
- row [ref=f15e324]:
- cell "10" [ref=f15e325]
- cell "10" [ref=f15e326]
- cell "10" [ref=f15e327]
- cell "1,285" [ref=f15e328]
- row [ref=f15e329]:
- cell "100" [ref=f15e330]
- cell "20" [ref=f15e331]
- cell "100" [ref=f15e332]
- cell "1,961" [ref=f15e333]
- row [ref=f15e334]:
- cell "1,000" [ref=f15e335]
- cell "20" [ref=f15e336]
- cell "1,000" [ref=f15e337]
- cell "2,917" [ref=f15e338]
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
- region [ref=f15e341]:
- heading [level=2] [ref=f15e342]:
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
- text: Fetch join에 페이징을 적용 했을 때의 경고
- generic [aria-hidden] [ref=f15e344]: "#"
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
- generic [ref=f15e346]:
- generic [ref=f15e347]: TEXT
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
- region [ref=f15e354]:
- heading [level=2] [ref=f15e355]:
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
- /url: "#페이지가-아닌-데이터셋에-비례한다"
- text: 페이지가 아닌 데이터셋에 비례한다
- generic [aria-hidden] [ref=f15e357]: "#"
- region "표" [ref=f15e358]:
- table [ref=f15e359]:
- caption [ref=f15e360]
- rowgroup [ref=f15e361]:
- row [ref=f15e362]:
- columnheader "N" [ref=f15e363]
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
- columnheader "스레드 누적 할당" [ref=f15e366]
- rowgroup [ref=f15e367]:
- row [ref=f15e368]:
- cell "10" [ref=f15e369]
- cell "6.184 ms" [ref=f15e370]
- cell "6.566 ms" [ref=f15e371]
- cell "약 1.5 MB" [ref=f15e372]
- row [ref=f15e373]:
- cell "100" [ref=f15e374]
- cell "13.890 ms" [ref=f15e375]
- cell "16.062 ms" [ref=f15e376]
- cell "약 3.0 MB" [ref=f15e377]
- row [ref=f15e378]:
- cell "1,000" [ref=f15e379]
- cell "79.452 ms" [ref=f15e380]
- cell "83.526 ms" [ref=f15e381]
- cell "약 10.0 MB" [ref=f15e382]
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
- region [ref=f15e386]:
- heading [level=2] [ref=f15e387]:
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
- /url: "#sql에-limit-노드가-없다"
- text: SQL에 LIMIT 노드가 없다
- generic [aria-hidden] [ref=f15e389]: "#"
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
- generic [ref=f15e391]:
- generic [ref=f15e392]: TEXT
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
- code [ref=f15e396]: "-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) Sort Method: quicksort Memory: 445kB -> Hash Join (... actual ... rows=1961 loops=1) -> Seq Scan on highlights h (actual ... rows=1961 loops=1) -> Hash (actual ... rows=100 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) -- (b) 엔티티만 페이징 — Limit 노드 존재 Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort (actual ... rows=20 loops=1) Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1)"
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
- region [ref=f15e400]:
- heading [level=2] [ref=f15e401]:
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
- /url: "#다음-선택"
- text: 다음 선택
- generic [aria-hidden] [ref=f15e403]: "#"
- figure [ref=f15e433]:
- button "batch-fetch-in-clause-cb44066a 이미지 크게 보기" [ref=f15e434]:
- img "조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도." [ref=f15e435]
- generic [ref=f15e436]: 크게 보기
- generic [ref=f15e437]: 조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도.
- paragraph [ref=f15e438]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
- region [ref=f15e405]:
- paragraph [ref=f15e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
- list [ref=f15e408]:
- listitem [ref=f15e409]:
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
- /url: /cases/fetch-join-multibag-and-row-explosion
- generic [ref=f15e411]: 검증 기록
- generic [ref=f15e412]:
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e415]:
- complementary [ref=f15e416]:
- heading "작업 상태" [level=2] [ref=f15e417]
- status "편집 상태" [ref=f15e418]: 저장됨
- generic [ref=f15e419]:
- generic [ref=f15e420]:
- term [ref=f15e421]: 저장 버전
- definition [ref=f15e422]: "46"
- generic [ref=f15e423]:
- term [ref=f15e424]: 종류
- definition [ref=f15e425]: 검증 기록
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f15e427]:
- button "저장" [disabled] [ref=f15e428]
- button "게시" [ref=f15e429]
- paragraph [ref=f15e430]: 버전 46으로 저장했습니다.
@@ -0,0 +1,16 @@
- generic [ref=f1e3]:
- banner [ref=f1e4]:
- generic [ref=f1e5]: prod
- main [ref=f1e6]:
- heading "Sign in to your account" [level=1] [ref=f1e8]
- generic [ref=f1e12]:
- generic [ref=f1e13]:
- generic [ref=f1e14]: Username or email
- textbox "Username or email" [active] [ref=f1e17]
- generic [ref=f1e18]:
- generic [ref=f1e19]: Password
- generic [ref=f1e21]:
- textbox "Password" [ref=f1e24]
- button "Show password" [ref=f1e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f1e27]:
- button "Sign In" [ref=f1e30] [cursor=pointer]
@@ -0,0 +1,60 @@
- generic [ref=f8e3]:
- link "본문으로 건너뛰기" [ref=f8e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e5]:
- generic [ref=f8e6]:
- link "TechLog Studio" [ref=f8e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e8]: Studio
- navigation "Studio 주 탐색" [ref=f8e10]:
- link "작업본" [ref=f8e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e17]
- main [ref=f8e18]:
- generic [ref=f8e19]:
- generic [ref=f8e20]:
- paragraph [ref=f8e21]: NEW WORKING COPY
- heading "새 문서" [level=1] [ref=f8e22]
- paragraph [ref=f8e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
- generic [ref=f8e24]:
- group "문서 종류" [ref=f8e25]:
- generic [ref=f8e27] [cursor=pointer]:
- radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [ref=f8e28]
- strong [ref=f8e29]: Case
- generic [ref=f8e30]: 문제를 재현하고 검증한 결론을 기록합니다.
- generic [ref=f8e31]: 문제 · 결론 · 환경 · 재현 · 본문
- generic [ref=f8e32] [cursor=pointer]:
- radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [checked] [active] [ref=f8e33]
- strong [ref=f8e34]: Reference
- generic [ref=f8e35]: 반복해서 적용할 기술 기준을 정리합니다.
- generic [ref=f8e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시
- generic [ref=f8e37] [cursor=pointer]:
- radio "개념 개념의 내부 동작과 아키텍처를 처음부터 풀어 씁니다. 기준 버전 · 본문" [ref=f8e38]
- strong [ref=f8e39]: 개념
- generic [ref=f8e40]: 개념의 내부 동작과 아키텍처를 처음부터 풀어 씁니다.
- generic [ref=f8e41]: 기준 버전 · 본문
- generic [ref=f8e42] [cursor=pointer]:
- radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [ref=f8e43]
- strong [ref=f8e44]: Question
- generic [ref=f8e45]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다.
- generic [ref=f8e46]: 상태 · 사실 · 가정 · 미지수 · 선택지
- generic [ref=f8e47] [cursor=pointer]:
- radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f8e48]
- strong [ref=f8e49]: Decision
- generic [ref=f8e50]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.
- generic [ref=f8e51]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거
- generic [ref=f8e52]:
- button "작업본 만들기" [ref=f8e53]
- note [ref=f8e54]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다. 세션이 끝나면 사라집니다.
- paragraph [ref=f8e55]
@@ -0,0 +1,109 @@
- generic [ref=f8e3]:
- link "본문으로 건너뛰기" [ref=f8e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e5]:
- generic [ref=f8e6]:
- link "TechLog Studio" [ref=f8e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e8]: Studio
- navigation "Studio 주 탐색" [ref=f8e10]:
- link "작업본" [ref=f8e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e17]
- main [ref=f8e18]:
- generic [ref=f8e56]:
- generic [ref=f8e57]:
- region [ref=f8e58]:
- generic [ref=f8e59]:
- paragraph [ref=f8e60]: REFERENCE · VERSION 1
- heading "문서 편집" [level=1] [ref=f8e61]
- paragraph [ref=f8e62]: 제목 없는 작업본
- region [ref=f8e63]:
- generic [ref=f8e64]:
- paragraph [ref=f8e65]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f8e66]
- generic [ref=f8e67]:
- generic [ref=f8e68]:
- generic [ref=f8e69]: 제목
- textbox "제목" [ref=f8e70]
- generic [ref=f8e71]:
- generic [ref=f8e72]: slug
- textbox "slug" [ref=f8e73]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- generic [ref=f8e74]:
- generic [ref=f8e75]: 요약
- textbox "요약" [ref=f8e76]
- generic [aria-hidden] [ref=f8e77]: 목록 카드에는 약 90자까지 보입니다 · 0 / 2000
- generic [ref=f8e78]:
- generic [ref=f8e79]: Topic
- combobox "Topic" [ref=f8e80]:
- option "선택하지 않음" [selected]
- option "JPA 피드 조회 성능"
- option "OAuth/OIDC 인증 경계"
- generic [ref=f8e81]:
- generic [ref=f8e82]: Project
- combobox "Project" [ref=f8e83]:
- option "미지정" [selected]
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제"
- status [ref=f8e84]
- group "관계" [ref=f8e85]:
- paragraph [ref=f8e87]: 연결한 공개 기록이 없습니다.
- button "관계 추가" [ref=f8e88]
- region [ref=f8e89]:
- generic [ref=f8e90]:
- paragraph [ref=f8e91]: REFERENCE
- heading "재사용할 기준" [level=2] [ref=f8e92]
- generic [ref=f8e93]:
- generic [ref=f8e94]: 이 기준을 쓰는 이유
- textbox "이 기준을 쓰는 이유" [ref=f8e95]
- group "판단 기준" [ref=f8e96]:
- paragraph [ref=f8e98]: 아직 입력한 판단 기준이 없습니다.
- button "판단 기준 추가" [ref=f8e99]
- group "적용할 때" [ref=f8e100]:
- paragraph [ref=f8e102]: 아직 입력한 항목이 없습니다.
- button "적용할 때 추가" [ref=f8e103]
- group "예외와 주의" [ref=f8e104]:
- paragraph [ref=f8e106]: 아직 입력한 항목이 없습니다.
- button "예외와 주의 추가" [ref=f8e107]
- group "예시" [ref=f8e108]:
- paragraph [ref=f8e110]: 아직 입력한 항목이 없습니다.
- button "예시 추가" [ref=f8e111]
- generic [ref=f8e112]:
- generic [ref=f8e113]: 마지막 검증일
- textbox "마지막 검증일" [ref=f8e114]
- region [ref=f8e115]:
- generic [ref=f8e116]:
- paragraph [ref=f8e117]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f8e118]
- alert [ref=f8e120]:
- heading "초안을 미리 볼 수 없습니다" [level=2] [ref=f8e121]
- list [ref=f8e122]:
- listitem [ref=f8e123]: 1:1 TOPIC catalog entry is required
- complementary [ref=f8e124]:
- heading "작업 상태" [level=2] [ref=f8e125]
- status "편집 상태" [ref=f8e126]: 저장됨
- generic [ref=f8e127]:
- generic [ref=f8e128]:
- term [ref=f8e129]: 저장 버전
- definition [ref=f8e130]: "1"
- generic [ref=f8e131]:
- term [ref=f8e132]: 종류
- definition [ref=f8e133]: 적용 기준
- paragraph [ref=f8e134]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f8e135]:
- button "저장" [disabled] [ref=f8e136]
- button "게시" [ref=f8e137]
- paragraph [ref=f8e55]: Reference 작업본을 만들었습니다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,552 @@
- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e19]:
- generic [ref=f4e20]:
- generic [ref=f4e21]:
- paragraph [ref=f4e22]: WORKING COPIES
- heading "작업본" [level=1] [ref=f4e23]
- paragraph [ref=f4e24]: 세션에 있는 검증 기록·동작 원리·적용 기준·열린 질문·설계 결정을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f4e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f4e26]:
- search [ref=f4e27]:
- generic [ref=f4e28]: 검색
- generic [ref=f4e29]:
- searchbox "검색" [ref=f4e30]
- button "검색" [ref=f4e31]
- generic [ref=f4e32]:
- text: 종류
- combobox "종류" [ref=f4e33]:
- option "전체" [selected]
- option "검증 기록"
- option "동작 원리"
- option "적용 기준"
- option "열린 질문"
- option "설계 결정"
- generic [ref=f4e34]:
- text: 상태
- combobox "상태" [ref=f4e35]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f4e36]:
- generic [ref=f4e37]: 48개 중 20개 표시 중
- generic [ref=f4e38]: · 1 / 3 쪽
- alert [ref=f4e39]: 이 기록을 참조하는 곳이 있어 삭제할 수 없습니다
- generic [ref=f4e40]:
- article [ref=f4e41]:
- paragraph [ref=f4e42]: 검증 기록
- generic [ref=f4e43]:
- heading [level=2] [ref=f4e44]:
- link "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [ref=f4e45] [cursor=pointer]:
- /url: /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit
- paragraph [ref=f4e46]: KeyCloak Patterns
- generic [ref=f4e47]:
- generic [ref=f4e48]:
- term [ref=f4e49]: 상태
- definition [ref=f4e50]: 게시 중
- generic [ref=f4e51]:
- term [ref=f4e52]: 다음
- definition [ref=f4e53]:
- link "검증하기" [ref=f4e54] [cursor=pointer]:
- /url: /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/validation
- generic [ref=f4e55]:
- term [ref=f4e56]: 수정
- definition [ref=f4e57]:
- time [ref=f4e58]: 2026. 9. 8.
- generic [ref=f4e59]:
- link "편집" [ref=f4e60] [cursor=pointer]:
- /url: /studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit
- button "삭제" [ref=f4e61]
- article [ref=f4e62]:
- paragraph [ref=f4e63]: 검증 기록
- generic [ref=f4e64]:
- heading [level=2] [ref=f4e65]:
- link "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" [ref=f4e66] [cursor=pointer]:
- /url: /studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit
- paragraph [ref=f4e67]: KeyCloak Patterns
- generic [ref=f4e68]:
- generic [ref=f4e69]:
- term [ref=f4e70]: 상태
- definition [ref=f4e71]: 게시 중
- generic [ref=f4e72]:
- term [ref=f4e73]: 다음
- definition [ref=f4e74]:
- link "검증하기" [ref=f4e75] [cursor=pointer]:
- /url: /studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/validation
- generic [ref=f4e76]:
- term [ref=f4e77]: 수정
- definition [ref=f4e78]:
- time [ref=f4e79]: 2026. 9. 8.
- generic [ref=f4e80]:
- link "편집" [ref=f4e81] [cursor=pointer]:
- /url: /studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit
- button "삭제" [ref=f4e82]
- article [ref=f4e83]:
- paragraph [ref=f4e84]: 적용 기준
- generic [ref=f4e85]:
- heading [level=2] [ref=f4e86]:
- link "패턴 검증을 실제로 돌릴 때의 안전한 순서" [ref=f4e87] [cursor=pointer]:
- /url: /studio/documents/036563a1-3a43-4931-a017-0e693b591e89/edit
- paragraph [ref=f4e88]: KeyCloak Patterns
- generic [ref=f4e89]:
- generic [ref=f4e90]:
- term [ref=f4e91]: 상태
- definition [ref=f4e92]: 게시 전
- generic [ref=f4e93]:
- term [ref=f4e94]: 다음
- definition [ref=f4e95]:
- link "검증하기" [ref=f4e96] [cursor=pointer]:
- /url: /studio/documents/036563a1-3a43-4931-a017-0e693b591e89/validation
- generic [ref=f4e97]:
- term [ref=f4e98]: 수정
- definition [ref=f4e99]:
- time [ref=f4e100]: 2026. 9. 7.
- generic [ref=f4e101]:
- link "편집" [ref=f4e102] [cursor=pointer]:
- /url: /studio/documents/036563a1-3a43-4931-a017-0e693b591e89/edit
- button "삭제" [ref=f4e103]
- article [ref=f4e104]:
- paragraph [ref=f4e105]: 검증 기록
- generic [ref=f4e106]:
- heading [level=2] [ref=f4e107]:
- link "Collection Fetch Join Pagination의 In-memory Paging" [ref=f4e108] [cursor=pointer]:
- /url: /studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit
- paragraph [ref=f4e109]: Liner N + 1문제
- generic [ref=f4e110]:
- generic [ref=f4e111]:
- term [ref=f4e112]: 상태
- definition [ref=f4e113]: 게시 중
- generic [ref=f4e114]:
- term [ref=f4e115]: 다음
- definition [ref=f4e116]:
- link "검증하기" [ref=f4e117] [cursor=pointer]:
- /url: /studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/validation
- generic [ref=f4e118]:
- term [ref=f4e119]: 수정
- definition [ref=f4e120]:
- time [ref=f4e121]: 2026. 9. 7.
- generic [ref=f4e122]:
- link "편집" [ref=f4e123] [cursor=pointer]:
- /url: /studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit
- button "삭제" [ref=f4e124]
- article [ref=f4e125]:
- paragraph [ref=f4e126]: 검증 기록
- generic [ref=f4e127]:
- heading [level=2] [ref=f4e128]:
- link "Projection 이후에도 1,509행을 읽은 Row Over-fetch" [ref=f4e129] [cursor=pointer]:
- /url: /studio/documents/4c9c3b90-bc89-4300-9334-088ea95d37d8/edit
- paragraph [ref=f4e130]: Liner N + 1문제
- generic [ref=f4e131]:
- generic [ref=f4e132]:
- term [ref=f4e133]: 상태
- definition [ref=f4e134]: 게시 전
- generic [ref=f4e135]:
- term [ref=f4e136]: 다음
- definition [ref=f4e137]:
- link "검증하기" [ref=f4e138] [cursor=pointer]:
- /url: /studio/documents/4c9c3b90-bc89-4300-9334-088ea95d37d8/validation
- generic [ref=f4e139]:
- term [ref=f4e140]: 수정
- definition [ref=f4e141]:
- time [ref=f4e142]: 2026. 9. 7.
- generic [ref=f4e143]:
- link "편집" [ref=f4e144] [cursor=pointer]:
- /url: /studio/documents/4c9c3b90-bc89-4300-9334-088ea95d37d8/edit
- button "삭제" [ref=f4e145]
- article [ref=f4e146]:
- paragraph [ref=f4e147]: 검증 기록
- generic [ref=f4e148]:
- heading [level=2] [ref=f4e149]:
- link "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f4e150] [cursor=pointer]:
- /url: /studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/edit
- paragraph [ref=f4e151]: Liner N + 1문제
- generic [ref=f4e152]:
- generic [ref=f4e153]:
- term [ref=f4e154]: 상태
- definition [ref=f4e155]: 게시 중
- generic [ref=f4e156]:
- term [ref=f4e157]: 다음
- definition [ref=f4e158]:
- link "검증하기" [ref=f4e159] [cursor=pointer]:
- /url: /studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/validation
- generic [ref=f4e160]:
- term [ref=f4e161]: 수정
- definition [ref=f4e162]:
- time [ref=f4e163]: 2026. 9. 7.
- generic [ref=f4e164]:
- link "편집" [ref=f4e165] [cursor=pointer]:
- /url: /studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/edit
- button "삭제" [ref=f4e166]
- article [ref=f4e167]:
- paragraph [ref=f4e168]: 검증 기록
- generic [ref=f4e169]:
- heading [level=2] [ref=f4e170]:
- link "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f4e171] [cursor=pointer]:
- /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit
- paragraph [ref=f4e172]: Liner N + 1문제
- generic [ref=f4e173]:
- generic [ref=f4e174]:
- term [ref=f4e175]: 상태
- definition [ref=f4e176]: 게시 중
- generic [ref=f4e177]:
- term [ref=f4e178]: 다음
- definition [ref=f4e179]:
- link "검증하기" [ref=f4e180] [cursor=pointer]:
- /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/validation
- generic [ref=f4e181]:
- term [ref=f4e182]: 수정
- definition [ref=f4e183]:
- time [ref=f4e184]: 2026. 9. 7.
- generic [ref=f4e185]:
- link "편집" [ref=f4e186] [cursor=pointer]:
- /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit
- button "삭제" [ref=f4e187]
- article [ref=f4e188]:
- paragraph [ref=f4e189]: 동작 원리
- generic [ref=f4e190]:
- heading [level=2] [ref=f4e191]:
- link "Forward-Auth와 Nginx auth_request의 동작" [ref=f4e192] [cursor=pointer]:
- /url: /studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit
- paragraph [ref=f4e193]: KeyCloak Patterns
- generic [ref=f4e194]:
- generic [ref=f4e195]:
- term [ref=f4e196]: 상태
- definition [ref=f4e197]: 게시 전
- generic [ref=f4e198]:
- term [ref=f4e199]: 다음
- definition [ref=f4e200]:
- link "검증하기" [ref=f4e201] [cursor=pointer]:
- /url: /studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/validation
- generic [ref=f4e202]:
- term [ref=f4e203]: 수정
- definition [ref=f4e204]:
- time [ref=f4e205]: 2026. 9. 7.
- generic [ref=f4e206]:
- link "편집" [ref=f4e207] [cursor=pointer]:
- /url: /studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit
- button "삭제" [ref=f4e208]
- article [ref=f4e209]:
- paragraph [ref=f4e210]: 동작 원리
- generic [ref=f4e211]:
- heading [level=2] [ref=f4e212]:
- link "Cookie로 인증하는 요청에서 CSRF token이 하는 일" [ref=f4e213] [cursor=pointer]:
- /url: /studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit
- paragraph [ref=f4e214]: KeyCloak Patterns
- generic [ref=f4e215]:
- generic [ref=f4e216]:
- term [ref=f4e217]: 상태
- definition [ref=f4e218]: 게시 전
- generic [ref=f4e219]:
- term [ref=f4e220]: 다음
- definition [ref=f4e221]:
- link "검증하기" [ref=f4e222] [cursor=pointer]:
- /url: /studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/validation
- generic [ref=f4e223]:
- term [ref=f4e224]: 수정
- definition [ref=f4e225]:
- time [ref=f4e226]: 2026. 9. 7.
- generic [ref=f4e227]:
- link "편집" [ref=f4e228] [cursor=pointer]:
- /url: /studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit
- button "삭제" [ref=f4e229]
- article [ref=f4e230]:
- paragraph [ref=f4e231]: 검증 기록
- generic [ref=f4e232]:
- heading [level=2] [ref=f4e233]:
- link "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f4e234] [cursor=pointer]:
- /url: /studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit
- paragraph [ref=f4e235]: KeyCloak Patterns
- generic [ref=f4e236]:
- generic [ref=f4e237]:
- term [ref=f4e238]: 상태
- definition [ref=f4e239]: 게시 중
- generic [ref=f4e240]:
- term [ref=f4e241]: 다음
- definition [ref=f4e242]:
- link "검증하기" [ref=f4e243] [cursor=pointer]:
- /url: /studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/validation
- generic [ref=f4e244]:
- term [ref=f4e245]: 수정
- definition [ref=f4e246]:
- time [ref=f4e247]: 2026. 9. 7.
- generic [ref=f4e248]:
- link "편집" [ref=f4e249] [cursor=pointer]:
- /url: /studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit
- button "삭제" [ref=f4e250]
- article [ref=f4e251]:
- paragraph [ref=f4e252]: 검증 기록
- generic [ref=f4e253]:
- heading [level=2] [ref=f4e254]:
- link "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [ref=f4e255] [cursor=pointer]:
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
- paragraph [ref=f4e256]: KeyCloak Patterns
- generic [ref=f4e257]:
- generic [ref=f4e258]:
- term [ref=f4e259]: 상태
- definition [ref=f4e260]: 게시 중
- generic [ref=f4e261]:
- term [ref=f4e262]: 다음
- definition [ref=f4e263]:
- link "검증하기" [ref=f4e264] [cursor=pointer]:
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/validation
- generic [ref=f4e265]:
- term [ref=f4e266]: 수정
- definition [ref=f4e267]:
- time [ref=f4e268]: 2026. 9. 7.
- generic [ref=f4e269]:
- link "편집" [ref=f4e270] [cursor=pointer]:
- /url: /studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit
- button "삭제" [ref=f4e271]
- article [ref=f4e272]:
- paragraph [ref=f4e273]: 열린 질문
- generic [ref=f4e274]:
- heading [level=2] [ref=f4e275]:
- link "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" [ref=f4e276] [cursor=pointer]:
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
- paragraph [ref=f4e277]: Liner N + 1문제
- generic [ref=f4e278]:
- generic [ref=f4e279]:
- term [ref=f4e280]: 상태
- definition [ref=f4e281]: 게시 전
- generic [ref=f4e282]:
- term [ref=f4e283]: 다음
- definition [ref=f4e284]:
- link "검증하기" [ref=f4e285] [cursor=pointer]:
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/validation
- generic [ref=f4e286]:
- term [ref=f4e287]: 수정
- definition [ref=f4e288]:
- time [ref=f4e289]: 2026. 9. 4.
- generic [ref=f4e290]:
- link "편집" [ref=f4e291] [cursor=pointer]:
- /url: /studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit
- button "삭제" [ref=f4e292]
- article [ref=f4e293]:
- paragraph [ref=f4e294]: 열린 질문
- generic [ref=f4e295]:
- heading [level=2] [ref=f4e296]:
- link "Round Trip과 Row Volume을 독립 측정할 것인가" [ref=f4e297] [cursor=pointer]:
- /url: /studio/documents/5159c415-232d-424a-970a-b0db52746767/edit
- paragraph [ref=f4e298]: Liner N + 1문제
- generic [ref=f4e299]:
- generic [ref=f4e300]:
- term [ref=f4e301]: 상태
- definition [ref=f4e302]: 게시 전
- generic [ref=f4e303]:
- term [ref=f4e304]: 다음
- definition [ref=f4e305]:
- link "검증하기" [ref=f4e306] [cursor=pointer]:
- /url: /studio/documents/5159c415-232d-424a-970a-b0db52746767/validation
- generic [ref=f4e307]:
- term [ref=f4e308]: 수정
- definition [ref=f4e309]:
- time [ref=f4e310]: 2026. 9. 4.
- generic [ref=f4e311]:
- link "편집" [ref=f4e312] [cursor=pointer]:
- /url: /studio/documents/5159c415-232d-424a-970a-b0db52746767/edit
- button "삭제" [ref=f4e313]
- article [ref=f4e314]:
- paragraph [ref=f4e315]: 적용 기준
- generic [ref=f4e316]:
- heading [level=2] [ref=f4e317]:
- link "JPA N+1 정량 진단 기준" [ref=f4e318] [cursor=pointer]:
- /url: /studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit
- paragraph [ref=f4e319]: Liner N + 1문제
- generic [ref=f4e320]:
- generic [ref=f4e321]:
- term [ref=f4e322]: 상태
- definition [ref=f4e323]: 게시 전
- generic [ref=f4e324]:
- term [ref=f4e325]: 다음
- definition [ref=f4e326]:
- link "검증하기" [ref=f4e327] [cursor=pointer]:
- /url: /studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/validation
- generic [ref=f4e328]:
- term [ref=f4e329]: 수정
- definition [ref=f4e330]:
- time [ref=f4e331]: 2026. 9. 4.
- generic [ref=f4e332]:
- link "편집" [ref=f4e333] [cursor=pointer]:
- /url: /studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit
- button "삭제" [ref=f4e334]
- article [ref=f4e335]:
- paragraph [ref=f4e336]: 설계 결정
- generic [ref=f4e337]:
- heading [level=2] [ref=f4e338]:
- link "Query Plan은 실제 PostgreSQL에서 측정한다" [ref=f4e339] [cursor=pointer]:
- /url: /studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit
- paragraph [ref=f4e340]: Liner N + 1문제
- generic [ref=f4e341]:
- generic [ref=f4e342]:
- term [ref=f4e343]: 상태
- definition [ref=f4e344]: 게시 전
- generic [ref=f4e345]:
- term [ref=f4e346]: 다음
- definition [ref=f4e347]:
- link "검증하기" [ref=f4e348] [cursor=pointer]:
- /url: /studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/validation
- generic [ref=f4e349]:
- term [ref=f4e350]: 수정
- definition [ref=f4e351]:
- time [ref=f4e352]: 2026. 9. 4.
- generic [ref=f4e353]:
- link "편집" [ref=f4e354] [cursor=pointer]:
- /url: /studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit
- button "삭제" [ref=f4e355]
- article [ref=f4e356]:
- paragraph [ref=f4e357]: 동작 원리
- generic [ref=f4e358]:
- heading [level=2] [ref=f4e359]:
- link "외부 IdP Brokering의 동작" [ref=f4e360] [cursor=pointer]:
- /url: /studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit
- paragraph [ref=f4e361]: KeyCloak Patterns
- generic [ref=f4e362]:
- generic [ref=f4e363]:
- term [ref=f4e364]: 상태
- definition [ref=f4e365]: 게시 중
- generic [ref=f4e366]:
- term [ref=f4e367]: 다음
- definition [ref=f4e368]:
- link "검증하기" [ref=f4e369] [cursor=pointer]:
- /url: /studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/validation
- generic [ref=f4e370]:
- term [ref=f4e371]: 수정
- definition [ref=f4e372]:
- time [ref=f4e373]: 2026. 9. 1.
- generic [ref=f4e374]:
- link "편집" [ref=f4e375] [cursor=pointer]:
- /url: /studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit
- button "삭제" [ref=f4e376]
- article [ref=f4e377]:
- paragraph [ref=f4e378]: 적용 기준
- generic [ref=f4e379]:
- heading [level=2] [ref=f4e380]:
- link "Top-N-per-group 선택 기준" [ref=f4e381] [cursor=pointer]:
- /url: /studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit
- paragraph [ref=f4e382]: Liner N + 1문제
- generic [ref=f4e383]:
- generic [ref=f4e384]:
- term [ref=f4e385]: 상태
- definition [ref=f4e386]: 게시 전
- generic [ref=f4e387]:
- term [ref=f4e388]: 다음
- definition [ref=f4e389]:
- link "검증하기" [ref=f4e390] [cursor=pointer]:
- /url: /studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/validation
- generic [ref=f4e391]:
- term [ref=f4e392]: 수정
- definition [ref=f4e393]:
- time [ref=f4e394]: 2026. 8. 31.
- generic [ref=f4e395]:
- link "편집" [ref=f4e396] [cursor=pointer]:
- /url: /studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit
- button "삭제" [ref=f4e397]
- article [ref=f4e398]:
- paragraph [ref=f4e399]: 적용 기준
- generic [ref=f4e400]:
- heading [level=2] [ref=f4e401]:
- link "PostgreSQL Query Plan 측정 기준" [ref=f4e402] [cursor=pointer]:
- /url: /studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit
- paragraph [ref=f4e403]: Liner N + 1문제
- generic [ref=f4e404]:
- generic [ref=f4e405]:
- term [ref=f4e406]: 상태
- definition [ref=f4e407]: 게시 전
- generic [ref=f4e408]:
- term [ref=f4e409]: 다음
- definition [ref=f4e410]:
- link "검증하기" [ref=f4e411] [cursor=pointer]:
- /url: /studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/validation
- generic [ref=f4e412]:
- term [ref=f4e413]: 수정
- definition [ref=f4e414]:
- time [ref=f4e415]: 2026. 8. 31.
- generic [ref=f4e416]:
- link "편집" [ref=f4e417] [cursor=pointer]:
- /url: /studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit
- button "삭제" [ref=f4e418]
- article [ref=f4e419]:
- paragraph [ref=f4e420]: 적용 기준
- generic [ref=f4e421]:
- heading [level=2] [ref=f4e422]:
- link "Keyset Pagination 설계 기준" [ref=f4e423] [cursor=pointer]:
- /url: /studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit
- paragraph [ref=f4e424]: Liner N + 1문제
- generic [ref=f4e425]:
- generic [ref=f4e426]:
- term [ref=f4e427]: 상태
- definition [ref=f4e428]: 게시 전
- generic [ref=f4e429]:
- term [ref=f4e430]: 다음
- definition [ref=f4e431]:
- link "검증하기" [ref=f4e432] [cursor=pointer]:
- /url: /studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/validation
- generic [ref=f4e433]:
- term [ref=f4e434]: 수정
- definition [ref=f4e435]:
- time [ref=f4e436]: 2026. 8. 31.
- generic [ref=f4e437]:
- link "편집" [ref=f4e438] [cursor=pointer]:
- /url: /studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit
- button "삭제" [ref=f4e439]
- article [ref=f4e440]:
- paragraph [ref=f4e441]: 적용 기준
- generic [ref=f4e442]:
- heading [level=2] [ref=f4e443]:
- link "Fetch Type과 Fetch Strategy 구분" [ref=f4e444] [cursor=pointer]:
- /url: /studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit
- paragraph [ref=f4e445]: Liner N + 1문제
- generic [ref=f4e446]:
- generic [ref=f4e447]:
- term [ref=f4e448]: 상태
- definition [ref=f4e449]: 게시 전
- generic [ref=f4e450]:
- term [ref=f4e451]: 다음
- definition [ref=f4e452]:
- link "검증하기" [ref=f4e453] [cursor=pointer]:
- /url: /studio/documents/51095f6e-2cc8-439c-8648-065033614215/validation
- generic [ref=f4e454]:
- term [ref=f4e455]: 수정
- definition [ref=f4e456]:
- time [ref=f4e457]: 2026. 8. 31.
- generic [ref=f4e458]:
- link "편집" [ref=f4e459] [cursor=pointer]:
- /url: /studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit
- button "삭제" [ref=f4e460]
- navigation "작업본 페이지" [ref=f4e461]:
- button "이전 페이지" [disabled] [ref=f4e462]:
- list [ref=f4e463]:
- listitem [ref=f4e464]:
- button "1 페이지" [ref=f4e465]: "1"
- listitem [ref=f4e466]:
- text: "|"
- button "2 페이지" [ref=f4e467]: "2"
- listitem [ref=f4e468]:
- text: "|"
- button "3 페이지" [ref=f4e469]: "3"
- button "다음 페이지" [ref=f4e470]:
- paragraph [ref=f4e471]

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