부하테스트 ‐ (10) 게시글 댓글 목록 페이지 조회 성능 개선(댓글수 비정규화, 쿼리 튜닝) - ttasjwi/board-system GitHub Wiki
개요
- 사실 지난 글에서 한 성능 튜닝(인덱스 추가)만으로 충분히 좋은 성능을 보이는 것을 확인했다.
- 그런데 여기서 좀 더 성능을 개선할 수 있는 여지가 있을 것 같았고, 이를 시도해봤다.
테스트
import http from 'k6/http';
import {sleep} from 'k6';
export const options = {
// 부하를 생성하는 단계(stages)를 설정
stages: [
// 10분에 걸쳐 vus(virtual users, 가상 유저수)가 1000에 도달하도록 설정
{duration: '10m', target: 1000}
],
};
export default function () {
// API 주소로 GET 요청
const articleId = 24000000
const page = 100
http.get(`http://3.35.168.50/api/v1/article-comments?articleId=${articleId}&page=${page}&pageSize=50`);
sleep(1);
}
- 테스트에서, 높은 OFFSET 에서의 성능차를 확인하기 위해 성능 테스트를 변경했다.
- articleId = 240000000, page = 100 고정
기존 방식 성능테스트

- vus 181, 최대 tsp 167.4 추정
- 그 이후에서는 증가가 정체됨.

- 데이터베이스쪽에서 병목이 발생(CPU 사용률 98.65%)
댓글수 조회
mysql> WITH cte AS (
-> SELECT article_comment_id
-> FROM article_comments
-> WHERE article_id = 24000000
-> LIMIT 12000001
-> )
-> SELECT COUNT(*)
-> FROM cte;
+----------+
| COUNT(*) |
+----------+
| 12000001 |
+----------+
1 row in set (38.32 sec)
- 기존 댓글수 조회 방식이다.
- 실제
article_comments
테이블에서 count(*)
쿼리를 통해 댓글 수를 조회해오고 있었다.
- 댓글 수가 늘어날 수록, limit 값이 늘어나서 쿼리 성능은 크게 악화될 여지가 있다.
mysql> SELECT comment_count
-> FROM article_comment_counts
-> WHERE article_id = 24000000;
+---------------+
| comment_count |
+---------------+
| 12000828 |
+---------------+
1 row in set (0.01 sec)
- 근데,
article_comment_counts
테이블을 따로 만들어서 게시글별 댓글 수를 비정규화해놨다. 이를 통해 조회해오는 것이 몇 배는 빠르다.
- 그래서, 애플리케이션 로직에서 댓글수 조회 로직을
article_comment_counts
테이블을 통해 조회해오는 식으로 변경했다.
게시글 댓글 페이지 번호 제약 추가
- 게시글 댓글 페이지 번호에 제약을 추가했다.
- 최대 100 페이지까지 가져올 수 있게 한다.
- 이러면 게시글 댓글에 대한 offset 은 최대 4950 정도까지 제한을 둘 수 있다.
- 보통 서비스에서 어떤 게시글에 대한 댓글이 4950개를 넘을 일은 그렇게 많지 않고, 설령 그런 일이 일어나더라도 그 때 가서 페이지 번호 제한을 조금 완화하는 것을
고려해봐도 충분하다.
- 그리고 보통 사용자가 100페이지 넘는 댓글을 전부 찾아 접근하는 일도 드물다.
댓글 조회 쿼리 수정
mysql> explain analyze
-> SELECT
-> article_comment_id,
-> content,
-> article_id,
-> root_parent_comment_id,
-> writer_id,
-> writer_nickname,
-> parent_comment_writer_id,
-> parent_comment_writer_nickname,
-> delete_status,
-> created_at,
-> modified_at
-> FROM article_comments
-> WHERE article_id = 24000000
-> ORDER BY root_parent_comment_id, article_comment_id
-> LIMIT 50 OFFSET 12000000;
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Limit/Offset: 50/12000000 row(s) (cost=1.66e+6 rows=0) (actual time=90411..90411 rows=50 loops=1)
-> Index lookup on article_comments using idx_article_id_root_parent_comment_id_article_comment_id (article_id=24000000) (cost=1.66e+6 rows=11.6e+6) (actual time=0.0792..89914 rows=12e+6 loops=1)
|
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (1 min 30.43 sec)
- 기존 댓글 조회 로직이다.
- index look up 을 통해 접근하는데 이 과정에서 사실 1200만 offset 에 도달할 떄까지 disk i/o 가 발생한다.
mysql> EXPLAIN ANALYZE
-> WITH cte(article_comment_id) AS (
-> SELECT ac.article_comment_id
-> FROM article_comments ac
-> WHERE ac.article_id = 24000000
-> ORDER BY ac.root_parent_comment_id, ac.article_comment_id
-> LIMIT 50 OFFSET 12000000
-> )
-> SELECT
-> ac.article_comment_id AS articleCommentId,
-> ac.content AS content,
-> ac.article_id AS articleId,
-> ac.root_parent_comment_id AS rootParentCommentId,
-> ac.writer_id AS writerId,
-> ac.writer_nickname AS writerNickname,
-> ac.parent_comment_writer_id AS parentCommentWriterId,
-> ac.parent_comment_writer_nickname AS parentCommentWriterNickname,
-> ac.delete_status AS deleteStatus,
-> ac.created_at AS createdAt,
-> ac.modified_at AS modifiedAt
-> FROM article_comments ac
-> JOIN cte ON cte.article_comment_id = ac.article_comment_id;
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Nested loop inner join (cost=12.6e+6 rows=0) (actual time=18586..18586 rows=50 loops=1)
-> Table scan on cte (cost=1.2e+6..1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
-> Materialize CTE cte (cost=1.2e+6..1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
-> Limit/Offset: 50/12000000 row(s) (cost=1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
-> Covering index lookup on ac using idx_article_id_root_parent_comment_id_article_comment_id (article_id=24000000) (cost=1.2e+6 rows=11.6e+6) (actual time=0.022..18106 rows=12e+6 loops=1)
-> Single-row index lookup on ac using PRIMARY (article_comment_id=cte.article_comment_id) (cost=0.981 rows=1) (actual time=0.0312..0.0312 rows=1 loops=50)
|
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (18.61 sec)
- 새로 개선한 쿼리는 커버링 인덱스를 활용한 방식이다.
- 1200만번째 offset에 도달할 때까지 인덱스만을 사용하여 필터링하기 때문에(커버링 인덱스) 성능이 더 우수하다.
- 물론 낮은 offset에서는 기존 로직과는 별 차이가 없는 것처럼 느껴지거나, 오히려 더 성능이 느리게 느껴질 수도 있다.
튜닝 후 성능 테스트

- vus 314, tps 303.2
- 기존 최대 tps는 167.4였는데, page = 100 일 경우 1.81배 성능이 향상됐다.

- rds 의 cpu 사용률은 22%대로 다시 낮아지고, EC2가 병목지점이 됐다.