본문 바로가기

카테고리 없음

Connection Pool과 적절한 Size 설정하기 (with HikariCP)

Connection

유저가 보낸 요청을 처리하는 과정에서 서버(WAS)가 DB에 접근하기 위해서는 Connection 타입의 DB 연결을 위한 객체가 필요합니다.

DriverManager

JDBC에서는 DriverManager를 사용하면 Connection 을 아래와 같이 가져올 수 있습니다.

 

Connection connection = DriverManager.getConnection(DB_URL, USER, PASSWORD)

Connection은 생성 비용이 크다

하지만 이런식으로 DB에 접근할 때마다 Connection을 생성하는 것은 비효율적입니다. Connection을 생성하는 데 비용이 많이 들기 때문입니다.

 

// 행을 insert하는 데 드는 시간 (괄호 안의 숫자는 비율을 나타낸다)
// 연결 비용은 3, 연결 종료 비용은 1로 꽤 큰 비중을 차지하는 것을 알 수 있다.

Connecting: (3)

Sending query to server: (2)

Parsing query: (2)

Inserting row: (1 × size of row)

Inserting indexes: (1 × number of indexes)

Closing: (1)

 

💡 커넥션 생성에 비용이 많이 드는 이유

보통 DB와 연결할 때 TCP 통신을 하기 때문입니다. TCP 통신은 커넥션의 안정성과 신뢰성 보장을 위해 위해 연결 시 3 Way-handshake, 연결 종료 시 4 Way-handshake 과정을 거칩니다. 이 과정에서 통신 비용이 많이 소모되므로 커넥션을 계속해서 생성하는 데에 비용이 많이 드는 것입니다.

그래서 Connection Pool이 필요하다.

이렇게 Connection을 생성하기 위한 비용을 줄이기 위해 Connection Pool을 사용합니다. Connection 을 미리 만들어 pool에 보관하고, 필요할 때마다 커넥션을 꺼내 사용하면 커넥션을 생성하는데 드는 비용을 줄일 수 있습니다.

DataSource

DataSource란 DB나 파일과 같은 물리적 데이터 소스에 연결할 때 사용하는 인터페이스입니다. 구현체는 각 vendor사에서 제공합니다.

 

DataSource를 사용하면 Connection Pool을 활용할 수 있습니다. DriverManager가 아닌 DataSource 를 사용하는 이유도 이 때문입니다. 뿐만 아니라 애플리케이션 코드를 직접 수정하지 않고 properties로 DB 연결을 변경할 수도 있으며 분산 트랜잭션도 사용할 수 있습니다.

 

JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL(H2_URL);

// Connection을 새로 생성하지 않고, 이미 만들어진 Connection을 get으로 가져온다.
Connection connection = dataSource.getConnection(USER, PASSWORD);

HikariCP

스프링 부트 2.0부터는 HikariCP를 기본 DataSource로 채택하고 있습니다. HikariCP는 빠르고 간편하며 오버헤드가 zero라고 합니다(HikariCP 피셜이며 자세한 이유는 모르겠네요).

HikariCP Property

  • maximumPoolSize
    • Connection Pool이 가질 수 있는 최대 커넥션 개수. 유휴한 커넥션과 사용중인 커넥션을 모두 포함한 개수입니다. 적절한 값은 실행 환경에 따라 다릅니다.
    • 디폴트: 10개
  • connectionTimeout
    • 클라이언트가 pool에서 connection을 기다리는 최대 시간(ms). 시간이 초과되면 SQLException이 발생합니다.
    • 디폴트: 30000ms (30초)
    • 최소: 250ms
  • maxLifetime
    • 풀에 존재하는 커넥션의 최대 lifetime. 사용 중인 커넥션은 절대 제거되지 않으며 커넥션이 닫히고 나서만 제거됩니다. HikariCP는maxLifetime을 설정하는 것을 권장합니다. maxLifetime은 DB나 인프라의 커넥션 타임 limit 보다 몇 초 더 짧아야 합니다. 같거나 더 길다면 설정한 의미가 없기 때문입니다.
    • 값이 0인 경우 lifetime 설정이 적용되지 않습니다. 즉 무한의 lifetime을 가집니다.
    • 디폴트: 1800000 (30분)
    • 최소: 30000ms (30초)
  • keepaliveTime
    • 커넥션이 유효한지 확인하는 주기 설정(ms). 유효하지 않는 경우 커넥션을 풀에서 제거한다. 이 값은 반드시 maxLifeTime 보다 작아야 합니다. maxLifeTime 보다 길다면 이미 커넥션이 제거된 이후 검증하는 것이므로 설정이 무의미합니다.
    • 디폴트: 0 (disabled)
    • 최소: 30000ms (30초)

MySQL Configuration

MySQL 관련 설정들을 소개합니다. 아래 설정을 통해 성능을 향상시킬 수 있습니다.

💡 prepared statement란?
동일하거나 비슷한 데이터베이스 문을 높은 효율성으로 반복적으로 실행하기 위해 사용되는 기능. 일반적으로 쿼리나 업데이트와 같은 SQL 문과 함께 사용된다. 보통 템플릿의 형태를 취하며, 그 템플릿 안으로 특정한 상수값이 매 실행 때마다 대체된다.

INSERT INTO products (name, price) VALUES (?, ?);

 

  • cachePrepStmts
    • prepared statement의 캐싱 여부 (true: 캐싱 함)
    • 디폴트값: false, 추천값: true (캐싱 사용)
  • prepStmtCacheSize
    • 하나의 커넥션에 캐싱할 prepared statement의 개수
    • the number of prepared statements that the MySQL driver will cache per connection.
    • 디폴트값: 25, 추천값: 250-500
  • prepStmtCacheSqlLimit
    • 캐싱할 prepared statement의 최대 길이
    • the maximum length of a prepared SQL statement that the driver will cache.
    • 디폴트값: 256, 추천값: 2048
final var hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(H2_URL);
hikariConfig.setUsername(USER);
hikariConfig.setPassword(PASSWORD);
hikariConfig.setMaximumPoolSize(5);
hikariConfig.addDataSourceProperty("cachePrepStmts", "true");
hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250");
hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

final var dataSource = new HikariDataSource(hikariConfig);

Connection Pool Size

이론상 데드락을 피하면서 모든 요청을 처리할 수 있는 커넥션 개수의 공식은 아래와 같습니다.

 

Connection Pool Size = Tn * (Cn - 1) + 1

- Tn: 전체 Thread 개수
- Cn: 하나의 요청에서 동시에 필요한 Connection 개수

 

우리 달록 서비스에서는 동시에 커넥션을 두 개 이상 사용하는 일이 없으니 사실상 커넥션이 1개만 있어도 모든 요청에 대한 처리가 가능합니다. 하지만 이렇게 커넥션 한 개를 돌려사용하면 성능이 좋진 않겠죠?

그럼 적절한 Connection Pool Size는 얼마인가요?

 

사실 이론상으로는 하나의 코어를 가진 CPU가 수백개의 요청을 ‘동시에’ 처리할 수 있습니다. 하지만 이는 time-slicing 기법 때문에 동시에 동작하는 것으로 보이는 것이지, 실제로는 하나의 코어는 동시에 하나의 작업만 처리할 수 있습니다. 빠르게 여러 개의 작업을 context-swithing하며 동작하기 때문에 ‘동시에’ 동작하는 것처럼 보이는 것 뿐입니다. 따라서 단순히 pool size를 키운다고 해서 무조건적으로 처리가 빨라지는 것은 아닙니다.

 

그럼 하나의 코어에 하나의 커넥션만 생성하면 되지 않을까요? 디스크와 네트워크를 무시하면 하나의 코어에 하나의 커넥션만 생성하는 것이 최적일 것입니다. 컨텍스트 스위칭 비용이 발생하지 않기 때문이죠. 하지만 실제로는 디스크와 네트워크가 변수로 작용합니다. 디스크나 네트워크를 사용하는 동안은 스레드가 block되어 다른 작업을 할 수 없고, 그에 따라 성능이 저하됩니다. 따라서 디스크나 네트워크 때문에 block된 시간동안 다른 작업을 처리하면 성능을 향상시킬 수 있습니다. 그러니 Connection Pool Size는 CPU 코어의 개수보다는 많은 것이 좋겠죠.

PostgreSQL이 추천하는 Connection Pool Size 공식

PostgreSQL은 이러한 디스크와 네트워크에 접근하는 시간을 고려하여 CPU 코어 개수(core_count)의 두 배로 사이즈를 설정하는 것을 추천합니다. 또한 한 번에 접근할 수 있는 DB의 수가 많아지면 block된 커넥션이 늘어날 것이므로 effective_spindle_count를 더해줍니다.

 

Connection Pool Size = (core_count * 2) + effective_spindle_count

- core_count: CPU 코어 개수.
- effective_spindle_count: 하드 디스크의 개수. spindle은 DB 서버가 관리할 수 있는 동시 I/O 요청의 개수를 의미한다.

 

공식을 참고하되, 각자의 실행 환경에서 적절한 테스트를 통해 사이즈를 구하는 것이 가장 정확한 방법인 것 같습니다.

References