커넥션 풀이 꽉 차서 동작하지 않는 문제

dev-jjun·2023년 8월 13일
1

트러블 슈팅

목록 보기
2/8

오류 사항

org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection

문제 원인

JPA 관련 문제 예상

  • LAZY 로드 대상이 늘어날수록 서버의 DB Connetion Pool은 빈곤해지고 성능 감소로 이어질 가능성이 크다.
  • HikariCP 커넥션 누수 → 어디선가 커넥션을 열어둔 상태로 유지하여, 반환하지 않는다.

결론은?!

빠밤

정답은 RDS의 DatabaseConnection 지표에서 찾을 수 있었다..

결국, 각각의 개발자가 API 서버를 켜서 수행하는 경우에 인당 미리 지정해둔 커넥션 풀(default: 10개)만큼이 잡혀있는데, 이 수가 최대치를 초과하면서 트랜잭션 에러 및 속도가 저하되는 문제가 발생한 것이다.

*스케줄러에서의 Pool과 HikariCP Pool은 다른 개념!

  • SchedulerConfig의 명확한 의미
    @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()를 통해 스케줄러를 초기화합니다.

      스케줄러는 이러한 설정을 기반으로 스레드 풀을 생성하고 작업을 수행한다. 설정한 쓰레드 풀 크기에 따라 동시에 실행 가능한 작업의 수가 결정되며, 쓰레드 풀이 꽉 차게 되면 거부된 작업을 처리하게 되는데, 이 설정에 따라 스케줄러가 실행되는 작업의 동시성 및 관리 방식이 달라지는 것이다.

🚨 스케줄링 작업이 DBCP에도 영향을 주겠지?!

스케줄링 작업이 더 많은 리소스를 사용하면서 데이터베이스 연결 관리에 필요한 리소스가 부족해질 수 있다. 또는 데이터베이스 연결을 관리하기 위한 HikariCP가 스케줄링 작업으로 인해 블로킹될 수 있어서 데이터베이스 연결 관리가 지연될 수 있다.

완전히 공통된 풀을 사용한다고 보기에는 어렵지만, 충분히 비동기로 이루어지는 스케줄링 작업이 데이터베이스 커넥션에 영향을 줄 수 있다는 것이다!

*보통 pool size를 늘려서 배치에 적용하기도 하는데, 배치 작업은 특정 스레드 수를 지정할 수 있고 처리되는 작업의 수를 예상하여 지정해둘 수 있기 떄문이다.

해결 방안

TRY1. HikariCP 적용하기 → 옵션 수동 설정

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 등의 옵션값 지정

  • HikariCP 옵션의 의미
옵션기능기본값
maximum-pool-size풀에 유지시킬 수 있는 최대 커넥션 수 → 풀의 커넥션 수가 옵션 값에 도달하게 되면 idle인 상태는 존재하지 않음default: 10
connection-timeout풀에서 커넥션을 얻어오기 전까지 기다리는 최대 시간 → 허용가능한 대기시간을 초과하면 SQLException을 던짐default: 30000 (30s), 최소: 250ms
leak-detection-threshold커넥션이 누수 로그메시지가 나오기 전에 커넥션을 검사하여 풀에서 커넥션을 내보낼 수 있는 시간default: 0 (이용X), 최소: 2000ms
validation-timeoutvalid 쿼리를 통해 커넥션 유효성 검사에 사용되는 timeoutdefault: 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-commitdefault: 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를 정확히 지정해두는 것이 좋다.
    • 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 공식

    https://hudi.blog/dbcp-and-hikaricp/

TRY2. 트랜잭션 커넥션이 close 되지 않은 부분 찾아서 수정

스케줄링 작업에서 영속성 컨텍스트의 관리 대상에서 제외되고, 커밋이 반영되지 않는 문제 등에 의해 임의로 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이 된 경우도 고려하도록 하였다.

TRY3. RDS 인스턴스 삭제

RDS 사용량을 추적해보았을 때, 위와 같이 커넥션이 t2-micro의 기준으로 max인 66개에 달하는 수만큼 급증한 것을 볼 수 있었다.

RDS를 재부팅해보고, 초기화하려 했지만 이미 Too Many Connections로 연결 자체가 안되니 close() 할 수도 없었다 😢

결국 스냅샷을 생성해서 현재까지의 데이터들을 백업해두고, 새로 생성하여 복원하는 방식으로 노가다식 접근을 했고, 다시 RDS를 생성한 만큼 똑같은 문제가 발생하지 않도록 제대로 커넥션 풀 옵션을 지정하고 누수를 막는 방향으로 리팩토링하였다!!!

TRY4. AWS RDS의 옵션 설정 변경

(MySQL / MariaDB) Too many connections 해결 (max connections 오류)

RDS 파라미터 그룹 설정 변경

  • time_zone : Asia/Seoul
  • characterset : utfmb4
  • collation_server, collation_connection : utf8mb4_general_ci
  • wait_timeout, interactive_timeout 모두 180(3분)으로 설정 ⭐ 이 설정이 현재 트러블 슈팅에서 가장 중요한 부분인데, Too Many Connections가 또 다시 뜨지 않게 하기 위해 DB 자체에서 커넥션 개수를 관리하도록 설정하는 것이다.
    1. 일정 시간 요청이 없는 커넥션을 끊자! (interactive_timeout)

      → interactive 모드란? ‘mysql>’과 같은 프롬프트가 있는 콘솔 or 터미널 모드

      → max-life-time 값보다 적지 않은 선에서 적절히 조절

    2. 커넥션이 닫히기 전에 기다리는 시간을 짧게 설정하자! (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)

[Spring boot] Hikari CP 적용 & 커넥션 누수 이슈

profile
서버 개발자를 꿈꾸며 성장하는 쭌입니다 😽

1개의 댓글

comment-user-thumbnail
2023년 8월 13일

개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.

답글 달기