목록 화면이 느렸다. 쿼리 로그를 켜고 한 번 열어 봤다.
한 번 여는 데 쿼리가 300번 넘게 나갔다.
Table of contents
Open Table of contents
코드는 한 줄이었다
화면 코드에서 조회하는 자리는 몇 군데 없었다.
foreach ($products as $p) {
echo $p->getCategory()->name;
}
getCategory() 가 안에서 SELECT 을 하는데 그것도 행마다 한 번씩이다.
목록이 100건이면 카테고리 조회가 100번이다. 여기에 브랜드와 재고와 이미지가 붙으면 400번이 된다. 코드만 읽어서는 이것이 안 보인다.
속성을 읽는 것처럼 생겼는데 그 순간 SELECT 이 나가기 때문이고 그 사실이 눈에 안 들어온다.
자료가 적을 때는 열 번쯤이라 느낌으로도 안 잡히고 general_log 를 켜고 세어 봐야 드러난다.
조치 — 미리 가져오면 두 번으로
필요한 것을 반복에 들어가기 전에 한 번에 가져온다.
$ids = array_column($products, 'category_id');
$categories = Category::whereIn('id', $ids)->get()->keyBy('id');
foreach ($products as $p) {
echo $categories[$p->category_id]->name;
}
whereIn 으로 한 번에 가져와 keyBy 로 색인해 두고 반복 안에서는 꺼내 쓰기만 한다. 목록 한 번과 카테고리 한 번으로 끝난다.
행이 100개든 1,000개든 SELECT 수가 같다. 300번이 2번이 되니 화면이 눈에 띄게 빨라졌다.
프레임워크에 이것을 해 주는 기능이 있으면 쓰고 없으면 손으로 한다. 반복 밖으로 빼는 것이 요점이다.
주의 — 반대 방향 함정
미리 가져오기를 적용하다가 다른 문제를 만났다. 모델에 이런 것이 걸려 있었다.
protected $with = ['stock']; // 항상 같이 가져옴
$with 에 적힌 stock 은 그 모델을 가져올 때마다 자동으로 따라오고 편하라고 넣은 것이 조회를 몰래 확장한다.
상품 → 옵션 → 재고
상품 100개에 옵션이 각 20개면 재고 조회의 IN 절에 ID가 2,000개 들어간다. 상품이 1,000개인 화면에서는 20,000개가 된다.
어느 순간 한계를 넘는다. 플레이스홀더 개수 제한도 있고 문장 길이는 max_allowed_packet 에 걸린다.
소량에서는 안 나타난다
이것이 고약했는데 상품이 적은 계정에서는 멀쩡하게 돌기 때문이다.
많은 계정에서만 터지므로 개발 환경에서는 안 보이고 운영에서도 특정 사용자만 겪어 재현이 안 된다.
원인을 알고 나니 재현은 쉬웠고 상품 수가 많은 계정으로 그 화면을 열면 됐다.
여기서도 순서가 반대였는데 재현부터 하려 들면 못 하고 구조를 먼저 보면 어느 계정을 열지가 나온다.
나눠서 가져왔다
한 번에 다 못 가져오면 쪼갠다.
$all = collect();
$query->chunk(200, function ($rows) use (&$all) {
$all = $all->merge($rows);
});
chunk 로 200개씩 나눠 가져와 합치면 각 조회의 IN 절이 한계 안에 들어온다. 조회 수는 늘지만 행 수에 비례하는 것이 아니라 200으로 나눈 값이다.
근본적으로는 안 쓰는 관계를 자동으로 안 가져오는 것이 맞고 재고를 안 보여 주면 가져올 이유가 없다.
$with 를 빼고 필요한 화면에서만 요청하는 쪽이 옳은데 그 모델을 쓰는 곳이 많아 먼저 세어야 했다.
변경 내용 — 기록을 앞으로
이 화면에서 하나를 더 봤는데 목록을 만들다 실패하면 작업 기록 자체가 안 남았다.
INSERT 가 조회 뒤에 있어서 조회에서 죽으면 거기 도달을 못 한다. 사용자에게는 아무것도 안 생긴 것으로 보인다.
오류가 아니라 아무 일도 안 일어난 것처럼 보이는 상태라 INSERT 를 조회 앞으로 옮겼다.
1. 작업 기록 생성 (상태: 진행 중)
2. 조회
3. 상태 갱신 (완료 / 실패)
실패해도 진행 중으로 남아 있는 것이 아무것도 없는 것보다 낫다. 남은 기록이 그 시각에 무언가 시도했다는 것을 말해 준다.
전체 흐름 — 확인 순서
목록이 느리다는 제보를 받으면 이 순서로 본다.
1. 쿼리 수를 센다 — 로그를 켜고 한 번 열어 본다
2. 같은 쿼리가 반복되는지 본다 — 파라미터만 다르면 확실하다
3. 미리 가져오기를 적용한 뒤 다시 센다
4. 데이터가 많은 계정에서 다시 본다
첫째와 둘째로 반복 안 조회인지가 갈리고 셋째는 고친 결과를 같은 방법으로 되재는 것이다.
넷째를 빼먹으면 고쳤다고 하고 나중에 다시 터지는데 적은 계정의 결과가 많은 계정에서도 같지 않다.
정리
- 반복 안에서 관계를 조회하면 코드는 한 줄인데 실행은 행 수만큼이다
- 속성을 읽는 것처럼 보여서 코드로는 잘 안 걸린다
whereIn으로 미리 가져오면 조회 수가 행 수와 무관한 상수가 된다$with에 적힌 관계는 그 모델을 가져올 때마다 따라와 조회를 확장한다- 그 확장이 플레이스홀더 한계와
max_allowed_packet을 넘는다 - 적은 계정에서는 안 나타나고 많은 계정에서만 터진다
- 한 번에 못 가져오면
chunk로 나눠 가져와 합친다 - 근본적으로는 안 쓰는 관계를
$with에서 뺀다 - 기록 삽입이 조회 뒤에 있으면 실패 시 흔적이 없으므로 앞으로 옮긴다
- 고친 뒤 자료가 가장 많은 계정에서 다시 센다