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

  • 커넥션 풀 고갈은 어떻게 전체 장애로 번지는가?
  • 느린 쿼리 하나가 정상 API까지 느리게 만드는 이유는 무엇인가?
  • 장애 중 pool size 조정은 어떻게 판단해야 하는가?

개요

커넥션 풀 고갈은 백엔드 DB 장애에서 매우 흔하고 위험한 증상이다. 모든 커넥션이 사용 중이면 새 요청은 커넥션을 기다린다. 대기 시간이 늘면 요청 스레드가 쌓이고, gateway timeout과 사용자 실패가 증가한다.

중요한 점은 풀 고갈이 원인일 수도 있고 결과일 수도 있다는 것이다. 느린 쿼리, 락 대기, DB CPU 포화가 먼저 생기고 그 결과 커넥션이 반납되지 않아 풀 고갈이 발생할 수 있다.

원리

Pool 고갈의 원리는 제한된 connection을 오래 붙잡는 요청이 생기면, 그 요청과 무관한 다른 API까지 connection acquire 단계에서 멈춘다는 것이다. 그래서 pool 고갈은 원인이 아니라 증상인 경우가 많다. 느린 쿼리, lock wait, 긴 트랜잭션, 외부 I/O, connection leak가 connection 반납을 늦추고, 그 결과 전체 요청이 대기열에 쌓인다.

장애 대응의 핵심은 “pool을 키울까?”보다 “무엇이 connection을 오래 잡고 있는가?”를 찾는 것이다. DB가 이미 병목이면 pool을 키우는 것은 대기열을 DB 내부로 밀어 넣는 선택이 될 수 있다.

장애 전파 흐름

대표 흐름은 다음과 같다.

느린 쿼리 또는 lock wait 발생
  커넥션 반납 지연
    Hikari active connection이 maximum 도달
      새 요청이 connection acquire 대기
        애플리케이션 스레드 점유
          API timeout 증가
            재시도 트래픽 증가
              DB 부하 증가

이 흐름에서 재시도 정책이 공격자가 될 수 있다. 클라이언트나 내부 서비스가 timeout 후 즉시 재시도하면 DB는 더 많은 요청을 받는다.

이때 circuit breaker, rate limit, bulkhead가 도움이 된다. 특히 읽기 API, 쓰기 API, batch/export가 같은 pool을 공유한다면 한 workload의 장애가 다른 workload로 전파된다.

예시 로그

애플리케이션에서는 이런 로그를 볼 수 있다.

HikariPool-1 - Connection is not available, request timed out after 3000ms.

DB에서는 lock wait가 보일 수 있다.

SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL;

API 로그에서는 서로 다른 endpoint가 모두 느려질 수 있다. 원인은 특정 쓰기 API의 락 대기인데, 같은 pool을 공유하는 읽기 API도 커넥션을 못 얻어 느려지는 식이다.

먼저 할 일

장애 중에는 다음 순서로 본다.

  • 풀 지표에서 active, idle, pending을 확인한다.
  • DB에서 active query와 lock wait를 확인한다.
  • 최근 배포, 배치, migration을 확인한다.
  • 가장 오래 실행 중인 쿼리와 트랜잭션을 확인한다.
  • 안전하게 중지할 수 있는 배치나 쿼리가 있는지 판단한다.
  • API rate limit이나 기능 차단으로 유입을 줄인다.
  • retry 폭주가 있는지 확인하고 backoff나 circuit breaker를 적용한다.
  • batch/export/admin workload를 멈추거나 낮은 우선순위 pool로 격리한다.

pool size를 늘리는 것은 마지막에 가까운 선택이다. DB가 여유 있고 단순히 pool이 너무 작게 잡힌 경우에는 도움이 될 수 있지만, DB가 이미 병목이면 악화될 수 있다.

코드에서 만드는 원인

다음 패턴은 pool 고갈을 부른다.

@Transactional
public void exportLargeReport() {
    List<Order> orders = orderRepository.findAllByCreatedAtBetween(start, end);
    for (Order order : orders) {
        reportWriter.write(order); // 긴 파일 I/O
    }
}

대량 데이터를 한 트랜잭션에서 읽고 파일 I/O까지 수행하면 커넥션을 오래 잡는다. stream 처리도 트랜잭션과 커넥션을 오래 열 수 있으므로 주의해야 한다.

개선 방향은 배치 크기를 줄이고, 읽기와 외부 I/O를 분리하고, 커서/페이지 처리와 timeout을 설계하는 것이다.

public void exportLargeReport() {
    Long lastId = 0L;
    while (true) {
        List<OrderRow> rows = orderRepository.findNextRows(lastId, 1000);
        if (rows.isEmpty()) {
            break;
        }
        reportWriter.write(rows);
        lastId = rows.get(rows.size() - 1).id();
    }
}

실전 팁

  • DB 작업과 외부 I/O를 같은 트랜잭션에 오래 묶지 않는다.
  • bulk 작업은 별도 pool, 별도 스케줄, 작은 batch로 분리한다.
  • 사용자 API와 관리자/배치 작업이 같은 pool을 공유하면 장애가 전파될 수 있다.
  • 풀 고갈 알림은 timeout이 폭증한 뒤가 아니라 pending acquire가 증가하는 시점에 잡는다.
  • timeout 후 재시도는 jitter와 backoff를 둔다.
  • pool 고갈 알림에는 endpoint, pool name, pending, active, acquire time p95, DB lock wait를 함께 붙인다.
  • 장애 중에는 새 기능 배포보다 유입 제한, 배치 중단, blocking query 확인을 먼저 한다.
  • 같은 DB라도 API, batch, admin pool을 분리하면 장애 전파 범위를 줄일 수 있다.

위험 신호!

  • 모든 API와 배치가 같은 DataSource pool을 사용한다.
  • 긴 리포트 생성이 웹 요청 트랜잭션 안에서 실행된다.
  • 클라이언트가 timeout마다 즉시 재시도한다.
  • 장애 중 pool size만 계속 늘린다.
  • active connection이 maximum인데 DB에서 lock wait가 많다.
  • connection timeout을 client retry가 즉시 재시도해 요청 수가 눈덩이처럼 늘어난다.
  • pool 고갈이 특정 endpoint에서 시작됐는지 추적할 trace가 없다.
  • batch/export가 사용자 API와 같은 pool을 모두 점유한다.

확인 질문

  • 커넥션 풀 고갈이 정상 API까지 느리게 만드는 이유는 무엇인가?
    • 같은 풀을 공유하는 요청들이 커넥션을 얻기 위해 대기하므로 문제 쿼리와 무관한 API도 DB 접근 전부터 막히기 때문이다.
  • pool size를 늘려도 되는지 판단하려면 무엇을 봐야 하는가?
    • DB CPU/I/O 여유, lock wait 여부, 느린 쿼리 원인, 총 연결 수, 인스턴스 수, acquire 대기 원인을 봐야 한다.
  • 장애 중 가장 먼저 줄여야 할 것은 무엇인가?
    • DB로 유입되는 비필수 작업과 재시도 폭주, 오래 도는 배치나 쿼리다.
  • pool 고갈이 원인인지 결과인지 구분하려면 무엇을 봐야 하는가?
    • acquire time과 pending 증가 시점, slow query와 lock wait, active session, 긴 트랜잭션, 최근 배포/배치, endpoint별 connection 보유 시간을 함께 봐야 한다.

참고 문서