Spring Web
Lombok
Spring Data Redis
spring:
data:
redis:
host: <서버 주소>
port: <포트 번호>
username: <사용자 계정, 기본값 default>
password: <사용자 비밀번호>
spring-boot-starter-data-redis 의존성이 추가되어 있다면, Java 객체를 Redis에 손쉽게 CRUD 가능
도메인 객체 & Repository 만들기
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@RedisHash("item")
public class Item implements Serializable {
@Id
private Long id;
private String name;
private String description;
private Integer price;
}
일반적인 JPA Entity와 유사하게, Item이 나타내는 데이터가 Redis에 저장될 것임을 나타내는 클래스
차이점은, JPA의 @Entity 어노테이션이 아닌 @RedisHash 어노테이션이 추가
public interface ItemRepository extends CrudRepository<Item, Long> {}
extends CrudRepository의 존재로, 저희가 특별히 메서드를 선언하지 않아도 기본적인 CRUD 작업을 위한 메서드가 마련
이를 사용하면 Redis에 Hash 자료형으로 데이터를 저장
Create
@Test
public void createTest() {
Item item = Item.builder()
.id(1L)
.name("keyboard")
.description("Mechanical Keyboard Expensive 😢")
.build();
itemRepository.save(item);
}
Read One
@Test
public void readOneTest() {
Item item = itemRepository.findById(1L)
.orElseThrow();
System.out.println(item.getDescription());
}
Update
@Test
public void updateTest() {
Item item = itemRepository.findById(1L)
.orElseThrow();
item.setDescription("On Sale!!!");
itemRepository.save(item);
item = itemRepository.findById(1L)
.orElseThrow();
System.out.println(item.getDescription());
}
Delete
@Test
public void deleteTest() {
itemRepository.deleteById(1L);
}
Repository를 사용하는 것은 CRUD작업을 손쉽게 만들 수 있으며, 저희가 익숙한 Spring Data JPA와 유사하다는 장점이 있고 확장하는 법도 동일
하지만 Redis의 서로 다른 자료형, 그 자료형을 활용한 복잡한 기능을 만드는데는 한계 존재
RedisTemplate을 사용RedisTemplate을 정의하면서 Key와 Value로 사용될 Java 클래스를 정하고, 이후 사용할 세부 작업을 RedisOperations를 이용해 가져오는 방식복잡한 작업없이 Java의 문자열만 다루는 경우, StringRedisTemplate이 기본
@SpringBootTest
public class RedisTemplateTests {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void stringOpsTest() {
}
}
StringRedisTemplate은 각 자료형에 대응하는 *Operations 인터페이스 구현체를 반환할 수 있는 메서드들을 가짐
String
opsForValue() 메서드를 호출하게 될 경우 ValueOperations<String, String>이 반환
@Test
public void stringValueOpsTest() {
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("simplekey", "simplevalue");
System.out.println(ops.get("simplekey"));
ops.set("greeting", "hello redis!");
System.out.println(ops.get("greeting"));
}
ValueOperations<String, String>는, Java의 String 데이터를 Key와 Value로, Redis의 String 작업을 할 수 있는 메서드를 보유
set, get 말고도, Map과 사용할 수 있는 multiSet, Collection과 사용할 수 있는 multiGet 등, 원래 String 데이터 타입을 기준으로 사용하던 다양한 명령들이 메서드로 구현
Set
다루고 싶은 데이터 타입이 Set이라면, opsForSet()을 활용
@Test
public void stringSetOpsTest() {
SetOperations<String, String> setOps = stringRedisTemplate.opsForSet();
setOps.add("hobbies", "games");
setOps.add("hobbies", "coding");
setOps.add("hobbies", "alcohol");
setOps.add("hobbies", "games");
System.out.println(setOps.size("hobbies"));
}
Defalut
그 외에 EXPIRE, DEL같은 공용 기능은 StringRedisTemplate 자체에 정의
@Test
public void redisOpsTest() {
stringRedisTemplate.expire("simplekey", 5, TimeUnit.SECONDS);
stringRedisTemplate.expire("greeting", 10, TimeUnit.SECONDS);
stringRedisTemplate.expire("hobbies", 15, TimeUnit.SECONDS);
}
@Configuration
자세히 살펴보면, StringRedisTemplate 자체가 RedisTemplate<String, String>을 상속받는 클래스임을 알 수 있으며, 실제 opsForValue()나 opsForSet()등의 메서드는 RedisTemplate<K, V>에 정의

직접 <K, V>의 타입 파라미터를 결정할 수 있다면, 더 복잡한 데이터를 Redis와 주고받는게 가능할 것이라 추측, Bean 객체로 등록하면, 원하는 곳에서 주입해서 사용
이미 @RedisHash가 적용된 Item 대신 사용할 ItemDto를 만들어 보겠습니다.
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemDto {
private String name;
private String description;
private Integer price;
}그리고 @Configuration을 적용한 RedisConfig 클래스를 만들고, 아래 코드를 작성
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, ItemDto> itemRedisTemplate(
RedisConnectionFactory connectionFactory
) {
RedisTemplate<String, ItemDto> template = new RedisTemplate<>(); // 1
template.setConnectionFactory(connectionFactory); // 2
template.setKeySerializer(RedisSerializer.string()); // 3-a
template.setValueSerializer(RedisSerializer.json()); // 3-b
return template; // 4
}
}RedisTemplate을 만들면서, 타입 파라미터를 String, ItemDto로 설정. 이는 Key는 Java의 String, Value는 ItemDto를 사용해 Redis와 소통한다는 의미 ⇒ 이러면 Hash를 사용한다는 뜻인가… 사실상 Json으로 입력하니까
Redis와의 연결을 담당할 RedisConnectionFactory를 template에 전달
이때, RedisConnectionFactory의 경우 applcation.yaml 파일의 내용을 바탕으로 내부적으로 만들어 Bean 객체로 등록됨
데이터 직렬화 방법을 결정
Redis 상의 데이터를 좀 더 읽기 쉽게 하기 위함, 필수 아님
마지막으로 template을 반환하면 Bean 객체로 등록
이제 이 RedisTemplate을 사용하는 테스트를 작성
@Test
public void itemRedisTemplateTest() {
ValueOperations<String, ItemDto> ops = itemRedisTemplate.opsForValue();
ops.set("my:keyboard", ItemDto.builder()
.name("Mechanical Keyboard")
.price(300000)
.description("Expensive 😢")
.build());
System.out.println(ops.get("my:keyboard"));
ops.set("my:mouse", ItemDto.builder()
.name("mouse mice")
.price(100000)
.description("Expensive 😢")
.build());
System.out.println(ops.get("my:mouse"));
} 
실제로 Redis에 잘 저장됬는지도 확인

이처럼 Redis의 특정 기능을 상세하게 사용하고 싶다면, RedisTemplate을 사용할 수 있다. 내가 Redis에 저장하고 싶은 데이터의 형태를 잘 생각해본 다음, 적당한 형태의 RedisTemplate을 만들고, 필요한 곳에서 활용
강의 영상에 소개된 방식으로 Redis를 컴퓨터에 설치해보자.
내 블로그 글 별 조회수를 Redis로 확인하고 싶다.
/articles/{id} 형식이다.⇒ 이 경우는 뭐든 상관없다고 생각. String, String이면 될
블로그에 로그인한 사람들의 조회수와 가장 많은 조회수를 기록한 글을 Redis로 확인하고 싶다.
/articles/{id} 형식이다.⇒ 이 경우는 Sorted Set이 적절하다고 생각
처음은 Repository를 extends 하는 걸로 해봤다. 물론 비효율이 있다는 것은 알고 있다.
도메인
@Getter
@NoArgsConstructor
@RedisHash("article")
public class Article implements Serializable {
@Id
private Long id;
private Long viewCount;
public Article(Long id) {
this.id = id;
this.viewCount = 0L;
}
public void incrementViewCount() {
this.viewCount++;
}
}
리포지토리
public interface ArticleRedisRepository extends CrudRepository<Article, Long> { }
서비스
@Service
@RequiredArgsConstructor
public class BlogService {
private final ArticleRedisRepository redisRepository;
public String getArticle(Long id) {
Article target = redisRepository.findById(id).orElse(null);
if (target == null) {
target = new Article(id);
}
target.incrementViewCount();
redisRepository.save(target);
return "id : " + target.getId() + " 조회수 : " + target.getViewCount();
}
}
컨트롤러
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class BlogController {
private final BlogService blogService;
@GetMapping("/article/{id}")
public ResponseEntity<String> getArticle(@PathVariable("id") Long id) {
return ResponseEntity.ok().body(
"정보 : " + blogService.getArticle(id)
);
}
}
비효율인 것은 알지만 써보고 싶어서 썼다.

잘 오른다.
순위를 위해 ZSet을 활용해야 한다고 생각했다.
그리고 로그인 유저 중복 조회를 방지하기 위해 Set을 추가로 동작하게 해야 한다고 생각했다.
user는 실제로 로그인을 구현하기는 싫기 때문에 Request Parameter로 퉁쳤다.
Config
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new GenericToStringSerializer<>(Long.class));
template.setValueSerializer(new GenericToStringSerializer<>(Long.class));
return template;
}
}
서비스
@Service
@RequiredArgsConstructor
public class BlogService {
private final RedisTemplate<String, String> redisTemplate;
private static final String RANK_KEY = "article:viewRank";
private static final String USER_CHECK_KEY = "article:userCheck";
public String getArticle(Long id, String user) {
SetOperations<String, String> setOps = redisTemplate.opsForSet();
ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
if (Boolean.FALSE.equals(setOps.isMember(USER_CHECK_KEY, id + ":" + user))) {
setOps.add(USER_CHECK_KEY, id + ":" + user);
zSetOps.incrementScore(RANK_KEY, id.toString(), 1);
}
long count = Objects.requireNonNull(zSetOps.score(RANK_KEY, id.toString())).longValue();
return "id : " + id + " 조회수 : " + count;
}
public String getTopArticle() {
ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
Set<ZSetOperations.TypedTuple<String>> top = zSetOps.reverseRangeWithScores(RANK_KEY, 0, 2);
String returnVal = "";
StringBuilder sb = new StringBuilder(returnVal);
for (ZSetOperations.TypedTuple<String> t : Objects.requireNonNull(top)) {
Object value = t.getValue();
System.out.println("Value: " + value + ", Type: " + value.getClass().getName());
String article = String.valueOf(t.getValue());
long score = Objects.requireNonNull(t.getScore()).longValue();
sb.append(String.format("article : %s, viewCount : %d<br>", article, score));
}
return sb.toString();
}
}
컨트롤러
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class BlogController {
private final BlogService blogService;
@GetMapping("/article/{id}")
public ResponseEntity<String> getArticle(@PathVariable("id") Long id, @RequestParam(name = "user") String user) {
return ResponseEntity.ok().body(
blogService.getArticle(id, user)
);
}
@GetMapping("/article/top")
public ResponseEntity<String> getTopArticle() {
return ResponseEntity.ok().body(
blogService.getTopArticle()
);
}
}

먼저 조회수가 잘 올라가는 걸 알 수 있다.

그리고 Top article ranking도 가져오는 걸 볼 수 있다.
이 과정에서 이슈가 있었는데
Object value = t.getValue();
System.out.println("Value: " + value + ", Type: " + value.getClass().getName());
서비스의 이 라인이 문제가 됐었다.
java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.String (java.lang.Long and java.lang.String are in module java.base of loader 'bootstrap')
나는 분명 Redis의 String val을 저장했는데 가져오니까 알아서 Long 타입으로 바뀌어 나오는 것이었다.

그런데 intellij의 타입 추론은 저게 String이 맞다고 우겨서 에러를 엄청 뱉어냈다.
여튼 그냥 String으로 뽑긴 했다.
이렇게 실습을 하면서 배운 점은.
RedisTemplate<Key, Value>에서 Key로 하여금 그 자료구조를 가져오고 Value는 그 자료구조에 들어갈 애들의 타입을 정하는 거다.
127.0.0.1:6379> SMEMBERS article:userCheck
1) "1:\xeb\x82\xa8\xec\x98\x88\xec\xa4\x80"
2) "2:\xeb\x82\xa8\xec\x98\x88\xed\x9b\x88"
3) "3:\xeb\x82\xa8\xec\x98\x88\xec\xa4\x80"
4) "1:\xeb\x82\xa8\xec\x98\x88\xec\x9b\x90"
5) "2:\xeb\x82\xa8\xec\x98\x88\xec\x9b\x90"
6) "1:\xeb\x82\xa8\xec\x98\x88\xed\x9b\x88"
보통 String, String으로 해서 Hash든 Set이든 그냥 직렬화 역직렬화를 조지는 거다. 아니면 Serialize를 상속받든 하는 듯 하다.
여튼 Key에 따라 자료구조가 하나 할당된다고 생각하면 된다.
redis 인스턴스 하나를 통째가 Map이긴 하지만 효용성을 생각해서 redis 인스턴스는 Map<String, Object>라고 생각하고 각각의 Object가 Map<>일 수도, Set일 수도 있고 그런 식으로 생각해야 한다.
어 근데 redis cli 에서 기분나쁘게 나온다

3번 article은 1개 ~ 1번 article은 3개인데 1), 2)가 3번에 대한 정보고 3), 4)가 2번에 대한 정보고 그렇다…
새로운 Spring Boot 프로젝트를 만들어 다음을 진행해보자.
ItemOrder 클래스를 RedisHash로 만들고,ItemOrder의 속성값들을 ID를 제외하고 클라이언트에서 전달해준다.ItemOrder를 사용자에게 응답해준다.새로운 Spring Boot 프로젝트를 만들어 2번 실습의 기능을 실제로 만들어보자.
해설 코드에는 이상한 게 섞여있던데 무시하고
도메인
@Getter
@NoArgsConstructor
@RedisHash(value = "order")
public class Order {
@Id
private String id;
private String name;
private int quantity;
private long price;
private String status;
public Order(OrderDto dto) {
this.name = dto.name();
this.quantity = dto.quantity();
this.price = dto.price();
this.status = dto.status();
}
public void updateOrder(OrderDto dto) {
this.name = dto.name();
this.quantity = dto.quantity();
this.price = dto.price();
this.status = dto.status();
}
}
리포지토리
public interface OrderRepository extends CrudRepository<Order, String> { }
Dto
@Builder
public record OrderDto(
String name,
int quantity,
long price,
String status
) {
public static OrderDto from(Order order) {
return OrderDto.builder()
.name(order.getName())
.quantity(order.getQuantity())
.price(order.getPrice())
.status(order.getStatus())
.build();
}
}
컨트롤러(서비스 겸용)
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class OrderController {
private final OrderRepository orderRepository;
@PostMapping("/order")
public ResponseEntity<OrderDto> createOrder(@RequestBody OrderDto orderDto) {
Order order = new Order(orderDto);
orderRepository.save(order);
return ResponseEntity.ok(orderDto);
}
@PutMapping("/order/{id}")
public ResponseEntity<OrderDto> updateOrder(@PathVariable String id, @RequestBody OrderDto orderDto) {
Order order = orderRepository.findById(id).orElseThrow(IllegalArgumentException::new);
order.updateOrder(orderDto);
orderRepository.save(order);
return ResponseEntity.ok(orderDto);
}
@GetMapping("/order/{id}")
public ResponseEntity<OrderDto> getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id).orElseThrow(IllegalArgumentException::new);
return ResponseEntity.ok(OrderDto.from(order));
}
@GetMapping("/order")
public ResponseEntity<List<OrderDto>> getAllOrders() {
List<OrderDto> list = new ArrayList<>();
orderRepository.findAll().forEach(order -> list.add(OrderDto.from(order)));
return ResponseEntity.ok(list);
}
@DeleteMapping("/order/{id}")
public ResponseEntity<Void> deleteOrder(@PathVariable String id) {
Order order = orderRepository.findById(id).orElseThrow(IllegalArgumentException::new);
orderRepository.delete(order);
return ResponseEntity.ok().build();
}
}





이번 실습을 통해서 알게 된 점은… Id에 대해서 자동으로 생성해주지만 실제 저장되는 건
"order:4f14fad5-1611-453a-9ed6-e46de32f58b3”
이렇게 RedisHash value와 random string이 결합된 형태로 나타난다.
뒤에 random string 부분을 id로 사용하면 된다.
그리고 value, member가 저장되는 건 hash로 저장된다. hget, hset이 동작한다는 뜻