JSCODE Logo
면접 스터디블로그후기멘토진
회사명 : JSCODE대표 : 박재성사업자 등록번호 : 244-22-01557통신판매업 : 제 2023-인천미추홀-0381 호
학원 명칭 : 제이에스코드(JSCODE)원격학원학원설립ㆍ운영 등록번호 : 제6063호

서울특별시 구로구 경인로 20가길 11(오류동, 아델리아)

Copyright ⓒ 2025 JSCODE - 최상위 현업 개발자들의 프로그래밍 교육 All rights reserved.

이용약관개인정보처리방침

비전공자도 이해할 수 있는 MySQL 성능 최적화 입문/실전 (SQL 튜닝편)

MySQL 성능 최적화를 본격적으로 배우기 전에!

신입 백엔드 면접에서 자주 물어보는 ‘DB 성능 최적화’ 경험?!
DB 성능 개선할 때 ‘SQL 튜닝’을 가장 먼저 해야 하는 이유 (vs 스케일업, 레플리케이션, 샤딩, 캐싱)
성능 개선을 위한 MySQL 구조 파악 / SQL 튜닝의 핵심

인덱스(Index) 기본 개념 / 실전 활용법

인덱스(Index)란?
[실습] 인덱스 직접 설정해보기 / 성능 측정해보기
기본으로 설정되는 인덱스 (PK)
제약 조건을 추가하면 자동으로 생성되는 인덱스 (UNIQUE)
[실습] 인덱스를 무식하게 많이 걸면 어떻게 될까?
멀티 컬럼 인덱스 (Mulitple-Column Index)란?
[실습] 멀티 컬럼 인덱스 직접 설정해보기 / 작동방식 이해하기
멀티 컬럼 인덱스 생성 시 주의점
커버링 인덱스(Covering Index)란?

실행 계획(EXPLAIN)을 활용해 성능 저하 요인 찾아내기

SQL문의 ‘실행 계획’ 사용해보기 (EXPLAIN)
실행 계획에서 type 의미 분석하기 (ALL, index)
실행 계획에서 type 의미 분석하기 (const, range, ref)

SQL문 튜닝 연습하기

[실습] 한 번에 너무 많은 데이터를 조회하는 SQL문 튜닝하기
[실습] WHERE문이 사용된 SQL문 튜닝하기 - 1
[실습] WHERE문이 사용된 SQL문 튜닝하기 - 2
[실습] 인덱스를 걸었는데도 인덱스가 작동하지 않는 경우 - 1
[실습] 인덱스를 걸었는데도 인덱스가 작동하지 않는 경우 - 2
[실습] ORDER BY문이 사용된 SQL문 튜닝하기
[실습] WHERE문에 인덱스를 걸기 vs ORDER BY문에 인덱스를 걸기
[실습] HAVING문이 사용된 SQL문 튜닝하기

실전 SQL문으로 튜닝 직접 해보기

[실습] 유저 이름으로 특정 기간에 작성된 글 검색하는 SQL문 튜닝하기
[실습] 특정 부서에서 최대 연봉을 가진 사용자들 조회하는 SQL문 튜닝하기
[실습] 부서별 최대 연봉을 가진 사용자들 조회하는 SQL문 튜닝하기
[실습] 2023년 주문 데이터 조회하는 SQL문 튜닝하기
[실습] 2024년 1학기 평균 성적이 100점인 학생 조회하는 SQL문 튜닝하기
[실습] 좋아요 많은 순으로 게시글 조회하는 SQL문 튜닝하기
← 블로그 목록으로 돌아가기

[실습] 좋아요 많은 순으로 게시글 조회하는 SQL문 튜닝하기

JSCODE 박재성
JSCODE 박재성
2025-12-02
author
JSCODE 박재성
category
MySQL
createdAt
Dec 2, 2025
series
비전공자도 이해할 수 있는 MySQL 성능 최적화 입문/실전 (SQL 튜닝편)
slug
posts-by-likes-desc-sql-tuning
type
post
updatedAt
Dec 2, 2025 10:54 AM

✅ 좋아요 많은 순으로 게시글 조회하기

  1. 기본 테이블 셋팅
    1. DROP TABLE IF EXISTS likes; DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS posts; CREATE TABLE posts ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE likes ( id INT AUTO_INCREMENT PRIMARY KEY, post_id INT, user_id INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (user_id) REFERENCES users(id) );
 
  1. 더미 데이터 추가
    1. -- 높은 재귀(반복) 횟수를 허용하도록 설정 -- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.) SET SESSION cte_max_recursion_depth = 1000000; -- posts 테이블에 더미 데이터 삽입 INSERT INTO posts (title, created_at) WITH RECURSIVE cte (n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수 ) SELECT CONCAT('Post', LPAD(n, 7, '0')) AS name, -- 'User' 다음에 7자리 숫자로 구성된 이름 생성 TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성 FROM cte; -- users 테이블에 더미 데이터 삽입 INSERT INTO users (name, created_at) WITH RECURSIVE cte (n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수 ) SELECT CONCAT('User', LPAD(n, 7, '0')) AS name, -- 'User' 다음에 7자리 숫자로 구성된 이름 생성 TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성 FROM cte; -- likes 테이블에 더미 데이터 삽입 INSERT INTO likes (post_id, user_id, created_at) WITH RECURSIVE cte (n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수 ) SELECT FLOOR(1 + RAND() * 1000000) AS post_id, -- 1부터 1000000 사이의 난수로 급여 생성 FLOOR(1 + RAND() * 1000000) AS user_id, -- 1부터 1000000 사이의 난수로 급여 생성 TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성 FROM cte;
 
  1. 성능 개선해야 할 SQL문
    1. SELECT p.id, p.title, p.created_at, COUNT(l.id) AS like_count FROM posts p INNER JOIN likes l ON p.id = l.post_id GROUP BY p.id, p.title, p.created_at ORDER BY like_count DESC LIMIT 30;
      notion image
      대략 2600ms 정도의 시간이 소요되고 있다. 분명 개선해야 할 SQL문이다.
 
  1. 실행 계획 조회해보기
    1. 성능 개선을 위해 어떤 식으로 SQL문이 작동하고 있는 지 살펴보고 어떻게 개선할 지 고민해보자.
      [실행 계획 조회]
      EXPLAIN SELECT p.id, p.title, p.created_at, COUNT(l.id) AS like_count FROM posts p INNER JOIN likes l ON p.id = l.post_id GROUP BY p.id, p.title, p.created_at ORDER BY like_count DESC LIMIT 30;
       
[실행 계획 세부 내용 조회]
EXPLAIN ANALYZE SELECT p.id, p.title, p.created_at, COUNT(l.id) AS like_count FROM posts p INNER JOIN likes l ON p.id = l.post_id GROUP BY p.id, p.title, p.created_at ORDER BY like_count DESC LIMIT 30;
-> Limit: 30 row(s) (actual time=2775..2775 rows=30 loops=1) -> Sort: like_count DESC, limit input to 30 row(s) per chunk (actual time=2775..2775 rows=30 loops=1) -> Table scan on <temporary> (actual time=2675..2745 rows=575582 loops=1) -> Aggregate using temporary table (actual time=2675..2675 rows=575582 loops=1) -> Nested loop inner join (cost=449599 rows=997632) (actual time=0.126..920 rows=1e+6 loops=1) -> Table scan on p (cost=100428 rows=997632) (actual time=0.0937..115 rows=1e+6 loops=1) -> Covering index lookup on l using post_id (post_id=p.id) (cost=0.25 rows=1) (actual time=602e-6..703e-6 rows=1 loops=1e+6)
세부 실행 계획을 보니 INNER JOIN과 GROUP BY(Aggreagte using temporary table)에 시간을 많이 사용했다. 이 이유를 추측해보니 INNER JOIN, GROUP BY를 수행할 때 풀 테이블 스캔으로 조회한 데이터 100만개를 가지고 처리를 해서 오래 걸렸다고 추측할 수 있다. 이를 개선해보자.
 
  1. 성능 개선
    1. 어떻게 성능 개선을 할 지 고민할 때는 성능 저하가 일어난 부분의 원인을 어떻게 개선할 수 있을 지를 고민하다보면 쉽게 떠올릴 수 있다.
      SELECT p.*, l.like_count FROM posts p INNER JOIN (SELECT post_id, count(post_id) AS like_count FROM likes l GROUP BY l.post_id ORDER BY like_count DESC LIMIT 30) l ON p.id = l.post_id;
    2. 이전 SQL문은 INNER JOIN을 한 뒤에 GROUP BY를 했었다. 하지만 위 SQL문은 likes 테이블만 가지고 먼저 GROUP BY를 한 뒤 좋아요 수가 많은 post_id를 찾아낸다. 이 때, GROUP BY 과정에서 post_id만 사용하기 때문에 풀 테이블 스캔을 하지 않고 인덱스만 활용해서 데이터를 조회할 수 있어서 빠르다.(커버링 인덱스)
    3. 그러고 기존 posts 테이블과 걸러진 30개의 행을 가진 테이블과 INNER JOIN을 함으로써 데이터 액세스 수를 확 줄였다.
    4.  
      성능도 2500ms에서 170ms로 아주 많이 개선되었다.
      notion image
 
  1. 성능 개선 후 실행 계획
    1. [실행 계획 조회]
      EXPLAIN SELECT p.*, l.like_count FROM posts p INNER JOIN (SELECT post_id, count(post_id) AS like_count FROM likes l GROUP BY l.post_id ORDER BY like_count DESC LIMIT 30) l ON p.id = l.post_id;
      notion image
      이제 점점 실행 계획이 읽기 어려워진다. 하나 체크하고 가면 좋은 건 풀 테이블 스캔으로 액세스한 데이터의 수가 30으로 줄었다. 그리고 l이라는 테이블에서 인덱스 풀 스캔을 했음을 알 수 있다. 즉, 대부분의 데이터를 원래 풀 테이블 스캔을 하던 걸 풀 인덱스 스캔으로 고친 것이다.
       
      [실행 계획 세부 내용 조회]
      EXPLAIN ANALYZE SELECT p.*, l.like_count FROM posts p INNER JOIN (SELECT post_id, count(post_id) AS like_count FROM likes l GROUP BY l.post_id ORDER BY like_count DESC LIMIT 30) l ON p.id = l.post_id;
      -> Nested loop inner join (cost=20.5 rows=30) (actual time=227..227 rows=30 loops=1) -> Filter: (l.post_id is not null) (cost=0.196..5.88 rows=30) (actual time=227..227 rows=30 loops=1) -> Table scan on l (cost=2.5..2.5 rows=0) (actual time=227..227 rows=30 loops=1) -> Materialize (cost=0..0 rows=0) (actual time=227..227 rows=30 loops=1) -> Limit: 30 row(s) (actual time=227..227 rows=30 loops=1) -> Sort: like_count DESC, limit input to 30 row(s) per chunk (actual time=227..227 rows=30 loops=1) -> Stream results (cost=200702 rows=573484) (actual time=0.0883..199 rows=575582 loops=1) -> Group aggregate: count(l.post_id) (cost=200702 rows=573484) (actual time=0.0837..163 rows=575582 loops=1) -> Covering index scan on l using idx_post_id (cost=100912 rows=997899) (actual time=0.074..101 rows=1e+6 loops=1) -> Single-row index lookup on p using PRIMARY (id=l.post_id) (cost=0.392 rows=1) (actual time=0.0019..0.00192 rows=1 loops=30)
      실제 커버링 인덱스를 활용했음을 알 수 있다. 그리고 풀 테이블 스캔의 데이터보다 훨씬 크기가 작은 커버링 인덱스만을 활용해서 GROUP BY를 실행하니 훨씬 속도가 빠른 걸 알 수 있다.
       
       
author
category
createdAt
series
비전공자도 이해할 수 있는 MySQL 성능 최적화 입문/실전 (SQL 튜닝편)
slug
type
series-footer
updatedAt
Dec 6, 2025 12:47 AM
📎
이 글은 비전공자도 이해할 수 있는 Docker 입문/실전 강의의 수업 자료 중 일부입니다.