티스토리 뷰
[ 개요 ]
서버를 운영하다 보면 hikari connection과 관련된 에러를 겪게 된다. 빈번하게 발생 가능한 에러라 정리해보려 한다.
먼저 ConnectionPool이 뭔지, HikariCP는 어떻게 동작하는지에 대해 알아보자.
[ ConnectionPool ]
ConnectionPool 이란 커넥션을 미리 생성해두고 사용하는 방법이다. 데이터베이스 커넥션을 새로 생성하는 것은 과정도 복잡하고, 많은 시간이 소요되므로 커넥션 풀을 사용한다.
커넥션 풀에 커넥션을 요청하면, 커넥션 풀은 자신이 갖고 있는 커넥션 중 하나를 반환한다. 커넥션을 모두 사용하고 나면 커넥션을 종료하는 것이 아니라 커넥션 풀에 반환한다. 커넥션 풀을 사용하면 커넥션 수를 제한할 수 있어 과도한 접속으로 인한 서버 자원 고갈을 방지할 수 있다.
커넥션 풀 오픈 소스에는 여러가지 있지만, 스프링 부트 2.0부터는 기본 커넥션 풀로 hikariCP를 제공한다. 그럼 hikariCP 동작원리에 대해 알아보자.
[ HikariCP에서 하나의 쿼리가 실행되는 과정 ]
커넥션을 얻는 방법은 (DriverManager, HikariCP ..) 다양하기 때문에 커넥션을 획득하는 방법을 추상화한 인터페이스인 DataSource를 사용한다. HikariDataSource는 HikariCP에서 커넥션을 획득하는 방법을 구현한 DataSource의 구현체이다.
실제 코드는 복잡하게 잘 구성되어 있지만 간략한 뼈대는 아래와 같을 것이다. 아래 적어놓은 주석과 같은 과정으로 커넥션을 얻고, 쿼리를 실행하고, 커넥션을 반납하게 된다.
Connection connection = null;
PreparedStatement preparedStatement = null
try {
connection = hikariDataSource.getConnection(); // 1. connection pool로부터 커넥션 획득
preparedStatement = connection.preparedStatement(sql); // 2. SQL을 넣은 statement 객체 반환
preparedStatement.executeQuery(); // 3. 쿼리 실행
} catch(Throwable e) {
throw new RuntimeException(e);
} finally {
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close(); // 4. connection pool에 커넥션 반납
}
}
HikariCP에서 쿼리가 실행되는 과정을 알아봤으니, hikariDataSource.getConnection() 과 connection.close() 의 내부 동작 원리를 살펴보자.
[ HikariCP 동작 원리 ]
1. 커넥션 요청 : hikariDataSource.getConnection()
쓰레드가 커넥션을 요청하면 이전에 사용했던 Connection이 존재하는지 확인하고, 이를 우선적으로 반환한다.

1-2. 사용 가능한 커넥션이 없는 경우
사용 가능한 커넥션이 존재하지 않으면, HandOffQueue를 Polling하면서 다른 쓰레드가 커넥션을 반납하기를 기다린다. (지정한 TimeOut 시간까지 대기하다가 시간이 만료되면 예외를 던진다. )

2. 커넥션 사용 후 커넥션 풀에 반납 : connection.close()
쓰레드가 커넥션을 반납하면 Connection Pool은 커넥션 사용 내역을 기록하고, 반납된 커넥션을 HandOffQueue에 삽입한다. 그 후 HandOffQueue를 Polling하던 쓰레드는 커넥션을 획득하고 작업을 이어나간다.

[ Hikari Connection Pool 에러 핸들링 ]
Connection 관련 에러는 보통 아래의 에러 메시지와 함께 문제가 발생한다.
- o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
- o.h.engine.jdbc.spi.SqlExceptionHelper : hikari-pool-1 – Connection is not available, request timed out after 30000ms.
- org.hibernate.exception.JDBCConnectionException: unable to obtain isolated JDBC connection
- Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
에러의 내용은 트랜잭션을 만드는데 실패했고, JDBC Connection을 획득할 수 없다. 즉, hikariPool 에서 커넥션을 사용할 수 없어 요청 시간이 만료되었다는 의미이다.
그렇다면, 왜 커넥션을 사용할 수 없는지 간단한 예시로 상황을 재현해보자.
[ Connection 에러 상황 재현 ]
1. hikari 관련 옵션 설정
application.yml
spring:
datasource:
hikari:
jdbc-url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
connection-timeout: 3000
maximum-pool-size: 5
max-lifetime: 30000
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
- maximum-pool-size : hikari pool에서 관리하는 최대 커넥션 수
- connection-timeout : 사용가능한 커넥션이 없는 경우 커넥션을 얻기 위해 대기하는 시간
connection-timeout 으로 설정한 시간 안에 커넥션을 얻지 못하면 에러가 발생한다. 위 application.yml 파일을 test/resources 경로에 저장하자.
2. 비즈니스 로직 작성
Order
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
OrderRepository
public interface OrderJpaRepository extends JpaRepository<Order, Long> {
}
OrderService
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderJpaRepository orderJpaRepository;
@Transactional
public void saveWithDelay(Order order) throws InterruptedException {
log.info("saveWithDelay 호출 : {}", order.getId());
Thread.sleep(10000);
orderJpaRepository.save(order);
}
}
- saveWithDelay는 Order 엔티티를 저장하고, 10초간 delay하는 메서드이다.
- 여기서 중요한 점은 saveWithDelay가 트랜잭션으로 묶여있다는 점이다.
- 따라서 saveWithDelay가 호출되면, 트랜잭션은 10초간 유지될 것이다.
3. 테스트
멀티 스레드를 사용해 각 스레드가 커넥션을 제대로 획득하는지 테스트해보자. 테스트 시나리오는 아래와 같다.
- 멀티 스레드를 사용해 동시에 10개의 스레드를 만든다.
- saveWithDelay를 호출한다. (10초의 delay)
@SpringBootTest
public class OrderServiceTest {
@Autowired
private OrderService orderService;
@Test
void 동시에_10개의_order를_저장한다() throws InterruptedException {
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for(int i = 0; i < threadCount; i++) {
long id = i;
executorService.submit(() -> {
try {
Order order = new Order(id);
orderService.saveWithDelay(order);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
}
}
테스트 실행 결과는 다음과 같다.
INFO 864755 --- [pool-1-thread-9] com.example.springevent.OrderService : saveWithDelay 호출 : 8
INFO 864755 --- [pool-1-thread-8] com.example.springevent.OrderService : saveWithDelay 호출 : 7
INFO 864755 --- [pool-1-thread-4] com.example.springevent.OrderService : saveWithDelay 호출 : 3
INFO 864755 --- [pool-1-thread-5] com.example.springevent.OrderService : saveWithDelay 호출 : 4
INFO 864755 --- [ool-1-thread-10] com.example.springevent.OrderService : saveWithDelay 호출 : 9
WARN 864755 --- [pool-1-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
WARN 864755 --- [pool-1-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
WARN 864755 --- [pool-1-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
WARN 864755 --- [pool-1-thread-7] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
WARN 864755 --- [pool-1-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
ERROR 864755 --- [pool-1-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 3000ms.
ERROR 864755 --- [pool-1-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 3001ms.
ERROR 864755 --- [pool-1-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 3000ms.
ERROR 864755 --- [pool-1-thread-7] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 3000ms.
ERROR 864755 --- [pool-1-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 3000ms.
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
java.util.concurrent.CompletionException: org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
이러한 결과가 발생한 이유는 다음과 같다.
- max-pool-size를 5로 설정했기에 커넥션 풀에 5개의 커넥션이 존재한다.
- saveWithDelay가 호출되면 트랜잭션에 의해 약 10초간 커넥션을 잡고 있는다.
- 먼저 실행된 5개의 saveWithDelay는 커넥션을 획득하고, 나머지 5개의 스레드는 커넥션 풀에 사용 가능한 커넥션이 존재하지 않아 대기한다.
- 커넥션 풀로부터 커넥션 획득을 기다리는 시간인 connection-timeout 으로 설정한 3초가 지나 예외가 발생한다.
즉, 커넥션 풀에 존재하는 모든 커넥션이 사용 중인 상황에서 커넥션 획득을 위해 대기하다 일정시간이 지나면 예외가 발생하는 것이다. 커넥션 관련 에러는 어떻게 해결해야 할까?
[ 해결 방법 ]
1. Connection Pool 사이즈를 늘린다.
spring:
datasource:
hikari:
jdbc-url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
connection-timeout: 3000
maximum-pool-size: 10 // 5 -> 10
max-lifetime: 30000
driver-class-name: org.h2.Driver
테스트 결과는 당연히 성공한다. 10개의 스레드 모두 커넥션을 획득하여 saveWithDelay 메서드를 실행한 것을 볼 수 있다.

Connection Pool 사이즈를 늘리는 것이 무조건적으로 에러를 해결해줄 수 있는 것은 아니다. 트래픽이 증가한다면, 커넥션 풀 사이즈를 늘린다고 하더라도 금방 커넥션이 고갈될 것이다. 또한, Connection Pool 사이즈를 너무 크게 설정하는 것은 그만큼 커넥션을 미리 생성해 확보해놓는 것이므로 리소스 낭비가 될 수 있다.
2. Connection timeout 시간을 늘린다.
spring:
datasource:
hikari:
jdbc-url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
connection-timeout: 15000 // 3000 -> 15000
maximum-pool-size: 5
max-lifetime: 30000
driver-class-name: org.h2.Driver
테스트 결과는 당연히 성공한다. saveWithDelay 호출 시 약 10초간 커넥션을 사용하고, 커넥션 풀에 반납한다. 대기하던 스레드들은 약 10초 뒤 커넥션을 획득해 saveWithDelay를 실행할 수 있게 된다. timeout 시간인 15초를 넘기지 않았으므로 에러는 발생하지 않는다.

connection-timeout 시간을 늘리는 것은 일시적인 해소는 될 수 있지만, 오랜 시간 커넥션 대기로 인해 클라이언트에게 응답이 늦어질 수 있다.
해결방법 1과 2는 임시 방편으로 사용해볼 수 있겠으나, 근본적인 해결방법은 아닐 수 있다.
3. 트랜잭션 범위를 작게 설정
connection pool 사이즈와 timeout 시간은 처음과 같이 가져가고, saveWithDelay 메서드에 @Transactional 애노테이션을 제거해주었다.
spring:
datasource:
hikari:
jdbc-url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
connection-timeout: 3000
maximum-pool-size: 5
max-lifetime: 30000
driver-class-name: org.h2.Driver
// @Transactional
public void saveWithDelay(Order order) throws InterruptedException {
log.info("saveWithDelay 호출 : {}", order.getId());
Thread.sleep(10000);
orderJpaRepository.save(order);
}
- saveWithDelay 메서드 자체에 걸려있는 트랜잭션을 빼주면, 10초간 트랜잭션을 가져가는 것이 아닌, orderJpaRepository.save(order)가 호출되는 순간에만 트랜잭션을 가져간다.
- 따라서, 커넥션을 사용해도 save 작업이 끝나면 바로 반납하기 때문에 connection-timeout이 발생하지 않는다.
Connection 관련 에러가 발생하면 먼저, 트랜잭션의 범위를 너무 넓게 잡아둔 곳이 있는지 확인해보자.
테스트는 아래와 같이 성공한다.

마무리하며
Hikari Connection pool 동작 원리, Connection 관련 에러 원인 및 해결 방법에 대해 간단히 알아보았다. 해결 방법은 많지만, 상황에 따라 적절히 사용하는 것이 중요하다. Connection pool 사이즈를 늘리는 것이 근본적인 해결방법이라 할 수 없다. 사이즈를 늘린다 하더라도 트래픽이 몰리는 경우 똑같은 에러 상황이 발생할 수 있고, 하나의 스레드에서 하나의 커넥션만 사용할 것이라는 보장이 없기 때문에 언제든지 커넥션으로 인한 에러가 발생할 수 있다. 이러한 많은 상황을 고려해 Connection pool 사이즈를 지정해야 할 것이다.
트랜잭션 범위가 넓어 커넥션을 오래 잡고 있는 코드가 있는지 살펴보는 것도 중요하다. DB와 밀접한 연관이 있는 부분만 트랜잭션 범위로 설정하고, 나머지는 트랜잭션 밖으로 분리하자.
Reference
https://techblog.woowahan.com/2664/
https://steady-coding.tistory.com/564
https://velog.io/@kdohyeon/Error-Could-not-open-JPA-EntityManager-for-transaction
'Server > Spring' 카테고리의 다른 글
[Spring] oneToMany 관계에서 fetch join, limit 같이 사용하는 경우 (0) | 2023.09.05 |
---|