[Spring Boot] Redis (Lettuce)를 이용한 간단한 API 제작

2020. 10. 16. 23:49Spring/Spring Boot

1. Redis

Redis 서버가 설치되어 있다는 전제하에 진행된다.


[참고] Database - Practice - AWS EC2 인스턴스 Redis 설치

 

 

 

2. Redis 설정

1) build.gradle

Redis (Lettuce) 를 사용하기 위한 의존성을 작성해준다. Redis에 접근하기 위해서는 Spring Data Redis 라이브러리를 사용하며, Lettuce와 Jedis 방식이 있다. 별도의 의존성 설정이 필요한 Jedis와 달리 Lettuce는 별도의 설정없이 사용할 수 있다.

 

Spring Data Redis에서는 RedisTemplate를 이용하는 방식과 Redis Repository를 이용한 방식이 존재한다. 해당 글에서는 두 가지 방법을 혼용해서 진행한다. (테스트 케이스는 RedisTemplate, 저장 및 조회 기능은 Redis Repository)

compile('org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE')

2) application.yml

spring:
  redis:
    host: [Redis 서버 IP]
    port: 6379

3) RedisProperties.java

application.yml에서 읽어온 값을 객체 클래스로 제작한다.

package com.example.lettucetest.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Getter
@Setter
@ConfigurationProperties(prefix = "spring.redis")
@Component
public class RedisProperties {
    private String host;
    private int port;
}

4) RedisRepositoryConfig.java

Redis의 연결을 정의하는 클래스를 작성해준다. RedisConnectionFactory를 통해 내장 혹은 외부의 Redis를 연결한다. RedisTemplate을 통해 RedisConnection에서 넘겨준 byte 값을 객체 직렬화한다.

package com.example.lettucetest.config;

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.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {

    /*
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;
    */

    private final RedisProperties redisProperties;

    // Lettuce
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

Redis 외부 서버가 존재할 경우 필요는 없지만, 만약 내장 서버로 환경을 구성할 경우, Embedded Redis의 설정을 추가로 해주어야한다. Embedded Redis의 경우 H2와 같은 내장 Redis 데몬이다. 내장 Redis를 사용하기 위한 it.ozimov.embedded-redis는 의존성을 추가해주고, 내장 Redis를 사용할 때 설정 파일의 prifile이 local일 때만 동작하도록 @Profile 어노테이션을 사용하여 EmbeddedRedis 클래스를 생성해준다.

compile group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2'
package com.example.lettucetest.config;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import redis.embedded.RedisServer;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@Slf4j
@RequiredArgsConstructor
@Profile("local")
@Configuration
public class EmbeddedRedisConfig {
    
    private final RedisProperties redisProperties;
    
    private RedisServer redisServer;
    
    @PostConstruct
    public void redisServer() {
        redisServer = new RedisServer(redisProperties.getPort());
        redisServer.start();
    }
    
    @PreDestroy
    public void stopRedis() {
        if(redisServer != null) {
            redisServer.stop();
        }
    }
}

5) Redis 데이터 타입 테스트

Redis에서 사용되는 String, List, Set, Sorted Set, Hash 데이터 타입별로 테스트를 진행하여 동작 여부를 확인한다. 테스트를 진행하면서, 실제로 서버에 키-값이 저장이 되는지 직접 redis-cli에 명령어를 실행해보면서 확인한다.

메서드 설명
opsForValue String을 쉽게 Serialize / Deserialize 해주는 인터페이스
opsForList List를 쉽게 Serialize / Deserialize 해주는 인터페이스
opsForSet Set을 쉽게 Serialize / Deserialize 해주는 인터페이스
opsForZSet ZSet을 쉽게 Serialize / Deserialize 해주는 인터페이스
opsForHash Hash를 쉽게 Serialize / Deserialize 해주는 인터페이스
package com.example.lettucetest.config;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.*;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;

@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisRepositoryConfigTest {

    @Autowired
    StringRedisTemplate redisTemplate;

    // String
    @Test
    public void testString() {
        final String key = "testString";
        final ValueOperations<String, String> stringStringValueOperations = redisTemplate.opsForValue();

        stringStringValueOperations.set(key, "1");
        final String result_1 = stringStringValueOperations.get(key);

        System.out.println("result_1 = " + result_1);

        stringStringValueOperations.increment(key);
        final String result_2 = stringStringValueOperations.get(key);

        System.out.println("result_2 = " + result_2);

        /*
        redis-cli 명령어
        - GET key
         */
    }

    // List
    @Test
    public void testList() {
        final String key = "testList";
        final ListOperations<String, String> stringStringListOperations = redisTemplate.opsForList();

        stringStringListOperations.rightPush(key, "H");
        stringStringListOperations.rightPush(key, "E");
        stringStringListOperations.rightPush(key, "L");
        stringStringListOperations.rightPush(key, "L");
        stringStringListOperations.rightPush(key, "O");

        stringStringListOperations.rightPushAll(key, " ", "W", "O", "L", "D");

        final String character_1 = stringStringListOperations.index(key, 1);

        System.out.println("character_1 = " + character_1);

        final Long size = stringStringListOperations.size(key);

        System.out.println("size = " + size);

        final List<String> resultRange = stringStringListOperations.range(key, 0,9);

        assert resultRange != null;
        System.out.println("resultRange = " + Arrays.toString(resultRange.toArray()));

        /*
        redis-cli 명령어
        - INDEX key index
        - LRANGE key start stop
         */
    }

    // Set
    @Test
    public void testSet() {
        final String key = "testSet";
        SetOperations<String, String> stringStringSetOperations = redisTemplate.opsForSet();

        stringStringSetOperations.add(key, "H");
        stringStringSetOperations.add(key, "E");
        stringStringSetOperations.add(key, "L");
        stringStringSetOperations.add(key, "L");
        stringStringSetOperations.add(key, "O");

        Set<String> test =  stringStringSetOperations.members(key);

        assert test != null;
        System.out.println("member = " + Arrays.toString(test.toArray()));

        final Long size = stringStringSetOperations.size(key);

        System.out.println("size = " + size);

        Cursor<String> cursor = stringStringSetOperations.scan(key, ScanOptions.scanOptions().match("*").count(3).build());

        while(cursor.hasNext()) {
            System.out.println("cursor = " + cursor.next());
        }

        /*
        redis-cli 명령어
        - SMEMBERS key
         */
    }

    // Sorted Set
    @Test
    public void testSortedSet() {
        final String key = "testSortedSet";
        ZSetOperations<String, String> stringStringZSetOperations = redisTemplate.opsForZSet();

        stringStringZSetOperations.add(key, "H", 1);
        stringStringZSetOperations.add(key, "E", 5);
        stringStringZSetOperations.add(key, "L", 10);
        stringStringZSetOperations.add(key, "L", 15);
        stringStringZSetOperations.add(key, "O", 20);

        Set<String> range = stringStringZSetOperations.range(key, 0, 5);

        assert range != null;
        System.out.println("range = " + Arrays.toString(range.toArray()));

        final Long size = stringStringZSetOperations.size(key);

        System.out.println("size = " + size);

        Set<String> scoreRange = stringStringZSetOperations.rangeByScore(key, 0, 13);

        assert scoreRange != null;
        System.out.println("scoreRange = " + Arrays.toString(scoreRange.toArray()));

        /*
        redis-cli 명령어
        - ZRANGE key start stop [WITHSCORES]
        - ZRANGEBYSCORE key min max [WITHSCORES]
         */
    }

    // Hash
    @Test
    public void testHash() {
        final String key = "testHash";
        HashOperations<String, Object, Object> stringObjectObjectHashOperations = redisTemplate.opsForHash();

        stringObjectObjectHashOperations.put(key, "Hello", "testHash");
        stringObjectObjectHashOperations.put(key, "Hello2", "testHash2");
        stringObjectObjectHashOperations.put(key, "Hello3", "testHash3");

        Object hello = stringObjectObjectHashOperations.get(key, "Hello");

        System.out.println("hello = " + hello);

        Map<Object, Object> entries = stringObjectObjectHashOperations.entries(key);

        System.out.println("entries = " + entries.get("Hello2"));

        Long size = stringObjectObjectHashOperations.size(key);

        System.out.println("size = " + size);

        /*
        redis-cli 명령어
        - HGET key field
         */
    }

    /*
    redis-cli 키 전체 삭제 명령어
    - flushAll
    */
}

Redis 데이터 타입 테스트

 

 

 

3. API 제작

1) src 패키지 구조

config
ㄴ RedisProperties.java
ㄴ RedisRepositoryConfig.java

controller
ㄴ dto
   ㄴ RedisCrudResponseDto.java
   ㄴ RedisCrudSaveRequestDto.java
ㄴ RedisController.java

domain
ㄴ redis
   ㄴ RedisCrud.java
   ㄴ RedisCrudRepository.java
   
service
ㄴ redis
   ㄴ RedisCrudService

2) RedisCrud.java

쉽게 생각한다면 Entity 클래스이다. Builder 어노테이션을 이용하였고, 수정이 가능한 메서드를 작성해준다.

package com.example.lettucetest.domain.redis;

import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import java.time.LocalDateTime;

@ToString
@Getter
@RedisHash("redisCrud")
public class RedisCrud {

    @Id
    private Long id;
    private String description;
    private LocalDateTime updatedAt;

    @Builder
    public RedisCrud(Long id, String description, LocalDateTime updatedAt) {
        this.id = id;
        this.description = description;
        this.updatedAt = updatedAt;
    }

    public void update(String description, LocalDateTime updatedAt) {
        if(updatedAt.isAfter(this.updatedAt)) {
            this.description = description;
            this.updatedAt = updatedAt;
        }
    }
}

3) RedisCrudRepository

RedisCrudRepository는 JpaRepository를 상속받을 때와 유사하게 작성된다.

package com.example.lettucetest.domain.redis;

import org.springframework.data.repository.CrudRepository;

public interface RedisCrudRepository extends CrudRepository<RedisCrud, Long> {
}

4) RedisCrudService.java

package com.example.lettucetest.service.redis;

import com.example.lettucetest.controller.dto.RedisCrudResponseDto;
import com.example.lettucetest.controller.dto.RedisCrudSaveRequestDto;
import com.example.lettucetest.domain.redis.RedisCrud;
import com.example.lettucetest.domain.redis.RedisCrudRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class RedisCrudService {

    private final RedisCrudRepository redisCrudRepository;

    @Transactional
    public Long save(RedisCrudSaveRequestDto requestDto) {
        return redisCrudRepository.save(requestDto.toRedisHash()).getId();
    }

    public RedisCrudResponseDto get(Long id) {
        RedisCrud redisCrud = redisCrudRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("Nothing saved. id=" + id));
        return new RedisCrudResponseDto(redisCrud);
    }
}

5) RedisCrudSaveRequestDto.java

저장할 때 데이터를 전달하기 위해 사용되는 Dto 클래스를 작성해준다.

package com.example.lettucetest.controller.dto;

import com.example.lettucetest.domain.redis.RedisCrud;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor
public class RedisCrudSaveRequestDto {

    private Long id;
    private String description;
    private LocalDateTime updatedAt;

    @Builder
    public RedisCrudSaveRequestDto(Long id, String description, LocalDateTime updatedAt) {
        this.id = id;
        this.description = description;
        this.updatedAt = updatedAt;
    }

    public RedisCrud toRedisHash() {
        return RedisCrud.builder()
                .id(id)
                .description(description)
                .updatedAt(LocalDateTime.now())
                .build();
    }
}

6) RedisCrudResponseDto.java

조회할 때 Redis로 부터 값을 받아올 Dto 클래스를 작성한다.

package com.example.lettucetest.controller.dto;

import com.example.lettucetest.domain.redis.RedisCrud;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class RedisCrudResponseDto {

    private Long id;
    private String description;
    private LocalDateTime updatedAt;

    public RedisCrudResponseDto(RedisCrud redisHash) {
        this.id = redisHash.getId();
        this.description = redisHash.getDescription();
        this.updatedAt = redisHash.getUpdatedAt();
    }
}

7) RedisController.java

package com.example.lettucetest.controller;

import com.example.lettucetest.controller.dto.RedisCrudResponseDto;
import com.example.lettucetest.controller.dto.RedisCrudSaveRequestDto;
import com.example.lettucetest.service.redis.RedisCrudService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.Arrays;
import java.util.Set;

@Slf4j
@RequiredArgsConstructor
@RestController
public class RedisController {

    private final RedisCrudService redisCrudService;
    private final StringRedisTemplate redisTemplate;

    @GetMapping("/")
    public String ok() {
        return "ok";
    }

    @GetMapping("/keys")
    public String keys() {
        Set<String> keys = redisTemplate.opsForSet().members("*");
        assert keys != null;
        return Arrays.toString(keys.toArray());
    }

    @PostMapping("/save")
    public Long save(@RequestBody RedisCrudSaveRequestDto requestDto) {
        log.info(">>>>>>>>>>>>>>> [save] redisCrud={}", requestDto);
        return redisCrudService.save(requestDto);
    }

    @GetMapping("/get/{id}")
    public RedisCrudResponseDto get(@PathVariable Long id) {
        return redisCrudService.get(id);
    }
}

8) 테스트

RedisCrudRepository와 RedisController Unit Test를 진행한다.

package com.example.lettucetest.domain.redis;

import org.junit.After;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisCrudRepositoryTest {

    @Autowired
    private RedisCrudRepository redisCrudRepository;

    @After
    public void tearDown() throws Exception {
        redisCrudRepository.deleteAll();
    }

    @Test
    public void 기본_등록_조회() {
        // given
        Long id = 0L;
        String description = "description";
        LocalDateTime updatedAt = LocalDateTime.of(2020, 10, 16, 0, 0);

        RedisCrud redisCrudSave = RedisCrud.builder()
                .id(id)
                .description(description)
                .updatedAt(updatedAt)
                .build();

        // when
        redisCrudRepository.save(redisCrudSave);

        // then
        RedisCrud redisCrudFind = redisCrudRepository.findById(id).get();
        assertThat(redisCrudFind.getDescription()).isEqualTo("description");
        assertThat(redisCrudFind.getUpdatedAt()).isEqualTo(updatedAt);
    }

    @Test
    public void 기본_등록_수정() {
        // given
        Long id = 0L;
        String description = "description";
        LocalDateTime updatedAt = LocalDateTime.of(2020, 10, 16, 0, 0);

        RedisCrud redisCrudSave = RedisCrud.builder()
                .id(id)
                .description(description)
                .updatedAt(updatedAt)
                .build();

        redisCrudRepository.save(redisCrudSave);

        // when
        RedisCrud redisCrudUpdate = redisCrudRepository.findById(id).get();
        redisCrudUpdate.update("updated description", LocalDateTime.of(2020,10, 17, 0, 0));
        redisCrudRepository.save(redisCrudUpdate);

        // then
        RedisCrud redisCrudFind = redisCrudRepository.findById(id).get();
        assertThat(redisCrudFind.getDescription()).isEqualTo("updated description");
    }
}
package com.example.lettucetest.controller;

import com.example.lettucetest.controller.dto.RedisCrudSaveRequestDto;
import com.example.lettucetest.domain.redis.RedisCrudRepository;
import org.junit.After;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class RedisControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private RedisCrudRepository redisCrudRepository;

    @After
    public void tearDown() throws Exception {
        redisCrudRepository.deleteAll();
    }

    @Test
    public void 기본() {
        // given
        String url = "http://localhost:" + port;

        // when
        ResponseEntity<String> responseEntity = restTemplate.getForEntity(url, String.class);

        // then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isEqualTo("ok");
    }

    @Test
    public void 기본_등록_조회() {
        // given
        RedisCrudSaveRequestDto requestDto = RedisCrudSaveRequestDto.builder()
                .id(1L)
                .description("description")
                .updatedAt(LocalDateTime.now())
                .build();

        String url = "http://localhost:" + port + "/save";

        // when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        // then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);
    }

}

9) 구현 화면

우측 Gradle 탭에서 전체 Test를 진행했을 때 문제 없이 동작하는 것을 확인할 수 있다.

테스트 확인 구현 화면

실제로 Redis 서버와 통신을 하면서 저장과 조회가 정상적으로 동작하는 것을 확인할 수 있다.

Redis 서버 통신 구현 화면


[참고] sabarada.tistory.com/105

[참고] sabarada.tistory.com/106

[참고] jojoldu.tistory.com/297

[참고] jojoldu.tistory.com/418

[참고] github.com/jojoldu/spring-boot-redis-tip

[참고] github.com/ozofweird/SpringBoot_Redis_Lettuce

728x90