세 가지 레디스 자바 클라이언트
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는 제디스는 커넥션 풀링에 비해 레투스는 단일 커넥션을 여러 스레드가 공유할 수 있다는 점, 커넥션 풀링은 레디스 커넥션 수를 증가시키는 물리적 비용이 발생한다는 점을 언급했습니다.
이에 대한 직접적인 성능 이슈는 향로님의 실험에서 찾아볼 수 있습니다.
위 링크의 성능 테스트 결과를 보면 레디스 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/
'Spring' 카테고리의 다른 글
스프링 @Retry를 이용한 재시도 처리하기 (0) | 2024.06.12 |
---|---|
스프링 @Async를 이용한 비동기 실행 적용하기 (1) | 2024.06.11 |
스프링에서 외부 API 호출을 테스트하는 방법(feat. RestClient) (0) | 2024.05.20 |
스프링 이벤트(Spring Event) 사용해보기 (0) | 2024.02.12 |
로그 세팅하기 (1) | 2023.11.19 |