모르지 않다는 것은 아는것과 다르다.

Jdbc

HikariCP 코드 분석하기 4편 (Connection 점유, Connection 반납)

채마스 2023. 1. 22. 00:00

개요

  • 이번 편에서는 커넥션을 점유하고 반납하는 과정을 알아보자.
  • 먼저, 개념을 설명한 뒤, 예시를 들어 디버깅을 해보기로 하자.

 

커넥션 점유(개념)

  • connection.getConnection() -> HikariDataSource.getConnection() -> HikariPool.getConnection() -> ConcurrentBag.borrow()
  • 1편에서 설명한 것처럼 커넥션을 점유하는 과정은 borrow() 메소드를 통해서 이루어지며, 크게 3가지 과정으로 구성된다.

  1. 먼저 threadList를 검사해서 해당 Thread가 커넥션 풀에 방문한 이력이 있는지 검사하고 이력이 있다면 SharedList 까지 보지 않고 빠르게 커넥션을 반환해 준다. (마치 캐시처럼)
    • 물론 해당 커넥션이 NOT_IN_USE(idle) 상태인 경우에만 반환 가능하다.
    • IN_USE(active) 상태라면 sharedList를 검사한다.
  2. threadList에서 커넥션을 반환받지 못했다면, sharedList에서 커넥션을 반환하려고 시도한다.
    • 현재 sharedList에 있는 커넥션 중에서 NOT_IN_USE(idle) 상태인 커넥션을 반환해 준다.
    • 만약 모든 커넥션이 IN_USE(active) 상태라면 handOffQueue로 이동한다.
  3. sharedList에서도 커넥션을 반환받지 못했다면 해당 Thread는 handOffQueue 에서 다른 Thread가 커넥션을 handOffQueue로 반환해 줄 때까지 pooling을 시도한다.
    • 설정한 connectionTimeout 시간안에 커넥션을 반환받지 못하면, 예외를 던진다.
    • 다음에 나올 커넥션 반환부분에서 handOffQueue에서 대기하고 있는 Thread가 있는지 검사하고 있다면 handOffQueue로 커넥션을 반환해주는데, 반환해 주는 순간 커넥션을 빠르게 반환 받을 수 있다.

 

커넥션 반납(개념)

  • connection.close() -> ProxyConnection.close() -> PoolEntry.recycle -> HikariPool.recycle -> ConcurrentBag.requite()
  • 1편에서 설명한 것처럼 커넥션을 반납하는 과정은 requite() 메소드를 통해서 이루어지며, 크게 3가지 과정으로 구성된다.

  1. 먼저 커넥션 상태를 IN_USE(active) 에서 NOT_IN_USE(idle) 로 바꾼다.
  2. 현재 handOffQueue에서 커넥션을 기다리는 Thread가 있다면, handOffQueue로 커넥션을 반환시킨다.
  3. 기다리는 커넥션이 없다면, threadList에 이력을 남긴다.
    • threadList의 size는 50이다.

 

예시

  • 아래와 같이 4가지 예시를 들어보도록 하자
  • DataSource는 아래와 같은 설정으로 등록 되었다.

  • maximumPoolSize는 3으로 설정했다.
  • connectionTime은 디버깅을 편하게 보기위해서 길게 설정했다. (디폴트로 두면 connection timeout 에러가 너무 자주난다..ㅎㅎ)

 

예시1 (커넥션 점유)

  • 가정: Thread1이 C1를 사용한 이력이 있고, 현재 Thread2가 C1, C3를 점유하고 있다. -> 여기서 Thread1이 Connection을 요청한다.
  • 결론: C1이 이미 사용중이기 때문에, threadList에서 커넥션을 반환받지 못하고 sharedList에서 커넥션을 반환 받는다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class HikariExController {
    private final DataSource dataSource;
    private Connection c1;
    private Connection c2;
    private Connection c3;

    /**
     * 1. Thread1이 Connection Pool에서 C1을 방문한 이력이 있음
     * 2. Thread2가 현재 C1, C3를 점유하고 있는 상태
     * 3. Thread1이 Connection 요청
     *
     * @throws InterruptedException
     */
    @GetMapping("/ex1")
    public void ex2() throws InterruptedException {

      Thread thread1 = new Thread(() -> {
        try {
          c1 = dataSource.getConnection();
          log.info("thread1에서 커넥션 사용: {}", c1);
          DataSourceUtils.releaseConnection(c1, dataSource);
          log.info("thread1에서 커넥션 반환: {}", c1);

          Thread.sleep(5000); //5초 대기
          dataSource.getConnection();

        } catch (SQLException | InterruptedException e) {
            e.printStackTrace();
        }
      });

      Thread thread2 = new Thread(() -> {
        try {
          c1 = dataSource.getConnection();
          log.info("thread2에서 커넥션 사용: {}", c1);
          Thread.sleep(500);
          c3 = dataSource.getConnection();
          log.info("thread2에서 커넥션 사용: {}", c3);
        } catch (SQLException | InterruptedException e) {
          e.printStackTrace();
        }
      });

      thread1.start();
      Thread.sleep(1000); // 1초 대기
      thread2.start();
  }
}
  • 위 코드의 내용은 아래와 같다.
  • 먼저 Thread1에서 커넥션을 사용한 뒤 DataSourceUtils.releaseConnection(c1, dataSource) 를 통해서 커넥션을 반환한다.
  • 그 뒤에 5초를 대기한다. -> 5초를 대기하는 동안 Thread2에서 커넥션 2개를 점유한다.
  • 아래와 같이 dataSource.getConnection()에 브레이크 포인트를 잡아두고 로그를 보면 Thread1이 470e84f8 를 사용하고 pool에 반납한 것을 확인할 수 있다. -> 그 뒤에 Thread2가 470e84f8, 76f7c29f 커넥션을 점유중이다.

  • 5초가 지난 후에 다시 Thread1에서 커넥션을 요청할 것이다. -> dataSource.getConnection()에 브레이크 포인트를 잡아두고 HikariDataSource.getConnection() -> HikariPool.getConnection() -> borrow()메소드로 타고 들어가보자.

  • 현재 Thread2에서 커넥션 2개(470e84f8, 76f7c29f)를 점유중이기 때문에 IN_USE 상태인 것을 확인할 수 있다.
  • 위에 그림에서는 Thread2가 커넥션을 가져간 것처럼 보이지만, 실제로는 Connetion을 담고있는 PoolEntry에서 state값만 IN_USE 상태로 바꾸는 것으로 사용가능 여부를 결정한다.

  • 현재 Connection을 요청한 쓰레드는 Thread1이고 Thread1은 과거에 470e84f8 커넥션을 사용한 이력이 있기 때문에 470e84f8를 threadList에서 먼저 반환 받기를 윈하지만, 470e84f8이 IN_USE 상태이기 때문에 threadList에서 커넥션을 반환 받는것에 실패한다.

  • threadList에서 커넥션을 반환 받지 못했기 때문에 sharedList에서 현재 커넥션 상태가 NOT_IN_USE인 커넥션이 있는지 확인하고 있다면 반환받는다. 현재는 NOT_IN_USE 상태인 433118c1 커넥션을 반환받는다.

 

예시2 (커넥션 점유)

  • 가정: Thread1이 C1를 사용한 이력이 있고, 현재 Thread2가 C1, C2, C3를 점유하고 있다. -> 여기서 Thread1이 Connection을 요청한다.
  • 결론: threadList, sharedList 모두에서 커넥션을 반환받을 수 없기 때문에 handOffQueue에서 커넥션을 기다리고 Thread2가 커넥션을 반납하는 순간 handOffQueue에서 커넥션을 반환받는다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class HikariExController {
    private final DataSource dataSource;
    private Connection c1;
    private Connection c2;
    private Connection c3;


    /**
     * 1. Thread2가 3개의 Connection을 모두 점유중
     * 2. Thread1이 Connection 요청 -> handOffQueue에서 대기
     * 3. Thread2가 Connection 1개 반환 -> Thread1이 handOffQueue에서 커넥션 1개 점유
     *
     */
    @GetMapping("/ex3")
    public void ex3() {
        Thread thread1 = new Thread(() -> {
            try {
                //c1 사용후 반납
                c1 = dataSource.getConnection();
                log.info("thread1에서 c1 사용: {}", c1);
                DataSourceUtils.releaseConnection(c1, dataSource);
                log.info("thread1에서 c1 반환: {}", c1);
                Thread.sleep(4000);

                Connection newConnection = dataSource.getConnection();
                log.info("thread2에서 connection 사용: {}", newConnection);

            } catch (SQLException | InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                c1 = dataSource.getConnection();
                log.info("thread2에서 c1 사용: {}", c1);

                c2 = dataSource.getConnection();
                log.info("thread2에서 c2 사용: {}", c2);

                c3 = dataSource.getConnection();
                log.info("thread2에서 c3 사용: {}", c3);

                //70초 후에 c1 반환
                Thread.sleep(70000);

            } catch (SQLException | InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        Thread.sleep(1000);
        thread2.start();
    }
}
  • 위 코드의 내용은 아래와 같다.
  • 먼저 thread1이 커넥션을 사용하고 반환한다.
  • 그 뒤에 4초간 대기한다. -> 그 동안 thread2가 세개의 커넥션을 모두 점유한다.
  • 2초가 지난 후에 thread1이 커넥션을 요청한다.
  • 70초 후에 thread2가 c2를 반환한다.
  • Connection newConnection = dataSource.getConnection(); 에 브래이크 포인트를 잡아두고 로그를 보면 아래와 같다.

  • thread1은 1ba7b0d2 커넥션을 쓰고 반납했다. -> 4초 후에 커넥션을 요청한다.
  • 그 동안 thread2는 1ba7b0d2, 578252b8, 1eb819cd 커넥션을 모두 점유중이다.
  • thread2는 70초 후에 578252b8를 반환한다.
  • Connection newConnection = dataSource.getConnection(); 에 브래이크 포인트를 잡아두고 HikariDataSource.getConnection() -> HikariPool.getConnection() -> borrow()메소드로 타고 들어가 보자.

  • thread1이 1ba7b0d2를 사용한 이력이 있기때문에 1ba7b0d2를 threadList에서 반환 받기를 원하지만 현재 사용중이기 때문에 반환받지 못한다.

  • threadList에서 커넥션을 반환 받지 못했기 때문에 sharedList에서 사용가능한 커넥션을 조회하지만 현재 3개의 커넥션 모두 사용중이라 sharedList에서도 반환받지 못한다.

  • threadList, sharedList 둘 모두에서 커넥션을 반환받지 못했기 때문에 handOffQueue에서 커넥션이 반납될 때까지 handOffQueue에서 커넥션을 Pooling한다.
  • 153번째 라인에 브레이크 포인트를 찍어두고 대기하면, 70초 후에 thread2가 578252b8를 반납하고, thread1는 handOffQueue에서 반납된 578252b8를 반환받는 것을 확인할 수 있다.
  • 커넥션을 반납하는 과정은 예시3,4에서 좀 더 자세하게 알아보자.

 

예시3 (커넥션 반환)

  • 가정: Thread1이 C1를 사용 후 반환, handOffQueue에서 대기하고 있는 스레드는 없음
  • 결론: handOffQueue에 대기하고 있는 스레드가 없기 때문에 커넥션을 sharedList로 반환하고 threadList에 이력을 남긴다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class HikariExController {
    private final DataSource dataSource;
    private Connection c1;
    private Connection c2;
    private Connection c3;

    @GetMapping("/ex3")
    public void ex3() {
        Thread thread1 = new Thread(() -> {
            try {
                c1 = dataSource.getConnection();
                log.info("thread1에서 커넥션 사용: {}", c1);
                DataSourceUtils.releaseConnection(c1, dataSource);
                log.info("thread1에서 커넥션 반환: {}", c1);

            } catch (SQLException e) {
                e.printStackTrace();
            }
        });
        thread1.start();
    }  
}

  • 먼저 커넥션의 상태를 IN_USE -> NOT_IN_USE로 변경한다. -> sharedList로 반환한다는 것과 같은 의미이다.
  • 다음으로 handOffQueue에서 대기하고 있는 스레드가 있는지 검사한다.
  • 현재는 없기 때문에 넘어간다. -> 마지막으로 threadList에 커넥션을 사용한 이력을 남긴다. -> 위에서 볼 수 있듯이 50개 이상으로는 남길 수 없다.

 

예시4 (커넥션 반환)

  • 가정: Thread2가 커넥션 3개를 모두 점유하고 있는 상태에서 Thread1이 커넥션을 요청했고, 그 뒤에 Thread2가 커넥션 1개를 반환한다.
  • 결론: 현재 Thread1가 handOffQueue에서 대기하고 있기 때문에 thread2는 handOffQueue로 커넥션을 반환하고, thread1는 handOffQueue에서 커넥션을 빠르게 반환받는다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class HikariExController {
    private final DataSource dataSource;
    private Connection c1;
    private Connection c2;
    private Connection c3;

    @GetMapping("/ex4")
    public void ex4() throws InterruptedException {
        Thread thread2 = new Thread(() -> {
            try {
                c1 = dataSource.getConnection();
                log.info("thread2에서 c1 사용: {}", c1);

                c2 = dataSource.getConnection();
                log.info("thread2에서 c2 사용: {}", c2);

                c3 = dataSource.getConnection();
                log.info("thread2에서 c3 사용: {}", c3);

                //30초 후에 c2 반환
                Thread.sleep(20000);
                DataSourceUtils.releaseConnection(c2, dataSource);
                log.info("thread2에서 c2 반환 {}", c2);

            } catch (SQLException | InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread1 = new Thread(() -> {
            try {
                Connection newConnection = dataSource.getConnection();
                log.info("thread1에서 커넥션 획득: {}", newConnection);

            } catch (SQLException e) {
                e.printStackTrace();
            }
        });


        thread2.start();
        Thread.sleep(2000);
        thread1.start();
    }   
}
  • 현재 thread2에서 커넥션 3개를 모두 점유하고 있다.
  • 그렇기 때문에 thread1이 커넥션을 요청하면, 아래와 같이 handOffQueue에서 커넥션을 Pooling하고 있다.

  • 그리고 DataSourceUtils.releaseConnection(c2, dataSource); 를 통해 커넥션을 반환하는 시점에 ConcurrentBag.requite() 메소드가 호출된다.

  • 위와 같이 커넥션의 상태를 NOT_IN_USE로 바꾼다.
  • 그 다음 waiters의 수를 체크한다. -> 현재 thread1이 handOffQueue앞에서 대기하고 있기 때문에 waiters가 1인 것을 확인할 수 있다.
  • waiters 가 있기 때문에 handoffQueue.offer() 메소드가 호출된다.

  • handoffQueue.offer()가 호출되었기 때문에 handoffQueue.pool()하고 있는 thread1이 커넥션을 반환받은 것을 확인할 수 있다.
  • 전체 로그를 보면 아래와 같다.

  • thread2에서 c2(18283c31)을 반환했고, thread1에서 c2(18283c31)을 반환받은 것을 확인 할 수 있다.

 

예시5

  • 아래와 같이 maximumPoolSize를 10으로 설정하고, minimumIdle 수를 3으로 설정하고 커넥션의 숫자를 테스트해보자.

  • 위와 같이 옵션을 설정하면, 처음 커넥션풀이 생성될 때 커넥션이 3개만 만들어진다.
  • 이후에 Pool에 커넥션이 부족해서 sharedList에서도 커넥션을 반환받을 수 없을때 커넥션이 추가된다.
  • 아래의 예시를 보면 커넥션을 3개 사용하고 반환하지 않은 상태에서 dataSource.getConnection()을 통해서 커넥션을 하나더 요청했다.

  • 요청하기 직전에 상태를 보면 maximumPoolSize가 10이더라도 minimumIdle가 3이기 때문에 sharedList에 3개의 커넥션만 있는 것을 확인할 수 있다.
  • 또한, 3개의 커넥션이 사용중이기 때문에 모두 IN_USE 상태이다.
  • 이후에 아래와 같이 커넥션을 다시 요청하면 sharedList에 커넥션이 1개더 추가된 것을 확인할 수 있다.

  • 이전에 작은 프로젝트의 개발환경에서 Azure database중 가장 저렴한 모델을 사용한 적이 있다. 그 당시 해당 database 모델은 최대 커넥션을 50개 까지만 제공해 주었다.
  • 하지만, 개발자가 7명이 붙었고, default maximumPoolSize가 10이기 때문에, 커넥션이 모자라서 나머지 2명이 서버구동에 실패한 적이 있었다.
  • 이런 경우에 위와 같은 설정으로 개발을 해도 나쁘진 않을 것 같다는 생각이 들었다. 하지만, 운영환경에서는 maximumPoolSize를 10으로 두고 minimumIdle는 설정하지 않는 것을 권장한다. 이유는 당연히 DB로 부터 커넥션을 맺어서 Pool에 추가하는 비용이 크기 때문이다.

 

 

References