Home > AI > Backend > N+1 제거 4종 세트 — fetch join이 답이 아닐 때

N+1 제거 4종 세트 — fetch join이 답이 아닐 때
AI Claude Code JPA 성능최적화 QueryDSL

N+1은 하나의 문제가 아니다

한 달 반 동안 대시보드 조회 성능을 고치면서 N+1을 여러 번 만났다. 매번 처방이 달랐다. “N+1이면 fetch join”이라는 반사가 통하지 않는 경우가 절반 이상이었다.

(a) fetch join을 못 쓸 때 — @BatchSize

f6fd21ef에서 만난 첫 케이스다. 제품 목록 조회에서 product.getLcaList()를 지연 로딩하며 제품당 1쿼리씩 나가고 있었다.

fetch join으로 해결하려 했으나 MultipleBagFetchException이 났다. Product에는 컬렉션이 두 개(lcaList, productUnitProcesses) 있고 둘 다 bag(List, 순서 보장 없음) 이다. Hibernate는 bag 두 개를 동시에 fetch join하면 카테시안 곱을 구분할 수 없어 거부한다.

대안은 @BatchSize(100)이었다. 지연 로딩은 유지하되 N번 나가던 쿼리를 IN 절 배치 조회로 묶는다.

@BatchSize(size = 100)
private List<Lca> lcaList;

511ea939에서도 같은 판단을 했다. 임계 경로인 cfResults는 fetch join으로 잡고, 두 번째 컬렉션인 lciaCfMonthlyMetaList@BatchSize(100)으로 처리했다 — 둘 다 fetch join하면 역시 MultipleBagFetchException이다. 그 직전 커밋(122be661)에서 월별 메타를 fetch join으로 시도했다가 이 조합에서 되돌린 흔적이 남아 있다.

정리하면 컬렉션이 하나면 fetch join, 둘 이상이면 하나만 fetch join하고 나머지는 @BatchSize다.

(b) 루프 안의 집계 쿼리 — 집합을 넓혀 1회 호출

가장 효과가 컸던 건 94dea13cfe2e2c56(PR #688)이다. 이건 엔티티 연관관계 N+1이 아니라 애플리케이션 코드가 루프를 돌며 집계 쿼리를 반복 호출하는 형태였다.

사업장 목록 API는 사업장마다 집계 쿼리 두 개를 호출했다. 제품 목록 API도 제품마다 두 개씩 호출했다.

처방은 새 repository 메서드를 만드는 게 아니라 기존 IN 쿼리의 집합을 넓히는 것이었다. 전 사업장의 대상 id를 합집합으로 모아 쿼리를 1회씩만 날리고, 결과를 groupingBy로 사업장별·제품별로 재분배한다.

경로 이전 이후
사업장 집계 3N 쿼리 ~3 고정
제품 집계 2P 쿼리 2 고정
사업장 LCA 목록 N 쿼리 1

결과가 변하지 않음을 논증하기

이 방식은 “쿼리를 합치고 메모리에서 나눈다”이므로, 합계가 달라질 수 있는지 확인이 필요하다. 커밋 메시지에 근거를 적었다.

행 단위 연산(변환·곱셈·나눗셈)은 개별 행에만 적용되므로 집합을 넓혀도 각 행의 결과는 동일하다. 그리고 BigDecimal의 덧셈은 결합법칙과 교환법칙을 만족하므로, 어떤 순서로 어떻게 묶어 더하든 합계가 같다.

BigDecimal은 임의 정밀도라 double과 달리 덧셈 순서에 따른 오차 누적이 없다. 이 성질이 없었다면 “쿼리 하나로 합치기”는 값이 미세하게 달라질 수 있는 변경이었다. 성능 최적화가 결과 불변임을 논증할 수 있는 변경인지 확인하는 건 리뷰어 입장에서 가장 알고 싶은 부분이라고 본다.

빈 사업장 제외 규칙이나 TreeMap 출력 정렬 같은 기존 동작도 그대로 유지했다.

(c) 전체 로드 후 Java 합산 — SQL SUM

f6fd21ef에 포함된 또 하나. 기간별 총량을 구하는 메서드가 해당 기간의 행을 전부 엔티티로 로드한 뒤 Java에서 더하고 있었다.

SUM(kgAmount) 집계 쿼리로 바꿨다. 행이 늘어날수록 격차가 커지는 종류의 문제라, 지금 당장 느리지 않아도 고칠 가치가 있다. null은 ZERO로 처리했다.

(d) DTO 매퍼가 유발한 N+1 — @EntityGraph 배치 조회

c633557b는 조금 다른 모양이다. 사용자 목록 조회에서 DTO 매퍼가 rg.getRoleGroup().getName()으로 LAZY 프록시를 목록 사용자 수만큼 건드리고 있었다. 서비스 계층 쿼리는 멀쩡했고, 매퍼가 N+1을 만든 것이다.

@EntityGraph(roleGroup)을 붙인 조회 메서드를 추가하고, 서비스에서 Map<Long, List<String>>으로 한 번에 배치 조회해 매퍼에 주입했다. 매퍼는 3인자 오버로드로 확장하고 기존 2인자 호출부는 위임으로 남겨 깨지지 않게 했다.

곁들여 — dev 로깅 끄기

fe2e2c56에는 성능 수정 하나가 더 들어 있다. dev 환경의 P6Spy SQL 로깅을 껐다. 요청당 10여 개 쿼리를 전부 문자열로 포매팅해 로깅하는 고정 오버헤드가 있었다. prod / stg / local은 그대로 뒀다.

측정 환경 자체가 느리면 최적화 효과를 제대로 볼 수 없다. 성능 작업을 할 때 계측 도구가 오버헤드를 만들고 있지 않은지 먼저 확인할 필요가 있다.

남는 교훈

처방을 정리하면 이렇다.

증상 처방
연관 컬렉션 1개 지연로딩 fetch join
연관 컬렉션 2개 이상 1개 fetch join + 나머지 @BatchSize
루프 안 집계 쿼리 반복 IN 집합 확대 + groupingBy 재분배
전체 로드 후 Java 집계 SQL 집계 함수
DTO 매퍼의 프록시 접근 @EntityGraph + Map 배치 주입

N+1은 원인이 여러 갈래인데 증상이 같아서 하나의 처방으로 뭉뚱그리기 쉽다. 쿼리 로그를 보고 “N번 나간다”까지만 확인한 뒤 fetch join을 붙이면, MultipleBagFetchException을 만나거나 (b)처럼 애초에 엔티티 연관관계 문제가 아니어서 붙일 곳조차 없다.

어디서 N이 발생하는지 — 연관 로딩인지, 애플리케이션 루프인지, DTO 매퍼인지 — 를 먼저 구분하는 게 처방보다 앞선다.