Skip to content
isdnetworks
Go back

인덱스가 아니라 N+1

(수정됨)

상품 목록 화면이 느리다는 얘기가 계속 있었고 인덱스 문제로 보고 있었다. 조건이 복잡하고 테이블이 크니 그럴듯한 짐작이었다.

Table of contents

Open Table of contents

증상 — 쿼리 수 측정

인덱스를 손대기 전에 한 페이지에 쿼리가 몇 개 나가는지부터 셌다. DB::enableQueryLog() 로 쿼리 로그를 켜고 20건짜리 1페이지를 여니 이천 개 가까이 나갔고 상품 한 건에 백 개 가까이였다.

어디서 나오는지 나눠 보니 목록 조회 관련은 1할 남짓이었고 나머지는 전부 접근자였다. 수수료율 접근자가 절반 넘게, 할인 접근자가 3할쯤을 차지했다. 접근자가 9할 가까이였다.

원인 — 곱셈 구조

컨트롤러를 보니 구조가 곱셈이었다. distributions·latestJobs·placeStocks 관계를 상품마다 따로 읽고, 그 안에서 채널별 접근자를 다시 돌린다.

foreach ($products as $product) {
    $product->distributions;
    $product->latestJobs;
    $product->placeStocks;

    foreach ($activeChannels as $channel) {
        $product->{"{$channel}_rate"};
        $product->{"{$channel}_discount"};
    }
}

상품 20건에 활성 채널이 스무 개를 넘고 채널마다 {$channel}_rate·{$channel}_discount 접근자가 둘이므로 여기서만 천 개가 넘는다. 여기에 상품마다 세 번씩 도는 관계 조회 60개와 목록 조회를 더하면 측정한 숫자와 대략 맞는다. 쿼리 수가 상품 수에 비례하는 것이 아니라 상품 수와 채널 수의 곱에 비례한다.

판단 기준 — 인덱스로 안 되는 이유

이 구조를 확인하고 나니 인덱스 튜닝으로는 체감이 개선되지 않는다는 결론이 나왔다. 쿼리 하나하나는 빠를 수 있고 1ms 라고 해도 이천 개면 2초이며, 각각에 왕복이 있으므로 네트워크 지연이 0.5ms 만 돼도 1초가 더 붙는다. Laravel 공식 문서의 Eager Loading 절이 이 모양을 N + 1 문제로 부르고, 부모를 읽을 때 with() 로 관계를 한 번에 읽는 것을 해법으로 든다.

쿼리 하나를 빠르게 하는 문제가 아니라 개수를 줄이는 문제였다. 같은 증상을 두고 원인의 종류가 다르면 손댈 곳도 다르다.

조치 — 개선 순서와 대상 경로

셋 중 무엇을 먼저 잡을지는 비중으로 정했다. 목록 조회를 배치화해도 1할 남짓이라 체감이 안 나고, 수수료율과 할인 접근자를 잡으면 9할 가까이가 줄어 이천 개가 수백 개 수준이 된다. 비중이 큰 것부터 잡는다는 것은 당연한데 실제로는 고치기 쉬운 것부터 하고 싶어지므로 숫자를 먼저 보는 것이 중요했다.

접근자를 잡는 데는 두 가지 방법이 있었는데 채널별 수수료율과 할인을 목록 쿼리 뒤에 한 번에 읽어 컬렉션에 실어 두는 것과 접근자 안에 요청 단위 캐시를 두는 것이다. 앞쪽을 골랐다. 이유는 접근자 안의 캐시는 누가 언제 무효화하는지 규칙을 하나 더 만들어서다. 대신 접근자 호출부를 전부 찾아 컬렉션 조회로 바꾸는 것을 감수했다.

같은 방식으로 다른 화면도 셌다. 마스터 목록이 이천 개 가까이인데 마켓 목록은 수백 개이고 상세와 수정 화면은 수십 개였다. 상세 화면 코드를 보니 with() 로 관계를 미리 로드하고 있었다.

$product = Product::with(['distributions', 'options', 'media'])->find($id);

같은 코드베이스인데 화면마다 다르다. 누가 언제 손댔느냐에 따라 갈린 것인데, 잘 된 화면이 있으면 그것이 바로 참고 구현이 된다.

어느 화면을 고쳐야 하는지도 정확히 지목했다. 느린 것은 관리 콘솔 앱이 아니라 구 API를 호출하는 특정 경로였고 새 앱 쪽은 다른 API를 쓰고 있었다. 상품 화면이 느리다는 말을 경로 하나로 좁히지 않으면 엉뚱한 곳을 고친다.

검증 — 인덱스 확인 결과

인덱스가 원인이 아니라고 결론지었지만 확인 자체는 했다. products 조회의 EXPLAIN 을 보니 OR 조건 때문에 옵티마이저가 idx_provider_created 를 고르지 않고 있었고, FORCE INDEX 로 지정하니 사용은 됐다.

FROM products FORCE INDEX (idx_provider_created)
-- rows: 32,692

사용은 가능한데 자동 선택은 안 되는 상태다. 지금 병목은 아니므로 고치지 않고 별도 항목으로 적어 뒀다. 조사 과정에서 확인한 사실은 원인이 아니어도 기록해 두면 다음 사람이 같은 확인을 반복하지 않는다.

정리


Share this post on:

Previous Post
도매 API 연동 시 Rate Limit 분산 전략
Next Post
백지에서 시작한 새 레코드