grep

Engineering

Aurora MySQL의 숨겨진 idle close 동작 — HikariCP "Failed to validate connection" 추적기

Kuiper여기어때

2026년 6월 8일

원문에서 보기 ↗

1. 들어가며

안녕하세요. 여기어때컴퍼니 숙박플랫폼개선TF팀 카이퍼입니다.

운영 중에 “즉시 장애는 아니지만 분명 무언가 잘못된” 류의 WARN 로그를 만난 적이 있으신가요? 분당 단위로 쌓이는 모양새가 좋지 않은데, 원인이 바로 잡히지 않을 때 의외의 자리에서 답이 나오는 경우가 있습니다.

이번 글은 그런 로그 한 줄 — HikariPool - Failed to validate connection — 의 원인을 쫓다가 Aurora MySQL 이 표준 MySQL 과 다르게 동작하는 지점에 도달했던 기록입니다.

같은 로그를 마주친 분이라면 진단 과정과 발견한 원인이 도움될 수 있을 것 같아 정리해 공유합니다.

본 글은 다음을 다룹니다.

증상 — 어느 날의 WARN 로그

어느 어플리케이션의 로그에서 한 DB 의 HikariPool 에서 다음 WARN 이 분당 다수 발생하기 시작했습니다.

HikariPool - Failed to validate connection
com.mysql.cj.jdbc.exceptions.CommunicationsException: ...
  Caused by: java.sql.SQLNonTransientConnectionException:
  No operations allowed after connection closed.

서비스 동작에 즉각적인 장애는 없었지만, 로그 자체가 분당 단위로 누적되는 모양새가 좋지 않았습니다. 무엇보다 이 로그가 말하는 바를 그대로 읽으면 이상한 상황이었습니다.

Pool 이 idle connection 살아있는지 keepalive 로 점검했더니, 이미 server 쪽에서 끊긴 상태였다.

HikariCP 에는 keepaliveTime 이라는 옵션이 있고, 그 값은 30초로 설정되어 있었습니다. keepalive 가 주기적으로 ping 을 날려 connection 을 살아있게 유지하는 것이 그 옵션의 존재 이유인데, 정작 그 ping 시점에 connection 이 이미 끊긴 상태로 발견된다는 것은 server 가 keepalive 주기(30초) 보다 빨리 끊고 있다는 뜻이었습니다.

“keepalive 30s 가 있는데도 왜 dead 가 발견되는가.”

이 한 줄의 질문이 이 글의 출발점입니다.

2. 사전 지식 — MySQL 의 idle close 표준 동작

본격적인 디버깅 이야기 전에, MySQL 이 idle connection 을 끊는 표준 동작을 짧게 정리하고 가겠습니다. 이 표준이 있어야 다음 장의 가설들이 왜 합리적이었는지가 보입니다.

두 개의 timeout 변수

MySQL 에는 idle 종료와 관련된 두 개의 server 변수가 있습니다.

여기서 interactive / non-interactive 의 구분은 클라이언트가 연결 handshake 시 CLIENT_INTERACTIVE capability flag 를 set 했는지 여부로 결정됩니다.

handshake 시 session 변수 초기화

표준 MySQL 의 동작은 다음과 같습니다.

Client → Server: connection handshake
  ├─ CLIENT_INTERACTIVE flag = ON  → session.wait_timeout = global interactive_timeout
  └─ CLIENT_INTERACTIVE flag = OFF → session.wait_timeout = global wait_timeout

즉, 두 global 변수 중 하나가 connection 의 session.wait_timeout 으로 초기화되고, 이후 idle 종료 판정은 오직 session.wait_timeout 만 참조합니다. 한쪽 값만 적용되고 다른 값은 무시되는 구조입니다.

JDBC 의 기본값

MySQL Connector/J 에는 interactiveClient 라는 connection property 가 있고, 기본값은 false 입니다. 즉 별도로 켜지 않는 한 JDBC 로 맺은 connection 은 non-interactive 로 분류되어 wait_timeout 만 적용되어야 합니다.

HikariCP 도 JDBC 위에 얹힌 pool 이므로 이 분류를 그대로 따릅니다. 따라서 표준 MySQL 모델 위에서라면 interactive_timeout 값은 JDBC 연결의 idle 종료에 영향을 줘서는 안 됩니다.

이 표준 모델을 머릿속에 두고 다음 장으로 갑니다.

3. 합리적인 가정들과 검증 결과

손에 있는 단서는 단 하나, “keepalive ping 시점에 이미 dead 였던 connection” 이었습니다. 여기서부터 합리적으로 의심해볼 만한 가설들을 하나씩 세우고 검증해 나갔습니다.

가설 1 — 설정이 실제로는 적용되지 않은 것 아닐까

가장 먼저 의심한 것은 설정 자체였습니다. 환경별 yml 이 코드상으로는 정상 값이 적용된 것처럼 보이지만 실제 런타임에는 다른 값이 바인딩되어 있는 경우입니다.

만약 keepaliveTime, maxLifetime 같은 클라이언트 측 값이 의도와 다르게 들어가 있다면, 같은 증상이 충분히 만들어질 수 있었습니다.

검증 : Spring Boot Actuator 의 env / configprops endpoint 로 실제 런타임에 바인딩된 Hikari 설정과 DB 측 SHOW SESSION VARIABLES 출력을 함께 확인했습니다. 양쪽 모두 의도한 값 그대로였습니다.

→ 부정. 설정 자체는 문제가 없었습니다.

가설 2 — pool size 가 영향을 주는 것 아닐까

다음으로 의심한 것은 pool 회전율의 영향이었습니다. connection 이 자주 borrow / return 되면 idle 도달 빈도가 줄어들 것이고, pool 이 크면 idle 인 connection 비중이 늘어 발생 빈도가 더 높을 수 있다는 가정이었습니다.

발생 빈도가 pool size 에 비례한다면 — 이 가설이 맞다면 — 적어도 발생 패턴의 일부를 설명할 수 있을 것이라 보았습니다.

검증 : maximumPoolSize 를 25 에서 15 로 줄여 재배포 후 일정 시간 모니터링했습니다. 분당 WARN 발생 횟수에 의미 있는 변화는 없었습니다.

→ 부정. idle 종료 자체는 사용 빈도나 pool 크기와 무관한, server 측에서 결정되는 동작으로 좁혀졌습니다.

가설 3 — DB 측 interactive_timeout 이 영향을 주는 것 아닐까

원인이 server 측으로 좁혀진 시점에서, DB parameter 쪽을 들여다봤습니다. parameter group 을 확인해보니 interactive_timeout 이 다른 변수들에 비해 눈에 띄게 작은 값으로 설정되어 있었습니다.

표준 MySQL 모델대로라면 JDBC 는 non-interactive 로 연결되므로 interactive_timeout 은 idle 종료에 영향을 주지 않아야 합니다. 하지만 "그래도 혹시" 무언가 표준과 다르게 이 값이 작용하고 있다면 그게 원인일 수도 있다는 의심이 들었고, 가설로 확인해보기로 했습니다.

검증 : DBA 에 요청해 parameter group 의 interactive_timeout 을 충분한 값으로 상향하고 함께 모니터링했습니다. 변경 적용 직후 Failed to validate connection WARN 이 즉시 멈췄습니다.

→ 가설 자체는 확인. interactive_timeout 이 분명히 영향을 주고 있었습니다. 그런데 "왜 영향을 주는가?" 는 여전히 풀리지 않았습니다. JDBC 가 non-interactive 로 연결되어 있다면 표준 모델 위에서 이 값은 무시되어야 하는데, 명백히 효과가 났기 때문입니다. 이 모순을 다음 가설에서 풀어보기로 했습니다.

가설 4 — JDBC 가 사실은 CLIENT_INTERACTIVE 를 켜고 있는 것 아닐까

가설 3 의 효과를 표준 모델 안에서 자연스럽게 풀 수 있는 가장 합리적인 설명은, “우리 환경의 JDBC connection 이 사실은 interactive 로 연결되고 있을 가능성” 이었습니다.

interactive 로 분류되어 있다면 작은 interactive_timeout 이 session 의 idle 종료 변수로 들어왔을 것이고, 그 값을 늘려서 WARN 이 멈춘 것이 깔끔하게 설명됩니다.

MySQL Connector/J 에는 이 flag 를 제어하는 interactiveClient 라는 connection property 가 있고, 알려진 기본값은 false 입니다. 만약 우리 환경 어딘가에서 이 값이 명시적으로 true 로 들어가 있거나, 우리가 쓰는 버전에서 default 가 변경되어 있다면 가설이 성립합니다.

검증 1 — 설정 경로 점검 : JDBC URL, Hikari dataSourceProperties, connectionInitSql — interactiveClient 를 활성화할 수 있는 모든 경로를 grep 했습니다. 명시적으로 활성화하는 설정은 없었습니다.

검증 2 — 라이브러리의 default 값 확인 : 그래도 “우리가 쓰는 버전의 default 가 정말 false 인가" 를 직접 확인하기로 하고, 사용 중인 mysql-connector-j 의 소스를 열어봤습니다. 모든 connection property 의 default 값은 PropertyDefinitions 클래스의 static initializer 에서 한 줄씩 등록됩니다.

// com/mysql/cj/conf/PropertyDefinitions.java (mysql-connector-j 9.1.0)
public class PropertyDefinitions {

    public static final boolean DEFAULT_VALUE_FALSE = false;
    public static final boolean RUNTIME_NOT_MODIFIABLE = false;

    static {
        // ... 다른 property 등록 ...

        new BooleanPropertyDefinition(
            PropertyKey.interactiveClient,
            DEFAULT_VALUE_FALSE,         // ← 기본값
            RUNTIME_NOT_MODIFIABLE,
            Messages.getString("ConnectionProperties.interactiveClient"),
            "3.1.0",                     // since version
            CATEGORY_CONNECTION,
            Integer.MIN_VALUE
        ),

        // ... 다른 property 등록 ...
    }
}

생성자 시그니처를 함께 보면 두 번째 인자가 default 값임이 명확합니다.

// com/mysql/cj/conf/BooleanPropertyDefinition.java
public BooleanPropertyDefinition(
        PropertyKey key,
        Boolean defaultValue,            // ← 두 번째 인자
        boolean isRuntimeModifiable,
        String description,
        String sinceVersion,
        String category,
        int orderInCategory) {
    super(key, defaultValue, isRuntimeModifiable, description,
          sinceVersion, category, orderInCategory);
}

PropertyKey.interactiveClient 의 default 값으로 같은 클래스의 DEFAULT_VALUE_FALSE 상수(= false) 가 들어가고 있습니다. 우리가 쓰는 버전의 라이브러리 기본값이 false 임이 source 레벨에서 확인되었습니다.

→ 부정. 설정에도, 라이브러리 default 에도, interactiveClient=true 가 들어갈 자리가 없었습니다. connection 은 표준대로 non-interactive 로 맺어지고 있었습니다.

모순이 정리된 자리에서

여기까지의 결론을 정리하면 두 사실이 동시에 참입니다.

표준 모델 위에서 두 사실을 동시에 만족시킬 방법은 없습니다. 여기서 의심의 방향을 바꿨습니다.

“내가 잘못 적용한 것이 아니라, 내가 가정한 모델 자체가 이 환경에 맞지 않는 것일 수 있다.”

그렇다면 표준 MySQL 의 idle close 모델 자체가 이 DB 엔진에서 다르게 동작하고 있을 가능성을 봐야 합니다. 다음 장에서 그 가능성을 직접 확인합니다.

4. 진짜 원인 — Aurora MySQL 의 비표준 dual timeout

문제의 DB 는 community MySQL 이 아닌 Amazon Aurora MySQL 이었습니다. 그리고 Aurora 는 idle close 판정에서 표준 MySQL 과 다르게 동작합니다.

Aurora 의 effective idle timeout

핵심은 한 줄로 요약됩니다.

Aurora MySQL 은 non-interactive 클라이언트에 대해서도 wait_timeout 과 interactive_timeout 두 값을 모두 평가한다. 두 값 중 작은 쪽이 effective idle timeout 으로 동작한다.

문제 클러스터의 두 변수 값은 다음과 같았습니다.

표준 MySQL 모델이라면 JDBC 연결은 wait_timeout = 120초만 적용되어야 했지만, Aurora 에서는 두 값 중 작은 쪽인 20초가 적용되고 있었습니다.

타이밍 — 왜 30초 keepalive 가 무효였는가

HikariCP 의 keepaliveTime 은 30초였습니다. keepalive 가 한 바퀴 돌기 전에, Aurora 가 먼저 20초 시점에 server 측에서 connection 을 close 합니다. 그러면 30초 시점에 keepalive ping 이 점검을 수행할 때 이미 dead 가 된 connection 을 마주치고, validate 에 실패하면서 다음 로그를 남깁니다.

HikariPool - Failed to validate connection ...
  No operations allowed after connection closed.

3장의 실측 — “ interactive_timeout 만 늘렸더니 WARN 이 멈췄다" — 도 이 모델로 깔끔하게 설명됩니다. interactive_timeout 을 충분히 큰 값으로 올리면 min(wait_timeout, interactive_timeout) 이 커지면서 effective idle timeout 이 keepalive 30초 너머로 밀려나고, keepalive ping 이 먼저 도달해 connection 이 유지됩니다.

5. 기술 메커니즘 — Aurora 는 왜 표준과 다르게 동작하는가

여기서 글을 끝내도 실용적으로는 충분합니다. 하지만 왜 Aurora 가 이렇게 동작하는지를 알면, 비슷한 종류의 차이를 다음에 마주칠 때 훨씬 빨리 잡을 수 있습니다. 이 장에서는 community MySQL 과 Aurora 의 connection 처리 모델 차이를 짚어보겠습니다.

community MySQL — one-thread-per-connection

community MySQL 은 클라이언트 connection 마다 mysqld 프로세스 안에 OS 레벨 worker thread 한 개를 dedicated 로 할당합니다. 이 thread 는 해당 connection 이 종료될 때까지 그 connection 만 전담합니다.

idle 종료는 자연스럽게 발생합니다. connection 이 idle 상태라는 것은 그 dedicated thread 가 클라이언트의 다음 요청을 기다리며 네트워크 read() 호출에 block 되어 있다는 뜻인데, 이 read() 가 session.wait_timeout 만큼 block 되다가 timeout 으로 풀리면 그 자리에서 connection 을 close 합니다. 즉 idle 종료 판정 = 네트워크 read timeout 이 자연스럽게 매칭됩니다.

이 모델에서는 idle 판정에 참조되는 변수가 session.wait_timeout 하나입니다. 다른 변수가 끼어들 자리가 없습니다.

Aurora MySQL — thread-pool + monitor thread

Aurora MySQL 은 connection 처리 모델 자체가 다릅니다. SELECT @@thread_handling 을 조회하면 thread-pools 가 반환됩니다.

제한된 수의 worker thread 만 두고, 다수의 사용자 connection 이 이 worker thread 들을 multiplex 합니다. 한 worker thread 가 특정 connection 에 dedicated 되지 않고, 시점마다 다른 connection 으로 자유롭게 switch 합니다. 어떤 connection 이 idle 이 되면 worker 는 그 connection 을 떠나 다른 활동 중인 connection 으로 옮겨갑니다. 멀티 코어 CPU 가 코어 수보다 많은 프로세스를 시분할로 처리하는 것과 같은 발상입니다.

이 구조는 community MySQL 의 자연스러운 idle 판정 메커니즘 — read-block 으로 자연 timeout — 을 그대로 적용할 수 없습니다. worker thread 가 특정 connection 의 read() 에 묶여있지 않기 때문입니다.

이를 보완하기 위해 Aurora 는 별도의 모니터링 thread 가 주기적으로 idle connection 을 검사하여 종료시키는 방식 을 사용합니다. 이 모니터링 thread 의 검사 로직은 session.wait_timeout 또는 session.interactive_timeout 중 하나라도 idle 시간을 초과한 connection 을 종료 시키는 형태로 구성되어 있고, 그 결과가 4장에서 본 min(wait_timeout, interactive_timeout) 동작으로 드러납니다.

추가로 이 모니터링 thread 의 검사 주기는 기본 약 60초입니다. 그래서 짧은 idle 시간(예: 25초, 45초) 에서는 검사 자체가 일어나지 않아 타이밍에 따라 connection 이 살아있는 것처럼 보일 때도 있고, 같은 환경에서도 발생 패턴이 들쭉날쭉할 수 있는 이유가 됩니다.

공식 문서에는 어디까지 적혀 있는가

이 글 작성 시점 기준으로 확인해보면, AWS RDS Aurora MySQL Parameter Groups reference 페이지 의 interactive_timeout / wait_timeout 파라미터 row 에 다음 한 줄이 명시되어 있습니다.

“Aurora evaluates the minimum value of interactive_timeout and wait_timeout. It then uses that minimum as the timeout to end all idle sessions, both interactive and noninteractive."

즉 “두 값 중 작은 쪽이 effective idle timeout 으로 동작한다” 는 사실 자체는 공식 문서에서 확인할 수 있습니다. 다만 왜 그렇게 동작하는지 — 본 장에서 풀어쓴 thread-pool 구조와 별도 모니터링 thread 라는 메커니즘 — 까지 설명한 공식 문서는 (작성 시점 기준) 찾지 못했습니다.

Aurora MySQL Database Administrator’s Handbook 의 connection handling 페이지가 thread-pool 아키텍처를 비교적 자세히 다루지만, idle close 와 두 timeout 변수의 관계까지는 연결해 두지 않았습니다.

본 글의 메커니즘 서술은 다음 자료들의 교차 검증에 기반합니다.

6. 해결과 권장 설정

원인이 server-side 동작으로 확정된 시점에, 진단용으로 임시 변경했던 클라이언트 측 설정(Hikari 디버그 로깅, keepalive 단축 시도 등) 은 일괄 원복했습니다. 클라이언트 어디에도 손댈 곳이 없습니다.

해결은 한 줄입니다.

DB parameter group 에서 interactive_timeout 을 wait_timeout 과 동일한 충분한 값으로 상향.

적용 후 Failed to validate connection WARN 은 즉시 멈추었습니다.

DB 측 (핵심)

클라이언트 측 (참고)

체크리스트 — 신규 Aurora 클러스터 연동 시

7. 마치며

배운 점

1. 표준 모델에 어긋나는 실측은 가장 좋은 단서다.

이번 디버깅의 결정타는 “ interactive_timeout 만 늘렸더니 멈췄다" 라는 한 줄의 실측이었습니다.

표준 MySQL 모델 위에서는 이 실측이 설명되지 않았고, 그 모순이 "내가 잘못 적용한 것이 아니라, 내가 가정한 모델 자체가 환경에 맞지 않는 것일 수 있다" 라는 방향으로 시야를 돌려주었습니다.

가설이 부정될 때 “왜 부정되었는가” 를 끝까지 따라가는 것 — 부정 자체에서 다음 가설의 단서가 나옵니다.

2. Managed engine 의 “호환” 너머의 차이는 공식 문서만으로 닿지 않을 때가 있다.

Aurora MySQL 의 dual timeout 동작 자체는 RDS parameter 페이지의 한 줄로 명시되어 있지만, 메커니즘(thread-pool 구조와 별도 모니터링 thread) 까지 풀어 설명한 공식 문서는 찾기 어렵습니다. 같은 종류의 차이는 다른 managed engine 에서도 마주칠 수 있습니다.

“여기엔 적혀 있지 않다” 가 “이런 동작이 없다” 와 같지 않습니다. 외부 검증 사례, 커뮤니티 보고, 그리고 필요하다면 벤더 채널까지 동원해서 답을 모아야 할 때가 있습니다.

3. 클라이언트 설정이 server-side 동작을 이기는 일은 없다.

처음에 한참을 client 측 설정 — Hikari keepalive, pool size, JDBC 옵션 — 을 만지작거리며 보냈습니다.

그러나 원인이 server-side 였기 때문에 client 측 어떤 손댐도 본질적으로는 해결책이 되지 못했습니다.

비슷한 류의 connection 이슈를 만나면, 가설 트리의 한 가지는 “server 측 동작 자체가 내가 가정한 것과 다를 가능성” 을 항상 두는 것이 좋겠습니다.

같은 로그를 만날 다음 사람에게

HikariPool - Failed to validate connection 로그를 마주쳤다면, 그리고 그 DB 가 Aurora MySQL 이라면, 먼저 parameter group 의 wait_timeout 과 interactive_timeout 두 값을 확인해보시기 바랍니다.

두 값이 다르고, 작은 쪽이 클라이언트 pool 의 keepaliveTime 보다 짧다면, 이 글의 케이스와 같은 상황일 가능성이 큽니다.

감사의 말

이 글에 정리된 진단·해결·메커니즘은 혼자서는 절대 닿을 수 없었습니다.

증상 공유 직후 DB parameter group 의 interactive_timeout 을 즉시 조정해주셔서 운영 영향 없이 증상을 멈출 수 있게 해주시고, 이어 표준 모델로는 풀리지 않는 모순을 AWS Support 채널을 통해 직접 확인해주신 SRE 팀의 로켓에게 감사드립니다. 그 답변 덕분에 본 글의 thread-pool / monitor thread 메커니즘 서술이 가능해졌습니다.

또한 상세한 재현 테스트 결과까지 정리해 답변해주신 AWS Support Engineer 분께도 감사 인사를 전합니다.

참고 자료

AWS 공식 문서

MySQL / Connector/J 표준 동작

외부 분석/사례