N+1은 보통 사후에 발견된다. 더미데이터를 잔뜩 넣고 API 테스트를 하는 도중에 너무 느려서 발견한다던가, 코드리뷰로 발견한다던가 보통 그런식으로 발견해왔다. 물론! 처음부터 N+1이 발생하지 않도록 구현하는게 가장 좋겠지만, 놓칠때도 종종 있다. 요즘은 AI로 코드를 짜는 일이 훨씬 많기 때문에, 더더욱 놓칠때가 많았다.
한 번 fetch join으로 고치고 나서도, 누군가가 연관 엔티티를 lazy하게 건드리는 코드를 추가하면 또다시 N+1이 발생한다. 이런 문제를 겪다보니 문득 테스트코드로 만들면 CI를 돌릴 때 잡을 수 있지 않을까? 하는 생각을 하게 되었다.
로직을 짤 때 쿼리가 몇 번 나가는지를 안다면 수를 비교해서 N+1 발생을 인지할 수 있다. 쿼리가 나가는 수는 Hibernate에서 제공을 해준다! SessionFactory의 Statics가 prepared statement를 실행횟수를 갖고 있어 이것을 이용해 테스트 인프라를 만들어보았다.
테스트 인프라 구축
세팅
Statics는 기본적으로 꺼져있어서 테스트 프로파일에서 켜주어야 한다. 운영환경에서 켜면 모든 쿼리마다 카운터를 갱신해서 오버헤드만 추가되기 때문에 테스트환경에서만 넣어주면 충분하다.
spring:
jpa:
properties:
hibernate:
generate_statistics: true
인프라 구축하기
Statistics statistics = em.getEntityManagerFactory()
.unwrap(SessionFactory.class)
.getStatistics();
statistics.clear(); // 0으로 리셋
action.run(); // 측정 대상 실행
long actual = statistics.getPrepareStatementCount(); // 그동안 나간 SQL 수
이렇게 짜두면 이걸 헬퍼로 감싸서 테스트 할 때 사용할 수 있다! 사용 예시는 아래와 같다.
assertQueryCount(em, 1, () -> bookmarkService.getBookmarkedParties(memberId));
만들어두면 아주 간단하게 사용할 수 있다!
테스트 주의사항
1. 통합테스트에 얹어 실행한다.
이 테스트의 목적은 JPA가 최종적으로 몇 개의 쿼리를 날리는지를 측정하는 것이기 때문에, 모킹을 사용하는 단위테스트에서는 무의미하다. 진짜 EntityManager와 커넥션이 있어야 유의미하므로, 꼭 통합테스트 위에서 실행되어야 한다! 그래서 필자는 도커로 DB를 띄워서 사용했다.
2. 영속성 컨텍스트를 초기화 해야한다.
처음에 테스트코드를 짜고 돌렸는데 테스트가 실패할 때도 있고 성공할 때도 있었다. 원인을 살펴보니 영속성 컨텍스트였다.
JPA의 1차 캐시와 쓰기 지연은 SQL이 언제 실제로 나가는지를 미룬다. 이로 인해 집계가 더 적게 되거나, 더 많이 집계되었다.
- 과소 집계: setup에서 persist한 엔티티가 1차 캐시에 살아있으면, action 안의 findById가 DB를 안 때리고 캐시에서 반환된다 → SELECT가 0번 → 기대값 미달로 실패
- 과다/지연 집계: 쓰기 지연된 INSERT가 측정 창 밖(커밋 시점)에서 나가거나, 반대로 action 안의 JPQL 직전 auto-flush로 끼어들어 카운트를 흔든다
그래서 고민했던 건 equals로 측정하지 않고 부등호를 사용할까? 였다. N+1은 어차피 더 많은 쿼리가 나가는 경우일 것이기 때문에, 적은건 상관없지 않나? 였다. 그런데 이렇게 할 경우 아래 두 케이스를 구분할 수 없다는 단점이 있었다.
- 지연됐지만 커밋 때 결국 나갈 정상 쿼리
- 비정상적으로 누락된 경우
그래서 그냥 영속성 컨텍스트 상태를 강제했다.
- 측정 전 em.clear()
- 1차 캐시를 비움
- 그래야 action의 조회가 캐시가 아니라 진짜 DB를 때린다. (운영의 콜드 캐시 재현 → 과소 집계 제거)
- 측정 후 em.flush()
- action이 유발한 쓰기 지연 SQL을 카운트를 읽기 전에 강제로 내보낸다. (늦게 나가서 측정 창을 벗어나는 문제 제거)
다만 이렇게 캐시를 비우는 경우, 이 메서드 호출 이후에 픽스처 엔티티를 다시 만지면 detach 상태라 lazy 접근에서 LazyInitializationException이 날 수 있기 때문에 유의해야 한다.
이렇게 CI에서 N+1을 잡을 수 있도록 테스트 인프라를 만들었다!
'백엔드 > 스프링' 카테고리의 다른 글
| [Spring] AOP + SpEL을 활용한 Resource 기반 접근 제어 구현하기 (1) | 2026.02.13 |
|---|---|
| [Spring] @Valid를 이용한 유효성 검증 알아보기 (feat. @NotBlank, @NotNull, @NotEmpty) (2) | 2025.07.19 |
| [Spring] JAVA 로깅 알아보기 (로그레벨, @Slf4j) (0) | 2024.04.03 |
| [Spring] 용어정리 - DTO, DAO, TDD (0) | 2024.03.29 |
| [Spring] 기초 어노테이션 학습 - @NoArgsContructor, @Data, @Builder (0) | 2024.03.28 |