개요
- 이번 편에서는 커넥션을 점유하고 반납하는 과정을 알아보자.
- 먼저, 개념을 설명한 뒤, 예시를 들어 디버깅을 해보기로 하자.
커넥션 점유(개념)
- connection.getConnection() -> HikariDataSource.getConnection() -> HikariPool.getConnection() -> ConcurrentBag.borrow()
- 1편에서 설명한 것처럼 커넥션을 점유하는 과정은 borrow() 메소드를 통해서 이루어지며, 크게 3가지 과정으로 구성된다.
- 먼저 threadList를 검사해서 해당 Thread가 커넥션 풀에 방문한 이력이 있는지 검사하고 이력이 있다면 SharedList 까지 보지 않고 빠르게 커넥션을 반환해 준다. (마치 캐시처럼)
- 물론 해당 커넥션이 NOT_IN_USE(idle) 상태인 경우에만 반환 가능하다.
- IN_USE(active) 상태라면 sharedList를 검사한다.
- threadList에서 커넥션을 반환받지 못했다면, sharedList에서 커넥션을 반환하려고 시도한다.
- 현재 sharedList에 있는 커넥션 중에서 NOT_IN_USE(idle) 상태인 커넥션을 반환해 준다.
- 만약 모든 커넥션이 IN_USE(active) 상태라면 handOffQueue로 이동한다.
- sharedList에서도 커넥션을 반환받지 못했다면 해당 Thread는 handOffQueue 에서 다른 Thread가 커넥션을 handOffQueue로 반환해 줄 때까지 pooling을 시도한다.
- 설정한 connectionTimeout 시간안에 커넥션을 반환받지 못하면, 예외를 던진다.
- 다음에 나올 커넥션 반환부분에서 handOffQueue에서 대기하고 있는 Thread가 있는지 검사하고 있다면 handOffQueue로 커넥션을 반환해주는데, 반환해 주는 순간 커넥션을 빠르게 반환 받을 수 있다.
커넥션 반납(개념)
- connection.close() -> ProxyConnection.close() -> PoolEntry.recycle -> HikariPool.recycle -> ConcurrentBag.requite()
- 1편에서 설명한 것처럼 커넥션을 반납하는 과정은 requite() 메소드를 통해서 이루어지며, 크게 3가지 과정으로 구성된다.
- 먼저 커넥션 상태를 IN_USE(active) 에서 NOT_IN_USE(idle) 로 바꾼다.
- 현재 handOffQueue에서 커넥션을 기다리는 Thread가 있다면, handOffQueue로 커넥션을 반환시킨다.
- 기다리는 커넥션이 없다면, 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
'Jdbc' 카테고리의 다른 글
HikariCP 코드 분석하기 3편(HikariCP 커넥션 풀 초기화 과정 디버깅) (0) | 2023.01.21 |
---|---|
HikariCP 코드 분석하기 2편 (HikariCP 커넥션 풀 초기화 과정) (0) | 2023.01.21 |
HikariCP 코드 분석하기 1편 (HikariCP란?) (0) | 2023.01.21 |
JOOQ (Java Object Oriented Querying) (0) | 2022.03.24 |
Spring JDBC (0) | 2022.02.27 |