Spring boot에서 Redis로 캐싱하기 (w. JPA)

Kai·2024년 2월 19일
2

Redis

목록 보기
2/4

☕ 개요


이번 글에서는 Spring boot 서버에서 Redis를 사용하는 방법에 대해서 알아보도록 하겠다. 그저 Redis를 사용하는 것 뿐만 아니라 캐시 서버로써 Redis를 사용하는 방법까지도 알아보도록 하겠다.
이 글에서 활용한 코드들은 깃헙에 올려두었으니 혹시 필요하면 참고하길 바란다.


📒 설정


build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

가장 먼저, Redis 스타터 Library를 추가해준다.
여기에는 Spring-data-redis와 Redis클라이언트들(Jedis, Lettuce)이 포함되어 있다. Redis를 사용하기 위한 All-in-One 라이브러리라고 생각하면 된다.

설정 클래스

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class RedisConfig {

    @Bean
    LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }

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

}

Redis 클라이언트의 종류는 다양한데, 그 중에서 Jedis와 Lettuce가 많이 사용되는 것같다.
이 둘 중에서는 Lettuce가 후발주자로써 좀 더 좋은 평가가 있는 것 같아서, Lettuce를 사용해보도록 하겠다.


🔨 Redis 사용해보기


위 과정까지 완료가 되면, Redis를 사용할 최소한의 준비가 완료된 것이다.
이제 간단한 Redis Repository를 하나 만들어보고, 잘 동작하는지 확인해보도록 하겠다.

Entity 만들기

import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.redis.core.RedisHash;

@Getter
@Builder
@RedisHash(value = "sport", timeToLive = 300)
public class Sport {

    @Id
    public Long id;
    public String name;

}

먼저, 매우 간단한 형태의 Entity역할을 할 클래스를 하나 만들어보았다. '운동'을 나타내는 Entity이다.
RedisHash를 사용할 때, value는 Redis에 저장할 때 사용할 KeySpace이고, timeToLive는 얼마동안 Redis에서 값을 유지할 것인지에 대한 정의이다.

Repository 만들기

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SportRepository extends CrudRepository<Sport, Long> {}

역시나 매우 간단한 형태로 Repository를 하나 만들어주었다.

Test 코드 작성

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SportRepositoryTest {

    @Autowired
    SportRepository sportRepository;

    @Test
    @DisplayName("redisBasicTest")
    void redisBasicTest() {
        Sport soccer = Sport.builder().id(1L).name("Soccer").build();
        sportRepository.save(soccer);
        log.info("Sport 저장");

        Sport found = sportRepository.findById(1L).get();
        log.info("Soccer 찾기");

        assertEquals(found.getId(), 1L);
        assertEquals(found.getName(), "Soccer");

    }

}

데이터 하나를 저장하고, 저장한 값을 찾는 매우 간단한 테스트 코드이다.

위 코드를 실행하면 이렇게 정상적으로 코드가 통과되는 것을 확인할 수 있다.

Redis-cli로 데이터 확인하기

keys *
HGETALL sport:1
HGET sport:1 name

cli로 접속해서 확인해보니, Redis의 Hash자료구조로 위에서 저장한 값이 잘 저장된 것을 확인할 수 있다.


⚡ 캐시 서버 Redis


Redis를 사용하는 큰 이유중에 하나는 캐시 서버로써 활용하기 위함이고 그렇게 하기 위해서는 '어떤 API 요청에 대해서 캐시 서버(Redis)에 해당 값이 있는지 확인하고, 없다면 DB에서 값을 조회하고 그 값을 Redis에 추가하는 것'과 같은 과정이 코드레벨에서 구현되어야 한다.

캐시 매니저 Bean 등록

import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(1L));

        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(factory)
                .cacheDefaults(cacheConfig)
                .build();
    }

}

먼저, CacheMaganer의 구현체가 필요하니, RedisCacheManager로 Bean을 만들어주자.

그리고, 캐싱을 구현하기 위한 필수적으로 알아야하는 어노테이션들이 있으니, 이것들에 대해서 알아보자.

@EnableCaching

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@EnableCaching
@SpringBootApplication
public class SpringBootRedisApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringBootRedisApplication.class, args);
	}

}

Spring boot의 메인 클래스 또는 설정 클래스에에 붙여서 사용하면 되고, 캐싱 기능을 활성화하겠다는 의미를 갖고 있다.

이 어노테이션이 붙으면, 내부적으로는 캐싱과 관련된 기능들을 프록시 패턴을 활용해서 캐싱을 적용할 기능 또는 메서드에 붙여주게 된다.

@Cacheable

메서드에 붙여서 사용할 것을 권장하고, 캐시를 저장 또는 조회할 때 사용한다. 메서드에서 조회할 캐시 데이터가 캐시 서버에 있는지 확인하고, 있으면 반환하고 없으면 새로 저장한 후 반환하는 것이다.

@CachePut

메서드에 붙여서 사용할 것을 권장하고, 캐시를 저장 또는 수정하는 목적으로만 사용한다. 즉, 메서드에서 조회하고자하는 데이터는 DB에서 직접 꺼내서 전달하고, 그 결과를 캐시 서버에 반영하기만 하는 것이다.

@CacheEvict

캐시 제거를 위해서 사용한다. 역시 메서드에 붙여서 사용하는 것이 바람직하고, 삭제 기능을 갖고 있는 메서드에 붙여서 사용할 수 있다.


🔥 캐싱 기능 활용하기


이제 준비가 다 되었으니, 매우 기본적인 CRUD 기능을 만들어보도록 하자.

엔티디 생성

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Entity
public class Food {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter private String name;
    @Setter private Integer price;

    public static Food create(String name, Integer price) {
        Food food = new Food();
        food.setName(name);
        food.setPrice(price);
        return food;
    }

}

'음식'을 의미하는 엔티티이다.

Repository 생성

import org.springframework.data.jpa.repository.JpaRepository;

public interface FoodRepository extends JpaRepository<Food, Long> {}

별 다른 기능이 없는 JpaRepository를 상속받은 기본 Repository를 하나 만들어준다.

Service 생성

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class FoodService {

    private final FoodRepository foodRepository;

    public Food createFood(String name, Integer price) {
        return foodRepository.save(Food.create(name, price));
    }

    public Food updateFood(Long id, String name, Integer price) {
        Food food = getFood(id);
        food.setName(name);
        food.setPrice(price);
        return food;
    }

    public Long deleteFood(Long id) {
        foodRepository.deleteById(id);
        return id;
    }

    public List<Food> getAllFoods() {
        List<Food> result = new ArrayList<>();
        Iterable<Food> all = foodRepository.findAll();
        all.forEach(result::add);
        return result;
    }

    public Food getFood(Long id) {
        return foodRepository.findById(id).orElse(null);
    }

}

아주 아주 아주 간단한 Food를 CRUD할 수 있는 Service이다.

캐싱 어노테이션 붙이기

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class FoodService {

    private final FoodRepository foodRepository;

    @CachePut(cacheNames = "food", key = "#result.id")
    public Food createFood(String name, Integer price) {
        return foodRepository.save(Food.create(name, price));
    }

    @CachePut(cacheNames = "food", key = "#id")
    public Food updateFood(Long id, String name, Integer price) {
        Food food = getFood(id);
        food.setName(name);
        food.setPrice(price);
        return food;
    }

    @CacheEvict(cacheNames = "food", key = "#id")
    public Long deleteFood(Long id) {
        foodRepository.deleteById(id);
        return id;
    }

    public List<Food> getAllFoods() {
        List<Food> result = new ArrayList<>();
        Iterable<Food> all = foodRepository.findAll();
        all.forEach(result::add);
        return result;
    }

    @Cacheable(cacheNames = "food", key = "#id")
    public Food getFood(Long id) {
        return foodRepository.findById(id).orElse(null);
    }

}

생성, 수정 기능에는 @CachePut을 붙이고, 삭제 기능에는 @CacheEvict를 붙이고, 단 건 조회 기능에는 @Cacheable을 붙였다.
각각은 더 디테일한 옵션들을 매개변수로 넘길 수 있는데, 나는 가장 간단한 형태로 구성해보았다.

테스트 코드 작성

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FoodServiceTest {

    @Autowired
    public FoodService foodService;

    @Test
    @DisplayName("cachedFoodTest")
    public void cachedFoodTest() throws Exception {
        // 생성
        Food hamburger = foodService.createFood("햄버거", 10000);

        // 조회
        Long foodId = hamburger.getId();
        Food found = foodService.getFood(foodId);
        Assertions.assertEquals(foodId, found.getId());

        // 수정
        Food updated = foodService.updateFood(foodId, "햄버거", 11000);
        Assertions.assertEquals(11000, updated.getPrice());

        // 삭제
        foodService.deleteFood(foodId);
        Food food = foodService.getFood(foodId);
        Assertions.assertNull(food);
    }

}

여기서 Assertions 코드가 들어가 있는 부분에 Breakpoint를 걸고 테스트 코드를 실행하고, 각 Breakpoint마다 redis-cli로 값을 확인해보자.

위에서 CacheManager Bean을 등록할 때, 기본적으로 String으로 데이터가 생성되도록 했기때문에 MGET명령어를 통해서 데이터를 조회해보았다. 각 시점에 예상한 데이터들이 Redis에서 조회되는 것을 확인할 수 있다. 😊


☕ 마무리


이번 글에서는 Spring boot서버에 Redis를 연동하는 방법과 캐싱 어노테이션과 함께 실질적인 캐싱서버로써의 Redis의 동작을 알아보았다.
다음 글에서는 Redis의 또 다른 강력한 기능인 Pub/Sub 또는 메세지 큐 기능에 대해서 알아보도록 하겠다!

그럼 이만


🙏 참고


0개의 댓글