서비스를 운영하다 보면 평소에는 잘 동작하던 조회 API가 갑자기 느려지는 순간이 있다.
SELECT *
FROM orders
WHERE customer_id = 1004
ORDER BY created_at DESC
LIMIT 20;
쿼리만 보면 단순해 보인다. 이런 상황에서 가장 먼저 떠오르는 해결책은 보통 이것이다.
“인덱스를 추가하면 되지 않을까?”
하지만 조회가 느린 원인은 인덱스 하나로 끝나지 않는다. 실행 계획이 잘못됐을 수도 있고, 읽는 데이터가 너무 많거나, 정렬과 조인 비용이 클 수도 있다. 쿼리가 아니라 락이나 커넥션 풀에서 기다리고 있을 가능성도 있다.
따라서 느린 SELECT를 만났을 때는 감으로 인덱스를 추가하기보다 다음 순서로 원인을 좁혀야 한다.
느린 요청 확인
→ 실제 SQL과 실행 시간 확인
→ 실행 계획 분석
→ 읽은 데이터 양 확인
→ 인덱스와 조건절 점검
→ JOIN·정렬·집계 점검
→ 락과 서버 자원 확인
→ 변경 전후 성능 측정
1. 정말 SQL 실행이 느린지 확인한다
API 응답이 느리다고 해서 반드시 SELECT가 느린 것은 아니다.
요청은 데이터베이스에 도착하기 전후로 여러 구간을 거친다.
클라이언트
→ 애플리케이션 서버
→ 커넥션 풀 대기
→ DB 네트워크
→ SQL 실행
→ 결과 전송
→ 객체 변환
→ 응답 직렬화
SQL 자체는 30ms 만에 끝났지만 커넥션 풀에서 2초를 기다렸을 수 있다. 반대로 DB에서는 빠르게 조회했지만 수십만 건의 결과를 애플리케이션으로 전달하느라 오래 걸렸을 수도 있다.
먼저 다음 항목을 구분해서 측정해야 한다.
- API 전체 응답 시간
- 커넥션을 얻기까지 걸린 시간
- DB에서 SQL을 실행한 시간
- 조회 결과를 전송하고 변환한 시간
- 반환된 행의 개수와 데이터 크기
MySQL이라면 Slow Query Log, PostgreSQL이라면 log_min_duration_statement나 pg_stat_statements 등을 이용해 실제로 느린 SQL을 찾을 수 있다.
핵심은 추측이 아니라 실제 운영 환경에서 어떤 SQL이 얼마나 자주, 얼마나 오래 실행됐는지 확인하는 것이다.
2. 실행 계획부터 확인한다
느린 SQL을 찾았다면 다음 단계는 EXPLAIN으로 실행 계획을 확인하는 것이다.
EXPLAIN
SELECT *
FROM orders
WHERE customer_id = 1004
ORDER BY created_at DESC
LIMIT 20;
실제로 쿼리를 실행한 통계까지 보고 싶다면 DBMS가 지원하는 명령을 사용할 수 있다.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE customer_id = 1004
ORDER BY created_at DESC
LIMIT 20;
EXPLAIN ANALYZE는 SQL을 실제로 실행할 수 있으므로, 운영 환경에서 UPDATE나 DELETE에 무심코 사용하면 안 된다. SELECT라도 매우 무거운 쿼리라면 주의해야 한다.
실행 계획에서는 다음 내용을 살펴본다.
- 테이블 전체를 읽는지
- 어떤 인덱스를 선택했는지
- 예상 행 수와 실제 행 수가 크게 다른지
- JOIN 순서가 적절한지
- 별도의 정렬이나 임시 공간을 사용하는지
- 각 단계에서 얼마나 많은 행을 읽고 버렸는지
- 어느 단계에서 가장 많은 시간이 소비되는지
특히 중요한 것은 최종적으로 반환한 행 수보다 몇 행을 읽었는가이다.
20건을 반환하는 쿼리가 20건만 읽었다면 효율적이다. 반면 100만 건을 읽고 20건만 반환했다면 많은 데이터를 불필요하게 탐색한 것이다.
반환 행 수: 20건
실제 탐색 행 수: 1,000,000건
→ 필터링 전에 너무 많은 데이터를 읽고 있음
3. WHERE 조건에 적절한 인덱스가 있는지 확인한다
다음 쿼리가 자주 실행된다고 해보자.
SELECT *
FROM orders
WHERE customer_id = 1004;
customer_id에 인덱스가 없다면 DB는 원하는 주문을 찾기 위해 테이블 전체를 읽을 수 있다.
CREATE INDEX idx_orders_customer_id
ON orders (customer_id);
하지만 WHERE 절에 등장하는 모든 컬럼에 무조건 인덱스를 만드는 것은 좋은 방법이 아니다.
인덱스는 조회를 빠르게 만들 수 있지만 다음 비용이 생긴다.
- INSERT, UPDATE, DELETE 시 인덱스도 함께 수정해야 한다.
- 디스크와 메모리를 추가로 사용한다.
- 비슷한 인덱스가 많아지면 옵티마이저의 선택과 관리가 복잡해진다.
- 값의 종류가 적은 컬럼은 단독 인덱스의 효과가 작을 수 있다.
예를 들어 대부분의 주문 상태가 COMPLETED라면 다음 조건은 많은 행을 반환한다.
WHERE status = 'COMPLETED'
status 인덱스가 있어도 테이블의 대부분을 읽어야 한다면 DB가 전체 스캔을 선택할 수 있다. 인덱스가 존재한다고 해서 반드시 사용되는 것은 아니다.
4. 복합 인덱스의 컬럼 순서를 확인한다
실무에서는 조건이 하나만 있는 경우보다 여러 조건과 정렬이 함께 사용되는 경우가 많다.
SELECT id, status, total_price, created_at
FROM orders
WHERE customer_id = 1004
AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
이 쿼리에는 다음과 같은 복합 인덱스를 고려할 수 있다.
CREATE INDEX idx_orders_customer_status_created
ON orders (customer_id, status, created_at DESC);
이 인덱스는 다음 흐름에 맞춰져 있다.
customer_id로 범위를 좁힌다
→ status로 다시 좁힌다
→ created_at 순서대로 읽는다
→ 20건에서 멈춘다
복합 인덱스는 컬럼의 순서가 중요하다. 일반적으로 다음 요소를 함께 고려한다.
- 동등 조건: customer_id = ?
- 범위 조건: created_at >= ?
- 정렬 조건: ORDER BY created_at
- 컬럼의 선택도
- 실제 쿼리 패턴
예를 들어 다음 인덱스는 서로 같지 않다.
(customer_id, status, created_at)
(status, customer_id, created_at)
(created_at, customer_id, status)
따라서 “WHERE 절에 있는 컬럼을 전부 인덱스에 넣는다”가 아니라, 자주 실행되는 조회 패턴과 실행 계획을 기준으로 순서를 결정해야 한다.
5. 인덱스를 못 타게 만드는 조건인지 확인한다
인덱스가 있어도 조건식을 어떻게 작성했는지에 따라 제대로 활용하지 못할 수 있다.
컬럼에 함수를 적용하는 경우
SELECT *
FROM orders
WHERE DATE(created_at) = '2026-09-03';
컬럼 값을 행마다 변환해야 하므로 일반적인 created_at 인덱스를 효율적으로 사용하지 못할 수 있다.
범위 조건으로 바꾸는 편이 유리하다.
SELECT *
FROM orders
WHERE created_at >= '2026-09-03 00:00:00'
AND created_at < '2026-09-04 00:00:00';
앞부분이 열려 있는 LIKE 검색
WHERE product_name LIKE '%keyboard%'
일반적인 B-Tree 인덱스는 문자열의 시작점을 알 수 없기 때문에 이런 검색에 취약하다.
반면 다음 조건은 인덱스를 활용할 가능성이 있다.
WHERE product_name LIKE 'keyboard%'
문장 내부 검색이 핵심 기능이라면 일반 인덱스에 집착하기보다 Full Text Search나 Elasticsearch 같은 검색 전용 기술을 검토할 수 있다.
암묵적 타입 변환
문자열 컬럼을 숫자로 비교하는 등 타입이 일치하지 않으면 컬럼에 변환이 발생해 인덱스 활용이 제한될 수 있다.
-- phone_number가 문자열 타입인 경우
WHERE phone_number = 1012345678
다음과 같이 타입을 맞추는 것이 안전하다.
WHERE phone_number = '1012345678'
6. SELECT *와 반환 데이터의 크기를 확인한다
SELECT *는 편리하지만 필요하지 않은 데이터까지 읽고 전송한다.
SELECT *
FROM orders
WHERE customer_id = 1004;
목록 화면에 주문 번호, 상태, 금액만 필요하다면 필요한 컬럼만 선택하는 것이 좋다.
SELECT id, status, total_price
FROM orders
WHERE customer_id = 1004;
이렇게 하면 다음 비용을 줄일 수 있다.
- DB가 읽는 데이터 크기
- 네트워크 전송량
- 애플리케이션의 객체 생성 비용
- JSON 직렬화 비용
- 메모리 사용량
조건과 조회 컬럼이 인덱스 안에서 모두 해결되면 테이블 본문을 추가로 읽지 않는 커버링 인덱스 효과도 기대할 수 있다. 다만 이를 위해 너무 많은 컬럼을 인덱스에 포함하면 인덱스 자체가 커지고 쓰기 비용도 증가하므로 균형이 필요하다.
7. JOIN에서 데이터가 폭발하지 않는지 확인한다
JOIN이 느린 이유는 테이블 개수보다 JOIN 과정에서 만들어지는 중간 결과의 크기에 있는 경우가 많다.
SELECT *
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN product_images pi ON pi.product_id = oi.product_id;
한 주문에 상품이 10개이고 각 상품에 이미지가 5개라면 주문 한 건이 중간 결과에서는 50건으로 증가할 수 있다.
주문 1건
× 주문 상품 10건
× 상품 이미지 5건
= 중간 결과 50건
JOIN을 확인할 때는 다음을 살펴본다.
- JOIN 컬럼에 인덱스가 있는가
- ON 조건이 누락되거나 잘못되지 않았는가
- 1:N 관계가 여러 번 연결되어 행이 폭증하지 않는가
- JOIN 전에 데이터를 충분히 필터링하고 있는가
- DISTINCT로 잘못된 JOIN 결과를 뒤늦게 제거하고 있지는 않은가
DISTINCT를 추가했더니 결과가 정상적으로 보인다면, 중복 제거 비용을 고민하기 전에 JOIN 관계가 올바른지 먼저 확인해야 한다.
8. ORDER BY와 GROUP BY 비용을 확인한다
필터링이 빠르더라도 정렬하거나 그룹화해야 하는 데이터가 많으면 쿼리는 느려질 수 있다.
SELECT *
FROM orders
WHERE customer_id = 1004
ORDER BY created_at DESC
LIMIT 20;
customer_id만 인덱싱돼 있다면 해당 고객의 주문을 찾은 뒤 다시 정렬해야 할 수 있다. 고객별 주문이 매우 많다면 정렬 비용도 커진다.
이 경우 조회 패턴에 맞는 복합 인덱스가 도움 될 수 있다.
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC);
집계 쿼리도 마찬가지다.
SELECT customer_id, COUNT(*)
FROM orders
GROUP BY customer_id;
대량 데이터를 매 요청마다 집계하고 있다면 인덱스만으로 해결하려 하지 말고 다음 방법도 검토해야 한다.
- 집계 결과를 별도 테이블에 미리 저장한다.
- 배치 작업으로 통계를 생성한다.
- Materialized View를 사용한다.
- 읽기 전용 프로젝션을 구성한다.
- 결과를 캐싱한다.
9. OFFSET 페이지네이션을 확인한다
페이지 번호가 뒤로 갈수록 느려진다면 OFFSET이 원인일 수 있다.
SELECT id, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 500000;
DB는 500,000번째 행으로 순간 이동하는 것이 아니다. 앞의 행을 읽고 건너뛴 뒤 필요한 20건을 반환할 수 있다.
뒤 페이지까지 자주 조회해야 한다면 마지막으로 조회한 값을 기준으로 다음 페이지를 찾는 커서 방식, 즉 Keyset Pagination을 고려할 수 있다.
SELECT id, created_at
FROM orders
WHERE (created_at, id) < ('2026-09-03 12:00:00', 15000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
created_at이 같은 데이터가 있을 수 있으므로 고유한 id를 함께 사용하면 정렬 순서를 안정적으로 유지할 수 있다.
OFFSET 방식
→ 앞의 데이터를 읽고 버린다.
커서 방식
→ 인덱스에서 마지막 위치 다음부터 읽는다.
다만 커서 방식은 특정 페이지 번호로 바로 이동하기 어렵다는 트레이드오프가 있다.
10. 인덱스 문제가 아니라 대기 문제일 수도 있다
SELECT가 느리다고 해서 항상 데이터를 읽는 작업 자체가 느린 것은 아니다.
다음 원인으로 SQL이 실행되지 못하고 기다릴 수도 있다.
- 다른 트랜잭션이 잡고 있는 락
- 너무 오래 열린 트랜잭션
- 커넥션 풀 고갈
- DB 커넥션 수 부족
- 디스크 I/O 증가
- CPU 사용률 상승
- 메모리 부족
- 네트워크 지연
- 복제 DB의 동기화 지연
특히 실행 계획은 정상인데 실행 시간이 특정 시점에만 갑자기 증가한다면 서버 자원과 대기 이벤트를 함께 확인해야 한다.
평소에도 항상 느리다
→ 쿼리 구조, 인덱스, 데이터 양 점검
특정 시간대에만 느리다
→ 트래픽, 락, 배치 작업, 서버 자원 점검
가끔 몇 초씩 튄다
→ 커넥션 대기, 락 대기, I/O 지연 점검
11. 통계 정보와 데이터 분포도 확인한다
DB의 옵티마이저는 테이블 통계를 기반으로 실행 계획을 선택한다. 통계가 오래됐거나 실제 데이터 분포와 맞지 않으면 비효율적인 계획을 선택할 수 있다.
예를 들어 옵티마이저는 조건에 맞는 데이터가 100건이라고 예상했지만 실제로는 100만 건일 수 있다.
예상 행 수: 100
실제 행 수: 1,000,000
이처럼 예상값과 실제값의 차이가 크다면 다음 항목을 확인할 수 있다.
- 테이블 통계가 최신인지
- 특정 값에 데이터가 지나치게 몰려 있는지
- 파라미터에 따라 데이터 분포가 크게 달라지는지
- 최근 데이터 증가로 기존 인덱스의 효율이 달라졌는지
같은 SQL도 어떤 조건값을 넣었는지에 따라 성능이 크게 달라질 수 있다. 평균 실행 시간만 보지 말고 느린 파라미터와 데이터 분포까지 살펴봐야 한다.
인덱스를 추가하기 전 확인할 체크리스트
SELECT가 느릴 때는 다음 순서로 점검하면 된다.
- 느린 구간이 정말 DB의 SQL 실행인지 측정한다.
- 실제로 실행된 SQL과 바인딩 값을 확인한다.
- EXPLAIN ANALYZE로 실행 계획을 확인한다.
- 반환한 행보다 지나치게 많은 행을 읽는지 확인한다.
- WHERE와 JOIN 컬럼에 적절한 인덱스가 있는지 확인한다.
- 복합 인덱스의 컬럼 순서가 조회 패턴과 맞는지 확인한다.
- 함수, 타입 변환, %keyword%가 인덱스 사용을 방해하는지 확인한다.
- JOIN의 중간 결과가 폭증하지 않는지 확인한다.
- 정렬, 집계, OFFSET 비용을 확인한다.
- 락, 커넥션 풀, CPU, 메모리, 디스크 I/O를 확인한다.
- 변경 전후의 실행 시간과 탐색 행 수를 다시 측정한다.
자주 하는 실수
느리면 바로 인덱스를 추가한다
원인을 확인하지 않고 인덱스를 추가하면 사용되지 않는 인덱스만 늘어날 수 있다. 쓰기 성능과 저장 공간에도 비용이 발생한다.
개발 환경에서 한 번 실행하고 빠르다고 판단한다
개발 환경은 데이터가 적고 동시에 실행되는 요청도 거의 없다. 운영과 비슷한 데이터 양과 분포에서 확인해야 한다.
실행 시간만 비교한다
캐시 상태와 서버 부하에 따라 실행 시간은 달라질 수 있다. 실행 계획, 실제 탐색 행 수, 정렬 여부 등도 함께 비교해야 한다.
LIMIT이 있으면 빠르다고 생각한다
SELECT *
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
인덱스가 조건과 정렬을 지원하지 못하면 DB는 많은 데이터를 읽고 정렬한 뒤 마지막에 20건만 반환할 수 있다. LIMIT 20은 반환 건수만 제한할 뿐, 항상 탐색 비용까지 20건으로 줄여주지는 않는다.
SELECT가 느리면 바로 인덱스를 추가하기보다 먼저 API 지연이 SQL 실행 시간인지 커넥션이나 네트워크 대기인지 구분하겠습니다. 그다음 실제 SQL과 파라미터를 확보하고 EXPLAIN ANALYZE를 통해 전체 스캔 여부, 사용 인덱스, 예상 행 수와 실제 행 수, JOIN 순서, 정렬과 임시 공간 사용 여부를 확인합니다. 특히 반환 행 수 대비 탐색 행 수가 큰 지점을 찾고, WHERE·JOIN·ORDER BY에 맞는 복합 인덱스를 점검하겠습니다. 실행 계획이 정상이라면 락 대기, 커넥션 풀, CPU와 디스크 I/O를 확인하고, 수정 후에는 운영과 유사한 데이터에서 실행 시간과 탐색 행 수를 다시 비교하겠습니다.
마무리
느린 SELECT를 해결하는 핵심은 인덱스를 많이 만드는 것이 아니다.
측정
→ 실행 계획 확인
→ 가장 비싼 단계 발견
→ 원인에 맞는 개선
→ 변경 전후 검증
인덱스가 필요한 문제라면 인덱스를 추가하고, 너무 많은 데이터를 반환한다면 조회 범위를 줄여야 한다. JOIN에서 데이터가 폭증한다면 관계와 조건을 수정하고, 반복되는 대규모 집계가 문제라면 미리 계산한 조회 모델이나 캐시를 검토해야 한다. 락이나 커넥션 풀에서 기다리는 문제라면 SQL 튜닝만으로는 해결되지 않는다.
결국 좋은 튜닝은 “이 인덱스를 추가했습니다”가 아니라 다음 질문에 답하는 과정이다.
이 쿼리는 어디에서 기다리고 있으며, 원하는 결과를 만들기 위해 실제로 얼마나 많은 데이터를 읽고 있는가?
'CS 정리' 카테고리의 다른 글
| 복합 인덱스, 컬럼 순서는 어떻게 정할까? (0) | 2026.09.05 |
|---|---|
| 이벤트 루프란? 싱글 스레드는 어떻게 수많은 요청을 처리할까 (0) | 2026.09.01 |
| DNS TTL이란? TTL을 300초로 설정하면 정말 5분 뒤에 IP가 바뀔까? (0) | 2026.08.31 |
| Cache-Aside를 실무에서 사용하면 어떤 문제가 생길까? (0) | 2026.08.29 |
| Cache-Aside란? (0) | 2026.08.28 |