--- id: b0b55ac9-c0a3-4c01-ba84-0aa478923ace kind: REFERENCE slug: nplus1-quantitative-diagnosis title: JPA N+1 정량 진단 기준 topic: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit" --- # JPA N+1 정량 진단 기준 N+1을 쿼리 로그의 인상이 아니라 지표로 확인한다. Hibernate Statistics의 지표는 이름이 뜻하는 것이 서로 달라서, SQL 실행 횟수로 바꿔 읽으면 배치를 적용한 뒤 결론이 어긋난다. ## 관계 - **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1** 엔티티별 fetch 통계로 ToOne 쪽을 확인한 기록이다. - **PostgreSQL Query Plan 측정 기준** 같은 측정에서 실행계획을 다루는 기준이다. ## 목적 쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계에는 목록 루트, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 섞여 있다. 지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회되는지 확정할 수 있다. 그래야 fetch 전략을 바꿨을 때 무엇이 줄었는지 말할 수 있다. ## 규칙 ### 1. 지표 이름이 뜻하는 것을 그대로 읽는다 getCollectionFetchCount()는 초기화된 컬렉션 수다. 실행된 SELECT SQL 수가 아니다. getPrepareStatementCount()는 획득한 PreparedStatement 수다. 이 값도 SQL 실행 수와 항상 같지는 않다. getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수다. 실행된 SELECT SQL 수가 아니다. ### 2. 등식이 성립하는 조건을 함께 적는다 batch나 subselect가 없을 때만 초기화 컬렉션 수와 자식 SELECT 수가 같다. 이 조건에서만 컬렉션 수를 SQL 수로 바꿔 읽을 수 있다. Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 등식이 깨진다. 배치 적용 여부는 prepared와 collectionFetch를 함께 보고 판단한다. ### 3. 총계를 형태별로 가르고 검산한다 총 PreparedStatement를 다음처럼 나눈다. content 1 count 1 distinct ToOne 대상 수 N ToOne (아이템마다 다른 연관) N 컬렉션 초기화 파생값과 직접 측정값이 일치하는지 교차 검증한다. 회계 항등식은 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 뺀 값이 entityFetch와 같은지 보는 것이다. ### 4. 회귀 가드는 시더 카디널리티와 무관한 값으로 고정한다 합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있다. 엔티티별 지표로 고정하는 편이 안정적이다. 예를 들어 아이템마다 다른 연관은 pageFetch == N이 성립한다. 합계는 회귀 가드가 아니라 교차 검증에 쓴다. ### 5. count 쿼리가 언제 나오는지 안다 Page를 반환하면 Spring Data가 전체 건수 count를 한 번 더 실행한다. offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다. 같은 코드라도 pageSize와 반환 건수의 관계에 따라 총계가 달라진다. 측정값을 비교할 때 이 조건을 맞춘다. ### 6. 캐시가 결과를 먹지 않게 한다 같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 흡수한다. 지연 반복 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출한다. clear 비용은 측정 구간 밖에 둔다. 쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다. ### 7. 증가 기준이 무엇인지 명시한다 N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 테이블이 100만 행이어도 이 왕복 수는 늘지 않는다. 전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다. ### 8. 왕복과 행수를 다른 축으로 센다 한 조회에 두 위반이 함께 있을 수 있다. 부모 수에 비례하는 왕복과, 한 번의 왕복에서 자식을 전부 읽는 과조회다. 왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다. 한쪽을 고쳐 놓고 다른 쪽이 해결됐다고 적지 않는다. ## 적용 조건 - ORM 조회에서 쿼리 발생량이 데이터 규모를 따라 늘어나는지 확인할 때 - fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때 - N+1 회귀를 테스트로 고정할 때 ## 예외 - SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다. - 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다. ## 예시 - 초기화 컬렉션 수 : 실행된 SELECT 수가 아니라 초기화된 컬렉션 수 - 총 PreparedStatement : 획득한 statement 수, SQL 실행 수와 다를 수 있음 - 회계 항등식 : 총계 − 컬렉션 N − content 1 − count 1 = entityFetch - 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관) - 측정 규율 : 매 반복 전 em.clear, stats.clear 직후 1회만 읽기