Spring에서 Redis

남예준·2025년 10월 23일

Dependencies and Yaml

Spring Web

Lombok

Spring Data Redis

spring:
  data:
    redis:
      host: <서버 주소>
      port: <포트 번호>
      username: <사용자 계정, 기본값 default>
      password: <사용자 비밀번호>
    

Spring Data Repository

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 자료형으로 데이터를 저장

Test

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

  • 만약 Key를 직접 설정하고 자료형도 직접 선택해 가면서 Redis를 활용하고 싶다면, RedisTemplate을 사용
  • RedisTemplate을 정의하면서 Key와 Value로 사용될 Java 클래스를 정하고, 이후 사용할 세부 작업을 RedisOperations를 이용해 가져오는 방식

StringRedisTemplate

복잡한 작업없이 Java의 문자열만 다루는 경우, StringRedisTemplate이 기본

  • Key와 Value를 전부 Java의 문자열이라고 가정, 문자열 데이터를 주고받기 위한 작업들을 준비하며, 기본 설정을 가지고 자동으로 만들어져 주입되는 Spring Bean
☝🏻 Redis의 String 자료형만 사용하는것이 아닌, Redis와 Spring Boot 사이에 데이터를 주고받는 과정에서 직렬화 - 역직렬화 할때 Java의 String으로 취급되는 클래스 Redis List에 넣을 때 Java 문자열을 넣고, Redis Set에 넣을 때 Java 문자열을 넣는다는 의미
@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);
}

RedisTemplate 정의하기

@Configuration

자세히 살펴보면, StringRedisTemplate 자체가 RedisTemplate<String, String>을 상속받는 클래스임을 알 수 있으며, 실제 opsForValue()opsForSet()등의 메서드는 RedisTemplate<K, V>에 정의

직접 <K, V>의 타입 파라미터를 결정할 수 있다면, 더 복잡한 데이터를 Redis와 주고받는게 가능할 것이라 추측, Bean 객체로 등록하면, 원하는 곳에서 주입해서 사용

이미 @RedisHash가 적용된 Item 대신 사용할 ItemDto를 만들어 보겠습니다.

  • ItemDto.java
    @Getter
    @ToString
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class ItemDto {
        private String name;
        private String description;
        private Integer price;
    }

그리고 @Configuration을 적용한 RedisConfig 클래스를 만들고, 아래 코드를 작성

  • RedisConfig.java
    @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
        }
    }
    1. RedisTemplate을 만들면서, 타입 파라미터를 String, ItemDto로 설정. 이는 Key는 Java의 String, Value는 ItemDto를 사용해 Redis와 소통한다는 의미 ⇒ 이러면 Hash를 사용한다는 뜻인가… 사실상 Json으로 입력하니까

    2. Redis와의 연결을 담당할 RedisConnectionFactorytemplate에 전달
      이때, RedisConnectionFactory의 경우 applcation.yaml 파일의 내용을 바탕으로 내부적으로 만들어 Bean 객체로 등록됨

    3. 데이터 직렬화 방법을 결정

      Redis 상의 데이터를 좀 더 읽기 쉽게 하기 위함, 필수 아님

      1. Key는 문자열로 직렬화, 역직렬화를 진행
      2. Value는 데이터를 JSON으로 직렬화
    4. 마지막으로 template을 반환하면 Bean 객체로 등록

이제 이 RedisTemplate을 사용하는 테스트를 작성

  • itemRedisTemplateTest()
    @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에 잘 저장됬는지도 확인

  • MGET의 결과

이처럼 Redis의 특정 기능을 상세하게 사용하고 싶다면, RedisTemplate을 사용할 수 있다. 내가 Redis에 저장하고 싶은 데이터의 형태를 잘 생각해본 다음, 적당한 형태의 RedisTemplate을 만들고, 필요한 곳에서 활용

실습1

  1. 강의 영상에 소개된 방식으로 Redis를 컴퓨터에 설치해보자.

  2. 내 블로그 글 별 조회수를 Redis로 확인하고 싶다.

    1. 블로그 URL의 PATH는 /articles/{id} 형식이다.
    2. 로그인 여부와 상관없이 새로고침 될때마다 조회수가 하나 증가한다.
    3. 이를 관리하기 위해 적당한 데이터 타입을 선정하고,
    4. 사용자가 임의의 페이지에 접속할 때 실행될 명령을 작성해보자.

    ⇒ 이 경우는 뭐든 상관없다고 생각. String, String이면 될

  3. 블로그에 로그인한 사람들의 조회수와 가장 많은 조회수를 기록한 글을 Redis로 확인하고 싶다.

    1. 블로그 URL의 PATH는 /articles/{id} 형식이다.
    2. 로그인 한 사람들의 계정은 영문으로만 이뤄져 있다.
    3. 이를 관리하기 위해 적당한 데이터 타입을 선정하고,
    4. 사용자가 임의의 페이지에 접속할 때 실행될 명령을 작성해보자.
    5. 만약 상황에 따라 다른 명령이 실행되어야 한다면, 주석으로 추가해보자.

    ⇒ 이 경우는 Sorted Set이 적절하다고 생각

2번

처음은 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)
        );
    }
}

비효율인 것은 알지만 써보고 싶어서 썼다.

잘 오른다.

3번

순위를 위해 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번에 대한 정보고 그렇다…

실습2

새로운 Spring Boot 프로젝트를 만들어 다음을 진행해보자.

  1. 주문 ID, 판매 물품, 갯수, 총액, 결제 여부에 대한 데이터를 지정하기 위한 ItemOrder 클래스를 RedisHash로 만들고,
    1. 주문 ID - String
    2. 판매 물품 - String
    3. 갯수 - Integer
    4. 총액 - Long
    5. 주문 상태 - String
  2. 주문에 대한 CRUD를 진행하는 기능을 만들어보자.
    1. ItemOrder의 속성값들을 ID를 제외하고 클라이언트에서 전달해준다.
    2. 성공하면 저장된 ItemOrder를 사용자에게 응답해준다.

새로운 Spring Boot 프로젝트를 만들어 2번 실습의 기능을 실제로 만들어보자.

  1. 실제 Entity 등은 만들지 않고, Redis에 데이터만 저장해보자.

1, 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이 동작한다는 뜻

0개의 댓글