광클스탑/백엔드

RedisConnectionException 에러

kittae 2026. 5. 19. 01:43

필자는 도커이미지를 통해 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