Spring은 Connection pool 에서 Connection을 언제 가져올까?

김유정·2025년 2월 16일
3

글을 쓰는 목적

application.yaml에 hikari와 관련된 설정을 별 생각없이 했었고, connection pool에 대한 이해도도 낮았습니다. 그런데, 얼마 전 connection pool의 중요성을 알게 되는 상황이 생겼습니다. 트래픽이 급증하는 상황에서 connection 이 없어서 스레드는 계속 대기하고 서비스가 엄청 느려지는 상황이 발생했습니다. 뿐만 아니라 속도가 느린 API가 계속해서 connection을 잡아먹고 있어서 다른 가벼운 API들이 수행되지 못하는 경우도 있었구요. 그래서 그 때 connection pool이 궁금해졌습니다. "connection pool은 뭐고, connection은 어떨 때 가져오는거지?" 그래서 이를 이해하고자 글을 쓰게 됐습니다.

Spring의 connection pool

Spring에서는 성능과 동시성 때문에, 사용 가능한 connection pool 중에서 우선순위가 높은 걸 사용한다고 합니다.

📊 connection pool 우선순위

  1. HikariCP
  2. Tomcat pooling DataSource
  3. DBCP2
  4. Oracle UCP

Spring에서는 HikariCP를 가장 선호하고 대부분의 경우 사용하기 때문에, 이 글에서는 Hikari를 기준으로 살펴볼 것입니다.

HikariCP란?

빠르고 간단한 JDBC connection pool 라이브러리인데요. spring-boot-starter-jdbcspring-boot-starter-data-jpa starters 를 사용하면, 자동으로 dependency에 포함되기 때문에, 추가할 필요가 없습니다.

HikariCP는 다음과 같은 설정값들을 가지는데, 디폴트값이 있기 때문에 필요한 것들만 수정해서 쓰시면 됩니다.

설정설명디폴트값
connectionTimeoutpool에서 connection을 얻기까지 대기하는 시간의 최댓값30000(30초)
idleTimeoutconnection이 pool에서 유휴상태로 유지될 수 있는 시간의 최댓값600000(10분)
keepaliveTime유휴 connection이 유지될 수 있도록 keep-alive 패킷을 보내는 주기120000(2분)
maxLifetimeconnection의 최대 수명 주기18000000(30분)
minimumIdle유휴 connection의 최소 개수10개
maximumPoolSizeconnection의 최대 개수10개
poolNameconnection pool의 이름auto-generated

🧐 설정을 바탕으로 역할 이해해보기

  • 애플리케이션이 시작되면, HikariCP는 minimumIdle 만큼의 connection을 생성합니다.
  • connection이 필요한데 없을 때, connection 개수가 maximumPoolSize를 넘지 않는다면 새로운 connection을 만듭니다.
  • 유휴 connection이 idleTimeout만큼 살아있었다면, 종료시킵니다.
  • connection 중에서 maxLifeTime만큼 살아있었다면, 종료시킵니다. 물론 현재 실행중인 건 끝날 때까지 보류합니다.
  • 종료된 connection으로 인해서 현재 connection 개수가 minimumIdle보다 작다면, 새로 connection을 만듭니다.
  • 데이터베이스에 설정된 wait_timeout으로 인해 만들어둔 connection들이 죽지 않도록 keepaliveTime 에 설정된 주기마다 데이터베이스로 살아있다는 신호를 보냅니다. 미처 신호를 늦게 보내서 이미 connection이 종료된 후일 수 있지만, 어차피 minimumIdle만큼 유지되도록 생성될 것이기 때문에 걱정할 것 없습니다.

❗️ 고려할 것

고려할 것은 많겠지만, 공식 문서에서 권장하는 하나의 조건을 설명하고 넘어가볼까 해요!

minimumIdle은 별도로 설정하지 않고, maximumPoolSize와 동일하게 설정하여 pool size를 고정하는 것을 추천합니다.

minimumIdel이 maximumPoolSize보다 작다면, connection 개수는 유동적으로 움직이게 됩니다. 필요한 곳이 많다면, maximum까지 만들었다가 끝나면 minimum까지 줄어들겠죠.
그렇게 되면, 필요할 때마다 생성되어야하기 때문에 응답이 늦어질 수 있습니다. 공식 문서에서 추천하는 방식대로라면 minimum과 maximum을 동일하게 설정하여 고정되면, 항상 동일한 커넥션 개수를 유지하게 됩니다. 물론 이건 상황에 따라 달라질 수 있겠지만, 일반적으로는 고려해보면 좋을 요소인 것 같습니다.

Connection pool에서 Connection은 언제 가져올까?

JPA와 MyBatis에서 모두 테스트해봤을 때, 둘 다 @Transactional 어노테이션 사용 여부에 따라 connection 획득 시점이 달라진 걸 확인할 수 있었습니다.

상황Connection 가져오는 시점
@Transactional 없음실제 SQL 실행 시점 (즉, Statement 실행 시)
@Transactional 있음트랜잭션이 시작되는 즉시 Connection을 가져옴

왜 @Transactional 어노테이션 유무에 따라 connection 획득 시점이 달라질까?

획득 시점이 달라지는 이유에 앞서 connection을 획득하는 과정을 먼저 설명드릴게요!

🔗 Connection 획득 과정

트랜잭션이 시작되면, 아래와 같은 과정을 통해 connection을 가져오게 됩니다.

  • AbstractPlatformTransactionManager는 기존에 트랜잭션이 있으면 참여하고, 없으면 새로운 트랜잭션 시작
  • 새로운 트랜잭션을 시작할 경우, JpaTransactionManagerdoBegin() 메서드를 호출

메서드에서 connection을 가져오는 부분은 바로 여기입니다! 즉, 참여하고 있는 트랜잭션이 없을 경우, 트랜잭션을 시작하고 그 때 호출되는 doBegin() 메서드에서 새로운 connection을 얻어오는거죠.

그래서 이게 무슨 상관이냐! 왜 @Transactional 어노테이션 사용 유무에 따라 달라지는 것이냐! 이제 설명드리겠습니다.

@Transactional 을 사용하든 안하든 트랜잭션이 생성될 때 connection을 가져오는 것은 맞는데요. 트랜잭션이 시작되는 시점이 다르기 때문입니다.

상황Transaction이 시작되는 시점
@Transactional 없음실제 SQL 실행 시점 (즉, Statement 실행 시)
@Transactional 있음메서드가 수행되는 시점에 Connection을 가져옴

테스트로 확인해볼까요? 저는 아래와 같이 테스트를 위해 코드를 작성했습니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;
    private final ConnectionPoolMonitorService connectionPoolMonitorService;
    
    public String getUserNameById(long id) {
        log.info("-------- [UserService getUserNameById START] --------");
        connectionPoolMonitorService.printConnectionPoolStatus();
        Optional<User> user = userRepository.findById(id);
        if (user.isEmpty()) {
            throw new IllegalArgumentException("id에 해당하는 사용자가 없습니다.");
        }
        String userName = user.get().getName();
        log.info("-------- [UserService getUserNameById END] --------");
        return userName;
    }
}

어노테이션이 붙지 않은 경우 서비스가 시작되었다는 로그까지 찍히고, findById() 를 실행시킬 때 getTransaction() 이 호출됩니다. 반면 어노테이션이 붙은 경우, 서비스단의 메서드를 호출할 때 바로 트랜잭션이 시작됩니다.

너무 당연하게 알고 계신 분들도 있으시겠지만, 저는 실제로 테스트해보면서 확인한 건 처음이라 넣어봤습니다.

📍 요약

  • Spring에서는 트랜잭션이 시작될 때 트랜잭션 매니저가 DataSource 로부터 connection을 얻어옵니다.
  • @Transactional 어노테이션의 유무로 인해 트랜잭션이 시작되는 시기가 달라지면서 connection 획득 시점도 달라지게 됩니다.
  • readOnly를 true로 한다고 해도 마찬가지로 서비스의 메서드가 시작되는 그 시점에 connection을 획득합니다.

그럼 여기서 생각해볼만한 거리가 있어요! @Transactional 어노테이션을 사용하는 경우, 서비스 메서드가 시작되는 시점에 connection을 가져오는 게 효율적일까?🧐

Lazy connection

아래와 같은 상황들을 고려해봅시다. 이런 경우에는 실제 쿼리를 수행하려고 할 때 connection을 가져오는 게 효율적일 수도 있습니다. connection은 한정되어있기 때문에 너무 오래잡고 있는 건 좋지 않기 때문입니다.

  • 특정 조건에 따라 DB에 접근할 수도 있고, 안할 수도 있는 경우
  • 초기에는 다른 비즈니스 로직을 실행하고, DB 호출이 늦게 발생하는 경우. 예를 들어 이미지나 동영상을 S3에 업로드하고 인코딩 등을 모두 수행한 후에 결과를 저장한다고 하면, 그 시간동안에도 connection을 점유하게 된다.
  • 읽기 전용 트랜잭션 (Read-Only Transactions)에서 Second-Level Cache를 사용하는 경우. 캐시된 데이터를 사용하여 DB 접근이 필요하지 않을 수 있음

LazyConnectionDataSourceProxy

connection을 가져올 때 proxy로 감싼 객체를 반환해서 statement가 만들어질 때 connection을 가져올 수 있도록 해준다고 합니다.
자세한 사용방법은 생략하도록 하겠습니다. 더 궁금하신 분은 아래 문서 참고하시기 바랍니다.
https://docs.spring.io/spring-framework/docs/6.2.3/javadoc-api/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.html

참고

2개의 댓글

comment-user-thumbnail
2025년 2월 23일

차근차근 너무 글을 잘 정리해주셔서 이해가 잘 되네요! 감사합니다.

1개의 답글