본문 바로가기
BE/Spring

Springboot에서 Redis Cache 적용하기

by cjsrhd94 2022. 6. 27.

(2022.12.05 최종 수정)

개인 프로젝트를 진행하던 중 페이지 별로 반복 호출되는 API들이 있었다.

대표적으로 게시판 별 추천글 목록, 전체 인기글 목록 등이 있었다.

이러한 반복적으로 호출되는 쿼리를 줄여 부하 분산을 하기 위해 프로젝트에 Spring Cache를 도입하였다.

 

왜 Redis를 선택했을까?

캐시는 크게 로컬 캐시글로벌 캐시 두 가지가 있다.

로컬 캐시는 각각의 WAS 내부 저장소에 캐시를 저장하는 방식이다. WAS의 리소스를 사용하기 때문에 속도는 빠르지만 Scale-Out 방식의 서버 확장시 서버간 데이터 공유가 안되어 일관성 문제가 발생할 수 있다는 문제가 있다.

글로벌 캐시는 별도의 캐시 서버를 두고 WAS에서 캐시서버를 참조하는 방식이다. 캐시 데이터를 얻을 때 마다 트래픽이 발생하기 때문에 로컬 캐시보다 상대적으로 속도는 느리지만, 서버간 데이터를 공유하기 용이하다.

현재 진행하는 개인 프로젝트는 글로벌 캐시를 활용하기로 했다. 그 이유는 향후 프로젝트를 Scale-Out 해볼 생각이 있었고, JWT RefreshToken을 도입할 생각이 있기 때문에, 장기적으로 글로벌 캐시를 사용하는 것이 더 좋은 방법이라고 생각했다.

 

Spring Cache

이해를 돕기 위해 예제 프로젝트를 작성하였다. JPA, H2 DB를 활용하였다.

https://github.com/cjsrhd94/redis-practice

 

application.yml

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/redis;
    username: sa
    password:

  redis:
    port: 6379
    host: localhost

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.H2Dialect

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type: trace

redis는 기본적으로 6379포트를 사용한다. host는 엔드 포인트로 로컬 환경에서는 localhost로 작성하면 된다.

 

RedisConfig

@EnableCaching // Spring 에서 Caching을 사용하겠다고 선언한다.
@Configuration // 해당 class는 configuration이다.
public class RedisConfig extends CachingConfigurerSupport {
	
    // yaml 파일에서 host 값을 가져온다.
    @Value("${spring.redis.host}")
    private String redisHost;

    // yaml 파일에서 port 값을 가져온다.
    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, redisPort));
    }

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
		
        // 캐시의 Key/Value를 직렬화-역직렬화 하는 Pair를 설정한다.
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new StringRedisSerializer()));

        // 캐시이름 별로 만료기간을 다르게 사용하기 위한 설정이다.
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        cacheConfigurations.put(CacheKey.USER, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.USER_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.POST, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.POST_EXPIRE_SEC)));

        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(configuration)
                .withInitialCacheConfigurations(cacheConfigurations)
                .build();
    }
}

RedisConfig에서는 yaml파일에서 작성한 정보를 토대로 WAS와 캐시 서버의 connection을 생성한다. 서버간 통신에 필요한 직렬화하는 클래스를 선택할 수 있으며, 캐시 이름 별로 만료기간을 다르게 설정할 수도 있다.

 

CacheKey

public class CacheKey {

    private CacheKey() {

    }

    public static final String USER = "user";
    public static final int USER_EXPIRE_SEC = 1800;

    public static final String POST = "post";
    public static final int POST_EXPIRE_SEC = 1800;
}

캐시 키는 캐시 간 구분을 위해 사용된다. 캐시 키는 위와 같이 만료시간과 함께 별도의 클래스로 관리한다.

 

UserService

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Transactional
    public Long join(UserJoinReqDto joinReqDto) {
        User user = userRepository.save(joinReqDto.toEntity());
        return user.getId();
    }

    @CachePut(value = CacheKey.USER, key = "#id")
    @Transactional
    public UserResDto update(Long id, UserUpdateReqDto updateReqDto) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다."));
        user.update(updateReqDto.getNickname(), updateReqDto.getPassword());
        return new UserResDto(user);
    }

    @CacheEvict(value = CacheKey.USER, key = "#id")
    @Transactional
    public Long delete(Long id) {
        userRepository.deleteById(id);
        return id;
    }

    @Cacheable(value = CacheKey.USER, key = "#id", unless = "#result == null")
    @Transactional(readOnly = true)
    public UserResDto getUser(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다."));
        return new UserResDto(user);
    }

    @Cacheable(value = CacheKey.USER, unless = "#result == null")
    @Transactional(readOnly = true)
    public List<UserResDto> getUserList() {
        List<User> users = userRepository.findAll();
        return users.stream()
                .map(UserResDto::new)
                .collect(Collectors.toList());
    }
}

UserService는 다음과 같이 작성하였다. 위에 등장하는 @Cacheable, @CachePut, @CacheEvict가 오늘 설명할 Spring Cache와 관련된 어노테이션이다.

 

@Cacheable

@Cacheable은 조회 메서드의 return 값을 캐시에 저장한다.

한번 캐시에 저장되면 해당 메서드를 실행하기 전 캐시 저장소를 먼저 조회한다. 캐시 데이터가 있다면 메서드를 실행하지 않고 캐시 데이터를 반환한다.

캐시가 적용 전 조회 요청
캐시 적용 후 조회 요청

@Cacheable 적용 후 조회 속도가 향상된 것을 확인 할 수 있다. 이후 캐시 만료기한 내에 지속적인 요청을 보내도 쿼리가 발생하지 않는 것으로 데이터가 캐시 서버에서 조회되고 있는 것을 알 수 있다.

redis 서버

redis 서버를 확인해 보면 데이터가 잘 등록된 것을 확인할 수 있다.

 

@CachePut

@CachePut은 value와 key에 해당하는 캐시를 수정한다.

메서드 실행후 캐시 서버를 조회하여 해당하는 캐시 데이터가 있다면 수정한다.

 

@CacheEvict

@CacheEvict는 value와 key에 해당하는 캐시를 제거한다.

메서드와 실행후 캐시 서버를 조회하여 해당하는 캐시 데이터가 있다면 삭제한다.

 

value는 어떤 캐시 정책을 쓸 것인가에 대한 값이고, key는 정책내에서 캐시를 구분해주는 키 값으로 SpEL문법으로 작성한다. unless = "#result ==null" 옵션은 return값이 null 일 때 캐싱 되지 않게 해준다.

@CachePut과 @CacheEvict는 사용시 주의가 필요하다. 모든 캐시 데이터가 DB 데이터와 동일하게 update 되거나 delete되지는 않기 때문에 데이터 불일치가 일어 날 수 있다. 그렇기 때문에 데이터의 update와 delete가 자주 발생하거나 데이터 일치가 중요한 비즈니스 로직은 캐시 대상에서 제외한다. 개인적으로는 캐시 유효기간을 짧게하고 @CachePut과 @CacheEvict의 사용을 지양하는 것이 좋은 방법이라 생각한다.

 

참조

https://spring.io/guides/gs/spring-data-reactive-redis/

https://docs.microsoft.com/en-us/windows/win32/fileio/local-caching

댓글