본문 바로가기
Java/Spring

커넥션풀과 데이터소스 이해하기 with HikariCP

by oneny 2023. 10. 3.

커넥션 풀

커넥션 풀은 데이터베이스 연결을 관리하는 데 사용되는 메커니즘이다. 데이터베이스 연결을 생성하고 닫는 과정은 비용이 많이 들기 때문에 애플리케이션에서 데이터베이스와의 연결을 효율적으로 관리하기 위해 연결 풀을 사용한다.

 

커넥션 풀 등장배경

데이터베이스 커넥션을 획득할 때는 다음과 같은 복잡한 과정을 거친다.

  1. 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회한다.
  2.  DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 물론 이 과정에서 3 way handshake 같은 TCP/IP 연결을 위한 네트워크 동작이 발생한다.
  3. DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW와 기타 부가정보를 DB에 전달한다.
  4. DB는 ID, PW를 통해 내부 인증을 완료하고, 내부에 DB 세션을 생성한다.
  5. DB는 커넥션 생성이 완료되었다는 응답을 보낸다.
  6. DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다.

이렇게 커넥션을 새로 만드는 것은 과정도 복잡하고 시간도 많이 소모되는 작업이다. DB는 물론이고 애플리케이션 서버에서도 TCP/IP 커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 하므로 커넥션을 매번 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 줄 수 있다.

이런 문제를 한 번에 해결하는 아이디어가 바로 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법이다.

 

커넥션 풀

애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다. 보통 얼마나 보관할 지는 서비스의 특징과 서버 스펙에 따라 다르지만 기본값으로 10개이다.

커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다. 따라서 애플리케이션 로직에서는 DB 드라이버를 통해서 새로운 커넥션을 획득하는 것이 아닌 커넥션 풀에 커넥션을 요청하면 이미 생성되어 있는 커넥션 중 하나를 반환한다. 그리고 사용된 커넥션을 종료하는 것이 아닌 다음에도 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환하면 된다. 여기서 주의해야할 점은 커넥션을 종료하는 것이 아닌 커넥션이 살아있는 상태로 커넥션 풀에 반환해야 한다는 것이다.

대표적인 커넥션 풀 오픈소스는 commons-dbcp2, tomcat-jdbc pool, HikariCP 등이 있다. 스프링 부트 2.0부터는 성능, 사용의 편리함, 안전성 측면에서 이미 검증이 되었기 때문에 기본 커넥션 풀로 HikariCP를 제공한다.

 

HikariCP

DB Connection은 애플리케이션 서버와 DB 서버 사이의 연결을 의미하기 때문에 애플리케이션 서버와 DB 서버 각각에서의 설정(configuration) 방법을 잘 알고 있어야 한다.

 

MySQL의 max_connections

MySQL에는 중요한 두 가지 파라미터가 있는데 첫 번째가 max_connections이다. max_connections은 DB 서버 설정으로 클라이언트와 맺을 수 있는 최대 connection 수를 나타낸다.

만약 max_connections 수가 4이고 DBCP의 최대 connection 수가 4인 경우에는 어떻게 될까? DB 서버 입장에서 보면 이미 max_connections 수만큼 이미 커넥션을 맺고 있다. 이런 상황에서 애플리케이션 서버로 트래픽이 많이 몰려오게 된다면 그 요청들을 처리하기 위해 바쁘게 애플리케이션 서버가 동작을 하기 때문에 커넥션 풀에 있는 커넥션도 계속해서 사용되고 있고, 애플리케이션 서버 자체의 CPU, 메모리 사용량이 많아지게 된다. 그래서 결국 애플리케이션 서버가 과부화가 걸리는 것을 방지하기 위해 다른 하나의 애플리케이션을 만들어 분산시키려고 하지만 이미 DB 서버의 max_connections만큼 이미 최대 맺을 수 있는 커넥션 수만큼 맺고 있기 때문에 새로 투입된 애플리케이션 서버와 커넥션을 맺고 싶어도 맺을 수 없는 상황이 발생할 수 있다.

이런 의미에서 max_connections 파라미터가 중요한 의미를 가진다. 이 값을 충분히 적절하게 잘 설정을 해줘야 지금처럼 신규로 애플리케이션 서버를 투입하거나 기존 애플리케이션의 DBCP의 커넥션 수를 늘린다고 할 때 에러가 발생하지 않고 정상적으로 동작할 수 있다.

 

MySQL의 wait_timeout

wait_timeout도 DB 서버의 설정인데 connection이 inactive할 때 다시 요청이 오기까지 얼마의 시간을 기다린 뒤에 close할 것인지를 결정한다. 만약 애플리케이션 서버에서 요청을 보내서 이제 커넥션을 끊을 것이라고 요청을 주면 그 뒤에 애플리케이션 서버와 DB 서버의 커넥션이 끊기게 된다. 하지만 이런 일반적인 상황이 아닌 애플리케이션 서버에서 다음과 같은 약간 이상한 상태로 연결 상태가 빠질 수 있다. 

  • 비정상적인 connection 종료
  • connection 다 쓰고 반환이 안됨
  • 네트워크 단절

DB 서버에서는 위와 같은 상황이 발생하더라도 알 수 없기 때문에 계속 요청이 오기를 기다리는 정상적으로 열려있는 커넥션이라고 생각할 수 있다. 이렇게 계속 DB 서버를 운영하게 되면 이런 상태가 점점 많아지다 보면 DB 서버에 영향이 갈 수 있기 때문에 적절한 시점에 이 문제를 해결할 수 있도록 해야 한다. 그 때 사용되는 파라미터가 wait_timeout이다. MySQL에서는 이 파라미터를 설정하면 마지막 요청을 기준으로 설정한 시간까지 요청을 기다리다 요청이 오지 않으면 DB 서버에서 커넥션 연결을 끊어 리소스를 반환할 수 있게 된다.

 

이 뒤부터는 DBCP 설정에 대해서 살펴보자.

 

maximumPoolSize

maximumPoolSize는 pool이 가질 수 있는 최대 connection 수를 나타낸다. 이 때의 connection은 idle과 active(in-use) connection을 합친 최대의 수를 나타낸다. maximumPoolSize 파라미터의 의미를 알아야 minimumIdle 파라미터가 어떻게 동작하는지를 이해할 수 있다.

 

minimumIdle

pool에서 유지하는 최소한의 idle connection 수이다. 여기서 idle은 커넥션이 연결되어 있지만 어떤 요청이 올 때까지 대기하고 있는 상태를 말한다. 즉, 유휴 자원(현재 사용되지 않는 자원)이 되는 커넥션의 수를 설정한다.

 

DBCP에서 idle connection 수가 minimumIdle 보다 작고, 전체 connection 수도 maximumPoolSize보다 작다면 신속하게 추가로 connection을 만든다. 여기서 알 수 있는 점은 maximumPoolSize 파라미터가 우선순위가 더 높다는 것이다. 예를 들어, minimumIdle이 2, maximumPoolSize가 4라면 DBCP가 어떻게 동작할까? 맨 처음 초기 상태는 minimumIdle을 2로 잡았기 때문에 DBCP의 커넥션 수는 2이다. 그런데 요청이 들어와서 그 요청을 하나의 커넥션이 사용되면 이 순간의 DBCP에서 idle 상태의 커넥션 수는 1개가 됐기 때문에 minimumIdle의 값 2보다 작기 때문에 추가로 하나 만들어줘야 한다. 

 

maximumPoolSize의 개수만큼 커넥션이 생성된 상황

이후에 계속해서 트래픽이 들어오게 되면 minimumIdle의 값보다 작은 경우 계속해서 생성해줘야 한다. 하지만 위와 같은 상황에서도 DBCP에서 idle 상태가 1개가 되었기 때문에 커넥션을 하나 더 만들어야 하지만 maximumPoolSize가 4이기 때문에 더 이상 커넥션은 만들지 않는다. 즉, maximumPoolSize이 minimumIdle보다 더 높은 우선순위를 갖는다고 할 수 있다.

 

만약 더 이상 트래픽이 들어오지 않는다면 일하던 커넥션들이 커넥션 풀에 들어오고 idle 상태가 된다. 그 때 minimumIdle의 값이 2이기 때문에 2개의 커넥션을 끊어주게 된다. HikariCP에서 minimumIdle 파라미터와 maximumPoolSize 파라미터가 서로 연관이 있다는 것을 이해해야 한다. minimumIdle의 권장사항이 있는데 기본값으로 maximumPoolSize와 동일하도록 설정하는 것이다. 이 값을 동일하게 하는 이유는 위 상황처럼 트래픽이 없다 트래픽이 몰려오게 되면 커넥션을 생성해야 하는데 생성하는 작업을 비싸기 때문에 생성하는 속도보다 트래픽이 더 빨리 밀려들어오면 애플리케이션이 제때 응답하지 못할 수 있기 때문에 애초에 처음부터 PoolSize를 고정해서 사용하는 것을 권장하고 있다.

 

maxLifetime

Pool에서 connection의 최대 수명을 나타낸다. DBCP에 있는 커넥션의 lifetime이 maxLifetime을 넘어서면 idle일 경우 Pool에서 바로 제거하고, active인 경우에는 Pool로 반환된 후 제거한다. 만약 DBCP의 사이즈를 4개로 고정으로 했었다면 커넥션이 없어지면서 바로 새로운 커넥션을 만들어서 추가를 하여 4개로 유지한다. 여기서 중요한 점은 Pool로 반환이 안되면 maxLifetime이 동작을 하지 않는다는 것이다.

 

예를 들어, maxLifetime을 60초 잡는다고 하더라도 해당 커넥션이 요청을 처리하기 위해 비즈니스 로직에서 획득해서 가져가고 요청을 다처리했음에도 불구하고 어떤 코드 상에 에러 등이 있어서 다시 커넥션 풀로 반환이 안되는 경우에는 active한 상태로 인식하기 때문에 최대 수명을 넘어서서도 제거되지 않고 살아있게 된다. 이러한 상황에서 MySQL DB 서버에서 wait_timeout 설정값을 60초로 잡는다면 해당 DB 서버에서는 애플리케이션에서 문제가 생겼다고 인식하고 해당 커넥션을 끊고 리소스를 반환한다. 그런데 그 이후에 커넥션을 가지고 있는 애플리케이션 로직이 다시 해당 커넥션을 가지고 요청을 보내면 이 요청은 DB 서버에서 이미 끊겼기 때문에 전달되지 않고 그 요청에 대해 Exception을 발생시킨다. 그래서 maxLifetime이 잘동작하게 하기 위해서는 다 쓴 커넥션은 Pool로 반환을 시켜주는 것이 중요하다! 따라서 비슷한 에러를 보게 되면 커넥션이 제 때 반환되지 않아 커넥션 누수 현상이 발생하지 않는지 의심해야한다.

 

또한 maxLifetime을 설정할 때에는 DB의 connection time limit(MySQL에서는 wait_timeout)보다 몇 초 짧게 설정해야 한다. 만약 DB의 wait_timeout이 60초, DBCP의 maxLifetime이 60초인 경우에는 어떻게 동작할까? 어떤 요청이 들어올 때 해당 커넥션이 lifetime이 59.5초인 상황에 유효하기 떄문에 애플리케이션 로직이 획득해 갔다고 가정해보자. 그리고 애플리케이션 서버에서 DB 서버로 요청을 보내는 중간에 60초가 되어 wait_timeout에 걸리면서 DB 서버에서는 해당 커넥션을 끊어버리게 되었다. 그리고 60.1초가 되었을 때 DB 서버에 도착을 한다면 이미 해당 커넥션은 끊겨있기 때문에 해당 요청은 정상적으로 처리되지 않을 수 있다.

 

connectionTimeout

애플리케이션 서버로 요청이 들어왔을 때 Pool에서 connection을 받기 위한 대기 시간을 나타낸다. 만약 애플리케이션 서버에 트래픽이 엄청나게 몰려오는 상황에서 모든 커넥션들이 active일 때 connectionTimeout이 30초인 경우에는 어떻게 동작할까? 어떤 요청들은 대기하다가 DBCP로부터 커넥션을 획득할 수도 있지만 다른 요청들은 계속 기다리다 30초를 넘겨 connectionTimeout이 동작을 하면서 더 이상 기다리지 않고 Exception을 받게 된다.

또한, 일반적인 사용자로부터 온 요청이라면 30초까지 기다리지 않고 많이 잡아도 10초 정도 기다렸다가 응답이 없으면 나가는 경우가 많다. 그러면 그 요청한 유저로부터 커넥션이 끊기게 되어 아무리 커넥션을 획득해서 DB로부터 응답받고 이를 API에서 응답한다고 하더라도 응답을 줄 수 없다. 따라서 적절하게 connectionTimeout을 설정하는 것도 중요하다.

 

DataSource

 

커넥션을 얻는 방법은 위에서 설명한 JDBC DriverManager를 직접 사용하거나, 커넥션 풀을 사용하는 등 다양한 방법이 존재한다. 예를 들어, 애플리케이션 로직에서 DriverManager를 사용해서 커넥션을 획득하다가 HikariCP 같은 커넥션 풀을 사용하도록 변경하면 커넥션을 획득하는 애플리케이션 코드도 함께 변경해야 한다. 따라서 커넥션을 획득하는 방법을 추상화한 것을 DataSource라고 할 수 있다.

 

자바에서는 javax.sql.DataSource라는 인터페이스를 제공하는데 이 DataSource가 커넥션을 획득하는 방법을 추상화하는 인터페이스이다. 따라서 애플리케이션 로직은 DataSource 인터페이스에만 의존하면 DriverManagerDataSource에서 HikariCP로 바꾸게 되더라도 코드만 변경하고 애플리케이션 로직은 변경하지 않아도 된다.

 

DataSource 적용

@Slf4j
public class MemberRepository {

    private final DataSource dataSource;

    public MemberRepository(DataSource dataSource) {
       this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
       String sql = "insert into member(member_id, money) value (?, ?)";

       Connection con = null;
       PreparedStatement pstmt = null;

       try {
          con = getConnection();
          pstmt = con.prepareStatement(sql);
          pstmt.setString(1, member.getMemberId());
          pstmt.setInt(2, member.getMoney());
          pstmt.executeUpdate();
          return member;
       } catch (SQLException e) {
          log.error("db error", e);
          throw e;
       } finally {
          close(con, pstmt, null);
       }
    }

    private void close(Connection con, Statement pstmt, ResultSet rs) {
       JdbcUtils.closeResultSet(rs);
       JdbcUtils.closeStatement(pstmt);
       JdbcUtils.closeConnection(con);
    }

    private Connection getConnection() throws SQLException {
       Connection con = dataSource.getConnection();
       log.info("get connection={}, class={}", con, con.getClass());
       return con;
    }
}

코드는 이전 게시글에서 DriverManager를 사용해서 커넥션 가져온 것을 리팩토링한 것이다. 이전 DriverManager 사용했을 때의 코드와 비교해보면 비즈니스 로직인 save() 메서드는 건들이지 않고 close() 메서드와 getConnection() 메서드 코드만 변경한 것을 확인할 수 있다. 또한, JDBCUtils 유틸 메서드를 사용하여 커넥션을 좀 더 편리하게 닫을 수 있다.

 

Test 코드도 정상적으로 통과한 것을 확인할 수 있다.

 

 

출처

DBCP(DB connection pool)의 개념부터 설정 방법까지! hikariCP와 MySQL을 예제로 설명합니다! 이거 잘 모르면 힘들..

스프링 DB 1편 - 데이터 접근 핵심 원리