주문 목록을 주문항목까지 한 번에 가져오려고 fetch join에 페이징을 걸었다. 기능은 됐는데 데이터가 몇만 건 쌓이자 목록 API가 6초씩 걸리고 가끔 힙이 터졌다. 로그를 보니 조용히 경고가 찍혀 있었다.
HHH000104: firstResult/maxResults specified with collection fetch;
applying in memory
페이징을 메모리에서 하고 있었다
컬렉션을 fetch join 하면서 페이징을 걸면, DB에서는 limit을 못 건다. 조인 때문에 한 주문이 여러 줄로 뻥튀기돼서 DB의 행 수와 우리가 원하는 엔티티 수가 안 맞기 때문이다. 그래서 하이버네이트는 조건에 맞는 데이터를 전부 읽어 온 다음, 메모리에서 잘라서 페이지를 만든다. 20건 달라고 했는데 실제론 수만 건을 다 퍼올린 뒤 20건만 돌려주고 있었던 거다. 데이터가 적을 땐 티가 안 나다가 쌓이면 그대로 터진다.
ID 먼저, 컬렉션은 배치로
해결은 컬렉션 fetch join을 페이징에서 떼어내는 것이었다. 페이징은 주문 자체에만 걸어 ID나 엔티티를 먼저 가져오고, 주문항목은 @BatchSize나 default_batch_fetch_size로 in (...) 쿼리 몇 방에 나눠 로딩하게 바꿨다.
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이렇게 하니 목록 쿼리는 limit이 정상적으로 DB로 내려갔고, 주문항목은 페이지 크기만큼만 in 절로 묶여 나갔다. 6초짜리 API가 200ms대로 내려왔고 힙도 잠잠해졌다. 컬렉션 fetch join과 페이징은 같이 쓰면 안 된다는 걸 몸으로 배웠다.