"Commons DBCP"의 버전별로 JDK 버전과 JDBC 버전을 확인
DBCP 2.7.0 compiles and runs under Java 8 only (JDBC 4.2)
DBCP 2.6.0 compiles and runs under Java 8 only (JDBC 4.2)
DBCP 2.5.0 compiles and runs under Java 8 only (JDBC 4.2)
DBCP 2.4.0 compiles and runs under Java 7 only (JDBC 4.1)
DBCP 1.4 compiles and runs under Java 6 only (JDBC 4)
DBCP 1.3 compiles and runs under Java 1.4-5.0 only (JDBC 3)
아래 속성에서 1~4번 속성은 통일해도 무방하다. 여기서 커넥션은 java.sql.Connection 이다.
Evictor 스레드는 Commons DBCP 내부에서 커넥션 자원을 정리하는 구성 요소이며 별도의 스레드로 실행된다.
커넥션 개수를 제대로 설정하려면 Commons DBCP 내부에서 커넥션 풀이 어떤 구조로 저장되는지 이해해야 한다. Commons DBCP는 그림 1처럼 commons-pool에서 제공하는 리소스 풀의 기능을 이용한다.
그림 1 commons-pool의 GeneriObjectPool
커넥션 생성은 Commons DBCP에서 이루어진다. Commons DBCP는 PoolableConnection 타입의 커넥션을 생성하고 생성한 커넥션에 ConnectionEventListener를 등록한다. ConnectionEventListener에는 애플리케이션이 사용한 커넥션을 풀로 반환하기 위해 JDBC 드라이버가 호출할 수 있는 콜백 메서드가 있다. 이렇게 생성된 커넥션은 commons-pool의 addObject() 메서드로 커넥션 풀에 추가된다. 이때 commons-pool은 내부적으로 현재 시간을 담고 있는 타임스탬프와 추가된 커넥션의 레퍼런스를 한 쌍으로 하는 ObjectTimestampPair라는 자료구조를 생성한다. 그리고 이들을 LIFO(last in first out) 형태의 CursorableLinkedList로 관리한다.
커넥션의 개수는 BasicDataSource 클래스의 다음 속성으로 지정할 수 있다.
표 3 BasicDataSource 클래스의 커넥션 개수 지정 속성
만약 8개의 커넥션을 최대로 활용할 수 있을 때 4개는 사용 중이고 4개는 대기 중인 상태라면 커넥션 풀의 상태는 그림 2와 같을 것이다.
그림 2 일부 커넥션이 활성화된 상태
커넥션 개수와 관련된 속성은 다음과 같은 조건을 만족시켜야 한다.
maxActive = 10
이고 initialSize = 20
이라고 가정하면 최초에 커넥션을 생성할 때 initialSize 값이 최대 커넥션 개수인 maxActive 값보다 커서 논리적으로 오류가 있는 설정이다.maxIdle < minIdle
로 설정할 수는 있지만 최솟값이 최댓값보다 커서 논리적으로 오류가 있는 설정이다.maxActive = 10
이고 maxIdle = 5
라고 가정해 보자. 항상 커넥션을 동시에 5개는 사용하고 있는 상황에서 1개의 커넥션이 추가로 요청된다면 maxActive = 10
이므로 1개의 추가 커넥션을 데이터베이스에 연결한 후 풀은 비즈니스 로직으로 커넥션을 전달한다. 이후 비즈니스 로직이 커넥션을 사용 후 풀에 반납할 경우, maxIdle=5
에 영향을 받아 커넥션을 실제로 닫아버리므로, 일부 커넥션을 매번 생성했다 닫는 비용이 발생할 수 있다.initialSize와 maxActive, maxIdle, minIdle 항목을 동일한 값으로 통일해도 무방하다. 커넥션 개수와 관련된 가장 중요한 성능 요소는 일반적으로 커넥션의 최대 개수다. 4개 항목의 설정 값 차이는 성능을 좌우하는 중요 변수는 아니다.
maxActive 값은 DBMS의 설정과 애플리케이션 서버의 개수, Apache, Tomcat에서 동시에 처리할 수 있는 사용자 수 등을 고려해서 설정해야 한다. DBMS가 수용할 수 있는 커넥션 개수를 확인한 후에 애플리케이션 서버 인스턴스 1개가 사용하기에 적절한 개수를 설정한다. 사용자가 몰려서 커넥션을 많이 사용할 때는 maxActive 값이 충분히 크지 않다면 병목 지점이 될 수 있다. 반대로 사용자가 적어서 사용 중인 커넥션이 많지 않은 시스템에서는 maxActive 값을 지나치게 작게 설정하지 않는 한 성능에 큰 영향이 없다.
Commons DBCP에서는 DBMS에 로그인을 시도하고 있는 커넥션도 사용 중인 것으로 간주한다. 만약 DBMS에 로그인을 시도하고 있는 상태에서 무한으로 대기하고 있다면, 애플리케이션에서 모든 커넥션이 사용 중인 상태가 돼 새로운 요청을 처리하지 못할 수도 있다. 이런 경우 장애 확산을 최소화하려면 Microsoft SQL Server의 JDBC 드라이버에서 설정하는 loginTimeOut 속성같은 JDBC 드라이버별 타임아웃 속성을 설정하는 것이 좋다. JDBC의 타임아웃에 관한 자세한 내용은 "JDBC Internal - 타임아웃의 이해" 글을 참고한다.
BasicDataSource 클래스의 maxWait 속성은 커넥션 풀 안의 커넥션이 고갈됐을 때 커넥션 반납을 대기하는 시간(밀리초)이며 기본값은 무한정이다. maxWait 속성을 적절하게 설정하지 않아도 일반적인 상황에서는 큰 문제가 되지 않는다. 하지만 사용자가 갑자기 급증하거나 DBMS에 장애가 발생했을 때 장애를 더욱 크게 확산시킬 수 있어 주의해야 한다.
적절한 maxWait 값을 설정하려면 TPS(transaction per seconds)와 Tomcat에서 처리 가능한 스레드 개수 등을 이해해야 한다. 예를 들어 자세히 살펴보겠다.
maxActive = 5
과 maxIdle = 5
, minIdle = 5
로 설정한 상황을 가정한다. 사용자의 요청 A는 그림 3과 같이 요청 하나에 쿼리 10개를 실행한다고 가정하자. 각 쿼리의 평균 실행 시간은 50밀리초라고 하면 전체 10개 쿼리의 실행 시간은 500밀리초다. 결국 요청에 대한 최종 응답 시간은 500밀리초라고 생각할 수 있다. 물론 요청에 응답하기 위해 다른 컴포넌트도 시간을 소비하지만 무시할 수 있는 정도의 값이라고 생각해 제외했다.
그림 3 사용자 요청의 처리 과정
앞의 가정에서 시스템 전체의 TPS를 대략적으로 산출하면 그림 4와 같다. 요청 하나의 응답 시간이 500밀리초이므로 커넥션 풀에 이용 가능한 유휴 상태의 커넥션이 5개일 때는 동시에 5개의 요청을 500밀리초 동안 처리한다. 따라서 1초 동안에는 10개의 요청을 처리할 수 있고 성능 지수는 10TPS라고 볼 수 있다.
그림 4 커넥션풀의 커넥션 개수가 5인 경우
커넥션의 개수가 TPS와 밀접한 관계가 있는 것은 다음 그림처럼 처리할 요청 수가 증가해도 커넥션 풀의 커넥션 개수가 5개이면 10TPS 이상의 성능을 낼 수 없기 때문이다. 1번부터 5번까지의 요청이 실행되는 동안은 커넥션 풀에 여분의 커넥션이 없기 때문에 6번부터 10번까지의 요청은 대기(wait) 상태가 돼 여분의 커넥션이 생길 때까지 maxWait 값만큼 기다린다.
그림 5 대기로 인한 성능 저하 발생
이렇게 커넥션이 부족한 상태의 스택 덤프를 보면 다음과 같이 BasicDataSource.getConnection() 메서드에서 스레드가 대기하고 있는 것을 확인할 수 있다.
"TP-Processor104" daemon prio=10 tid=0x00007f76e8093800 nid=0x2d80in Object.wait() [0x00007f76f905a000]
java.lang.Thread.State: TIMED_WAITING (onobject monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x00000000f886fee0> (a org.apache.commons.pool.impl.GenericObjectPool$Latch)
at org.apache.commons.pool.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:1112)
- locked <0x00000000f886fee0> (a org.apache.commons.pool.impl.GenericObjectPool$Latch)
at org.apache.commons.dbcp.PoolingDataSource.getConnection(PoolingDataSource.java:106)
at org.apache.commons.dbcp.BasicDataSource.getConnection(BasicDataSource.java:1044)
at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:111)
at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:77)
at org.mybatis.spring.transaction.SpringManagedTransaction.openConnection(SpringManagedTransaction.java:80)
이를 해결하는 가장 쉬운 방법은 그림 6처럼 단순히 maxActive 값을 높여서 커넥션 풀의 개수를 늘리는 것이다.
그림 6 커넥션 개수 증가로 인한 TPS증가
커넥션의 개수를 5에서 10으로 늘리면 전체적인 성능도 10TPS에서 20TPS로 증가한다. 하지만 일반적으로 DBMS의 리소스는 다른 서비스와 공유해 사용하는 경우가 많기 때문에 무조건 커넥션 개수를 크게 설정할 수 없는 상황이 많다. 따라서 예상 접속자 수와 서비스의 실제 부하를 측정해 최적값을 설정하는 것이 중요하다. 그림 5의 대기 시간(wait) 값 조절이 무한히 커넥션 개수를 늘리지 않고 최적의 시스템 환경을 구축하는 데 중요한 역할을 한다. maxWait 값을 어떻게 설정했는지가 일시적인 과부하 상태에서 드러나는 시스템의 전체적인 견고함을 결정짓는다.
그렇다면 적당한 maxWait 값은 얼마일까? 이 부분을 이해하려면 Commons DBCP 외에 Tomcat의 동작 방식도 고려해야 한다. Tomcat은 스레드 기반으로 동작해 사용자의 요청을 처리한다. Commons DBCP가 커넥션 풀을 가지고 있는 것처럼 Tomcat도 내부에 스레드 풀(wait set)을 가지고 있어 그림 7처럼 사용자의 요청이 들어올 때마다 스레드 풀에서 하나씩 스레드를 꺼내 요청을 처리한다.
그림 7 Tomcat 스레드의 대기
중점적으로 살펴볼 부분은 1~5번의 요청이 처리되기 전에 또 다른 요청이 들어올 때 시작된다. 즉 동시에 6개의 요청이 들어왔을 때 그림 7처럼 6번 요청은 여분의 커넥션이 없으므로 maxWait 값만큼 기다린다. 여기서 중요한 사실은 기다리는 주체가 Tomcat의 스레드라는 점이다. Tomcat에서 사용자의 연결을 처리하는 최대의 스레드 개수는 server.xml 파일에서 maxThread 속성으로 지정한다.
그림 7에서 maxWait 속성에 설정한 시간이 10,000밀리초면 처리량을 넘어서는 요청의 스레드는 10초 동안 대기 상태에 있게 된다. 그리고 사용자의 요청이 계속 증가하면 결국 Tomcat 스레드 풀의 모든 스레드가 소진돼 Tomcat은 다음과 같은 오류를 출력하며 응답을 멈출 것이다.
심각:Allthreads (512)arecurrentlybusy,waiting.IncreasemaxThreads (512)orchecktheservletstatus
더욱 억울한 것은 결국 10초 동안의 대기 상태가 해제되고 커넥션을 획득해 사용자의 요청을 열심히 처리하고 응답을 보내도 그 응답을 받을 사용자는 이미 떠나고 난 뒤라는 점이다. 클릭 후 2~3초 내에 반응이 없으면 페이지를 새로 고치거나 다른 페이지로 이동하는 것이 보통인 인터넷 사용자의 행동을 생각하면 쉽게 이해되리라. 결국은 기다리는 사람도 없는 요청에 응답하기 위해 자원을 낭비한 셈이 된다. 사용자가 인내할 수 있는 시간을 넘어서는 maxWait 값은 아무런 의미가 없다.
그럼 반대로 너무 작게 설정하면 어떤 문제가 발생할까? 상상대로다. 과부하 시 커넥션 풀에 여분의 커넥션이 없을 때마다 오류가 반환될 것이고 사용자는 너무 자주 오류 메시지를 볼 것이다.
이렇듯 maxWait 값도 사용자의 대기 가능한 시간 같은, 애플리케이션의 특성과 다른 주변의 설정, 자원의 상황 등을 고려해 판단해야 한다. 만약 갑작스럽게 사용자가 증가해 maxWait 값 안에 커넥션을 얻지 못하는 빈도가 늘어난다면 maxWait 값을 더 줄여서 시스템에서 사용하는 스레드가 한도에 도달하지 않도록 방어할 수 있다. 전체 시스템 장해는 피하고 '간헐적 오류'가 발생하는 정도로 장애의 영향을 줄이는 것이다. 이런 상황이 자주 있다면 Commons DBCP의 maxActive 값과 Tomcat의 maxThread 값을 동시에 늘이는 것을 고려한다. 그러나 시스템 자원의 한도를 많이 넘는 요청이 있다면 설정을 어떻게 변해도 장애를 피할 수 없다. 애플리케이션 서버의 자원이 설정 변경을 수용할 만큼 충분하지 않다면 시스템을 확충해야 할 것이다.
유효성 검사 쿼리(validation query)와 Evictor 스레드 관련 설정으로도 애플리케이션의 안정성을 높일 수 있다.
JDBC 커넥션의 유효성은 validationQuery 옵션에 설정된 쿼리를 실행해 확인할 수 있다. Commons DBCP 1.x에서는 다음과 같은 세 가지 테스트 옵션으로 유효성을 검사한다. 유효성을 검사할 때는 validationQuery 옵션에 하나 이상의 결과를 반환하는 쿼리를 설정해야 한다. Commons DBCP 2.x에서는 validationQuery 옵션이 없을 때 Connection.isValid() 메서드를 호출해 유효성을 검사한다.
validationQuery 옵션에는 DBMS에 따라 다음과 같이 쿼리를 설정하기를 권장한다. 실제 테이블에 있는 데이터를 조회하는 쿼리를 validationQuery 옵션에 설정했다가 운영 서버에서 많은 데이터를 조회해 장애로 이어진 사례도 있다.
검증에 지나치게 자원을 소모하지 않게 testOnBorrow 옵션과 testOnReturn 옵션은 false로 설정하고, 오랫동안 대기 상태였던 커넥션이 끊어지는 현상을 막게 testWhileIdle 옵션은 true로 설정하는 것을 추천한다. 참고로 CUBRID는 자체적으로 커넥션을 관리하고 자동으로 다시 연결하도록 구현됐다. DBCP 수준에서 한 번 더 유효성 검사 쿼리를 보내는 것은 추가 비용을 소모할 뿐이므로 CUBRID를 사용할 때는 testWhileIdle 옵션도 false로 설정하기를 권장한다.
Oracle JDBC 드라이버 9.x에서는 강제로 세션을 종료했을 때 발생하는 ORA-00028 오류가 난 후 부적절한 상태의 커넥션이 커넥션 풀로 반납돼 데이터베이스에 로그인되지 않은 때 발생하는 오류인 ORA-01012 오류가 계속 발생한 사례가 있다. 근본적인 원인은 Oracle JDBC 드라이버가 해당 오류 상황에서 JDBC 명세에 정의된 ConnectionEventListener.connectionErrorOccurred() 메서드를 제대로 호출하지 않았기 때문이었다. Oracle JDBC 드라이버를 10.x로 업그레이드해서 테스트했을 때는 같은 오류가 재현되지 않았다. 오류가 발생하는 버전을 사용하는 애플리케이션에서 Commons DBCP의 testWhileIdle 옵션을 true로 설정한 서버에서도 오류가 발생하지 않았다. Commons DBCP에서 vadliationQuery 옵션을 실행하면서 오류가 발생하면 해당 커넥션을 커넥션 풀에서 제외했기 때문이다. 이렇듯 testWhileIdle 옵션과 유효성 검사 쿼리 설정으로 예상치 못한 오류 상황도 대비할 수 있다.
Evictor 스레드는 Commons DBCP 내부에서 커넥션 자원을 정리하는 구성 요소이며 별도의 스레드로 실행된다. 이와 관련된 속성은 다음과 같다.
Evictor 스레드의 역할은 3가지인데 각각의 역할을 수행할 때 위의 속성이 어떻게 참조되는지 살펴보자.
첫째, 커넥션 풀 내의 유휴 상태의 커넥션 중에서 오랫동안 사용되지 않은 커넥션을 추출해 제거한다. Evictor 스레드 실행 시 설정된 numTestsPerEvictionRun 속성값만큼 CursorableLinkedList의 ObjectTimestampPair를 확인한다. ObjectTimestampPair의 타임스탬프 값과 현재 시간의 타임스탬프 값의 차이가 minEvictableIdleTimeMillis 속성값을 초과하면 해당 커넥션을 제거한다. 커넥션 숫자를 적극적으로 줄여야 하는 상황이 아니라면 minEvictableIdleTimeMillis 속성값을 -1로 설정해서 해당 기능을 사용하지 않기를 권장한다.
둘째, 커넥션에 대해서 추가로 유효성 검사를 수행해 문제가 있을 경우 해당 커넥션을 제거한다. testWhileIdle 옵션이 true로 설정됐을 때만 이 동작을 수행한다. 첫 번째 작업 시 minEvictableIdleTimeMillis 속성값을 초과하지 않은 커넥션에 대해서 추가로 유효성 검사를 수행하는 것이다.
셋째, 앞의 두 작업 이후 남아 있는 커넥션의 개수가 minIdle 속성값보다 작으면 minIdle 속성값만큼 커넥션을 생성해 유지한다.
예를 들어, testWhileIdle=true && timeBetweenEvictionRunMillis > 0
이면 위의 3가지 역할을 다 수행하고, testWhileIdle=false && timeBetweenEvictionRunMillis > 0
이면 두 번째 동작은 수행하지 않는다.
Evictor 스레드는 동작 시에 커넥션 풀에 잠금(lock)을 걸고 동작하기 때문에 너무 자주 실행하면 서비스 실행에 부담을 줄 수 있다. 또한 numTestsPerEvictionRun 값을 크게 설정하면 Evictor 스레드가 검사해야 하는 커넥션 개수가 많아져 잠금 상태에 있는 시간이 길어지므로 역시 서비스 실행에 부담을 줄 수 있다. 게다가 커넥션 유효성 검사를 위한 테스트 옵션(testOnBorrow, testOnReturn, testWhileIdle)을 어떻게 설정하느냐에 따라 애플리케이션의 안정성과 DBMS의 부하가 달라질 수 있다. 그러므로 Evictor 스레드와 테스트 옵션을 사용할 때는 데이터베이스 관리자와 상의해서 사용하는 DBMS에 최적화될 수 있는 옵션으로 설정해야 한다.
IDC(internet data center) 정책에 따라서는 서버 간의 소켓 연결 후 정해진 시간 이상 아무런 패킷도 주고받지 않으면 연결을 종료한다. 이런 경우 timeBetweenEvictionRunsMillis 속성 등으로 의도하지 않게 연결이 끊어지는 것을 방어할 수 있다. 예를 들어 30분 동안 통신이 없을 때 연결이 끊어지는 정책으로 네트워크를 운영한다면, BasicDataSource가 풀링(pooling)하는 커넥션의 수가 30개라고 가정할 때 30분 안에 모든 커넥션에 유효성 검사 쿼리를 한 번씩은 실행하는 것이 바람직하다. Evictor 스레드가 5분에 한 번씩 실행되도록 설정했을 때 30분 동안 Evictor 스레드 실행 횟수는 6번이므로 매번 5개의 커넥션을 검사해야 전체 커넥션을 테스트할 수 있다. 30분 안에 5분마다 Evctor 스레드가 실행되면 6번 실행되지만 오차를 감안해 5번으로 가정하면 이때 설정해야 할 numTestsPerEvictionRun 값은 다음과 같이 구할 수 있다.
6 * numTestsPerEvictionRun > 30개
따라서 numTestsPerEvictionRun 속성값은 최소 6 이상이어야 한다. 일반적인 공식으로 정리하면 다음과 같다.
('IDC 정책에서 허용하는 최대 유휴 커넥션 유지 시간' / timeBetweenEvictionRunsMillis 속성값) * numTestsPerEvictionRun 속성값) > 전체 커넥션 개수
statement pooling은 JDBC 3.0에 정의된 명세다. JDBC 드라이버가 3.0 명세를 지원하지 않으면 사용할 수 없는 기능이다. 하지만 JDBC 2.0 명세만 지원하는 JDBC 드라이버를 사용할 때도 커넥션 풀로 Commons DBCP를 사용하고 있다면 poolPreparedStatements 옵션을 true로 설정해서 Commons DBCP를 커넥션 풀뿐만 아니라 statement pool로도 사용할 수 있다. 이때는 반드시 maxOpenPreparedStatements 옵션을 같이 사용해 커넥션당 풀링할 PreparedStatement의 적절한 개수를 설정해야 한다. 그렇지 않으면 런타임에서 메모리 부족(out of memory) 오류 등이 발생할 수 있다.
maxOpenPreparedStatements 값은 문제가 발생지 않도록 50 정도로 작게 설정한 후 데이터베이스 관리자의 도움을 얻어 PreparedStatement의 캐시 적중률(hit ratio)을 관찰한 후 조정하기를 권장한다. 여기서 설정한 PreparedStatement 개수는 개별 커넥션마다 할당된다. 즉 커넥션 풀에 10개의 커넥션이 있을 때 maxOpenPreparedStatements = 50
이라면 총 10 x 50 = 500개
의 PreparedStatement가 캐시에 저장된다. 절대 BasicDataSource 클래스에 설정되는 개수가 아니다.
다음 값은 특별한 이유가 없다면 기본값을 쓰는 것을 권장한다.
removeAbandoned 옵션은 false가 기본값이다. removeAbandoned 옵션은 오랫동안 열려만 있고 Connection.close() 메서드가 호출되지 않는 커넥션을 임의로 닫는 기능을 설정하는 옵션이다. removeAbandoned 옵션을 true로 설정하고 removeAbandonedTimeout 옵션에 허용할 최대 시간을 지정하면 Commons DBCP에서 자동으로 Connection.close() 메서드를 호출한다.
애플리케이션 개발자가 직접 JDBC API를 다루던 때는 Connection.close() 메서드 호출을 누락해서 전체 시스템의 자원을 고갈시키는 경우가 많았다. 근래에는 대부분의 애플리케이션이 Spring이나 MyBatis 등의 프레임워크를 사용하기 때문에 그럴 위험이 없다. 하지만 커넥션 자원이 제대로 반납되지 않는다는 의심이 있다면 Commons DBCP 수준에서 방어하기보다는 문제 지점을 찾아서 근본적으로 수정해야 애플리케이션을 더 안정적으로 만들 수 있다.
removeAbandoned 옵션을 true로 설정하면 실행 시간이 긴 쿼리의 커넥션을 의도하지 않게 닫는 부작용이 있다. removeAbandoned 옵션은 기본값이 false로 사용하고, 오래 걸리는 쿼리는 JDBC Statement의 쿼리 타임아웃(query timeout) 등 다른 속성으로 제어하는 편이 바람직하다.
defaultAutoCommit 속성은 true가 기본값이다. 이 속성을 false로 설정하면 커넥션을 커넥션 풀에서 꺼낼 때 바로 setAutocommit(false) 메서드를 호출해서 트랜잭션을 시작하겠다는 의미다. defaultAutoCommit 속성을 false로 설정하면 애플리케이션에서 트랜잭션 처리가 되어 있지 않은 경우에는 INSERT 쿼리나 UPDATE 쿼리가 제대로 반영되지 않는다. 따라서 기본값인 true를 그대로 사용하는 것이 무난하다.