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

  • 느린 API가 DB 때문인지 어떻게 좁혀야 하는가?
  • slow query와 connection pool 고갈은 어떻게 함께 봐야 하는가?
  • 장애 중 어떤 대응을 피해야 하는가?

개요

느린 API 대응은 백엔드 개발자의 DB 실전 역량이 가장 잘 드러나는 상황이다. 좋은 대응은 추측보다 증거를 모은다. API 로그, SQL, 실행 계획, DB 지표, 커넥션 풀 지표를 하나의 타임라인으로 연결한다.

원리

느린 API 대응의 원리는 병목을 단일 원인으로 단정하지 않고 요청 경로를 단계별로 나누는 것이다. HTTP 처리, pool acquire, SQL 실행, lock wait, row scan, 정렬, count, serialization 중 어디에서 시간이 늘었는지 분해해야 한다.

백엔드 개발자는 slow query를 “DB 문제”로 넘기는 사람이 아니라, SQL과 API 계약을 함께 바꾸는 사람이다. 때로는 인덱스보다 count 제거, 기간 제한, 비동기 export, 캐시, rate limit이 더 좋은 해결이다.

상황

사용자가 주문 목록 API가 느리다고 제보했다.

GET /api/members/10/orders?page=0&size=20
p95: 180ms -> 3.2s
error: gateway timeout 일부 발생

이때 바로 인덱스를 추가하지 않는다. 먼저 실제 병목이 어디인지 나눈다.

먼저 확인할 것

  • 언제부터 느려졌는가?
  • 특정 endpoint만 느린가, 전체 API가 느린가?
  • 특정 사용자나 파라미터에서만 느린가?
  • 실제 SQL과 바인딩 값은 무엇인가?
  • 호출량이 늘었는가?
  • connection pool pending이 증가했는가?
  • DB CPU, I/O, lock wait가 증가했는가?
  • 최근 배포, 배치, migration이 있었는가?

처음 15분에는 다음처럼 움직인다.

1. 장애 범위: 특정 API인가 전체 API인가?
2. 시간대: 언제부터 p95/p99가 튀었는가?
3. pool: acquire time과 pending이 늘었는가?
4. DB: CPU/I/O/lock wait/active session 중 무엇이 튀었는가?
5. SQL: slow fingerprint와 호출량이 바뀌었는가?
6. 변경: 최근 배포, batch, migration, 이벤트가 있었는가?
7. 완화: 유입 제한, batch 중지, 캐시 우회 중 무엇이 안전한가?

이 순서는 완벽한 원인 분석보다 피해 확산 방지에 초점을 둔다.

SQL과 실행 계획

실제 SQL을 확보한다.

SELECT o.id, o.status, o.created_at, o.total_price
FROM orders o
WHERE o.member_id = 10
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 0;

실행 계획을 본다.

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

만약 많은 row를 읽고 Sort가 발생한다면 후보 인덱스는 다음이다.

CREATE INDEX idx_orders_member_created_id
ON orders (member_id, created_at DESC, id DESC);

하지만 이미 인덱스가 있고도 느리다면 락 대기, 특정 회원의 데이터 쏠림, DB 전체 부하, 커넥션 풀 대기를 확인한다.

백엔드 개발자가 의심해야 할 원인

  • N+1로 주문 항목 조회가 반복된다.
  • Page count 쿼리가 전체 조건을 크게 스캔한다.
  • offset pagination이 뒤 페이지에서 많은 행을 건너뛴다.
  • 정렬 조건을 지원하는 인덱스가 없다.
  • 트랜잭션 안에서 외부 API나 파일 I/O를 수행한다.
  • 특정 대형 고객 데이터가 평균보다 훨씬 많다.

JPA N+1 예시는 다음과 같다.

List<Order> orders = orderRepository.findByMemberId(memberId);
for (Order order : orders) {
    order.getItems().size(); // 주문마다 추가 SQL
}

SQL 로그가 짧은 쿼리 수십 개를 보여준다면 slow query 하나보다 fetch 전략이 문제일 수 있다.

또한 EXPLAIN ANALYZE는 실제로 쿼리를 실행한다. 운영에서 변경 쿼리나 매우 무거운 쿼리에 무심코 붙이면 위험하다. 가능하면 replica, staging, 제한된 파라미터, DBA 협의로 실행한다.

인프라/DBA와 같이 볼 것

인프라/DBA와는 다음 자료를 함께 본다.

  • 문제 시간대 DB CPU, IOPS, memory
  • active session과 wait event
  • lock wait와 blocking query
  • slow query top N
  • replica lag
  • connection 수와 max connections 근접 여부
  • 최근 vacuum/analyze, 통계 상태

백엔드 쪽에서는 해당 시간대 배포와 트래픽 변화를 제공한다. DBA는 DB 내부 상태를 볼 수 있지만, 비즈니스 이벤트와 코드 변경 맥락은 개발자가 가장 잘 안다.

하지 말아야 할 대응

  • 실행 계획 없이 인덱스를 추가한다.
  • timeout만 늘린다.
  • pool size만 늘린다.
  • 장애 중 대량 배치를 재시작한다.
  • 모든 요청을 즉시 재시도하게 둔다.
  • 느린 API가 하나인데 전체 DB 스펙부터 올린다.

실전 팁

  • 느린 시간대와 배포 시간을 같은 타임라인에 놓는다.
  • SQL 하나의 평균 시간보다 호출 빈도와 총 시간을 함께 본다.
  • DB CPU가 낮은데 API가 느리면 lock wait와 pool wait를 의심한다.
  • 쿼리 개선 후에는 실행 계획과 p95/p99를 전후 비교한다.
  • 임시 완화와 근본 해결을 별도 이슈로 나눈다.
  • 느린 API 하나에도 data query와 count query를 분리해 본다.
  • DB CPU가 낮으면 “DB 문제 아님”으로 끝내지 말고 lock wait, pool wait, 외부 I/O 포함 트랜잭션을 확인한다.
  • 운영 진단 중 실행한 query와 조치 시간을 남겨 회고에서 재현 가능하게 만든다.

주니어 팁

느린 API를 보면 “코드”, “SQL”, “DB 상태”를 분리해서 적는다.

코드: 어떤 Repository 메서드가 호출되는가?
SQL: 실제 SQL은 무엇인가?
DB: 실행 계획과 lock/pool 상태는 어떤가?

이 세 줄만 잘 모아도 질문 수준이 달라진다.

시니어 팁

시니어는 즉시 완화와 근본 해결을 분리한다.

  • 즉시 완화: rate limit, 비핵심 기능 차단, 배치 중지, 캐시 우회, 안전한 query kill
  • 근본 해결: 인덱스, 쿼리 재작성, pagination 변경, fetch 전략 수정, 트랜잭션 축소, timeout 재설계

장애 중에는 완벽한 설계보다 피해 확산 방지가 먼저다.

Guru급 팁

느린 쿼리는 원인이 아니라 증상일 수 있다. lock wait 때문에 느린 쿼리로 기록될 수 있고, pool 고갈 때문에 정상 SQL이 timeout을 낼 수 있다. DB CPU가 낮은데 API가 느리다면 락 대기와 커넥션 대기를 의심한다. DB CPU가 높은데 lock wait가 낮다면 스캔, 정렬, 집계, 과도한 동시 쿼리를 의심한다.

위험 신호!

  • timeout을 늘린 뒤 장애가 해결됐다고 판단한다.
  • 실제 SQL과 바인딩 값을 확보하지 않는다.
  • pool pending 증가를 DB 스펙 부족으로만 해석한다.
  • N+1처럼 짧은 쿼리 반복을 slow query 목록에서 놓친다.
  • 장애 중 실행 계획 없이 운영 인덱스를 추가한다.
  • EXPLAIN ANALYZE의 운영 부하를 모르고 장애 중 반복 실행한다.
  • count 쿼리, serialization, 외부 API 지연을 모두 DB query 지연으로 뭉갠다.

확인 질문

  • 느린 API 대응에서 실제 SQL이 중요한 이유는 무엇인가?
    • 백엔드 코드와 ORM 메서드 이름만으로는 DB가 실제로 실행한 조인, 정렬, count, 바인딩 값을 알 수 없기 때문이다.
  • slow query가 원인이 아니라 결과일 수 있는 경우는 무엇인가?
    • 다른 트랜잭션의 락을 기다리거나 DB 전체 부하와 풀 고갈 때문에 실행이 지연된 경우다.
  • 장애 중 pool size를 바로 키우면 안 되는 이유는 무엇인가?
    • DB에 더 많은 동시 쿼리를 보내 근본 병목을 악화시킬 수 있기 때문이다.
  • 느린 API의 첫 15분 대응에서 목표는 무엇인가?
    • 전체 원인을 완벽히 밝히는 것이 아니라 영향 범위를 좁히고, 증거를 모으고, 안전한 완화 조치로 피해 확산을 막는 것이다.

참고 문서