필자는 도커이미지를 통해 api 인스턴스를 업데이트하고 있다.
최신 이미지를 pull하면 RedisConnectionException라는 에러가 뜨면서 수강신청을 실패하는 에러를 확인할 수 있었다.
1. RedisConnectionException이란?
RedisConnectionException은 Redisson 클라이언트가 Redis 서버와의 연결을 확보하지 못했을 때 발생하는 런타임 예외이다.
RuntimeException을 상속하며, 컴파일 타임에는 잡히지 않고 실행 시점에 발생한다.
연결 타임아웃의 규칙
Redisson은 명령을 보내기 전에 커넥션 풀에서 연결을 하나 얻어와야 한다.
풀에 쓸 수 있는 연결이 없으면 그 자리에서 새로 TCP 연결을 맺는데, 이때 connectTimeout 안에 핸드셰이크가 끝나지 않으면 예외를 던진다.
connectTimeout = 3000ms // 연결 1회 시도 제한 3초
retryAttempts = 3 // 재시도 3회
retryInterval = 1000ms // 재시도 사이 1초
→ 연결이 안 되면: 3s + 1s + 3s ... ≈ 약 6초 후 RedisConnectionException
에러 메시지 RedisConnectionException: java.util.concurrent.TimeoutException의 의미는 다음과 같다.
- RedisConnectionException: 연결 확보 실패
- Caused by: TimeoutException: Connection refused(서버 다운)가 아니라, 연결 수립 자체가 제한 시간 안에 안 끝남
2. 운영 환경에서 발생한 에러 분석
Sentry 에러 로그
운영 서버(production)에서 Sentry에 다음과 같은 Fatal 에러가 10시간 동안 다수 보고되었다.
org.redisson.client.RedisConnectionException: java.util.concurrent.TimeoutException
Stack Trace:
org.redisson.connection.MasterSlaveConnectionManager.doConnect()
org.redisson.connection.MasterSlaveConnectionManager.lazyConnect()
org.redisson.RedissonBaseLock.isHeldByCurrentThread()
hello.gcstopapi.domain.registration.service.RegistrationService.registerWithDistributedLock() (line 89)
hello.gcstopapi.domain.registration.service.RegistrationService.register() (line 62)
hello.gcstopapi.domain.registration.controller.RegistrationController.createRegistration() (line 59)
- 엔드포인트: POST /api/v1/registrations
- 사용자: anonymousUser
- 환경: production (Eclipse Adoptium JDK 21)
코드 분석
에러가 발생한 흐름을 코드로 따라가 보자.
RegistrationController - API 진입점
@PostMapping
public ResponseEntity<Long> createRegistration(...) {
// ...
Long itemId = registrationService.register(player.getId(), request); // line 59
return ResponseEntity.ok(itemId);
}
RegistrationService - 분산락 진입
public Long register(Long playerId, RegistrationRequest request) {
// ...
try {
return registerWithDistributedLock(playerId, roundId, courseId); // line 62
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.warn("Redis 분산락 실패, FOR UPDATE fallback: courseId={}", courseId, e);
return registerWithDbLock(playerId, roundId, courseId);
}
}
분산락 메서드 - 에러 발생 지점
private Long registerWithDistributedLock(Long playerId, Long roundId, Long courseId) {
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + courseId);
try {
if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) {
throw new BusinessException(ErrorCode.FAILED_TO_ACQUIRE_LOCK);
}
// ...
} catch (InterruptedException e) {
// ...
} finally {
if (lock.isHeldByCurrentThread()) { // line 89 - 여기서 예외 발생!
lock.unlock();
}
}
}
왜 lock.isHeldByCurrentThread()에서 예외가 발생하는가?
isHeldByCurrentThread()는 단순 boolean 체크처럼 보이지만, 내부적으로 Redis에 명령을 보낸다.
// RedissonBaseLock (Redisson 소스)
isHeldByCurrentThread() → isHeldByThreadAsync() → writeAsync()
→ 커넥션 풀에서 연결 획득 시도
→ 풀이 비어 있으면 lazyConnect() → 새 TCP 연결
→ connectTimeout(3초) 초과 → TimeoutException
즉 tryLock()에서 연결을 못 맺어 예외가 났는데, finally의 isHeldByCurrentThread()가 Redis를 한 번 더 호출하면서 또 타임아웃이 났고, 자바 try/finally 특성상 finally의 예외가 원래 예외를 통째로 덮어써 버린 것이다.
실패 1건당 3초 타임아웃을 2번 지불하는 셈이다.
그런데 Redis는 멀쩡한데 왜 연결이 안 되는가?
운영 Redis를 직접 확인해봤다.
connected_clients:1 // ops가 흐르는 중인데 상시 연결이 1개뿐
rejected_connections:0 // Redis가 연결을 거부한 적이 한 번도 없음
rdb_bgsave_in_progress:0 // 서버 측 멈춤 없음
Redis는 문제가 아니였다! 문제는 클라이언트 설정에 있었다.
// RedisConfig.java
config.useSingleServer()
.setConnectionMinimumIdleSize(0) // 워밍 커넥션 0개
config.setLazyInitialization(true); // 빈 생성 시 연결 안 함, 첫 사용 때 연결
minIdle=0 + lazyInitialization=true이므로 API가 떠 있어도 Redis 연결을 미리 들고 있지 않다.
그래서 라운드가 열려 수강신청이 한꺼번에 쏟아지는 순간(= API 재배포 직후 + JVM 워밍업으로 Netty 스레드가 굶는 순간), 수십 개 스레드가 동시에 콜드 connect를 시도하다가 3초 제한을 넘겨 줄줄이 타임아웃이 났던 것이다.
즉, 문제는 Redis 장애가 아니라 빈 커넥션 풀 + 지연 초기화가, 트래픽이 가장 몰리는 콜드스타트 순간과 정면충돌한 것이 원인이다.
3. 해결 방법
① 커넥션 풀 워밍 + 즉시 초기화
빈 풀로 두지 않고, 빈 초기화 시점에 연결을 미리 맺어 워밍을 끝낸다.
API는 docker-compose의 depends_on: redis: condition: service_healthy로 Redis 기동 후에만 뜨므로, 부팅 시 Redis 부재 위험은 이 환경에서 사실상 없다.
변경 전
config.useSingleServer()
.setConnectionMinimumIdleSize(0)
.setConnectionPoolSize(10)
.setSubscriptionConnectionMinimumIdleSize(0);
config.setLazyInitialization(true); // 첫 사용 때 연결
변경 후
config.useSingleServer()
.setConnectionMinimumIdleSize(8) // 유휴 연결 8개 상시 워밍
.setConnectionPoolSize(32) // 버스트 대비 풀 확대
.setSubscriptionConnectionMinimumIdleSize(1)
.setPingConnectionInterval(30000) // 죽은 유휴 TCP 조기 감지
.setKeepAlive(true);
config.setLazyInitialization(false); // 빈 초기화 때 풀 워밍 완료 (트래픽 도착 전)
4. 결과는??
배포 후 모니터링 결과, connected_clients가 1 → 9 안팎으로 상시 워밍된 상태로 바뀌었고, 해당 에러는 없어졌다!
Sentry 도입 이후, 무지했던 에러에 대해 분석하고, 해결하는 습관이 생긴 것 같아 뿌듯하다!
읽어주셔서 감사합니다. 오늘도 좋은 하루 되시길 바랍니다 :)
'광클스탑 > 백엔드' 카테고리의 다른 글
| ArrayIndexOutOfBoundsException 에러 (0) | 2026.03.06 |
|---|