Redis 커넥션 급증 해결 우아한형제들 2가지 특급 노하우

우아한형제들은 어떻게 Redis 신규 커넥션 증가 이슈를 해결했을까?

서비스 성능 최적화를 위해 캐싱은 필수적입니다. 특히 Redis는 사용자 맞춤 데이터 제공에 핵심적인 역할을 하죠. 하지만 Redis 관련 지표를 모니터링하던 중 예상치 못한 신규 커넥션 증가 현상을 발견했습니다. 단순한 설정 문제가 아닌, Redis 커넥션 관리의 깊은 이해가 필요했던 이 흥미로운 여정을 공유합니다.

Redis 신규 커넥션, 무엇이 문제였을까요?

서비스의 심장부인 Redis 지표를 유심히 살피던 중, 한 가지 이상한 점을 발견했습니다. 밤늦은 시간, 트래픽이 줄어드는 자정에도 불구하고 신규 커넥션 연결 수가 줄어들지 않고 오히려 증가하는 현상이었죠. 혹시 매번 새로운 Redis 커넥션이 생성되어 서비스 지연을 유발하는 것은 아닐까 하는 우려가 들었습니다. 커넥션 풀 설정을 확인해보니 분명 활성화되어 있었는데, 왜 이런 현상이 발생하는지 깊이 파고들어 보았습니다.

파이프라이닝과 Lettuce의 Redis 커넥션 동작 방식 이해

가장 먼저 Redis 파이프라이닝 사용 방식에 주목했습니다. 저희 팀에서는 특정 사용자가 최근 본 상품을 캐싱하는 데 Redis List 자료구조를 활용하며, 여러 명령어를 효율적으로 처리하기 위해 redisTemplate.executePipelined 메소드를 사용하고 있었습니다. 이 메소드는 일반적인 redisTemplate 사용과 달리, 파이프라이닝 처리를 위한 전용 커넥션을 할당받는다는 사실을 확인했습니다.

LettuceConnectionopenPipeline() 메소드 내부에서 this.getOrCreateDedicatedConnection()을 호출하며 전용 커넥션이 생성되는 구조였죠. 저희는 테스트 코드를 통해 openPipeline() 사용 시 공유 커넥션이 아닌 전용 커넥션이 할당되는 것을 명확히 확인할 수 있었습니다. 이는 파이프라이닝이 단일 커넥션을 공유하지 않고 개별적인 커넥션을 사용한다는 중요한 단서를 제공했습니다.

// RedisTemplate.executePipelined 내부
public List<Object> executePipelined(RedisCallback<?> action, @Nullable RedisSerializer<?> resultSerializer) {
    return execute((RedisCallback<List<Object>>) connection -> {
        connection.openPipeline(); // 파이프라인 여는 부분
        // ...
    });
}

// LettuceConnection.openPipeline 내부
public void openPipeline() {
    if (!isPipelined) {
        isPipelined = true; // 파이프라이닝 마킹
        // ...
        pipeliningFlushState.onOpen(this.getOrCreateDedicatedConnection()); // 전용 커넥션 할당
    }
}

커넥션 풀 설정은 완벽했을까? 숨겨진 함정들

파이프라이닝이 전용 커넥션을 사용한다 해도, 커넥션 풀이 있다면 풀에서 커넥션을 재활용해야 정상입니다. 프로젝트에는 Lettuce커넥션 풀이 최대 20개로 설정되어 있었기에 신규 커넥션이 계속 늘어나는 것은 납득하기 어려웠습니다.

spring:
  data:
    redis:
      lettuce:
        pool:
          enabled: true
          maxActive: 20
          # ...

로그를 살펴보던 중, LettuceConnectionWatchDog 클래스에서 Redis 커넥션이 끊어지면 자동으로 재연결을 시도하는 reconnect 메시지를 발견했습니다. 즉, Redis와 애플리케이션 사이의 커넥션이 어떤 이유로 끊어지고 있었던 것이죠.

// ConnectionWatchDog.channelInactive 내부
public class ConnectionWatchdog extends ChannelInboundHandlerAdapter {
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        if (listenOnChannelInactive && !reconnectionHandler.isReconnectSuspended()) {
            scheduleReconnect(); // Channel이 inactive된 경우 해당 메소드 호출
        }
        // ...
    }
}

Elasticache timeout과 IDLE 커넥션의 미스터리

여기서 커넥션 풀의 중요한 기본 동작을 이해해야 합니다. GenericObjectPool은 기본적으로 LIFO(Last-In, First-Out) 전략을 사용합니다. 이는 최근에 사용한 커넥션 위주로 재사용하고, 오랫동안 IDLE 상태로 있던 커넥션은 풀 깊숙이 남아있게 된다는 의미입니다.

더불어 커넥션 풀 설정 시 timeBetweenEvictionRunsminEvictableIdleDuration 설정이 없으면 IDLE 커넥션에 대한 자동 정리가 이루어지지 않습니다. 저희 프로젝트에는 이 설정들이 누락되어 IDLE 커넥션이 계속 쌓이는 상황이었습니다.

진정한 원인은 바로 Elasticachetimeout 파라미터에 있었습니다. 현재 사용하고 있는 Elasticachetimeout이 100초로 설정되어 있어, 100초 동안 사용하지 않는 IDLE 커넥션을 자동으로 끊고 있었습니다.

정리하면 아래와 같은 악순환이 발생하고 있었습니다.

  1. Elasticache에서 100초 동안 사용하지 않는 IDLE 커넥션을 제거합니다.
  2. 애플리케이션 측에서는 Elasticache에서 커넥션을 닫으면, LettuceConnectionWatchDog에 의해 재연결이 시도됩니다.

이로 인해 트래픽이 많은 피크 시간에는 커넥션 풀커넥션이 고르게 사용되지만, 자정처럼 트래픽이 줄어들면 많은 IDLE 커넥션timeout에 의해 끊어지고 재연결되면서 신규 커넥션이 오히려 늘어나는 기묘한 현상이 벌어졌던 것입니다.

현명한 Redis 커넥션 관리, 두 가지 해결책

Redistimeout 파라미터를 늘리는 방법도 있었지만, 100초 timeout은 합리적이라고 판단하여 애플리케이션 단에서 문제를 해결하기로 했습니다. 두 가지 방안을 고안했습니다.

1. 효과적인 커넥션 풀 운영을 위한 전략 변경 (LIFO → FIFO)

첫 번째는 커넥션 풀의 LIFO 전략을 FIFO(First-In, First-Out)로 변경하는 것입니다. GenericObjectPoolConfiglifo 값을 false로 설정하여, 반납된 커넥션이 먼저 재사용되도록 했습니다. 이렇게 하면 커넥션 풀 내부의 커넥션들이 고르게 사용되어 특정 커넥션IDLE 상태로 오래 남아있다가 timeout에 의해 끊기는 현상을 줄일 수 있습니다. 테스트를 통해 FIFO 전략이 의도대로 동작하여 커넥션 재사용성이 높아지는 것을 확인했습니다.

val poolConfig = GenericObjectPoolConfig<io.lettuce.core.api.StatefulRedisConnection<String, String>>()
// ...
poolConfig.lifo = false // 커넥션 풀 fifo로 조정

2. IDLE 커넥션 자동 정리를 통한 자원 최적화

두 번째는 IDLE 커넥션을 주기적으로 정리하는 것입니다. minEvictableIdleDuration (설정한 시간 동안 IDLE 상태인 커넥션 제거 대상으로 간주)과 timeBetweenEvictionRuns (IDLE 커넥션 제거 작업 주기) 설정을 추가하여 커넥션 풀이 스스로 불필요한 IDLE 커넥션을 정리하도록 했습니다. 이 설정을 통해 Elasticache timeout보다 더 빠른 주기로 애플리케이션이 IDLE 커넥션을 관리하게 되므로, 불필요한 reconnect를 방지할 수 있습니다. 실제 테스트를 통해 IDLE 커넥션이 효과적으로 제거되는 것을 확인했습니다.

val poolConfig = GenericObjectPoolConfig<io.lettuce.core.api.StatefulRedisConnection<String, String>>()
// ...
poolConfig.minEvictableIdleDuration = Duration.ofSeconds(30L) // 커넥션이 해당 시간만큼 아무 일을 하지 않으면 제거 대상으로 간주
poolConfig.timeBetweenEvictionRuns = Duration.ofSeconds(30L) // Idle 커넥션 제거 작업 주기

문제 해결의 여정, 그리고 변화

저희 팀은 IDLE 커넥션을 계속해서 유지할 필요는 없다고 판단하여 두 번째 방법, 즉 IDLE 커넥션 자동 정리 전략을 채택했습니다. 적용 결과, 피크 시간 이후에도 신규 커넥션 지표가 안정적으로 유지되는 것을 확인할 수 있었습니다.

이번 경험을 통해 Spring Data RedisLettuce의 설정뿐만 아니라, Redis OSSElasticache의 파라미터까지 다각적으로 이해하는 것이 Redis 커넥션 문제 해결에 얼마나 중요한지 깨달았습니다. 복잡한 시스템에서 발생하는 Redis 커넥션 문제는 단순히 표면적인 현상만을 보아서는 해결하기 어렵습니다. 커넥션 풀의 동작 원리, 클라이언트 라이브러리의 특성, 그리고 데이터베이스 자체의 timeout 설정까지 전반적인 이해가 필수적입니다.

이 글이 Redis 커넥션 관련 문제로 고민하는 다른 개발자분들께 실질적인 도움이 되기를 바랍니다.


참고기사 : https://techblog.woowahan.com/23121/
Korea Tech : https://alroetech.com/category/koreatech/