[Spring] 캐싱 적용하기(Feat. Redis)

sinryuji·2025년 3월 6일
post-thumbnail

Spring에 Redis를 이용해서 캐싱을 적용하는 방법을 알아봅시다. Redis 의존성이나 연결 설정은 모두 되었다는 가정하에 진행하겠습니다.

@EnableCaching

Spring에서 캐싱과 관련된 기능은 Spring 3.1 부터 기본적으로 내장이 되어 있으므로 별도의 라이브러리 의존성을 추가할 필요는 없습니다. 다만 스케쥴링 기능 등과 마찬가지로 특정 어노테이션으로 해당 기능을 활성화 시켜주어야 합니다. 그 어노테이션이 @EnableCaching입니다.

@Configuration이 붙은 설정 클래스에 다음과 같이 붙여주면 됩니다.

@Configuration
@EnableCaching
public class CacheConfig {}

추가로 해 주어야 할 게 CacheManager를 Bean으로 등록해주어야 합니다. CacheManager 캐싱과 관련된 전반적인 설정을 하는 클래스로 이 클래스가 Bean으로 등록이 되어있어야지 이어서 살펴볼 캐싱 기능들이 정상적으로 동작합니다! 주의해야 할 점이 @EableCaching이 붙은 설정 클래스에서 등록을 해주어야 합니다.

@Configuration
@EnableCaching
public class CacheConfig {

    // `RedisCacheManager`는 CacheManager`의 자식 클래스로 Redis를 이용하여 캐싱을 수행 할 것이기에 해당 클래스를 Bean으로 등록.
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration
            .defaultCacheConfig()
            // 캐시 값에 Null 허용.
            .disableCachingNullValues()
            // TTL(Time to Live)를 120초 설정.
            .entryTtl(Duration.ofSeconds(120))
            // 캐시에 붙을 prefix를 설정. `CacheKeyPrefix.simple()`의 경우 `::`로 설정.
            .computePrefixWith(CacheKeyPrefix.simple())
            // 캐시에 저장할 값을 어떻게 직렬화 할 것인지. RedisSerializer.java() 사용.
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.java())
            );

        return RedisCacheManager
            .builder(connectionFactory)
            // 모든 캐시에 기본적으로 저장.
            .cacheDefaults(config)
            .build();
    }
}

위와 같이 CacheManger 인스턴스를 만들어 Bean으로 등록해주면 됩니다.

@Cacheable

해당 어노테이션을 통해 데이터를 캐시에 저장하거나 읽어옵니다. 다음 메서드를 봅시다.

@Cacheable(cacheNames = "itemCache", key = "args[0]")
public ItemDto readOne(Long id) {
    log.info("Read One: {}", id);
    return itemRepository.findById(id)
        .map(ItemDto::fromEntity)
        .orElseThrow(() ->
            new ResponseStatusException(HttpStatus.NOT_FOUND));
}

id를 통해 Respository에서 단건 조회를 해오는 메서드입니다. 해당 메서드에 @Cacheable를 붙여주었습니다.

  • cacheNames: 캐시의 이름을 지정해줍니다. 앞서 캐시 설정에서 지정한 prefix인 ::와 합쳐져 name::key를 키로 캐시가 저장됩니다.
  • key: name::key에서 key에 해당할 값을 지정해줍니다. args[0]을 하면 매개변수 중 첫번째 값이 되고 #id와 같이 매개변수의 명을 활용할 수도 있습니다.

이 어노테이션의 정확한 동작은 다음과 같습니다. 주어진 파라미터로 캐시를 조회하고 해당 값이 존재하면 메서드를 실행하지 않고 캐싱된 값을 반환, 해당 값이 존재하지 않으면 메서드를 실행하고 결과를 캐시에 저장한 후 반환합니다.

해당 메서드로 캐싱을 수행하면 Redis에 다음과 같이 저장이 됩니다.

@CachePut

해당 어노테이션은 메서드의 실행 결과를 항상 캐시에 저장하고 이미 캐싱된 값이 있다면 덮어씌웁니다.

@CachePut(cacheNames = "itemCache", key = "#result.id")
public ItemDto create(ItemDto dto) {
    return ItemDto.fromEntity(itemRepository.save(Item.builder()
        .name(dto.getName())
        .description(dto.getDescription())
        .price(dto.getPrice())
        .build()));
}

위 메서드는 데이터를 DB에 저장하는 함수입니다. 즉, 해당 메서드의 결과가 항상 캐시에 저장되어야 합니다. 이런 경우에 @CachePut를 사용합니다.

key의 값을 #result.id 지정해주었습니다. 여기서 #result는 메서드의 반환 값이고 그 값 객체의 id를 key로 지정한다는 뜻입니다.

@CacheEvict

캐시에서 값을 제거하는 어노테이션입니다.

@CacheEvict(cacheNames = "itemCache", key = "args[0]")
public void delete(Long id) {
    itemRepository.deleteById(id);

마찬가지로 cacheNameskey를 제공 받아 해당하는 캐시를 제거합니다. DB에서 데이터가 지워지면 역시나 캐시에서도 지워져야 함으로 해당 메서드에 붙여주었습니다.

DB에 데이터를 업데이트 하는 경우 다른 어노테이션과 조합하여 다음과 같이 활용할 수 있습니다.

@CachePut(cacheNames = "itemCache", key = "args[0]")
@CacheEvict(cacheNames = "itemAllCache", allEntries = true)
public ItemDto update(Long id, ItemDto dto) {
    Item item = itemRepository.findById(id)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    item.setName(dto.getName());
    item.setDescription(dto.getDescription());
    item.setPrice(dto.getPrice());
    return ItemDto.fromEntity(itemRepository.save(item));
}

itemAllCache는 모든 아이템 리스트에 대한 캐시입니다.

만약 DB에서 한 아이템에 대한 값이 업데이트가 된다면 역시나 캐시에서도 해당 값을 @CachePut으로 업데이트 해주어야 합니다.

그리고 itemAllCache 역시 업데이트 되기 전 값을 포함한 리스트가 캐싱되어 있으므로 새로 갱신이 필요하기에 @CacheEvict를 통해 지워줍니다. 이후에 다시 아이템 리스트에 대한 요청이 들어오면 새로운 리스트가 캐시에 갱신 될 것입니다.

페이지 캐싱

페이지네이션이 적용되어있을 경우, page와 size 마다 캐싱이 이루어져야 합니다. 또한 검색 기능의 경우 검색어에 따라서도 캐싱이 개별로 이루어져야 합니다. 이럴 경우 다음과 같이 여러 값을 key로 지정해 구현할 수 있습니다.

@Cacheable(
    cacheNames = "itemSearchCache",
    key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
)
public Page<ItemDto> searchByName(String query, Pageable pageable) {
    return itemRepository.findAllByNameContains(query, pageable).map(ItemDto::fromEntity);
}

검색어로 받은 queryargs[0]을 통해, 페이지네이션을 위해 받은 Pageable 객체를 args[1]을 통해 Pageable 객체의 pageNumber와 pageSize를 Key로 활용해주었습니다.

만약 query가 0이고 pageNumber이 0이고 pageSize이 20이면 다음과 같이 캐싱이 됩니다.

profile
응애 개발자입니다.

0개의 댓글