org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
빠밤
정답은 RDS의 DatabaseConnection 지표에서 찾을 수 있었다..
결국, 각각의 개발자가 API 서버를 켜서 수행하는 경우에 인당 미리 지정해둔 커넥션 풀(default: 10개)만큼이 잡혀있는데, 이 수가 최대치를 초과하면서 트랜잭션 에러 및 속도가 저하되는 문제가 발생한 것이다.
@Bean
public TaskScheduler scheduler() {
scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(POOL_SIZE);
scheduler.setThreadNamePrefix("현재 쓰레드 풀-");
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
scheduler.initialize();
return scheduler;
}
Pool Size (쓰레드 풀 크기)
POOL_SIZE
변수에 지정한 값만큼의 쓰레드를 가지는 쓰레드 풀을 생성
→ 이는 스케줄러가 동시에 실행할 수 있는 작업의 수를 결정
Thread Name Prefix (쓰레드 이름 접두사)
"현재 쓰레드 풀-"
과 같은 접두사가 있는 쓰레드 이름을 가진 쓰레드들이 쓰레드 풀에 생성
→ 이는 디버깅 및 모니터링 목적으로 유용할 수 있습니다.
Rejected Execution Handler (거부된 작업 처리)
거부된 작업(쓰레드 풀이 가득 찼을 때 발생하는 경우)을 어떻게 처리할지를 설정
AbortPolicy | 새 작업을 거부하고 예외를 발생시킴 |
---|---|
CallerRunsPolicy | 거부된 작업을 직접 실행 |
DiscardOldestPolicy | 가장 오래된 미처리 요구를 파기해 execute 재실행 |
DiscardPolicy | 거부된 작업을 통지 없이 파기 |
Initialize: scheduler.initialize()
를 통해 스케줄러를 초기화합니다.
스케줄러는 이러한 설정을 기반으로 스레드 풀을 생성하고 작업을 수행한다. 설정한 쓰레드 풀 크기에 따라 동시에 실행 가능한 작업의 수가 결정되며, 쓰레드 풀이 꽉 차게 되면 거부된 작업을 처리하게 되는데, 이 설정에 따라 스케줄러가 실행되는 작업의 동시성 및 관리 방식이 달라지는 것이다.
스케줄링 작업이 더 많은 리소스를 사용하면서 데이터베이스 연결 관리에 필요한 리소스가 부족해질 수 있다. 또는 데이터베이스 연결을 관리하기 위한 HikariCP가 스케줄링 작업으로 인해 블로킹될 수 있어서 데이터베이스 연결 관리가 지연될 수 있다.
완전히 공통된 풀을 사용한다고 보기에는 어렵지만, 충분히 비동기로 이루어지는 스케줄링 작업이 데이터베이스 커넥션에 영향을 줄 수 있다는 것이다!
*보통 pool size를 늘려서 배치에 적용하기도 하는데, 배치 작업은 특정 스레드 수를 지정할 수 있고 처리되는 작업의 수를 예상하여 지정해둘 수 있기 떄문이다.
Spring Boot 2.0에서는 Connection Pool의 기본을 hikariCP를 사용하도록 되어 있다.
HikariCP에서 기본적으로 30초 이상 응답이 지연될 경우, 강제로 connection을 끊어버린다. 지연 시간을 더 짧게 가져갈수록 성능 측면에서 좋아지는 것이다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url:
username:
password:
hikari:
connection-timeout: 15000
maximum-pool-size: 100
max-lifetime: 240000
leak-detection-threshold: 10000
connection-timeout, max-lifetime, maximumPoolSize 등의 옵션값 지정
옵션 | 기능 | 기본값 |
---|---|---|
maximum-pool-size | 풀에 유지시킬 수 있는 최대 커넥션 수 → 풀의 커넥션 수가 옵션 값에 도달하게 되면 idle인 상태는 존재하지 않음 | default: 10 |
connection-timeout | 풀에서 커넥션을 얻어오기 전까지 기다리는 최대 시간 → 허용가능한 대기시간을 초과하면 SQLException을 던짐 | default: 30000 (30s), 최소: 250ms |
leak-detection-threshold | 커넥션이 누수 로그메시지가 나오기 전에 커넥션을 검사하여 풀에서 커넥션을 내보낼 수 있는 시간 | default: 0 (이용X), 최소: 2000ms |
validation-timeout | valid 쿼리를 통해 커넥션 유효성 검사에 사용되는 timeout | default: 5000ms, 최소: 250ms |
minimum-idle | 아무런 일을 하지 않아도 이 옵션에 설정한 최소 size로 커넥션들을 유지해주는 설정 → 최적의 성능과 응답성을 요구한다면 이 값은 설정하지 않는 것을 권장 | default: maximum-pool-size와 동일 |
idle-timeout | 풀에서 일을 안 하는 커넥션을 유지하는 시간 **minimum-idle < maximum-pool-size 일 때만 설정 가능 | default: 600000 (10m), 최소: 10000ms |
max-lifetime | 커넥션 풀에서 살아있을 수 있는 최대 수명 시간 (사용 중이지 않은 경우에만 제거) → 풀이 아닌 커넥션 단위로 적용 : 풀에서 커넥션들이 대량으로 제거되는 것을 방지하기 위함 | default: 1800000 (30m), 최소: 0 (infinite lifetime) |
auto-commit | default: true |
→ 'org.springframework.boot:spring-boot-starter-jdbc’ 에 포함되어 있어 따로 의존성 라이브러리를 추가하지 않아도 된다!
참고 - https://freedeveloper.tistory.com/250
hikari:
pool-name: Hikari 커넥션 풀 # Pool
connection-timeout: 30000 # 30초(default: 30초)
maximum-pool-size: 10 # default: 10개
max-lifetime: 600000 # 10분(default: 30분)
leak-detection-threshold: 2000 # default: 0(이용X)
옵션 설정 시 주의할 점
maxLifeTime의 값은 mysql의 wait_timeout 보다 몇초정도 짧게 설정한다. (뒤에서 자세히 설명)
connection pool size를 thread 개수보다 넉넉히 가져가준다. (Hikari CP 데드락 이슈 https://techblog.woowahan.com/2663/)
minimumIdle의 기본 값은 maximumPoolSize이므로, idleTimeout을 설정해주지 않는 이상 따로 손보지 않아도 된다.
leakDetectionThreashold는 커넥션이 설정 시간보다 길게 잡고 있다면 누수로 판단하고 WARN 로그를 출력한다.
하나의 Conncection이 늘어날 떄마다 DB에 부하가 가지 않는지 등을 고려한다.
*https://github.com/brettwooldridge/HikariCP#gear-configuration-knobs-baby
*기본적으로 default 옵션으로 설정하는 것을 권장한다
HikariCP 공식
스케줄링 작업에서 영속성 컨텍스트의 관리 대상에서 제외되고, 커밋이 반영되지 않는 문제 등에 의해 임의로 Custom TransactionManager를 이용해 커밋하고 merge()를 해주었는데
에러 로그를 볼 때 커넥션 풀이 말도 안 되는 숫자로 쌓인 부분은 일반적인 API 호출이 아닌 스케줄링 작업에서 증가함을 알 수 있었다. 즉, 풀을 사용하고 반환하는 과정이 일반 API 호출에서는 잘 이루어지고 있었지만, 어느 부분에서 커넥션 풀의 누수가 생겨 커넥션이 계속해서 쌓이고 있었던 것으로 추정된다.
parentchild.addCount();
Parentchild pc = em.merge(parentchild);
transactionManager.commit(transactionStatus);
log.info("스케줄링 작업 예약 내 addCount 후 count: {}", pc.getCount());
QnA todayQnA = parentchild.getQnaList().get(parentchild.getCount() - 1);
**em.close();**
→ em.close() 부분을 변경
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try{
<!-- 이 부분이 주석 처리된 상태였다 -->
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
위 트랜잭션 매니저를 명시적으로 호출하여 트랜잭션을 관리하고, 커밋을 수행하는 예제 코드를 참고하여 일단 임시로 넣어둔 em.close() 호출 부분을 finally 구문으로 옮겨, rollback이 된 경우도 고려하도록 하였다.
RDS 사용량을 추적해보았을 때, 위와 같이 커넥션이 t2-micro의 기준으로 max인 66개에 달하는 수만큼 급증한 것을 볼 수 있었다.
RDS를 재부팅해보고, 초기화하려 했지만 이미 Too Many Connections로 연결 자체가 안되니 close() 할 수도 없었다 😢
결국 스냅샷을 생성해서 현재까지의 데이터들을 백업해두고, 새로 생성하여 복원하는 방식으로 노가다식 접근을 했고, 다시 RDS를 생성한 만큼 똑같은 문제가 발생하지 않도록 제대로 커넥션 풀 옵션을 지정하고 누수를 막는 방향으로 리팩토링하였다!!!
(MySQL / MariaDB) Too many connections 해결 (max connections 오류)
RDS 파라미터 그룹 설정 변경
일정 시간 요청이 없는 커넥션을 끊자! (interactive_timeout)
→ interactive 모드란? ‘mysql>’과 같은 프롬프트가 있는 콘솔 or 터미널 모드
→ max-life-time 값보다 적지 않은 선에서 적절히 조절
커넥션이 닫히기 전에 기다리는 시간을 짧게 설정하자! (wait_timeout)
→ mysqld와 mysql client가 연결을 맺은 후, 다음 쿼리까지 기다리는 최대 시간
*참고로, RDS는 재부팅해도 안에 있는 데이터가 날아가지 않는다!!
이렇게 설정해주니까 !! 서버를 실행할 때마다 기본적으로 10개의 커넥션이 생성되고, 평소에 로컬을 켜지 않은 상태로 둘 때는 다음과 같이 연결을 자체적으로 끊어내는 것을 볼 수 있었다.
HikariCP 를 이용해 DB 커넥션 풀을 관리하면서, 누수가 발생하지 않도록 주의하고 상황에 맞게 적절한 pool_size 등의 옵션값을 지정해줘야 한다.
- Entity Manager 사용 주의
- 트랜잭션이 없는 곳에서의 쿼리 주의
- wait_timeout, interactive_timeout 옵션값 설정으로 DB 자체의 커넥션 수 관리
- 커넥션 누수 감지 로깅 설정 → 감지를 설정하고, 조치는 우리가 직접 한다
Spring Boot Hikari Connection Pool 에러 핸들링
[Spring Boot] This connection has been closed
[AWS] RDS MySQL 언어 변경 (utf8 / utf8mb4, character-set, collation)
개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.