Redis Connection Pool이 끊긴 연결을 계속 내주는 이유
Connection Pool은 연결을 재사용해서 성능을 올려주지만, 풀 안의 연결이 죽었는지 살았는지는 별개 문제다.
EKS 업그레이드 중 Redis pod가 재시작되면서 Pool 안의 연결이 전부 끊겼는데, Pool이 유효성 검증 없이 끊긴 연결을 그대로 반환했다. Lettuce에 auto-reconnect이 있는데도 복구가 안 된 이유와, testOnBorrow/testWhileIdle 설정의 차이를 정리한다.
| 항목 | 내용 |
|---|---|
| 증상 | RedisSystemException, pod restart 전까지 복구 불가 |
| 원인 | Connection Pool의 testOnBorrow 미설정 |
| 스택 | Spring Boot 3.5.0 + Lettuce 6.5.5 + Commons Pool2 |
| 해결 | testOnBorrow=true 또는 testWhileIdle=true 추가 |
| 재발 조건 | Redis pod와 앱 pod가 동시에 재생성되는 경우 |
order-api는 Redis 연결을 Lettuce + Commons Pool2 기반 Connection Pool로 관리하고 있었다.
|
|
EKS 업그레이드 중 Redis pod가 nodeSelector 변경으로 강제 삭제 → 재생성되면서, Pool 안의 connection이 전부 끊겼다. 하지만 Pool은 이를 모른 채 끊긴 connection을 계속 반환했고, 모든 Redis 요청이 RedisSystemException으로 실패했다.
Lettuce는 auto-reconnect 기능이 있다. 실제로 replica 연결은 자동 재연결에 성공했다.
하지만 Connection Pool과 함께 사용할 때는 다른 문제가 생긴다:
- auto-reconnect: 이미 존재하는 connection 객체가 끊기면, 그 객체 자체가 재연결을 시도
- Connection Pool: connection 객체를 빌려주고 반납받는 관리자. 빌려줄 때 그 객체가 살아있는지는 별도로 검증해야 함
Pool이 “이 connection 아직 살아있나?” 확인 없이 내주면, auto-reconnect이 아무리 잘 돼있어도 이미 깨진 객체를 받게 된다.
sequenceDiagram
participant App as Application
participant Pool as Connection Pool
participant Conn as Lettuce Connection
participant Redis as Redis Server
Note over Redis: Pod 재시작으로 연결 끊김
Redis--xConn: TCP 연결 끊김
App->>Pool: connection 빌려줘 (borrow)
Pool->>App: 여기 (끊긴 connection 반환)
Note over Pool: testOnBorrow=false<br/>유효성 검증 안 함
App->>Conn: Redis GET 명령
Conn--xRedis: 연결 끊김 상태
Conn->>App: RedisSystemException
평소 배포는 rolling update라서 Redis 연결이 끊기는 시간이 수초다. 이번에는 EKS 업그레이드로 특수한 상황이 겹쳤다:
- Redis pod의 nodeSelector가 변경되면서 강제 삭제 → 재생성 (약 18분 다운)
- order-api pod도 동시에 재생성되면서 Pool이 초기화됨
- Pool 초기화 시점에 Redis primary가 아직 미복구 상태
minIdle=8에 의해 Pool이 8개 connection을 미리 생성 시도 → 전부 실패한 connection으로 채워짐
일반적인 운영(개별 배포, rolling restart)에서는 발생하지 않는 조건이다.
둘 다 TCP 연결은 끊긴다. 차이는 새 Pool이 초기화될 때 Redis가 살아있느냐다.
| Rolling Update (평소 배포) | EKS 업그레이드 (이번 케이스) | |
|---|---|---|
| TCP 끊김 | 구 pod에서 끊김 | 양쪽 다 끊김 |
| Redis 상태 | 계속 살아있음 | 같이 죽음 |
| 새 Pool 초기화 | 정상 connection으로 채워짐 | 깨진 connection으로 채워짐 |
| 결과 | 정상 | RedisSystemException |
Rolling Update는 새 pod가 뜰 때 Redis가 살아있으니 Pool이 정상 초기화된다. 이번 케이스는 Redis pod와 앱 pod가 동시에 재생성되면서, 새 Pool이 초기화되는 시점에 Redis가 아직 안 떠있었던 게 문제였다.
앱이 계속 떠있는 상태에서 Redis만 재시작되는 경우도 동일한 문제가 발생한다.
1. Pool에 connection 8개 유지 중 (minIdle=8)
2. Redis pod 재시작 → TCP 연결 끊김 (RST)
3. Pool은 여전히 8개 connection 객체를 들고 있음
4. 앱이 borrow → Pool이 끊긴 connection을 그대로 반환
5. RedisSystemException
Pool은 TCP 레벨 상태를 실시간으로 감시하지 않는다. 누군가 PING을 보내봐야 끊긴 걸 알 수 있다. testOnBorrow와 testWhileIdle 설정이 없으면, Redis failover, 네트워크 순단, 운영자의 Redis rolling restart 등 Redis 측에서 연결이 끊기는 모든 상황에서 같은 문제가 재현될 수 있다.
Apache Commons Pool2는 두 가지 유효성 검증 옵션을 제공한다.
connection을 빌릴 때마다 PING으로 검증한다.
|
|
- Redis 요청 1건 = PING 1건 추가
- 빈틈: 없음 (매번 확인)
- 오버헤드: 매 요청 ~1ms 추가
백그라운드에서 주기적으로 idle connection을 검증한다.
|
|
- eviction 스레드가 30초마다 idle connection에 PING
- 끊긴 connection 발견 시 폐기 + 새로 생성
- 빈틈: 최대 30초 (eviction 주기)
- 오버헤드: 없음 (백그라운드 처리)
| 상황 | 권장 설정 |
|---|---|
| 저~중 트래픽 | testOnBorrow=true + testWhileIdle=true |
| 고트래픽 (초당 수만 건) | testWhileIdle=true만 (eviction 주기 짧게) |
testWhileIdle=true만 켜도 이번 사고처럼 영원히 복구 안 되는 상황은 방지할 수 있다. testOnBorrow=true는 30초 빈틈마저 없애려는 추가 안전장치다.
sequenceDiagram
participant App as Application
participant Pool as Connection Pool
participant Conn1 as 끊긴 Connection
participant Conn2 as 새 Connection
participant Redis as Redis Server
Note over Redis: Pod 재시작 후 정상 복구
App->>Pool: connection 빌려줘 (borrow)
Pool->>Conn1: PING 테스트
Note over Pool: testOnBorrow=true<br/>유효성 검증
Conn1--xPool: 응답 없음 (끊김)
Pool->>Pool: 폐기하고 새로 생성
Pool->>Conn2: 새 connection 생성
Conn2->>Redis: TCP 연결 수립
Pool->>App: 새 connection 반환
App->>Conn2: Redis GET 명령
Conn2->>Redis: 정상 전달
Redis->>App: 응답
testOnBorrow가 끊긴 connection을 폐기한 뒤, Pool이 새 connection을 만드는 과정은 다음과 같다.
1. Pool: "끊긴 connection 폐기, 새로 만들자"
2. Pool → Lettuce: "Redis 서버로 connection 하나 만들어줘"
3. Lettuce → OS: Socket.connect(redis-primary:6379)
4. OS → DNS: "redis-primary IP가 뭐야?" → ClusterIP(Service IP) 반환
5. OS → Redis: TCP 3-way handshake (SYN → SYN-ACK → ACK)
6. Lettuce: TCP 연결 수립 완료 → Connection 객체 생성
7. Pool: 이 Connection 객체를 Pool에 등록하고 앱에 반환
각 레이어가 connection 정보를 저장하는 위치:
| 레이어 | 저장 위치 | 내용 |
|---|---|---|
| OS | 커널 소켓 테이블 | 소스IP:포트 ↔ 목적지IP:포트 |
| JVM | java.net.Socket 객체 |
OS 소켓의 파일 디스크립터(fd) 참조 |
| Lettuce | StatefulRedisConnection 객체 |
Socket을 감싼 Redis 전용 connection |
| Pool | GenericObjectPool 내부 리스트 |
connection 객체들을 idle/active로 관리 |
이번 문제에서는 3번 단계에서 Redis가 아직 안 떠있어서 Connection refused가 발생했고, Pool은 이 실패한 객체를 그대로 보관했다. testOnBorrow가 있었다면 빌려줄 때 PING으로 걸러냈을 것이고, testWhileIdle이 있었다면 30초 안에 백그라운드에서 걸러냈을 것이다.
- Connection Pool의
testOnBorrow/testWhileIdle은 DB든 Redis든 동일하게 적용되는 Apache Commons Pool2 설정이다 - Lettuce auto-reconnect과 Pool 레벨 유효성 검증은 별개 메커니즘이다
testWhileIdle=true만으로도 최악 30초 내 복구 가능. 고트래픽이 아니라면testOnBorrow=true까지 함께 설정하는 것이 안전하다- 이번 이슈는 EKS 업그레이드 시 Redis pod와 앱 pod가 동시에 재생성되는 특수 상황에서 발생했다. 일반적인 운영에서는 재발 가능성이 낮다