본문 바로가기
Work Logs/Problem Solving | Dev in Practice

SELECT COUNT 슬로우 쿼리 튜닝기: 9.46ms → 3.52ms

by novxerim 2025. 6. 14.

문제 발견: 이벤트 오픈마다 반복되는 슬로우 레이턴시

어느 날부터 평화롭던 특정 프로젝트에서 슬로우 레이턴시 알람이 울렸다.

보통 우리 프로젝트의 경우 이벤트가 오픈 시 순간적인 트래픽이 상승이 자주 발생하기 때문에,

이번에도 이벤트 시즌이 오픈하면서 일시적으로 튀었다가 안정화 된 케이스라고 생각하고 넘길 수 있었다.

 

하지만 다음달, 또 같은 슬로우 레이턴시 알람이 울렸다.

이번에도 지표는 일시적으로 튄 뒤 곧 안정화되었지만, 한 달 간격으로 같은 시간대에 반복해서 알람이 울린 점이 이상하게 느껴졌다.

그래서 두 날짜에 운영된 이벤트 등의 공통점을 추적해보기 시작했다.

내가 추적한 원인은 사진의 1번에 해당하는, 신규 이벤트(기존 이벤트를 폴리싱하면서 코드를 전면적으로 갈아엎은 형태)였다.

이벤트 기획의 특성상, 이벤트 오픈과 동시에 콜요청이 상당히 많을 수 밖에 없었고 실제로 해당 콜에 사용되는 테이블에 부하가 치는 것을 보며 해당 이벤트의 시즌이 오픈될 때마다 RDS에서 슬로우 레이턴시 알람이 울린 것으로 판단하게 되었다.

문제는, 해당 이벤트는 기존 컨셉만 유지한 채, 기획과 운영 방식은 완전히 변경된 상태였고 그에 따라 전체 코드도 새롭게 구성된 상태였기 때문에, 병목이 어디에서 발생하는지 처음부터 분석해봐야하는 상황이었다.

 

나는 DynamoDB의 write/read capacity 만의 문제가 아닐 수 있겠다는 생각이 들었고, 상사분께 코드 내에 사용된 오래된 쿼리문에 대한 문제성을 함께 제기하며 튜닝 작업 방향에 대한 조언을 구하였다.

논의 결과 우선은 DynamoDB부터 먼저 조정해보자는 방향으로 결정되었고, 이벤트 운영 기간을 관리하는 데이터를 읽어와 새로운 시즌의 오픈 시간에 맞춰 write capacity를 조정하게하는 DynamoDB 용량 조정 스크립트 적용 작업을 진행하였다.

그런데 작업 도중, 그 때 의심했던 오래된 쿼리문이 다시 떠올랐고, 해당 이벤트의 코드를 딥다이빙하여 깊이 살펴보던 중 이벤트 그룹핑 로직 내부에서 슬로우 쿼리로 추정되는 구문을 발견했다.

(신규 이벤트였지만 부모 클래스로 오래된 경쟁이벤트용 로직을 가져다 쓰게 작업되어있었다.)

해당 이벤트는 기획상, 시즌 오픈 시 모든 유저가 동일한 플로우를 한 번씩 반드시 타게 되는 구조였고, 이 과정에서 유저 1인당 최소 9회 이상의 DB 접근이 발생하는 방식이었던 것이다.

팀에 공유해 조언을 구했고, 한 팀원분께서 내가 특정한 쿼리의 실행 시간을 볼 수 있도록 직접 추가해 확인해주셨다.

그 결과, 다음 시즌이 오픈되었을 때 실제로 해당 쿼리가 200ms 이상 느려지는 병목 현상이 일어나고 있는 것을 확인하였고 즉시 인덱스를 추가하기로 결정되었다.

다행히도 해당 쿼리가 성능 병목 후보라는 점을 사전에 의심하고 있던 상황이었기 때문에, 빠른 시간 안에 원인을 특정하고 해결 방안을 논의할 수 있었다.

 

인덱스 설계 과정

dev 서버에서 테스트를 위해 인덱스를 임의 생성한 뒤, 옵티마이저가 생성한 인덱스를 바로 타지 않아서 FORCE INDEX로 실행 계획을 확인해보았다.

(실제 내부 데이터와 다르게 익명화된 쿼리입니다.)

-- 인덱스
mysql> CREATE INDEX idx_grouping_opt
    -> event_result_table (event_id, group_key, group_value, user_id);

-- 생성 전
mysql> EXPLAIN
    -> SELECT COUNT(user_id) AS result_count
    -> FROM event_result_table
    -> WHERE user_id < 9999999
    ->   AND event_id = 1
    ->   AND group_key = 'A_GROUP'
    ->   AND group_value = 1;
+----+-------------+---------------------+------------+-------+--------------------------------------+-------------+---------+------+-------+----------+------------------------------------+
| id | select_type | table               | partitions | type  | possible_keys                        | key         | key_len | ref  | rows  | filtered | Extra                              |
+----+-------------+---------------------+------------+-------+--------------------------------------+-------------+---------+------+-------+----------+------------------------------------+
|  1 | SIMPLE      | event_result_table  | NULL       | range | PRIMARY,event_id,group_value         | event_id    | 12      | NULL |     1 |     5.00 | Using index condition; Using where |
+----+-------------+---------------------+------------+-------+--------------------------------------+-------------+---------+------+-------+----------+------------------------------------+

-- 생성 후 (FORCE INDEX 적용)
mysql> EXPLAIN
    -> SELECT COUNT(id) AS result_count
    -> FROM event_result_table FORCE INDEX (idx_grouping_opt)
    -> WHERE user_id < 9999999
    ->   AND event_id = 1
    ->   AND group_key = 'A_GROUP'
    ->   AND group_value = 1;
+----+-------------+---------------------+------------+-------+------------------+------------------+---------+------+-------+----------+--------------------------+
| id | select_type | table               | partitions | type  | possible_keys    | key              | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+---------------------+------------+-------+------------------+------------------+---------+------+-------+----------+--------------------------+
|  1 | SIMPLE      | event_result_table  | NULL       | range | idx_grouping_opt | idx_grouping_opt | 115     | NULL |     1 |   100.00 | Using where; Using index |
+----+-------------+---------------------+------------+-------+------------------+------------------+---------+------+-------+----------+--------------------------+

하지만 위 dev서버의 테스트만으로는 확신이 어려웠다.

실제 데이터양과 분포에 따라 옵티마이저의 실행 계획이 달라질 수 있기 때문이었다. (라이브 서비스와는 데이터 양이 다르기 때문이다)

그러나 다른 방법이 없었고, 라이브 서비스에 인덱스를 추가해보기로 하였다.

 

결과는 명확했다. 실제로 효과가 있었다.

복합 인덱스를 적용한 뒤 해당 SELECT 쿼리의 평균 응답 속도가 크게 개선되었으며, slow latency 알람도 더 이상 울리지 않았다.

 


▲ RDS의 SelectLatency 평균이 인덱스 적용 직후 급감하며 3ms 수준으로 안정화된 모습. (기존 평균 9.46ms → 3.52ms, 약 63% 개선)

🔥 효과

  • 이벤트 오픈 시 발생한 RDS 슬로우 쿼리 이슈 해결을 위해 복합 인덱스를 설계 및 적용하여 평균 응답시간을 227ms → 97ms로 57% 개선
  • star_cafe 테이블 내 슬로우 쿼리(SELECT COUNT) 튜닝을 통해 Select Latency 평균 9.46ms → 3.52ms로 약 63% 개선
  • 이벤트 트래픽 집중 구간에서 RDS 부하 감소 유도, 전체 평균 레이턴시 안정화 달성

 

이번 쿼리 튜닝 경험을 통해, 개발 환경의 실행 계획(EXPLAIN)만으로는 판단할 수 없는 부분들이 존재한다는 걸 체감했다.

dev 환경에서는 큰 차이를 보이지 않던 인덱스가, 실제 라이브 환경에서는 데이터의 양, 분포, 트래픽에 따라 명확한 성능 개선 효과를 나타낸 것이 인상적이었다.

단순 수치나 예상만으로 판단하기보다, 실제 유저 흐름과 시스템 부하를 고려한 실험과 검증이 얼마나 중요한지 다시금 배운 경험이었다.

특히 그동안 쿼리 튜닝이 단순 기술로만 느껴졌지만, 단 하나의 인덱스 추가만으로도 레이턴시 이슈가 이렇게 개선될 수 있다는 점이 개인적으로 꽤 인상 깊은 경험이었다.


🔍 인덱스 튜닝 이후에도 남았던 고민

인덱스를 적용한 뒤 레이턴시 개선 효과는 분명했지만, 마음 한켠에는 여전히 “정말 최적의 인덱스를 쓴 걸까?”, "더 효율적인 인덱스 구조가 있지 않았을까?" 하는 의문이 남아 있었다.

물론, 인덱스는 카디널리티(Cardinality)가 높은 컬럼을 앞에 배치하는 것이 일반적인 기준이지만, 당시에 추가했던 인덱스의 경우 카디널리티가 제일 높은 키가 가장 마지막에 두어졌기 때문이다.

당시의 이유로는:

 

  • 해당 키는 기본키였고,
  • 동시에 범위 검색 (<) 조건에 사용되는 컬럼이었기 때문이다.

 

하지만 이 선택이 정말 최선이었을까? 라는 생각이 들어 쿼리 구조와 인덱스 설계를 한 번 더 들여다보게 되었다.

 

실제 고민한 쿼리 구조와 인덱스

기존 인덱스:

CREATE INDEX idx_grouping_opt
ON event_result_table (event_id, group_value, user_id);

 

슬로우 쿼리:

SELECT COUNT(user_id) AS result_count
FROM event_result_table
WHERE user_id < ?
  AND event_id = ?
  AND group_key = ?
  AND group_value = ?;

 

고민 포인트 1: 조건문의 컬럼 순서와 인덱스 순서를 맞춰야 할까?

컬럼명 카디널리티
user_id (PK) 2,110,000
group_value 16,325
event_id 78

→ user_id, group_value, event_id 순으로 바꾸면 더 낫지 않을까?

하지만 범위 조건 (<) 이기 때문에 뒤에 배치한 것이 맞는 선택이다.

그렇다면 

고민 포인트 2: 슬로우 쿼리문에 항상 함께 사용되던 group_key2 (string) 도 인덱스에 포함시켰다면 더 효율적이었을까?

CREATE INDEX idx_grouping_opt
ON event_result_table (event_id, group_key2, group_key, group_value, user_id);

 

 

나는 이런 고민들에 대해 다음 시즌이 오픈되기 전 조언을 구하였고,

다음과 같은 답변을 받게되었다!

인덱스 컬럼 순서와 쿼리 조건 순서는 달라도 괜찮다. 오히려 쿼리를 약간 수정하는 쪽이 더 효율적일 수도 있음.
또한 group_key2는 항상 같은 값이 들어가는 컬럼이라면, 오히려 인덱스에 포함시키지 않는 것이 나을 수 있다.
당시 EXPLAIN으로 비교해보았을 때도, 포함 유무에 따른 차이가 거의 없었기 때문에 지난번 요청했던 방식으로 생성하였다.

 

이로부터 단순히 쿼리 조건에 등장하는 컬럼 순서나 카디널리티 높은 컬럼이 앞에 와야 한다는 기준만으로 인덱스를 설계하기보다는, 실제 데이터의 분포, 쿼리 패턴, 실행 계획(EXPLAIN)을 함께 고려해야 한다는 걸 다시금 느꼈다.

그리고 “효율적인 인덱스”란 정답이 있는 게 아니라, 실제 운영 환경에서 가장 잘 작동하는 조합을 찾아내는 과정임을 배웠다.

 

 

이 포스트는 실제 실무에서 겪었던 대용량 트러블슈팅 사례를 바탕으로 작성되었습니다.
문제 발견시 댓글 부탁드리고, 따뜻한 조언은 언제나 환영입니다 :)

 

댓글