왜 기본으로 HikariCP를 선택할까? 어떤 옵션이 있을까?
스프링 공식 문서에서 데이터 소스 연결에 관한 문서의 내용을 발췌해 보면 이런 문구가 있다
참고: https://docs.spring.io/spring-boot/reference/data/sql.html#data.sql.datasource.connection-pool
SQL Databases :: Spring Boot
The Reactive Relational Database Connectivity (R2DBC) project brings reactive programming APIs to relational databases. R2DBC’s Connection provides a standard method of working with non-blocking database connections. Connections are provided by using a C
docs.spring.io
- 성능과 동시성 면에서 HikariCP를 선호한다. HikariCP를 사용할 수 있다면 항상 HIkariCP를 선택한다
- HikariCP를 사용할 수 없다면 Tomcat pooling DataSource를 선택한다
- 그렇지 않다면, Commons DBCP2를 사용한다
- 이마저도 사용할 수 없다면 Oracle UCP를 사용한다
공식문서에서 저렇게 얘기할 정도로 HikariCP가 좋은 DB pool이라는 것은 충분히 이해는 했고, 도대체 왜 좋은가에 대해서 탐구한 후에 DB 관련해서 live 서버에서는 어떤 설정으로 운영하면 좋을지까지 정리해보려고 한다
참고: https://www.baeldung.com/hikaricp
커넥션 풀(DBCP)를 그래서 왜 쓰는데?
자바에서 DB에 직접 연결해서 처리하는 경우(JDBC) 드라이버를 로드하고 커넥션 객체를 받아오는 과정을 거쳐야 한다. 그러면 매번 사용자가 요청을 할 때마다 드라이버를 로드하고, 커넥션 객체를 생성해서 연결하고, 작업 후에 연결을 종료하는 과정을 거쳐야 하기 때문에 딱 봐도 엄청 비효율적이다. 그래서 커넥션풀을 통해서 이미 연결하는 작업을 해놓고, 그 Pool에서 커넥션 객체를 꺼내서 사용한다. 과정을 정리해 보면 아래와 같다
1. 애플리케이션 로직은 DB 드라이버를 통해서 커넥션을 조회한다
2. DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 물론 3 way handshake 동작이 발생한다 - 홀리몰리
3. DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW와 기타 부가 정보를 DB에 전달한다
4. DB는 ID, PW를 통해서 인증을 완료하고, 내부에 DB 세션을 생성한다
5. DB는 커넥션 생성이 완료되었다는 응답을 보낸다.
6. DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다
성능
먼저 기본적으로 HikariCP 팀에서 발표한 벤치마크 결과를 보면서 각 pool과의 성능 차이를 보도록 하자
- 왼쪽 그래프 (Connection Cycle ops/ms)
- 커넥션의 획득 / 반환 주기의 처리량을 초당 작업 수로 측정한 자료다
- HikariCP가 약 47,609 ~ 50,273 ops/ms로 압도적으로 높은 성능을 보여주고 있다
- 두 번째로 높은 Vibur도 5,651 ~ 5,960 ops/ms로 HikariCP의 1/10 수준에 불과하다
- 오른쪽 그래프 (Statement Cycle ops/ms)
- SQL 구문 실행 주기의 처리량 측정
- 역시나 HikariCP가 압도적으로 높은 성능을 보인다
그럼 왜 HikariCP는 성능이 좋을까?
참고: https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole
Down the Rabbit Hole
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
- 바이트 코드 수준 엔지니어링 - 일부분에 바이트코드 수준으로 엔지니어링을 했다(어셈블리 수준 네이티브 코딩 포함해서) ㅎㄷㄷ;
- 자료를 보니 JIT의 어셈블리 출력을 연구해서 주요한 루틴을 JIT 인라인 임계값 이하로 제한했다
- 인라인 임계값 이하로 제한? -> 인라인에서 호출되므로 메소드 호출 부분이 사라져 성능이 향상한다. 단점으로 코드양 증가와 캐시 효율성 감소가 있다
- 이런 최적화로 최적화 중 일부는 수백만 번 호출에도 밀리초 단위로 측정된다;;
- 그리고 좀 더 보니 CPU의 캐시 부분 고려해서 성능을 끌어올린 작업도 했다(L1, L2 캐시)
- 이 부분이 무슨 의미일지 생각해보니 CPU의 코어수는 생각보다 크지 않고, 할당한 프로세스의 수많은 스레드들이 병렬적으로 실행될 공간이 없어서 스레드당 주어진 동작 시간이 매우 촉박하다
- OS 스케쥴러가 다시 실행 기회를 주기까지(다시 리소스를 할당해 주는 차례가 오기까지)가 길어질 수 있고, 그럼 당연히 다른 코어에 온 후에는 캐시에 당연히 정보가 없는 경우가 발생 - 저번에 코루틴의 장점을 설명하면서 나왔던 프로세스 전환에 의한 캐시가 없는 상황을 생각해 보면 된다
- 새로운 코어에서 다시 데이터를 캐시에 로드해야 하는 과정 발생
- 그래서 명령어 수를 최소화로 줄여서 OS가 준 실행 단위 내에서 완료되도록 최적화했다
- 마이크로 최적화 - 아주 미세한 단위들의 성능 개선(이것들이 합해지더니 전체 퍼포먼스가 좋아졌다)
- 컬렉션 프레임워크 사용
- 기존 사용하는 ArrayList<Statement>에서 FastList라는 커스텀 클래스로 대체
- 범위 체크 제거
- ArrayList는 기본적으로 인덱스 접근 시 범위를 체크한다 - ex) 인덱스가 배열의 크기를 넘어가지 않는지
- 이러한 체크를 제거해서 성능을 향상시켰다(.......없애도 되는 건가....?)
- head에서 tail 방향으로 제거 스캔 수행
- 리스트에서 요소를 제거할 때의 탐색 방향을 최적화했다
- 앞에서 뒤로 순차적으로 스캔하도록 구현했다
HikariCP 팀에서 제안하는 MySQL 성능 최적화를 위한 옵션들
1. PreparedStatement(SQL 쿼리의 템플릿을 미리 만들어두고 재사용하기 위한 방식) 캐싱 관련 설정
- cachePrepStmts = true
- PreparedStatement 캐싱을 활성화하는 기본 설정
- JDBC 드라이버 레벨의 캐싱 (클라이언트 측)
- 각 커넥션마다 PreparedStatement 객체를 캐시
- 메모리 사용을 줄이고 객체 생성 비용을 절약
- 이 설정이 false면 캐싱 관련 설정들이 모두 무시된다
- prepStmtCacheSize = 250
- 각 커넥션 당 캐시할 PreparedStatement 수
- 기본값: 25
- 권장값: 250 ~ 500
- ORM 이용 시 더 많은 Statement를 캐싱하면 성능향상에 도움이 된다
- prepStmtCacheSqlLimit = 2048
- 캐시 할 SQL 문장의 최대 길이
- 기본값: 256
- 권장값: 2048
- 특히 Hibernate와 같은 ORM 사용 시 SQL이 길어지므로 큰 값이 필요
2. 서버 사이드 최적화 설정
- useServerPrepStmts = true
- 서버 사이드 PreparedStatement 사용
- SQL을 완성해서 MySQL에 보내주던 클라이언트 방식에서 템플릿을 서버에 전달해 두고, 이후에는 파라미터 값만 서버로 보내는 형태로 개선
- SQL 전문을 보내는 게 아니라서 네트워크 트래픽 감소
- 실행 계획 재사용 - 최적화된 실행 계획을 계속 사용
- 서버 CPU 부하 감소 - 반복적인 파싱/최적화 작업 제거
- MySQL 서버 레벨의 캐싱
- 실행 계획을 서버에서 캐시
- 최신 MySQL에서 지원하며 성능이 크게 향상
- 서버 사이드 PreparedStatement 사용
- useLocalSessionState = true
- 로컬에서 세션 상태를 추적해서 불필요한 서버 통신 감소
- rewriteBatchedStatements = true
- 배치 작업 최적화
- INSERT나 UPDATE 구문을 배치로 실행할 때 성능 향상
3. 메타데이터 캐싱 설정
- cacheResultSetMetadata = true
- ResultSet 메타데이터를 캐시 해서 재사용
- 반복적인 쿼리 실행 시 성능 향상
- cacheServerConfiguration = true
- 서버 설정 정보를 캐시
- 커넥션 초기화 시 성능 향상
4. 추가 최적화 설정
- elideSetAutoCommits = true
- 불필요한 autocommit 설정 호출 제거
- maintainTimeStats = false
- 통계 정보 수집 비활성화로 오버헤드 감소
- 과연 이걸 비활성화해도 될까...?
- 이건 진짜 모니터링 툴이 기깔난 게 붙어있어도 과연 추천할만한 옵션인지를 모르겠다
maintainTimeStats가 수집하는 정보:
- 커넥션 획득 시간
- 쿼리 실행 시간
- 커넥션 생존 시간
- 기타 성능 관련 메트릭
비활성화 시 장단점:
장점:
- 시간 측정 오버헤드 제거
- 메모리 사용량 감소
- 약간의 성능 향상
단점:
- 성능 문제 발생 시 원인 분석이 어려움
- 커넥션 풀 동작 상태 모니터링 불가
- 튜닝에 필요한 데이터 부재
DB 자체에서 해야 하는 설정은?
당연히 커넥션에 관한 설정이 서버 측에서만 있다고 해서 이게 성능을 최대로 뽑지 못할 거고, DB에서도 설정해줘야 한다
MySQL
# 커넥션 관련
max_connections = 1000 # 최대 동시 연결 수
max_connect_errors = 1000000 # 연결 오류 허용 횟수
# 버퍼 설정
innodb_buffer_pool_size = 4G # InnoDB 버퍼 풀 크기
innodb_buffer_pool_instances = 4 # 버퍼 풀 인스턴스 수
# 스레드 설정
innodb_thread_concurrency = 0 # 0은 자동 관리
thread_cache_size = 50 # 스레드 캐시 크기
# 타임아웃 설정
wait_timeout = 28800 # 비활성 커넥션 타임아웃 (초)
interactive_timeout = 28800 # 대화형 커넥션 타임아웃 (초)
PostgreSQL
# 커넥션 관련
max_connections = 1000 # 최대 동시 연결 수
superuser_reserved_connections = 3 # 슈퍼유저용 예약 연결 수
# 메모리 설정
shared_buffers = 2GB # 공유 메모리 버퍼
work_mem = 16MB # 작업 메모리
maintenance_work_mem = 256MB # 유지보수 작업 메모리
# 백그라운드 writer 설정
bgwriter_delay = 200ms # 백그라운드 writer 지연
bgwriter_lru_maxpages = 1000 # 한 번에 쓸 최대 페이지 수
# 타임아웃 설정
idle_in_transaction_session_timeout = '1h' # 트랜잭션 내 idle 타임아웃
statement_timeout = '30s' # 쿼리 실행 타임아웃
Spring DataSource 설정
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
- maximumPoolSize = 10
- 이 값이 커질수록 메모리 사용량이 늘어나고 DB 서버 부하가 증가
- 일반적으로 (CPU 코어 수 * 2) + 효과적 디스크 스핀들 수를 권장 - 보통 10개가 적합하다
- minimumIdle = 5
- maximumPoolSize의 약 50% 정도가 권장된다
- 갑작스러운 접속 증가에 대비하면서도 불필요한 커넥션 유지를 방지하기 위해서 설정
- idleTimeout = 300000 (5분)
- 유휴 커넥션이 풀에서 제거되기 전 대기 시간
- 5분은 일반적인 웹 애플리케이션의 요청 주기를 고려한 값
- connectionTimeout = 20000 (20초)
- 새 커넥션 획득 대기 시간
- 웹 애플리케이션의 일반적인 타임아웃 값(30초) 보다 작게 설정
- maxLifetime = 1200000 (20분)
- DB나 네트워크 장비의 커넥션 타임아웃보다 짧게 설정
최종 형태
spring:
datasource:
url: jdbc:mysql://your-database-url:3306/dbname?rewriteBatchedStatements=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&useSSL=true&enabledTLSProtocols=TLSv1.2
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: HikariCP
maximum-pool-size: 10 # (CPU 코어 수 * 2) + 유효 디스크 수 다른 공식이 더 좋은듯?
idle-timeout: 300000 # 5분
connection-timeout: 5000 # 5초
max-lifetime: 1200000 # 20분
auto-commit: true
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
useLocalSessionState: true
rewriteBatchedStatements: true
cacheResultSetMetadata: true
cacheServerConfiguration: true
elideSetAutoCommits: true
maintainTimeStats: false
jpa:
database-platform: org.hibernate.dialect.MySQLDialect
hibernate:
ddl-auto: validate # 운영환경에서는 validate 사용
properties:
hibernate:
format_sql: false
show_sql: false # 운영환경에서는 비활성화
jdbc:
batch_size: 100 # 배치 작업 사이즈
batch_versioned_data: true
order_inserts: true
order_updates: true
order_updates: true
connection:
provider_disables_autocommit: false
query:
in_clause_parameter_padding: true
generate_statistics: false # 운영환경에서는 비활성화
cache:
use_second_level_cache: false