628 lines
50 KiB
YAML
628 lines
50 KiB
YAML
- generic [ref=f13e3]:
|
|
- link "본문으로 건너뛰기" [ref=f13e4] [cursor=pointer]:
|
|
- /url: "#main-content"
|
|
- banner [ref=f13e5]:
|
|
- generic [ref=f13e6]:
|
|
- link "TechLog Studio" [ref=f13e7] [cursor=pointer]:
|
|
- /url: /studio
|
|
- text: TechLog
|
|
- generic [ref=f13e8]: Studio
|
|
- navigation "Studio 주 탐색" [ref=f13e10]:
|
|
- link "작업본" [ref=f13e11] [cursor=pointer]:
|
|
- /url: /studio/documents
|
|
- link "게시 기록" [ref=f13e12] [cursor=pointer]:
|
|
- /url: /studio/publications
|
|
- link "새 문서" [ref=f13e13] [cursor=pointer]:
|
|
- /url: /studio/documents/new
|
|
- link "주제·프로젝트" [ref=f13e14] [cursor=pointer]:
|
|
- /url: /studio/taxonomy
|
|
- link "릴리즈" [ref=f13e15] [cursor=pointer]:
|
|
- /url: /studio/releases
|
|
- link "공개 사이트 보기" [ref=f13e16] [cursor=pointer]:
|
|
- /url: /
|
|
- button "로그아웃" [ref=f13e17]
|
|
- main [ref=f13e18]:
|
|
- generic [ref=f13e19]:
|
|
- generic [ref=f13e20]:
|
|
- region [ref=f13e21]:
|
|
- generic [ref=f13e22]:
|
|
- paragraph [ref=f13e23]: CASE · VERSION 37
|
|
- heading "문서 편집" [level=1] [ref=f13e24]
|
|
- paragraph [ref=f13e25]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
|
- region [ref=f13e26]:
|
|
- generic [ref=f13e27]:
|
|
- paragraph [ref=f13e28]: DOCUMENT
|
|
- heading "기본 정보" [level=2] [ref=f13e29]
|
|
- generic [ref=f13e30]:
|
|
- generic [ref=f13e31]:
|
|
- generic [ref=f13e32]: 제목
|
|
- textbox "제목" [ref=f13e33]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
|
- generic [ref=f13e34]:
|
|
- generic [ref=f13e35]: slug
|
|
- textbox "slug" [ref=f13e36]:
|
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
|
- text: eager-toone-nplus1-without-access
|
|
- generic [ref=f13e37]:
|
|
- generic [ref=f13e38]: 요약
|
|
- textbox "요약" [ref=f13e39]: "`@ManyToOne`의 기본값인 `EAGER`는 연관 엔티티를 함께 로딩해야 한다는 계약이지, 데이터를 `JOIN`으로 가져온다는 보장은 없다. 실제 파생 쿼리에서는 연관 엔티티를 가져오기 위한 2차 `SELECT`가 행마다 발생했다. LAZY인 highlights도 접근하는 순간 N번 조회. N+1 문제는 fetch 타입이 아니라 조회 방식에서 발생하게 된다."
|
|
- generic [aria-hidden] [ref=f13e40]: 목록 카드에는 약 90자까지 보입니다 · 207 / 2000
|
|
- generic [ref=f13e41]:
|
|
- generic [ref=f13e42]: Topic
|
|
- combobox "Topic" [ref=f13e43]:
|
|
- option "선택하지 않음"
|
|
- option "JPA 피드 조회 성능" [selected]
|
|
- option "OAuth/OIDC 인증 경계"
|
|
- generic [ref=f13e44]:
|
|
- generic [ref=f13e45]: Project
|
|
- combobox "Project" [ref=f13e46]:
|
|
- option "미지정"
|
|
- option "Backend Clean Architecture"
|
|
- option "KeyCloak Patterns"
|
|
- option "Liner N + 1문제" [selected]
|
|
- status [ref=f13e47]
|
|
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f13e48]:
|
|
- generic [ref=f13e50] [cursor=pointer]:
|
|
- checkbox "파생 쿼리 그대로" [checked] [ref=f13e51]
|
|
- generic [ref=f13e52]: 파생 쿼리 그대로
|
|
- generic [ref=f13e53] [cursor=pointer]:
|
|
- checkbox "컬렉션 fetch join" [ref=f13e54]
|
|
- generic [ref=f13e55]: 컬렉션 fetch join
|
|
- generic [ref=f13e56] [cursor=pointer]:
|
|
- checkbox "fetch join + 페이징" [ref=f13e57]
|
|
- generic [ref=f13e58]: fetch join + 페이징
|
|
- group "관계" [ref=f13e59]:
|
|
- generic [ref=f13e61]:
|
|
- generic [ref=f13e62]:
|
|
- generic [ref=f13e63]: 관계 1 대상
|
|
- combobox "관계 1 대상" [ref=f13e64]:
|
|
- 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 선택 기준"
|
|
- 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=f13e65]:
|
|
- generic [ref=f13e66]: 관계 1 이유
|
|
- textbox "관계 1 이유" [ref=f13e67]: 이 현상을 기준으로 정리한 기록이다.
|
|
- generic [aria-hidden] [ref=f13e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
|
- generic [ref=f13e69]:
|
|
- button "위로" [disabled] [ref=f13e70]
|
|
- button "아래로" [ref=f13e71]
|
|
- button "삭제" [ref=f13e72]
|
|
- generic [ref=f13e73]:
|
|
- generic [ref=f13e74]:
|
|
- generic [ref=f13e75]: 관계 2 대상
|
|
- combobox "관계 2 대상" [ref=f13e76]:
|
|
- 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 선택 기준"
|
|
- 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=f13e77]:
|
|
- generic [ref=f13e78]: 관계 2 이유
|
|
- textbox "관계 2 이유" [ref=f13e79]: 엔티티별 fetch 통계로 확인한 방법이다.
|
|
- generic [aria-hidden] [ref=f13e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
|
- generic [ref=f13e81]:
|
|
- button "위로" [ref=f13e82]
|
|
- button "아래로" [disabled] [ref=f13e83]
|
|
- button "삭제" [ref=f13e84]
|
|
- button "관계 추가" [ref=f13e85]
|
|
- region [ref=f13e86]:
|
|
- generic [ref=f13e87]:
|
|
- paragraph [ref=f13e88]: CASE
|
|
- heading "문제와 검증" [level=2] [ref=f13e89]
|
|
- generic [ref=f13e90]:
|
|
- generic [ref=f13e91]:
|
|
- generic [ref=f13e92]: 문제
|
|
- textbox "문제" [ref=f13e93]: "컬렉션을 조회하는 쿼리를 제외하고도 추가 쿼리가 계속 발생했다. 데이터 수를 늘려 확인해 보니 추가 쿼리 수도 13개에서 120개, 1,020개로 함께 증가했다. 엔티티에는 fetch 방식을 따로 지정하지 않아 JPA 기본값을 사용하고 있었다. `@ManyToOne`은 `EAGER`, `@OneToMany`는 `LAZY`였다."
|
|
- generic [ref=f13e94]:
|
|
- generic [ref=f13e95]: 결론
|
|
- textbox "결론" [ref=f13e96]: "같은 `@ManyToOne(EAGER)`라도 추가 쿼리 수는 달랐다. `Page`는 아이템마다 다른 대상을 참조해 N번 조회됐지만, `User`는 같은 대상을 재사용하면서 1차 캐시 덕분에 조회 수가 제한됐다. `EAGER`는 연관 객체를 직접 사용하지 않아도 추가 조회를 발생시켰고, `LAZY`도 실제 접근하는 순간 N번 조회됐다. 결국 N+1은 `EAGER`나 `LAZY` 자체보다 연관 데이터를 개별 쿼리로 조회하는 방식과 서로 다른 연관 대상의 수에 따라 문제가 발생 했다."
|
|
- generic [ref=f13e97]:
|
|
- generic [ref=f13e98]: 검증 환경
|
|
- textbox "검증 환경" [ref=f13e99]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) 시드 feed_item : N page : N (아이템당 1개, 전부 다름) user : max(3, min(20, N/5+1))"
|
|
- generic [ref=f13e100]:
|
|
- generic [ref=f13e101]: 재현 조건
|
|
- textbox "재현 조건" [ref=f13e102]: "1. 데이터 수에 따른 차이를 확인하기 위해 Feed Item을 각각 10개, 100개, 1,000개 생성한 뒤 전체 데이터를 조회한다. 2. Hibernate 통계에서 `Page`와 `User` 엔티티가 추가로 조회된 횟수를 각각 확인한다. 3. 두 엔티티의 추가 조회 횟수를 합한 값이 Hibernate가 기록한 전체 엔티티 추가 조회 횟수와 일치하는지 확인한다. 4. 실제 실행된 전체 쿼리에서도 같은 결과가 나오는지 확인한다. Feed 조회와 Count 쿼리, 컬렉션 조회 쿼리를 제외하고 남은 쿼리 수를 엔티티 추가 조회 횟수와 비교한다. 5. 연관 객체에 접근하지 않아도 `EAGER` 로딩이 발생하는지 확인한다. Feed Item 100개를 JPQL로 조회한 뒤 `getUser()`, `getPage()`, `getHighlights()`를 호출하지 않은 상태에서 `Page`와 `User`의 추가 조회 횟수를 확인한다. 6. 이후 `LAZY` 로딩으로되어있는 `getHighlight()`를 호출해서 추가 조회 횟수를 확인한다. 7. `Page`와 `User` 조회 또는 `Highlight` SQL을 `EXPLAIN (ANALYZE, BUFFERS)`로 확인해 각 쿼리의 실행 방식과 비용을 확인한다."
|
|
- generic [ref=f13e103]:
|
|
- generic [ref=f13e104]: 마지막 검증일
|
|
- textbox "마지막 검증일" [ref=f13e105]: 2026-09-01
|
|
- generic [ref=f13e106]:
|
|
- generic [ref=f13e107]: 본문 Markdown
|
|
- group "Markdown 삽입" [ref=f13e108]:
|
|
- button "코드" [ref=f13e109] [cursor=pointer]
|
|
- button "표" [ref=f13e110] [cursor=pointer]
|
|
- button "목록" [ref=f13e111] [cursor=pointer]
|
|
- textbox "본문 Markdown" [ref=f13e112]: "## 측정한 조회 코드 ```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(); } ``` ## EAGER 연관 관계에서 발생한 추가 조회 :::evidence key=\"eager-lazy-query-sequence-47c12bda\" alt=\"loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER 연관마다 추가 SELECT가 뒤따르는 순서도.\" caption=\" \" zoom=\"true\" ::: | N | Page 추가 조회 | User 추가 조회 | ToOne 추가 조회 합계 | 컬렉션 조회 | 전체 쿼리 | |---:|---:|---:|---:|---:|---:| | 10 | 10 | 3 | 13 | 10 | 25 | | 100 | 100 | 20 | 120 | 100 | 222 | | 1,000 | 1,000 | 20 | 1,020 | 1,000 | 2,022 | Page와 User는 모두 @ManyToOne(EAGER)지만 추가 조회 회수는 달랐다. Page는 FeedItem마다 서로 다른 엔티티를 참조하고 있어서 Feed Item이 10개, 100개, 1000개로 늘어날 때 추가 조회도 그대로 10번, 100번, 1000번 발생했다. 반대로 User 같은 경우는 Feed Item이 같은 사용자를 참조하기 때문에 100부터는 20명이 반환되었고 이미 영속성 계층에 존재하기 때문에 다시 조회를 하지 않는 결과를 확인할 수 있다. | 연관 관계 | 데이터 구성 | N=10 / 100 / 1,000 | |---|---|---| | User (EAGER ToOne) | 여러 Feed Item이 최대 20명의 User를 반복 참조 | 3 / 20 / 20 | | Page (EAGER ToOne) | Feed Item마다 서로 다른 Page 참조 | 10 / 100 / 1,000 | | highlights (LAZY ToMany) | FeedItem 마다 별도의 컬렉션 조회 | 10 / 100 / 1,000 | 즉, EAGER인 ToOne 연관 관계가 별도 SELECT로 로딩되더라도 항상 Feed Item 수만큼 쿼리가 발생하는 것은 아니었다. ## 필드에 접근하지 않아도 조회가 나간다 seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다. | 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 | |---|---|---|---:| | 0회 | Page | @ManyToOne (EAGER) | 100 (= N) | | 0회 | User | @ManyToOne (EAGER) | 20 | | 0회 | highlights | @OneToMany (LAZY) | 0 | EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다. ## 실행 계획보다 반복 횟수가 문제 ```text label=\"seed(100)\" -- pages Index Scan using pk_pages on pages (cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1) Buffers: shared hit=2 Execution Time: 0.021 ms -- users Index Scan using pk_users on users (cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1) Buffers: shared hit=2 Execution Time: 0.022 ms ``` 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 단건 계획이 이미 Index Scan이지만 이 빠른 조회를 여러번 한다는게 문제다. ## 한 번의 하이라이트 조회가 읽는 행 수 ```text label=\"반복되는 하이라이트 조회 — 대량 시드 직후, 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에 끝났다. 다만 쿼리가 `SELECT * FROM highlights WHERE feed_item_id = ?`라 ORDER BY와 LIMIT이 없어서 그 FeedItem의 하이라이트를 전부 읽는다. 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다. 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다. ## 핵심은 지연이냐 즉시냐가 아니다 같은 조회에서 `EAGER`와 `LAZY`연관 관계가 언제 추가 쿼리를 발생시키는지 확인했다. | fetch 방식 | 연관 객체를 사용하지 않을 때 | 연관 객체를 사용할 때 | |---|---|---| | User·Page EAGER (`@ManyToOne`) | 추가 조회 발생 | 추가 조회 발생 | | highlights LAZY (`@OneToMany`) | 추가 조회 없음 | 추가 조회 발생 | EAGER인 Page와 User는 조회한 연관 객체를 코드에서 사용하지 않아도 추가 쿼리가 발생했다. 반대로 LAZY인 highlights는 접근하지 않으면 추가 쿼리가 발생하지 않았다. 하지만 FeedItem을 조회할 때 연관 데이터를 사용해야되는 상황이기에 highlight도 추가 쿼리가 발생하고 있었다. 그래서 EAGER를 LAZY로 변경해도 해결되진 않고 이를 해결하려면 Fetch Type만 변경하는게 아니라 필요한 연관 데이터를 가져오는 방식으로 바꿔야 한다."
|
|
- group [ref=f13e113]:
|
|
- paragraph [ref=f13e114]: EVIDENCE
|
|
- heading "본문에 Asset 삽입" [level=3] [ref=f13e115]
|
|
- paragraph [ref=f13e116]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
|
- generic [ref=f13e117]:
|
|
- generic [ref=f13e118]:
|
|
- generic [ref=f13e119]: 업로드 종류
|
|
- combobox "업로드 종류" [ref=f13e120]:
|
|
- option "이미지" [selected]
|
|
- option "다이어그램"
|
|
- option "첨부파일"
|
|
- button "Asset 업로드" [ref=f13e121]
|
|
- generic [ref=f13e122]:
|
|
- search [ref=f13e123]:
|
|
- generic [ref=f13e124]: Asset 검색
|
|
- generic [ref=f13e125]:
|
|
- searchbox "Asset 검색" [ref=f13e126]
|
|
- button "검색" [ref=f13e127]
|
|
- generic [ref=f13e128]:
|
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f13e129]
|
|
- generic [ref=f13e130]: 삽입할 때 크게 보기 허용
|
|
- status [ref=f13e131]: 삽입할 수 있는 Asset 24개
|
|
- list [ref=f13e132]:
|
|
- listitem [ref=f13e133]:
|
|
- button "ap4-edge-trust-architecture-1a916e10" [ref=f13e134]
|
|
- button "삭제" [ref=f13e135]
|
|
- listitem [ref=f13e136]:
|
|
- button "ap3-bff-session-flow-1b005e15" [ref=f13e137]
|
|
- button "삭제" [ref=f13e138]
|
|
- listitem [ref=f13e139]:
|
|
- button "ap3-bff-architecture-a27ea91c" [ref=f13e140]
|
|
- button "삭제" [ref=f13e141]
|
|
- listitem [ref=f13e142]:
|
|
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f13e143]
|
|
- button "삭제" [ref=f13e144]
|
|
- listitem [ref=f13e145]:
|
|
- button "ap2-mediator-architecture-c95ed25f" [ref=f13e146]
|
|
- button "삭제" [ref=f13e147]
|
|
- listitem [ref=f13e148]:
|
|
- button "projection-row-over-fetch-f2b1943b" [ref=f13e149]
|
|
- button "삭제" [ref=f13e150]
|
|
- listitem [ref=f13e151]:
|
|
- button "cartesian-row-multiplication-dce2e166" [ref=f13e152]
|
|
- button "삭제" [ref=f13e153]
|
|
- listitem [ref=f13e154]:
|
|
- button "eager-lazy-query-sequence-47c12bda" [ref=f13e155]
|
|
- button "삭제" [ref=f13e156]
|
|
- listitem [ref=f13e157]:
|
|
- button "ap3-bff-session-flow-a8dfff6f" [ref=f13e158]
|
|
- button "삭제" [ref=f13e159]
|
|
- listitem [ref=f13e160]:
|
|
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f13e161]
|
|
- button "삭제" [ref=f13e162]
|
|
- listitem [ref=f13e163]:
|
|
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f13e164]
|
|
- button "삭제" [ref=f13e165]
|
|
- listitem [ref=f13e166]:
|
|
- button "ap3-csrf-boundary-971df81c" [ref=f13e167]
|
|
- button "삭제" [ref=f13e168]
|
|
- listitem [ref=f13e169]:
|
|
- button "login-api-phase-split-3e354274" [ref=f13e170]
|
|
- button "삭제" [ref=f13e171]
|
|
- listitem [ref=f13e172]:
|
|
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f13e173]
|
|
- button "삭제" [ref=f13e174]
|
|
- listitem [ref=f13e175]:
|
|
- button "ap1-direct-architecture-0adf4199" [ref=f13e176]
|
|
- button "삭제" [ref=f13e177]
|
|
- listitem [ref=f13e178]:
|
|
- button "nplus1-query-fanout-644febe6" [ref=f13e179]
|
|
- button "삭제" [ref=f13e180]
|
|
- listitem [ref=f13e181]:
|
|
- button "ap4-edge-trust-1cff2399" [ref=f13e182]
|
|
- button "삭제" [ref=f13e183]
|
|
- listitem [ref=f13e184]:
|
|
- button "ap3-csrf-split-501dd1f7" [ref=f13e185]
|
|
- button "삭제" [ref=f13e186]
|
|
- listitem [ref=f13e187]:
|
|
- button "ap3-bff-custody-82fa18bd" [ref=f13e188]
|
|
- button "삭제" [ref=f13e189]
|
|
- listitem [ref=f13e190]:
|
|
- button "ap2-split-custody-779cb791" [ref=f13e191]
|
|
- button "삭제" [ref=f13e192]
|
|
- listitem [ref=f13e193]:
|
|
- button "ap1-custody-v3-6e0376d2" [ref=f13e194]
|
|
- button "삭제" [ref=f13e195]
|
|
- listitem [ref=f13e196]:
|
|
- button "ap1-custody-v2-e110bd98" [ref=f13e197]
|
|
- button "삭제" [ref=f13e198]
|
|
- listitem [ref=f13e199]:
|
|
- button "ap1-credential-custody-f5e0c027" [ref=f13e200]
|
|
- button "삭제" [ref=f13e201]
|
|
- listitem [ref=f13e202]:
|
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f13e203]
|
|
- button "삭제" [ref=f13e204]
|
|
- region [ref=f13e205]:
|
|
- generic [ref=f13e206]:
|
|
- paragraph [ref=f13e207]: LIVE
|
|
- heading "즉시 미리보기" [level=2] [ref=f13e208]
|
|
- generic [ref=f13e211]:
|
|
- generic [ref=f13e212]:
|
|
- navigation "문서 경로" [ref=f13e213]:
|
|
- link "검증 기록" [ref=f13e214] [cursor=pointer]:
|
|
- /url: /explore/cases
|
|
- generic [aria-hidden] [ref=f13e215]: /
|
|
- generic [ref=f13e216]: JPA 피드 조회 성능
|
|
- generic [aria-hidden] [ref=f13e217]: /
|
|
- link "Liner N + 1문제" [ref=f13e218] [cursor=pointer]:
|
|
- /url: /projects/liner-n-plus-1
|
|
- heading "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [level=1] [ref=f13e219]
|
|
- paragraph [ref=f13e220]:
|
|
- code [ref=f13e221]: "@ManyToOne"
|
|
- text: 의 기본값인
|
|
- code [ref=f13e222]: EAGER
|
|
- text: 는 연관 엔티티를 함께 로딩해야 한다는 계약이지, 데이터를
|
|
- code [ref=f13e223]: JOIN
|
|
- text: 으로 가져온다는 보장은 없다. 실제 파생 쿼리에서는 연관 엔티티를 가져오기 위한 2차
|
|
- code [ref=f13e224]: SELECT
|
|
- text: 가 행마다 발생했다.
|
|
- paragraph [ref=f13e225]: LAZY인 highlights도 접근하는 순간 N번 조회. N+1 문제는 fetch 타입이 아니라 조회 방식에서 발생하게 된다.
|
|
- region "문제와 결론" [ref=f13e226]:
|
|
- generic [ref=f13e227]:
|
|
- paragraph [ref=f13e228]: 문제
|
|
- paragraph [ref=f13e229]: 컬렉션을 조회하는 쿼리를 제외하고도 추가 쿼리가 계속 발생했다. 데이터 수를 늘려 확인해 보니 추가 쿼리 수도 13개에서 120개, 1,020개로 함께 증가했다.
|
|
- paragraph [ref=f13e230]:
|
|
- text: 엔티티에는 fetch 방식을 따로 지정하지 않아 JPA 기본값을 사용하고 있었다.
|
|
- code [ref=f13e231]: "@ManyToOne"
|
|
- text: 은
|
|
- code [ref=f13e232]: EAGER
|
|
- text: ","
|
|
- code [ref=f13e233]: "@OneToMany"
|
|
- text: 는
|
|
- code [ref=f13e234]: LAZY
|
|
- text: 였다.
|
|
- generic [ref=f13e235]:
|
|
- paragraph [ref=f13e236]: 결론
|
|
- paragraph [ref=f13e237]:
|
|
- text: 같은
|
|
- code [ref=f13e238]: "@ManyToOne(EAGER)"
|
|
- text: 라도 추가 쿼리 수는 달랐다.
|
|
- code [ref=f13e239]: Page
|
|
- text: 는 아이템마다 다른 대상을 참조해 N번 조회됐지만,
|
|
- code [ref=f13e240]: User
|
|
- text: 는 같은 대상을 재사용하면서 1차 캐시 덕분에 조회 수가 제한됐다.
|
|
- paragraph [ref=f13e241]:
|
|
- code [ref=f13e242]: EAGER
|
|
- text: 는 연관 객체를 직접 사용하지 않아도 추가 조회를 발생시켰고,
|
|
- code [ref=f13e243]: LAZY
|
|
- text: 도 실제 접근하는 순간 N번 조회됐다. 결국 N+1은
|
|
- code [ref=f13e244]: EAGER
|
|
- text: 나
|
|
- code [ref=f13e245]: LAZY
|
|
- text: 자체보다 연관 데이터를 개별 쿼리로 조회하는 방식과 서로 다른 연관 대상의 수에 따라 문제가 발생 했다.
|
|
- generic [ref=f13e246]:
|
|
- generic [ref=f13e247]:
|
|
- term [ref=f13e248]: 검증 환경
|
|
- definition [ref=f13e249]:
|
|
- paragraph [ref=f13e250]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
|
|
- paragraph [ref=f13e251]: "시드feed_item : Npage : N (아이템당 1개, 전부 다름)user : max(3, min(20, N/5+1))"
|
|
- generic [ref=f13e252]:
|
|
- term [ref=f13e253]: 검증 데이터
|
|
- definition [ref=f13e254]:
|
|
- paragraph [ref=f13e255]: 1. 데이터 수에 따른 차이를 확인하기 위해 Feed Item을 각각 10개, 100개, 1,000개 생성한 뒤 전체 데이터를 조회한다.
|
|
- paragraph [ref=f13e256]:
|
|
- text: 2. Hibernate 통계에서
|
|
- code [ref=f13e257]: Page
|
|
- text: 와
|
|
- code [ref=f13e258]: User
|
|
- text: 엔티티가 추가로 조회된 횟수를 각각 확인한다.
|
|
- paragraph [ref=f13e259]: 3. 두 엔티티의 추가 조회 횟수를 합한 값이 Hibernate가 기록한 전체 엔티티 추가 조회 횟수와 일치하는지 확인한다.
|
|
- paragraph [ref=f13e260]: 4. 실제 실행된 전체 쿼리에서도 같은 결과가 나오는지 확인한다. Feed 조회와 Count 쿼리, 컬렉션 조회 쿼리를 제외하고 남은 쿼리 수를 엔티티 추가 조회 횟수와 비교한다.
|
|
- paragraph [ref=f13e261]:
|
|
- text: 5. 연관 객체에 접근하지 않아도
|
|
- code [ref=f13e262]: EAGER
|
|
- text: 로딩이 발생하는지 확인한다. Feed Item 100개를 JPQL로 조회한 뒤
|
|
- code [ref=f13e263]: getUser()
|
|
- text: ","
|
|
- code [ref=f13e264]: getPage()
|
|
- text: ","
|
|
- code [ref=f13e265]: getHighlights()
|
|
- text: 를 호출하지 않은 상태에서
|
|
- code [ref=f13e266]: Page
|
|
- text: 와
|
|
- code [ref=f13e267]: User
|
|
- text: 의 추가 조회 횟수를 확인한다.
|
|
- paragraph [ref=f13e268]:
|
|
- text: 6. 이후
|
|
- code [ref=f13e269]: LAZY
|
|
- text: 로딩으로되어있는
|
|
- code [ref=f13e270]: getHighlight()
|
|
- text: 를 호출해서 추가 조회 횟수를 확인한다.
|
|
- paragraph [ref=f13e271]:
|
|
- text: "7."
|
|
- code [ref=f13e272]: Page
|
|
- text: 와
|
|
- code [ref=f13e273]: User
|
|
- text: 조회 또는
|
|
- code [ref=f13e274]: Highlight
|
|
- text: SQL을
|
|
- code [ref=f13e275]: EXPLAIN (ANALYZE, BUFFERS)
|
|
- text: 로 확인해 각 쿼리의 실행 방식과 비용을 확인한다.
|
|
- generic [ref=f13e276]:
|
|
- term [ref=f13e277]: 기록
|
|
- definition [ref=f13e278]: 게시 2026.09.01 · 마지막 검증 2026.09.01
|
|
- group [ref=f13e280]:
|
|
- generic "목차 · 측정한 조회 코드" [ref=f13e281] [cursor=pointer]
|
|
- article [ref=f13e283]:
|
|
- region [ref=f13e284]:
|
|
- heading [level=2] [ref=f13e285]:
|
|
- link "측정한 조회 코드 바로가기" [ref=f13e286] [cursor=pointer]:
|
|
- /url: "#측정한-조회-코드"
|
|
- text: 측정한 조회 코드
|
|
- generic [aria-hidden] [ref=f13e287]: "#"
|
|
- figure "JAVA ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드 복사" [ref=f13e288]:
|
|
- generic [ref=f13e289]:
|
|
- generic [ref=f13e290]: JAVA
|
|
- generic [ref=f13e291]: ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑
|
|
- button "코드 복사" [ref=f13e292] [cursor=pointer]: 복사
|
|
- region "FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드" [ref=f13e293]:
|
|
- code [ref=f13e294]: "@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=f13e296]:
|
|
- heading [level=2] [ref=f13e297]:
|
|
- link "EAGER 연관 관계에서 발생한 추가 조회 바로가기" [ref=f13e298] [cursor=pointer]:
|
|
- /url: "#eager-연관-관계에서-발생한-추가-조회"
|
|
- text: EAGER 연관 관계에서 발생한 추가 조회
|
|
- generic [aria-hidden] [ref=f13e299]: "#"
|
|
- figure [ref=f13e300]:
|
|
- button "eager-lazy-query-sequence-47c12bda 이미지 크게 보기" [ref=f13e301]:
|
|
- img "loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER 연관마다 추가 SELECT가 뒤따르는 순서도." [ref=f13e302]
|
|
- generic [ref=f13e303]: 크게 보기
|
|
- generic [ref=f13e304]: loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER 연관마다 추가 SELECT가 뒤따르는 순서도.
|
|
- region "표" [ref=f13e305]:
|
|
- table [ref=f13e306]:
|
|
- caption [ref=f13e307]
|
|
- rowgroup [ref=f13e308]:
|
|
- row [ref=f13e309]:
|
|
- columnheader "N" [ref=f13e310]
|
|
- columnheader "Page 추가 조회" [ref=f13e311]
|
|
- columnheader "User 추가 조회" [ref=f13e312]
|
|
- columnheader "ToOne 추가 조회 합계" [ref=f13e313]
|
|
- columnheader "컬렉션 조회" [ref=f13e314]
|
|
- columnheader "전체 쿼리" [ref=f13e315]
|
|
- rowgroup [ref=f13e316]:
|
|
- row [ref=f13e317]:
|
|
- cell "10" [ref=f13e318]
|
|
- cell "10" [ref=f13e319]
|
|
- cell "3" [ref=f13e320]
|
|
- cell "13" [ref=f13e321]
|
|
- cell "10" [ref=f13e322]
|
|
- cell "25" [ref=f13e323]
|
|
- row [ref=f13e324]:
|
|
- cell "100" [ref=f13e325]
|
|
- cell "100" [ref=f13e326]
|
|
- cell "20" [ref=f13e327]
|
|
- cell "120" [ref=f13e328]
|
|
- cell "100" [ref=f13e329]
|
|
- cell "222" [ref=f13e330]
|
|
- row [ref=f13e331]:
|
|
- cell "1,000" [ref=f13e332]
|
|
- cell "1,000" [ref=f13e333]
|
|
- cell "20" [ref=f13e334]
|
|
- cell "1,020" [ref=f13e335]
|
|
- cell "1,000" [ref=f13e336]
|
|
- cell "2,022" [ref=f13e337]
|
|
- paragraph [ref=f13e338]: Page와 User는 모두 @ManyToOne(EAGER)지만 추가 조회 회수는 달랐다.
|
|
- paragraph [ref=f13e339]: Page는 FeedItem마다 서로 다른 엔티티를 참조하고 있어서 Feed Item이 10개, 100개, 1000개로 늘어날 때 추가 조회도 그대로 10번, 100번, 1000번 발생했다.
|
|
- paragraph [ref=f13e340]: 반대로 User 같은 경우는 Feed Item이 같은 사용자를 참조하기 때문에 100부터는 20명이 반환되었고 이미 영속성 계층에 존재하기 때문에 다시 조회를 하지 않는 결과를 확인할 수 있다.
|
|
- region "표" [ref=f13e341]:
|
|
- table [ref=f13e342]:
|
|
- caption [ref=f13e343]
|
|
- rowgroup [ref=f13e344]:
|
|
- row [ref=f13e345]:
|
|
- columnheader "연관 관계" [ref=f13e346]
|
|
- columnheader "데이터 구성" [ref=f13e347]
|
|
- columnheader "N=10 / 100 / 1,000" [ref=f13e348]
|
|
- rowgroup [ref=f13e349]:
|
|
- row [ref=f13e350]:
|
|
- cell "User (EAGER ToOne)" [ref=f13e351]
|
|
- cell "여러 Feed Item이 최대 20명의 User를 반복 참조" [ref=f13e352]
|
|
- cell "3 / 20 / 20" [ref=f13e353]
|
|
- row [ref=f13e354]:
|
|
- cell "Page (EAGER ToOne)" [ref=f13e355]
|
|
- cell "Feed Item마다 서로 다른 Page 참조" [ref=f13e356]
|
|
- cell "10 / 100 / 1,000" [ref=f13e357]
|
|
- row [ref=f13e358]:
|
|
- cell "highlights (LAZY ToMany)" [ref=f13e359]
|
|
- cell "FeedItem 마다 별도의 컬렉션 조회" [ref=f13e360]
|
|
- cell "10 / 100 / 1,000" [ref=f13e361]
|
|
- paragraph [ref=f13e362]: 즉, EAGER인 ToOne 연관 관계가 별도 SELECT로 로딩되더라도 항상 Feed Item 수만큼 쿼리가 발생하는 것은 아니었다.
|
|
- region [ref=f13e363]:
|
|
- heading [level=2] [ref=f13e364]:
|
|
- link "필드에 접근하지 않아도 조회가 나간다 바로가기" [ref=f13e365] [cursor=pointer]:
|
|
- /url: "#필드에-접근하지-않아도-조회가-나간다"
|
|
- text: 필드에 접근하지 않아도 조회가 나간다
|
|
- generic [aria-hidden] [ref=f13e366]: "#"
|
|
- paragraph [ref=f13e367]: seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
|
|
- region "표" [ref=f13e368]:
|
|
- table [ref=f13e369]:
|
|
- caption [ref=f13e370]
|
|
- rowgroup [ref=f13e371]:
|
|
- row [ref=f13e372]:
|
|
- columnheader "접근" [ref=f13e373]
|
|
- columnheader "연관" [ref=f13e374]
|
|
- columnheader "fetch 계약" [ref=f13e375]
|
|
- columnheader "접근 0에서 fetch 수" [ref=f13e376]
|
|
- rowgroup [ref=f13e377]:
|
|
- row [ref=f13e378]:
|
|
- cell "0회" [ref=f13e379]
|
|
- cell "Page" [ref=f13e380]
|
|
- cell "@ManyToOne (EAGER)" [ref=f13e381]
|
|
- cell "100 (= N)" [ref=f13e382]
|
|
- row [ref=f13e383]:
|
|
- cell "0회" [ref=f13e384]
|
|
- cell "User" [ref=f13e385]
|
|
- cell "@ManyToOne (EAGER)" [ref=f13e386]
|
|
- cell "20" [ref=f13e387]
|
|
- row [ref=f13e388]:
|
|
- cell "0회" [ref=f13e389]
|
|
- cell "highlights" [ref=f13e390]
|
|
- cell "@OneToMany (LAZY)" [ref=f13e391]
|
|
- cell "0" [ref=f13e392]
|
|
- paragraph [ref=f13e393]: EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다.
|
|
- region [ref=f13e394]:
|
|
- heading [level=2] [ref=f13e395]:
|
|
- link "실행 계획보다 반복 횟수가 문제 바로가기" [ref=f13e396] [cursor=pointer]:
|
|
- /url: "#실행-계획보다-반복-횟수가-문제"
|
|
- text: 실행 계획보다 반복 횟수가 문제
|
|
- generic [aria-hidden] [ref=f13e397]: "#"
|
|
- figure "TEXT ·seed(100) 코드 복사" [ref=f13e398]:
|
|
- generic [ref=f13e399]:
|
|
- generic [ref=f13e400]: TEXT
|
|
- generic [ref=f13e401]: ·seed(100)
|
|
- button "코드 복사" [ref=f13e402] [cursor=pointer]: 복사
|
|
- region "seed(100) 코드" [ref=f13e403]:
|
|
- code [ref=f13e404]: "-- pages Index Scan using pk_pages on pages (cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1) Buffers: shared hit=2 Execution Time: 0.021 ms -- users Index Scan using pk_users on users (cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1) Buffers: shared hit=2 Execution Time: 0.022 ms"
|
|
- paragraph [ref=f13e406]: 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다.
|
|
- paragraph [ref=f13e407]: 단건 계획이 이미 Index Scan이지만 이 빠른 조회를 여러번 한다는게 문제다.
|
|
- region [ref=f13e408]:
|
|
- heading [level=2] [ref=f13e409]:
|
|
- link "한 번의 하이라이트 조회가 읽는 행 수 바로가기" [ref=f13e410] [cursor=pointer]:
|
|
- /url: "#한-번의-하이라이트-조회가-읽는-행-수"
|
|
- text: 한 번의 하이라이트 조회가 읽는 행 수
|
|
- generic [aria-hidden] [ref=f13e411]: "#"
|
|
- figure "TEXT ·반복되는 하이라이트 조회 — 대량 시드 직후, ANALYZE 실행 전 코드 복사" [ref=f13e412]:
|
|
- generic [ref=f13e413]:
|
|
- generic [ref=f13e414]: TEXT
|
|
- generic [ref=f13e415]: ·반복되는 하이라이트 조회 — 대량 시드 직후, ANALYZE 실행 전
|
|
- button "코드 복사" [ref=f13e416] [cursor=pointer]: 복사
|
|
- region "반복되는 하이라이트 조회 — 대량 시드 직후, ANALYZE 실행 전 코드" [ref=f13e417]:
|
|
- code [ref=f13e418]: "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=f13e420]:
|
|
- text: 하이라이트 조회도 인덱스를 타고 0.173 ms에 끝났다. 다만 쿼리가
|
|
- code [ref=f13e421]: SELECT * FROM highlights WHERE feed_item_id = ?
|
|
- text: 라 ORDER BY와 LIMIT이 없어서 그 FeedItem의 하이라이트를 전부 읽는다. 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다.
|
|
- paragraph [ref=f13e422]: 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
|
|
- region [ref=f13e423]:
|
|
- heading [level=2] [ref=f13e424]:
|
|
- link "핵심은 지연이냐 즉시냐가 아니다 바로가기" [ref=f13e425] [cursor=pointer]:
|
|
- /url: "#핵심은-지연이냐-즉시냐가-아니다"
|
|
- text: 핵심은 지연이냐 즉시냐가 아니다
|
|
- generic [aria-hidden] [ref=f13e426]: "#"
|
|
- paragraph [ref=f13e427]:
|
|
- text: 같은 조회에서
|
|
- code [ref=f13e428]: EAGER
|
|
- text: 와
|
|
- code [ref=f13e429]: LAZY
|
|
- text: 연관 관계가 언제 추가 쿼리를 발생시키는지 확인했다.
|
|
- region "표" [ref=f13e430]:
|
|
- table [ref=f13e431]:
|
|
- caption [ref=f13e432]
|
|
- rowgroup [ref=f13e433]:
|
|
- row [ref=f13e434]:
|
|
- columnheader "fetch 방식" [ref=f13e435]
|
|
- columnheader "연관 객체를 사용하지 않을 때" [ref=f13e436]
|
|
- columnheader "연관 객체를 사용할 때" [ref=f13e437]
|
|
- rowgroup [ref=f13e438]:
|
|
- row [ref=f13e439]:
|
|
- cell [ref=f13e440]:
|
|
- text: User·Page EAGER (
|
|
- code [ref=f13e441]: "@ManyToOne"
|
|
- text: )
|
|
- cell "추가 조회 발생" [ref=f13e442]
|
|
- cell "추가 조회 발생" [ref=f13e443]
|
|
- row [ref=f13e444]:
|
|
- cell [ref=f13e445]:
|
|
- text: highlights LAZY (
|
|
- code [ref=f13e446]: "@OneToMany"
|
|
- text: )
|
|
- cell "추가 조회 없음" [ref=f13e447]
|
|
- cell "추가 조회 발생" [ref=f13e448]
|
|
- paragraph [ref=f13e449]: EAGER인 Page와 User는 조회한 연관 객체를 코드에서 사용하지 않아도 추가 쿼리가 발생했다.반대로 LAZY인 highlights는 접근하지 않으면 추가 쿼리가 발생하지 않았다.
|
|
- paragraph [ref=f13e450]: 하지만 FeedItem을 조회할 때 연관 데이터를 사용해야되는 상황이기에 highlight도 추가 쿼리가 발생하고 있었다.그래서 EAGER를 LAZY로 변경해도 해결되진 않고 이를 해결하려면 Fetch Type만 변경하는게 아니라 필요한 연관 데이터를 가져오는 방식으로 바꿔야 한다.
|
|
- complementary [ref=f13e451]:
|
|
- heading "작업 상태" [level=2] [ref=f13e452]
|
|
- status "편집 상태" [ref=f13e453]: 저장됨
|
|
- generic [ref=f13e454]:
|
|
- generic [ref=f13e455]:
|
|
- term [ref=f13e456]: 저장 버전
|
|
- definition [ref=f13e457]: "37"
|
|
- generic [ref=f13e458]:
|
|
- term [ref=f13e459]: 종류
|
|
- definition [ref=f13e460]: 검증 기록
|
|
- paragraph [ref=f13e461]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
|
- generic [ref=f13e462]:
|
|
- button "저장" [disabled] [ref=f13e463]
|
|
- button "게시" [ref=f13e464]
|
|
- paragraph [ref=f13e465]: 버전 37으로 저장했습니다. |