5분 안에 구축하는 Redis(레디스)
레디스(Remote Dictionary Server)가 무엇인가!
- 메모리 기반 저장 서비스
- 키-값 구조
- 문자열만 저장할 수 있는 Memcached와 달리 다양한 자료구조(Collection: 목록성 데이터 처리 구조) 지원 -> 다양한 형식으로 저장 가능 (String, Set, Sorted Set, Hash, List 등)
- 오픈 소스 (무료)
- 원격(Remote) 저장소로 여러 서버에서 같은 데이터를 공유할 수 있음
- 캐시로 사용 가능
- 메모리에만 저장하는 Memcached와 달리 디스크에도 저장하여 데이터 저장소로 사용 가능 (백업 방식으로 2가지가 존재함.)
> 개념만 봤을때 아 ~ 그렇구나 정도지 실제 해보지 않으면 알기 어렵다.. (다음번에 테스트 해볼 예정)
RDB | AOF (Append Only File) | |
저장 방법 | 메모리에 있는 데이터 전체를 바이너리 파일로 저장하는 방식 | 명령이 실행될 때마다 해당 명령이 파일에 기록되는 방식 |
저장 시점 | - 설정 : save [time], [count] - 지정 하는 일정 time 동안 count 만큼의 key 변경이 발생하면 rdb 파일로 저장됨 |
- 설정 : appendfsync [always | everysec | no] - 명령어 실행시 / 1초 마다 / 기록하는 시점을 OS 가 지정 |
로드 시간 | RDB파일이 사이즈가 작아 AOF파일 보다 빨리 로드 가능 | |
저장(백업) 파일 | dump.rdb | appendonly.aof |
기능 | # rewrite 기능 - 특정 시점에 데이터 전체를 다시 쓰는 기능 - 같은 key를 100번 저장하는 경우 100번이 모두 기록 되지만 rewrite 실행시 최종 수정된 마지막 값만 남아 파일 사이즈가 작아짐 |
|
읽기 가능 | 바이너리 파일이라 사람이 읽을 수 없음 | 텍스트 파일이라 파일을 읽을 수 있음 (잘못 입력한 명령어는 수정 하여 명령어 취소가 가능) |
참고 | - 스냅샷을 일정 간격으로 HDD에 저장하게 가능하며, 저장 간격에 따라 데이터 손실 정도가 달라짐 - 데이터 세트가 매우 크고 CPU 성능이 좋지 않은 경우 Redis가 몇 밀리 초 동안 또는 심지어 1 초 동안 클라이언트 서비스를 중지 할 수 있음 |
참고 :: redis 장점도 많지만 캐시에 저장된 데이터가 사라져도 괜찮다면 Memcached 는 멀티 스레드(실행 흐름 단위)로 처리하여 성능이 좋기(빠른 처리) 때문에 Memcached 사용하는 편이 좋을 것 같다.
설치를 해봅시다! (mac 기준)
$brew install redis
설치가 완료되면 아래 서비스 시작 명령어로 서비스를 시작 해줍니다.
- homebrew로 서비스 시작
$brew services start redis
- 서비스 중단
$brew services stop redis
- 서비스 재기동
$brew services restart redis
- redis 클라이언트로 접속
$redis-cli
프로젝트를 만들어 봅시다!
# 개발환경
- mac os catalina
- java 15
- spring boot 2.4.2
- gradle 6.6.1- mac os catalina
:: github.com/works-code/redis
# 레디스 port, ip 정의
package com.code.redis.configuration;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
// 해당 설정을 해도 되고, application.yml에 설정 해도 됨
@ConfigurationProperties(prefix = "spring.redis")
@Component
public class RedisProperties {
private String host;
private int port;
}
// env.properties
spring.redis.host=localhost
spring.redis.port=6379
# 설정 (각자 사용하고자 하는 라이브러리에 맞게 쓰시면 됩니다.)
package com.code.redis.configuration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.clients.jedis.JedisPoolConfig;
@EnableRedisRepositories
@Configuration
public class RedisConfiguration {
@Autowired
RedisProperties redisProperties;
// jedis 사용시 해당 빈을 사용
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new JedisConnectionFactory(new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()));
}
// lettuce 사용시 해당 빈을 사용
/*@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()));
}*/
}
# 라이브러리 정의 (각자 사용하고자 하는 라이브러리에 맞게 쓰시면 됩니다.)
dependencies {
...
// jedis 사용시 해당 부분 정의
compile ('org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE') {
exclude(group: 'io.lettuce', module: 'lettuce-core')
}
compile group: 'redis.clients', name: 'jedis'
// lettuce 사용시 해당 부분 정의
/*compile ('org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE')*/
}
# 레디스 서비스(사용을 간편하게 하기 위해 서비스 구성)
package com.code.redis.service;
import com.code.redis.vo.Struct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.NumberUtils;
import org.springframework.util.ObjectUtils;
import java.util.*;
import java.util.concurrent.TimeUnit;
/***
* String에 특화된 StringRedisTemplate
* String 만 다루려면 아래 서비스 사용하고, 그게 아니라면 RedisTemplate 빈 정의하여 사용.
*/
@Service
public class RedisService {
@Autowired
private StringRedisTemplate redisTemplate;
// string (opsForValue)
public void setStringOps(String key, String value, long ttl, TimeUnit unit){
redisTemplate.opsForValue().set(key, value, ttl, unit);
}
public String getStringOps(String key){
return (String) redisTemplate.opsForValue().get(key);
}
// list (opsForList)
public void setListOps(String key, List<String> values){
redisTemplate.opsForList().rightPushAll(key, values);
}
public List<String> getListOps(String key){
Long len = redisTemplate.opsForList().size(key);
return len == 0 ? new ArrayList<>() : redisTemplate.opsForList().range(key, 0, len-1);
}
// hash (opsForHash)
public void setHashOps(String key, HashMap<String, String> value){
redisTemplate.opsForHash().putAll(key, value);
}
public String getHashOps(String key, String hashKey){
return redisTemplate.opsForHash().hasKey(key, hashKey) ? (String) redisTemplate.opsForHash().get(key, hashKey) : new String();
}
// set (opsForSet)
public void setSetOps(String key, String... values){
redisTemplate.opsForSet().add(key, values);
}
public Set<String> getSetOps(String key){
return redisTemplate.opsForSet().members(key);
}
// sorted set (opsForZSet)
public void setSortedSetOps(String key, List<Struct.SortedSet> values){
for(Struct.SortedSet v : values){
redisTemplate.opsForZSet().add(key, v.getValue(), v.getScore());
}
}
public Set getSortedSetOps(String key){
Long len = redisTemplate.opsForZSet().size(key);
return len == 0 ? new HashSet<String>() : redisTemplate.opsForZSet().range(key, 0, len-1);
}
}
# Junit 테스트
1. value - string
@Test
void string_redis() {
String key = "string_redis";
String value = "string";
redisService.setStringOps(key, value, 10, TimeUnit.SECONDS);
log.error("### Redis Key => {} | value => {}", key, redisService.getStringOps(key));
}
- 서비스 로그
2021-04-07 19:52:43.926 ERROR 40256 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Key => string_redis | value => string
- 레디스 클라이언트
// 레디스 클라이언트 접속
$redis-cli
// 모든키 조회
127.0.0.1:6379> keys *
1) "string_redis"
// 키 값 조회
127.0.0.1:6379> get string_redis
"string"
// 10초뒤 모든키 조회시 없다고 나옴
127.0.0.1:6379> keys *
(empty list or set)
2. value - list
@Test
void list_redis() {
String key = "list_redis";
List<String> values = new ArrayList<>();
values.add("list_01");
values.add("list_02");
redisService.setListOps(key, values);
log.error("### Redis Key => {} | value => {}", key, redisService.getListOps(key));
String keyEty = "empty_key";
log.error("### Redis Empty Key => {} | value => {}", keyEty, redisService.getListOps(keyEty));
}
- 서비스 로그
2021-04-07 19:58:27.180 ERROR 40357 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Key => list_redis | value => [list_01, list_02]
2021-04-07 19:58:27.182 ERROR 40357 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Empty Key => empty_key | value => []
- 레디스 클라이언트
// 모든키 조회
127.0.0.1:6379> keys *
1) "list_redis"
// 키 값 가져오기 (저장하는 값에 따라 값을 가져오는 명령어가 달라 에러 발생)
127.0.0.1:6379> get list_redis
(error) WRONGTYPE Operation against a key holding the wrong kind of value
// 처음부터 끝까지 키의 값 가져오기
// lrange key 시작인덱스 끝인덱스(끝 인덱스는 결과에 포함됨)
127.0.0.1:6379> lrange list_redis 0 -1
1) "list_01"
2) "list_02"
3. value - hashmap
@Test
void hash_redis() {
String key = "hash_redis";
HashMap<String, String> map = new HashMap<>();
String mapKeyOne = "hash_key_1";
String mapKeyTwo = "hash_key_2";
map.put(mapKeyOne, "hash_value_1");
map.put(mapKeyTwo, "hash_value_2");
redisService.setHashOps(key, map);
log.error("### Redis One Key => {} | value => {}", key, redisService.getHashOps(key, mapKeyOne));
log.error("### Redis Two Key => {} | value => {}", key, redisService.getHashOps(key, mapKeyTwo));
String keyEty = "key_empty";
log.error("### Redis Empty hash Key => {} | value => {}", keyEty, redisService.getHashOps(key, keyEty));
log.error("### Redis Empty Key => {} | value => {}", keyEty, redisService.getHashOps(keyEty, mapKeyOne));
}
- 서비스 로그
2021-04-07 20:08:04.932 ERROR 40501 --- [ main] com.code.redis.RedisApplicationTests : ### Redis One Key => hash_redis | value => hash_value_1
2021-04-07 20:08:04.933 ERROR 40501 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Two Key => hash_redis | value => hash_value_2
2021-04-07 20:08:04.933 ERROR 40501 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Empty hash Key => key_empty | value =>
2021-04-07 20:08:04.933 ERROR 40501 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Empty Key => key_empty | value =>
- 레디스 클라이언트
// 모든 키 조회
127.0.0.1:6379> keys *
1) "hash_redis"
2) "list_redis"
// 해시 값 가져오기
127.0.0.1:6379> hget hash_redis hash_key_1
"hash_value_1"
127.0.0.1:6379> hget hash_redis hash_key_2
"hash_value_2"
4. value - set
@Test
void set_redis() {
String key = "key_set";
redisService.setSetOps(key, "value_1","value_2","value_1");
log.error("### Redis Key => {} | value => {}", key, redisService.getSetOps(key));
String keyEty = "key_empty";
log.error("### Redis Empty Key => {} | value => {}", keyEty, redisService.getSetOps(keyEty));
}
- 서비스 로그 (중복된 값은 저장하지 않는다)
2021-04-07 20:20:21.711 ERROR 40675 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Key => key_set | value => [value_2, value_1]
2021-04-07 20:20:21.714 ERROR 40675 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Empty Key => key_empty | value => []
- 레디스 클라이언트
// 모든 키 가져오기
127.0.0.1:6379> keys *
1) "hash_redis"
2) "key_set"
3) "list_redis"
// 키 값 가져오기 (set)
127.0.0.1:6379> smembers key_set
1) "value_2"
2) "value_1"
5. value - sorted set
@Test
void sortedSet_redis() {
String key = "sortedSet_redis";
List<Struct.SortedSet> values = new ArrayList<>();
values.add(new Struct.SortedSet(){{
setValue("sortedSet_value_100");
setScore(100D);
}});
values.add(new Struct.SortedSet(){{
setValue("sortedSet_value_10");
setScore(10D);
}});
redisService.setSortedSetOps(key, values);
log.error("### Redis Key => {} | value => {}", key, redisService.getSortedSetOps(key));
String keyEty = "key_empty";
log.error("### Redis Empty Key => {} | value => {}", keyEty, redisService.getSortedSetOps(keyEty));
}
- 서비스 로그 (오름차순)
2021-04-07 20:27:01.875 ERROR 40785 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Key => sortedSet_redis | value => [sortedSet_value_10, sortedSet_value_100]
2021-04-07 20:27:01.876 ERROR 40785 --- [ main] com.code.redis.RedisApplicationTests : ### Redis Empty Key => key_empty | value => []
- 레디스 클라이언트
// 모든 키 조회
127.0.0.1:6379> keys *
1) "hash_redis"
2) "key_set"
3) "list_redis"
4) "sortedSet_redis"
// 키 값 가져오기 (sorted set)
127.0.0.1:6379> zrange sortedSet_redis 0 -1
1) "sortedSet_value_10"
2) "sortedSet_value_100"
# 참고 사항
- Q) 데이터를 중복으로 저장시 어떻게 저장할까?
- string : overwrite
- list : tail add
- hash : overwrite
- set : 중복된 값이 있으면 저장 안함, 중복 되지 않으면 순서 상관없이 저장
- sorted set : 중복된 값이 있으면 score 만 업데이트하여 오름차순 정렬, 중복 되지 않으면 score 오름차순으로 저장
- Q) 어떤 라이브러리가 나을까? - lettuce
:: 친절하게 테스트 진행한 부분을 정리한 블로그를 보았다...! (테스트 할 필요없다 앗싸!)
- Q) redis 서버 장애시 각 라이브러리는 어떻게 처리할까?
lettuce | jedis |
# 서비스 중단 $ brew services stop redis - 증상 1. 중단 즉시 : ConnectionWatchdog class 가 Redis Node가 죽는걸 감지하고 30초 간격으로 connecting 을 계속 시도 함 (AutoReconnect 를 통해 다시 커넥션을 맺는다.) 2. 레디스 서비스 호출시 : 장애시 서비스 호출하면 바로 오류 리턴하지 않고 설정한 시간 동안 redis 연결시도를 계속 하고, 그 안에 복구 안되면 오류 리턴 (아래 정리) 3. redis 장애 복구시 : '30초 뒤에 모니터링에 정상 감지' or '서비스 호출' 시 재 연결됨 |
# 서비스 중단 $ brew services stop redis - 증상 1. 중단 즉시 : lettuce 처럼 connecting 계속 시도 하지는 않음 (사용시에만 연결 체크함 - 장애 복구시 바로 연결하여 값 리턴) 2. 레디스 서비스 호출시 : 장애시 서비스 호출하면 연결 재 시도 없이 바로 오류 리턴(타임 아웃 설정해도 동일) - JedisConnectionException 3. redis 장애 복구시 : '서비스 호출' 시 재 연결됨 |
-> lettuce 중단시 라이브러리에서 남기는 서비스 로그 (모니터링)
2021-04-07 21:32:02.912 INFO 41946 --- [xecutorLoop-1-5] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was localhost/<unresolved>:6379
2021-04-07 21:32:02.914 WARN 41946 --- [ioEventLoop-6-6] i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [localhost/<unresolved>:6379]: Connection refused: localhost/127.0.0.1:6379
2021-04-07 21:32:33.014 INFO 41946 --- [xecutorLoop-1-5] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was localhost/<unresolved>:6379
2021-04-07 21:32:33.016 WARN 41946 --- [ioEventLoop-6-7] i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [localhost/<unresolved>:6379]: Connection refused: localhost/127.0.0.1:6379
2021-04-07 21:33:03.110 INFO 41946 --- [ecutorLoop-1-10] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was localhost/<unresolved>:6379
2021-04-07 21:33:03.113 WARN 41946 --- [ioEventLoop-6-8] i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [localhost/<unresolved>:6379]: Connection refused: localhost/127.0.0.1:6379
-> lettuce 중단시 라이브러리에서 남기는 서비스 로그 (호출시)
참고 :: Aspect 는 제가 만든 class 지만, 뒤에 남은 에러 메세지는 라이브러리에서 남기는 메세지 리턴한 부분입니다
2021-04-07 23:10:32.694 ERROR 44533 --- [nio-8081-exec-9] com.code.redis.aspect.RedisCacheAspect : ### [RedisCacheAspect] Error : Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 second(s)
-> lettuce 장애 복구시 라이브러리에서 남기는 서비스 로그 (모니터링 or 호출시)
2021-04-07 21:35:03.508 INFO 41946 --- [xecutorLoop-1-6] i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was localhost/<unresolved>:6379
2021-04-07 21:35:03.514 INFO 41946 --- [oEventLoop-6-12] i.l.core.protocol.ReconnectionHandler : Reconnected to localhost/<unresolved>:6379
-> jedis 장애시 라이브러리에서 남기는 서비스 로그 (호출시)
참고 :: Aspect 는 제가 만든 class 지만, 뒤에 남은 에러 메세지는 라이브러리에서 남기는 메세지 리턴한 부분입니다
// 컨넥션 풀을 설정 안했을시
2021-04-07 21:42:08.760 ERROR 42837 --- [nio-8081-exec-9] com.code.redis.aspect.RedisCacheAspect : ### [RedisCacheAspect] Error : Cannot get Jedis connection; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
// 컨넥션 풀을 설정 했을시
2021-04-07 23:34:35.185 ERROR 45545 --- [nio-8081-exec-8] com.code.redis.aspect.RedisCacheAspect : ### [RedisCacheAspect] Error : Unexpected end of stream.; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
- > jedis 장애 복구시 서비스 로그
없음
# 참고 설정
- lettuce 장애시 서비스 호출하면 연결 시간(재시도 시간) 설정
// 위에 빈 설정 했던 부분을 아래와 같이 변경
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.setTimeout(1000); // 이부분으로 millisecond 단위 (1000ms = 1s)
return factory;
}
- jedis connection pool 설정 및 연결 시간 설정
// jedis 사용시 해당 빈 정의 필요
@Bean
public RedisConnectionFactory redisConnectionFactory() {
JedisConnectionFactory factory = new JedisConnectionFactory(new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()));
factory.setTimeout(3000);
// jedis pool
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(100); // 최대로 열수 있는 connection 수가 100개
jedisPoolConfig.setMaxIdle(10); // 바로 사용할 수 있는 게 10개
factory.setUsePool(true);
factory.setPoolConfig(jedisPoolConfig);
return factory;
}
Q) redis 장애를 대비할 수 있는 방법이 있을까?
- redis sentinel 이용! (다음 블로그에 계시 예정)