# 개요
크론잡이 iTunes Search API를 호출해 받은 결과를 파싱한 뒤 Cloud SQL에 적재하는 구조에서, 로그에 다음 WARN이 반복적으로 찍혔다.
WARN --- [l-1:housekeeper] com.zaxxer.hikari.pool.PoolBase :
HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@...
(This connection has been closed.). Possibly consider using a shorter maxLifetime value.
이 WARN의 정체를 파고들다 보니, 정작 더 위험한 문제는 따로 있었다. 트랜잭션 안에서 외부 API를 호출하고 있었다는 것이다. 이 글은 (1) 커넥션 풀의 기본 개념, (2) HikariCP 커넥션 검증 WARN의 정체와 세 가지 타이머, (3) keepalive의 동작 원리, (4) 실제 로그 판독, (5) 트랜잭션 안에서 외부 API를 호출하면 안 되는 근본 이유, 그리고 (6) 노트북 절전모드라는 함정까지 정리한다.
# 1. 커넥션 풀이란 — "미리 데워둔 전화선"
DB 커넥션은 새로 맺는 비용이 비싸다. TCP 핸드셰이크 + 인증 + (Cloud SQL이면 프록시 경유)까지 매 요청마다 하면 느리다. 그래서 커넥션을 미리 여러 개 열어두고 재사용하는 것이 커넥션 풀이고, Spring Boot에서는 HikariCP가 그 역할을 한다.
커넥션 풀 ≠ 스레드 풀
헷갈리기 쉬운데 둘은 완전히 다르다. 커넥션 풀은 DB 커넥션을 담아 DB 접근을 재사용하고, 스레드 풀은 워커 스레드를 담아 작업 실행을 병렬화한다. 이 글은 전부 커넥션 풀 이야기다.
앱이 뜰 때(또는 필요 시 지연 생성) HikariCP가 물리 커넥션을 미리 열어 풀에 채워둔다. 이후 DB 작업의 사이클은:
- 풀에서 커넥션 하나를 빌린다(borrow) → 이 순간 그 커넥션은 active
- 그 커넥션으로 쿼리를 날린다
- 다 쓰면 풀에 반납한다 → 다시 idle(유휴)
커넥션은 매 순간 둘 중 하나다.
| 상태 | 뜻 |
|---|---|
| active (busy) | 지금 누가 빌려가서 쿼리 중 |
| idle (유휴) | 풀에 반납되어 아무도 안 쓰고 대기 중 |
반납된 유휴 커넥션은 "노는" 게 아니다
싱글스레드 크론잡이 커넥션 1개를 빌려 쓰는 순간, 풀 10개 중 나머지 9개는 idle이다. 이건 낭비가 아니라 다음 요청에 즉시 재사용하려고 데워둔 예비 커넥션이다. 커넥션 풀의 존재 이유 그 자체다. 작업 단위(스레드) 하나당 커넥션 1개를 빌리며, 풀 크기는 minimum-idle~maximum-pool-size(기본 둘 다 10) 범위에서 관리된다.
# 2. WARN의 정체 — 자가회복되는 로그
# 시나리오
문제는 유휴 커넥션이 너무 오래 조용할 때 생긴다. 커넥션을 안 쓰고 오래 놀리면 DB 서버(또는 앞단의 Cloud SQL Auth Proxy) 가 "이 커넥션 안 쓰네?" 하고 먼저 끊어버린다(idle timeout). 그런데 앱은 그 순간엔 모른다. 나중에 HikariCP의 housekeeper 스레드가 풀 안의 유휴 커넥션을 점검(pull) 하다가 이미 닫힌 커넥션을 발견하면 위 WARN을 남긴다.
중요한 건, HikariCP가 그 죽은 커넥션을 폐기하고 새 커넥션으로 갈아끼운다는 점이다. 그래서 쿼리가 실제로 실패하지는 않는다. ERROR가 아니라 WARN인 이유가 여기 있다 — 자가회복되는 로그다.
실시간 알림(push)이 아니라 점검(pull)이다
DB가 "너 연결 끊겼어"라고 앱에 능동적으로 알려주지 않는다. DB는 말없이 커넥션을 끊고(TCP FIN), 앱은 나중에 그 커넥션을 직접 건드려 보고 나서야 죽은 걸 안다. 그래서 풀에는 잠깐 "죽었지만 살아있는 줄 아는 좀비 커넥션"이 남는다.
# "끊는다"가 실제로 해제하는 자원
커넥션을 끊는 걸 단순히 "자바 객체를 메모리에서 제거"로 이해하면 초점이 어긋난다. 끊을 때 풀리는 자원 중 앱 메모리는 제일 가벼운 축이다.
| 해제되는 자원 | 어디 | 무게 |
|---|---|---|
| DB 백엔드 프로세스 + 세션 | DB 서버 | 가장 무거움 |
커넥션 슬롯 (max_connections 한 칸) | DB 서버 | 무거움 |
| TCP 소켓 / 파일 디스크립터 | OS (앱 서버) | 중간 |
| 자바 커넥션 객체 | 앱 힙 → GC 대상 | 가벼움 |
PostgreSQL은 커넥션 1개당 백엔드 프로세스 1개를 띄우고 max_connections 상한이 있다. 유휴 커넥션이 이 슬롯과 서버 메모리를 계속 차지하니, DB 서버가 idle timeout으로 끊어 슬롯을 회수하려는 것이다. DB가 유휴 커넥션을 끊는 근본 동기가 여기에 있다.
# 3. 커넥션 풀의 세 가지 타이머 (서로 독립적이다)
이 WARN을 "keepalive가 maxLifetime보다 짧아서 생겼다"는 식으로 오해하기 쉬운데, 세 타이머는 서로 독립적이고 아무도 서로를 갱신해주지 않는다.
| 타이머 | 주체 | 기본값 | 역할 |
|---|---|---|---|
max-lifetime | HikariCP | 30분 | 커넥션 최대 수명. 이 나이를 넘으면 유휴일 때 폐기 + 재생성. 핑을 보내지 않고 그냥 버린다 |
keepalive-time | HikariCP | 0 (비활성) | 유휴 커넥션에 주기적으로 핑을 보내 서버가 끊기 전에 살려둔다 |
| idle timeout | DB 서버 / 프록시 | (서버 정책) | 너무 오래 논 커넥션을 서버가 먼저 끊는다 ← WARN의 실제 원인 |
이번 WARN의 진짜 원인
수정 전 설정에는 keepalive-time이 없었다 = 기본값 0 = keepalive가 아예 꺼져 있었다. 즉 "keepalive가 maxLifetime보다 컸다"가 아니라 핑을 하나도 안 보내서 유휴 커넥션을 안 살려뒀다가 정답이다. 그 결과 유휴 커넥션이 DB의 idle timeout에 걸려 조용히 끊겼다.
올바른 부등식은 이렇다.
keepalive-time < 서버 idle timeout < max-lifetime
HikariCP는 keepalive-time을 설정하면 내부적으로 max-lifetime보다 작아야 한다고 강제한다. keepalive >= max-lifetime으로 잡으면 부팅 시 경고와 함께 keepalive를 무시(0)한다. 크론잡처럼 트래픽이 뜸한 워크로드에서는 application.properties에 다음을 넣는다.
# 유휴 커넥션이 Cloud SQL idle timeout에 끊기기 전에 keepalive로 살려둔다
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.max-lifetime=600000
# 4. keepalive는 어떻게 동작하나
keepalive의 방향과 단위를 정확히 짚어두면 위 설정이 무슨 일을 하는지 선명해진다.
- 방향: 앱 → DB. DB가 앱한테 보내는 게 아니라, HikariCP가 DB로 먼저 핑을 날리는 능동적 동작이다.
- 대상: 유휴 커넥션만. active 커넥션은 어차피 트래픽이 흐르니 핑을 보내지 않는다.
- 단위: 커넥션마다 독립적. 하나의 공용 핑이 아니라, 각 커넥션이 마지막 사용 시점 기준으로
keepalive-time이 지나면 자기 핑을 따로 받는다. 커넥션 A의 idle 타이머는 A로 핑을 보내야만 리셋되기 때문에 필연적으로 커넥션별 독립 핑이다.
커넥션 A: 12:00 반납 → 12:05 핑 → 12:10 핑 ...
커넥션 B: 12:02 반납 → 12:07 핑 → 12:12 핑 ...
핑의 정체는 가벼운 검증 쿼리다. JDBC 드라이버가 Connection.isValid()를 지원하면 그걸 쓰고, 아니면 SELECT 1 같은 connection-test-query를 날린다. 여기서 핵심은 응답 값이 아니라 왕복 그 자체다.
HikariCP ──"SELECT 1"──▶ DB (DB: "트래픽 왔네 → idle 타이머 리셋")
HikariCP ◀───"1"─────── DB (HikariCP: "응답 왔네 → 살아있음, 유지")
이 사소한 왕복 한 번이 두 가지를 동시에 한다.
- 앱에겐 생존 증명 — 응답이 정상적으로 오면 살아있는 커넥션이니 풀에 유지. 예외/타임아웃이면 폐기 후 새로 생성.
- DB에겐 "나 아직 쓰는 커넥션" 신호 — 쿼리가 오가는 것 자체가 트래픽이라 DB가 idle timeout 카운터를 리셋 → 안 끊는다.
# 5. 실제 로그 판독
이론을 실제 로그와 맞춰보면 앞의 설명이 그대로 찍혀 있다.
12:38:11 Batch 114 done — processed: 85, lastId: 295000
⋯ (약 8분간 배치 로그 없음) ⋯
12:46:30 connection aborted - error reading from instance:
read tcp 192.168.0.166:61496->35.223.50.240:3307: read: connection reset by peer
12:46:30 WARN [l-1:housekeeper] HikariPool-1 - Failed to validate connection ... (This connection has been closed.)
12:46:35 connection aborted ... 61504->...:3307: read: connection reset by peer
12:46:35 connection aborted ... 61481->...:3307: read: connection reset by peer
⋯ (여러 소켓이 연달아 리셋) ⋯
12:46:58 Accepted connection from 127.0.0.1:64528 ← 새 커넥션 재연결
읽는 법:
35.223.50.240:3307= Cloud SQL 인스턴스(프록시가 실제 DB로 나가는 포트).read: connection reset by peer= 상대편(Cloud SQL 서버)이 커넥션을 리셋했다는 뜻. 앱이나 프록시가 아니라 DB 쪽이 먼저 끊었다는 직접 증거다. 3장에서 말한 "DB가 말없이 끊는다"가 로그에 그대로 보인다.- 소켓 번호가 여러 개(
61496, 61504, 61481...) 동시에 뜨는 건 = 유휴 커넥션 여러 개가 한꺼번에 리셋됐다는 뜻. Accepted connection from 127.0.0.1:64528= Cloud SQL Proxy가 HikariCP의 새 커넥션 요청을 받아들임 = 재연결 성공. 그래서 실제 작업은 안 깨진다.
# 6. 함정 — 노트북 절전모드
위 로그에서 눈여겨볼 건 8분간의 공백이다. 배치가 40~70초 간격으로 규칙적으로 돌다가 114번 이후 8분간 멈췄고, 그 뒤 커넥션 리셋이 몰렸다. 원인은 로컬 맥이 절전모드로 들어간 것이었다(디스플레이 꺼짐 → 시스템 슬립).
맥이 잠들면:
- JVM 프로세스 자체가 얼어붙는다 → 배치 스레드 정지 → 8분간 로그 공백
- 그 커넥션들의 TCP 트래픽도 완전 정지
- Cloud SQL 서버는 앱이 자는 줄 모르고 "왜 조용하지" → idle timeout으로 커넥션 무더기 리셋
- 맥이 깨어나면
housekeeper재가동 → 죽은 커넥션 발견 → WARN → 재연결
절전으로 인한 끊김은 keepalive로 못 막는다
keepalive는 JVM이 깨어 있어야 핑을 보낸다. 절전 중엔 핑을 보내는 스레드까지 같이 얼어붙어서 아무것도 못 보낸다. 다행히 이 케이스는 깨어난 뒤 자동 재연결로 회복되므로 데이터 유실은 없다. 다만 자는 동안 크론잡 진행이 멈춘다.
로컬에서 오래 도는 배치라면 절전을 막아야 한다.
# 크론잡 도는 동안만 시스템이 잠들지 않게 (뒤 명령이 끝날 때까지 유지)
caffeinate -i ./run-cron.sh
근본적으로는 이런 오래 도는 배치를 노트북에 의존시키는 것 자체가 취약하다(뚜껑 닫으면 멈춤). 장기적으로는 Cloud Run Job이나 항상 켜진 VM으로 옮기면 절전·네트워크 걱정 없이 돌릴 수 있다.
# 7. 진짜 문제 — 트랜잭션 안의 외부 API 호출
여기까지는 무시하거나 회복되는 WARN이었다. 하지만 코드를 보니 배치 로직이 이렇게 생겨 있었다.
@Transactional
public ProcessResult processBatch(String jobName) {
CronState cronState = cronStateRepository.findByJobName(jobName)...;
long start = cronState.getLastProcessedId() + 1;
// 트랜잭션 = DB 커넥션을 쥐고 있는 상태인데,
// 그 안에서 외부 HTTP 호출로 몇 초를 기다린다
List<ItunesItem> items = iTunesAPIClient.lookup(range(start, start + 200));
// ... 파싱 후 DB 저장 ...
}
# 왜 위험한가
트랜잭션을 연다 = DB 커넥션 하나를 빌려서(borrow) 트랜잭션이 끝날 때까지 안 놓는다. 그 안에서 외부 API를 호출하면:
- iTunes 응답을 기다리는 몇 초 동안 DB 커넥션을 빌려놓고 아무것도 안 한다. 그 커넥션은 서버 쪽에서 놀고 있는 상태다.
- iTunes가 느리게 응답하거나 (앞서 본) 절전으로 멈추면, 그 사이 Cloud SQL이 그 빌려 쓰던 커넥션을 끊어버릴 수 있다. 곧이어 DB 저장을 시도하면
DataAccessException이 터진다. - 요청이 많아지면 API 응답 대기 중인 커넥션들이 풀(10개)을 금방 바닥내, 다른 요청들이 커넥션을 못 빌리고 멈춘다.
keepalive는 이 문제를 못 막는다
keepalive는 풀에 반납된 유휴 커넥션만 핑한다. borrow해서 쥐고 있는(트랜잭션이 점유 중인) 커넥션은 건드리지 않는다. 그래서 트랜잭션 안에서 외부 호출을 하는 한, keepalive를 아무리 짧게 잡아도 소용없다.
게다가 이 크론잡의 루프는 DataAccessException이 나면 잡 전체를 break 하도록 되어 있어, 긴 크론잡이 중간에 뚝 끊긴다. 매우 큰 track id를 끝점으로 잡고 오래 돌려야 하는 상황에서는 치명적이다.
# 8. 규칙과 해결
트랜잭션 안에서 외부 API를 호출하지 마라.
트랜잭션은 짧아야 하고 DB 작업만 담아야 한다. 트랜잭션 안에 넣으면 안 되는 것들:
- 외부 API 호출 (이번 케이스)
- 파일 업로드/다운로드
Thread.sleep, 오래 걸리는 연산- 그 밖의 무거운 I/O
이들은 전부 트랜잭션 밖으로 빼는 것이 정석이다. 올바른 패턴은:
① 외부 API 호출 ← 트랜잭션 없음 (DB 커넥션 안 씀)
② 결과 받기 / 파싱
③ [트랜잭션 시작] DB 저장 [트랜잭션 끝] ← 짧게
# 실제 리팩터링
iTunes 호출을 트랜잭션이 없는 상위 서비스로 올리고, @Transactional 메서드는 이미 조회된 결과만 받아 DB에 적재하도록 쪼갰다.
// 트랜잭션 없는 오케스트레이터
while (true) {
long startId = cronBatchService.peekNextStartId(jobName); // 시작 id만 짧게 조회
List<ItunesItem> items = iTunesAPIClient.lookup( // HTTP, DB 커넥션 안 씀
LongStream.range(startId, startId + 200).boxed().toList());
ProcessResult result = cronBatchService.processBatch(jobName, items); // @Transactional, DB만
// ...
}
// DB 저장만 담당 — 트랜잭션 안에 외부 HTTP 호출이 없다
@Transactional
public ProcessResult processBatch(String jobName, List<ItunesItem> itunesItemList) {
// dedup / genre 조회 / save / cronState 갱신
}
이제 iTunes 응답을 기다리는 동안 DB 커넥션을 쥐고 있지 않으므로, borrow한 커넥션이 서버에 끊길 일 자체가 없어졌다.
TIP
개별 트랙 저장은 @Transactional(propagation = REQUIRES_NEW)로 두어, 트랙 하나가 유니크 제약에 걸려도 배치 전체가 롤백되지 않게 격리했다. 이 격리는 리팩터링 후에도 그대로 유지했다.
# 정리
- 커넥션 풀은 재사용을 위해 커넥션을 미리 데워두는 구조다. 반납된 유휴 커넥션은 "노는" 게 아니라 재사용 대기 상태다.
- HikariCP
housekeeper의connection has been closedWARN은 서버가 유휴 커넥션을 먼저 끊어서 생기며, HikariCP가 점검하다 발견해 재연결하므로 자가회복되는 WARN이다. max-lifetime/keepalive-time/ 서버 idle timeout은 독립적인 세 타이머다. 이번 WARN의 원인은keepalive-time이 기본값 0(꺼짐) 이라 유휴 커넥션을 안 살려둔 것.- keepalive는 앱 → DB 방향으로, 커넥션마다 독립적으로
SELECT 1같은 왕복을 보내 (앱엔 생존 확인, DB엔 idle timeout 리셋)을 동시에 한다. - 로그의
reset by peer @3307은 Cloud SQL 서버가 먼저 끊었다는 증거고, 8분 공백은 노트북 절전으로 JVM이 얼어붙은 것이었다. 절전 끊김은 keepalive로 못 막고 재연결로만 회복된다. - 진짜 위험은 트랜잭션 안에서 외부 API를 호출하는 것이다. borrow한 커넥션을 API 대기 동안 점유해 커넥션이 끊기고, 풀이 고갈되고, 긴 잡이 중간에 죽는다. 해결은 외부 호출을 트랜잭션 밖으로 빼는 것.