이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 느린 API를 만났을 때 어떤 순서로 DB 원인을 좁혀야 하는가?
  • slow query, lock wait, connection pool 문제를 어떻게 구분하는가?
  • 장애 중 임시 완화와 근본 해결은 어떻게 나눠야 하는가?

개요

느린 쿼리 분석은 “인덱스 추가”로 바로 뛰어들면 안 된다. API가 느린 원인은 SQL 자체, 락 대기, 커넥션 풀 고갈, DB CPU/I/O, 네트워크, 외부 API, 애플리케이션 스레드 고갈 등 여러 층에 있다.

좋은 루틴은 원인을 빨리 좁히고, 장애 중에는 영향을 줄이며, 장애 후에는 재발 방지로 이어진다.

원리

느린 쿼리 분석의 원리는 “한 번에 정답을 맞히는 것”이 아니라 대기 위치를 빠르게 분리하는 것이다. 요청은 애플리케이션 스레드, connection pool, DB session, lock, CPU, I/O, network, ORM 매핑을 통과하므로, 어느 단계에서 시간을 쓰는지 나누지 않으면 잘못된 조치를 하게 된다.

장애 중에는 근본 튜닝보다 피해 확산을 줄이는 판단이 먼저다. pool pending이 폭증하는데 인덱스를 만들기 시작하거나, lock wait인데 timeout만 늘리거나, N+1인데 DB CPU만 보는 식의 대응을 피해야 한다.

먼저 확인할 것

느린 API가 보고되면 다음 증거부터 모은다.

  • 느린 시간대
  • endpoint와 method
  • p50, p95, p99 응답 시간
  • 호출량 변화
  • 에러율과 timeout 종류
  • 실제 SQL과 바인딩 값
  • 커넥션 풀 active/idle/pending 지표
  • DB CPU, I/O, active session
  • lock wait 또는 deadlock 로그
  • 최근 배포와 배치 작업

“DB가 느려요”라는 문장을 위 정보로 쪼개야 한다.

처음 공유 메시지는 다음처럼 구체적이어야 한다.

14:05 이후 GET /orders/latest p99 350ms -> 4.2s
트래픽은 1.3배, 에러는 Hikari connection timeout 위주
slow query 상위는 orders by member_id latest
pool pending 0 -> 80, DB CPU 45%, lock wait 일부 관측
13:58 주문 목록 fetch join 변경 배포

이 정도면 백엔드, 인프라, DBA가 같은 타임라인에서 움직일 수 있다.

실제 SQL과 호출 빈도

ORM을 쓰면 먼저 실제 SQL을 확보한다.

2026-06-30 13:10:21.331 DEBUG SQL:
select o.id, o.member_id, o.status, o.created_at
from orders o
where o.member_id=?
order by o.created_at desc
limit ?
 
binding parameter [1] as [BIGINT] - 10
binding parameter [2] as [INTEGER] - 20

느린 쿼리 하나가 문제인지, 짧은 쿼리가 너무 많이 반복되는지 구분한다. N+1은 각 쿼리만 보면 빠르지만 전체 API에서는 느리다.

select * from order_items where order_id = ? -- 100회 반복

이 경우 인덱스보다 fetch 전략, batch size, DTO 조회가 먼저일 수 있다.

실행 계획 확인

SQL과 바인딩 값을 확보한 뒤 실행 계획을 본다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.member_id, o.status, o.created_at
FROM orders o
WHERE o.member_id = 10
ORDER BY o.created_at DESC
LIMIT 20;

확인할 질문은 다음이다.

  • 전체 스캔인가?
  • 인덱스를 쓰는가?
  • 읽는 row가 너무 많은가?
  • Sort가 큰 비용을 쓰는가?
  • 예상 row와 실제 row가 크게 다른가?
  • 조인 순서가 이상한가?

실행 계획만으로 락 대기를 알 수 없는 경우도 있다. 실행 자체는 빠른 쿼리지만 락을 기다려 느릴 수 있다.

따라서 slow query와 lock wait를 분리한다. 실행 계획에서 비용이 낮아 보여도 실제 요청이 느리면 pg_stat_activity, MySQL Performance Schema, pool acquire time을 함께 확인한다.

락 대기 확인

PostgreSQL에서는 다음처럼 활동 세션을 볼 수 있다.

SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state <> 'idle';

wait_event_typeLock이면 락 대기 가능성이 있다. 오래 열린 트랜잭션도 확인한다.

SELECT pid, state, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY tx_age DESC;

MySQL에서는 Performance Schema, SHOW PROCESSLIST, InnoDB status 등을 통해 대기와 락을 확인한다.

백엔드 개발자는 이 정보를 보고 “어떤 코드 경로가 이 트랜잭션을 오래 열었는가”를 추적해야 한다.

커넥션 풀 확인

느린 쿼리 몇 개가 커넥션을 오래 잡으면 풀 대기가 생긴다. 이때 정상 쿼리도 커넥션을 얻지 못해 느려진다.

HikariCP에서는 다음 지표가 중요하다.

  • active connections
  • idle connections
  • pending threads
  • connection acquire time
  • connection timeout count

증상이 다음이면 풀 고갈을 의심한다.

SQLTransientConnectionException:
HikariPool-1 - Connection is not available, request timed out after 30000ms.

이때 바로 maximumPoolSize를 키우면 DB를 더 세게 때릴 수 있다. 먼저 어떤 쿼리가 커넥션을 오래 잡는지, 락 대기인지, 외부 호출이 트랜잭션 안에 있는지 확인한다.

임시 완화와 근본 해결

장애 중에는 완화가 먼저다.

  • 문제가 되는 endpoint를 rate limit 한다.
  • 오래 도는 배치를 중지한다.
  • 비핵심 기능을 임시 비활성화한다.
  • 캐시 가능한 조회를 캐시로 우회한다.
  • 명확히 안전한 경우 blocking query를 종료한다.

근본 해결은 장애 후에 분리해서 진행한다.

  • 인덱스 추가 또는 제거
  • 쿼리 구조 변경
  • pagination 방식 변경
  • 트랜잭션 경계 축소
  • N+1 제거
  • pool과 timeout 재설계
  • 모니터링과 알림 추가

실전 팁

  • 느린 쿼리와 느린 API를 구분한다. API가 느린데 DB는 빠를 수도 있고, DB가 느린데 API 로그에는 timeout만 보일 수도 있다.
  • 장애 중에는 새 인덱스 생성도 부하가 될 수 있으므로 신중하게 판단한다.
  • slow query log는 호출 빈도와 함께 봐야 한다. 5초 쿼리 1회와 100ms 쿼리 100만 회는 다른 문제다.
  • DB 지표와 애플리케이션 배포 시간을 같은 타임라인에 놓는다.
  • 회고에는 SQL, 실행 계획, 지표, 코드 경로, 재발 방지 항목을 남긴다.
  • 먼저 “느린 쿼리 1개”인지 “짧은 쿼리 반복”인지 “lock/pool wait”인지 분류한다.
  • 장애 중 새 인덱스 생성은 DDL 부하와 lock 위험 때문에 마지막에 검토한다.
  • 완화 조치에는 종료 조건을 둔다. 예를 들어 rate limit을 언제 풀지, batch를 언제 재개할지 정한다.

위험 신호!

  • timeout을 늘리고 장애가 해결됐다고 생각한다.
  • DB CPU만 보고 락 대기를 놓친다.
  • slow query 하나만 보고 N+1 반복 호출을 놓친다.
  • pool size를 키웠더니 DB CPU와 lock wait가 더 증가한다.
  • 인프라/DBA에게 실제 SQL 없이 “DB가 느려요”만 전달한다.
  • EXPLAIN만 보고 lock wait와 connection acquire time을 보지 않는다.
  • 장애 중 즉흥적으로 만든 완화 설정이 회고 없이 영구 설정이 된다.

확인 질문

  • 느린 API에서 DB 원인을 분석할 때 가장 먼저 확보할 정보는 무엇인가?
    • 실제 SQL과 바인딩 값, 호출 빈도, 응답 시간 분포, 커넥션 풀 지표, DB 지표, 락 대기 여부다.
  • 커넥션 풀 고갈에서 pool size를 바로 키우면 위험한 이유는 무엇인가?
    • DB로 들어가는 동시 작업을 늘려 근본 병목을 악화시킬 수 있기 때문이다.
  • 장애 대응에서 임시 완화와 근본 해결을 나눠야 하는 이유는 무엇인가?
    • 장애 중에는 영향 범위 축소가 우선이고, 구조 변경은 충분한 검증과 배포 절차가 필요하기 때문이다.
  • 느린 API를 분석할 때 실행 계획만으로 부족한 이유는 무엇인가?
    • 실행 계획은 접근 경로와 연산 비용을 보여주지만, lock wait, connection pool 대기, network, 애플리케이션 매핑 비용은 별도 지표로 확인해야 하기 때문이다.

참고 문서