레디스 간단한 설정
1. 도커로 redis-cli 접속
- 아래 명령어를 통해 redis-cli 에 접근할 수 있다.
docker run --name my-redis -d -p 6379:6379 redis
docker exec -it my-redis redis-cli —-raw
2. SpringBoot 설정
build.gradle
implementation ("org.springframework.boot:spring-boot-starter-data-redis")
- 스프링 부트 2.X 부터는 lettuce를 기본 클라이언트로 선택하여 redis와 통신한다.
- TPS, CPU, Connection 개수, 응답속도 등에서 jedis 보다 성능이 좋다고 한다. 자세한 내용은 여기를 참고하자. https://jojoldu.tistory.com/418
레디스 인터페이스
- 레디스의 모든 기능을 이용하기 위해 RedisTemplate으로 사용할 수도 있고, 엔티티 중심으로 CRUD 연산을 쉽고 빠르게 할 수 있는 RedisRepository 있다. 또한, Spring Cache와 통합하여 어노테이션 기반으로 캐싱을 쉽게 적용할 수 있다.
1. RedisTemplate
- key, value 연산을 직접 할 수 있으며, 레디스 자료 구조에 따른 세밀한 제어가 필요한 경우에 사용한다.
- key, value 직렬화를 어떤 방식으로 할지 먼저 설정해 주어야 한다.
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
- Redis Hash 자료구조를 사용한다면 해시 자료구조에 대한 직렬화 방법도 등록해준다. GenericJackson2JsonRedisSerializer를 사용하면 값을 Json 형태로 직렬화할 수 있다.
@Getter
public class Product {
private String name;
private int price;
private int quantity;
public Product(
@JsonProperty("name") String name,
@JsonProperty("price") int price,
@JsonProperty("quantity") int quantity) {
this.name = name;
this.price = price;
this.quantity = quantity;
}
...
}
- Setter를 열어두지 않는다면 @JsonProperty로 매핑 정보를 알려주어야 한다.
Redis 자료 구조 제어하기
- 키의 추가는 자료구조에 따라 아래 메서드를 사용한다.
String | opsForValue() | 단일 Key-Value 형태 데이터 저장 |
---|
List | opsForList() | 순서가 있는 데이터 저장 |
Set | opsForSet() | 중복 없는 데이터 저장 |
Sorted Set | opsForZSet() | 정렬된 데이터 저장 |
Hash | opsForHash() | 키-필드-값 형태 데이터 저장 |
| | |
- 키의 삭제는 delete(key), 만료 기간 설정은 expire(key, ttl)로 할 수 있다.
1) String
(1) 스프링
@Autowired RedisTemplate redisTemplate;
@Test
void setString() {
redisTemplate.delete("juny");
redisTemplate.delete("지니");
redisTemplate.opsForValue().set("juny", "쭈니야");
redisTemplate.opsForValue().set("지니", "쭈니");
Product product1 = new Product("Monitor", 120, 15);
redisTemplate.opsForValue().set("product:1", product1);
String value1 = (String) redisTemplate.opsForValue().get("juny");
String value2 = (String) redisTemplate.opsForValue().get("지니");
Product value3 = (Product) redisTemplate.opsForValue().get("product:1");
assertThat(value1).isEqualTo("쭈니야");
assertThat(value2).isEqualTo("쭈니");
assertThat(value3).isEqualTo(product1);
}
(2) 레디스
- 한글로 된 Key, Value 값을 redis-cli에서 이스케이프 형식으로 보고 싶지 않다면,
docker exec -it my-redis redis-cli —-raw
로 접속한다.
127.0.0.1:6379> get juny
"쭈니야"
127.0.0.1:6379> get 지니
"쭈니"
127.0.0.1:6379> get product:1
{"@class":"redis.RedisTemplateTest$Product","name":"Monitor","price":120,"quantity":15}
2) List
(1) 스프링
@Test
void setList() {
redisTemplate.delete("user");
redisTemplate.opsForList().leftPush("user", "1");
redisTemplate.opsForList().leftPush("user", "2");
redisTemplate.opsForList().leftPush("user", "3");
List<String> list = redisTemplate.opsForList().range("user", 0, -1);
assertThat(list).containsExactly("3", "2", "1");
}
(2) 레디스
127.0.0.1:6379> LRANGE user 0 -1
"3"
"2"
"1"
3) Set
(1) 스프링
@Test
void setSet() {
redisTemplate.delete("numbers");
redisTemplate.opsForSet().add("numbers", "1", "1", "2", "2", "3", "4", "5", "5");
Set<Object> numbers = redisTemplate.opsForSet().members("numbers");
assertThat(numbers).contains("1", "2", "3", "4", "5");
assertThat(numbers).hasSize(5);
}
(2) 레디스
127.0.0.1:6379> SMEMBERS numbers
"1"
"2"
"3"
"4"
"5"
4) Sorted Set
(1) 스프링
@Test
void setSortedSet() {
redisTemplate.delete("scores");
redisTemplate.opsForZSet().add("scores", "korean", 100);
redisTemplate.opsForZSet().add("scores", "science", 80);
redisTemplate.opsForZSet().add("scores", "math", 90);
Set scores = redisTemplate.opsForZSet().rangeWithScores("scores", 0, -1);
scores.forEach(System.out::println);
assertThat(scores).hasSize(3);
assertThat(scores)
.extracting("value")
.containsExactly("science", "math", "korean");
assertThat(scores)
.extracting("score")
.containsExactly(80.0, 90.0, 100.0);
}
(2) 레디스
127.0.0.1:6379> ZRANGE scores 0 -1 WITHSCORES
"science"
80
"math"
90
"korean"
100
5) Hash
(1) 스프링
@Test
void setHash() {
redisTemplate.opsForHash().put("product", "name", "상품 1");
redisTemplate.opsForHash().put("product", "price", "50000");
redisTemplate.opsForHash().put("product", "quantity", "30");
Map<String, String> map = redisTemplate.opsForHash().entries("product");
assertThat(map).containsEntry("name", "상품 1");
assertThat(map).containsEntry("price", "50000");
assertThat(map).containsEntry("quantity", "30");
}
(2) 레디스
127.0.0.1:6379> HGETALL product
name
"상품 1"
price
"50000"
quantity
"30"
2. RedisRepository
- RedisRepository 인터페이스를 이용하면, Spring Data JPA와 유사하게 레디스 CRUD를 할 수 있다.
- 기본적으로 Redis Hash 자료구조를 사용하여 저장한다.
(1) 스프링
@RedisHash(value = "RefreshToken", timeToLive = 60)
@Getter
public class RefreshToken {
@Id
private Long id;
private Long userId;
private String refresh;
public RefreshToken(Long id, Long userId, String refresh) {
this.id = id;
this.userId = userId;
this.refresh = refresh;
}
}
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {}
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Test
void saveRefreshToken() {
RefreshToken refreshToken = new RefreshToken(1L, 1L, "asdkfaisxk291j28");
RefreshToken savedToken = refreshTokenRepository.save(refreshToken);
RefreshToken findToken = refreshTokenRepository
.findById(1L)
.orElseThrow(() -> new RuntimeException("Refresh token not found"));
assertThat(savedToken.getId()).isEqualTo(findToken.getId());
assertThat(savedToken.getRefresh()).isEqualTo(findToken.getRefresh());
assertThat(savedToken.getUserId()).isEqualTo(findToken.getUserId());
}
(2) 레디스
127.0.0.1:6379> HGETALL RefreshToken:1
_class
com.juny.jspboardwithmybatis.redis.RefreshToken
id
1
refresh
asdkfaisxk291j28
userId
1
3. Spring Cache
- Spring Cache를 Redis와 통합하여 redis의 빠른 읽기, 쓰기 성능을 이용하는 방법이다.
- 먼저, Spring Cache 의존성을 추가한다.
gradle
implementation 'org.springframework.boot:spring-boot-starter-cache'
- 레디스 캐시 매니저를 통해 Redis를 캐시 저장소로 사용하도록 설정한다.
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(5));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.build();
}
- Spring Cache에 공통 유효기간 외 특정 키에 대한 유효기간을 별도로 지정하고 싶다면 아래와 같이 설정한다.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(5));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("boardDetails", defaultConfig.entryTtl(Duration.ofMinutes(1)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
- DB 쿼리를 사용하지 않고, 캐시된 데이터를 재사용하고 싶다면 서비스에서 캐싱을 적용한다.
@Cacheable(cacheNames = "boardDetails", key = "#id")
public ResBoardDetail getBoard(Long id) {
Map<String, Object> board = boardMapper.findBoardDetailById(id);
if (board == null) {
throw new BoardNotFoundException(ErrorCode.BOARD_NOT_FOUND);
}
return BoardDTOConverter.convertToResBoardDetail(id, board);
}
- 키를 삭제하고 싶다면, @CacheEvict를 이용해 삭제할 수 있다.
@CacheEvict(cacheNames = "boardDetails", key = "#reqBoardDelete.boardId")
@Transactional
public void deleteBoard(ReqBoardDelete reqBoardDelete) {
for (var comment : reqBoardDelete.getComments()) {
commentMapper.deleteCommentById(comment.getId());
}
for (var att : reqBoardDelete.getAttachments()) {
attachmentMapper.deleteAttachmentById(att.getId());
}
for (var image : reqBoardDelete.getBoardImages()) {
boardImageMapper.deleteBoardImageById(image.getId());
}
boardMapper.deleteBoardById(reqBoardDelete.getBoardId());
}
레디스 적용 사례 찾아보기
- 레디스를 캐시로 사용했을 때, 성능 개선 효과가 크려면 아래 조건에 해당해야 한다.
1. 검색하는 시간이 오래 걸리거나, 매번 계산을 통해 데이터를 가져와야 하는 경우
2. 데이터가 잘 변하지 않는 경우
3. 자주 검색되는 데이터인 경우
- 위 경우에 해당하면, Redis를 도입했을 때 성능효과가 클 수 있다. 여태까지 프로젝트를 진행하며 적용할 수 있는 부분을 찾아보면…
1. 상품 상세페이지, 게시판 상세 페이지 등
- 도메인 특성상 해당 페이지가 자주 조회되고, 잘 변경되지 않는다면 레디스 적용하기에 적합하다.
- 좋아요, 조회수 등도 매번 반영하기보다 캐시에 저장해놓고, 일정 주기로 DB에 반영한다면 성능 효과가 클 것으로 보인다.
2. 리프레시 토큰
- 사용자가 로그인할 때마다 해당 토큰이 유효한지 검증해야 하므로 레디스 적용하기 적합하다.
- MySQL을 토큰 저장소로 사용할 경우 주기적으로 유효기간이 지난 토큰을 배치나 스케줄러로 삭제해 주어야 하지만, 레디스는 TTL을 통해 유효기간이 만료된 키를 자동으로 삭제할 수 있다.
- 최근 검색 목록도 자주 조회되고, 오래된 데이터를 삭제할 때 sorted set을 이용하면 쉽고 효율적으로 구현할 수 있다.
3. 실시간 시스템 (알림, 랭킹, 지도 등)
- 랭킹 시스템(ex: 일간, 주간, 월간 가장 인기 있는 상품 10개)
- 실시간 알림(레디스 메시징 큐)
- 지도
- geo set을 이용해 경도와 위도 연산 효율적으로 처리할 수 있다.
다루지 않았지만, 중요한 내용들
- 레디스 자료 구조, 캐시 전략
- 레디스 백업 AOF, RDB (가용성)
- 레디스 복제 (가용성)
- 페일오버, 센티널 (가용성)
- 레디스 클러스터 (확장성)