Skip to main content

Redis Cluster를 사용할 때 Spring Boot와 Lettuce client를 설정해 드립니다

About 3 minJavaSpringSpring BootRedisArticle(s)blogmeetup.nhncloud.comjavajdkspringspring-bootredis

Redis Cluster를 사용할 때 Spring Boot와 Lettuce client를 설정해 드립니다 관련

Spring > Article(s)

Article(s)

Redis Cluster를 사용할 때 Spring Boot와 Lettuce client를 설정해 드립니다 | NHN Cloud Meetup
Redis Cluster를 사용할 때 Spring Boot와 Lettuce client를 설정해 드립니다
Redis Lettuce
Redis Lettuce

개요

Spring Boot에서 Redis에 명령어를 실행하기 위하여 Lettuce 라이브러리를 기본으로 사용하고 있습니다. 이때 자동 설정(Auto Configuration)을 사용하기보다 몇 가지 설정 값을 튜닝해서 사용한다면 보다 안정적으로 여러분들의 서비스를 운영할 수 있습니다.

Spring Boot 애플리케이션에서 Redis를 사용하기 위하여 일반적으로는 Spring-Boot-Starter-Data-Redis 의존성을 추가해서 사용합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Redis에 명령어를 실행하려면 자동 설정된 RedisTemplate 스프링 빈을 주입하여 제공하는 메서드를 실행합니다. 그리고 Spring Boot에서 제공하는 RedisAutoConfiguration이 자동 설정을 담당합니다. 다음 RedisAutoConfiguration의 코드와 자동 설정이 동작하는 조건을 살펴보겠습니다.

@AutoConfiguration
@ConditionalOnClass(RedisOperations.class)                // ---------- (1)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")            // ---------- (2)
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)  // ---------- (3)
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 생략
        return template;
    }

    @Bean
    @ConditionalOnMissingBean                                    // ---------- (4)
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)  // ---------- (5)
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        return new StringRedisTemplate(redisConnectionFactory);
    }
}

.RedisAutoConfiguration.java의 주석을 살펴봅시다.

  1. Spring-Data-Redis의 RedisOperation 클래스가 클래스패스에 있는 조건이면 RedisAutoConfiguration이 동작한다.
  2. 스프링 애플리케이션에 RedisTemplate redisTemplate 스프링 빈이 없는 경우
  3. 스프링 애플리케이션에 RedisConnectionFactory 스프링 빈이 하나만 존재하는 경우 (2)번 항목과 함께 만족하면 RedisTemplate 스프링 빈을 생성한다.
  4. 스프링 애플리케이션에 StringRedisTemplate stringRedisTemplate 스프링 빈이 없는 경우
  5. 스프링 애플리케이션에 RedisConnectionFactory 스프링 빈이 하나만 존재하는 경우 (4)번 항목과 함께 만족하면 StringRedisTemplate 스프링 빈을 생성한다.

그러므로 개발자는 스프링 애플리케이션에 RedisConnectionFactory 스프링 빈을 하나만 설정하면 됩니다. 자동 설정에 의해서 RedisTemplate redisTemplate, RedisTemplate stringRedisTemplate 스프링 빈이 자동 생성됩니다. RedisConnectionFactory는 Redis와 애플리케이션 사이에 Connection 객체를 생성하는 기능을 제공합니다. LettuceConnectionFactoryRedisConnectionFactory의 구현 클래스들 중 하나이며, LettuceConnection 객체를 생성하는 팩토리 클래스입니다.


RedisConnectionFactory 설정 - LettuceConnectionFactory

Redis 클러스터에 연결하기 위한 위한 RedisConnectionFactory 스프링 빈 생성 코드를 확인해 봅시다. 전체 코드는 다음과 같으며 각 설정의 상세 설명은 뒤에서 다시 설명합니다. RedisConnectionFactory 객체를 개발자가 설정할 수 있는 부분은 크게 4부분으로 나눌 수 있습니다.

  • SocketOptions: 소켓 설정
  • ClusterTopologyRefreshOptions: Redis 클러스터 토폴로지 정보를 클라이언트에 동기화하는 설정
  • ClusterClientOptions: SocketOptions + ClusterTopologyRefreshOptions를 사용하여 클라이언트를 설정
  • LettuceClientConfiguration: ClusterClientOptions를 사용하여 Lettuce 클라이언트를 설정
    @Bean(name = "redisConnectionFactory")
    public RedisConnectionFactory redisConnectionFactory() {

        //----------------- (1) Socket Option
        SocketOptions socketOptions = SocketOptions.builder()
                                                   .connectTimeout(Duration.ofMillis(100L))
                                                   .keepAlive(true)
                                                   .build();

        //----------------- (2) Cluster topology refresh 옵션
        ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions
                .builder()
                .dynamicRefreshSources(true)
                .enableAllAdaptiveRefreshTriggers()
                .enablePeriodicRefresh(Duration.ofSeconds(30))
                .build();

        //----------------- (3) Cluster client 옵션
        ClusterClientOptions clusterClientOptions = ClusterClientOptions
                .builder()
                .pingBeforeActivateConnection(true)
                .autoReconnect(true)
                .socketOptions(socketOptions)
                .topologyRefreshOptions(clusterTopologyRefreshOptions)
                .maxRedirects(3).build();

        //----------------- (4) Lettuce Client 옵션
        final LettuceClientConfiguration clientConfig = LettuceClientConfiguration
                .builder()
                .commandTimeout(Duration.ofMillis(150L))
                .clientOptions(clusterClientOptions)
                .build();

        RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(redisClusterProperties.getNodes());
        clusterConfig.setMaxRedirects(3);
        clusterConfig.setPassword("password");

        LettuceConnectionFactory factory = new LettuceConnectionFactory(clusterConfig, clientConfig);
        //----------------- (5) LettuceConnectionFactory 옵션
        factory.setValidateConnection(false);

        return factory;
    }

소켓 옵션(SocketOptions.java)

SocketOptions socketOptions = SocketOptions.builder()
        .connectTimeout(Duration.ofMillis(100L))
        .keepAlive(true)
        .build();

Lettuce 라이브러리를 사용한다면 Keep Alive 기능을 활성화하고 Connection timeout을 설정하는 것을 추천합니다.

keepAlive 옵션을 활성화(keepAlive(true))하면, 애플리케이션 런타임 중에 실패한 연결을 처리해야 할 상황이 줄어듭니다. 이 속성은 TCP Keep Alive 기능을 설정합니다. TCP Keep Alive는 다음과 같은 특성을 가집니다.

  • TCP Keep Alive를 켜면 오랫동안 데이터를 전송하지 않아도, TCP Connection이 활성된 상태로 유지됩니다.
  • TCP Keep Alive는 주기적으로 프로브(Probe)나 메시지를 전송하고 Acknowledgment를 수신합니다.
  • 만약 Acknowledgment가 주어진 시간에 오지 않는다면, TCP Connection은 끊어진 걸로 간주되어 종료됩니다.

Java 애플리케이션에서 TCP Keep Alive를 활성화하기 위해서는 몇 가지 조건이 필요합니다. 다음을 참고하시길 바랍니다.

ConnectionTimeout에 설정된 시간 값(connectTimeout(Duration.ofMillis(100L)))은 애플리케이션과 Redis 사이에 LettuceConnection을 생성하는 시간 초과 값입니다. 일반적으로 Redis와 애플리케이션은 내부 네트워크를 사용하고 있으므로 커넥션을 생성하는 시간을 짧게 두어도 무방합니다. 예제에서는 100ms로 설정했습니다. connectionTimeout은 뒤에서 설명할 command timeout과 같이 반드시 설정해야 하는 값입니다.

네트워크 또는 Redis에 문제가 발생하여 Redis 명령어를 빠르게 실행할 수 없다면 애플리케이션 처리량까지 느려질 수 있습니다. 두 설정 값을 너무 크게 잡지 않는다면(예: 1초 이상) Redis나 네트워크에 문제가 발생했을 때 빠르게 예외(Exception)를 발생시킬 수 있습니다. 그래서 애플리케이션이 연쇄적인 장애에 빠지지 않게, 시스템을 격리/보호하는 전략도 고려해 볼 수 있습니다. 비즈니스 로직에 따라서 빠른 실패가 시스템 전체를 보호할 수 있습니다.

클러스터 토폴로지 갱신 옵션(ClusterTopologyRefreshOptions)

ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions
    .builder()
    .dynamicRefreshSources(true)                    // 모든 Redis 노드로부터 topology 정보 획득. default = true
    .enableAllAdaptiveRefreshTriggers()             // Redis 클러스터에서 발생하는 모든 이벤트(MOVE, ACK)등에 대해서 topology 갱신
    .enablePeriodicRefresh(Duration.ofSeconds(30))  // 주기적으로 토폴로지를 갱신하는 시간 
    .build();

Redis 클러스터는 3개 이상의 Redis 노드들로 구성되어 있습니다. Redis 클러스터에 노드를 추가/삭제 또는 Master 승격 같은 이벤트가 발생하면 토폴로지가 변경됩니다. Redis 클러스터를 연결된 클라이언트 애플리케이션은 최신의 Redis 클러스터 정보를 동기화합니다. 그래서 클라이언트 애플리케이션이 어떤 노드에 데이터를 조회/생성/삭제할지 미리 알고 있습니다. ClusterTopologyRefreshOptions는 Redis 클러스터 토폴로지에 변경이 발생했을 때 클라이언트 애플리케이션이 가진 토폴로지 갱신과 관련된 설정 기능을 제공합니다.

enablePeriodicRefresh()의 시간 인자는 클라이언트 애플리케이션이 Redis 토폴로지를 갱신하는 주기를 설정합니다. 하지만 dynamicRefreshSources(), enableAllAdaptiveRefreshTriggers()는 Redis 클러스터에서 발생하는 이벤트를 클라이언트 애플리케이션이 수신하여 토폴로지를 갱신하는 차이가 있습니다.

만약 클라이언트 애플리케이션의 토폴로지 정보가 업데이트되지 않아 잘못된 노드에 명령어를 실행해도 문제없습니다. Redis 노드들 또한 토폴로지 정보를 업데이트하고 있으며, MOVED 응답으로 해당 데이터가 저장된 정확한 노드를 응답합니다.

127.0.0.1:7100> get session:12351712:member:123123123
(error) MOVED 7879 127.0.0.1:7200

enablePeriodicRefresh()의 기본값은 60초입니다. 이 옵션이 비활성화되면 클라이언트 애플리케이션은 클러스터에 명령을 실행하고 오류가 발생할 때만 클러스터 토폴로지를 업데이트합니다. 대규모의 Redis 클러스터를 사용하고 있다면 리프레시 주기를 길게 가져가는 것이 좋습니다. 갱신 시간 값이 짧고 Redis 클러스터의 노드 수가 많은 클라이언트 애플리케이션이 자주 토폴로지를 갱신한다면, Redis 클러스터 전체에 부하가 될 수 있습니다.

enableAllAdaptiveRefreshTriggers()는 Redis 클러스터에서 발행하는 모든 트리거에 대해서 토폴로지를 갱신합니다. 트리거는 MOVED_REDIRECT, ASK_REDIRECT, PERSISTENT_RECONNECTS, UNCOVERED_SLOT, UNKNOWN_NODE 등이 될 수 있습니다.

dynamicRefreshSources()의 기본 값은 true입니다. 소규모 클러스터에는 DynamicRefreshResources를 활성화하고 대규모 클러스터에는 비활성화하는 것이 좋습니다. 이 설정이 false이면 Redis 클라이언트는 seed 노드에만 질의하여 새로운 노드를 찾는 데 사용합니다. 이 경우 문제가 있는 노드가 클라이언트 애플리케이션의 토폴로지 정보에서 제외되는 데 시간이 소요됩니다. 이 설정이 true이면 Redis 클라이언트는 모든 Redis 클러스터 노드에게 질의하여 결과를 비교합니다. 그래서 새로운 정보로 토폴로지를 업데이트합니다. 그러므로 대규모 Redis 클러스터에는 DynamicRefreshResources 기능을 끄는 것을 추천합니다.

클러스터 클라이언트 옵션(ClusterClientOptions)

ClusterClientOptions clusterClientOptions = ClusterClientOptions
    .builder()
    .pingBeforeActivateConnection(true)                        // 커넥션을 사용하기 위하여 PING 명령어를 사용하여 검증합니다.
    .autoReconnect(true)                                       // 자동 재접속 옵션을 사용합니다.
    .socketOptions(socketOptions)                              // 앞서 생성한 socketOptions 객체를 세팅합니다.
    .topologyRefreshOptions(clusterTopologyRefreshOptions)     // 앞서 생성한 clusterTopologyRefreshOptions 객체를 생성합니다.
    .maxRedirects(3).build();

maxRedirects() 옵션은 Redis 클러스터가 MOVED_REDIRECT를 응답할 때 클라이언트 애플리케이션에서 Redirect하는 최대 횟수를 설정합니다.

Redis 클라이언트는 Redis 토폴로지 정보를 동기화하고 있습니다. 각 Redis 노드의 마스터/슬레이브 정보와 IP, 그리고 데이터를 분배하는 정보인 슬롯 범위를 동기화합니다. 만약 Redis 클라이언트가 토폴로지 업데이트에 실패하거나 동기화하지 못한 경우, 잘못된 노드에 Redis 명령을 실행할 수 있습니다. 이 경우 Redis는 실패(MOVED_REDIRECT)를 응답하고 클라이언트는 적절한 노드로 리다이렉션할 수 있습니다. 만약 Redis 클러스터가 3대의 노드로 구성되어 있다면 maxRedirects 값을 3으로 설정했다고 생각해 봅시다. 이 경우 클라이언트 애플리케이션이 실행한 명령어가 실패할 확률은 매우 줄어듭니다.


LettuceClientConfiguration

final LettuceClientConfiguration clientConfig = LettuceClientConfiguration
    .builder()
    .commandTimeout(Duration.ofMillis(150L)) // ----------- 명령어 타임아웃 설정.
    .clientOptions(clusterClientOptions)
    .build();

Lettuce 라이브러리는 지연 연결을 사용하고 있으므로, Command Timeout 값이 Connection Timeout 값보다 커야 합니다. 예제에서는 Command Timeout을 150ms로 설정했으며, 앞서 설정한 SocketOptions의 Connection Timeout 값을 100ms로 설정했습니다.

DynamicTimeout

Lettuce에서는 Redis 명령어마다 별도의 Timeout을 설정할 수 있습니다. FLUSHDB, FLUSHALL, KEYS, SMEMBERS 또는 Lua 스크립트와 같이 여러 키를 반복하는 명령어에는 더 긴 command timeout을 설정할 수 있습니다. 반대로 SET, GET, HSET 등 단일 키 명령어에 대해서는 상대적으로 짧은 command timeout을 설정할 수 있습니다. Lettuce 라이브러리에서는 io.lettuce.core.TimeoutOptions.TimeoutSource 추상 클래스를 제공하고 있으며, 다음과 같이 커스텀 클래스를 작성할 수 있습니다.

public class DynamicCommandTimeout extends TimeoutOptions.TimeoutSource {

    private static final Set<ProtocolKeyword> META_COMMAND_TYPES =
            ImmutableSet.<ProtocolKeyword>builder()
                        .add(CommandType.FLUSHDB)
                        .add(CommandType.FLUSHALL)
                        .add(CommandType.CLUSTER)
                        .add(CommandType.INFO)
                        .add(CommandType.KEYS)
                        .build();

    private final Duration defaultCommandTimeout;
    private final Duration metaCommandTimeout;

    // META_COMMAND_TYPES에 정의된 명령어는 metaTimeout을 설정하고
    // 나머지 명령어는 defaultTimeout을 설정합니다.
    DynamicCommandTimeout(Duration defaultTimeout, Duration metaTimeout) {
        defaultCommandTimeout = defaultTimeout;
        metaCommandTimeout = metaTimeout;
    }

    @Override
    public long getTimeout(RedisCommand<?, ?, ?> command) {
        if (META_COMMAND_TYPES.contains(command.getType())) {
            return metaCommandTimeout.toMillis();
        }
        return defaultCommandTimeout.toMillis();
    }
}

예제의 DynamicCommandTimeout 클래스는 FLUSHDB, FLUSHALL, CLUSTER, INFO, KEYS 명령어에 대해서 Duration metaTimeout 값을 설정하는 구조입니다. 다음은 DynamicCommandTimeout 객체를 만들어서 설정하는 방법을 설명합니다.

  • 먼저 TimeoutOptions.Builder 객체를 생성하고 timeoutSource() 메서드를 사용하여 DynamicCommandTimeout 객체를 주입합니다.
  • DynamicCommandTimeout 객체는 기본 timeout 값으로 100ms를 설정하고, META_COMMAND_TYPES에 정의된 명령어에 대해서는 300ms로 설정한 예제입니다.
// 앞서 생성한 클래스를 DynamicCommandTimeout 객체
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
        .timeoutSource(new DynamicCommandTimeout(Duration.ofMillis(100L),Duration.ofMillis(300L)))
        .build();

ClusterClientOptions clusterClientOptions = ClusterClientOptions
        .builder()
        //... 생략
        .timeoutOptions(timeoutOptions)        // 생성한 timeoutOptions는 다음과 같이 설정합니다.
        .build();

DynamicConnection

ClusterClientOptions.Builder 클래스의 nodeFilter() 메서드는 문제가 발생한 노드를 Redis 토폴로지에서 제외하는 기능을 제공합니다. nodeFilter() 메서드는 Predicate를 인자로 받으므로 다음 예제에서는 람다식으로 간단히 표현되어 있습니다.

RedisClusterNode.NodeFlag 열거형의 자세한 내용은 여기open in new window에서 확인하길 바랍니다.

ClusterClientOptions clusterClientOptions = 
    ClusterClientOptions.builder()
    ... // other options
    .nodeFilter(it -> 
        ! (it.is(RedisClusterNode.NodeFlag.FAIL) 
        || it.is(RedisClusterNode.NodeFlag.EVENTUAL_FAIL) 
        || it.is(RedisClusterNode.NodeFlag.HANDSHAKE)
        || it.is(RedisClusterNode.NodeFlag.NOADDR)))
    .validateClusterNodeMembership(false)
    .build();
redisClusterClient.setOptions(clusterClientOptions);

Redis가 장애 상태에서 복구되면 Redis 클러스터는 복구 프로세스를 시작합니다. 이때 Redis 클러스터는 토폴로지를 새로 갱신하며, 작동 안 되는 노드(down node)들은 토폴로지에서 제거될 때 까지는 실패(FAIL) 상태로 표시됩니다. 이 기간동안 Redis 클라이언트는 이를 정상 노드(healthy node)로 판단하며 계속해서 연결하려고 합니다.
validateClusterNodeMembership은 클러스터 노드에 연결 전 유효한 노드인지 확인하는 옵션이므로 정상 노드로 착각하여 검증하지 않도록 false로 설정합니다.


참고 문헌

Lettuce client configuration - Amazon ElastiCache for Redis

This section describes the recommended Java and Lettuce configuration options, and how they apply to ElastiCache clusters.

이찬희 (MarkiiimarK)
Never Stop Learning.