티스토리 뷰

Spring

[Spring] 데이터베이스 커넥션 풀 및 JPA, 하이버네이트 설정 최적화(Database Connection Pool and JPA, Hibernate properties optimization)

망나니개발자 2025. 2. 4. 10:00
반응형



 

 

1. 데이터베이스 커넥션 풀 및 JPA, 하이버네이트 설정 최적화
(Database Connection Pool and JPA, Hibernate properties optimization)


[ 데이터베이스 커넥션 풀 설정 최적화 ]

기본 정보들

데이터베이스에 연결하기 위한 기본 정보들로는 url, username, password, driver-class-name과 같은 것들이 있다. 여기서 중요한 것은 url에 접속 파라미터 부분이다.

spring.datasource.url=jdbc:mysql://com.mangkyu.database:3306?\\
    rewriteBatchedStatements=true\\
    &zeroDateTimeBehavior=convertToNull\\
    &useUnicode=true\\
    &characterEncoding=utf8\\
    &autoReconnect=true\\
    &useSSL=false\\
    &requireSSL=false\\
    &connectTimeout=3000\\
    &socketTimeout=60000\\
    &serverTimezone=Asia/Seoul\\
    &sessionVariables=character_set_results=utf8mb4

spring.datasource.username=mangkyu
spring.datasource.password=mangkyu
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

 

 

인코딩이나 timezone 그리고 SSL 사용 여부 부분은 크게 어려운 부분이 없어 그대로 붙여주면 된다. 남은 5개의 부분은 각각 다음을 의미한다. 모두 공통적으로 적용해줄 필요가 있고, connectionTimeout 또는 socketTimeout 등은 상황에 따라 조정이 필요할 수 있다.

  • rewriteBatchedStatements=true: 배치 쿼리 실행 시 여러 SQL 문을 하나로 묶어 전송하여 네트워크 지연을 줄임으로써 성능을 향상시킴
  • zeroDateTimeBehavior=convertToNull: MySQL에서 DATETIME 타입의 값이 0000-00-00 00:00:00이면 이를 null로 변환하는 설정으로, 파싱할 수 없는 값이 있으면 JPA로 꺼내올 때 에러가 발생할 수 있음
  • autoReconnect=true: MySQL 서버와의 연결이 끊어졌을 때 자동으로 재연결을 시도하도록 하여, 네트워크 장애나 MySQL 서버 재시작 후에도 연결을 복구할 수 있음
  • connectTimeout=3000: MySQL 서버와 연결할 때의 타임아웃으로, 해당 타임아웃 동안 연결시도가 성공하지 않으면 연결을 실패로 간주함
  • socketTimeout=60000: MySQL 서버와의 연결이 일정 시간 동안 응답이 없으면 소켓을 종료함

 

 

그 외에도 다음과 같은 설정들이 추가로 적용 가능하니 필요에 따라 검토하도록 하자.

  • validationQuery: 커넥션이 끊어진 경우에 처음 실행되는 쿼리는 에러가 발생할 수 있으므로, 먼저 커넥션이 정상 상태인지 확인하도록 함
  • testWhileIdle: 커넥션 풀 안에 있는 유휴 상태의 커넥션을 대상으로 테스트를 실행하도록 함
  • timeBetweenEvictionRunsMillis: 특정 시간 간격으로 유효하지 않은 커넥션을 제거함

 

 

커넥션 풀의 크기

데이터베이스 커넥션 풀(데이터 소스) 설정에 있어 가장 중요한 부분 중 하나는 바로 커넥션 풀의 크기이다. 많은 개발자들이 데이터베이스 성능 향상을 위해 커넥션 풀 크기를 가장 먼저 늘리지만, Oracle의 성능 테스트 결과에 따르면 지나친 커넥션 풀의 숫자는 독이 되는 경우가 많았다. 오히려 기존에 2048이던 커넥션 풀 크기를 96개로 줄이자 응답 시간이 100ms에서 2ms로 줄어든 것이다.

이는 nginx 웹 서버가 단 4개의 스레드만으로도 100개의 프로세스를 가진 Apache 웹 서버보다 훨씬 뛰어난 성능을 발휘할 수 있는 것과 동일한데, CPU 코어가 하나뿐인 컴퓨터도 수십, 수백 개의 스레드를 "동시에" 실행하는 것처럼 보이지만 이는 운영체제가 시분할(time-slicing) 기법으로 만드는 환상에 불과하다. 실제로 하나의 코어는 한 번에 하나의 스레드만 실행할 수 있으며, OS의 컨텍스트 전환에 의해 단일 CPU 환경에서는 A와 B를 순차적으로 실행하는 것이 시분할을 통해 실행하는 것보다 항상 빠르다는 것이다.

따라서 스레드 수가 CPU 코어 수를 초과하면, 스레드를 추가할수록 성능이 향상되는 것이 아니라 오히려 저하되며, 이는 데이터베이스 커넥션 숫자에서도 동일하다. 적합한 커넥션 수를 위한 정형화된 공식은 다음과 같은데, 이를 기준점으로 삼고 서비스 특성에 따라 최적화하는 것이 좋다.

참고로 여기서 effective_spindle_count는 디스크 성능과 관련된 용어로, 데이터베이스가 디스크에서 데이터를 읽을 때 사용하는 물리적 디스크 드라이브의 수를 의미한다. 실제 디스크의 물리적 스핀들 수(디스크의 회전하는 부분)는 시스템 성능에 영향을 미칠 수 있다.

connections = (core_count * 2) + effective_spindle_count

 

 

이러한 기본 공식에 따라 일반적으로 운영 환경에서는 최대 풀의 크기를 16개 정도로 두고, 운영을 하면서 적합한 수치를 조정해가는 것이 바람직할 것이다. 반면에 개발 환경에서는 많은 양의 커넥션 풀이 필요하지는 않으므로, 2개 또는 4개 정도로 설정해두면 될 것이다. 따라서 기본적으로는 2개를 사용하도록 application.properties에 적용해두고, 운영 환경에서는 16개를 사용하도록 하면 될 것이다.

  • application.properties에 spring.datasource.hikari.maximum-pool-size=2를 넣어 기본적으로 최대 2개의 커넥션 풀을 사용하도록 함
  • application-live.properties에 spring.datasource.hikari.maximum-pool-size=16를 넣어 운영 환경에서는 최대 16개의 커넥션 풀을 사용하도록 함
// 기본 설정
spring.datasource.hikari.maximum-pool-size=2

// 운영 환경용
spring.datasource.hikari.maximum-pool-size=16

 

 

여기서 또 하나의 중요한 포인트는 minimum-idle과 maximum-pool-size를 반드시 동일하게 설정해야 한다는 점이다. minimum-idle은 커넥션 풀에서 유지할 유휴 커넥션의 최소 개수로, 유휴 커넥션이 해당 값 이하로 떨어지면 새로운 커넥션이 생성되는데, HikariCP의 경우 해당 값은 기본적으로 maximum-pool-size와 동일하게 설정된다.

즉, minimum-idle과 maximum-pool-size를 고정함으로써 커넥션 풀의 커넥션의 숫자가 고정되는데, 임의로 minimum-idle 값을 변경하여 maximum-pool-size과 다르게 설정하면 커넥션 불균형 및 과도한 커넥션 생성 등의 문제가 생길 수 있으므로 그대로 따라가는 것이 좋다 (추가로 커넥션 풀이 유휴 커넥션을 임의로 제거하지 않도록 아래에서 idle-timeout=0 까지 적용해주어야 한다). 왜냐하면 HikariCP의 커넥션 풀에서 커넥션을 가져오는 로직과 문제 상황을 간략히 살펴보면 다음과 같기 때문이다.

  1. 현재 스레드에서 이전에 먼저 사용했고, 지금도 미사용 상태(STATE_NOT_IN_USE)인 커넥션이 스레드 로컬에 존재한다면 이를 먼저 활용함
  2. 없다면 공용 풀에서 미사용 상태(STATE_NOT_IN_USE)의 스레드를 찾아 점유를 시도함
  3. 모든 커넥션이 사용중이라서 대기 중인 스레드가 1개 이상이라면, 새로운 커넥션 추가 요청을 시도하게 됨(즉, 새로운 커넥션 추가 요청 시도는 커넥션 풀의 숫자가 아닌 대기중인 스레드 존재 여부가 기준임)
  4. 최종적으로 커넥션 추가를 위해서는 minimum-idle과 maximum-pool-size를 비교하지만, or 조건으로 대기중인 스레드가 존재하는 경우도 커넥션이 추가됨.
  5. 따라서 minimum-idle과 maximum-pool-size이 다르다면, 커넥션을 대기중인 상황에도 커넥션이 추가되고 커넥션의 증가와 소멸이 빈번하게 이루어져 커넥션의 불균형 및 리소스 낭비가 생길 수 있음

 

 

해당 로직을 HikariCP의 상세 코드를 보면서 이해해보도록 하자.

먼저 처음 로직의 시작은 HikariPool 클래스의 getConnection 부분에서 시작된다.

 

 

그리고 해당 내부 로직에서는 실질적인 커넥션 풀에 해당하는 ConcurrentBag의 borrow를 통해 미사용 상태의 커넥션을 가져오거나 새로운 커넥션을 생성하려고 시도하는 요청을 시도하게 된다. 아래 로직에서 보이듯이 먼저 이전에 사용했던 커넥션 중에서 미사용 상태가 존재한다면, 이를 점유하게 된다. 하지만 없다면 대기 상태가 되어 버린다. 그리고 대기중인 스레드가 이미 존재하는 상황이라면 커넥션 풀의 추가 요청을 시도하게 된다.

 

 

실제 추가 여부를 결정할 때에는 minimum-idle과 maximum-pool-size 비교 뿐만 아니라 or 조건으로 대기중인 스레드의 존재 여부도 검사하여 비교한다. 스프링은 멀티스레드로 처리되므로 커넥션을 대기하는 경우가 자연스럽게 생길 수 있는데, 커넥션 풀을 고정하지 않으면 빈번하게 커넥션 풀이 초과 생성되고 해제되면서 문제가 생길 수 있는 것이다.

 

 

따라서 이러한 기본적인 동작을 염두해두고, 두 가지 값(minimum-idle과 maximum-pool-size)를 반드시 동일하여 커넥션 풀의 숫자를 고정하도록 하자. 그리고 idle-timeout을 0으로 설정하여 유휴 상태의 커넥션이 소멸되지 않도록 하는 부분도 함께 챙겨주어야 하는데, 이어서 살펴보도록 하자.

 

 

타임아웃 관련 설정

그 다음으로는 데이터베이스 커넥션의 타임아웃 설정과 관련된 부분이다.

spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.idle-timeout=0
spring.datasource.hikari.max-lifetime=170000

 

 

위의 옵션들은 각각 다음과 같이 적용된다.

  • spring.datasource.hikari.connection-timeout=3000
    • 커넥션 풀에서 사용 가능한 커넥션을 얻기 위해 대기하는 시간(ms)
    • 해당 시간 내에 커넥션을 가져오지 못하면 예외(SQLTransientConnectionException: Timeout waiting for idle connection)가 발생함
    • 해당 값이 지나치게 짧으면 과도한 실패가 발생하고, 너무 길면 응답 속도가 느려질 수 있으므로 적절한 조정이 필요함
  • spring.datasource.hikari.idle-timeout=0
    • 풀에서 사용하지 않는 idle 커넥션을 유지하는 최대 시간(ms)
    • 0으로 설정하면 유휴(Idle) 커넥션을 강제로 닫지 않도록 비활성화 함
    • 0으로 두면 풀 크기 유지가 보장되지만, 너무 많은 유휴 커넥션이 유지되면 MySQL의 연결 개수 제한(max_connections) 문제를 일으킬 수 있으므로 maximum-pool-size를 적절하게 잡아주어야 함
  • spring.datasource.hikari.max-lifetime=170000
    • 커넥션이 살아있을 수 있는 최대 시간(ms)
    • 해당 시간이 지나면 커넥션이 닫히고 새 커넥션이 생성됨
    • 해당 시간이 너무 짧으면 커넥션이 자주 재생성되어 오버헤드가 증가하므로 적절한 설정이 필요함
    • MySQL에서는 연결이 너무 오래 유지되어 끊어지는 것을 방지하도록 wait_timeout 설정에 따라 일정 시간마다 커넥션을 재생성함(해당 설정의 기본값은 1800000ms, 30분)임. 해당 시간보다 설정이 길어지면 강제로 커넥션이 끊길 수 있으므로, MySQL의 wait_timeout 보다 짧게 설정해야 함

 

 

 

커넥션 초기화 SQL

데이터베이스에는 영어와 한글 외에도 이모지 등이 저장될 수 있다. 따라서 데이터베이스 커넥션을 초기화할 때 서버 간의 문자 인코딩 방식을 UTF-8로 설정해주는 것이 좋다. 연결 주소의 쿼리 파라미터로도 설정할 수 있는데, 간혹 해당 옵션이 다른 설정에 의해 덮어씌워지면서 무시되는 상황이 생길 수 있다. 따라서 아래의 설정을 반드시 적용해주는 것이 좋다.

spring.datasource.hikari.connection-init-sql=SET NAMES utf8mb4

 

 

 

 

[ JPA, 하이버네이트 설정 최적화 ]

JPA, 하이버네이트 JPQL 쿼리 로깅

개발을 하다 보면 JPA와 같은 ORM이 실행하는 쿼리를 직접 확인해야 하는 경우가 있다. 이러한 경우에는 다음의 두 가지 설정을 통해 통해 포맷팅된 SQL을 주석과 함께 로그로 출력할 수 있다.

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

 

 

이러한 옵션을 통해 다음과 같은 쿼리를 로그로 확보할 수 있을 것이다.

// JPA 옵션에 따른 drop-create DDL
Hibernate: 
    drop table if exists Member cascade 

Hibernate: 
    create table Member (
        age integer,
        id bigint generated by default as identity,
        name varchar(255),
        primary key (id)
    )

// JpaRepository를 통한 DML
Hibernate: 
    /* <criteria> */ select
        m1_0.id,
        m1_0.age,
        m1_0.name 
    from
        Member m1_0

 

 

하지만 해당 내용은 운영 환경에서는 필요하지 않고, 로컬 혹은 개발 환경에서만 필요하다. 따라서 다음과 같이 설정해주면 좋을 것이다.

  • application.properties에 false 옵션을 넣어 기본적으로 비활성화함
  • application-local.properties 및 application-alpha.properties에 true 옵션을 넣어 활성화함

 

 

 

OSIV(Open-Session-In-View) 설정

OSIV(Open-Session-In-View)는 하이버네이트와 JPA에서 제공하는 기능으로, 영속성 컨텍스트(EntityManager)의 생명주기를 확장하여 트랜잭션 범위를 넘어 뷰(View) 렌더링까지 지속되도록 하는 설정이다. 개인적으로는 JPA와 하이버네이트가 제공하는 연관 관계를 비롯한 영속성 컨텍스트에 대한 기능의 사용을 지양하고 있기 때문에 이를 비활성화 해두었다. 해당 내용은 전역적으로 적용되도록 application.properties에 기본 설정으로 넣어두었다.

spring.jpa.open-in-view=false

 

 

 

자동 DDL 설정

현대에는 MSA를 통해 서비스를 작게 가져가는 것이 주류인 시대가 되었고, 그에 따라 하나의 비즈니스를 처리하기 위해 여러 서비스 간에 통신이 필요해졌다. 그 얘기인 즉슨 데이터 역시 서로 다른 저장소에 각기 다르게 저장되어 있음을 의미한다. 따라서 개발 환경이라고 하더라도 함부로 테이블을 초기화하고 데이터를 삭제하면 다른 서비스에 영향을 줄 수 있다. 따라서 자동 DDL 옵션 중에서 데이터베이스 스키마에 직접적인 영향을 주는 옵션은 사용을 지양하는 것이 좋다. 대신 validate 옵션을 통해 데이터베이스 스키마와 ORM 엔티티 매핑을 비교하여 일관성을 검증해주는 것이 좋다.

spring.jpa.hibernate.ddl-auto=validate

 

 

대신 테스트를 위한 환경 등에서 H2와 같은 인메모리 데이터베이스를 사용하는 경우에는 다음과 같이 create-drop 옵션을 활용하여 스키마가 자동 구축되도록 처리하는 것이 용이할 것이다. 따라서 필요한 경우에만 다음과 같이 스키마를 처리해주는 옵션을 사용해주도록 하자. 운영 환경에서는 update 또는 create-drop과 같은 부분을 직접 사용해서는 절대 안될 것이다.

spring.jpa.hibernate.ddl-auto=create-drop

 

 

 

실행 쿼리의 상수 파라미터 binding

기본적으로 Hibernate을 통해 JPQL을 실행하면 문자열은 실행 파라미터에 inline 되지 않고 bind 되어 파라미터가 다른 쿼리들도 다음과 같이 동일한 쿼리로 파싱되고 처리된다.

// queries
SELECT id, isbn, name FROM book b WHERE b.name = 'MangKyu'
SELECT id, isbn, name FROM book b WHERE b.name = 'MinKyu'

// bind
SELECT id, isbn, name FROM book b WHERE b.name = ?

 

 

하지만 상수값은 inline이 되어 버리려 각각이 독립적인 쿼리로 해석되고 다음과 같이 실행이 된다.

// queries
SELECT id, isbn, name FROM book b WHERE b.isbn = 9789730228236
SELECT id, isbn, name FROM book b WHERE b.isbn = 2789730228236

// inline
SELECT id, isbn, name FROM book b WHERE b.isbn = 9789730228236
SELECT id, isbn, name FROM book b WHERE b.isbn = 2789730228236

 

 

inline된 쿼리는 그 자체로 하나의 새로운 실행 쿼리이기 때문에 파라미터에 따라 쿼리 파싱을 매번 해주어야 하며, 그에 따라 캐싱된 실행 계획을 재사용하는 등의 이점을 누릴 수 없다. 따라서 상수인 경우에도 쿼리가 bind되도록 설정해주면 용이한데, 다음의 옵션을 통해 이를 활성화할 수 있다.

spring.jpa.properties.hibernate.criteria.literal_handling_mode=bind

 

 

하지만 상수 값이 편향되어 있거나 실행 계획 캐시에서 경합이 발생하는 경우에 오히려 해당 옵션이 해로올 수 있다. 예를 들어 다음과 같이 쿼리에 따른 데이터가 편향되어 있는 상태라고 하자. 이러한 경우에는 값에 따라 최적의 경로를 제공해줄 수도 있을 텐데, 항상 동일한 실행 계획을 강제로 재사용하기 때문에 오히려 성능이 저하될 수 있으므로 참고해주도록 하자.

SELECT id, isbn, name FROM book b WHERE b.name = 'MangKyu' // 10만개
SELECT id, isbn, name FROM book b WHERE b.name = 'MinKyu'  // 1개

 

 

대부분의 경우에는 해당 옵션을 적용해주는 것이 좋고, DBMS에서 문제가 생길 수 있는 상황이라면 오늘날에는 애플리케이션 레벨에서 캐싱을 사용하는 것이 더욱 바람직할 수 있으므로 해당 옵션을 기본적으로 적용해주는 것이 합리적이라고 판단하였다. 물론 데이터가 많은 경우라면 직접적인 성능 테스트를 진행해보는 것이 좋다.

 

 

 

IN 절 실행 최적화

애플리케이션을 개발 하다 보면 IN 절을 사용하는 경우가 종종 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByIdIn(List<Long> ids);
}

 

 

하지만 IN 절로 넘어오는 파라미터의 개수는 매번 다를 수 있기 때문에, 다음과 같이 실행 쿼리가 무한정 늘어날 수 있다.

SELECT * FROM member where id IN (?);
SELECT * FROM member where id IN (?, ?);
SELECT * FROM member where id IN (?, ?, ?);
SELECT * FROM member where id IN (?, ?, ?, ?);
SELECT * FROM member where id IN (?, ?, ? ,?, ?);

 

 

이러한 방식은 preparedStatement 효과를 못 누리게 될 뿐만 아니라, 하이버네이트는 각각을 별도의 Execution Plan Cache로 관리하여 지나치게 메모리를 잡아먹어 OOM을 유발할 수 있다. 따라서 다음의 하이버네이트 설정을 통해 2의 거듭 제곱 단위로 실행 쿼리를 패딩하도록 하면 코드의 변경 없이 성능을 향상시킬 수 있다.

spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true

 

 

그러면 이제 실행되는 IN절의 쿼리들은 다음과 같이 2의 거듭 제곱으로 패딩될 것이다.

SELECT * FROM member where id IN (?);
SELECT * FROM member where id IN (?, ?);
SELECT * FROM member where id IN (?, ?, ?, ?);
SELECT * FROM member where id IN (?, ?, ?, ?, ?, ?, ?, ?);

 

 

만약 실행되는 쿼리의 IN 절 파라미터가 1, 2, 3 총 3개라면 다음과 같이 파라미터가 패딩 및 바인딩 될 것이다. 이를 통해 데이터베이스의 쿼리 캐시 등의 이점을 누릴 수 있다.

SELECT * FROM member where id IN (1, 2, 3, 3);

 

 

 

auto-commit 비활성화

쿼리를 실행하는 과정에는 커넥션 풀(데이터 소스)로부터 커넥션을 획득하고, 기본적으로 auto-commit을 false로 설정하는 작업이 있다. 그리고 처리가 완료되어 커넥션을 커넥션 풀로 반납할 때에는 다시 auto-commit을 true로 변경하게 된다. setAutoCommit은 평균적으로 1~3ms가 소요되며, false와 true 각각 1번씩 수행되므로 우리는 이로 인해 기본적으로 2~6ms의 성능 손해를 보고 시작한다고 볼 수 있다. 데이터베이스로의 요청이 적다면 크게 문제가 되지 않겠지만, 요청량이 많아진다면 이러한 부분 역시 줄여주는 것이 바람직하다.

따라서 다음의 두 가지 옵션을 통해 기본적으로 모든 데이터베이스 커넥션의 auto-commit이 false인 상황이 되도록 하여 하이버네이트로 하여금 이러한 부분을 건너뛰게 힌트를 제공할 수 있다.

spring.datasource.hikari.auto-commit=false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true

 

 

벤치마크 결과에 따르면 이러한 설정을 통해 70%의 효과를 얻었으며, 실제 기업 사례를 보아도 최대 110%의 효과를 누리기도 했다고 한다. 작아보이는 부분일지라도 트래픽이 많아진다면 큰 개선 효과를 볼 수 있을 것이다.

 

 

 

 

 

참고 자료

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함