Skip to content
isdnetworks
Go back

한 번에 많이 받으니 더 느려졌다

목록 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배인데 mapencode 가 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'],
    ];
}

optionsimagesstock 은 목록에서 안 쓴다.

빼니 항목당 조회 셋이 사라졌다.

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),
]);

셋 중 어디가 커졌는지에 따라 손댈 곳이 다르다. 이번에는 그 기록이 없어서 재는 것부터 시작해야 했다.

정리


Share this post on:

Previous Post
공유했는지 확인할 수 없었다
Next Post
최초 실행을 어떻게 테스트하나