Files
document-haness/.playwright-mcp/page-2026-09-04T02-45-11-033Z.yml

572 lines
48 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
- 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]:
- region [ref=f3e21]:
- generic [ref=f3e22]:
- paragraph [ref=f3e23]: CASE · VERSION 4
- heading "문서 편집" [level=1] [ref=f3e24]
- paragraph [ref=f3e25]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
- region [ref=f3e26]:
- generic [ref=f3e27]:
- paragraph [ref=f3e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f3e29]
- generic [ref=f3e30]:
- generic [ref=f3e31]:
- generic [ref=f3e32]: 제목
- textbox "제목" [ref=f3e33]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
- generic [ref=f3e34]:
- generic [ref=f3e35]: slug
- textbox "slug" [ref=f3e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-nplus1-dto-mapping
- generic [ref=f3e37]:
- generic [ref=f3e38]: 요약
- textbox "요약" [ref=f3e39]: "Feed Item을 엔티티로 조회한 뒤 Stream으로 `FeedSummary`를 만드는 과정에서 발생한다. DTO를 만드는 과정에서 `getHighlights()`에 접근하면 LAZY로 설정된 Highlight 컬렉션이 초기화된다. 실제로 N=10, 100, 1,000에서 초기화된 Highlight 컬렉션도 각각 10개, 100개, 1,000개였다. N=1,000에서는 총 쿼리가 2,022개 발생했다."
- generic [aria-hidden] [ref=f3e40]: 목록 카드에는 약 90자까지 보입니다 · 230 / 2000
- generic [ref=f3e41]:
- generic [ref=f3e42]: Topic
- combobox "Topic" [ref=f3e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f3e44]:
- generic [ref=f3e45]: Project
- combobox "Project" [ref=f3e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f3e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f3e48]:
- generic [ref=f3e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f3e51]
- generic [ref=f3e52]: 파생 쿼리 그대로
- generic [ref=f3e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f3e54]
- generic [ref=f3e55]: 컬렉션 fetch join
- generic [ref=f3e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [ref=f3e57]
- generic [ref=f3e58]: fetch join + 페이징
- group "관계" [ref=f3e59]:
- generic [ref=f3e61]:
- generic [ref=f3e62]:
- generic [ref=f3e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f3e64]:
- 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=f3e65]:
- generic [ref=f3e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f3e67]: 이 측정에서 사용한 지표와 회계 항등식이다.
- generic [aria-hidden] [ref=f3e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e69]:
- button "위로" [disabled] [ref=f3e70]
- button "아래로" [ref=f3e71]
- button "삭제" [ref=f3e72]
- generic [ref=f3e73]:
- generic [ref=f3e74]:
- generic [ref=f3e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f3e76]:
- 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=f3e77]:
- generic [ref=f3e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f3e79]: 컬렉션이 지연 로딩이라 접근 시점에 조회가 나갔다.
- generic [aria-hidden] [ref=f3e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e81]:
- button "위로" [ref=f3e82]
- button "아래로" [ref=f3e83]
- button "삭제" [ref=f3e84]
- generic [ref=f3e85]:
- generic [ref=f3e86]:
- generic [ref=f3e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f3e88]:
- 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=f3e89]:
- generic [ref=f3e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f3e91]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
- generic [aria-hidden] [ref=f3e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e93]:
- button "위로" [ref=f3e94]
- button "아래로" [disabled] [ref=f3e95]
- button "삭제" [ref=f3e96]
- button "관계 추가" [ref=f3e97]
- region [ref=f3e98]:
- generic [ref=f3e99]:
- paragraph [ref=f3e100]: CASE
- heading "문제와 검증" [level=2] [ref=f3e101]
- generic [ref=f3e102]:
- generic [ref=f3e103]:
- generic [ref=f3e104]: 문제
- textbox "문제" [ref=f3e105]: 피드 API는 페이지에 하이라이트가 아무리 많아도 조회량이 비례해 늘지 않아야 했다. 최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤 Stream으로 순회하며 FeedSummary로 필드를 옮겼다. 이 코드에는 하이라이트를 위한 명시적인 for가 없고 getHighlights().stream()만 있다. 조회가 몇 번 나가는지 코드만 보고 알기 어려웠다.
- generic [ref=f3e106]:
- generic [ref=f3e107]: 결론
- textbox "결론" [ref=f3e108]: 초기화된 Highlight 컬렉션 수가 N과 정확히 같았다. N=10에서 10, N=100에서 100, N=1,000에서 1,000이었다. 총 PreparedStatement는 25, 222, 2,022였다. 반복문이 사라진 것이 아니라 Stream 뒤에 숨었다. 지연 로딩 컬렉션은 접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생한다. 같은 기준선에 두 가지 위반이 함께 있었다. 부모 수에 비례하는 왕복(N+1)과, 한 번의 왕복에서 해당 아이템의 하이라이트를 전부 읽는 과조회다. 가장 많은 아이템은 500행이었다. 증가 기준은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수였다.
- generic [ref=f3e109]:
- generic [ref=f3e110]: 검증 환경
- textbox "검증 환경" [ref=f3e111]: "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=f3e112]:
- generic [ref=f3e113]: 재현 조건
- textbox "재현 조건" [ref=f3e114]: "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=f3e115]:
- generic [ref=f3e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f3e117]
- generic [ref=f3e118]:
- generic [ref=f3e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f3e120]:
- button "코드" [ref=f3e121] [cursor=pointer]
- button "표" [ref=f3e122] [cursor=pointer]
- button "목록" [ref=f3e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f3e124]: "## 기준선 구현 ```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에 비례한다 | 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 | 초기화 컬렉션 수는 기울기 1의 직선이다. 시드 하이라이트 총량은 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 ``` 개별 조회는 인덱스로 처리되고 0.173 ms다. 이 빠른 쿼리가 N번 반복되어 N=1,000에서 피드 한 번 로딩이 194 ms가 됐다. 이 실행계획을 최적이라고 읽으면 안 된다. 이 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 한 번에 최대 500행을 읽는다. 응답에 필요한 것은 최신 3개인데 결과량을 제한하지 못한다. 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고 아직 검증하지 않았다. ## 두 지표를 같은 것으로 읽지 않는다 | 지표 | 뜻 | 주의 | |---|---|---| | `getCollectionFetchCount()` | 초기화된 컬렉션 수 | 실행된 SELECT SQL 수가 아니다 | | `getPrepareStatementCount()` | 획득한 PreparedStatement 수 | SQL 실행 수와 항상 같지는 않다 | 현재 기준선에는 batch나 subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다. ## 요청당 왕복이 처리량에 곱해진다 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=f3e125]:
- paragraph [ref=f3e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f3e127]
- paragraph [ref=f3e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f3e129]:
- generic [ref=f3e130]:
- generic [ref=f3e131]: 업로드 종류
- combobox "업로드 종류" [ref=f3e132]:
- option "이미지"
- option "다이어그램" [selected]
- option "첨부파일"
- button "Asset 업로드" [ref=f3e133]
- generic [ref=f3e134]:
- search [ref=f3e135]:
- generic [ref=f3e136]: Asset 검색
- generic [ref=f3e137]:
- searchbox "Asset 검색" [ref=f3e138]
- button "검색" [ref=f3e139]
- generic [ref=f3e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f3e141]
- generic [ref=f3e142]: 삽입할 때 크게 보기 허용
- status [ref=f3e143]: 삽입할 수 있는 Asset 8개
- list [ref=f3e144]:
- listitem [ref=f3e145]:
- button "ap4-edge-trust-1cff2399" [ref=f3e146]
- button "삭제" [ref=f3e147]
- listitem [ref=f3e148]:
- button "ap3-csrf-split-501dd1f7" [ref=f3e149]
- button "삭제" [ref=f3e150]
- listitem [ref=f3e151]:
- button "ap3-bff-custody-82fa18bd" [ref=f3e152]
- button "삭제" [ref=f3e153]
- listitem [ref=f3e154]:
- button "ap2-split-custody-779cb791" [ref=f3e155]
- button "삭제" [ref=f3e156]
- listitem [ref=f3e157]:
- button "ap1-custody-v3-6e0376d2" [ref=f3e158]
- button "삭제" [ref=f3e159]
- listitem [ref=f3e160]:
- button "ap1-custody-v2-e110bd98" [ref=f3e161]
- button "삭제" [ref=f3e162]
- listitem [ref=f3e163]:
- button "ap1-credential-custody-f5e0c027" [ref=f3e164]
- button "삭제" [ref=f3e165]
- listitem [ref=f3e166]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f3e167]
- button "삭제" [ref=f3e168]
- dialog [ref=f3e374]:
- generic [ref=f3e375]:
- paragraph [ref=f3e376]: ASSET UPLOAD
- heading "Asset 업로드" [level=2] [ref=f3e377]
- paragraph [ref=f3e378]: 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 아니면 대체 텍스트가 필요합니다.
- generic [ref=f3e379]:
- generic [ref=f3e380]: Asset 파일
- button "Asset 파일" [active] [ref=f3e381]
- generic [ref=f3e382]:
- checkbox "장식용 이미지 (대체 텍스트 없음)" [ref=f3e383]
- generic [ref=f3e384]: 장식용 이미지 (대체 텍스트 없음)
- generic [ref=f3e385]:
- generic [ref=f3e386]: 대체 텍스트
- textbox "대체 텍스트" [ref=f3e387]
- status "업로드 상태" [ref=f3e388]
- generic [ref=f3e389]:
- button "닫기" [ref=f3e390]
- button "업로드" [ref=f3e391]
- region [ref=f3e169]:
- generic [ref=f3e170]:
- paragraph [ref=f3e171]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f3e172]
- generic [ref=f3e175]:
- generic [ref=f3e176]:
- navigation "문서 경로" [ref=f3e177]:
- link "검증 기록" [ref=f3e178] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f3e179]: /
- generic [ref=f3e180]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f3e181]: /
- link "Liner N + 1문제" [ref=f3e182] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1" [level=1] [ref=f3e183]
- paragraph [ref=f3e184]:
- text: Feed Item을 엔티티로 조회한 뒤 Stream으로
- code [ref=f3e185]: FeedSummary
- text: 를 만드는 과정에서 발생한다.
- paragraph [ref=f3e186]:
- text: DTO를 만드는 과정에서
- code [ref=f3e187]: getHighlights()
- text: 에 접근하면 LAZY로 설정된 Highlight 컬렉션이 초기화된다. 실제로 N=10, 100, 1,000에서 초기화된 Highlight 컬렉션도 각각 10개, 100개, 1,000개였다.
- paragraph [ref=f3e188]: N=1,000에서는 총 쿼리가 2,022개 발생했다.
- region "문제와 결론" [ref=f3e189]:
- generic [ref=f3e190]:
- paragraph [ref=f3e191]: 문제
- paragraph [ref=f3e192]: 피드 API는 페이지에 하이라이트가 아무리 많아도 조회량이 비례해 늘지 않아야 했다. 최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤 Stream으로 순회하며 FeedSummary로 필드를 옮겼다.
- paragraph [ref=f3e193]: 이 코드에는 하이라이트를 위한 명시적인 for가 없고 getHighlights().stream()만 있다. 조회가 몇 번 나가는지 코드만 보고 알기 어려웠다.
- generic [ref=f3e194]:
- paragraph [ref=f3e195]: 결론
- paragraph [ref=f3e196]: 초기화된 Highlight 컬렉션 수가 N과 정확히 같았다. N=10에서 10, N=100에서 100, N=1,000에서 1,000이었다. 총 PreparedStatement는 25, 222, 2,022였다.
- paragraph [ref=f3e197]: 반복문이 사라진 것이 아니라 Stream 뒤에 숨었다. 지연 로딩 컬렉션은 접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생한다.
- paragraph [ref=f3e198]: 같은 기준선에 두 가지 위반이 함께 있었다. 부모 수에 비례하는 왕복(N+1)과, 한 번의 왕복에서 해당 아이템의 하이라이트를 전부 읽는 과조회다. 가장 많은 아이템은 500행이었다.
- paragraph [ref=f3e199]: 증가 기준은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수였다.
- generic [ref=f3e200]:
- generic [ref=f3e201]:
- term [ref=f3e202]: 검증 환경
- definition [ref=f3e203]:
- paragraph [ref=f3e204]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers, 클래스당 1개 공유)"
- paragraph [ref=f3e205]: "테스트 구성@DataJpaTest + @AutoConfigureTestDatabase(replace = NONE)spring.flyway.enabled : truespring.jpa.hibernate.ddl-auto : validatehibernate.generate_statistics : true"
- paragraph [ref=f3e206]: "측정 도구쿼리 수 : Hibernate Statistics지연 : System.nanoTime실행계획 : EXPLAIN (ANALYZE, BUFFERS)"
- paragraph [ref=f3e207]: "DB 캐시 : warm (shared read=0)"
- generic [ref=f3e208]:
- term [ref=f3e209]: 검증 데이터
- definition [ref=f3e210]:
- paragraph [ref=f3e211]: "1. FeedSeedFixture.seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만든다. user는 max(3, min(20, N/5+1))명, page는 N개, highlight는 순위 기반 편중 분포로 생성된다."
- paragraph [ref=f3e212]: 2. stats.clear() 직후 loadFeed(0, N)을 1회 실행하고 getCollectionFetchCount()와 getPrepareStatementCount()를 읽는다.
- paragraph [ref=f3e213]: 3. 지연은 별도로 latencyMicros(n, 7, 2)로 7회 반복하고 앞 2회를 워밍업으로 버린다. 매 반복 전에 em.clear()를 호출한다.
- paragraph [ref=f3e214]: 4. 반복되는 하이라이트 자식 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
- generic [ref=f3e215]:
- term [ref=f3e216]: 기록
- definition [ref=f3e217]: 게시 게시 전 · 마지막 검증
- group [ref=f3e219]:
- generic "목차 · 기준선 구현" [ref=f3e220] [cursor=pointer]
- article [ref=f3e222]:
- region [ref=f3e223]:
- heading [level=2] [ref=f3e224]:
- link "기준선 구현 바로가기" [ref=f3e225] [cursor=pointer]:
- /url: "#기준선-구현"
- text: 기준선 구현
- generic [aria-hidden] [ref=f3e226]: "#"
- figure "JAVA ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드 복사" [ref=f3e227]:
- generic [ref=f3e228]:
- generic [ref=f3e229]: JAVA
- generic [ref=f3e230]: ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑
- button "코드 복사" [ref=f3e231] [cursor=pointer]: 복사
- region "FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드" [ref=f3e232]:
- code [ref=f3e233]: "@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=f3e235]:
- heading [level=2] [ref=f3e236]:
- link "조회량이 N에 비례한다 바로가기" [ref=f3e237] [cursor=pointer]:
- /url: "#조회량이-n에-비례한다"
- text: 조회량이 N에 비례한다
- generic [aria-hidden] [ref=f3e238]: "#"
- region "표" [ref=f3e239]:
- table [ref=f3e240]:
- caption [ref=f3e241]
- rowgroup [ref=f3e242]:
- row [ref=f3e243]:
- columnheader "N" [ref=f3e244]
- columnheader "초기화 Highlight 컬렉션" [ref=f3e245]
- columnheader "총 PreparedStatement" [ref=f3e246]
- columnheader "지연 중앙값(5회)" [ref=f3e247]
- columnheader "지연 최댓값(5회)" [ref=f3e248]
- columnheader "시드 하이라이트" [ref=f3e249]
- rowgroup [ref=f3e250]:
- row [ref=f3e251]:
- cell "10" [ref=f3e252]
- cell "10" [ref=f3e253]
- cell "25" [ref=f3e254]
- cell "32.8 ms" [ref=f3e255]
- cell "36.1 ms" [ref=f3e256]
- cell "1,285" [ref=f3e257]
- row [ref=f3e258]:
- cell "100" [ref=f3e259]
- cell "100" [ref=f3e260]
- cell "222" [ref=f3e261]
- cell "85.9 ms" [ref=f3e262]
- cell "108.3 ms" [ref=f3e263]
- cell "1,961" [ref=f3e264]
- row [ref=f3e265]:
- cell "1,000" [ref=f3e266]
- cell "1,000" [ref=f3e267]
- cell "2,022" [ref=f3e268]
- cell "193.7 ms" [ref=f3e269]
- cell "238.4 ms" [ref=f3e270]
- cell "2,917" [ref=f3e271]
- paragraph [ref=f3e272]: 초기화 컬렉션 수는 기울기 1의 직선이다. 시드 하이라이트 총량은 N에 정비례하지 않는데도 조회 수는 N을 따라간다.
- paragraph [ref=f3e273]: 총 PreparedStatement를 SQL 형태별로 가르면 다음과 같다.
- figure "TEXT ·총 PreparedStatement의 구성 코드 복사" [ref=f3e274]:
- generic [ref=f3e275]:
- generic [ref=f3e276]: TEXT
- generic [ref=f3e277]: ·총 PreparedStatement의 구성
- button "코드 복사" [ref=f3e278] [cursor=pointer]: 복사
- region "총 PreparedStatement의 구성 코드" [ref=f3e279]:
- code [ref=f3e280]: 총 PreparedStatement = content 1 + count 1 ← Spring Data Page 반환의 전체 건수 count + distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 + N Page ← ToOne, 아이템마다 달라 N번 + N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화
- paragraph [ref=f3e282]:
- text: "검산:"
- code [ref=f3e283]: 1 + 1 + 3 + 10 + 10 = 25
- text: ·
- code [ref=f3e284]: 1 + 1 + 20 + 100 + 100 = 222
- text: ·
- code [ref=f3e285]: 1 + 1 + 20 + 1000 + 1000 = 2022
- paragraph [ref=f3e286]:
- text: count 쿼리는 findAllBy(Pageable)가
- code [ref=f3e287]: Page<FeedItem>
- text: 을 반환해서 나온다. offset이 0이고 pageSize가 반환 건수보다 크면 Spring Data가 count를 건너뛴다. 이 측정은 pageSize와 반환 건수가 같아 count가 실제로 실행된다.
- region [ref=f3e288]:
- heading [level=2] [ref=f3e289]:
- link "각 쿼리는 빠른데 느리다 바로가기" [ref=f3e290] [cursor=pointer]:
- /url: "#각-쿼리는-빠른데-느리다"
- text: 각 쿼리는 빠른데 느리다
- generic [aria-hidden] [ref=f3e291]: "#"
- paragraph [ref=f3e292]: 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다.
- figure "TEXT ·Plan A — 대량 시드 직후, ANALYZE 실행 전 코드 복사" [ref=f3e293]:
- generic [ref=f3e294]:
- generic [ref=f3e295]: TEXT
- generic [ref=f3e296]: ·Plan A — 대량 시드 직후, ANALYZE 실행 전
- button "코드 복사" [ref=f3e297] [cursor=pointer]: 복사
- region "Plan A — 대량 시드 직후, ANALYZE 실행 전 코드" [ref=f3e298]:
- code [ref=f3e299]: "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=f3e301]: 개별 조회는 인덱스로 처리되고 0.173 ms다. 이 빠른 쿼리가 N번 반복되어 N=1,000에서 피드 한 번 로딩이 194 ms가 됐다.
- paragraph [ref=f3e302]:
- text: 이 실행계획을 최적이라고 읽으면 안 된다. 이 쿼리는
- code [ref=f3e303]: SELECT * FROM highlights WHERE feed_item_id = ?
- text: 라 한 번에 최대 500행을 읽는다. 응답에 필요한 것은 최신 3개인데 결과량을 제한하지 못한다.
- paragraph [ref=f3e304]: 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고 아직 검증하지 않았다.
- region [ref=f3e305]:
- heading [level=2] [ref=f3e306]:
- link "두 지표를 같은 것으로 읽지 않는다 바로가기" [ref=f3e307] [cursor=pointer]:
- /url: "#두-지표를-같은-것으로-읽지-않는다"
- text: 두 지표를 같은 것으로 읽지 않는다
- generic [aria-hidden] [ref=f3e308]: "#"
- region "표" [ref=f3e309]:
- table [ref=f3e310]:
- caption [ref=f3e311]
- rowgroup [ref=f3e312]:
- row [ref=f3e313]:
- columnheader "지표" [ref=f3e314]
- columnheader "뜻" [ref=f3e315]
- columnheader "주의" [ref=f3e316]
- rowgroup [ref=f3e317]:
- row [ref=f3e318]:
- cell [ref=f3e319]:
- code [ref=f3e320]: getCollectionFetchCount()
- cell "초기화된 컬렉션 수" [ref=f3e321]
- cell "실행된 SELECT SQL 수가 아니다" [ref=f3e322]
- row [ref=f3e323]:
- cell [ref=f3e324]:
- code [ref=f3e325]: getPrepareStatementCount()
- cell "획득한 PreparedStatement 수" [ref=f3e326]
- cell "SQL 실행 수와 항상 같지는 않다" [ref=f3e327]
- paragraph [ref=f3e328]: 현재 기준선에는 batch나 subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다.
- region [ref=f3e329]:
- heading [level=2] [ref=f3e330]:
- link "요청당 왕복이 처리량에 곱해진다 바로가기" [ref=f3e331] [cursor=pointer]:
- /url: "#요청당-왕복이-처리량에-곱해진다"
- text: 요청당 왕복이 처리량에 곱해진다
- generic [aria-hidden] [ref=f3e332]: "#"
- paragraph [ref=f3e333]: page size를 20으로 고정하면 한 요청의 왕복도 20으로 고정된다. 그 20회가 트래픽에 곱해진다.
- figure "TEXT ·왕복이 처리량에 곱해지는 형태 코드 복사" [ref=f3e334]:
- generic [ref=f3e335]:
- generic [ref=f3e336]: TEXT
- generic [ref=f3e337]: ·왕복이 처리량에 곱해지는 형태
- button "코드 복사" [ref=f3e338] [cursor=pointer]: 복사
- region "왕복이 처리량에 곱해지는 형태 코드" [ref=f3e339]:
- code [ref=f3e340]: 추가 Highlight SELECT/초 ≈ page size × RPS 예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT
- region [ref=f3e342]:
- heading [level=2] [ref=f3e343]:
- link "측정 범위 바로가기" [ref=f3e344] [cursor=pointer]:
- /url: "#측정-범위"
- text: 측정 범위
- generic [aria-hidden] [ref=f3e345]: "#"
- paragraph [ref=f3e346]: 이 수치는 단일 스레드 퍼시스턴스 통합 테스트에서 SQL 형태와 데이터 규모에 따른 조회 횟수의 증가 형태를 측정한 것이다. 지연은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 단일 스레드·warm-cache 로컬 비교값이라 HTTP 종단 지연도 운영 p99도 아니다.
- paragraph [ref=f3e347]: 표본이 5개뿐이라 p50·p99가 아니라 중앙값과 최댓값으로 적었다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이다.
- region [ref=f3e348]:
- paragraph [ref=f3e349]: Next
- heading "다음에 읽을 것" [level=2] [ref=f3e350]
- list [ref=f3e351]:
- listitem [ref=f3e352]:
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f3e353] [cursor=pointer]:
- /url: /cases/eager-toone-nplus1-without-access
- generic [ref=f3e354]: 검증 기록
- generic [ref=f3e355]:
- strong [ref=f3e356]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
- paragraph [aria-hidden] [ref=f3e357]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
- generic [aria-hidden] [ref=f3e358]:
- complementary [ref=f3e359]:
- heading "작업 상태" [level=2] [ref=f3e360]
- status "편집 상태" [ref=f3e361]: 저장됨
- generic [ref=f3e362]:
- generic [ref=f3e363]:
- term [ref=f3e364]: 저장 버전
- definition [ref=f3e365]: "4"
- generic [ref=f3e366]:
- term [ref=f3e367]: 종류
- definition [ref=f3e368]: 검증 기록
- paragraph [ref=f3e369]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f3e370]:
- button "저장" [disabled] [ref=f3e371]
- button "게시" [ref=f3e372]
- paragraph [ref=f3e373]