<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Read Performance on 0AndWild_log</title><link>https://0andwild.com/categories/read-performance/</link><description>Recent content in Read Performance on 0AndWild_log</description><generator>Hugo -- gohugo.io</generator><language>ko-KR</language><lastBuildDate>Thu, 18 Jun 2026 17:19:15 +0900</lastBuildDate><atom:link href="https://0andwild.com/categories/read-performance/index.xml" rel="self" type="application/rss+xml"/><item><title>정렬 옵션에 따른 조회 성능 저하 어떻게 해결 할 수 있을까?</title><link>https://0andwild.com/posts/260618_read_improvement/</link><pubDate>Thu, 18 Jun 2026 17:19:15 +0900</pubDate><guid>https://0andwild.com/posts/260618_read_improvement/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post 정렬 옵션에 따른 조회 성능 저하 어떻게 해결 할 수 있을까?" /&gt;&lt;h2 id="목표"&gt;&lt;a href="#%eb%aa%a9%ed%91%9c" class="header-anchor"&gt;&lt;/a&gt;목표
&lt;/h2&gt;&lt;p&gt;상품목록 조회 성능의 개선을 위해 실행계획을 분석하며 쿼리 방식, 인덱스, 구조 변화 를 차례대로 진행해본다. &lt;br&gt;
10만개의 상품과 495만개의 좋아요 더미 데이터를 기반으로 조회 성능 비교를 진행해본다.&lt;/p&gt;
&lt;h2 id="문제-정의"&gt;&lt;a href="#%eb%ac%b8%ec%a0%9c-%ec%a0%95%ec%9d%98" class="header-anchor"&gt;&lt;/a&gt;문제 정의
&lt;/h2&gt;&lt;p&gt;우선 가장 성능에 문제가 될 부분은 상품 목록 조회 시 좋아요 기반 정렬 옵션이 존재 할 때이다.
현재 상품(Product) 과 좋아요(Likes) 테이블 이 존재하고 유저는 상품 당 한 번의 좋아요 를 누를 수 있으면 좋아요가 생성 될 때마다 1 개의 row 가 추가되고 다시 좋아요 취소를 누르면 해당 row 는 hard delete 된다.&lt;/p&gt;
&lt;p&gt;유저 수 가 적고 상품 데이터가 적으면 사실 잘못된 쿼리 방식을 사용 하거나 데이터를 풀 스캔 하더라도 성능 이슈가 발생하지 않는다.
하지만 만약 상품이 수백 수천만 건 존재하고 유저가 수십 수백만 정도의 규모라면 Likes 테이블의 데이터는 순식간에 수백 수천만 건 +a 으로 늘어날 것이다.
이떄 상품 조회 시 마다 좋아요 기반 정렬을 위해 좋아요 테이블의 집계 쿼리를 날리게 된다면 DB가 터지고 말 것이다.&lt;/p&gt;
&lt;p&gt;이런 상황을 가정하여 쿼리 개선, 인덱스 추가, 캐시 추가 를 순차적으로 진행해보면서 성능이 어느정도 개선이 되는지 직접 확인 해보고자 한다.
현재 Likes 테이블은 member_id, product_id 를 가지고 있으며 이 두 column 은 Unique key 로 설정이 되어 중복을 방지하고 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="집계-쿼리를-직접-실행해보고-결과를-확인해보자"&gt;&lt;a href="#%ec%a7%91%ea%b3%84-%ec%bf%bc%eb%a6%ac%eb%a5%bc-%ec%a7%81%ec%a0%91-%ec%8b%a4%ed%96%89%ed%95%b4%eb%b3%b4%ea%b3%a0-%ea%b2%b0%ea%b3%bc%eb%a5%bc-%ed%99%95%ec%9d%b8%ed%95%b4%eb%b3%b4%ec%9e%90" class="header-anchor"&gt;&lt;/a&gt;집계 쿼리를 직접 실행해보고 결과를 확인해보자.
&lt;/h2&gt;&lt;p&gt;여기서 EXPLAIN 과 EXPLAIN ANALYZE 의 차이는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; -&amp;gt; 쿼리를 실행하지 않고 실행 과정을 예측&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; -&amp;gt; 쿼리를 실행하고 실행 과정을 분석&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: like_cnt DESC (actual time=11413..11416 rows=100000 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on &amp;lt;temporary&amp;gt; (actual time=11377..11389 rows=100000 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Aggregate using temporary table (actual time=11377..11377 rows=100000 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Left hash join (ls.product_id = p.id) (cost=21.8e+9 rows=217e+9) (actual time=603..997 rows=4.95e+6 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9258 rows=44167) (actual time=0.0618..20.9 rows=100000 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9258 rows=88334) (actual time=0.0587..17.8 rows=100000 loops=1) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on ls using uk_likes_member_id_product_id (cost=244 rows=4.92e+6) (actual time=0.457..347 rows=4.95e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260618_read_improvement/img1.png" width="300"&gt;
&lt;/figure&gt;

&lt;p&gt;먼저 첫번째 쿼리 실행 시간은 대략 11초 정도 걸렸다. 요청당 11초+a 면 이미 서버는 저세상이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;왜 잘못된 쿼리인가 ?&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;쿼리 실행결과 분석을 살펴보면 잘못된 점이 명확하다.
495만건의 likes 테이블을 집계를 하지 않고 10만건의 product 테이블에 먼저 join 을 걸어 product + likes 가 합쳐진 495 만 건의 데이터가 생성되고
이를 기반으로 집계를 하는 부분에서 10초 가량이 소모되었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;그럼 쿼리를 어떻게 개선할 수 있을까?&lt;/code&gt;&lt;/p&gt;
&lt;h3 id="join-하기-전-집계를-수행한다"&gt;&lt;a href="#join-%ed%95%98%ea%b8%b0-%ec%a0%84-%ec%a7%91%ea%b3%84%eb%a5%bc-%ec%88%98%ed%96%89%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;JOIN 하기 전 집계를 수행한다.
&lt;/h3&gt;&lt;p&gt;먼저 product 테이블에 join 을 하기전에 likes 집계를 먼저 하면 현재 상품은 10만건 이기 때문에 10만 이하로 join 할 데이터를 줄일 수 있다.
또한 group by 대상을 product_id 하나로만 두면 되기 때문에 집계의 성능도 올라간다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ls.like_cnt DESC (actual time=1060..1064 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=625857 rows=0) (actual time=940..1028 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop left join (cost=625857 rows=0) (actual time=940..1016 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=10532 rows=44167) (actual time=0.0611..24.6 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=10532 rows=88334) (actual time=0.0574..20.9 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Index lookup on ls using &amp;lt;auto_key0&amp;gt; (product_id=p.id) (cost=0.25..13.9 rows=55.7) (actual time=0.00969..0.00982 rows=0.99 loops=100000)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Materialize (cost=0..0 rows=0) (actual time=939..939 rows=99000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on &amp;lt;temporary&amp;gt; (actual time=903..909 rows=99000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Aggregate using temporary table (actual time=903..903 rows=99000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on likes using uk_likes_member_id_product_id (cost=521764 rows=4.92e+6) (actual time=0.275..332 rows=4.95e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;실행 결과를 확인해보면 의도한 대로 &lt;strong&gt;likes를 먼저 집계&lt;/strong&gt; → &lt;strong&gt;product와 join&lt;/strong&gt; → &lt;strong&gt;정렬&lt;/strong&gt; 이 진행되었다.&lt;/p&gt;
&lt;p&gt;우선 최종 반환 시간은 첫번째 저세상 쿼리와 비교 하였을 때&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;11.416/s&lt;/strong&gt; -&amp;gt; &lt;strong&gt;1.064/s&lt;/strong&gt; 로 실행시간 약 &lt;strong&gt;91%&lt;/strong&gt; 감소.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="좋아요-테이블에-index-를-추가해-보자"&gt;&lt;a href="#%ec%a2%8b%ec%95%84%ec%9a%94-%ed%85%8c%ec%9d%b4%eb%b8%94%ec%97%90-index-%eb%a5%bc-%ec%b6%94%ea%b0%80%ed%95%b4-%eb%b3%b4%ec%9e%90" class="header-anchor"&gt;&lt;/a&gt;좋아요 테이블에 Index 를 추가해 보자
&lt;/h3&gt;&lt;p&gt;하지만 1초 짜리 쿼리도 빠른것은 아니기에 조금 더 살펴 보자.
지금 쿼리에서 가장 실행시간을 많이 잡아먹는 부분은 group by 집계를 통해 99000 row 를 반환 한는 부분이다.&lt;/p&gt;
&lt;p&gt;현재 likes 테이블의 인덱스는 Unique key 로 잡혀 있는 member_id, product_id 이기 때문에 GROUP BY product_id 를 할 경우 집계에 유리한 방식으로 인덱스를 탈 수 없다.
그 이유는 현재 UK 의 인덱스 정렬 순서가 member_id, product_id 로 아래와 같이 정렬이 되어 있기 때문이다. 즉 같은 product_id 인 row 들이 연속되지 않는다.
따라서 위 실행결과에서 보여지듯이 임시 테이블이나 해시 집계 같은 방식으로 다시 모으는 것을 볼 수 있다. (-&amp;gt; Covering index scan on likes using uk_likes_member_id_product_id &amp;hellip; -&amp;gt; Aggregate using temporary table)&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(member_id, product_id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 10)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 20)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 30)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(2, 10)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(2, 20)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(3, 10)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;그럼 UK 의 순서를 뒤집어 product_id, membe_id 로 재생성 하여 인덱스를 잘 탈 수 있게 하는 방법도 있지만 현재 내가 좋아요를 누른 상품 목록 조회에서 member_id 를 기준으로 인덱스를 태우는 곳이 있기 때문에
product_id 인덱스를 새로 만들었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes_product_id_index&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;다시 동일한 쿼리를 실행시켜보자&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ls.like_cnt DESC (actual time=555..559 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=439e+6 rows=4.39e+9) (actual time=456..530 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop left join (cost=439e+6 rows=4.39e+9) (actual time=456..518 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9259 rows=44167) (actual time=0.0941..20.5 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9259 rows=88334) (actual time=0.0923..16.9 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Index lookup on ls using &amp;lt;auto_key0&amp;gt; (product_id=p.id) (cost=1.02e+6..1.02e+6 rows=55.7) (actual time=0.00483..0.0049 rows=0.99 loops=100000)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Materialize (cost=1.02e+6..1.02e+6 rows=99338) (actual time=456..456 rows=99000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Group aggregate: count(likes.product_id) (cost=1.01e+6 rows=99338) (actual time=0.349..427 rows=99000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on likes using likes_product_id_index (cost=521749 rows=4.92e+6) (actual time=0.338..319 rows=4.95e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;최종 쿼리 실행 속도는 559ms 로 인덱스를 추가한 후&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1.064/s&lt;/strong&gt; -&amp;gt; &lt;strong&gt;559/ms&lt;/strong&gt; 로 실행 시간 약 &lt;strong&gt;47.5%&lt;/strong&gt; 감소.&lt;/p&gt;
&lt;p&gt;바뀐 부분은 이렇다&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;(likes_product_id_index 인덱스 생성 전)
Aggregate using temporary table
-&amp;gt; Covering index scan on likes using uk_likes_member_id_product_id&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;(likes_product_id_index 인덱스 생성 후)
Group aggregate: count(likes.product_id)
-&amp;gt; Covering index scan on likes using likes_product_id_index&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이전에는 product_id 가 흩어져 있어서 MySQL이 읽은 row 임시 테이블에 모아가며 집계를 해야 했지만 새로 만든 인덱스는 같은 product_id 가 연속되어 있다.
따라서 이전처럼 임시 테이블에 계속 쌓고 찾는 방식이 아니라, 정렬된 순서대로 지나가며 그룹을 완성 할 수 있게 된 것이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;product_id
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;20
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;20
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;30
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;
&lt;h2 id="여기서-더-개선-할-수-있는-방법이-없을까"&gt;&lt;a href="#%ec%97%ac%ea%b8%b0%ec%84%9c-%eb%8d%94-%ea%b0%9c%ec%84%a0-%ed%95%a0-%ec%88%98-%ec%9e%88%eb%8a%94-%eb%b0%a9%eb%b2%95%ec%9d%b4-%ec%97%86%ec%9d%84%ea%b9%8c" class="header-anchor"&gt;&lt;/a&gt;여기서 더 개선 할 수 있는 방법이 없을까?
&lt;/h2&gt;&lt;p&gt;현재 쿼리는 실시간 집계 방식으로는 꽤 좋아진 상태이다. 여기서 더 조회 성능을 높이기 위해서는 조회시 마다 집계를 하지않고 &lt;code&gt;MV(=Materialized View)&lt;/code&gt; 테이블을 두고 조회시 이미 계산된 좋아요의 수 만 가져오는 것이다.
기존 좋아요 정렬 기능 설계 당시 조회 성능을 고려해 product_stat 이라는 MV 테이블을 만들어 두었고 이를 이용해 테스트 해보겠다.&lt;/p&gt;
&lt;p&gt;다만 이렇게 MV 를 사용할 경우 생각해봐야 할 것은 집계 방식에 비해서는 정확도와 실시간성이 떨어질 수 있기에 조회 성능과의 트레이드 오프를 고려해야 한다.&lt;/p&gt;
&lt;h3 id="mv-사용-장단점"&gt;&lt;a href="#mv-%ec%82%ac%ec%9a%a9-%ec%9e%a5%eb%8b%a8%ec%a0%90" class="header-anchor"&gt;&lt;/a&gt;MV 사용 장단점
&lt;/h3&gt;&lt;p&gt;장점&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이미 계산된 데이터를 읽기만 하면 되기 때문에 매번 집계가 필요없음&lt;/li&gt;
&lt;li&gt;조회 성능이 likes 전체 크기에 직접 비례하지 않음&lt;/li&gt;
&lt;li&gt;트래픽이 많을 때 DB 부하가 안정적임&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;단점&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;정합성 문제 (likes 에 insert/delete 가 발생했을 때 product_stat.like_cnt 도 함께 갱신되어야 함)&lt;/li&gt;
&lt;li&gt;운영 중 정합성이 깨질 수 있으므로 주기적으로 원본 likes 기준으로 재계산하는 배치나 검증 쿼리를 두는 것이 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="product_stat-테이블을-이용한-조회-테스트"&gt;&lt;a href="#product_stat-%ed%85%8c%ec%9d%b4%eb%b8%94%ec%9d%84-%ec%9d%b4%ec%9a%a9%ed%95%9c-%ec%a1%b0%ed%9a%8c-%ed%85%8c%ec%8a%a4%ed%8a%b8" class="header-anchor"&gt;&lt;/a&gt;product_stat 테이블을 이용한 조회 테스트
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ps.like_count DESC, ps.product_id DESC (actual time=159..162 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=24717 rows=44167) (actual time=0.0745..127 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=24717 rows=44167) (actual time=0.0716..111 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9258 rows=44167) (actual time=0.0496..21.6 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9258 rows=88334) (actual time=0.0478..17.1 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on ps using UK6mdv3ubr8r663491df2j2du63 (product_id=p.id) (cost=0.25 rows=1) (actual time=775e-6..793e-6 rows=1 loops=100000)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이렇게 조회용 MV 테이블을 두고 가볍게 join 을 하여 조회를 하였을 경우 조회 성능의 이점은 크게 높일 수 있는 것을 확인할 수 있다.
기존 조회 시점 집계 방식은 매 요청마다 &lt;code&gt;likes&lt;/code&gt; 약 495만 건을 읽고 &lt;code&gt;product_id&lt;/code&gt; 기준으로 집계한 뒤 &lt;code&gt;product&lt;/code&gt;와 조인했다. 반면 &lt;code&gt;product_stat&lt;/code&gt; 방식은 이미 집계된 &lt;code&gt;like_count&lt;/code&gt; 값을 조회하므로, 실행 계획에서 &lt;code&gt;likes&lt;/code&gt; 테이블 접근 자체가 사라진다.&lt;/p&gt;
&lt;p&gt;조회 시점 마다 집계 방식: &lt;strong&gt;559/ms&lt;/strong&gt; -&amp;gt; MV 테이블 방식 조회: &lt;strong&gt;162/ms&lt;/strong&gt; 로 실행 시간은 약 &lt;strong&gt;71%&lt;/strong&gt; 감소&lt;/p&gt;
&lt;p&gt;다만 현재 실행 계획에서는 &lt;code&gt;product_stat&lt;/code&gt;을 먼저 읽는 것이 아니라, MySQL 옵티마이저가 &lt;code&gt;product&lt;/code&gt;를 먼저 전체 스캔한 뒤 &lt;code&gt;product_stat&lt;/code&gt;을 &lt;code&gt;product_id&lt;/code&gt; 유니크 키로 10만 번 lookup 하는 방식을 선택했다.
현재 데이터 규모에서는 이 방식도 약 162ms로 충분히 빠르지만, 상품 수가 수백만 건 이상으로 증가하면 product 전체 스캔과 10만 회 이상의 반복 lookup이 병목이 될 수 있다.
따라서 전체를 반환하는 것이 아닌 페이지네이션을 이용하여 필요한 개수만큼만 조회를 하는 방식을 사용한다면 좀 더 성능을 개선 할 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;OFFSET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 20 row(s) (cost=43892 rows=20) (actual time=39.3..39.7 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=43892 rows=48653) (actual time=39.3..39.7 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Sort: ps.like_count DESC, ps.product_id DESC (cost=9835 rows=97306) (actual time=39.2..39.2 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on ps (cost=9835 rows=97306) (actual time=1.01..17.2 rows=100000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=0.25 rows=0.5) (actual time=0.0252..0.0252 rows=1 loops=20)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on p using PRIMARY (id=ps.product_id) (cost=0.25 rows=1) (actual time=0.0245..0.0245 rows=1 loops=20)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;LIMIT 을 추가한 후 index looup 과 join 역시 20으로 줄어든 것을 확인 할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;162/ms&lt;/strong&gt; -&amp;gt; &lt;strong&gt;39.7/ms&lt;/strong&gt; 페이지네이션 추가 후 실행시간 약 &lt;strong&gt;75.5%&lt;/strong&gt; 감소&lt;/p&gt;
&lt;h3 id="like_cnt-인덱스-추가"&gt;&lt;a href="#like_cnt-%ec%9d%b8%eb%8d%b1%ec%8a%a4-%ec%b6%94%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;like_cnt 인덱스 추가
&lt;/h3&gt;&lt;p&gt;현재 상태에서는 이정도 조회 속도도 충분하다고 생각하지만 빠르게 쌓여 나갈 데이터 수 와 피크타임의 조회 트래픽 부하를 고려하여 like_cnt index 를 만들어두어 미리 정렬을 해두는 방법이 좋다고 생각한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_product_stat_like_count_product_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이렇게 하였을 때 기대하는 흐름은 다음과 같다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;product_stat을 like_count DESC 인덱스 순서로 읽음
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ product를 PK로 lookup
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ is_deleted = false 확인
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ 정상 상품 20개가 모이면 중단
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이렇게 되면 전체 10만 건을 정렬하지 않고 상위 row부터 읽다가 LIMIT 20에서 멈출 수 있다. &lt;br&gt;
인덱스 추가 후 동일한 쿼리를 실행해 보았다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 20 row(s) (cost=24329 rows=10) (actual time=0.632..0.953 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=24329 rows=10) (actual time=0.629..0.949 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on ps using idx_product_stat_like_cnt_product_id (cost=0.0218 rows=20) (actual time=0.448..0.452 rows=20 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=0.25 rows=0.5) (actual time=0.0238..0.0238 rows=1 loops=20)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on p using PRIMARY (id=ps.product_id) (cost=0.25 rows=1) (actual time=0.0227..0.0227 rows=1 loops=20)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이제 product_stat를 전체 스캔하지 않는다. idx_product_stat_like_cnt_product_id 인덱스가 이미 아래 순서로 정렬되어 있기 때문이다.
따라서 MySQL 은 정렬 작업 없이 맨 앞에서 20개만 읽으면 된다.&lt;/p&gt;
&lt;p&gt;like_cnt 인덱스 적용 전: &lt;strong&gt;39.7/ms&lt;/strong&gt; -&amp;gt; 적용 후: &lt;strong&gt;0.953ms&lt;/strong&gt; 실행시간 약 &lt;strong&gt;97.6%&lt;/strong&gt; 감소&lt;/p&gt;
&lt;p&gt;이 정도 성능이라면 현재 상태 + 어느정도 규모가 더 커져도 조회 성능이 문제가 될 것 같지는 않다.&lt;/p&gt;
&lt;p&gt;이렇게 실행계획을 살펴보며 쿼리 방식의 변화와 인덱스 설정 그리고 조회 테이블의 구조적 변화를 사용한 트레이드오프로 조회 성능을 올려가는 것을 눈으로 직접 확인해볼 수 있었다.&lt;/p&gt;
&lt;h3 id="조회-성능-개선-결과-정리"&gt;&lt;a href="#%ec%a1%b0%ed%9a%8c-%ec%84%b1%eb%8a%a5-%ea%b0%9c%ec%84%a0-%ea%b2%b0%ea%b3%bc-%ec%a0%95%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;조회 성능 개선 결과 정리
&lt;/h3&gt;&lt;p&gt;테스트 데이터는 상품 10만 건, 좋아요 약 495만 건을 기준으로 진행했다.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;단계&lt;/th&gt;
					&lt;th&gt;조회 방식&lt;/th&gt;
					&lt;th&gt;주요 실행 계획&lt;/th&gt;
					&lt;th style="text-align: right"&gt;실행 시간&lt;/th&gt;
					&lt;th style="text-align: right"&gt;직전 단계 대비&lt;/th&gt;
					&lt;th style="text-align: right"&gt;최초 대비&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;product&lt;/code&gt;와 &lt;code&gt;likes&lt;/code&gt;를 먼저 조인 후 집계&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;Left hash join&lt;/code&gt; 후 &lt;code&gt;Aggregate using temporary table&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;11,416ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;-&lt;/td&gt;
					&lt;td style="text-align: right"&gt;-&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;2&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;likes&lt;/code&gt;를 먼저 &lt;code&gt;product_id&lt;/code&gt; 기준 집계 후 &lt;code&gt;product&lt;/code&gt; 조인&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;Materialize&lt;/code&gt; + &lt;code&gt;Aggregate using temporary table&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;1,064ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 90.7% 감소, 약 10.7배 개선&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 90.7% 감소&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;3&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;likes(product_id)&lt;/code&gt; 인덱스 추가&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;Group aggregate&lt;/code&gt; + &lt;code&gt;likes_product_id_index&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;559ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 47.5% 감소, 약 1.9배 개선&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 95.1% 감소&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;product_stat&lt;/code&gt; MV 테이블 사용&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;product_stat&lt;/code&gt; 단건 lookup, &lt;code&gt;likes&lt;/code&gt; 접근 제거&lt;/td&gt;
					&lt;td style="text-align: right"&gt;162ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 71.0% 감소, 약 3.45배 개선&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 98.6% 감소&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;5&lt;/td&gt;
					&lt;td&gt;MV 테이블 조회에 &lt;code&gt;LIMIT 20 OFFSET 0&lt;/code&gt; 추가&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;product_stat&lt;/code&gt; 10만 건 정렬 후 상위 20개 조인&lt;/td&gt;
					&lt;td style="text-align: right"&gt;39.7ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 75.5% 감소, 약 4.1배 개선&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 99.65% 감소&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;6&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;product_stat(like_count DESC, product_id DESC)&lt;/code&gt; 인덱스 추가&lt;/td&gt;
					&lt;td&gt;정렬 없이 인덱스에서 상위 20개만 읽음&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.953ms&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 97.6% 감소, 약 41.7배 개선&lt;/td&gt;
					&lt;td style="text-align: right"&gt;약 99.99% 감소&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;최종적으로 최초 쿼리의 11,416ms에서 0.953ms까지 감소했다. 실행 시간 기준으로는 약 99.99% 감소했으며 속도 기준으로는 약 11,979배 개선되었다.&lt;/p&gt;
&lt;h3 id="마무리"&gt;&lt;a href="#%eb%a7%88%eb%ac%b4%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;마무리
&lt;/h3&gt;&lt;p&gt;개선 과정에서 가장 큰 변화는 세 가지였다.&lt;/p&gt;
&lt;p&gt;첫째, 조인 후 집계하던 방식을 집계 후 조인 방식으로 바꾸면서 불필요하게 큰 중간 결과를 제거했다.&lt;br&gt;
둘째, &lt;code&gt;likes(product_id)&lt;/code&gt; 인덱스를 추가해 &lt;code&gt;GROUP BY product_id&lt;/code&gt;가 임시 테이블 집계가 아닌 &lt;code&gt;Group aggregate&lt;/code&gt;로 처리되도록 개선했다.&lt;br&gt;
셋째, &lt;code&gt;product_stat&lt;/code&gt; MV 테이블과 &lt;code&gt;like_count&lt;/code&gt; 정렬 인덱스를 사용해 조회 시점의 대량 집계와 정렬 비용을 제거했다.&lt;/p&gt;
&lt;p&gt;특히 최종 단계에서는 &lt;code&gt;product_stat&lt;/code&gt; 전체를 정렬하지 않고, &lt;code&gt;idx_product_stat_like_count_product_id&lt;/code&gt; 인덱스에서 이미 정렬된 순서대로 20개만 읽은 뒤 &lt;code&gt;product&lt;/code&gt;를 PK로 조인한다. 따라서 인기순 페이지 조회에서는 원본 &lt;code&gt;likes&lt;/code&gt; 테이블 크기와 거의 무관하게 안정적인 조회 성능을 기대할 수 있었다.&lt;/p&gt;</description></item></channel></rss>