[SpringBoot] Spring Data Redis 를 사용해보자

zl존석동

·

2022. 7. 13. 12:42

 

SpringBoot 에서 Redis를 사용해보자


 

 

 

Redis 란?

 

Key-Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈소스 기반의 NoSQL

 

인-메모리 에서 데이터를 처리하기 때문에 매우 빠르고 가볍다.

 

 

 

언제 왜 쓸까?

 

캐싱

 

관계형 DB로의 I/O 가 잦을 때 발생하는 부하를 캐싱 처리하여 해결할 수 있다.

 

 API 캐싱으로 반복적인 응답에 대한 불필요한 반복적인 통신을 줄일 수 있다.

 

 

TTL 데이터 관리

 

사용자 인증 정보 같은 만료가 있는 데이터들을 관리하는 저장소로 활용할 수 있다.

 

Key-Value 구조이기 때문에 대량의 데이터나, 잦은 삽입과 조회 발생 상황에서도 RDB에 비해 아주 빠르다. 

 

 

요약하면 Key-Value 형식의 단순하면서도 활용이 잦은 데이터 처리나 캐싱에 사용된다고 할 수 있을 것 같다.

 

이 글에서는 캐싱이 아니라 springboot 에서 Redis를 사용하는 방법 자체에 중점을 두려고 한다. 

 

 

Spring Data Redis란? 

 

Spring에서 Redis와 통신하기 위한 라이브러리이다.

 

LettuceJedis 라는 프레임워크가 있는데 기본으로는 Lettuce로 설정되어있다.

 

예시에서는 기본 설정인 Lettuce를 사용하는데 이유는 깊게 설명하면 너무 길 것 같고

 

비동기 이벤트 기반 처리(빠름)에 멀티 스레드 환경에 더 적합하다고 하기 때문이라고 생각하였다.

 

 

공식문서에 따르면 다음과 같은 데이터 타입들을 지원한다고 한다.

Key, String, List, Set, Sorted Set, Hash, Server, Stream, Scripting, Geo, HyperLogLog

 

 

사용해보기

 

 

개발 환경

 

OS: window 10
언어: java8
프레임워크: SpringBoot 2.7.1
빌드: gradle 7.4.1
테스트: junit5

 

 

의존성 등록하기

 

implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.7.1'
implementation('it.ozimov:embedded-redis:0.7.3') { exclude group: "org.slf4j", module: "slf4j-simple" }

 

스프링과 연동을 위해 redis가 필요한데 도커로 따로 띄우거나 클라우드 서비스 써도 되지만 런타임 시 알아서 띄워져서 바로 사용할 수 있게 하기 위해 embedded-redis 를 사용했다.

 

여기선 하지 않지만 따로 로컬 레디스를 띄워서 할 경우 CI 시 redis 관련 테스트를 할 수 가 없는데 embedded-redis를 사용하면 가능하다.

 

해당 의존성과 기존 slf4j와 충돌이 나기 때문에 제외 설정도 해주었다.

 

 

 

Redis 설정 추가하기

 

properties 또는 yml 에 redis 정보 추가

spring:
  redis:
    host: localhost
    port: 6379

 

 

Redis 연결 설정 클래스 추가

@Configuration
public class RedisConfiguration {

    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

 

 

Embedded Redis 사용 설정 추가

 

나중에 프로덕션 환경이라 치고 ElastiCache 연동을 해볼 것이라 로컬 환경에서만 Embedded Redis 가 사용되도록 설정했다.

 

@Slf4j
@Profile("local")
@Configuration
public class EmbeddedRedisConfiguration {

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

    private RedisServer redisServer;

    @PostConstruct
    public void redisServer() {
        redisServer = RedisServer.builder()
                .port(redisPort)
                .setting("maxmemory 128M")
                .build();
        try {
            redisServer.start();
        } catch (Exception e) {
            log.error("", e);
        }
    }

    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
}

 

 

설정 후 redis-cli 를 실행시켜서 접속이 잘 되면 redis 사용 준비에 성공한 것이다

 

 

스프링 상에서도 값을 넣고 조회해보며 연결이 되어 사용할 수 있는가 간단하게 테스트 해보았다.

 

@SpringBootTest
class RedisConnectionTest {
    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String KEY = "key";

    @Test
    void setStringOpsShouldSuccess() {
        redisTemplate.opsForValue().set(KEY, "value");
        String value = redisTemplate.opsForValue().get(KEY);
        Assertions.assertThat(value).isEqualTo("value");
    }

}

 

 

Timeout을 설정한 데이터일 때 만료가 잘 되는지도 테스트해보았다.

 

junit 테스트에서 시간 지연 사용을 위해 awaitility 의존성을 추가로 등록하고 테스트해보자

 

testImplementation group: 'org.awaitility', name: 'awaitility', version: '4.1.1'

 

@Test
@DisplayName("만료된 데이터를 조회할 경우 null 을 반환한다.")
void findExpiredStringValueByKeyShouldReturnNull() {
    ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
    valueOperations.set(KEY2, "value", Duration.ofMillis(500));

    assertThat(valueOperations.get(KEY2)).isEqualTo("value");
    await().pollDelay(Duration.ofMillis(1000))
            .untilAsserted(() -> {
                assertThat(valueOperations.get(KEY2)).isNull();
            });
}

 

 

RedisTemplate 활용

 

자바에서 Redis 사용 시 기본적으로 RedisTemplate 객체를 이용해 작업을 한다.

 

StringRedisTemplateRedisTemplate 의 자식 클래스로 key-value 데이터에 대한 직렬화, 역직렬화를 string 으로 해준다.

 

 

설명을 보니 redis 사용 시 대부분 문자열 기반으로 사용하기 때문에 존재하는 것 같다.

 

그냥 RedisTemplate 를 사용할 경우 기본 Serializer 는 JdkSerializationRedisSerializer 로 자바 클래스나 필드 정보가 부가적으로 redis 에 저장된다. 

공식 문서에 따르면 기본 자바 직렬화로 포함된 불필요한 바이트 코드 정보들로 인해 역직렬화 시 애플리케이션에서 원격 코드 실행 등으로 악용될 수 있으니  JSON 같은 일반적인 메시지 포맷을 사용하라고 경고하고 있다.

 

 

RedisTemplate 사용하기

 

위의 테스트 예시를 보면 redisTemplateopsForValue() 라는 메소드로 ValueOperations 이라는 객체를 초기화 해 삽입과 조회를 했었는데 

 

 key-value 모두 문자열인 형태일 때의 사용 예시이고 Set, SortedSet, List, Hash 등 다양한 데이터 타입을 제공하여 알맞게 사용하면 된다.

 

아래 이미지를 보면 Key Bound Operations 라는 인터페이스도 있는데 사용해야 하는 데이터의 특정 키가 변경되지 않는 상황. 즉 특정 키에 대한 작업이 반복적으로 필요할 때 사용하라고 제공되는 인터페이스이다.

 

공식문서 참조

 

 

 

객체를 value로 다루기

 

RedisTemplate를 사용해 json 데이터 형태로 통신하기 위해 JSON 직렬화 설정을 위해 Jackson2JsonRedisSerializer  나  GenericJackson2JsonRedisSerializer 를 제공해주기 때문에 사용하면 되지만

 

StringRedisTemplate를 그대로 사용하면서 직접 객체 데이터 json 파싱을 하는 방법으로 구현했던 기억이 나 기록하였다.

 

 

RedisProvider 클래스 추가

 

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisProvider {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public <T> T getData(String key, Class<T> classType) {
        try {
            ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
            return getObjectFromValue(valueOperations.get(key), classType);
        } catch (JsonProcessingException e) {
            log.error("", e);
            return null;
        }
    }

    public <T> void setDataWithExpiration(String key, T data, long expireMills) {
        try {
            ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
            Duration expireDuration = Duration.ofMillis(expireMills);
            valueOperations.set(key, objectMapper.writeValueAsString(data), expireDuration);
        } catch (JsonProcessingException e) {
            log.error("", e);
        }
    }

    public void deleteData(String key) {
        redisTemplate.delete(key);
    }

    private <T> T getObjectFromValue(String values, Class<T> classType) throws JsonProcessingException {
        if (!StringUtils.hasText(values)) {
            return null;
        }
        return objectMapper.readValue(values, classType);
    }

}

 

사용하는 입장에서 조회 시 클래스 타입 정보가 추가로 필요한 것 빼고는 문자열 key-value 구조 때처럼 사용할 수 있게끔 구현해보았다.

 

아마 스프링에서 사용한다면 여러 DTO 객체를 value 로 redis에 저장하게 될 텐데 여러 타입의 객체를 한 번에 활용할 수 있게 하기 위해 Class 객체를 활용했다.

 

 

RedisProvider 테스트 코드

 

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RedisProviderTest {

    @Autowired
    private RedisProvider redisProvider;

    private final String objectKey = "key";
    private final SampleObj objectValue = new SampleObj("choiburngae", 25);

    @BeforeAll
    void setup() {
        redisProvider.setDataWithExpiration(objectKey, objectValue, 60000);
    }

    @Test
    @DisplayName("없는 객체 데이터에 대한 조회 요청에 대해 null 을 반환한다.")
    void findObjectValueByKeyAndTypeNotExistsShouldReturnNull() {
        SampleObj result = redisProvider.getData("hello", SampleObj.class);
        assertThat(result).isNull();
    }

    @Test
    @DisplayName("저장된 객체 key-value 데이터를 조회할 수 있다.")
    void findObjectValueByKeyAndTypeShouldReturnObjectResult() {
        SampleObj result = redisProvider.getData(objectKey, SampleObj.class);
        assertThat(result)
                .isNotNull()
                .usingRecursiveComparison()
                .isEqualTo(objectValue);
    }

    @Test
    @DisplayName("객체 key로 조회 시 부적절한 객체 타입일 경우 null을 반환한다")
    void findObjectValueByKeyAndWrongTypeShouldReturnNull() {
        Object result = redisProvider.getData(objectKey, SampleWrongObj.class);
        assertThat(result).isNull();
    }

    @AfterAll
    void destroy() {
        redisProvider.deleteData(objectKey);
    }

    static class SampleObj {

        private final String name;
        private final int age;

        public SampleObj(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public int getAge() {
            return age;
        }

    }

    static class SampleWrongObj {

        private final String name;

        public SampleWrongObj(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }

    }

}

 

 

RedisRepository 활용

 

Spring Data JPA 처럼 Spring Data Redis를 활용할 수 있다.

 

Redis Repository 를 활용하여 도메인 객체를 쉽게 Redis Hash 구조로 변환하고 커스텀 매핑 전략이나 보조 인덱스를 설정하여 활용할 수 있다.

 

 

Redis Hash 로 사용할 객체 만들기 

 

@RedisHash("person")

 

도메인 엔티티 클래스에 다음과 같은 어노테이션을 붙여준다.

 

클래스에 존재하는 @Id(org.springframework.data.annotation.Id) 멤버와 함께 해시 데이터를 위한 실제 Key를 구성한다.

 

@RedisHash("people")
public class Person {

  @Id String id;
  String name;
  int age;
}

 

id라는 이름을 가진 변수가 식별자로 인식될 수 있지만 @Id 어노테이션을 우선적으로 인식한다.

 

 

Repository 인터페이스 추가하기

 

JPA 사용했던 것 처럼 Spring Data의 CrudRepository를 상속받는 인터페이스를 선언해주어 엔티티 객체를 영속화 할 준비를 해준다.

 

CrudRepository는 JpaRepository의 상위 상위 인터페이스이다.

 

public interface PersonRepository extends CrudRepository<Person, String> {

}

 

 

Redis Repository 사용 활성화 설정하기

 

@EnableRedisRepositories

 

해당 어노테이션을 Redis 설정 클래스에 붙여준다.

 

사용 비즈니스 로직과 api 를 만들기 전

redis repository 를 활용한 실제 삽입과 조회가 잘 되나 정도만 간단하게 테스트를 해보았다.

 

JPA에서 @DataJpaTest 어노테이션을 통해 리포지토리 레이어 테스트를 했던 것과 마찬가지로

 

테스트 시 @DataRedisTest 를 붙여주고 의존성을 주입하면 된다.

 

같은 Spring Data 시리즈로 규격화 되어있다보니 사용법이 같아 편하다.

 

@DataRedisTest
public class PersonRepositoryTest {

    @Autowired
    private PersonRepository personRepository;
    private static RedisServer redisServer;

    @BeforeAll
    static void setup() {
        redisServer = RedisServer.builder()
                .port(6378)
                .setting("maxmemory 128M")
                .build();
        try {
            System.out.println("START");
            redisServer.start();
        } catch (Exception e) {

        }
    }

    @Nested
    @DisplayName("Person 삽입 테스트")
    class PersonSave {
        @Test
        @DisplayName("Person 객체를 Value로 하여 정상적으로 삽입된다.")
        void postPersonShouldSuccessSave() {
            Person person = personRepository.save(
                    Person.builder()
                            .name("kim")
                            .age(444)
                            .build());
            assertThat(person)
                    .isNotNull()
                    .isInstanceOf(Person.class);
            assertThat(person.getId()).isNotNull();
        }
    }

    @Nested
    @DisplayName("Person 조회 테스트")
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    class PersonSelection {
        private final Person person = new Person(null, "kim", 25);

        @BeforeAll
        void setup() {
            personRepository.save(person);
            assertThat(person).isNotNull();
            assertThat(person.getId()).isNotNull();
        }

        @Test
        @DisplayName("Redis 에 존재하는 key로 Person을 조회하여 객체로 얻어올 수 있다.")
        void findPersonByIdShouldGetPersonDomainEntity() {
            Person fundPerson = personRepository.findById(person.getId())
                    .orElse(null);
            assertThat(fundPerson).isNotNull()
                    .isInstanceOf(Person.class)
                    .usingRecursiveComparison()
                    .isEqualTo(person);
        }

        @Test
        @DisplayName("Redis 에 존재하지 않는 key로 Person을 조회해 예외 발생한다.")
        void findPersonByIdNotExistsShouldThrowsException() {
            assertThat(personRepository.findById("hello")).isNotPresent();
        }
    }

    @AfterAll
    static void destroy() {
        if (redisServer != null) {
            redisServer.stop();
            System.out.println("STOP");
        }
    }

}

 

 

 

사용 api 만들기

 

활용할 수 있도록 서비스 로직을 만들어준다. JPA와 사용법이 같다.

 

간단하게 삽입과 조회할 수 있는 로직과 컨트롤러만 만들어보았다.

 

 PersonService

@Service
@RequiredArgsConstructor
public class PersonService {

    private final PersonRepository personRepository;

    public String createPerson(PersonValueDto personDto) {
        Person person = personRepository.save(
                Person.builder()
                        .name(personDto.getName())
                        .age(personDto.getAge())
                        .build());
        return person.getId();
    }

    public PersonValueDto getPersonById(String id) {
        Person person = personRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Not Exists!"));
        return new PersonValueDto(person.getName(), person.getAge());
    }

}

 

 

PersonController

@RestController
@RequiredArgsConstructor
public class PersonController {

    private final PersonService personService;

    @GetMapping("/api/person/{id}")
    public ResponseEntity<PersonValueDto> getPerson(@PathVariable String id) {
        return ResponseEntity.ok(personService.getPersonById(id));
    }

    @PostMapping("/api/person")
    public ResponseEntity<String> postPerson(@RequestBody PersonValueDto personValueDto) {
        return ResponseEntity.ok(personService.createPerson(personValueDto));
    }

}

 

 

삽입 api

 

조회 api

 

Redis cli를 통해 실제 Redis에는 어떻게 저장되었는지 확인해보자

 

"person"person 객체(value) 인 모든 key를 관리하는 key로 set 자료구조로 저장된다.

"person:XXX" 의 형태로 삽입한 데이터의 key 가 관리되며 hash 자료구조로 저장된다.

 

 

삽입했던 특정 person 객체에 대해 key 와 value 를 조회해보자

 

 

 

 

다음 글에서는 Redis를 활용해 API 캐싱처리 하는 것을 공부하고 기록해보려고 한다!

 

 

 

 

Ref

 

 

Spring Data Redis

Some commands (such as SINTER and SUNION) can only be processed on the server side when all involved keys map to the same slot. Otherwise, computation has to be done on client side. Therefore, it is useful to pin keyspaces to a single slot, which lets make

docs.spring.io

 

[Java + Redis] Spring Data Redis로 Redis와 연동하기 - RedisTemplate 편

[Redis] 캐시(Cache)와 Redis [Redis] Redis의 기본 명령어 [Java + Redis] Spring Data Redis로 Redis와 연동하기 - RedisTemplate 편 [Java + Redis] Spring Data Redis로 Redis와 연동하기 - RedisRepository..

sabarada.tistory.com