Redis 캐시1 - RedisCacheManager (@Cacheable) 사용하기

2025. 8. 16. 20:26·공부일기../Spring

Redis를 활용한 캐시 저장소 구현 방법은 두가지가있다.

  1. RedisCacheManager
  2. RedisTemplate

우선 RedisCacheManager을 활용하는 법에대해 포스팅한다.

 

1. RedisCacheManager 특징

  • 선언적 캐시: 메서드에 어노테이션만 붙이면 끝. 로직 변경 거의 없음.
  • 일관된 설정: TTL, 직렬화, null 캐싱 여부를 캐시별로 중앙 설정 가능.
  • 스프링 표준: @Cacheable, @CacheEvict, @CachePut로 읽기/무효화/강제갱신 패턴을 공통화.

 

1-1. @Cacheable - 읽기 캐시

캐시 미스일 경우만 실제 메서드를 실행한다 -> 성공결과를 캐시에 저장

  • value 또는 cacheNames: 캐시 이름
  • key: SpEL로 캐시 키 지정 (예: "'v1:last3d'", "#userId")
  • sync=true: 미스 시 1스레드만 계산(JVM 한 대 기준). 스탬피드 방지용
  • condition: 캐싱 전 조건 (예: condition="#userId != null")
  • unless: 캐싱 후 조건(결과 기반) (예: unless="#result == null")
@Cacheable(value = "popularTop5", key = "'v1:last3d'", sync = true, unless = "#result == null")
public List<PopularProductInfo> getTopFivePopularProducts() { ... }

 

1-2. @CacheEvict - 무효화(삭제)

메서드 실행 시점에 캐시를 비우고 싶을때

  • key: 특정 키만 지움
  • allEntries=true: 해당 캐시 이름의 모든 엔트리 삭제
  • beforeInvocation=true: 메서드 실행 전 삭제(기본은 실행 후)
// 주문 확정 시 인기상품 캐시 1건만 무효화
@CacheEvict(value = "popularTop5", key = "'v1:last3d'")
public void confirmOrder(Order order) { ... }

 

1-3. @CachePut - 강제 갱신 (쓰기-스루)

메서드 결과를 항상 캐시에 반영하구싶을때 (Miss/ Hit 상관없이)

메서드 결과가 그대로 캐시에 들어감 반환타입 / 키 설계 정확히 해야한다.

@CachePut(value = "config", key = "#code")
public Config updateConfig(String code, Config newValue) { return newValue; }

 

2. 구현

2-1. RedisConfig파일을 설정한다.

나는 인기상품에 대해서만 캐시를 적용했기때문에 캐시 TTL을 한번에 25h로 설정했다. 

다르게 설정하려면 키 별로 다르게 map에 저장해도 될것같다. 

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory){
        //@캐시 어노테이션
        GenericJackson2JsonRedisSerializer jacksonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());

        //레디스 캐시 설정
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(25)) //ttl
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer.UTF_8))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jacksonSerializer));

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

    //Jackson 라이브러리에서 JSON 직렬화 및 역직렬화를 담당
    private ObjectMapper objectMapper() {
        return new ObjectMapper()
                .activateDefaultTyping(LaissezFaireSubTypeValidator.instance, //타입검증
                        ObjectMapper.DefaultTyping.NON_FINAL);
    }

}

 

2-2.사용하는 코드

최근 3일 동안 가장 많이 팔린 상품을 조회하기 위해 해당 레포지토리레서 상품을 조회한다.

그 후 AtomicLong을 이용해서 dto에 랭킹을 저장한다. 

@Service
@RequiredArgsConstructor
public class ProductService {

    private final IProductRepository productRepository;
    private final IOrderRepository orderRepository;

	// 인기상품 조회 : 상위 5개
    // 최근 3일 Top5 고정이므로 캐시 키는 상수
    @Transactional(readOnly = true)
    @Cacheable(value = "popularTop5", key = "'v1:last3d' ,sync = true" )
    public List<PopularProductInfo> getTopFivePopularProducts() {
        List<PopularProductQuery> productQueries = orderRepository.findTopFivePopularProducts();
        AtomicLong rank = new AtomicLong(1);
        return productQueries.stream()
                .map(query -> query.toInfo(rank.getAndIncrement()))
                .toList();
    }
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepositoryImpl implements OrderQueryRepository {
    private final JPAQueryFactory queryFactory;

    @Override
    public List<PopularProductQuery> findTopFivePopularProducts() {
        return queryFactory
                .select(Projections.constructor(PopularProductQuery.class,
                        product.id,
                        product.name,
                        product.price,
                        orderItem.quantity.sum()
                ))
                .from(orderItem)
                .join(orderItem.product, product)
                .where(orderItem.createdAt.after(LocalDateTime.now().minusDays(3)))
                .groupBy(product.id, product.name, product.price)
                .orderBy(orderItem.quantity.sum().desc())
                .limit(5)
                .fetch();
    }
}

 

3. 스템피드 방지

캐시 스템피드 : 

TTL만료나 처음조회(=-캐시미스)일경우 같은 데이터를 여러요청이 동시에 달려들어 전부 원본(DB,혹은 api호출)을 호출하는 현상

  1. TTL동시 만료 : 같은 키가 같은 시각에 만료 -> 동시에 캐시 미스
  2. Cold Start : 배포/스케일아웃 직후 캐시가 비어있는 상태에서 트래픽유입
  3. 핫 키 : 특정 키에만 트래픽 집중

 

3-1. @Cacheable(..., sync = false) (기본값)

같은 결과를 여러번 계산한다 -> DB부하 폭증, 지연증가

t0: 요청 A, B, C, D, E → 전부 캐시 미스
    → 전부 orderRepository.findTopFivePopularProducts() 실행 (중복 계산 N번)
t1: 가장 먼저 끝난 스레드가 캐시에 넣음
t2: 나머지는 이미 늦음. 어쨌든 DB/쿼리 N번 실행 완료

3-2. @Cacheable(..., sync = true)

계산은 1번만 하고 나머지는 기다렸다가 캐시 히트로 처리한다.

t0: 요청 A, B, C, D, E → 전부 캐시 미스
    → A만 실제 메서드 실행. B~E는 A가 끝날 때까지 대기(같은 키 기준)
t1: A가 결과를 캐시에 넣음
t2: B~E는 캐시에서 바로 읽어 응답

 

중요! 

하 지만 `sync = true`의 잠금범위는 JVM 내부 = 즉 인스턴스 1개 단일 환경에서만 가능하다

서버가 여러대라면 서버 A의 대기는 서버 B에게 공유되지않고 여전히 서버 수만큼 중복계산을 한다. 

-> 다중 인스턴스 환경에서 완전히 방지하기 위해서는 다른 방안 고민..! 

 

4. 무효화 전략

캐시는 결국 원본 데이터베이스의 복사본이다.

원본데이터가 바뀌었는데 캐시가 그대로이면 데이터 정합성에 문제가 생긴다

그렇기 때문에 캐시를 언제 없앨지 무효화 전략이 꼭! 필요하다.

 

우선 인기상품의 경우는 실시간 집계가 아니기때문에 캐시값을 자주 바꿀 필요는 없다.

 

베스트 인기상품은 지난 3일이 고정이기때문에 캐시 주기적 갱신 혹은 TTL만료로 충분하다.

하지만 내 코드의 경우 누가 인기상품을 조회했을 경우만 갱신이 된다.

 

4-1. 주문 즉시 무효화하려면 

해당 어노테이션을 주문 확정 시점에 붙히면 해당 캐시를 즉시 삭제 할 수있다.

이렇게하면 주문이 발생할때마다 캐시가 지워지고 다음번 인기상품 조회api호출 할때마다 새로 계산해서 캐시를 다시 채운다.

 @CacheEvict(value="popularTop5", key="'v1:last3d'")

 

4-2. 비동기 갱신 (캐시워밍 pre-warm)

`@CacheEvict`을 붙혀 캐시를 계속 삭제하는 방식은 다음 사용자가 요청할때까지 빈 캐시라 다음요청이 느려질수있다(쿼리새로돌리니까)

그래서 주문 확정 후 , 백그라운드에서 인기상품을 재계산해서 캐시에 다시 넣어둘수있다.

이렇게하면 다음 요청자는 느리지않게 따끈한..캐시를 받을수있음!

 

5. 내 코드 문제점

캐시 TTL이 25시간이기때문에

23:30에 인기상품을 조회하면 23:40분에 주문한 데이터는 다음날 인기상품에 반영이 안된다..

 

6. 개선~

템플릿을 활용해서 캐시 자료구조로 리팩토링 하도록한다!

'공부일기.. > Spring' 카테고리의 다른 글

[Redis] Redis 동작원리 : 속도 빠른이유  (4) 2025.08.21
Redis 활용시 직렬화/역직렬화를 해야하는 이유 (ObjectMapper)  (4) 2025.08.17
[Spring Boot] 게시판 만들기③ | EC2에 배포하고 실행까지 따라하기  (0) 2025.07.06
[Spring Boot 게시판 ] JWT 로그인 구현 하기  (0) 2025.07.01
[Spring Boot 게시판 ②] CRUD API , Postman 테스트(4/4)  (0) 2025.06.24
'공부일기../Spring' 카테고리의 다른 글
  • [Redis] Redis 동작원리 : 속도 빠른이유
  • Redis 활용시 직렬화/역직렬화를 해야하는 이유 (ObjectMapper)
  • [Spring Boot] 게시판 만들기③ | EC2에 배포하고 실행까지 따라하기
  • [Spring Boot 게시판 ] JWT 로그인 구현 하기
s0-0mzzang
s0-0mzzang
공부한것을 기록합니다...
  • s0-0mzzang
    승민이의..개발일기..🐰
    s0-0mzzang
  • 전체
    오늘
    어제
    • 전체~ (108)
      • 마음가짐..! (10)
      • 공부일기.. (76)
        • weekly-log (6)
        • Spring (19)
        • Java (18)
        • DataBase (10)
        • git (2)
        • JPA (6)
        • kafka (1)
        • Backend Architecture (3)
        • Troubleshooting (삽질..ㅋ) (2)
        • Cloud (1)
        • Docker (2)
        • 알고리즘 (1)
        • 리액트 (2)
        • Infra (3)
      • 하루일기.. (22)
        • 그림일기 (8)
        • 생각일기 (14)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    JPA
    인프라 기초
    자바
    스프링부트
    StringTokenizer
    swagger
    SpringBoot
    리팩토링
    spring
    MySQL
    ADC 환경
    항해플러스
    항해99
    다짐
    spring boot
    BufferedReader
    Paging
    TDD
    ERD
    React
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
s0-0mzzang
Redis 캐시1 - RedisCacheManager (@Cacheable) 사용하기
상단으로

티스토리툴바