559 lines
50 KiB
YAML
559 lines
50 KiB
YAML
- 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]:
|
||
- region [ref=f4e21]:
|
||
- generic [ref=f4e22]:
|
||
- paragraph [ref=f4e23]: CASE · VERSION 7
|
||
- heading "문서 편집" [level=1] [ref=f4e24]
|
||
- paragraph [ref=f4e25]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
|
||
- region [ref=f4e26]:
|
||
- generic [ref=f4e27]:
|
||
- paragraph [ref=f4e28]: DOCUMENT
|
||
- heading "기본 정보" [level=2] [ref=f4e29]
|
||
- generic [ref=f4e30]:
|
||
- generic [ref=f4e31]:
|
||
- generic [ref=f4e32]: 제목
|
||
- textbox "제목" [ref=f4e33]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
|
||
- generic [ref=f4e34]:
|
||
- generic [ref=f4e35]: slug
|
||
- textbox "slug" [ref=f4e36]:
|
||
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||
- text: collection-nplus1-dto-mapping
|
||
- generic [ref=f4e37]:
|
||
- generic [ref=f4e38]: 요약
|
||
- textbox "요약" [ref=f4e39]: 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 과정에 대한 내용이다. 매핑이 getHighlights()에 접근하는 시점에 N개의 쿼리가 추가로 나갔다. N=1,000이라면 추가 쿼리를 포함해 총 2,022개가 나갔다.
|
||
- generic [aria-hidden] [ref=f4e40]: 목록 카드에는 약 90자까지 보입니다 · 134 / 2000
|
||
- generic [ref=f4e41]:
|
||
- generic [ref=f4e42]: Topic
|
||
- combobox "Topic" [ref=f4e43]:
|
||
- option "선택하지 않음"
|
||
- option "JPA 피드 조회 성능" [selected]
|
||
- option "OAuth/OIDC 인증 경계"
|
||
- generic [ref=f4e44]:
|
||
- generic [ref=f4e45]: Project
|
||
- combobox "Project" [ref=f4e46]:
|
||
- option "미지정"
|
||
- option "Backend Clean Architecture"
|
||
- option "KeyCloak Patterns"
|
||
- option "Liner N + 1문제" [selected]
|
||
- status [ref=f4e47]
|
||
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f4e48]:
|
||
- generic [ref=f4e50] [cursor=pointer]:
|
||
- checkbox "파생 쿼리 그대로" [ref=f4e51]
|
||
- generic [ref=f4e52]: 파생 쿼리 그대로
|
||
- generic [ref=f4e53] [cursor=pointer]:
|
||
- checkbox "컬렉션 fetch join" [ref=f4e54]
|
||
- generic [ref=f4e55]: 컬렉션 fetch join
|
||
- generic [ref=f4e56] [cursor=pointer]:
|
||
- checkbox "fetch join + 페이징" [ref=f4e57]
|
||
- generic [ref=f4e58]: fetch join + 페이징
|
||
- group "관계" [ref=f4e59]:
|
||
- generic [ref=f4e61]:
|
||
- generic [ref=f4e62]:
|
||
- generic [ref=f4e63]: 관계 1 대상
|
||
- combobox "관계 1 대상" [ref=f4e64]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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 선택 기준"
|
||
- option "Fetch Type과 Fetch Strategy 구분" [disabled]
|
||
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||
- option "JPA N+1 정량 진단 기준" [selected]
|
||
- 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=f4e65]:
|
||
- generic [ref=f4e66]: 관계 1 이유
|
||
- textbox "관계 1 이유" [ref=f4e67]: 이 측정에서 사용한 지표와 회계 항등식이다.
|
||
- generic [aria-hidden] [ref=f4e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f4e69]:
|
||
- button "위로" [disabled] [ref=f4e70]
|
||
- button "아래로" [ref=f4e71]
|
||
- button "삭제" [ref=f4e72]
|
||
- generic [ref=f4e73]:
|
||
- generic [ref=f4e74]:
|
||
- generic [ref=f4e75]: 관계 2 대상
|
||
- combobox "관계 2 대상" [ref=f4e76]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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 선택 기준"
|
||
- option "Fetch Type과 Fetch Strategy 구분" [selected]
|
||
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||
- option "JPA N+1 정량 진단 기준" [disabled]
|
||
- 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=f4e77]:
|
||
- generic [ref=f4e78]: 관계 2 이유
|
||
- textbox "관계 2 이유" [ref=f4e79]: 컬렉션이 지연 로딩이라 접근 시점에 조회가 나갔다.
|
||
- generic [aria-hidden] [ref=f4e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f4e81]:
|
||
- button "위로" [ref=f4e82]
|
||
- button "아래로" [ref=f4e83]
|
||
- button "삭제" [ref=f4e84]
|
||
- generic [ref=f4e85]:
|
||
- generic [ref=f4e86]:
|
||
- generic [ref=f4e87]: 관계 3 대상
|
||
- combobox "관계 3 대상" [ref=f4e88]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging"
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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 선택 기준"
|
||
- option "Fetch Type과 Fetch Strategy 구분" [disabled]
|
||
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
|
||
- option "외부 IdP 연동과 Application 인증 구조의 경계"
|
||
- option "JPA N+1 정량 진단 기준" [disabled]
|
||
- 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=f4e89]:
|
||
- generic [ref=f4e90]: 관계 3 이유
|
||
- textbox "관계 3 이유" [ref=f4e91]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
|
||
- generic [aria-hidden] [ref=f4e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f4e93]:
|
||
- button "위로" [ref=f4e94]
|
||
- button "아래로" [disabled] [ref=f4e95]
|
||
- button "삭제" [ref=f4e96]
|
||
- button "관계 추가" [ref=f4e97]
|
||
- region [ref=f4e98]:
|
||
- generic [ref=f4e99]:
|
||
- paragraph [ref=f4e100]: CASE
|
||
- heading "문제와 검증" [level=2] [ref=f4e101]
|
||
- generic [ref=f4e102]:
|
||
- generic [ref=f4e103]:
|
||
- generic [ref=f4e104]: 문제
|
||
- textbox "문제" [ref=f4e105]: 피드 API는 한 페이지에 하이라이트가 많아져도 쿼리 수가 따라 늘지 않아야 했다. 최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤 Stream으로 순회하며 FeedSummary로 필드를 옮겼다. 이 코드에는 하이라이트를 도는 for가 없다. getHighlights().stream()만 있어서 쿼리가 몇 번 나가는지 코드만 보고는 알기 어려웠다.
|
||
- generic [ref=f4e106]:
|
||
- generic [ref=f4e107]: 결론
|
||
- textbox "결론" [ref=f4e108]: 매핑이 getHighlights()에 접근하는 시점에 하이라이트 쿼리가 한 번씩 나갔다. N=10에서 10개, N=100에서 100개, N=1,000에서 1,000개였다. 추가 쿼리를 포함한 총 쿼리는 25개, 222개, 2,022개였다. 코드에 반복문은 없다. Stream이 아이템을 하나씩 도는 동안 getHighlights() 접근이 N번 일어났고, 지연 로딩 컬렉션은 접근하는 순간 조회하므로 쿼리도 N번 나갔다. 문제는 두 가지다. 하나는 아이템 수만큼 쿼리가 늘어나는 것이다. 다른 하나는 그 쿼리 하나가 해당 아이템의 하이라이트를 전부 읽어 오는 것이다. 하이라이트가 가장 많은 아이템은 500행이었다. 쿼리 수는 테이블 전체 행 수를 따라 늘지 않았다. 한 요청에서 DTO로 조립하는 아이템 수를 따라 늘었다.
|
||
- generic [ref=f4e109]:
|
||
- generic [ref=f4e110]: 검증 환경
|
||
- textbox "검증 환경" [ref=f4e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers, 클래스당 1개 공유) 테스트 구성 @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) spring.flyway.enabled : true spring.jpa.hibernate.ddl-auto : validate hibernate.generate_statistics : true 측정 도구 쿼리 수 : Hibernate Statistics 지연 : System.nanoTime 실행계획 : EXPLAIN (ANALYZE, BUFFERS) DB 캐시 : warm (shared read=0)"
|
||
- generic [ref=f4e112]:
|
||
- generic [ref=f4e113]: 재현 조건
|
||
- textbox "재현 조건" [ref=f4e114]: "1. FeedSeedFixture.seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만든다. user는 max(3, min(20, N/5+1))명, page는 N개, highlight는 순위 기반 편중 분포로 생성된다. 2. stats.clear() 직후 loadFeed(0, N)을 1회 실행하고 getCollectionFetchCount()와 getPrepareStatementCount()를 읽는다. 3. 지연은 따로 잰다. latencyMicros(n, 7, 2)로 7회 반복하고 앞 2회는 워밍업으로 버린다. 매 반복 전에 em.clear()를 호출한다. 4. 반복되는 하이라이트 자식 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다."
|
||
- generic [ref=f4e115]:
|
||
- generic [ref=f4e116]: 마지막 검증일
|
||
- textbox "마지막 검증일" [ref=f4e117]
|
||
- generic [ref=f4e118]:
|
||
- generic [ref=f4e119]: 본문 Markdown
|
||
- group "Markdown 삽입" [ref=f4e120]:
|
||
- button "코드" [ref=f4e121] [cursor=pointer]
|
||
- button "표" [ref=f4e122] [cursor=pointer]
|
||
- button "목록" [ref=f4e123] [cursor=pointer]
|
||
- textbox "본문 Markdown" [ref=f4e124]: "## 측정한 loadFeed 구현 ```java label=\"FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑\" @Override public List<FeedSummary> loadFeed(int page, int size) { return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() .map(fi -> new FeedSummary( fi.getId().toString(), fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) fi.getFirstHighlightedAt(), fi.getHighlights().stream() // 컬렉션 (지연 로딩) .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) .toList())) .toList(); } ``` ## N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement :::evidence key=\"nplus1-query-fanout-644febe6\" alt=\"왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도.\" caption=\" \" zoom=\"true\" ::: 총 PreparedStatement는 이 요청에서 나간 쿼리 수를 보는 지표다. Hibernate가 SQL 한 건을 실행하려고 JDBC에서 얻은 문장 객체를 센 값이라, 실행 수와 항상 같지는 않다. | N | 초기화 Highlight 컬렉션 | 총 PreparedStatement | 지연 중앙값(5회) | 지연 최댓값(5회) | 시드 하이라이트 | |---:|---:|---:|---:|---:|---:| | 10 | 10 | 25 | 32.8 ms | 36.1 ms | 1,285 | | 100 | 100 | 222 | 85.9 ms | 108.3 ms | 1,961 | | 1,000 | 1,000 | 2,022 | 193.7 ms | 238.4 ms | 2,917 | 하이라이트 컬렉션 조회는 N이 10, 100, 1,000일 때 각각 10번, 100번, 1,000번 나갔다. 시드된 하이라이트는 1,285개, 1,961개, 2,917개다. 하이라이트 총량은 N에 정비례하지 않는데 조회 수는 N을 따라갔다. 총 PreparedStatement를 SQL 형태별로 가르면 다음과 같다. ```text label=\"총 PreparedStatement의 구성\" 총 PreparedStatement = content 1 + count 1 ← Spring Data Page 반환의 전체 건수 count + distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 + N Page ← ToOne, 아이템마다 달라 N번 + N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화 ``` 검산: `1 + 1 + 3 + 10 + 10 = 25` · `1 + 1 + 20 + 100 + 100 = 222` · `1 + 1 + 20 + 1000 + 1000 = 2022` count 쿼리는 findAllBy(Pageable)가 `Page<FeedItem>`을 반환해서 나온다. offset이 0이고 pageSize가 반환 건수보다 크면 Spring Data가 count를 건너뛴다. 이 측정은 pageSize와 반환 건수가 같아 count가 실제로 실행된다. ## 반복되는 하이라이트 조회 하나의 실행계획 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다. ```text label=\"Plan A — 대량 시드 직후, ANALYZE 실행 전\" Index Scan using ix_highlights_feed_items_created on highlights (cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1) Index Cond: (feed_item_id = '2b5b931f-...'::uuid) Buffers: shared hit=14 Planning Time: 0.086 ms Execution Time: 0.173 ms ``` 개별 조회는 `ix_highlights_feed_items_created` 인덱스로 처리되어 0.173 ms에 끝났다. 이 조회가 아이템마다 한 번씩 N번 나간다. N=1,000에서 피드 한 번 로딩은 194 ms였다. 쿼리 하나는 빠르지만 읽는 행 수는 제한하지 않는다. `SELECT * FROM highlights WHERE feed_item_id = ?`라 해당 아이템의 하이라이트를 한 번에 최대 500행까지 읽는다. 응답에 필요한 것은 최신 3개다. 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다. ## 초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것 | 지표 | 뜻 | 주의 | |---|---|---| | `getCollectionFetchCount()` | 초기화된 컬렉션 수 | 실행된 SELECT SQL 수가 아니다 | | `getPrepareStatementCount()` | 획득한 PreparedStatement 수 | SQL 실행 수와 항상 같지는 않다 | 이 구현에는 batch나 subselect 설정이 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다. ## page size를 고정해도 남는 요청당 왕복 page size를 20으로 고정하면 한 요청의 왕복도 20으로 고정된다. 그 20회가 트래픽에 곱해진다. ```text label=\"왕복이 처리량에 곱해지는 형태\" 추가 Highlight SELECT/초 ≈ page size × RPS 예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT ``` ## 측정 범위 이 수치는 단일 스레드 퍼시스턴스 통합 테스트에서 잰 값이다. SQL 형태와 데이터 규모에 따라 쿼리 수가 어떻게 늘어나는지를 봤다. 지연은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 값이다. 단일 스레드에 warm cache인 로컬 비교값이라 HTTP 종단 지연도 운영 p99도 아니다. 표본이 5개뿐이라 p50·p99가 아니라 중앙값과 최댓값으로 적었다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이다."
|
||
- group [ref=f4e125]:
|
||
- paragraph [ref=f4e126]: EVIDENCE
|
||
- heading "본문에 Asset 삽입" [level=3] [ref=f4e127]
|
||
- paragraph [ref=f4e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||
- generic [ref=f4e129]:
|
||
- generic [ref=f4e130]:
|
||
- generic [ref=f4e131]: 업로드 종류
|
||
- combobox "업로드 종류" [ref=f4e132]:
|
||
- option "이미지" [selected]
|
||
- option "다이어그램"
|
||
- option "첨부파일"
|
||
- button "Asset 업로드" [ref=f4e133]
|
||
- generic [ref=f4e134]:
|
||
- search [ref=f4e135]:
|
||
- generic [ref=f4e136]: Asset 검색
|
||
- generic [ref=f4e137]:
|
||
- searchbox "Asset 검색" [ref=f4e138]
|
||
- button "검색" [ref=f4e139]
|
||
- generic [ref=f4e140]:
|
||
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f4e141]
|
||
- generic [ref=f4e142]: 삽입할 때 크게 보기 허용
|
||
- status [ref=f4e143]: 삽입할 수 있는 Asset 9개
|
||
- list [ref=f4e144]:
|
||
- listitem [ref=f4e145]:
|
||
- button "nplus1-query-fanout-644febe6" [ref=f4e146]
|
||
- button "삭제" [ref=f4e147]
|
||
- listitem [ref=f4e148]:
|
||
- button "ap4-edge-trust-1cff2399" [ref=f4e149]
|
||
- button "삭제" [ref=f4e150]
|
||
- listitem [ref=f4e151]:
|
||
- button "ap3-csrf-split-501dd1f7" [ref=f4e152]
|
||
- button "삭제" [ref=f4e153]
|
||
- listitem [ref=f4e154]:
|
||
- button "ap3-bff-custody-82fa18bd" [ref=f4e155]
|
||
- button "삭제" [ref=f4e156]
|
||
- listitem [ref=f4e157]:
|
||
- button "ap2-split-custody-779cb791" [ref=f4e158]
|
||
- button "삭제" [ref=f4e159]
|
||
- listitem [ref=f4e160]:
|
||
- button "ap1-custody-v3-6e0376d2" [ref=f4e161]
|
||
- button "삭제" [ref=f4e162]
|
||
- listitem [ref=f4e163]:
|
||
- button "ap1-custody-v2-e110bd98" [ref=f4e164]
|
||
- button "삭제" [ref=f4e165]
|
||
- listitem [ref=f4e166]:
|
||
- button "ap1-credential-custody-f5e0c027" [ref=f4e167]
|
||
- button "삭제" [ref=f4e168]
|
||
- listitem [ref=f4e169]:
|
||
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f4e170]
|
||
- button "삭제" [ref=f4e171]
|
||
- region [ref=f4e172]:
|
||
- generic [ref=f4e173]:
|
||
- paragraph [ref=f4e174]: LIVE
|
||
- heading "즉시 미리보기" [level=2] [ref=f4e175]
|
||
- generic [ref=f4e178]:
|
||
- generic [ref=f4e179]:
|
||
- navigation "문서 경로" [ref=f4e180]:
|
||
- link "검증 기록" [ref=f4e181] [cursor=pointer]:
|
||
- /url: /explore/cases
|
||
- generic [aria-hidden] [ref=f4e182]: /
|
||
- generic [ref=f4e183]: JPA 피드 조회 성능
|
||
- generic [aria-hidden] [ref=f4e184]: /
|
||
- link "Liner N + 1문제" [ref=f4e185] [cursor=pointer]:
|
||
- /url: /projects/liner-n-plus-1
|
||
- heading "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1" [level=1] [ref=f4e186]
|
||
- paragraph [ref=f4e187]: 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 과정에 대한 내용이다. 매핑이 getHighlights()에 접근하는 시점에 N개의 쿼리가 추가로 나갔다.N=1,000이라면 추가 쿼리를 포함해 총 2,022개가 나갔다.
|
||
- region "문제와 결론" [ref=f4e188]:
|
||
- generic [ref=f4e189]:
|
||
- paragraph [ref=f4e190]: 문제
|
||
- paragraph [ref=f4e191]: 피드 API는 한 페이지에 하이라이트가 많아져도 쿼리 수가 따라 늘지 않아야 했다.최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤 Stream으로 순회하며 FeedSummary로 필드를 옮겼다.
|
||
- paragraph [ref=f4e192]: 이 코드에는 하이라이트를 도는 for가 없다. getHighlights().stream()만 있어서 쿼리가 몇 번 나가는지 코드만 보고는 알기 어려웠다.
|
||
- generic [ref=f4e193]:
|
||
- paragraph [ref=f4e194]: 결론
|
||
- paragraph [ref=f4e195]: 매핑이 getHighlights()에 접근하는 시점에 하이라이트 쿼리가 한 번씩 나갔다.N=10에서 10개, N=100에서 100개, N=1,000에서 1,000개였다.추가 쿼리를 포함한 총 쿼리는 25개, 222개, 2,022개였다.
|
||
- paragraph [ref=f4e196]: 코드에 반복문은 없다. Stream이 아이템을 하나씩 도는 동안 getHighlights() 접근이 N번 일어났고, 지연 로딩 컬렉션은 접근하는 순간 조회하므로 쿼리도 N번 나갔다.
|
||
- paragraph [ref=f4e197]: 문제는 두 가지다. 하나는 아이템 수만큼 쿼리가 늘어나는 것이다. 다른 하나는 그 쿼리 하나가 해당 아이템의 하이라이트를 전부 읽어 오는 것이다. 하이라이트가 가장 많은 아이템은 500행이었다.
|
||
- paragraph [ref=f4e198]: 쿼리 수는 테이블 전체 행 수를 따라 늘지 않았다. 한 요청에서 DTO로 조립하는 아이템 수를 따라 늘었다.
|
||
- generic [ref=f4e199]:
|
||
- generic [ref=f4e200]:
|
||
- term [ref=f4e201]: 검증 환경
|
||
- definition [ref=f4e202]:
|
||
- paragraph [ref=f4e203]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers, 클래스당 1개 공유)"
|
||
- paragraph [ref=f4e204]: "테스트 구성@DataJpaTest + @AutoConfigureTestDatabase(replace = NONE)spring.flyway.enabled : truespring.jpa.hibernate.ddl-auto : validatehibernate.generate_statistics : true"
|
||
- paragraph [ref=f4e205]: "측정 도구쿼리 수 : Hibernate Statistics지연 : System.nanoTime실행계획 : EXPLAIN (ANALYZE, BUFFERS)"
|
||
- paragraph [ref=f4e206]: "DB 캐시 : warm (shared read=0)"
|
||
- generic [ref=f4e207]:
|
||
- term [ref=f4e208]: 검증 데이터
|
||
- definition [ref=f4e209]:
|
||
- paragraph [ref=f4e210]: "1. FeedSeedFixture.seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만든다. user는 max(3, min(20, N/5+1))명, page는 N개, highlight는 순위 기반 편중 분포로 생성된다."
|
||
- paragraph [ref=f4e211]: 2. stats.clear() 직후 loadFeed(0, N)을 1회 실행하고 getCollectionFetchCount()와 getPrepareStatementCount()를 읽는다.
|
||
- paragraph [ref=f4e212]: 3. 지연은 따로 잰다. latencyMicros(n, 7, 2)로 7회 반복하고 앞 2회는 워밍업으로 버린다. 매 반복 전에 em.clear()를 호출한다.
|
||
- paragraph [ref=f4e213]: 4. 반복되는 하이라이트 자식 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
|
||
- generic [ref=f4e214]:
|
||
- term [ref=f4e215]: 기록
|
||
- definition [ref=f4e216]: 게시 게시 전 · 마지막 검증
|
||
- group [ref=f4e218]:
|
||
- generic "목차 · 측정한 loadFeed 구현" [ref=f4e219] [cursor=pointer]
|
||
- article [ref=f4e221]:
|
||
- region [ref=f4e222]:
|
||
- heading [level=2] [ref=f4e223]:
|
||
- link "측정한 loadFeed 구현 바로가기" [ref=f4e224] [cursor=pointer]:
|
||
- /url: "#측정한-loadfeed-구현"
|
||
- text: 측정한 loadFeed 구현
|
||
- generic [aria-hidden] [ref=f4e225]: "#"
|
||
- figure "JAVA ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드 복사" [ref=f4e226]:
|
||
- generic [ref=f4e227]:
|
||
- generic [ref=f4e228]: JAVA
|
||
- generic [ref=f4e229]: ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑
|
||
- button "코드 복사" [ref=f4e230] [cursor=pointer]: 복사
|
||
- region "FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드" [ref=f4e231]:
|
||
- code [ref=f4e232]: "@Override public List<FeedSummary> loadFeed(int page, int size) { return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() .map(fi -> new FeedSummary( fi.getId().toString(), fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) fi.getFirstHighlightedAt(), fi.getHighlights().stream() // 컬렉션 (지연 로딩) .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) .toList())) .toList(); }"
|
||
- region [ref=f4e234]:
|
||
- heading [level=2] [ref=f4e235]:
|
||
- link "N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement 바로가기" [ref=f4e236] [cursor=pointer]:
|
||
- /url: "#n에-따라-늘어난-컬렉션-초기화와-총-preparedstatement"
|
||
- text: N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement
|
||
- generic [aria-hidden] [ref=f4e237]: "#"
|
||
- figure [ref=f4e238]:
|
||
- button "nplus1-query-fanout-644febe6 이미지 크게 보기" [ref=f4e239]:
|
||
- img "왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도." [ref=f4e240]
|
||
- generic [ref=f4e241]: 크게 보기
|
||
- generic [ref=f4e242]: 왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도.
|
||
- paragraph [ref=f4e243]: 총 PreparedStatement는 이 요청에서 나간 쿼리 수를 보는 지표다. Hibernate가 SQL 한 건을 실행하려고 JDBC에서 얻은 문장 객체를 센 값이라, 실행 수와 항상 같지는 않다.
|
||
- region "표" [ref=f4e244]:
|
||
- table [ref=f4e245]:
|
||
- caption [ref=f4e246]
|
||
- rowgroup [ref=f4e247]:
|
||
- row [ref=f4e248]:
|
||
- columnheader "N" [ref=f4e249]
|
||
- columnheader "초기화 Highlight 컬렉션" [ref=f4e250]
|
||
- columnheader "총 PreparedStatement" [ref=f4e251]
|
||
- columnheader "지연 중앙값(5회)" [ref=f4e252]
|
||
- columnheader "지연 최댓값(5회)" [ref=f4e253]
|
||
- columnheader "시드 하이라이트" [ref=f4e254]
|
||
- rowgroup [ref=f4e255]:
|
||
- row [ref=f4e256]:
|
||
- cell "10" [ref=f4e257]
|
||
- cell "10" [ref=f4e258]
|
||
- cell "25" [ref=f4e259]
|
||
- cell "32.8 ms" [ref=f4e260]
|
||
- cell "36.1 ms" [ref=f4e261]
|
||
- cell "1,285" [ref=f4e262]
|
||
- row [ref=f4e263]:
|
||
- cell "100" [ref=f4e264]
|
||
- cell "100" [ref=f4e265]
|
||
- cell "222" [ref=f4e266]
|
||
- cell "85.9 ms" [ref=f4e267]
|
||
- cell "108.3 ms" [ref=f4e268]
|
||
- cell "1,961" [ref=f4e269]
|
||
- row [ref=f4e270]:
|
||
- cell "1,000" [ref=f4e271]
|
||
- cell "1,000" [ref=f4e272]
|
||
- cell "2,022" [ref=f4e273]
|
||
- cell "193.7 ms" [ref=f4e274]
|
||
- cell "238.4 ms" [ref=f4e275]
|
||
- cell "2,917" [ref=f4e276]
|
||
- paragraph [ref=f4e277]: 하이라이트 컬렉션 조회는 N이 10, 100, 1,000일 때 각각 10번, 100번, 1,000번 나갔다. 시드된 하이라이트는 1,285개, 1,961개, 2,917개다. 하이라이트 총량은 N에 정비례하지 않는데 조회 수는 N을 따라갔다.
|
||
- paragraph [ref=f4e278]: 총 PreparedStatement를 SQL 형태별로 가르면 다음과 같다.
|
||
- figure "TEXT ·총 PreparedStatement의 구성 코드 복사" [ref=f4e279]:
|
||
- generic [ref=f4e280]:
|
||
- generic [ref=f4e281]: TEXT
|
||
- generic [ref=f4e282]: ·총 PreparedStatement의 구성
|
||
- button "코드 복사" [ref=f4e283] [cursor=pointer]: 복사
|
||
- region "총 PreparedStatement의 구성 코드" [ref=f4e284]:
|
||
- code [ref=f4e285]: 총 PreparedStatement = content 1 + count 1 ← Spring Data Page 반환의 전체 건수 count + distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 + N Page ← ToOne, 아이템마다 달라 N번 + N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화
|
||
- paragraph [ref=f4e287]:
|
||
- text: "검산:"
|
||
- code [ref=f4e288]: 1 + 1 + 3 + 10 + 10 = 25
|
||
- text: ·
|
||
- code [ref=f4e289]: 1 + 1 + 20 + 100 + 100 = 222
|
||
- text: ·
|
||
- code [ref=f4e290]: 1 + 1 + 20 + 1000 + 1000 = 2022
|
||
- paragraph [ref=f4e291]:
|
||
- text: count 쿼리는 findAllBy(Pageable)가
|
||
- code [ref=f4e292]: Page<FeedItem>
|
||
- text: 을 반환해서 나온다. offset이 0이고 pageSize가 반환 건수보다 크면 Spring Data가 count를 건너뛴다. 이 측정은 pageSize와 반환 건수가 같아 count가 실제로 실행된다.
|
||
- region [ref=f4e293]:
|
||
- heading [level=2] [ref=f4e294]:
|
||
- link "반복되는 하이라이트 조회 하나의 실행계획 바로가기" [ref=f4e295] [cursor=pointer]:
|
||
- /url: "#반복되는-하이라이트-조회-하나의-실행계획"
|
||
- text: 반복되는 하이라이트 조회 하나의 실행계획
|
||
- generic [aria-hidden] [ref=f4e296]: "#"
|
||
- paragraph [ref=f4e297]: 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다.
|
||
- figure "TEXT ·Plan A — 대량 시드 직후, ANALYZE 실행 전 코드 복사" [ref=f4e298]:
|
||
- generic [ref=f4e299]:
|
||
- generic [ref=f4e300]: TEXT
|
||
- generic [ref=f4e301]: ·Plan A — 대량 시드 직후, ANALYZE 실행 전
|
||
- button "코드 복사" [ref=f4e302] [cursor=pointer]: 복사
|
||
- region "Plan A — 대량 시드 직후, ANALYZE 실행 전 코드" [ref=f4e303]:
|
||
- code [ref=f4e304]: "Index Scan using ix_highlights_feed_items_created on highlights (cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1) Index Cond: (feed_item_id = '2b5b931f-...'::uuid) Buffers: shared hit=14 Planning Time: 0.086 ms Execution Time: 0.173 ms"
|
||
- paragraph [ref=f4e306]:
|
||
- text: 개별 조회는
|
||
- code [ref=f4e307]: ix_highlights_feed_items_created
|
||
- text: 인덱스로 처리되어 0.173 ms에 끝났다. 이 조회가 아이템마다 한 번씩 N번 나간다. N=1,000에서 피드 한 번 로딩은 194 ms였다.
|
||
- paragraph [ref=f4e308]:
|
||
- text: 쿼리 하나는 빠르지만 읽는 행 수는 제한하지 않는다.
|
||
- code [ref=f4e309]: SELECT * FROM highlights WHERE feed_item_id = ?
|
||
- text: 라 해당 아이템의 하이라이트를 한 번에 최대 500행까지 읽는다. 응답에 필요한 것은 최신 3개다.
|
||
- paragraph [ref=f4e310]: 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
|
||
- region [ref=f4e311]:
|
||
- heading [level=2] [ref=f4e312]:
|
||
- link "초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것 바로가기" [ref=f4e313] [cursor=pointer]:
|
||
- /url: "#초기화-컬렉션-수와-preparedstatement-수가-뜻하는-것"
|
||
- text: 초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것
|
||
- generic [aria-hidden] [ref=f4e314]: "#"
|
||
- region "표" [ref=f4e315]:
|
||
- table [ref=f4e316]:
|
||
- caption [ref=f4e317]
|
||
- rowgroup [ref=f4e318]:
|
||
- row [ref=f4e319]:
|
||
- columnheader "지표" [ref=f4e320]
|
||
- columnheader "뜻" [ref=f4e321]
|
||
- columnheader "주의" [ref=f4e322]
|
||
- rowgroup [ref=f4e323]:
|
||
- row [ref=f4e324]:
|
||
- cell [ref=f4e325]:
|
||
- code [ref=f4e326]: getCollectionFetchCount()
|
||
- cell "초기화된 컬렉션 수" [ref=f4e327]
|
||
- cell "실행된 SELECT SQL 수가 아니다" [ref=f4e328]
|
||
- row [ref=f4e329]:
|
||
- cell [ref=f4e330]:
|
||
- code [ref=f4e331]: getPrepareStatementCount()
|
||
- cell "획득한 PreparedStatement 수" [ref=f4e332]
|
||
- cell "SQL 실행 수와 항상 같지는 않다" [ref=f4e333]
|
||
- paragraph [ref=f4e334]: 이 구현에는 batch나 subselect 설정이 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다.
|
||
- region [ref=f4e335]:
|
||
- heading [level=2] [ref=f4e336]:
|
||
- link "page size를 고정해도 남는 요청당 왕복 바로가기" [ref=f4e337] [cursor=pointer]:
|
||
- /url: "#page-size를-고정해도-남는-요청당-왕복"
|
||
- text: page size를 고정해도 남는 요청당 왕복
|
||
- generic [aria-hidden] [ref=f4e338]: "#"
|
||
- paragraph [ref=f4e339]: page size를 20으로 고정하면 한 요청의 왕복도 20으로 고정된다. 그 20회가 트래픽에 곱해진다.
|
||
- figure "TEXT ·왕복이 처리량에 곱해지는 형태 코드 복사" [ref=f4e340]:
|
||
- generic [ref=f4e341]:
|
||
- generic [ref=f4e342]: TEXT
|
||
- generic [ref=f4e343]: ·왕복이 처리량에 곱해지는 형태
|
||
- button "코드 복사" [ref=f4e344] [cursor=pointer]: 복사
|
||
- region "왕복이 처리량에 곱해지는 형태 코드" [ref=f4e345]:
|
||
- code [ref=f4e346]: 추가 Highlight SELECT/초 ≈ page size × RPS 예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT
|
||
- region [ref=f4e348]:
|
||
- heading [level=2] [ref=f4e349]:
|
||
- link "측정 범위 바로가기" [ref=f4e350] [cursor=pointer]:
|
||
- /url: "#측정-범위"
|
||
- text: 측정 범위
|
||
- generic [aria-hidden] [ref=f4e351]: "#"
|
||
- paragraph [ref=f4e352]: 이 수치는 단일 스레드 퍼시스턴스 통합 테스트에서 잰 값이다. SQL 형태와 데이터 규모에 따라 쿼리 수가 어떻게 늘어나는지를 봤다.
|
||
- paragraph [ref=f4e353]: 지연은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 값이다. 단일 스레드에 warm cache인 로컬 비교값이라 HTTP 종단 지연도 운영 p99도 아니다.
|
||
- paragraph [ref=f4e380]: 표본이 5개뿐이라 p50·p99가 아니라 중앙값과 최댓값으로 적었다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이다.
|
||
- region [ref=f4e354]:
|
||
- paragraph [ref=f4e355]: Next
|
||
- heading "다음에 읽을 것" [level=2] [ref=f4e356]
|
||
- list [ref=f4e357]:
|
||
- listitem [ref=f4e358]:
|
||
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f4e359] [cursor=pointer]:
|
||
- /url: /cases/eager-toone-nplus1-without-access
|
||
- generic [ref=f4e360]: 검증 기록
|
||
- generic [ref=f4e361]:
|
||
- strong [ref=f4e362]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
||
- paragraph [aria-hidden] [ref=f4e363]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
|
||
- generic [aria-hidden] [ref=f4e364]: ↗
|
||
- complementary [ref=f4e365]:
|
||
- heading "작업 상태" [level=2] [ref=f4e366]
|
||
- status "편집 상태" [ref=f4e367]: 저장 충돌
|
||
- generic [ref=f4e368]:
|
||
- generic [ref=f4e369]:
|
||
- term [ref=f4e370]: 저장 버전
|
||
- definition [ref=f4e371]: "7"
|
||
- generic [ref=f4e372]:
|
||
- term [ref=f4e373]: 종류
|
||
- definition [ref=f4e374]: 검증 기록
|
||
- alert [ref=f4e381]: 서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.
|
||
- generic [ref=f4e376]:
|
||
- button "저장" [disabled] [ref=f4e377]
|
||
- button "게시" [disabled] [ref=f4e378]
|
||
- paragraph [ref=f4e379]: 저장된 version이 더 최신입니다 |