개발놀이터

Redis를 이용해 캐싱 구현하기 (with Spring) 본문

CS 지식/데이터베이스

Redis를 이용해 캐싱 구현하기 (with Spring)

마늘냄새폴폴 2023. 5. 3. 02:14

저번 포스팅에선 Redis의 기본적인 개념에 대해서 알아봤습니다. Redis가 어떻게 NoSQL중에서 가장 빠른 성능을 보여줄 수 있었는지, 자료구조는 어떤 것을 지원하는지, 사용처는 어떤게 있는지, 배포방법까지 알아봤죠. 자세한 내용은 아래를 참고해주세요!

https://coding-review.tistory.com/364

 

Redis의 기초적인 개념

다음 프로젝트를 기획하면서 Redis를 사용해봐야겠다는 생각을 하게 되었습니다. 기존 이론적으로 알고 있었던 내용으로는 Redis가 캐싱이나 세션 데이터 저장소로서 사용된다는 것은 알고 있었

coding-review.tistory.com

 

이번 포스팅에선 Redis를 이용해 캐싱을 구현해보도록 하겠습니다. 

 

우선 결론부터 말씀드리자면 페이지 전체를 캐싱할 수는 없습니다. 페이지에 필요한 데이터들만 캐싱이 가능한 것으로 추정되구요. 

 

캐싱을 구현하는건 생각보다 매우 간단합니다. 하나하나 천천히 한번 알아보도록 하겠습니다. 

 

우선 Redis를 시작해야겠죠. Redis를 설치하는 방법은 생략하도록 하겠습니다. 또한, Redis의 GUI 툴은 Medis를 사용했습니다. 

 

Redis 시작하기

 

1. 의존관계를 설정한다

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

 

2. application.yml (or properties) 에 설정정보를 기입한다.

spring:
  data:
    redis:
      host: localhost
      port: 6379

 

3. Redis를 사용하기위한 Bean 설정정보 등록하기

Redis를 사용하는 방법에는 두가지가 있습니다. RedisTemplate를 사용하는 방법과 Spring Data Redis를 사용하는 방법이죠. 저는 Spring Data Redis를 사용하도록 하겠습니다. 

 

RedisTemplate을 사용하는 방법도 어렵지 않으니까요 구글링해보시면 쉽게 접근하실 수 있으실겁니다.

 

참고로 RedisTemplate과 Spring Data Redis를 사용하는 것의 차이는 조금 더 복잡하거나 세밀한 설정을 원한다면 RedisTemplate을 사용하시는걸 추천드리구요, 간단한 데이터 조작만 하실거라면 Spring Data Redis를 사용하시는 것을 추천드리도록 하겠습니다. 

 

3-1. Redis Config 만들기

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
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.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;

import java.time.Duration;

@EnableCaching
@Configuration
public class CacheConfig {

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

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

    @SuppressWarnings("deprecation")
    @Bean
    public CacheManager cacheManager() {
        RedisCacheManager.RedisCacheManagerBuilder builder =
                RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration =
                RedisCacheConfiguration.defaultCacheConfig()
                        .serializeValuesWith(RedisSerializationContext
                                .SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
                        .prefixCacheNameWith("Test")
                        .entryTtl(Duration.ofMinutes(30));
        builder.cacheDefaults(configuration);
        return builder.build();
    }

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

cacheManager를 빈으로 만들어 후에 스프링에게 해당 캐시매니저를 쓸거다 라고 지시해주기 위해 만드는건데요. 

 

여기에는 어떤 Redis 구현체를 사용할건지, 직렬화 방식은 어떤 구현체를 사용할건지, 캐시의 이름은 뭔지, 얼마나 오래동안 캐싱할건지를 설정합니다. 

 

참고로 Redis 구현체는 Jedis와 Lettuce가 있는데 기존에는 Jedis를 많이 사용했지만 요즘은 Jedis와 비교해서 Lettuce의 강력한 기능때문에 Lettuce를 사용한다고 합니다. 

 

Lettuce는 비동기 요청을 처리하기 때문에 성능상 이점이 있고 Lettuce는 Jedis와 다르게 별도의 풀을 생성하지 않아도 되기 때문에 개발이 단순하다는 장점도 있죠. 또한 Jedis는 스레드세이프 하지 않아서 Spring boot 2.0부터는 deprecated 되었습니다. 

 

또한, Redis는 캐싱을 진행할 때 어떤 방식으로 직렬화를 진행해야하는지 정해주지않으면 오류를 뱉습니다. 우리는 Json으로 직렬화를 할 것이기 떄문에 GenericJackson2JsonRedisSerializer 구현체를 사용할 것입니다. 

 

 

4. Redis 객체 생성해주기

Redis 객체를 만들어주면 해당 포맷으로 Redis에 저장됩니다. 마치 DTO라고 생각하시면 편합니다.

 

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


@Getter
@RedisHash(value = "cache", timeToLive = 300)
@NoArgsConstructor
public class CacheData {

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

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

클래스 레벨에 @RedisHash를 적어야 해당 클래스를 Redis에 정해진 포맷으로 저장합니다. value 속성으로는 Redis에 저장되는 이름을, 흔히 TTL이라고 부르는 timeToLive는 데이터베이스에 얼만큼의 시간동안 저장될 것인지를 정하는 설정입니다. 

 

또한, 주의해야하는 점으로는 @Id인데요. JPA에서 사용하는 패키지가 아닌 org.springframework.data.annotation을 사용해야합니다. 

 

@Id로 설정된 값은 생성자를통해 원하는 값으로 저장할 수 있지만 대부분 null 값으로 냅둬서 저절로 생성되는 랜덤값을 사용한다고 하더라구요. 

 

추가적으로 html 폼에서 받아올 데이터를 바인딩할 DTO도 만들어보겠습니다. 

import lombok.Data;

@Data
public class ResponseDto {

    private String name;
    private int age;

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

 

5. Redis Repository 생성하기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;
import org.springframework.data.repository.CrudRepository;

public interface RedisCacheRepository extends CrudRepository<CacheData, String> {

}

참고로 Spring Data Redis는 JPA처럼 RedisRepository 구현체가 없습니다. 따라서 CrudRepository를 사용해야 하는데요. CrudRepository는 제공하는 기능이 JPA만큼 강력한 기능이 없습니다. 

 

때문에 CrudRepository가 제공하는 메서드말고 다른 메서드를 만들기 위해서는 커스텀 인터페이스를 만들어서 사용해야 합니다. 

 

또한, JPA처럼 쿼리메서드가 작동하지 않는것으로 확인됐습니다. 뭣모르고 findByName 같은거 썼다가 오류를 마구 뱉길래 알아보니 따로 쿼리메서드는 지원하지 않는 것으로 결론내렸습니다. 

 

따라서 findByName같은 메서드를 만드시고 싶다면 커스텀 인터페이스를 만드시고 RedisTemplate을 이용해 구현하시면 됩니다. 이번 포스팅에서는 결이 맞지 않기 때문에 커스텀 인터페이스를 만드는 과정은 생략하도록 하겠습니다. 

 

6. Redis Service 생성하기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;
import com.project.configureSessionWithRedis.configureCacheWithRedis.repository.RedisCacheRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service
@RequiredArgsConstructor
@Slf4j
public class CacheService {

    private final RedisCacheRepository cacheRepository;

    public ArrayList<CacheData> findAll() {
        log.info("findAll Service 호출");
        Iterable<CacheData> findAll = cacheRepository.findAll();
        ArrayList<CacheData> list = new ArrayList<>();
        findAll.forEach(x -> {
            if (x != null) {
                list.add(x);
            }
        });
        return list;
    }

    public CacheData save(ResponseDto dto) {
        return cacheRepository.save(new CacheData(dto.getName(), dto.getAge());
    }
}

캐싱이 제대로 작동하는지 확인하기위해 로그를 위한 Slf4j를 선언했구요. Repository에서 findAll을 통해 모든 데이터를 캐싱할 예정입니다. 

 

참고로 findAll을 선언하면 기존 JPA에선 ArrayList로 반환되는 것과 다르게 Iterable로 반환됩니다. Iterable을 List에 넣을 때는 위와같이 forEach를 사용해 람다를 가지고 넣어야합니다. 

 

저는 캐싱에 들어오는 값을 null safety를 위해 null 체크를 한번 했습니다. 

 

그리고 save 까지 만들었습니다. 

 

7. Controller를 통해 캐싱데이터 만들기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;
import com.project.configureSessionWithRedis.configureCacheWithRedis.service.CacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@RequiredArgsConstructor
public class CacheSaveController {

    private final CacheService cacheService;

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

    @PostMapping("/save_cache")
    public String saveCachePost(@ModelAttribute ResponseDto dto) {
        cacheService.save(dto);

        return "complete";
    }
}

저는 html 폼으로 캐싱할 데이터를 받았구요. /save_cache 로 들어오는 값을 save 했습니다. 

 

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;
import com.project.configureSessionWithRedis.configureCacheWithRedis.service.CacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;

@RestController
@RequiredArgsConstructor
@Slf4j
public class CacheController {

    private final CacheService cacheService;

    @GetMapping("/getData")
    @Cacheable(value = "CacheData", cacheManager = "cacheManager")
    public ArrayList<CacheData> getData() {
        return cacheService.findAll();
    }


}

클래스를 하나 더만들어서 RestController로 만들었구요. 이 컨트롤러에선 findAll을 통해 데이터가 캐싱되는지 확인해볼겁니다. 

 

중요한것은 메서드에 @Cacheable 어노테이션을 반드시 달아주셔야 한다는 것입니다. value 속성에는 우리가 방금 만들었던 Redis 객체 (@RedisHash 어노테이션 달았던 그것) 를 적어주고 cacheManager에는 config 클래스에 빈으로 등록했던 빈 이름을 적어주시면 됩니다. 

 

자 모든 준비가 끝났습니다. 한번 캐싱이 잘 되는지 확인해볼까요? 

 

html 폼으로 데이터를 입력하고 데이터베이스를 보겠습니다. 

 

두개의 값을 저장했고 /getData로 GET 요청을 하니 캐시가 잘 생성된 것을 볼 수 있습니다. 

 

이후 새로고침을 마구 눌러도 로그가 더이상 생성되지 않습니다. 이는 Service 클래스를 통과하지 않고 즉각적으로 Redis에서 캐싱하고 있다는 얘기이죠. 

 

 

 

 

마치며

여기까지 Redis로 캐싱을 진행해봤습니다. 사이트에서 값이 자주 변하지 않는 데이터를 캐싱하면 애플리케이션의 전체적인 성능이 올라갈 것으로 예측됩니다. 

 

긴글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~