ActiveRecord 커넥션 풀의 작동 원리와 숨겨진 대기열
ActiveRecord는 요청마다 새로운 데이터베이스 연결을 생성하는 대신 커넥션 풀(Connection Pool)을 유지하여 효율성을 높입니다. 각 Puma 스레드는 쿼리를 실행하기 전 풀에서 커넥션을 체크아웃(checkout)하고, 요청이 완료되면 다시 반납하는 구조를 가집니다.
1. 보이지 않는 병목: Checkout 지연 시간
스레드 수가 가용한 풀 크기를 초과하면 스레드는 pool_timeout 설정 시간 동안 대기 상태에 빠집니다. 이 대기 시간은 New Relic과 같은 APM 도구에서 ‘데이터베이스 시간’이 아닌 ‘애플리케이션 시간’으로 기록되므로, 개발자는 데이터베이스 성능에 문제가 없다고 오판하기 쉽습니다. 실제로 15~30ms 수준의 미세한 스파이크는 데이터베이스 엔진의 지연이 아니라 커넥션을 기다리는 스레드의 정체에서 비롯되는 경우가 많습니다.
2. 지연 시간이 발생하는 기술적 배경
- 뮤텍스 락과 동기화: 풀에서 커넥션을 읽거나 상태를 확인하는 모든 작업은 동기화가 필요합니다. 수십 개의 스레드가 동시에 경합할 때 이 동기화 비용은 무시할 수 없는 수준이 됩니다.
- GIL(Global Interpreter Lock)의 영향: Ruby의 GIL은 멀티스레드 환경에서 동기화 작업을 수행할 때 병목을 증폭시키는 경향이 있습니다.
- 부하 변동성: 갑작스러운 트래픽 증가 시 스레드 스케줄링 병목이 발생하며, 이는 전체적인 시스템 처리량(Throughput) 저하로 이어집니다.
3. New Relic을 활용한 가시성 확보
New Relic의 ActiveRecord 인스트루멘테이션을 활성화하면 ActiveRecord::ConnectionAdapters::ConnectionPool#checkout 세그먼트를 확인할 수 있습니다. 개별적으로는 10ms 수준의 짧은 지연이라도 초당 수백 건의 요청이 몰리면 시스템 전체의 처리 용량을 심각하게 갉아먹는 ‘용량 누수’ 현상이 발생합니다. 이를 위해 Database/Connection/CheckoutTime 메트릭을 별도의 대시보드로 구성하여 상시 모니터링하는 것이 중요합니다.
4. 올바른 튜닝 전략과 해결책
- 풀 크기 최적화: 무조건 풀 크기를 늘리는 것은 메모리 낭비와 데이터베이스 서버의 세션 과부하를 초래합니다. 일반적으로
pool size ≈ max threads per worker공식을 따르는 것이 권장됩니다. - 타임아웃 설정:
timeout값을 기본 5000ms에서 2000ms 정도로 낮추어 경합 상황을 조기에 발견하고 스레드 정체를 방지해야 합니다. 실패를 빨리 드러내는 것이 시스템 안정성에 유리합니다. - 리소스 격리: 백그라운드 작업(ActiveJob)이 웹 요청과 동일한 풀을 공유하여 발생하는 간섭을 막기 위해
establish_connection을 사용하여 풀을 물리적으로 분리하는 것이 효과적입니다. - 로컬 시뮬레이션: 낮은 풀 크기와 높은 스레드 수를 설정한 로컬 부하 테스트를 통해 실제 운영 환경에서 발생할 수 있는 경합 상황을 재현하고 성능 한계를 사전에 파악할 수 있습니다.