적정 커넥션 풀(Connection Pool) 크기를 정하는 요소들
들어가며
APM을 보다가 응답시간이 평균 7초나 되는 쿠폰 발급 API의 로직을 개선한 적이 있다. 당시 공유 락과 동기화 블록(synchronized) 때문에 심각한 병목을 겪고 있었는데, 이를 비관적 락(Pessimistic Lock)으로 전환하면서 최종적으로 응답 속도를 2~3초에서 2ms까지 단축시켰다.
개선을 했지만 아직 풀지 못한 숙제도 있었다. 성능 개선 당시에 애플리케이션 설정 파일에는 커넥션 풀 크기가 500 이상으로 잡혀 있었는데, 실제 API 서버 로그에는 기본값인 10개로만 잡히고 있었기 때문이다.
개발팀에게는 운영 DB 커넥션을 조정할 권한이 없었고, 그런 요청을 받아본 적도 없는 인프라 팀과 설전을 벌이기엔 일정과 근거가 부족해 팀 차원에서는 일단 돌아가는 상태로 두자고 합의를 했었다. 락까지 걸었는데 커넥션 풀 사이즈를 갑자기 줄여서 배포하는 것도 리스크가 있다고 판단했기 때문이다.
어딘가 맞지 않는 톱니바퀴를 그대로 두고 넘어간 찝찝함이 가시질 않아, 적정 커넥션 풀 사이즈를 어떻게 찾아야 했을지 그 고민을 풀어보고자 한다.
적정 커넥션 풀 공식의 함정
HikariCP 공식 위키 등에서 권장하는 기본적인 공식은 다음과 같다.
이 공식대로라면 DB 서버 컴퓨터가 4코어(SSD 환경이라 spindle은 1로 가정)일 때 적정 커넥션 풀 크기는 9개다. HikariCP 개발자의 프로파일링에 따르면 이 정도의 적은 커넥션으로도 6000 TPS를 정도는 소화할 수 있다고 한다.
이 숫자만 가지고 설정을 하려고 들때 마주하는 문제는 커넥션 풀을 가지는 애플리케이션이 1대가 아니라는 점이다. 아무리 소소한 서비스여도 단일 DB에 사용자 애플리케이션(API)과 관리자용 어드민(Admin)은 따로 만드는 것이 보통이며, API 서버 역시 안정성을 위해 최소 2대 이상으로 이중화하는 것이 정석이다.
그럼 단순히 1/N을 하면 될까. API와 ADMIN 각각 이중화 구성이라고 가정하면 서버는 총 4대 이므로 1/4를 하는 것이다. 운영 DB 스펙이 16 코어는 되었던 것 같은데 그럼 대강 서버당 8~9 정도로 설정할 수 있다.
이런 1/N의 설정은 왠지 아닐 것 같다는 생각이 바로 든다. 조금만 생각해봐도 사용자 애플리케이션과 어드민이 처리하는 쿼리의 성격이나 소요 시간 등이 다를 것이고, 사용자 자체가 달라 요구되는 TPS의 수치도 다를 것이다. 늘 그렇듯 상황에 맞게 적절한 설정이 필요한 것이다.
적정 풀 사이즈를 결정하는 HikariCP 주요 설정
지금 풀려고 하는 문제는 결국 HikariCP 설정값을 적절히 넣는 것이다. 적정 풀 사이즈를 계산할 때 알아야 할 설정들은 다음과 같다.
spring:
datasource:
hikari:
# 1. 풀의 상한
maximum-pool-size: 10
# 2. 최소 유휴 커넥션
minimum-idle: 10
# 3. 연결 시 Fail-Fast 임계치 (2~3초)
connection-timeout: 3000
maximum-pool-size가 바로 우리가 구하고자 하는 적정 풀 사이즈 이며, minimum-idle은 유휴 커넥션의 마지노선 개수이다.
connection-timeout은 자주 접하는 값일 텐데, 애플리케이션 입장에서 이 설정 시간을 넘어가면 바로 SQLTransientConnectionException을 던지며 실패하기 때문에, 커넥션을 붙잡고 서버가 뻗어버리길 바라지 않는 이상 긴 시간으로 설정할 이유는 없다.
이 설정 항목들을 보면 앞서 이야기 1/N 보다는 더 나은 해결책을 만들 힌트를 얻을 수 있다.
적정 풀 사이즈와 연결되는 설정들
유휴 커넥션(Idle Connection)
유휴 커넥션의 설정 값이 존재했다는 것은, 노는 커넥션에 대한 설정이 있다는 것이다. HikariCP에서는 최소한 유지할 커넥션의 수를 넘어간 커넥션이 일정 시간 이상 유휴할 때 연결을 종료한다. 불필요한 DB 자원의 독점을 방지하고 자원을 반납하기 위함이다.
이 부분에 대해 HikariCP 문서에는 운영 API 서버 기준으로 고정 풀 유지를 위해 minimum-idle 수치를 maximum-pool-size와 동일하게 맞추기를 권장한다. 그렇게 되면 유지해야 하는 커넥션과 상한 커넥션 수가 같기 때문에 풀 사이즈가 고정되는 효과가 있다.
풀 사이즈를 고정하는 이유는, 커넥션을 맺고 끊는 비용을 줄이기 위해서다. 커넥션 풀의 커넥션 또한 TCP 3-Way Handshake와 DB 인증/인가, DB 세션 및 메모리 할당을 받아 만들어지는 것이므로 적지 않은 비용을 소모한다.
connection-timeout의 보수적인 조정
커넥션 풀이 부족해지면 단순히 DB 조회만 안 되는 것이 아니라 **서버 전체가 중단(Hang)**되는 도미노 현상이 발생할 수 있다.
커넥션 풀 10개가 꽉 찬 상태에서 요청이 100개 들어오면, Tomcat thread가 커넥션을 얻기 위해 HikariCP의 내부 대기열(HandOffQueue)에 들어간다.
HikariCP의 connection-timeout 기본값은 30초다. Tomcat thread들이 30초 동안 커넥션을 기다리며 대기 상태로 잠기게 되면, Tomcat thread pool(기본 200)이 순식간에 고갈된다. 고갈이라 함은, HikariCP 대기열에서 커넥션을 구하지 못한 thread는 지정한 connection-timeout 동안 WAITING 상태로 블로킹되는 것을 말한다.
이때 들어오는 모든 API 요청마다 요청 처리 스레드가 1개씩 대기 상태에 빠지므로, Tomcat의 Worker Thread Pool(기본 200개) 전체가 대기 상태로 채워져 더 이상 신규 TCP Connection 및 HTTP 요청 자체를 받아줄 스레드가 없어지는 현상이 발생한다.
결국 DB를 전혀 쓰지 않는 가벼운 API(예: 서버 헬스체크용 /health-check)마저 응답을 못 하고 클라이언트 요청 전체가 타임아웃(504 Gateway Timeout)으로 터지게 될 수 있는 것이다.
따라서 connection-timeout은 대기 오버헤드를 막기 위해 **2~3초 수준으로 짧게 설정(Fail-Fast)**해야 하며, Nginx 등 앞단 프록시의 타임아웃 설정보다 안쪽(HikariCP, DB Query Timeout)으로 들어갈수록 타임아웃 임계치를 좁혀 설정해야 시스템 전체의 연쇄 장애를 막을 수 있다.
서버의 역할과 트래픽 패턴에 따른 설정
이제 설정 값을 알아보았으니, 개별 서버의 커넥션 풀 사이즈를 설정해보자. 당연히 서버의 역할과 사용 패턴에 따라 풀 사이즈는 다르게 설정되어야 할 것이다. 만약 목표 하는 TPS 수치가 있다면 더 좋다. 여태 살펴본 요소를 가지고 추정치를 낸 뒤에 수치에 맞게 프로파일링을 해보면 된다.
이하에서는 러프한 예시를 가지고 풀 사이즈를 정하는 흐름을 살펴보려 한다.
예시 : API 서버 (High TPS, Short Query)
API 서버는 사용자 요청이 몰리고, 단건 조회나 짧은 CUD 위주이다. 이를테면 개선된 쿠폰 API처럼 쿼리 타임 2ms 내외로 보통 짧은 응답 시간을 갖는다.
응답 시간이 짧다는 것은 당연히 DB 쿼리 실행 시간 또한 매우 짧은 편이라고 추정할 수 있다. 쿼리의 실행시간이 적다면 작은 커넥션 풀로도 높은 TPS를 소화할 수 있을 것이다. 따라서 굳이 커넥션을 크게 잡기보다, connection-timeout을 극도로 짧게 가져가서 DB 장애 시 API 서버 전체로 장애가 전이되는 것을 막는 데 집중하는 것이 차라리 나을 수 있다.
예시 : Admin / Batch / Internal 서버 (Low TPS, Long Query)
Batch나 Admin의 경우 요청 수는 적지만, 대량 데이터 조회, 통계, 집계 쿼리 등 실행 시간이 긴(Long-running) 쿼리가 많다.
이렇게 되면 커넥션 하나가 쿼리를 쥐고 있는 시간이 길다 보니, 동시 사용자 수가 적어도 커넥션 고갈이 쉽게 발생할 수 있다. 따라서 API 서버보다 커넥션 수 자체는 상대적으로 넉넉하게 주되, DB 자원을 독점하지 않도록 적절한 상한을 두어야 한다.
리틀의 법칙으로 커넥션 수 추정하기
아예 다른 접근도 있다. 리틀의 법칙(Little’s Law)을 응용하는 방식이다. 리틀의 법칙은 원래 시스템 내에 존재하는 평균 객체 수는 평균 유입률 * 평균 체류 시간 이다 라는 공식이다.
이를 응용해보면 계산하고자 하는 객체는 커넥션의 수이고, 유입률은 TPS와 대응한다고 볼 수 있으며, 체류 시간은 평균 DB 점유 시간이 라고 볼 수 있다.
이를 대입하면 API 서버 예시의 경우 만약 목표 TPS가 2,000 이고 평균 DB 점유 시간 2ms (0.002초) 라면 2000 * 0.002 = 4 이므로 4개가 적정 커넥션 풀 사이즈가 된다.
물론 이 수치는 평균치이므로 버퍼를 두어야 할 수 있다. Stop-the-world GC나 순간적인 트래픽 쏠림 현상이 있을 수도 있고 네트워크 지연도 있을 수 있기 때문이다.
개별 쿼리 실행 시간보다 중요한 ‘커넥션 점유 시간(Connection Holding Time)’
조금은 다른 맥락의 접근도 있을 수 있다. 앞서도 나왔던 평균 DB 점유 시간을 조정하는 방법이다. 계산 식에 따르면(당연히 직관적으로도) 평균 DB 점유 시간을 줄여야 적은 커넥션으로도 커버가 가능하다.
스프링으로 만든 서버 애플리케이션에서 트랜잭션 범위 안에서 외부 API(OAuth 인증, 알림톡 발송 등)를 호출하는 등의 로직이 포함되면 그 연결에 대한 처리 만큼 커넥션의 반환은 느려진다. DB 점유 시간이 길어지는 것이다.
이는 스프링의 @Transactional은 메서드 시작 시점에 커넥션을 획득하여 메서드가 완전히 끝날 때까지 반납하지 않기 때문에 발생한다. 즉 외부 API 서버가 2초간 응답을 미루면, DB 쿼리는 단 1ms 만에 끝났더라도 커넥션은 2초 동안 묶여 있게 되는 것이다.
결국 코드의 구조 상으로도 트랜잭션을 빠르게 처리할 수 있도록 로직의 배치를 조절해 주는 것도 적정 커넥션 풀 사이즈에 영향을 미치게 된다.
마치며
커넥션 풀 사이즈에 대해 고려할 것들을 찾으면서, 사실 뭔가 더 알게된 느낌 보다는 모르는 것을 더 많이 찾은 느낌이다. 아직 이 글을 통해 적정 커넥션 풀 사이즈를 못 정하겠다해도 전혀 이상하지 않다.
다른 글을 통해 지금 나열한 요소들을 직접 실험하고, 특정한 제약 안에서 프로파일링을 통해 사이즈를 만들어가는 실험을 해볼까 한다.