본문 바로가기
Spring

Jedis, Lettuce, Redisson. 레디스를 위한 세 가지 자바 클라이언트

by hseong 2024. 10. 30.

세 가지 레디스 자바 클라이언트

Jedis

제디스(Jedis)는 동기식 작업을 지원하는 레디스 클라이언트입니다. 다른 클라이언트에 비해 간단하고 사용하기 쉬운 API를 제공합니다. 제디스 인스턴스는 스레드 안전(Thread-safe)하지 않기 때문에 멀티 스레드 환경에서 사용하려는 경우 제디스풀(JedisPool)을 사용해야 합니다.

Lettuce

레투스(Lettuce)는 동기, 비동기, 반응형 작업을 지원하는 레디스 클라이언트입니다. 제디스와 달리 스레드 안전하기 때문에 여러 스레드에서 안전하게 사용할 수 있습니다.

Redisson

레디슨(Redisson)은 다양한 분산 객체를 제공하는 레디스 클라이언트입니다. 다른 두 클라이언트가 레디스의 기본 자료구조를 다루는 API만 제공하는 데 비해 레디슨은 분산된 환경에서 사용할 수 있는 다양한 객체를 구현해놓았습니다.

각 클라이언트 비교

Jedis vs Lettuce

제디스와 레투스틀 직접 사용했을 때 가장 먼저 느낄 수 있는 차이는 사용 방법 상의 차이입니다. 코드를 보면 쉽게 이해할 수 있습니다.

public class JedisLettuceTest {  

    @Test  
    void 제디스() {  
        Jedis jedis = new Jedis("127.0.0.1", 6379);  

        jedis.set("jedis", "value");  
        String value = jedis.get("jedis");  

        assertThat(value).isEqualTo("value");  
        jedis.close();  
    }  

    @Test  
    void 레투스() {  
        RedisClient redisClient = RedisClient.create("redis://127.0.0.1:6379");  
        StatefulRedisConnection<String, String> con = redisClient.connect();  
        RedisCommands<String, String> sync = con.sync();  

        sync.set("lettuce", "value");  
        String value = sync.get("lettuce");  

        assertThat(value).isEqualTo("value");  
        con.close();  
        redisClient.shutdown();
    }  
}

제디스는 새로운 Jedis 인스턴스를 생성하면 곧바로 내부에서 커넥션을 생성합니다. 사용자는 연산을 수행하고 제디스 커넥션을 닫기만 하면 됩니다. 직관적이고 간단하게 사용할 수 있습니다. 반면, 레투스는 RedisClient를 생성하고, 커넥션을 획득한 뒤, 동기식으로 사용할지 비동기식으로 사용할지 결정한 뒤에 연산을 수행합니다. 제디스에 비해 코드가 좀 더 길어보이긴 합니다.

다만 멀티스레드 환경에서 제디스는 스레드 안전하지 않습니다. 제디스를 이용해 레디스를 사용하기 위해서는 반드시 제디스풀을 구성해줘야 합니다.

public class RedisTheadSafeTest {  

    private final int poolSize = 50;  
    private final ExecutorService executorService = Executors.newFixedThreadPool(poolSize);  
    private CountDownLatch latch;  

    @BeforeEach  
    void setUp() {  
        latch = new CountDownLatch(poolSize);  
    }  

    @Test  
    void 제디스풀() throws InterruptedException {  
        // given  
        JedisPoolConfig config = new JedisPoolConfig();  
        config.setMaxTotal(poolSize);  
        config.setMaxIdle(poolSize);  
        config.setMinIdle(poolSize);  
        JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 6379);  

        // when  
        for (int i = 0; i < poolSize; i++) {  
            executorService.submit(() -> {  
                try (Jedis jedis = jedisPool.getResource()) {  
                    jedis.incr("jedis");  
                } finally {  
                    latch.countDown();  
                }  
            });  
        }  
        latch.await();  

        // then  
        Jedis jedis = jedisPool.getResource();  
        String result = jedis.get("jedis");  
        assertThat(result).isNotEqualTo(String.valueOf(poolSize));  
        jedis.close();  
        jedisPool.close();  
    }  

    @Test  
    void 레투스_스레드_안전() throws InterruptedException {  
        // given  
        RedisClient redisClient = RedisClient.create("redis://127.0.0.1:6379");  
        StatefulRedisConnection<String, String> con = redisClient.connect();  
        RedisCommands<String, String> sync = con.sync();  
        sync.set("lettuce", "0");  

        // when  
        for (int i = 0; i < poolSize; i++) {  
            executorService.submit(() -> {  
                try {  
                    sync.incr("lettuce");  
                } finally {  
                    latch.countDown();  
                }  
            });  
        }  
        latch.await();  

        // then  
        assertThat(sync.get("lettuce")).isEqualTo(String.valueOf(poolSize));  
        con.close();  
        redisClient.shutdown();  
    }  
}

Lettuce vs Redisson

레투스가 비동기, 반응형 API를 지원하는 것처럼 레디슨도 동일하게 비동기, 반응형 API를 지원합니다. 멀티스레드 환경에서도 하나의 커넥션을 공유해도 스레드 안전합니다. 주요한 차이는 분산 환경에서 사용할 수 있는 다양한 객체를 지원한다는 점입니다. 대표적인 예시로 분산락이 있습니다.

public class LettuceRedissonTest {  

    private final int poolSize = 50;  
    private final ExecutorService executorService = Executors.newFixedThreadPool(poolSize);  
    private CountDownLatch latch;  
    private Counter counter;  

    private static class Counter {  

        private int count = 0;  

        private void increment() {  
            count++;  
        }  
    }  

    @BeforeEach  
    void setUp() {  
        latch = new CountDownLatch(poolSize);  
        counter = new Counter();  
    }  

    @Test  
    public void 레투스() throws InterruptedException {  
        // given  
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");  
        StatefulRedisConnection<String, String> con = redisClient.connect();  
        RedisCommands<String, String> sync = con.sync();  

        // when  
        for (int i = 0; i < poolSize; i++) {  
            executorService.submit(() -> {  
                try {  
                    while (!sync.setnx("lettuce", "lock")) {  
                        Thread.sleep(10);  
                    }  
                    counter.increment();  
                    sync.del("lettuce");  
                } catch (InterruptedException e) {  
                    throw new RuntimeException(e);  
                } finally {  
                    latch.countDown();  
                }  
            });  
        }  
        latch.await();  

        //then  
        assertThat(counter.count).isEqualTo(poolSize);  
        con.close();  
        redisClient.shutdown();  
    }  

    @Test  
    public void 레디슨() throws InterruptedException {  
        // given  
        Config config = new Config();  
        config.useSingleServer().setAddress("redis://localhost:6379");  
        RedissonClient redissonClient = Redisson.create(config);  

        // when  
        RLock lock = redissonClient.getLock("redisson");  
        for (int i = 0; i < poolSize; i++) {  
            executorService.submit(() -> {  
                try {  
                    if (lock.tryLock(5, 1, TimeUnit.SECONDS)) {  
                        counter.increment();  
                    }  
                } catch (InterruptedException e) {  
                    throw new RuntimeException(e);  
                } finally {  
                    lock.unlock();  
                    latch.countDown();  
                }  
            });  
        }  
        latch.await();  

        // then  
        assertThat(counter.count).isEqualTo(poolSize);  
        redissonClient.shutdown();  
    }  
}

분산 환경에서 레투스를 이용해 락을 사용한다거나, 레디스 자료 구조 이상의 무언가가 필요하다면 사용자가 직접 구현해주어야 합니다. 그러나 레디스는 락 뿐만 아니라 컬렉션, 아토믹 객체, 스케줄러 등 분산 환경에서 사용할 수 있는 다양한 객체들이 구현되어 있습니다. 기본 자료구조만으로 충분하다면 레투스도 문제 없으나 분산 환경에서 동기화나 복잡한 처리가 필요하다면 레디슨이 더 적절해보입니다.

스프링과의 통합

RedisConnection과 RedisConnectionFactory

RedisConnection은 레디스와의 통신을 위한 핵심입니다. 또한, 기본 연결 라이브러리 예외를 Spring의 추상화된 예외 계층으로 변환해주기 때문에 코드를 변경하지 않고 통신에 사용하는 레디스 클라이언트를 변경할 수 있습니다.

활성화된 RedisConnection 객체는 RedisConnectionFactory로부터 만들어집니다. 주의할 점은 사용하는 레디스 클라이언트와 별개로 RedisConnection 객체는 스레드 안전하지 않기 때문에 여러 스레드에서 해당 객체를 공유해서는 안됩니다.

레디슨의 경우 제디스, 레투스와 달리 spring-data-redis-starter에 RedisConnectionFactory를 자동 구성을 담당하는 클래스가 포함되어 있지 않습니다. 따라서 레디슨을 스프링에 통합하여 사용하려는 경우에는 자체적으로 제공하는 의존성을 추가해줘야 합니다.

RedisTemplate

RedisTemplate은 풍부한 기능을 가진 레디스 모듈의 핵심입니다. 템플릿은 레디스 상호 작용에 대해 높은 수준의 추상화를 제공합니다. RedisConnection이 바이트 배열을 반환하는 저수준의 작업을 제공하는 반면, RedisTemplate은 직렬화와 커넥션 관리 같은 작업을 처리하주기 때문에 사용자는 세부 사항에 대해 신경쓰지 않아도 됩니다. 또한, RedisTemplate은 스레드 안전하기 때문에 여러 스레드에서 공유할 수 있습니다.

RedisTemplate을 구성하는 방법은 다음과 같습니다.

@Configuration
public class RedisConfig {
    @Bean  
    public LettuceConnectionFactory lettuceConnectionFactory() {  
        return new LettuceConnectionFactory("127.0.0.1", 6379);  
    }  

    @Bean  
    public RedisTemplate<String, String> lettuceTemplate(LettuceConnectionFactory lettuceConnectionFactory) {  
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();  
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);  
        return redisTemplate;  
    }
}

Serializers

프레임워크 관점에서 레디스에 저장되는 데이터는 오직 바이트뿐입니다. Serializer는 사용자가 저장하길 원하는 데이터를 중간에 직렬화하는 역할을 담당합니다. 대표적인 Serializer는 다음과 같습니다.

  • StringRedisSerializer
    • 문자열을 바이트 배열로 직렬화
  • Jackson2JsonRedisSerializer
    • 객체를 json 포맷으로 직렬화
    • 특정 클래스 타입을 지정하여 사용
    • 타입 정보를 저장하지 않음
    • 클래스마다 별도의 RedisTemplate을 사용해야 함
  • GenericJackson2JsonRedisSerializer
    • 객체를 json 포맷으로 직렬화
    • 직렬화 시 클래스 타입 정보를 자동으로 포함
    • 역직렬화 시 패키지, 클래스명이 일치해야 함

스프링 부트가 Lettuce를 기본으로 사용하는 이유

스프링 부트는 빈 자동 구성을 통해 RedisConnectionFactory, RedisTemplate과 같은 빈들을 자동으로 등록해줍니다. 또한, spring.data.redis.client-type 속성 값을 통해 lettuce, jedis 중 어떤 레디스 클라이언트를 사용할 것인지 간단하게 설정해줄 수 있습니다. 그러나 막상 스타터 의존성(spring-data-redis-starter)를 까보면 레투스 의존성만 추가되어 있고 제디스 의존성은 포함되어 있지 않습니다.

아무런 설정을 만지지 않고 StringRestTemplate을 주입받은 뒤 어떤 RedisConnectionFactory가 사용되었는지 확인해보면 다음과 같이 LettuceConnectionFactory가 기본으로 사용되었음을 확인해볼 수 있습니다.

@SpringBootTest  
class RedisClientApplicationTests {  

    @Autowired  
    private ApplicationContext applicationContext;  

    @Autowired  
    private StringRedisTemplate redisTemplate;  

    @Test  
    void contextLoads() {  
        for (String bean : applicationContext.getBeanDefinitionNames()) {  
            System.out.println("bean: " + bean);  
        }  
        RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();  
        assertThat(connectionFactory).isInstanceOf(LettuceConnectionFactory.class);  
        // assertThat(connectionFactory).isInstanceOf(JedisConnectionFactory.class);  
    }  
}

반면, spring.data.redis.client-type 속성을 jedis로 설정해주고 위 테스트 코드를 다시 실행하면 주입 받을 수 있는 StringRestTemplate 빈이 없어 테스트는 실패합니다. JedisConnectionFactory의 자동 구성을 담당하는 클래스를 살펴보면 Jedis 클래스가 존재할 때를 조건으로 걸고 있는 것을 확인할 수 있습니다.

@Configuration(proxyBeanMethods = false)  
@ConditionalOnClass({ GenericObjectPool.class, JedisConnection.class, Jedis.class })  
@ConditionalOnMissingBean(RedisConnectionFactory.class)  
@ConditionalOnProperty(name = "spring.data.redis.client-type", havingValue = "jedis", matchIfMissing = true)  
class JedisConnectionConfiguration extends RedisConnectionConfiguration { ... }

따라서 직접 제디스 의존성을 추가하고 다시 테스트를 실행시키면 JedisConnectionFactory, StringRedisTemplate이 빈으로 잘 등록된 것을 확인할 수 있습니다.

그렇다면 어째서 스프링 부트는 레투스를 기본으로 사용하는 것일까요? 이에 대한 답변은 2017년 spring-projects 깃허브의 spring-session 리포지토리에 올라온 이슈에서 간접적으로 찾아볼 수 있습니다.

Why is Lettuce the default Redis client used in Spring Session Redis?

해당 이슈에서 Spring Data 프로젝트의 리드인 Mark Paluch는 제디스는 커넥션 풀링에 비해 레투스는 단일 커넥션을 여러 스레드가 공유할 수 있다는 점, 커넥션 풀링은 레디스 커넥션 수를 증가시키는 물리적 비용이 발생한다는 점을 언급했습니다.

이에 대한 직접적인 성능 이슈는 향로님의 실험에서 찾아볼 수 있습니다.

Jedis 보다 Lettuce 를 쓰자

위 링크의 성능 테스트 결과를 보면 레디스 CPU 사용률, 커넥션 수, 응답 속도 등 모든 분야에서 레투스가 제디스를 압도하는 것을 확인할 수 있습니다.

참고

https://jojoldu.tistory.com/418
https://docs.spring.io/spring-boot/docs/3.2.5/reference/htmlsingle/#data.nosql.redis
https://github.com/spring-projects/spring-session/issues/789
https://redis.io/blog/jedis-vs-lettuce-an-exploration/