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

  • DB 커넥션은 왜 요청마다 새로 만들면 안 되는가?
  • 커넥션은 애플리케이션과 DB 양쪽에서 어떤 자원을 점유하는가?
  • 백엔드 장애에서 커넥션 수가 왜 중요한 지표인가?

개요

DB 커넥션은 애플리케이션과 DB 사이의 대화 통로다. 웹 요청 하나가 DB 작업을 하려면 커넥션을 얻고 SQL을 보내고 결과를 받은 뒤 커넥션을 반납한다.

커넥션은 단순 객체가 아니다. 네트워크 연결, 인증, 세션 상태, DB 서버의 메모리와 프로세스 또는 스레드 자원을 사용한다. 그래서 대부분의 웹 애플리케이션은 커넥션을 매 요청마다 새로 만들지 않고 커넥션 풀에서 재사용한다.

원리

요청 흐름은 보통 다음과 같다.

HTTP Request
  Application Thread
    HikariCP에서 Connection 획득
      DB Session에서 SQL 실행
    Connection 반납
HTTP Response

커넥션을 새로 만들려면 TCP 연결, TLS, 인증, DB 세션 초기화가 필요할 수 있다. 요청량이 많을 때 이 비용을 매번 내면 응답 시간이 늘고 DB 서버도 연결 처리에 자원을 쓴다.

커넥션 풀은 미리 만들어 둔 커넥션을 빌려주고 반납받는다.

spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-timeout=3000

이 설정은 애플리케이션 인스턴스 하나가 최대 10개의 DB 커넥션을 동시에 사용할 수 있고, 커넥션을 얻기 위해 최대 3초 기다린다는 뜻이다.

HikariCP의 connectionTimeout은 SQL 실행 제한이 아니라 “풀에서 빌리기 위해 기다리는 시간”이다. 이 차이를 모르면 connection timeout을 slow query timeout처럼 오해하게 된다.

커넥션은 어디에서 비용을 만드는가

커넥션은 애플리케이션과 DB 양쪽에서 비용을 만든다.

  • 애플리케이션은 커넥션 풀 객체, 대기 큐, 요청 스레드를 사용한다.
  • DB는 연결별 세션 상태와 메모리, 작업자 자원을 사용한다.
  • 네트워크는 연결 유지와 패킷 왕복 비용을 가진다.
  • 트랜잭션은 커넥션에 묶여 실행된다.

커넥션을 오래 잡는 코드는 풀 전체를 압박한다.

@Transactional
public void placeOrder(OrderCommand command) {
    Order order = orderRepository.save(command.toOrder());
    paymentClient.pay(order.paymentRequest()); // 외부 API 호출
    order.markPaid();
}

이 코드는 결제 API가 느려지는 동안 DB 트랜잭션과 커넥션을 오래 잡을 수 있다. 결제 호출이 3초 지연되고 동시 요청이 늘면 커넥션 풀이 빠르게 고갈될 수 있다.

더 나은 구조는 DB 트랜잭션 안에서 해야 하는 일과 외부 시스템 호출을 분리하는 것이다. 실제 결제 도메인은 복잡하지만, 핵심은 커넥션 보유 시간을 짧게 만드는 것이다.

서버를 늘릴 때 생기는 문제

애플리케이션 인스턴스 하나의 pool size가 20이고 인스턴스가 10개라면 최대 200개 커넥션이 DB로 향한다.

maximumPoolSize 20 x application instances 10 = max 200 DB connections

서버를 수평 확장하면 DB 입장에서는 연결과 동시 쿼리가 늘어난다. 웹 서버 CPU는 여유가 생겨도 DB CPU, I/O, lock 경합이 더 빨리 한계에 도달할 수 있다.

그래서 오토스케일링 환경에서는 인스턴스 수 변화와 총 DB 연결 수를 함께 계산해야 한다. 개인 프로젝트에서도 Docker container를 여러 개 띄우면 같은 문제가 작게 재현된다.

운영 연결 예산은 애플리케이션만 보지 않는다.

API 서버: 8대 x pool 10 = 80
batch 서버: 2대 x pool 5 = 10
admin 서버: 2대 x pool 5 = 10
migration/monitoring/운영 접속 여유 = 20
총 예산 = 120

DB max_connections가 150이라면 수치상 가능해 보여도 failover, 배포 중 중복 인스턴스, connection leak, 운영 접속 여유를 감안하면 빠듯할 수 있다.

실전 팁

  • pool size는 애플리케이션 스레드 수가 아니라 DB가 감당할 동시 쿼리 수를 기준으로 본다.
  • 커넥션을 오래 잡는 코드는 외부 API, 파일 I/O, 긴 반복문, 대량 처리, 락 대기를 의심한다.
  • 서버 인스턴스 수를 늘릴 때 총 커넥션 수를 다시 계산한다.
  • 트랜잭션 안에서 사용자 응답 대기, 외부 API 호출, 대량 배치를 수행하지 않는다.
  • connection leak 감지는 개발과 스테이징에서 적극적으로 켠다.
  • pool 지표는 API endpoint와 연결해 본다. 특정 endpoint가 connection 보유 시간을 늘리는지 추적해야 한다.
  • leak detection 로그는 “반드시 leak”이라는 뜻이 아니라 threshold보다 오래 빌린 connection이라는 신호로 보고, 긴 트랜잭션과 slow query도 함께 확인한다.

위험 신호!

  • @Transactional 메서드 안에서 외부 HTTP 호출을 한다.
  • pool size를 서버마다 크게 설정하고 오토스케일링 총량을 계산하지 않는다.
  • 커넥션 대기 timeout이 나는데 쿼리 로그만 본다.
  • 요청 스레드가 DB 커넥션을 기다리며 쌓인다.
  • 커넥션을 반납하지 않는 JDBC 코드가 있다.
  • 배포 중 구버전/신버전 인스턴스가 동시에 떠 총 pool 수가 일시적으로 두 배가 되는 상황을 계산하지 않는다.
  • leak detection 로그를 무시하거나, 반대로 모든 로그를 실제 누수로 단정한다.

확인 질문

  • DB 커넥션을 요청마다 새로 만들지 않는 이유는 무엇인가?
    • 연결 생성과 인증, 세션 초기화 비용이 크고 DB 서버 자원을 많이 사용하기 때문에 풀로 재사용한다.
  • 애플리케이션 서버를 늘릴 때 DB 연결 수를 다시 계산해야 하는 이유는 무엇인가?
    • 인스턴스별 pool size가 합산되어 DB 전체 연결 수와 동시 쿼리 수를 증가시키기 때문이다.
  • 트랜잭션 안의 외부 API 호출이 위험한 이유는 무엇인가?
    • 외부 응답을 기다리는 동안 DB 커넥션과 락을 오래 점유해 풀 고갈과 락 경합을 만들 수 있기 때문이다.
  • DB 연결 예산을 계산할 때 애플리케이션 서버 외에 포함해야 할 것은 무엇인가?
    • batch, admin, migration, 모니터링, 운영자 접속, 배포 중 중복 인스턴스, failover 후 재연결 여유를 포함해야 한다.

참고 문서