15초 걸리던 API: JPA N+1 문제가 보낸 청구서



“목록 화면이 느려요”

목록 화면은 서비스의 현관이다. 사용자가 하루에도 수십 번 여닫는 문이고, 그래서 이 문이 무거워지면 불만은 가장 먼저, 가장 크게 들어온다.

내가 운영에 참여하는 시스템(농산물 생산 이력 관리, Spring Boot + JPA/Hibernate + PostgreSQL)에도 그런 문이 하나 있었다. 수확 이력 목록 API. 조회 버튼을 누르면 응답까지 '10초에서 15초'가 걸렸다. 페이지가 죽은 게 아닌가 싶어 새로고침을 누르면 다시 15초. 코드를 열어봐도 이상한 곳은 없어 보였다 — 리포지토리에서 목록을 조회하고, 화면에 넘겨줄 뿐인 평범한 코드였다.

평범해 보이는 코드가 15초를 쓰고 있다면, 코드가 아니라 코드가 '시키는 일'을 봐야 한다. 지난 글에서 데이터베이스를 창고에, JPA를 통역 직원에 비유했는데 — 이번 사건은 그 통역 직원에게 일을 어떻게 시키고 있었는지를 확인한 기록이다.


로그를 켜고, 세어봤다

JPA의 편리함은 SQL을 안 보이게 해주는 데서 온다. 그리고 문제의 90%는 안 보이는 그 SQL에 있다. 그래서 첫 조치는 로그 레벨을 debug로 내려 Hibernate가 실제로 실행하는 SQL을 전부 출력시키는 것이었다.

목록 API를 한 번 호출했다. 콘솔에 SQL이 흐르기 시작했다. 흐르고, 흐르고, 계속 흘렀다. 목록 '한 페이지'를 만드는 데 실행된 쿼리는 '수천 개'였다.

창고 비유로 옮기면 이렇다. 통역 직원에게 “수확 이력 목록 한 장 뽑아줘”라고 부탁했더니, 직원이 창고를 수천 번 왕복하고 있었다. 한 번 갈 때마다 물건 하나씩만 들고서.

통역 직원이 상자 하나씩만 들고 책상과 창고 사이를 수없이 왕복하며 발자국 트레일을 남기는 일러스트.
목록 한 장을 부탁했을 뿐인데, 통역 직원은 창고를 수천 번 왕복하고 있었다.

숫자를 눈으로 세는 순간 문제는 절반쯤 풀린 것이다. 이제 '왜 수천 번인가'를 알면 된다.


왜 수천 번인가: N+1의 원리

JPA는 연관된 데이터를 기본적으로 '게으르게(LAZY)' 가져온다. 수확 엔티티를 조회할 때 연관 데이터까지 다 들고 오는 게 아니라, 코드가 실제로 그 데이터를 만지는 순간마다 그때그때 쿼리를 날린다. 평소에는 합리적인 전략이다 — 안 쓸 데이터를 미리 나를 이유는 없으니까.

문제는 목록이다. 이 시스템의 수확(Harvesting) 엔티티에는 LAZY 연관 컬렉션이 '7개' 매달려 있었다. 건조, 포장, 폐기 대상, 검사, 분류 대상, 아이템, 그리고 재배 정보. 목록 쿼리 1번으로 수확 N건을 가져온 뒤, 화면에 필요한 정보를 채우려고 각 행의 컬렉션을 만지는 순간 — 행마다, 컬렉션마다 개별 쿼리가 나간다. 목록 쿼리 1개에 N×7개의 쿼리가 따라붙는 것이다. 이것이 'N+1 문제'다.

이 시스템은 한술 더 떴다. 태그 정보는 '수확 → 재배 → 포트 → 부착 태그 → 태그'로 4단계를 타고 내려가야 했다. 단계마다 LAZY 경계가 있으니 왕복은 곱으로 는다. 수천 개라는 숫자는 과장이 아니라 산수였다.


원인은 하나가 아니었다

“N+1은 fetch join으로 잡는다”까지는 나도 알고 있었다. fetch join은 연관 데이터를 처음부터 한 쿼리로 같이 들고 오게 하는 JPQL 문법이고, 비슷한 일을 어노테이션으로 시키는 @EntityGraph도 있다. 그런데 이 사건이 교과서와 갈라지는 지점이 여기다. 처방을 넣자 병이 낫는 게 아니라 다른 병이 튀어나왔다.

첫 번째 벽. @EntityGraph로 컬렉션 두 개를 같이 fetch하도록 걸자 Hibernate가 예외를 던졌다.

org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags

Hibernate는 순서 보장이 없는 List 타입 컬렉션을 'bag'이라고 부르는데, bag 두 개 이상을 한 쿼리로 동시에 eager fetch하는 것을 허용하지 않는다. 컬렉션이 7개인 엔티티에서 이 제약은 치명적이다.

두 번째 벽. 그럼 fetch 경로를 이리저리 넓혀보면? 컬렉션 조인은 결과 행 수를 '곱집합'으로 불린다. 수확 1건에 건조 3건, 포장 4건이 붙어 있으면 조인 결과는 3×4=12행이 된다. 컬렉션 몇 개만 엮어도 행 수가 폭발한다 — DB가 나르는 데이터가 페이지 하나 분량이 아니라 트럭 분량이 되는 것이다.

세 번째 벽은 쿼리 밖에 있었다. 코드를 더 읽어보니 이 API는 페이징을 DB에 맡기지 않고 Pageable.unpaged()로 '전체 데이터'를 메모리에 올린 뒤, 자바에서 잔량(남은 재고량) 조건을 필터링하고 손으로 페이지를 잘라내고 있었다. 잔량이 계산 값이라 SQL WHERE로 거르기 어렵다는 이유였는데, 결과적으로 매 요청이 전수 조사가 됐다.

정리하면 이 15초는 N+1 하나의 값이 아니었다. 'N+1 × bag 제약 × 곱집합 × 전량 로드'라는 4중 복합 청구서였다.


처방전: 한 방이 아니라 역할 분담

해결은 만능 한 방이 아니라, 도구마다 맡을 곳을 나눠주는 쪽이었다. 실제 적용한 조합은 이렇다.

조치역할
컬렉션 타입 ListSetbag 제약(MultipleBagFetchException) 회피
@BatchSize(size = 100)N개의 개별 쿼리를 IN (id1, ..., id100) 배치 쿼리로 — 왕복 N번이 N/100+1번이 된다
@EntityGraph에는 @ManyToOne행 수를 불리지 않는 단건 연관만 조인으로, 컬렉션은 전부 BatchSize에 위임
hibernate.default_batch_fetch_size=100위 전략을 전역 기본값으로
DB 페이징 우선unpaged() 폐기. 페이지만 조회하고, 잔량 필터는 페이지 안에서만 적용

창고 비유로 마무리하면: 왕복 자체를 없앤 게 아니라, 직원에게 '100개들이 카트'를 쥐여주고(BatchSize), 트럭이 필요한 짐(컬렉션)과 손에 들 짐(단건 연관)을 구분해준 것이다.

통역 직원이 상자 100개를 실은 카트를 한 번에 밀고 오는 일러스트. 옆에 과거의 수많은 발자국 흔적이 흐릿하게 남아 있다.
왕복을 없애는 게 아니라 카트를 쥐여주는 것 — BatchSize가 하는 일이다.

마지막 줄이 중요하다. '잔량 필터는 페이지 안에서만'은 공짜가 아니다 — 페이지 단위로 거르면 페이지마다 표시 건수가 들쭉날쭉해질 수 있다. 정확한 필터링을 조금 양보하고 응답 속도를 확보한 트레이드오프이고, 우리는 이 선택을 그대로 문서에 남겼다. 모든 처방이 그렇듯 부작용 기록까지가 처방이다.

결과: 10~15초 걸리던 응답이 '0.5초 안팎'으로 내려왔다. 같은 계열 문제(N+1에 죽은 조인, 페이지네이션 부재까지 겹친)로 5분까지 갔던 다른 목록 API도 같은 접근으로 수 초로 내려왔다.

같은 패턴을 로컬에서 재현해봤다. 엔티티 2개(수확→건조 LAZY 컬렉션), 수확 200건×건조 3건짜리 미니 데모다. 배치 설정 없이 목록을 돌리자 Hibernate 통계 기준 'SQL 201개'(목록 1 + 컬렉션 200), default_batch_fetch_size=100 하나를 켜자 'SQL 3개'(목록 1 + 100건씩 IN 배치 2). 201에서 3으로, 67분의 1이다.

아래 로그의 IN 절에 늘어선 물음표들은 깨진 출력이 아니다. Hibernate는 값을 SQL 문자열에 직접 넣지 않고 ? 자리에 따로 바인딩하는 PreparedStatement 방식을 쓰는데, 그래서 물음표 100개가 곧 'ID 100개를 한 번에 물었다'는 증거가 된다. 재현 코드는 공부 노트 저장소의 demo 폴더에 있으니 직접 돌려봐도 좋다.

로컬 재현 데모의 Hibernate SQL 로그. 위는 동일한 개별 SELECT가 반복되며 SQL 201개가 실행된 N+1 상태, 아래는 물음표 100개가 늘어선 IN 배치 쿼리 2개로 총 3개에 끝난 상태.
재현 데모 로그(수확 200건×건조 3건, H2) — 배치 설정 하나로 SQL 201개가 3개가 됐다.

실무 조언: 쿼리를 세는 습관

이 사건이 남긴 메모다.

  1. 'JPA가 느리다'는 진단은 대부분 틀렸다. JPA가 어떤 SQL을 만드는지 '안 보는' 개발자가 느린 것이다. 목록 API를 만들었다면 debug 로그로 쿼리 개수를 한 번은 세어보자. 세는 데 5분, 안 세면 15초짜리 API가 된다.
  2. fetch join / @EntityGraph / @BatchSize는 경쟁 관계가 아니라 분업 관계다. 행 수를 안 불리는 단건 연관은 조인으로, 컬렉션은 배치로 — 기준은 '이 연관이 결과 행 수를 곱하는가'다.
  3. 성능 조치의 트레이드오프는 코드가 아니라 문서에 남는다. '무엇을 양보했는지'를 적어두지 않으면, 다음 사람(대개 미래의 나)이 그 양보를 버그로 오해한다.

마치며: 청구서는 한 장이 아니었다

N+1은 한국 블로그에서 가장 많이 다뤄진 JPA 주제일 것이다. 나도 개념은 알고 있었다. 그런데 실전은 개념 문제를 한 장씩 내주지 않았다 — bag 제약, 곱집합, 전량 로드가 한 청구서에 묶여 날아왔고, 그걸 뜯어보게 만든 건 결국 로그에 찍힌 SQL을 '세어보는' 일이었다. SQL을 다시 공부하기로 한 뒤 가장 먼저 몸에 붙은 습관이 이거다. 통역 직원을 의심하는 게 아니라, 통역 결과를 눈으로 확인하는 것.

그런데 이 사건으로 쿼리 수를 100분의 1로 줄이고 나서도, 목록 화면의 문제는 끝나지 않았다. 얼마 뒤 다른 목록 화면에서 신고가 들어왔는데 — 이번에는 범인이 쿼리 수가 아니었다.

댓글 남기기