운영하다 보면 유독 새벽 시간대에만 애플리케이션 로그에 Communications link failure가 찍히는 경우를 만난다. 낮에는 멀쩡하다가 트래픽이 거의 없는 새벽 2시, 4시에만 터지고, 아침에 출근해서 보면 이미 복구돼 있다. 재현이 안 되니 한참 헤매기 쉬운 문제다. 예전에 비슷한 증상으로 네트워크 장비를 의심하다 며칠 날린 적이 있어서, 이번 글에서 원인과 해결을 정리해 둔다.
결론부터 말하자면, 대부분 애플리케이션 버그가 아니라 커넥션 풀이 붙잡고 있는 유휴 커넥션을 DB 서버가 먼저 끊어버려서 생긴다. MariaDB의 wait_timeout(기본 28800초, 8시간)이 유휴 커넥션을 닫는데, HikariCP가 그 닫힌 커넥션을 모르고 재사용하려다 실패하는 것이다. 해결은 HikariCP의 maxLifetime을 서버 타임아웃보다 짧게 맞춰, 풀이 먼저 커넥션을 폐기하도록 하는 것이다.
1. 왜 새벽에만 터지나
핵심 개념부터 짚는다. 커넥션 풀은 DB 연결을 매번 새로 맺지 않고 미리 만들어 둔 연결을 재사용하는 구조다. HikariCP는 자바 진영에서 가장 많이 쓰는 커넥션 풀이고, Spring Boot의 기본 풀이기도 하다. 풀은 요청이 없어도 최소 개수의 연결을 계속 유지한다.
문제는 DB 서버도 유휴 연결을 영원히 열어두지 않는다는 점이다. MariaDB에는 wait_timeout이라는 변수가 있다. 비대화형(애플리케이션) 연결이 아무 쿼리도 보내지 않고 이 시간을 넘기면 서버가 그 연결을 닫는다. JDBC 드라이버로 붙은 커넥션 풀 연결이 바로 이 비대화형에 해당한다.
낮에는 쿼리가 끊임없이 오가니 유휴 시간이 쌓이지 않는다. 그런데 새벽처럼 요청이 뜸해지면 풀 안의 커넥션이 wait_timeout을 넘길 만큼 조용히 놀게 된다. 서버는 이 연결을 닫지만, 풀은 아직 살아 있다고 믿는다. 다음 요청이 그 죽은 커넥션을 집어 쿼리를 던지는 순간 Communications link failure가 난다.
아래에서는 진단하는 방법, 서버 쪽 타임아웃 확인, HikariCP 설정 조정, 검증 순서로 살펴본다.
2. 증상과 로그 확인
전형적인 스택트레이스는 이렇게 생겼다. MariaDB Connector/J(또는 MySQL Connector/J)를 쓸 때 공통으로 나타난다.
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet successfully received from the server was 29,305 milliseconds ago.
The last packet sent successfully to the server was 29,305 milliseconds ago.
at com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException(...)
...
Caused by: java.io.EOFException: Can not read response from server.
Expected to read 4 bytes, read 0 bytes before connection was unexpectedly lost.
여기서 눈여겨볼 것은 The last packet successfully received ... was N milliseconds ago 줄이다. 이 값이 서버 wait_timeout 근처거나 그보다 크면 유휴 타임아웃에 걸린 것이 거의 확실하다. 위 예시의 29초짜리 값은 테스트로 wait_timeout을 짧게 줄여 재현한 것이고, 운영에서는 이 값이 8시간 가까이 찍히는 경우가 많다.
원인을 구분하는 기준은 단순하다. 방화벽이나 로드밸런서가 끊은 것이라면 특정 유휴 시간(예: 5분, 60분)에서 일정하게 끊긴다. DB가 끊은 것이라면 wait_timeout 값과 맞아떨어진다. 그래서 먼저 서버 설정을 확인해야 한다.
3. 서버 타임아웃 확인
MariaDB에 접속해 두 변수를 확인한다. wait_timeout은 비대화형 연결, interactive_timeout은 mysql CLI 같은 대화형 클라이언트에 적용된다. 둘 다 기본 28800초(8시간)다.
MariaDB [(none)]> SHOW VARIABLES LIKE '%timeout%'; +-----------------------------+----------+ | Variable_name | Value | +-----------------------------+----------+ | interactive_timeout | 28800 | | wait_timeout | 28800 | | ... | ... | +-----------------------------+----------+
여기서 한 번 헷갈리기 쉽다. mysql CLI로 접속해 SHOW VARIABLES를 치면 세션 값이 나오는데, 대화형 세션이라 wait_timeout이 interactive_timeout 값으로 보인다. 그래서 전역 설정을 봐야 정확하다.
MariaDB [(none)]> SHOW GLOBAL VARIABLES LIKE 'wait_timeout'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | wait_timeout | 28800 | +---------------+-------+
애플리케이션(비대화형) 커넥션에 실제로 적용되는 값은 이 전역 wait_timeout이다. 운영 현장에서는 커넥션 누수를 줄이려고 이 값을 600초, 300초처럼 짧게 내려둔 경우가 흔하다. 값을 내릴수록 유휴 커넥션은 빨리 정리되지만, 그만큼 풀 쪽과 어긋날 위험이 커진다.
현재 어떤 연결이 얼마나 놀고 있는지는 프로세스 목록으로 본다. Time 열이 유휴 시간(초)이다.
MariaDB [(none)]> SHOW PROCESSLIST; +-----+------+-----------------+------+---------+------+-------+------+ | Id | User | Host | db | Command | Time | State | Info | +-----+------+-----------------+------+---------+------+-------+------+ | 102 | app | 10.0.1.20:51022 | sarc | Sleep | 271 | | NULL | | 103 | app | 10.0.1.20:51024 | sarc | Sleep | 268 | | NULL | +-----+------+-----------------+------+---------+------+-------+------+
Command가 Sleep이고 Time이 점점 커지는 커넥션이 풀의 유휴 연결이다. 이 값이 wait_timeout에 도달하면 해당 연결은 사라진다.
4. HikariCP maxLifetime을 서버보다 짧게
해결의 핵심은 풀이 서버보다 먼저 커넥션을 폐기하게 만드는 것이다. HikariCP의 maxLifetime은 풀 안의 커넥션이 살아 있을 수 있는 최대 수명이다. 이 시간을 넘긴 커넥션은, 사용 중이 아닐 때 풀이 조용히 닫고 새로 만든다. 기본값은 1800000ms(30분)다.
HikariCP 공식 문서는 maxLifetime을 "데이터베이스나 인프라가 강제하는 연결 시간 제한보다 몇 초 짧게" 설정하라고 권장한다. 즉 maxLifetime < wait_timeout이 되어야 한다. 서버가 커넥션을 끊기 전에 풀이 먼저 손을 떼는 구조다.
예를 들어 서버 wait_timeout이 600초(600000ms)라면, maxLifetime은 그보다 넉넉히 짧은 값으로 둔다. Spring Boot의 application.yml이라면 이렇게 쓴다.
spring:
datasource:
hikari:
max-lifetime: 570000 # 570초, 서버 wait_timeout(600초)보다 30초 짧게
idle-timeout: 300000 # 300초
keepalive-time: 150000 # 150초, maxLifetime보다 작아야 함
connection-timeout: 30000 # 30초
minimum-idle: 5
maximum-pool-size: 20
서버 wait_timeout이 기본값 8시간(28800초)이라면 HikariCP 기본 maxLifetime 30분으로도 이미 어긋나지 않는다. 문제가 생기는 쪽은 서버 타임아웃을 짧게 내려둔 환경이다. 그럴 때 maxLifetime을 서버 값보다 짧게 다시 맞춰야 한다.
keepaliveTime도 함께 보면 좋다. 이 값은 유휴 커넥션에 주기적으로 신호를 보내 살아 있게 유지하는 간격이다. 기본 120000ms(2분)이고, 반드시 maxLifetime보다 작아야 한다. 방화벽이 중간에서 유휴 TCP 연결을 끊는 환경이라면 keepaliveTime이 특히 도움이 된다.
순수 코드로 HikariConfig를 다룬다면 단위는 모두 밀리초다.
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mariadb://10.0.1.10:3306/sarc");
config.setUsername("app");
config.setPassword("****");
config.setMaxLifetime(570_000); // ms
config.setKeepaliveTime(150_000); // ms
config.setConnectionTimeout(30_000);// ms
HikariDataSource ds = new HikariDataSource(config);
5. 하지 말아야 할 우회책
검색하면 JDBC URL에 autoReconnect=true를 붙이라는 글이 많이 나온다. 권하지 않는다. 드라이버가 끊긴 커넥션을 몰래 다시 붙여주긴 하지만, 트랜잭션 상태와 세션 변수가 초기화되어 데이터 정합성이 깨질 수 있다. 커넥터 문서에서도 이 옵션은 비권장으로 다룬다.
또 하나, 서버 wait_timeout을 무작정 크게 늘리는 방식도 근본 해결이 아니다. 유휴 커넥션이 오래 남으면 그만큼 서버 연결 슬롯을 점유한다. 올바른 방향은 풀과 서버의 타임아웃 관계를 맞추는 것이지, 어느 한쪽을 극단으로 미는 것이 아니다.
검증을 위해 커넥션을 미리 테스트하고 싶다면, JDBC4 드라이버에서는 HikariCP가 Connection.isValid()로 자동 검사하므로 별도 connectionTestQuery를 설정할 필요가 없다. 레거시 드라이버가 아니라면 이 옵션은 비워 두는 편이 맞다.
6. 적용 후 확인
설정을 반영하고 애플리케이션을 재기동한 뒤, HikariCP의 풀 상태 로그로 커넥션 교체가 일어나는지 본다. 로거 레벨을 DEBUG로 올리면 풀 통계가 주기적으로 찍힌다.
logging:
level:
com.zaxxer.hikari: DEBUG
com.zaxxer.hikari.pool.HikariPool: DEBUG
DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Pool stats (total=20, active=0, idle=20, waiting=0) DEBUG com.zaxxer.hikari.pool.PoolBase - HikariPool-1 - Closing connection ...: (connection has passed maxLifetime) DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection ...
connection has passed maxLifetime 메시지와 함께 커넥션이 닫히고 새로 추가되면, 풀이 수명이 다한 연결을 서버보다 먼저 교체하고 있다는 뜻이다. 이 상태라면 새벽 유휴 시간에 죽은 커넥션을 집어 쓰는 일이 사라진다.
서버 쪽에서는 SHOW PROCESSLIST를 몇 분 간격으로 보면서 Sleep 커넥션의 Time이 maxLifetime 근처에서 리셋되는지 확인한다. 특정 연결의 Id가 바뀌며 Time이 0부터 다시 올라가면 교체가 정상 동작한 것이다.
정리하면, Communications link failure는 풀과 DB 서버가 서로 다른 타이밍에 유휴 커넥션을 판단해서 생긴다. wait_timeout을 확인하고, maxLifetime을 그보다 짧게 맞추고, 로그로 교체를 검증하는 순서면 대부분 잡힌다.