목록 API가 느렸다. 한 페이지에 100건인데 호출 수가 많아서 그렇다고 보고 500건으로 올렸다. 호출 수는 5분의 1이 됐는데 전체 시간은 늘었다.
Table of contents
Open Table of contents
증상 — 크게 받으니 더 느려졌다
응답 하나를 구간별로 쟀다.
$t0 = microtime(true);
$rows = $this->repo->fetch($cond, $size);
$t1 = microtime(true);
$dto = array_map([$this, 'toDto'], $rows);
$t2 = microtime(true);
$body = json_encode($dto, JSON_UNESCAPED_UNICODE);
$t3 = microtime(true);
100건과 500건을 같은 조건으로 놓고 비교했다.
size=100 fetch 38ms map 12ms encode 9ms total 59ms
size=500 fetch 61ms map 71ms encode 88ms total 220ms
fetch 는 1.6배인데 map 과 encode 가 6배에서 10배다.
건수가 5배인데 전체가 3.7배로 늘었다. 호출 수는 5분의 1이 됐지만 한 번의 값이 그보다 더 비싸진 것이다.
구간을 나눠 쟀다
전체 시간만 재고 있었으면 느려졌다는 것만 알고 여기서 막혔을 것이다.
세 구간이 서로 다른 배수로 늘었다는 것이 답을 좁혔다. fetch 가 안 늘었으니 DB 쪽이 아니고 map 이 가장 많이 늘었으니 건마다 붙는 일이 있다는 뜻이다.
응답 크기도 같이 봤다.
size=100 응답 148 KB
size=500 응답 812 KB
148KB에서 812KB면 5.5배라 건수보다 조금 더 늘었고 압축과 전송에도 그만큼 시간이 붙는다.
원인 — 항목 하나의 무게
map 이 하는 일을 열어 봤다.
private function toDto(array $r): array
{
return [
'no' => (int)$r['no'],
'name' => $r['name'],
'options' => $this->options->of($r['no']),
'images' => $this->images->of($r['no']),
'stock' => $this->stock->of($r['no']),
...
];
}
항목마다 조회가 셋 붙어서 500건이면 1,500번이 된다.
fetch 가 아니라 map 에 시간이 실린 이유가 이것이었다. 목록을 가져오는 조회는 한 번인데 그 뒤에 건별 조회가 따라붙고 있었다.
이런 구조에서는 키운 만큼 건별 조회가 그대로 곱해지므로 페이지를 키울수록 나빠진다.
조치 — 목록용과 상세용 분리
화면이 실제로 쓰는 필드를 셌다. 여덟 개를 쓰는데 응답에는 서른한 개가 있었다.
// 목록용과 상세용을 나눴다
private function toListDto(array $r): array
{
return [
'no' => (int)$r['no'],
'name' => $r['name'],
'price' => (int)$r['price'],
'thumb' => $r['thumb'],
'state' => $r['state'],
];
}
options 와 images 와 stock 은 목록에서 안 쓴다.
빼니 항목당 조회 셋이 사라졌다.
size=500 fetch 61ms map 8ms encode 19ms total 88ms
220밀리초가 88밀리초가 됐고 map 은 71에서 8로 떨어졌다.
호출 수를 줄이려고 페이지를 키우면 한 응답의 값이 커진다. 어느 쪽이 싼지는 항목이 얼마나 무거운지에 달렸으니 항목을 먼저 가볍게 하고 크기는 그다음에 정할 일이었다.
부수 조회를 묶었다
상세 화면에는 그 세 조회가 여전히 필요했다.
$nos = array_column($rows, 'no');
$optMap = $this->options->ofMany($nos);
$imgMap = $this->images->ofMany($nos);
$stockMap = $this->stock->ofMany($nos);
건마다 부르지 않고 식별자를 모아 한 번에 가져온다.
SELECT product_no, ... FROM product_option WHERE product_no IN (?, ?, ...);
조회 수가 건수와 무관해져서 500건이든 100건이든 나가는 조회는 세 번이다.
다만 IN 목록이 길어지면 그것대로 느려진다.
foreach (array_chunk($nos, 200) as $chunk) { ... }
200개씩 잘라 도니 목록 길이가 한계를 넘지 않았고 한 번에 다 넣는 것이 언제나 나은 것은 아니었다.
제약 — 페이지 크기 상한과 기록
호출하는 쪽이 크기를 정하게 두면 언젠가 아주 큰 값이 들어온다.
$size = (int)($params['size'] ?? 50);
$size = max(1, min($size, 200));
최대 200으로 막고 그 이상이 필요하다는 요청이 오면 그때 이유를 보고 정하기로 했다.
상한이 없으면 한 요청이 서버를 오래 잡고 그동안 다른 요청이 밀린다. 느린 요청 하나가 자기 응답만 늦추는 것이 아니다.
구간별 시간은 로그에 남겼다.
Log::info('list.timing', [
'size' => $size, 'fetch' => $ms($t1-$t0), 'map' => $ms($t2-$t1), 'encode' => $ms($t3-$t2),
]);
셋 중 어디가 커졌는지에 따라 손댈 곳이 다르다. 이번에는 그 기록이 없어서 재는 것부터 시작해야 했다.
정리
- 페이지를 키우면 호출 수는 줄고 한 응답의 값이 커진다
- 조회와 변환과 직렬화를 나눠 재야 어디가 병목인지 보인다
- 세 구간이 다른 배수로 늘어난 것이 답을 좁혔다
- 항목마다 부수 조회가 붙으면 크기를 키울수록 나빠진다
- 무엇이 무거운지 보기 전에 크기부터 건드리지 않는다
- 목록과 상세의 필드를 나누고 화면이 안 쓰는 것은 뺀다
- 부수 조회는 식별자를 모아 한 번에 가져온다
IN목록이 길어지면 덩어리로 잘라 부른다- 페이지 크기에 상한을 둔다
- 느린 요청 하나가 다른 요청까지 밀리게 한다
- 구간별 시간을 남겨 두면 다음에 어디를 볼지 바로 갈린다