개요
Redis 학습 과정에서 RedisHash 말고도 RedisTemplate 방법이 있다는 것을 알았습니다.
프로젝트를 진행하면서 JPA로 MySQL과 매핑된 Product 클래스를 Redis에 저장해보고 싶었습니다.
@RedisHash 랑 @Entity를 한 클래스에 동시에 사용하는 것은 가능은 하지만 두 개의 저장소에 동시에 매핑됨으로 복잡하고 에노테이션 충돌이 발생할 수도 있고 불안한 점이 많았습니다.
이 때 RedisTemplate을 학습하고 적용했습니다.
RedisTemplate
RedisTemplate는 Spring Data Redis에서 제공하는 핵심 클래스로 Redis 연산을 수행하기 위한 기본적인 템플릿을 제공합니다.
RedisTemplate을 원하는 대로 커스텀하여 Spring Boot와 Redisdml 연결 설정, 데이터 변환 직렬화(Serializer) 등의 설정을 직접 할 수 있습니다.
RedisTemplate 사용하기
1. build.gradle에 의존성 추가
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// 직렬화, 역직렬화
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
2. application.yml에 연결 정보 추가
spring:
redis:
host: localhost
port: 6379
3. Product 객체 생성
package com.dotd.product.entity;
import jdk.jfr.Name;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.format.annotation.DateTimeFormat;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "seller_id")
private String sellerId;
private Integer categoryId;
private String code;
private String name;
private Integer price;
private String description;
@Builder.Default
@Column(name = "view_count")
private Integer viewCount = 0;
@Builder.Default
@Column(name = "order_count")
private Integer orderCount = 0;
@Builder.Default
@Column(name = "like_count")
private Integer likeCount = 0;
@Builder.Default
@Column(name = "review_count")
private Integer reviewCount = 0;
@CreatedDate
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
// 초기값 세팅
@PrePersist
public void initializer() {
this.createdAt = LocalDateTime.now();
}
}
4. RedisConfig 파일 생성 및 RedisTemplate 빈 생성
RedisTemplate 빈을 생성할 때 원하는 설정들을 선택합니다.
package com.dotd.product.redis.config;
import com.dotd.product.entity.Product;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
private final RedisConnectionFactory redisConnectionFactory;
// product 객체를 Redis에 연결하기 위해 RedisTemplate
@Bean
public RedisTemplate<String, Product> productRedisTemplate() {
RedisTemplate<String, Product> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// JSON 직렬화 설정
Jackson2JsonRedisSerializer<Product> serializer = new Jackson2JsonRedisSerializer<>(Product.class);
ObjectMapper mapper = new ObjectMapper();
// Java 객체를 JSON 직렬화할 때 클래스 타입 정보를 포함시키도록 지시 -> 역직렬화시 올바른 타입 객체 생성
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
// Java 8 날짜/시간 타입 지원 모듈 추가
mapper.registerModule(new JavaTimeModule());
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
serializer.setObjectMapper(mapper);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}
직렬화해서 저장하는 이유
Redis와 같은 인메모리 데이터베이스에서는 모든 데이터를 바이트 배열로 저장합니다. 따라서 객체나 복잡한 데이터 구조를 저장하기 위해 먼저 직렬화 과정을 거쳐야 합니다.
직렬화 : 바이트 배열로 저장하는 것
역직렬화 : 바이트 배열을 다시 원래 형태로 돌리는 것
ObjectMapper를 사용한 이유
Redis에 저장된 객체를 역직렬화할 때 타입이 무엇인지 몰라 에러가 발생했습니다.
ObjectMapper에 역직렬화할 때 변환해야 하는 타입을 알려주는 코드를 넣어 문제를 해결했습니다.
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
또한, LocalDateTime이 직렬화가 지원이 안되서 에러가 발생했습니다.
이를 해결하기 위해 ObjectMapper 를 생성하여 날짜, 시간을 지원해주는 모듈을 세팅해 주었습니다.
mapper.registerModule(new JavaTimeModule()); mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); serializer.setObjectMapper(mapper);
5. service 생성 - redisTemplate 사용
RedisTemplate 원하는 빈 가져오기
참고로 RedisTemplate가 여러 개 일 때는 자료형으로 구분해서 가져옵니다
즉 RedisTemplate<String, Product> 면 <String, Product>에 맞는 Bean을 가져와 주입해 줍니다.
package com.dotd.product.redis.service;
import com.dotd.product.dto.ProductRegistDto;
import com.dotd.product.dto.ProductResponseDto;
import com.dotd.product.entity.Product;
import com.dotd.product.mapper.ProductMapper;
import com.dotd.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
/*
RedisTemplate 을 이용한 객체 저장, 조회
*/
@Service
@RequiredArgsConstructor
public class ProductRedisService {
private final RedisTemplate<String, Product> redisTemplate;
private final ProductRepository productRepository;
private final ProductMapper productMapper;
// Redis 저장
public ProductResponseDto saveProduct(ProductRegistDto dto) {
Product product = productMapper.productRegistDtoToProduct(dto);
Product save = productRepository.save(product);
// redis 저장
redisTemplate.opsForValue().set("product" + save.getId(), save);
ProductResponseDto result = productMapper.productToProductResponseDto(save);
return result;
}
// Redis 조회
public ProductResponseDto getProduct(String id) {
Product product = (Product) redisTemplate.opsForValue().get("product" + id);
ProductResponseDto dto = productMapper.productToProductResponseDto(product);
return dto;
}
}
RedisTemplate 자료형 지원 메소드들
저는 RedisTemplate.opsForValue() 메소드로 객체를 String으로 저장했습니다.
원하는 자료 구조로 저장할 수 있게 다양한 메소드들을 지원해 줍니다.
메서드 | 설명 |
opsForValue | Strings를 쉽게 Serialize / Deserialize 해주는 interface |
opsForList | List를 쉽게 Serialize / Deserialize 해주는 interface |
opsForSet | Set을 쉽게 Serialize / Deserialize 해주는 interface |
opsForZSet | ZSet을 쉽게 Serialize / Deserialize 해주는 interface |
opsForHash | Hash를 쉽게 Serialize / Deserialize 해주는 interface |
6. controller 생성
package com.dotd.product.redis.controller;
import com.dotd.product.dto.ProductRegistDto;
import com.dotd.product.dto.ProductResponseDto;
import com.dotd.product.entity.Product;
import com.dotd.product.redis.entity.Desk;
import com.dotd.product.redis.service.DeskRedisService;
import com.dotd.product.redis.service.ProductRedisService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Controller
@RequiredArgsConstructor
@RequestMapping("/redis")
@Slf4j
public class RedisController {
private final ProductRedisService productRedisService;
private final DeskRedisService deskRedisService;
// 상품 등록
@PostMapping("/save")
public ResponseEntity<?> save(@RequestBody ProductRegistDto dto) {
System.out.println(dto.toString());
ProductResponseDto result = productRedisService.saveProduct(dto);
return ResponseEntity.ok(result);
}
@GetMapping("/get")
public ResponseEntity<?> get(@RequestParam(name = "id") String id) {
System.out.println(id);
ProductResponseDto dto = productRedisService.getProduct(id);
return ResponseEntity.ok(dto);
}
}
7. 저장 결과
Product 객체가 String 형태로 저장된 것을 확인할 수 있었습니다.
Cache-Aside 전략
Cache-Aside는 Lazy-Loading이라 불리며 캐시 조회 전략 중 하나 입니다.
조회 요청이 들어올 때 캐시를 먼저 확인합니다.
캐시가 있으면 캐시 값을 반환하고 캐시가 없으면 DB에서 값을 가져와 캐싱하고 값을 반환합니다.
그 이후, 같은 요청이 들어오면 캐싱된 값을 반환합니다.
캐싱은 DB의 I/O 작업이 없어서 빠른 응답이 가능하며 높은 부하 상황에서도 응답을 할 수 있게 해줍니다.
저는 Redis를 캐싱 서버로 하여 Cache-Aside를 구현해 보았습니다.
service 단에서 Cache-Aside 전략으로 생성한 메소드
// 상품 상세 조회 Cache Aside 사용
@Override
public ProductResponseDto findByIdCacheAside(Integer id) {
// 캐시 확인
Product productRedis = redisTemplate.opsForValue().get("product" + id);
if(productRedis != null) {
return productMapper.productToProductResponseDto(productRedis);
}
else {
Product productMySQL = productRepository.findById(id).get();
// redis 에 캐싱
redisTemplate.opsForValue().set("product" + productMySQL.getId(), productMySQL);
return productMapper.productToProductResponseDto(productMySQL);
}
}
성능 확인
MySQL에서 조회할 때 : 57ms
2023-09-07 10:47:27.449 INFO 21100 --- [nio-8080-exec-3] com.dotd.product.aop.LoggingAspect : 위치 : ProductController.findById(..) / 경과 시간 : 57ms
Redis에 캐싱값이 없어 DB에서 조회한 후 캐싱한 후 반환 : 334ms
2023-09-07 10:47:30.691 INFO 21100 --- [nio-8080-exec-2] com.dotd.product.aop.LoggingAspect : 위치 : ProductController.findByIdCacheAside(..) / 경과 시간 : 334ms
Redis에 캐싱값 조회 : 23ms
2023-09-07 10:47:32.050 INFO 21100 --- [nio-8080-exec-7] com.dotd.product.aop.LoggingAspect : 위치 : ProductController.findByIdCacheAside(..) / 경과 시간 : 23ms
Redis에 Cache로 조회했을 때 응답이 빨라진 것을 확인할 수 있었습니다.
이는 Redis에는 DB에서 하는 I/O 작업이 없기 때문에 빠른 성능을 낼 수 있습니다.
Redis에 Cache은 높은 부하 상태일 때 더 효과를 발휘합니다
테스트 프로그램인 Jmeter, ngrinder 등으로 높은 부하 상태일 때 성능을 측정할 수 있습니다.
각각의 Cache 전략들을 구현한 후에 테스트 프로그램으로 성능 측정을 해보겠습니다.
마무리
이번에는 또 다른 방법인 RedisTemplate을 활용하여 Spring Boot와 Redis를 연결하였습니다.
다음에는 Redis를 이용한 Cache 전략들을 구현하여 성능 개선을 확인해보려 합니다.
감사합니다.
참고 사이트
https://wildeveloperetrain.tistory.com/m/32