본문 바로가기

IT

스프링부트 Virtual Thread, Spring Data Redis(Lettuce) 도입기

728x90

대규모의 트래픽을 받기 위해 설계하고 고민하는 도중 여러 선택지가 있었다. Corutin, Gorutin, Spring Webflux의 Reactive, 그리고 요즘 말이 많은 Virtual Thread 등이 있었다. 

 

대규모 트래픽이라고 말하면 기준이 애매한 거 같다. 필자가 생각하는 대규모 트래픽은 현재 주어진 환경에서 받을 수 있는 최대치? 최선이 아닐까라고도 생각이 든다. 도메인마다 생각하는 대규모 트래픽이 다를 것이고 인프라 환경마다 받을 수 있는 트래픽이 다를 것이라고 생각이 들었기 때문이다. Virtual Thread를 선택한 이유는 단순히 기존 기술 스택에서 크게 벗어나지 않아 프로그래밍 스타일이 다르지 않고 러닝 커브와 학습 시간이 짧다고 생각했기 때문이다. 그렇지만 막상 도입하기 위해 POC를 가지려고 하니 결코 쉬운 선택은 아니였다.  

 

배경은 다음과 같다. 

요청 자체는 복잡한 로직을 가지고 있지 않지만(CPU 연산은 거의 없고) Redis에 여러번 I/O 가 발생하며 요청에 텀은 매우 짧고 잦다. 2Core 기준 1만 TPS는 받아야하지 않을까 생각했다.

레디스에 접근하는 클라이언트는 Lettuce를 활용하였다. 향로님의 블로그(https://jojoldu.tistory.com/418) 를 참고했고 요약하자면 Netty 기반의 비동기 이벤트 기반 고성능을 제공하여 Jedis에 비해 몇배 이상의 성능과 하드웨어 자원 절약이 가능하고 Issue에 대한 피드백이 빠르다고 한다. 

 

처리율 제한 장치 기능이 있기에 Bucket4J의 버킷 저장소로 Redis를 택했고 그 외의 로직은 RedisTemplate 객체를 활용하였다.

implementation group: 'org.springframework.data', name: 'spring-data-redis', version: '3.4.0'
implementation group: 'io.lettuce', name: 'lettuce-core', version: '6.5.0.RELEASE'
implementation 'com.bucket4j:bucket4j-redis:8.7.0'

 

Spring Data Redis의 AutoConfiguration에 대한 정리는 생략하고 본론만 정리하겠다.

LettuceConnectionConfiguration

 

해당 클래스의 내부를 확인하자면 

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
@ConditionalOnThreading(Threading.PLATFORM)
LettuceConnectionFactory redisConnectionFactory(
        ObjectProvider<LettuceClientConfigurationBuilderCustomizer> clientConfigurationBuilderCustomizers,
        ObjectProvider<LettuceClientOptionsBuilderCustomizer> clientOptionsBuilderCustomizers,
        ClientResources clientResources) {
    return createConnectionFactory(clientConfigurationBuilderCustomizers, clientOptionsBuilderCustomizers,
            clientResources);
}

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
@ConditionalOnThreading(Threading.VIRTUAL)
LettuceConnectionFactory redisConnectionFactoryVirtualThreads(
        ObjectProvider<LettuceClientConfigurationBuilderCustomizer> clientConfigurationBuilderCustomizers,
        ObjectProvider<LettuceClientOptionsBuilderCustomizer> clientOptionsBuilderCustomizers,
        ClientResources clientResources) {
    LettuceConnectionFactory factory = createConnectionFactory(clientConfigurationBuilderCustomizers,
            clientOptionsBuilderCustomizers, clientResources);
    SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-");
    executor.setVirtualThreads(true);
    factory.setExecutor(executor);
    return factory;
}

 

Thread 모델이 Virtual Thread 일 경우는 executor에 대한 추가적인 설정을 볼 수 있다.

/**
 * Configures the {@link AsyncTaskExecutor executor} used to execute commands asynchronously across the cluster.
 *
 * @param executor {@link AsyncTaskExecutor executor} used to execute commands asynchronously across the cluster.
 * @since 3.2
 */
public void setExecutor(AsyncTaskExecutor executor) {

    Assert.notNull(executor, "AsyncTaskExecutor must not be null");

    this.executor = executor;
}

LettuceConnectionFactory에 executor는 주석을 참고하자면 클러스터 전체에서 비동기적으로 명령을 실행하는데 사용된다고 한다. RedisConnection을 reconnect, 토폴로지 갱신, 트랜잭션 대기 등의 백그라운드 Task에서 executor가 사용된다.

 

LettuceConnectionFactory에서 start() 메소드에서 ClusterConfig의 경우 해당 executor가 설정되는 것을 볼 수 있다. 

public boolean isClusterAware() {
   return RedisConfiguration.isClusterConfiguration(this.configuration);
}
private ClusterCommandExecutor createClusterCommandExecutor(RedisClusterClient client,
        LettuceConnectionProvider connectionProvider) {

    return new ClusterCommandExecutor(new LettuceClusterTopologyProvider(client),
            new LettuceClusterConnection.LettuceClusterNodeResourceProvider(connectionProvider), EXCEPTION_TRANSLATION,
            this.executor);
}

 

만약 레디스를 다른 목적으로 여러대 사용해야할 때 AutoConfig보다 RedisConnectionFactory를 선언해야하는 경우 

@Bean
public RedisConnectionFactory activeConnectionFactory() {
    DefaultClientResources clientResources = DefaultClientResources.builder()
                                                                   .build();

    LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                                                                        .clientResources(clientResources)
                                                                        .build();

    LettuceConnectionFactory redisConnectionFactory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(activeRoomProperties.getHost(), activeRoomProperties.getPort()), clientConfig);
    SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-");
    executor.setVirtualThreads(true);
    redisConnectionFactory.setExecutor(executor);

    return redisConnectionFactory;
}

 

다음 처럼 동일하게 설정할 수 있을 것 같다. 하지만 위처럼 현재 Redis 서버가 Standalone이라면  의미가 없을 수도 있을 것 같다.

Redis Cluster 혹은 Sentinel 환경도 동일하게 설정하면 된다.

@Bean
public RedisTemplate<?, ?> redisActiveTemplate(@Qualifier("activeConnectionFactory") RedisConnectionFactory activeConnectionFactory) {
    RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(activeConnectionFactory);
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
    redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));

    return redisTemplate;
}

 

RedisTemplate에는 위에서 정의한 Lettuce 기반의 ConnectionFactory를 설정해주면 된다. 직/역직렬화는 임의로 넣었지만 추후에 Value 값이 커지는 경우 압축기능이 포함된 직렬화를 사용하는 것이 대역폭 관점에서 좋을 것 같다. 

 

Bucket4J의 경우는 다음 처럼 설정하면 어떨까 싶다.

@Bean(name = "lettuceRedisClient")
public RedisClient lettuceRedisClient() {
    DefaultClientResources clientResources = DefaultClientResources.builder()
                                                                   .eventExecutorGroup(new DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()*2, Thread.ofVirtual().factory()))
                                                                   .build();
    return RedisClient.create(clientResources,
        "redis://" + bucketProperties.getHost() + ":" + bucketProperties.getPort());
}

@Bean
public LettuceBasedProxyManager lettuceBasedProxyManager(RedisClient lettuceRedisClient) {

    StatefulRedisConnection<String, byte[]> connect = lettuceRedisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));

    return LettuceBasedProxyManager.builderFor(connect)
                                   .withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(60)))
                                   .build();
}

 

Virtual Thread 라서 다른 점은 ClientResorces를 만드는 부분이다. 

.eventExecutorGroup(new DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()*2, Thread.ofVirtual().factory()))

EventExecutorGroup에 대해 설명하기 위해서는 Netty 혹은 이벤트 루프 방식에 대한 이해가 필요하다. 가장 잘 정리되어 있는 글인 거 같으니 참고하면 좋을 듯 하다.

https://mark-kim.blog/netty_deepdive_1/

https://mark-kim.blog/netty_deepdive_1/

 

그림에서 알 수 있듯이, Selector에 등록된 Socket Channel Key중 이벤트가 발생한 SelectedKey들을 Task Queue에 넣고 등록된 Channel에 Handler Pipeline에 위임하여 네트워크 read/write와 비즈니스 로직을 처리한다.

 

다시 정리하자면 Non-blocking I/O 기반 이벤트 루프가 채널(소켓) I/O를 감지하고(정확히는 Selector), 파이프라인(ChannelHandler)에 이벤트를 전달되고 핸들러 로직이 이벤트 루프 스레드에서 실행되면, 하나의 핸들러가 오래 걸리는 작업을 할 때 이벤트 루프 전체가 멈춤기에 특정 핸들러를 별도 쓰레드 풀에서 실행하게 하여, 이벤트 루프가 블로킹되지 않도록 보호할 수 있어 더 높은 동시성과 레이턴시를 확보할 수 있다. 

/**
 * Sets a shared {@link EventExecutorGroup event executor group} that can be used across different instances of
 * {@link io.lettuce.core.RedisClient} and {@link io.lettuce.core.cluster.RedisClusterClient}. The provided
 * {@link EventExecutorGroup} instance will not be shut down when shutting down the client resources. You have to take
 * care of that. This is an advanced configuration that should only be used if you know what you are doing.
 *
 * @param eventExecutorGroup the shared eventExecutorGroup, must not be {@code null}.
 * @return {@code this} {@link Builder}.
 */
@Override
public Builder eventExecutorGroup(EventExecutorGroup eventExecutorGroup) {

    LettuceAssert.notNull(eventExecutorGroup, "EventExecutorGroup must not be null");

    this.sharedEventExecutor = true;
    this.eventExecutorGroup = eventExecutorGroup;
    return this;
}

이 부분은 고급 설정이니 잘 알고 쓰라고 적혀있다. 또한 직접 주입한 EventExecutorGroup은 클라이언트 종료 시 자동 종료되지 않으므로 사용자 측에서 수동으로 종료해야 한다고 하니 유의하도록 하자. 정리하자면 ChannelHandler는 이벤트 루프의 스레드가 아닌 EventExecutorGroup의 스레드에서 실행된다는 것이고 실행되는 이 스레드를 Virtual Thread로 만들었고 스레드 수를 기존 논리프로세서 수의 2배로 설정했다.

MultithreadEventLoopGroup

NioEventLoopGroup의 상위 클래스인 MultithreadEventLoopGroup의 코드를 보면 기본 Thread 수 가 논리프로세서 수의 2배로 설정되어 있기에 동일하게 설정했지만 이 부분은 병목 상황을 확인해보고 튜닝할 요소 중 하나로 생각된다.

 

그런데 문득 Lettuce 클라이언트가 Non-blocking으로 동작하는데 ReactiveRedisTemplate를 쓴다면? 또는 pool 수를 조정한다면 어떨까 궁금증이 생겼다. Lettuce를 동기 API로 사용할 때도 내부는 비동기로 I/O를 처리하지만 결과를 get()을 하는 순간 block을 하게 된다. 반면 비동기 API(ReactiveRedisTemplate)로 사용하면, 호출 즉시 Mono(혹은 Flux)를 반환하고 실제로는 비동기 I/O가 진행되며, I/O가 끝나면 이벤트 형태(= onNext/onComplete)로 결과를 전달한다. 

 

즉, Lettuce 비동기는 라이브러리 내부(Netty + Future) 관점이고, Reactive는 사용자 API 레벨이 논블로킹 스트림이 된다. 가상 스레드 환경에서도 리액티브 API는 여전히 논블로킹이므로, 크게 충돌 없이 잘 동작하는 거 같지만 가상 스레드는 동기 코드 스타일을 논블로킹처럼 쓸 수 있다는 장점이 있어서, “Reactive vs. Blocking” 사이에서 아키텍처별 트레이드오프가 존재하는 거 같다. 따라서 ReactiveRedisTemplate을 쓸거면 그냥 WebFlux 아키텍쳐로 전환하는 것이 더 맞지 않을까, 최소한 현재 필자의 환경에서는 고려하지 않게 되었다. 

 

두번째로 Lettuce를 사용할 때는 pool 을 쓰는 경우는 드물다고 한다. 트랜잭션이나 대용량 bulk 작업이 있어 커넥션을 따로 분리하는 경우가 아니면 단지 요청이 많다는 이유로 pool을 만드는 것은 비동기 멀티플렉싱 자체가 단일 커넥션으로도 상당히 높은 동시 처리를 지원하여 불필요하다고 보인다. 차라리 Redis의 역할을 분리하여 노드를 구성하고 이에 접근하는 API 또한 분리하는 것이 더 유연하고 확장성 있지 않을까 생각도 들었다. 

 

 

Jmeter를 통해 부하를 주어 확인해본 결과 lettuce의 Redis의 I/O가 워낙 빨라서 그런지 기존 Platform Thread 기반과 Virtual Thread 기반의 차이를 확인하기 애매한 부분이 꽤 많아 삽질의 시간이 있었지만 극명한 효과를 보기 위해서 톰캣의 max-threads 수를 1로 지정하고 테스트를 진행했더니 Virtual Thread와 너무나도 큰 (당시 20배 이상의) 처리율의 차이가 있었고 차분하게 생각해보니 Tomcat의 스레드풀보다 Virtual Thread가 매핑되는 Carrier thread의 수, 즉 ForkJoinPool의 크기가 중요하다고 깨달았다.

 

따라서 다음처럼 Platform Thread와 Virtual Thread의 설정을 맞춰주는게 맞을 거라 생각하고 부하 테스트를 진행했다. 

-Djdk.virtualThreadScheduler.maxPoolSize=10
# -Djdk.virtualThreadScheduler.parallelism=100

server.tomcat.threads.max=10
# server.tomcat.threads.min-spare=10

 

예상한 결과 Virtual Thread의 경우 더 높은 TPS와 짧은 Response Time을 확인할 수 있었다.

 

Platform Thread Model

Platform Thread

 

Virtual Thread Model

Virtual Thread

 

거의 두배 이상의 처리율이 차이가 났지만 이는 요청 수가 더 클수록, 스레드 풀의 크기가 클수록 차이가 나는 것을 확인할 수 있었다. 가상 스레드는 메모리도 KB 단위로 매우 적다고 하는데 이 부분에 대한 검증도 추후 진행해보려고 한다. 

 

-Djdk.tracePinnedThreads=full

Virtual Thread를 사용했을 경우 주의해야할 Pinned를 모니터링하기 위해 위 옵션을 설정하였지만 따로 해당 로그를 확인하진 못했다. 

 

Virtual Thread Tomcat Thread pool 200, 1

 

추가로 Tomcat의 Thread-Pool을 200에서 1로 바꿔도 Virtual Thread가 처리하는 TPS는 변동이 없음도 확인할 수 있었다.

서블릿(또는 Spring MVC) 단에서 보는 쓰레드는 이제 Virtual Thread가 되어, 블로킹 구간이 있어도 OS 스레드가 묶이지 않는 고동시성 구조를 구현하게 된 것이다.

 

이 뿐만 아니라 좀 더 꼼꼼하게 부하를 주어 테스트를 할 필요가 있다. 시나리오 별로 진행하기 전, Bucket4J를 쓰는 부분만 따로 진행하거나 단순 RedisTemplate을 사용하는 부분을 분리하여 먼저 진행해야 사용성을 검증할 수 있을 거 같다.

 

Virtual Thread, Netty 등에 대한 개념을 설명하지 않았지만 개념이 가장 어렵고 복잡한 거 같다. 운영을 가져봐야 비로소 더욱 좋거나 나쁜 효과에 대해 감이 올 거 같다. 필자 주관적인 생각이 많이 들어가있어 틀린 부분, 애매한 부분이 있을 수 있어 참고만 하는 것을 권장한다.