Files
document-haness/.playwright-mcp/page-2026-09-07T10-38-25-062Z.yml

564 lines
46 KiB
YAML

- 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으로 저장했습니다.