상세 화면이 2초 걸렸다. 부르는 것을 줄일 수 없었는데 순서를 바꾸니 빨라졌다.
Table of contents
Open Table of contents
증상 — 순서대로 부르던 네 호출
화면 하나를 그리는 데 외부를 넷 부르고 있었다.
$product = $this->productApi->get($no); // 320ms
$stock = $this->stockApi->get($no); // 180ms
$review = $this->reviewApi->summary($no); // 640ms
$related = $this->recommendApi->related($no); // 820ms
합이 1,960밀리초이고 하나를 기다렸다가 다음을 부르는 구조였다.
curl_getinfo 로 각 호출에 걸린 시간을 재 보니 그 합이 전체 시간과 거의 같았다. 화면 쪽 코드가 느린 것이 아니라 기다림이 그대로 쌓인 것이었다.
넷 사이에 의존이 있는지부터 봤다. related 만 product 의 분류를 쓰고 나머지 셋은 서로 관계가 없었다.
독립인 것을 같이 불렀다
관계가 없는 셋을 함께 띄웠다.
$promises = [
'product' => $this->productApi->getAsync($no),
'stock' => $this->stockApi->getAsync($no),
'review' => $this->reviewApi->summaryAsync($no),
];
$results = Promise\settle($promises)->wait();
$related = $this->recommendApi->related($no, $results['product']['value']['category']);
셋을 같이 부르면 가장 느린 것만큼만 걸린다.
전 320 + 180 + 640 + 820 = 1,960ms
후 max(320, 180, 640) + 820 = 1,460ms
500밀리초가 줄었는데 부르는 개수는 그대로다.
stock 과 review 를 기다리던 시간이 product 를 기다리는 동안에 겹쳐 들어간 것이다. 줄인 것은 일의 양이 아니라 기다림이 겹치지 않던 부분이었다.
남은 820밀리초가 여전히 컸다. recommendApi 호출이 뒤에 혼자 붙어 있어서 그 시간은 겹칠 자리가 없었다.
의존이 진짜인지 다시 봤다
related 가 분류를 실제로 무엇에 쓰는지 코드를 열어 봤다.
public function related(int $no, string $category): array {
return $this->call('/recommend/related', ['no' => $no, 'cate' => $category]);
}
cate 를 그대로 넘기기만 할 뿐 이쪽에서 쓰는 데는 없었다.
상대 쪽에 물어보니 cate 는 없어도 되는 값이었다. 있으면 추천 정확도가 조금 오르는 정도라고 했다.
$promises = [
'product' => ..., 'stock' => ..., 'review' => ...,
'related' => $this->recommendApi->relatedAsync($no),
];
넷을 다 같이 부르니 820밀리초로 떨어졌다.
의존이 있다고 본 것이 실제로는 없었다. 코드만 읽었으면 cate 를 넘기는 줄과 $results['product'] 를 꺼내는 줄을 보고 필수라고 판단했을 것이다.
변경 내용 — 화면 위쪽과 아래쪽
넷이 화면에서 나오는 자리도 서로 달랐다.
위 상품 정보, 재고
중 리뷰 요약
아래 관련 상품
관련 상품은 스크롤을 내려야 보이므로 처음 응답에 없어도 된다.
// 첫 응답에는 위쪽 것만
$data = ['product' => ..., 'stock' => ...];
// 아래쪽은 화면이 뜬 뒤 따로 가져간다
GET /product/{no}/related
첫 화면이 320밀리초에 뜨고 나머지는 뒤에 채워진다.
한 번에 전부 주려던 것을 GET /product/{no}/related 를 따로 두어 두 번으로 나눈 것뿐이다. 사람이 기다리는 시간은 화면이 뜰 때까지이지 자료가 다 올 때까지가 아니었다.
대응 — 실패와 시간 제한
넷 중 하나가 실패했을 때의 처리도 갈랐다.
foreach ($results as $k => $r) {
if ($r['state'] !== 'fulfilled') {
if ($k === 'product') { throw new NotFound(); } // 이건 없으면 화면이 안 된다
$data[$k] = null; // 나머지는 비워 둔다
}
}
product 가 없으면 화면이 성립하지 않고 나머지는 비어도 화면이 뜬다.
전에는 하나만 실패해도 전체가 오류였다. reviewApi 가 잠깐 안 될 때 상세 화면이 통째로 안 열렸다.
시간 제한도 같은 기준으로 갈랐다.
$this->productApi->timeout(2.0);
$this->reviewApi->timeout(0.5);
$this->recommendApi->timeout(0.5);
productApi 는 2초까지 기다리고 나머지는 0.5초에서 끊는다.
전체를 한 값으로 두면 길게 줄 때는 없어도 되는 것 때문에 화면이 늦어지고 짧게 줄 때는 필수인 것이 먼저 끊긴다. 둘을 가르고 나서야 양쪽을 다 만족시킬 수 있었다.
검증 — 중앙값과 느린 구간
바꾼 뒤에 실제 사용자 응답 시간을 봤다.
전 중앙값 1,940ms 상위 5% 4,200ms
후 중앙값 340ms 상위 5% 1,100ms
access log 의 응답 시간으로 중앙값과 상위 구간을 나란히 놓고 봤다.
평균은 아주 느린 몇 건에 끌려가서 좋아졌는지 나빠졌는지가 흐려진다. 중앙값은 보통의 요청이 어떤지를 보여주고 상위 5퍼센트는 가장 나쁜 경우를 보여준다.
상위 5퍼센트가 4.2초에서 1.1초가 된 것이 컸다. 답답하다고 말이 나오는 쪽은 340ms 가 아니라 이쪽이다.
정리
- 순서대로 부르면 기다리는 시간이 그대로 더해진다
- 독립인 것을 같이 부르면 가장 느린 것만큼만 걸린다
- 줄인 것은 일의 양이 아니라 겹치지 않던 기다림이다
- 의존이 있다고 생각한 것이 실제로는 없을 수 있다
- 코드만 읽으면 넘기는 값이 필수인지 아닌지 안 갈린다
- 화면 위쪽과 아래쪽을 갈라 필요한 것만 먼저 낸다
- 사람이 기다리는 시간은 화면이 뜰 때까지다
- 실패했을 때 필수인지 아닌지를 가르고 아닌 것은 비워 둔다
- 시간 제한을 하나로 두면 양쪽 중 하나를 포기하게 된다
- 평균이 아니라 중앙값과 느린 구간을 본다