엑셀 다운로드가 천여 건에 60초를 넘겼고 메모리 때문에 chunk(200) 으로 나눠 처리하고 있었다. DB::enableQueryLog() 로 쿼리 수부터 세어 보니 청크로 나눴을 때가 한 번에 가져올 때보다 다섯 배 많았다.
Table of contents
Open Table of contents
원인 — 청크마다 반복된 로딩
Order 모델에 $with 로 자동 즉시 로딩이 걸려 있어서 조회할 때마다 여러 관계를 같이 가져오고 있었다. 한 번에 가져오면 그 로딩이 한 번만 돌지만 청크로 나누면 청크 횟수만큼 반복된다.
메모리를 줄이려고 나눈 것이 쿼리를 배수로 늘린 셈이었다. 자동 로딩이 걸린 모델을 청크로 처리할 때 이 교환이 생기므로 나누기 전에 로딩 목록부터 확인해야 한다.
검증 — 안 쓰는 관계들
$with 목록과 OrderExport 가 실제로 참조하는 관계를 대조했다. 셋이 가져오기만 하고 안 쓰이고 있었다.
withoutEagerLoads() 는 이 프로젝트의 Laravel 8 에는 없어서 다른 방법을 찾았다. 두 가지 방법이 있었는데 without() 으로 안 쓰는 셋을 이름으로 빼는 것과 setEagerLoads([]) 로 목록을 비운 뒤 with() 로 필요한 것만 명시하는 것이다. 뒤쪽을 골랐다. 이유는 앞쪽은 $with 에 관계가 추가될 때마다 빼는 목록도 따라 고쳐야 해서다. 대신 내보내기에 필요한 관계가 늘면 이쪽 with() 를 잊지 않고 고쳐야 하는 것을 감수했다.
주의 — 소스가 정한 호출 순서
이 호출에 함정이 있어서 Eloquent\Builder 소스를 열어 봤다. setEagerLoads() 는 배열을 대체하고 with() 는 array_merge 로 병합하고 있었다.
그러면 목록을 비우는 호출이 먼저 오고 필요한 것을 명시하는 호출이 뒤에 와야 한다. 순서를 반대로 쓰면 명시한 것까지 사라지므로 소스를 안 봤으면 순서를 반대로 썼을 것이고 그 실수는 조용히 지나간다.
조치 — 반복된 개수 조회
또 하나 찾은 것이 조건 검사마다 ->count() 를 부르고 있는 코드였다. 같은 COUNT(*) 쿼리가 검사 횟수만큼 실행되고 있었다.
한 번 구해 변수에 담고 그것을 쓰게 고쳤다. 이런 것은 눈에 잘 안 띄는데 각 줄만 보면 자연스러운 코드이기 때문이다.
배경 — 셋을 조합한 결과
개수 캐싱과 로딩 차단과 chunk(1000) 확대 셋을 같이 적용해 쿼리 수를 3분의 1로 줄였다. 청크 크기를 키운 것도 효과가 있었는데 관계 로딩이 청크마다 도므로 횟수가 줄면 쿼리도 준다.
다만 그것은 메모리와의 교환이라 이 데이터에서 감당 가능한 크기로 정했다. 이 작업에서 결정적이었던 것은 문서에 안 나온 동작을 프레임워크 소스에서 확인한 것이었다.
정리
- 자동 즉시 로딩이 청크마다 반복돼 쿼리가 배수로 는다
- 자동 로딩 목록과 실제 사용 관계를 대조하면 미사용이 나온다
- 배제 메서드가 없으면 목록을 비우고 필요한 것만 명시한다
- 그 메서드가 대체인지 병합인지 확인한다
- 순서가 반대면 명시한 것까지 사라진다
- 개수 조회를 반복 호출하면 그만큼 실행되므로 한 번 구해 담는다
- 청크 크기를 키우면 로딩 반복이 주는 대신 메모리를 더 쓴다
- 동작이 애매하면 프레임워크 소스를 읽는다