image.png

1. 처음 관측된 증상

배치를 prod jar로 실행하면 그 회차의 due 트래킹이 전건 실패하고 success=0으로 끝났다. 실패 지점과 예외는 매번 동일했다.

2026-06-12 16:57:31.968 [main] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - Connection is closed
2026-06-12 16:57:31.968 [main] WARN  c.a.d.a.AiTrackingServiceImpl - Weekly batch competitor read transient DB failure (attempt 1/3) for aiTrackingSeq=84:
  could not prepare statement [Connection is closed]
  [select atc1_0.SEQ,... from AI_TRACKING_COMPETITOR atc1_0 where atc1_0.AI_TRACKING_SEQ=? order by atc1_0.DISPLAY_ORDER]
...
Caused by: java.sql.SQLException: Connection is closed
    at com.zaxxer.hikari.pool.ProxyConnection$ClosedConnection.lambda$getClosedConnection$0(ProxyConnection.java:562)
    at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:342)

여기서 두 가지를 사실로 확정할 수 있다.

첫째, 예외 출처가 ProxyConnection$ClosedConnection이다. 이것은 물리 소켓이 끊겼다는 뜻이 아니라, Hikari 커넥션 프록시 핸들이 이미 close() 처리된 상태에서 그 핸들로 prepareStatement를 호출했다는 뜻이다. 물리 단절일 경우 SQLNonTransientConnectionException ... Socket error 또는 Communications link failure 형태로 뜬다(실제로 후반에 별도로 관측됨, 6절 참고). Connection is closed는 그것과 구분되는 다른 종류의 오류다.

둘째, 실패는 항상 [main] 스레드의 competitor read(AI_TRACKING_COMPETITOR 조회)에서 발생했다. 배치 fan-out은 ForkJoinPool.commonPool 워커에서 도는데, 이 오류는 main에서만 났다.

배치에는 이미 retryOnTransientConnection(1초 backoff × 최대 3회) 재시도가 있었지만, 3회 모두 같은 Connection is closed로 소진됐다.

2. 첫 번째 조치 — Hikari keepalive 설정이 실제로 적용되지 않고 있었음

추론

긴 fan-out(트래킹별 OpenAI 호출이 수 분) 동안 풀에 반납된 idle 커넥션이 DB 서버/네트워크에 의해 끊기면, 다음 대여 때 죽은 커넥션을 받을 수 있다. 이를 막는 표준 수단이 Hikari keepaliveTime(주기적 ping)과 maxLifetime(주기 교체)이다. 그래서 이 값들이 실제로 적용되고 있는지부터 확인했다.

관측한 사실

DatabaseConfig는 풀 튜닝을 다음 프리픽스로 바인딩한다.

@Bean(name = "aigeoDataSource")
@ConfigurationProperties("spring.datasource.aigeo.hikari")   // hikari 하위에서 바인딩
public DataSource aigeoDataSource(DataSourceProperties props) { ... }

image.png

그런데 prod/dev yml은 키를 hikari: 하위가 아니라 spring.datasource.aigeo 바로 아래에 평면으로 두고 있었다. 즉 keepalive-timemax-lifetime 등이 바인딩 대상 프리픽스(...aigeo.hikari)에 들어가지 않아 무시되고, Hikari 기본값(keepaliveTime=0 = 비활성, maxLifetime=1800000 = 30분)으로 동작하고 있었다. 같은 키가 local yml에서는 hikari: 하위에 중첩되어 정상 적용되고 있었던 점이 이 불일치를 드러내는 단서였다.

조치

prod/dev/stage yml의 풀 튜닝 키를 hikari: 하위로 이동시켰다(평면 → 중첩). local과 동일한 구조이며 DatabaseConfig의 바인딩 프리픽스와 일치한다.

조치가 적용됐다는 직접 근거

이후 기동 로그에 Hikari가 자신의 최종 설정을 덤프했고, 의도한 값이 그대로 찍혔다. 이것이 "설정이 yml에 있다"가 아니라 "런타임에 실제 적용됐다"의 증거다.