Redis New Connection 증가 이슈 돌아보기

3 weeks ago 10

서비스 성능을 높이기 위해 여러 영역에서 캐싱을 활용하고 있습니다. 그중 사용자 맞춤 데이터를 빠르게 제공하기 위해 Redis를 사용하고 있는데요. Redis 관련 지표를 모니터링 하던 중 신규 커넥션 생성이 많은 현상을 발견하였습니다. 처음엔 단순한 설정 문제로 생각했지만, 원인을 추적하는 과정에서 Redis와 Lettuce, Elasticache의 동작 방식을 깊이 이해하게 되었습니다. 이번 글에서는 그 과정을 공유하려 합니다.

프로젝트 환경

  • Spring Boot 3.2.4
  • lettuce 6.3.2
  • elasticache valkey 8

상황

Redis 관련 지표를 살펴보다가 신규 커넥션 연결 수량 지표가 이상한 것을 확인하였습니다. 오후 저녁 시간대가 지나서 자정으로 향하는데도 불구하고 신규 커넥션 연결 지표가 줄어들지 않고 있었습니다. 행여라도 조회 시점에 매번 새로운 커넥션을 만들고 있으면 커넥션 생성시간 동안 지연이 될 가능성도 있을 것 같았습니다.

혹시라도 커넥션 풀 없이 매번 커넥션을 새로 만드나 하여 서버 내 Lettuce 설정을 보았을 때 이미 커넥션 풀을 사용하고 있었습니다. 그렇다면 커넥션을 생성하더라도 적은 수의 커넥션을 생성할 것으로 예상이 되었는데요. 커넥션을 새로 맺는 횟수가 오히려 피크시간에 비해 자정에 더 높게 잡히는 것에서 의문이 들어 상황을 좀 더 파악해 보았습니다.

가장 먼저 의심한 부분은, 파이프라이닝에서 커넥션을 만드는 부분이었습니다.

파이프라이닝에서의 커넥션 사용

저희 팀에서는 특정 사용자가 최근 본 상품을 캐싱하는 데 Redis를 사용하고 있습니다. 이 때, redis list 자료구조를 사용하고 있는데요. list의 데이터 중 일부 데이터들만을 효율적으로 처리하기 위해 파이프라이닝을 사용하고 있습니다.

파이프라이닝 내부에서 수행하는 동작은 대략 아래와 같습니다.

  • push (최근 본 상품 추가)
  • trim (전체 list 개수 조정)
  • expire (TTL 재조정)

일반적으로 redisTemplate을 사용하는 경우, 하나의 커넥션을 공유해서 사용하는데요. 위 케이스에서는 여러 명령어를 묶어서 효율적으로 처리하기 위해 redisTemplate의 executePipelined 메소드를 사용하여 파이프라이닝을 구성하고 있는 상태였습니다. executePipelined 메소드를 사용하는 경우, 파이프라이닝 처리를 위한 전용 커넥션을 할당받게 됩니다.

코드 분석

RedisTemplate의 executePipelined 메소드를 조금 더 살펴보겠습니다.

public class RedisTemplate<K, V> extends RedisAccessor implements RedisOperations<K, V>, BeanClassLoaderAware { ... @Override public List<Object> executePipelined(RedisCallback<?> action, @Nullable RedisSerializer<?> resultSerializer) { return execute((RedisCallback<List<Object>>) connection -> { connection.openPipeline(); // pipeline 여는 부분 ... }); } }

LettuceConnection의 openPipelined 메소드 내부에서 전용 커넥션을 생성해 주고 있습니다.

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

위 openPipelined() 메소드에 의해 단일 커넥션을 사용하지 않고, 여러 개의 커넥션을 사용하고 있다는 것을 알게 되었는데요.
실제로 openPipelined()이 단일 커넥션을 사용하는 것이 아닌 전용 커넥션을 할당하는지 테스트 코드를 통해 확인해 보았습니다. 아래는 관련 테스트 코드입니다.

@SpringBootTest class ConnectionPoolTest { @Autowired private lateinit var lettuceConnectionFactory: LettuceConnectionFactory @Test fun pipeline을_사용하지_않는_경우_기본적으로_동일한_커넥션을_사용한다() { val connectionA = lettuceConnectionFactory.connection val connectionB = lettuceConnectionFactory.connection assertThat(connectionA.nativeConnection).isEqualTo(connectionB.nativeConnection) } @Test fun pipeline을_사용하는_경우_전용_커넥션을_사용한다() { val connectionA = lettuceConnectionFactory.connection val connectionB = lettuceConnectionFactory.connection connectionA.openPipeline() connectionB.openPipeline() assertThat(connectionA.nativeConnection).isNotEqualTo(connectionB.nativeConnection) } }

테스트 결과, openPipeline() 메소드를 사용한 경우, 공유 커넥션이 아닌 전용 커넥션을 할당받는다는 것을 알 수 있었습니다.

커넥션 풀

그렇다면 파이프라이닝에서 커넥션을 새로 만드는 부분이 문제인가 싶었는데요. 하지만 openPipeline 메소드에서 전용 커넥션을 얻어올 때, 커넥션 풀이 설정되어 있으면 커넥션 풀에서 커넥션을 얻어옵니다.

커넥션 풀에 대한 설정은 application.yml에 이미 되어 있는 상태였으며, 풀의 개수 설정은 최대 20개로 되어 있는 상태였습니다. 그렇다면 한 번 만들어진 커넥션 풀을 계속해서 재사용한다면 커넥션 생성 수가 많지 않아야 할 것 같았습니다.

프로젝트 내 설정

  • spring data redis + lettuce 에서의 pool 설정시 common-pool2에 대한 의존성이 필요합니다.
implementation("org.apache.commons:commons-pool2:2.12.0") spring: data: redis: ... lettuce: pool: enabled: true ... maxActive: 20 ...

관련해서 서버 내 로그를 조금 더 확인해 보니 이러한 로그를 확인할 수 있었습니다.

로그를 확인해 보니 커넥션에 대한 reconnect가 일어났다는걸 알 수 있었습니다.

남은 로그를 조금 더 파악해 보니 lettuce의 ConnectionWatchDog 클래스에서 커넥션이 끊어지면 커넥션을 다시 맺게 되며, 커넥션을 다시 맺을 때 위와 같은 로그를 남기고 있었습니다.

public class ConnectionWatchdog extends ChannelInboundHandlerAdapter { ... @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { ... if (listenOnChannelInactive && !reconnectionHandler.isReconnectSuspended()) { scheduleReconnect(); // Channel이 inactive된 경우 해당 메소드 호출 } else { logger.debug("{} Reconnect scheduling disabled", logPrefix(), ctx); } super.channelInactive(ctx); } ... }

모종의 사유로 Redis Connection이 끊어진 것으로 보입니다. 그렇다면 왜 커넥션이 끊어지게 되었는지 알아보겠습니다.

그전에 커넥션 풀의 동작에 대해 알아두어야 할 부분이 있습니다.
GenericObjectPool을 사용하는 경우, 커넥션풀에서 커넥션을 가져오는 전략은 기본적으로 LIFO 전략입니다. 즉, 커넥션 풀 내부에 있는 모든 커넥션을 고르게 사용하는 것이 아닌, 최근에 사용한 커넥션 위주로 사용하는 전략입니다.

public abstract class BaseObjectPoolConfig<T> extends BaseObject implements Cloneable { /** * The default value for the {@code lifo} configuration attribute. * * @see GenericObjectPool#getLifo() * @see GenericKeyedObjectPool#getLifo() */ public static final boolean DEFAULT_LIFO = true; ... }

그렇다면 만약 일시적으로 지연이 생기거나 트래픽이 몰리게 되어 커넥션 풀이 최대로 만들어지고, 이후 상황이 해소되면 어떻게 될까요?

커넥션 풀에서 최근 사용한 몇몇 커넥션에 대해서만 사용하고, 대부분의 커넥션은 IDLE 상태로 남아 있게 됩니다.
또한, 커넥션 풀 설정 시 별도 설정을 하지 않으면, 기본적으로 IDLE 커넥션에 대해서 정리하지 않고 있습니다.

  • timeBetweenEvictionRuns, minEvictableIdleDuration이 모두 설정되어야 idle object에 대한 eviction이 일어납니다.
  • timeBetweenEvictionRuns의 기본값은 -1입니다. 해당 값이 음수인 경우, eviction이 동작하지 않으며 양수인 경우에만 동작합니다.
  • minEvictableIdleDuration의 기본값은 30분입니다.

프로젝트 설정 내에서는 위 두 설정이 존재하지 않아 IDLE 커넥션이 계속해서 남아있는 상태였습니다.

public abstract class BaseObjectPoolConfig<T> extends BaseObject implements Cloneable { ... /** * The default value for the {@code timeBetweenEvictionRuns} configuration attribute. * * @see GenericObjectPool#getDurationBetweenEvictionRuns() * @see GenericKeyedObjectPool#getDurationBetweenEvictionRuns() * @deprecated Use {@link #DEFAULT_TIME_BETWEEN_EVICTION_RUNS}. */ @Deprecated public static final long DEFAULT_TIME_BETWEEN_EVICTION_RUNS_MILLIS = -1L; ... // BaseObjectPoolConfig /** * The default value for the {@code minEvictableIdleDuration} configuration attribute. * * @see GenericObjectPool#getMinEvictableIdleDuration() * @see GenericKeyedObjectPool#getMinEvictableIdleDuration() * @deprecated Use {@link #DEFAULT_MIN_EVICTABLE_IDLE_TIME}. */ @Deprecated public static final long DEFAULT_MIN_EVICTABLE_IDLE_TIME_MILLIS = 1000L * 60L * 30L; ... }

IDLE 커넥션에 대해서 정리가 되지 않는다고 하더라도, 그렇다면 왜 신규 연결이 많이 맺어졌을까요? 이유는 Redis의 timeout 파라미터 설정에 있었습니다.

Redis에 설정할 수 있는 parameter에는 timeout이라는 파라미터가 존재합니다. timeout 파라미터는 redis에서 idle 클라이언트에 대한 연결을 끊기 전에 기다리는 시간입니다. aws elasticache에서는 parameter group에서 해당 값을 확인할 수 있었습니다.

현재 사용하고 있는 Elasticache 기준으로 timeout 파라미터가 100으로 설정되어 있어, 100초간 idle 커넥션을 사용하지 않으면 Elasticache에서 커넥션을 제거하고 있었습니다.

정리해 보면 아래와 같습니다.

  1. elasticache에서 100초 동안 사용하지 않는 IDLE 커넥션을 제거합니다.
  2. 서버 기준에서는 elasticache에서 커넥션을 닫으면, 서버의 ConnectionWatchDog에 의해 재연결이 되고 있었습니다.

로컬 환경에서 테스트해 본 결과, 커넥션을 열어두고 사용하지 않으면 실제로 100초 간격으로 커넥션이 재연결 되는 것을 확인할 수 있었습니다.

위에서 자정에 오히려 더 커넥션이 늘어나게 된 것은 피크시간 기준으로 많은 커넥션을 만들어 두었는데, 자정 기준으로 IDLE 커넥션이 많아지면서 오히려 New Connection이 많아진 상황으로 볼 수 있었습니다.

해소

redis의 timeout 파라미터를 늘리는 방법도 있지만, 100초 동안 사용하지 않는 커넥션을 제거하는 건 타당하다고 보여 파라미터를 수정하지는 않았습니다.
대신 애플리케이션 단에서 수행할 방안을 두 가지 정도 고안해 보았습니다.

1번 방법 : LIFO → FIFO로 커넥션 풀 전략 변경

레디스 커넥션 풀 설정 시 아래와 같은 설정을 추가하여 LIFO에서 FIFO로 커넥션 풀 전략을 수정합니다

  • application.yml에서는 해당 파라메터를 수정할 수 없어서, 직접 커넥션 풀 config를 설정하여 Lettuce 설정에 넣어주어야 합니다.
  • GenericObjectPoolConfig 내부의 lifo 값을 수정하여 커넥션 풀 전략을 조정할 수 있습니다.
val poolConfig = GenericObjectPoolConfig<io.lettuce.core.api.StatefulRedisConnection<String, String>>() val poolProperties = redisProperties.lettuce.pool poolConfig.maxTotal = poolProperties.maxActive poolConfig.maxIdle = poolProperties.maxIdle poolConfig.setMaxWait(poolProperties.maxWait) poolConfig.lifo = false // 커넥션풀 fifo로 조정 val clientConfig = LettucePoolingClientConfiguration.builder() .poolConfig(poolConfig) .build()

실제로 예상한 대로 동작하는지 테스트해 보겠습니다.

@SpringBootTest class ConnectionPoolQueueTest { @Autowired private lateinit var lettuceConnectionFactory: LettuceConnectionFactory @Test fun 커넥션풀이_fifo인_경우를_테스트한다() { val aRedisConnection = lettuceConnectionFactory.connection val bRedisConnection = lettuceConnectionFactory.connection aRedisConnection.openPipeline() bRedisConnection.openPipeline() val aConnection = aRedisConnection.nativeConnection val bConnection = bRedisConnection.nativeConnection // a,b 커넥션 종료 aRedisConnection.close() bRedisConnection.close() val connectionC = lettuceConnectionFactory.connection val connectionD = lettuceConnectionFactory.connection connectionC.openPipeline() connectionD.openPipeline() // fifo이므로 C, D 커넥션 할당 시에 -> A, B 커넥션이 다시 할당된다. assert(aConnection == connectionC.nativeConnection) assert(bConnection == connectionD.nativeConnection) } }

실행 결과

LIFO 방식이 아닌 FIFO 방식으로, 반납한 순서대로 커넥션을 가져와서 쓰는 걸 확인할 수 있었습니다. 즉, 트래픽이 계속해서 들어온다면 커넥션 풀에 있는 커넥션들이 IDLE 상태로 남아있지 않고 계속해서 재사용될 것을 기대할 수 있습니다.

2번 방법 : IDLE 커넥션에 대해서 정리

IDLE 커넥션에 대해 정리하기 위해서는, 커넥션 풀에 아래와 같은 설정이 필요합니다.

  • minEvictableIdleDuration : 커넥션이 설정한 시간 동안 아무 일을 하지 않으면(IDLE) 제거 대상으로 간주
  • timeBetweenEvictionRuns : IDLE 커넥션 제거 작업 주기
val poolConfig = GenericObjectPoolConfig<io.lettuce.core.api.StatefulRedisConnection<String, String>>() val poolProperties = redisProperties.lettuce.pool poolConfig.maxTotal = poolProperties.maxActive poolConfig.maxIdle = poolProperties.maxIdle poolConfig.setMaxWait(poolProperties.maxWait) poolConfig.minEvictableIdleDuration = Duration.ofSeconds(30L) // 커넥션이 해당 시간만큼 아무 일을 하지 않으면 제거 대상으로 간주 poolConfig.timeBetweenEvictionRuns = Duration.ofSeconds(30L) // Idle 커넥션 제거 작업 주기 val clientConfig = LettucePoolingClientConfiguration.builder() .poolConfig(poolConfig) .build()

실제로, IDLE 커넥션에 제거가 잘 일어나는지 테스트해 보겠습니다.

@SpringBootTest class ConnectionPoolEvictTest { @Autowired private lateinit var lettuceConnectionFactory: LettuceConnectionFactory @Test fun 커넥션풀_evict_테스트() { val connectionA = lettuceConnectionFactory.connection val connectionB = lettuceConnectionFactory.connection connectionA.openPipeline() connectionB.openPipeline() connectionA.close() connectionB.close() sleep(60000L) // 위 테스트에서는 evict 설정을 30초로 해주었으니, 여유 두어 60초정도 대기 println("") // bp용 call } }

이번 테스트에서는, 커넥션 풀 내부의 커넥션 상태를 보기 위해 assertion이 아닌 브레이크포인트를 설정하여 테스트를 진행하였습니다.
테스트를 통해 LettuceConnectionFactory 내부에서 사용하고 있는 커넥션 풀 내부에 있는 커넥션을 확인하였습니다.

먼저, evict 관련 설정이 없는 경우의 실행 결과를 확인해 보겠습니다.

커넥션이 정리되지 않고, 3개의 커넥션이 커넥션 풀에 계속해서 유지되는 것을 확인할 수 있습니다.

다음으로, evict 관련 설정이 있는 경우의 실행 결과입니다.

실행 결과를 보았을 때 IDLE 커넥션에 대한 eviction이 잘 일어나서 커넥션 풀 내부에 커넥션이 한 개만 있는 것을 확인할 수 있었습니다.

적용 결과

저희 프로젝트에서는 IDLE 커넥션에 대해 커넥션을 계속해서 연결해 놓지 않아도 될 거라 판단하여 2번 방법을 채택하였습니다.
개선 작업 이후 피크시간 이후에도 계속해서 New Connection 지표가 늘어나지 않는 것을 확인하였습니다.

마무리

지금까지 Redis New Connection 문제와 어떻게 해결하였는지에 대해 소개해 드렸습니다. Redis를 사용하는 데 있어 Spring data Redis 설정뿐 아니라 Redis OSS Parameter까지 다양한 설정을 확인해야 한다는 점을 배우게 된 계기가 되었습니다.

이 글이 관련된 문제를 확인하고 계신 다른 분들께 도움이 되었기를 바랍니다.

참고 문서

Read Entire Article