영어 원문의 번역
이 페이지는 영어 원문을 번역했어요. 내용이 다르면 영어 원문이 기준이에요.
N+1 쿼리 탐지¶
조회 테스트가 쿼리 예산을 넘으면, 반복된 SQL을 확인하고 애플리케이션이 읽는 연관관계를 실제로 실행해 보세요. @EnableQueryInspector를 쓰면 캡처된 문장과 Hibernate 이벤트를 검토하는 동안 탐지 결과는 참고용으로만 남아요. 고친 접근 방식은 예산이나 검토를 거친 N+1 탐지 정책으로 테스트에 고정하세요. 작업 흐름 가이드를 참고하세요.
| 이슈 코드 | n-plus-one |
| 심각도 | ERROR |
| 기본 기준값 | 3회 반복 |
N+1 문제란?¶
N+1 문제는 애플리케이션이 엔티티 목록을 가져오는 쿼리 1개를 실행한 뒤, 연관관계를 불러오려고 엔티티마다 쿼리 N개를 더 실행할 때 생겨요.
1 query: SELECT * FROM orders -- fetch all orders
N queries: SELECT * FROM members WHERE id = ? -- one per order (lazy load)
SELECT * FROM members WHERE id = ?
SELECT * FROM members WHERE id = ?
...
주문이 100개면 쿼리가 101개, 10,000개면 10,001개가 돼요. 데이터에 비례해서 늘어나고, 애플리케이션이 느려지는 가장 흔한 원인 중 하나예요.
Application Database
| |
|--- SELECT * FROM orders ---------> |
|<-- 100 rows ---------------------- |
| |
|--- SELECT * FROM members (id=1) -> | \
|<-- 1 row ------------------------- | |
|--- SELECT * FROM members (id=2) -> | | N times
|<-- 1 row ------------------------- | | (one per order)
|--- SELECT * FROM members (id=3) -> | |
|<-- 1 row ------------------------- | /
| ... |
QueryAudit이 찾는 방식¶
0.7.0부터 확정된 n-plus-one 탐지 결과는 JDBC 증거로만 판단하고, 기본 recommended 프로필에서 실행되는 유일한 내장 규칙이에요. EXPLAIN도 Hibernate도 필요 없어요.
-
SQL 정규화 -- 리터럴 값을
?자리표시자로 바꿔요. -
호출 위치로 묶기 -- 정규화된 SQL과 애플리케이션 호출 스택 전체로 문장을 묶어요. 프록시, 리플렉션, CGLIB, 프레임워크 프레임은 무시해서, 같은 리포지토리 메서드라도 서로 다른 두 곳에서 부르면 서로 다른 두 묶음이 돼요.
-
세기 -- 서로 다른 값을 바인딩한 SELECT가 한 묶음에 기준값 이상(기본 3개) 있으면 ERROR
n-plus-one탐지 결과가 돼요. 다음 세 경우는 해당하지 않아요.- 자리표시자가 여러 개인
IN (?, ?, ...)목록이 있는 문장은 묶어서 가져오는 것이라서,@BatchSize와 배치 페치는 테스트를 실패시키지 않아요. OFFSET으로 페이지를 넘기는 문장(LIMIT ? OFFSET ?,OFFSET ? ROWS,LIMIT ?, ?)은 다음 행의 연관관계가 아니라 다음 페이지를 읽는 거예요.- 묶음 안의 실행이 모두 같은 값을 바인딩하면 같은 조회를 반복한 거예요. 예를 들어 테스트가 같은 요청을 반복문으로 보내거나, 페이지 반복문의 count 쿼리가 그래요. 이런 경우는 테스트를 실패시키지 않는 INFO
n-plus-one탐지 결과로 보고해서, 운영 코드가 한 작업 안에서 같은 조회를 반복하는 것도 눈에 보이게 해요. QueryAudit은 바인딩한 값의 해시만 비교하고 값 자체는 보관하지 않아요.
- 자리표시자가 여러 개인
키셋 페이지네이션(WHERE id > ? ORDER BY id LIMIT ?)은 페이지마다 새 값을 바인딩하기 때문에 보고돼요. 의도한 반복문이라면 그 테스트에서 n-plus-one을 억제하세요.
Hibernate 연동이 켜져 있으면 지연 로딩 이벤트도 기록해요. 이 이벤트는 컬렉션이나 프록시 이름과 함께 추가할 페치를 제안하는 INFO n-plus-one 탐지 결과가 돼요. 확정된 탐지 결과를 설명해 주지만, 이벤트만으로 확정하지는 않아요. 예전의 SQL 전용 n-plus-one-suspect 규칙은 strict나 enabled-rules로 여전히 쓸 수 있어요.
@QueryAudit은 확정된 탐지 결과가 있으면 테스트를 실패시키고, @EnableQueryInspector는 보고만 해요. 다른 규칙을 켰을 때 N+1에서만 실패하게 하려면 @QueryAudit(failOn = IssueType.N_PLUS_ONE)을 쓰세요.
진단하는 방법¶
QueryAudit이 N+1을 보고하면, 다음 순서로 근본 원인을 찾아 고치세요.
1단계: 반복된 쿼리 찾기¶
콘솔 리포트에 반복된 SELECT, 그걸 실행한 줄, 읽는 테이블이 나와요(일부).
[ERROR] N+1 Query detected
Query: select m1_0.id,m1_0.name from members m1_0 where m1_0.id=?
Source: com.example.OrderService.lambda$findOrders$0:42
Target: members
Detail: The same SELECT ran 100 times from one call site
2단계: 원인이 된 코드 찾기¶
다음과 같은 코드를 찾으세요.
- 엔티티 컬렉션을 반복하는 코드
- 반복문 안에서 지연 로딩 연관관계에 접근하는 코드
- 반복문 안에서 리포지토리 메서드를 부르는 코드
스택 트레이스
QueryAudit은 쿼리가 실행될 때 스택 트레이스를 캡처해요. 콘솔에 나온 호출 위치가 프록시를 가리키면 JSON 리포트에 남은 그 쿼리의 stackTrace에서 애플리케이션 패키지를 찾으세요. Hibernate 이벤트로 생긴 탐지 결과에는 호출 위치가 없을 수 있어요. 같은 테스트에서 캡처된 문장과 맞춰 보세요.
3단계: 엔티티 매핑 확인하기¶
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // <-- lazy loading = N+1 risk
private Member member;
}
4단계: 흔한 코드 패턴 확인하기¶
N+1은 잘 보이지 않는 곳에 숨어 있는 경우가 많아요. 다음 패턴을 확인하세요.
5단계: 고치는 방법 고르기¶
아래 고치는 방법을 보세요. 알맞은 방법은 사용 사례에 따라 달라요.
실제 예시¶
예시 1: 기본 JPA 지연 로딩¶
가장 흔한 N+1이에요. 엔티티를 반복하면서 지연 로딩 연관관계에 접근해요.
-- 1st query
SELECT o.id, o.status, o.member_id, o.total FROM orders o;
-- N lazy-load queries (one per order)
SELECT m.id, m.name, m.email FROM members m WHERE m.id = 1;
SELECT m.id, m.name, m.email FROM members m WHERE m.id = 2;
SELECT m.id, m.name, m.email FROM members m WHERE m.id = 3;
-- ... repeated for every order
예시 2: 중첩된 N+1 (Order -> Member -> Address)¶
N+1이 중첩돼서 쿼리가 N x M개가 되는 특히 심한 경우예요.
예시 3: 컬렉션 매핑 (@OneToMany)¶
@Entity
public class Department {
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<Employee> employees;
}
예시 4: MyBatis 중첩 select¶
<!-- OrderMapper.xml -->
<resultMap id="orderWithMember" type="Order">
<id property="id" column="id"/>
<!-- This triggers a separate SELECT for each order -->
<association property="member" column="member_id"
select="com.example.mapper.MemberMapper.selectById"/>
</resultMap>
<select id="selectAll" resultMap="orderWithMember">
SELECT * FROM orders
</select>
<resultMap id="orderWithMember" type="Order">
<id property="id" column="id"/>
<association property="member" javaType="Member">
<id property="id" column="member_id"/>
<result property="name" column="member_name"/>
</association>
</resultMap>
<select id="selectAllWithMember" resultMap="orderWithMember">
SELECT o.*, m.name as member_name
FROM orders o
JOIN members m ON o.member_id = m.id
</select>
예시 5: Spring Data REST / JSON 직렬화¶
JSON 직렬화 중에 생기는, 잘 보이지 않는 N+1이에요.
@RestController
public class OrderController {
@GetMapping("/orders")
public List<Order> getOrders() {
// The N+1 happens during Jackson serialization, not here!
return orderRepository.findAll();
}
}
Jackson이 직렬화하면서 Order마다 getMember()를 불러서 지연 로딩이 일어나요.
찾기 어려운 경우
이 N+1은 컨트롤러 코드에 보이지 않아요. JSON 직렬화기 안에서 일어나요. 계측된 DataSource를 쓰면서 직렬화까지 감사 구간 안에서 실행해야 QueryAudit이 그 지연 로딩 쿼리도 캡처할 수 있어요.
고치기: 리포지토리 메서드에 DTO 프로젝션이나 @EntityGraph를 쓰세요.
예시 6: Spring Data JPA 파생 쿼리¶
Spring Data의 파생 쿼리는 JOIN FETCH를 지원하지 않아요. 흔히 빠지는 함정이에요.
예시 7: Hibernate 2차 캐시 미스¶
2차 캐시를 쓰면 캐시가 비워지거나 재시작한 뒤 N+1이 다시 나타날 수 있어요.
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Member {
// ...
}
// Works fine when cache is warm (no SQL at all for members)
// After cache eviction or restart: full N+1 occurs
for (Order order : orders) {
order.getMember().getName(); // Cache miss = SQL query
}
캐시는 N+1의 해결책이 아니에요
캐시는 N+1을 가릴 수 있어요. 캐시가 비워지거나, 재시작하거나, 데이터가 바뀌면 문제가 돌아와요. 캡처된 접근 패턴을 검토하고 그 작업에 맞는 페치 방식을 고르세요.
고치는 방법¶
JOIN을 쓰는 쿼리 하나로 추가 쿼리 N개를 모두 없애요.
페이지네이션 제약
@OneToMany 컬렉션에 JOIN FETCH와 Pageable을 같이 쓰면 Hibernate가 모든 결과를 메모리로 가져와서 애플리케이션에서 페이지를 나눠요(HHH000104 경고). 페이지를 나누는 쿼리에는 @EntityGraph나 @BatchSize를 쓰세요.
@EntityGraph(attributePaths = {"member"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithMember();
JPA가 member 연관관계를 같은 쿼리에서 즉시 가져오게 해요.
중첩된 연관관계라면:
@EntityGraph(attributePaths = {"member", "member.address"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithMemberAndAddress();
엔티티에 정의한 이름 있는 엔티티 그래프라면:
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Member member;
}
Hibernate가 쿼리 N개 대신 WHERE id IN (?, ?, ?, ...)로 묶어서 ceil(N / batchSize)개의 쿼리로 가져와요. 쿼리 하나는 아니지만 크게 줄어요.
고치는 방법 고르기¶
Do you need to modify the entities?
|
+-- YES --> Use JOIN FETCH or @EntityGraph
| |
| +-- Is it a @OneToMany? --> Use @EntityGraph (avoids pagination issues)
| +-- Is it a @ManyToOne? --> Either JOIN FETCH or @EntityGraph
|
+-- NO (read-only) --> Use DTO Projection (most efficient)
Need a safety net for all lazy loads?
+-- Use @BatchSize or hibernate.default_batch_fetch_size
엔티티를 수정해야 하면 JOIN FETCH나 @EntityGraph를 쓰세요. @OneToMany라면 페이지네이션 문제를 피할 수 있는 @EntityGraph가 낫고, @ManyToOne이면 둘 다 괜찮아요. 읽기만 한다면 DTO 프로젝션이 가장 효율적이에요. 모든 지연 로딩에 안전망이 필요하면 @BatchSize나 hibernate.default_batch_fetch_size를 쓰세요.
고치기 전과 후¶
+---------------------------------------------------------------------+
| BEFORE (N+1) | AFTER (JOIN FETCH) |
| | |
| SELECT * FROM orders; | SELECT o.*, m.* |
| SELECT * FROM members | FROM orders o |
| WHERE id = 1; | JOIN members m |
| SELECT * FROM members | ON o.member_id = m.id; |
| WHERE id = 2; | |
| SELECT * FROM members | -- 1 query total |
| WHERE id = 3; | -- All data in one round-trip |
| ... (97 more) | |
| | |
| 101 queries total | |
| 101 network round-trips | |
+---------------------------------------------------------------------+
QueryAudit 리포트 출력¶
감사하는 테스트마다 자기 리포트를 출력해요. 일부:
────────────────────────────────────────────────────────────────────────
QUERYAUDIT REPORT
Test: findOrders()
────────────────────────────────────────────────────────────────────────
--- CONFIRMED (sorted by priority) ---
[ERROR] N+1 Query detected
ID: qa-finding-v1:7452…
Query: select m1_0.id,m1_0.name from members m1_0 where m1_0.id=?
Source: com.example.OrderService.lambda$findOrders$0:42
Target: members
Detail: The same SELECT ran 100 times from one call site
Fix: Load the rows once before the loop: JOIN FETCH, @EntityGraph, or one query with an IN list.
테스트가 실패하면 실패 메시지가 애플리케이션 호출 스택과 함께 이 탐지 결과를 다시 보여 줘요.
설정¶
기준값¶
N+1로 판단하는 최소 반복 횟수예요.
기준값 고르기
기본값 3은 대부분의 실제 N+1을 잡으면서, 캐시 예열처럼 정당하게 두 번 실행되는 쿼리의 오탐은 피해요. 의도적으로 반복하는 쿼리가 많다면 5로 올리세요.
억제하기¶
탐지된 N+1이 의도한 것이라면(예: 일부러 행마다 쿼리를 실행하는 배치 처리기) 억제하세요.
관련 규칙¶
duplicate-query-- 똑같은 쿼리를 찾아요(현재 비활성)repeated-single-insert-- INSERT 문장에서 생기는 비슷한 패턴repeated-single-update-- 고유 키 기준 UPDATE에서 생기는 비슷한 패턴mergeable-queries-- 같은 테이블에 대한 여러 쿼리를 합칠 수 있는 경우