- 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 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`을 반환해서 나온다. 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 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 - 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]