Redis 캐싱 - SpringBoot에서 사용해보기

StrayCat·2026년 3월 18일

Spring Cache 추상화란?

Spring은 캐싱 로직을 비즈니스 코드에서 분리할 수 있도록 Cache Abstraction(캐시 추상화) 을 제공한다.
핵심 아이디어는 간단하다 — 어노테이션을 지정해주면 메서드의 결과가 자동으로 캐시에 저장되고 꺼내진다.

  • 비즈니스 로직에 캐싱 코드가 섞이지 않는다 (관심사 분리, SoC)
  • Redis, Caffeine, EhCache 등 구현체를 교체해도 코드 변경이 최소화된다
  • AOP(Aspect-Oriented Programming) 기반으로 동작하므로 메서드 호출을 가로채서 캐시 처리를 수행한다

Spring Cache는 특정 캐싱 라이브러리가 아니라, 다양한 캐시 구현체를 동일한 방식으로 다룰 수 있게 해주는 인터페이스(추상화 계층) 이다.



의존성 설정

build.gradle 기준으로, Spring Data Redis 스타터 하나면 Spring Cache와 Redis 연동이 모두 준비된다.

// Spring Data Redis + Lettuce 클라이언트 포함
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// Spring Cache 추상화 (위 starter에 포함되어 있으나, 명시적으로 쓰는 경우도 있음)
// implementation 'org.springframework.boot:spring-boot-starter-cache'

spring-boot-starter-data-redis를 추가하면 Lettuce 클라이언트가 기본으로 포함되며,
spring-boot-starter-cache는 별도로 추가하지 않아도 Spring Cache 어노테이션이 동작한다.



@EnableCaching과 CacheConfig

Spring Cache를 활성화하려면 @EnableCaching 어노테이션을 @Configuration 클래스에 붙여야 한다.
이 어노테이션이 없으면 @Cacheable 등의 어노테이션을 붙여도 아무것도 일어나지 않는다.

// CacheConfig.java
@Configuration
@EnableCaching  // 어노테이션 기반 캐싱을 활성화하는 핵심 어노테이션
public class CacheConfig {
    // CacheManager Bean이 여기에 정의됨 (아래에서 이어서 작성)
}

@EnableCaching은 내부적으로 AOP 프록시를 설정하고,
CacheManager 구현체(여기서는 RedisCacheManager)가 Bean으로 등록되어 있어야 실제로 동작한다.


CacheManager 설정

RedisCacheManager는 Redis를 캐시 저장소로 사용할 때 Spring Cache와 Redis를 연결해주는 핵심 컴포넌트다.

// CacheConfig.java — RedisCacheManager 설정 전체 예시
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.cache.annotation.EnableCaching;
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.RedisSerializer;
import java.time.Duration;

import static org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(
        RedisConnectionFactory redisConnectionFactory  // application.yml에 설정된 Redis 연결 정보를 가져옴
    ) {
        RedisCacheConfiguration configuration = RedisCacheConfiguration
            .defaultCacheConfig()
            // null 값은 캐싱하지 않음 — NullPointerException 방지 및 불필요한 메모리 낭비 제거
            .disableCachingNullValues()
            // TTL(Time To Live): 캐시 엔트리가 자동 만료되는 시간 설정
            .entryTtl(Duration.ofSeconds(60))
            // 캐시 키 앞에 "cacheName::" 형태의 접두사(prefix)를 자동으로 붙임
            .computePrefixWith(CacheKeyPrefix.simple())
            // 캐시에 저장할 값의 직렬화/역직렬화 방식 지정
            .serializeValuesWith(
                SerializationPair.fromSerializer(RedisSerializer.java())
                // 실제 환경에서는 아래 JSON 직렬화가 더 권장됨 (하단 직렬화 가이드 참고)
            );

        return RedisCacheManager
            .builder(redisConnectionFactory)
            .cacheDefaults(configuration)  // 위에서 구성한 설정을 기본값으로 등록
            .build();
    }
}

TTL vs TTI 차이

구분설명동작 방식
TTL (Time To Live)생성 시점부터 일정 시간 후 자동 삭제조회 여부와 관계없이 만료
TTI (Time To Idle)마지막 접근 시점부터 일정 시간 후 삭제자주 조회되는 항목은 유지

Spring Data Redis의 RedisCacheConfiguration은 현재 TTL만 공식 지원한다.
TTI는 Redis 자체 기능으로는 구현이 복잡하며, 별도 라이브러리 없이 Spring Cache에서 직접 지원하지는 않는다.



캐싱 어노테이션 3종 세트

Spring Cache의 핵심은 세 가지 어노테이션이다.

어노테이션동작주 사용 시점
@Cacheable캐시에 데이터가 있으면 반환, 없으면 메서드 실행 후 저장조회 (Read)
@CachePut항상 메서드를 실행하고 결과를 캐시에 저장/갱신생성/수정 (Create/Update)
@CacheEvict지정한 캐시 항목을 삭제수정/삭제 후 캐시 무효화

@Cacheable

메서드 결과를 캐시에 저장하고, 이후 동일한 키로 호출되면 메서드를 실행하지 않고 캐시에서 반환한다.
전형적인 Cache-Aside 패턴의 Read 측면이다.

// ItemService.java

// cacheNames: 이 캐시를 식별하는 논리적 이름 (Redis 키 접두사로 사용됨)
// key: 같은 캐시 안에서 개별 항목을 구분하는 기준값 — SpEL(Spring Expression Language) 사용
@Cacheable(cacheNames = "itemCache", key = "args[0]")
public ItemDto readOne(Long id) {
    log.info("DB에서 조회: {}", id);  // 캐시 HIT 시 이 로그는 출력되지 않음
    return repository.findById(id)
        .map(ItemDto::fromEntity)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

동작 흐름

첫 번째 호출 (id=1)
  → 캐시 MISS → DB 조회 → 결과를 "itemCache::1" 키로 Redis에 저장 → 반환

두 번째 호출 (id=1)
  → 캐시 HIT → Redis에서 바로 반환 (DB 조회 없음)

readAll()에 적용하는 경우, 인자가 없으므로 key를 메서드 이름으로 지정한다.

// 전체 목록 조회 — key를 메서드 이름으로 설정하여 단일 캐시 엔트리로 관리
@Cacheable(cacheNames = "itemAllCache", key = "methodName")
public List<ItemDto> readAll() {
    return repository.findAll()
        .stream()
        .map(ItemDto::fromEntity)
        .toList();
}

SpEL (Spring Expression Language) — 키 지정 방식

key 속성에는 SpEL 이라는 표현 언어를 사용한다.
메서드 인자, 반환값, 빈 속성 등을 동적으로 참조할 수 있다.

표현식의미
#id메서드 파라미터 이름이 id인 값
args[0]첫 번째 인자
#result.id메서드 반환 객체의 id 필드
methodName메서드 이름 문자열
{ args[0], args[1].pageNumber }여러 값을 조합한 복합 키

#파라미터명 방식이 args[0]보다 가독성이 좋아 일반적으로 선호된다.


@CachePut

항상 메서드를 실행하고 그 결과를 캐시에 저장한다. 캐시에 데이터가 있어도 실행을 건너뛰지 않는다.
데이터를 생성하거나 수정할 때 캐시도 함께 최신 상태로 유지하기 위해 사용한다 (Write-Through 전략).

// create() — 새 아이템 생성 후, 생성된 결과를 즉시 캐시에도 저장
// key = "#result.id" : 메서드가 반환하는 ItemDto의 id 필드를 키로 사용
@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())
            .stock(dto.getStock())
            .build()
    ));
}

중요한 포인트: Redis에 저장되는 키 형식이 cacheName::key 이므로,
create()가 저장한 itemCache::1readOne(id=1)이 찾는 itemCache::1동일한 키다.
따라서 create() 이후 readOne(1) 을 호출하면 DB를 거치지 않고 캐시에서 바로 반환된다.


@CacheEvict

캐시에 저장된 데이터를 무효화(삭제) 한다.
데이터가 변경되었을 때 오래된 캐시가 남아있지 않도록 정리하는 역할이다.

// update() — 특정 아이템 수정 시:
//   1. 해당 아이템의 단건 캐시를 최신 값으로 갱신 (@CachePut)
//   2. 전체 목록 캐시는 더 이상 유효하지 않으므로 전부 삭제 (@CacheEvict)
@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());
    item.setStock(dto.getStock());
    return ItemDto.fromEntity(itemRepository.save(item));
}

allEntries = true 는 해당 cacheNames 에 속하는 모든 키를 한 번에 삭제한다.
특정 키 하나만 삭제하려면 key = "#id" 처럼 지정하면 된다.

@CacheEvict에는 beforeInvocation 옵션도 있다.
기본값은 false(메서드 실행 후 삭제)이며, true로 설정하면 메서드 실행 전에 캐시를 삭제한다.
메서드 실행 중 예외가 발생해도 캐시를 확실히 지워야 할 때 사용한다.


검색 결과 캐싱

검색 기능에 캐싱을 적용하는 방법도 살펴보자.
핵심은 검색어 + 페이지 정보를 조합하여 복합 키를 만드는 것이다.

사전 준비: @EnableSpringDataWebSupport 추가

Page<T> 객체를 캐싱하려면, Page 직렬화 방식을 DTO 기반으로 설정해야 한다.

// RedisApplication.java
@SpringBootApplication
// Page 객체를 직렬화할 때 내부 구현체 대신 DTO 형태로 변환하도록 설정
// 이 설정 없이 Page를 캐싱하면 역직렬화 오류가 발생할 수 있음
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class RedisApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisApplication.class, args);
    }
}

Repository — Query Method 정의

public interface ItemRepository extends JpaRepository<Item, Long> {
    // 이름에 특정 문자열이 포함된 Item을 페이지 단위로 조회하는 JPA Query Method
    Page<Item> findAllByNameContains(String name, Pageable pageable);
}

Service — 복합 키로 캐싱

// 검색어(args[0])와 페이지 번호, 페이지 크기를 조합하여 복합 키 생성
// → 같은 검색어라도 페이지가 다르면 다른 캐시 엔트리로 저장됨
@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);
}

Controller — 검색 엔드포인트

// q 파라미터로 검색어를 받고, Pageable로 페이지 정보를 자동 바인딩
// 예시 요청: GET /items/search?q=노트북&page=0&size=10
@GetMapping("search")
public Page<ItemDto> search(
    @RequestParam(name = "q") String query,  // 검색어
    Pageable pageable                         // 페이지 정보 (Spring이 자동 바인딩)
) {
    return itemService.searchByName(query, pageable);
}

Redis에 저장되는 키 형태는 아래처럼 만들어진다.

itemSearchCache::[노트북, 0, 20]

직렬화 선택 가이드

예제 코드에서는 RedisSerializer.java() (JDK 기본 직렬화)를 사용했지만,
실제 개발 환경에서는 JSON 직렬화가 더 일반적으로 권장된다.

직렬화 방식장점단점
RedisSerializer.java() (JDK 직렬화)설정 없이 모든 Serializable 객체 처리Redis CLI로 읽을 수 없음, 클래스 변경 시 역직렬화 오류 위험
GenericJackson2JsonRedisSerializerJSON으로 저장되어 가독성 좋음, 타입 정보 포함@class 필드가 JSON에 포함됨, 제네릭 타입 처리 시 주의 필요
Jackson2JsonRedisSerializer특정 타입 전용으로 깔끔한 JSON타입마다 별도 인스턴스 필요, 타입 유연성 낮음

현재가장 많이 쓰이는 조합GenericJackson2JsonRedisSerializer다.

// 현재 개발 환경에서 권장되는 직렬화 설정
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

RedisCacheConfiguration configuration = RedisCacheConfiguration
    .defaultCacheConfig()
    .disableCachingNullValues()
    .entryTtl(Duration.ofSeconds(60))
    .computePrefixWith(CacheKeyPrefix.simple())
    .serializeValuesWith(
        // GenericJackson2JsonRedisSerializer: 타입 정보(@class)를 포함한 JSON으로 직렬화
        // redis-cli로 저장된 값을 직접 확인할 수 있어 디버깅에 유리함
        SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
    );

주의: GenericJackson2JsonRedisSerializer는 저장된 JSON에 "@class": "com.example.ItemDto" 같은
타입 정보를 함께 저장한다. 패키지명이 바뀌거나 클래스가 이동하면 역직렬화 시 오류가 발생하므로,
리팩토링 시 기존 캐시를 먼저 비워줘야 한다는 점을 기억하자.


정리

어노테이션 동작 비교

@Cacheable("cache")        → 캐시 HIT 시 메서드 실행 안 함 (조회 최적화)
@CachePut("cache")         → 항상 메서드 실행 + 캐시 갱신 (쓰기 동기화)
@CacheEvict("cache")       → 캐시 삭제 (무효화)

캐시 키 설계 원칙

  • 키는 충돌 없이 유일해야 한다. 동일 캐시 내에서 서로 다른 데이터가 같은 키를 가지면 데이터 오염이 발생한다.
  • 검색/목록 조회처럼 인자가 복합적일 때는 { args[0], args[1].pageNumber } 형태의 복합 키를 사용한다.
  • #파라미터명 방식이 args[N] 방식보다 가독성이 좋아 유지보수에 유리하다.

캐시 무효화 전략

단건 조회 캐시: @Cacheable → 수정 시 @CachePut, 삭제 시 @CacheEvict(key="#id")
전체 목록 캐시: 데이터 추가/수정/삭제 시 @CacheEvict(allEntries = true)

캐싱 적용 흐름 정리

1. CacheConfig 에서 RedisCacheManager 설정
   └─ TTL, 직렬화 방식, 키 접두사 등 전역 설정

2. Service 메서드에 어노테이션 부착
   ├─ 조회: @Cacheable
   ├─ 생성/수정: @CachePut
   └─ 삭제/무효화: @CacheEvict

3. Redis에 저장되는 키 구조: {cacheName}::{key}
   예) itemCache::1, itemSearchCache::[노트북, 0, 20]

레거시 환경에서의 주의사항

기존에 JdkSerializationRedisSerializer(기본 직렬화)로 운영 중인 시스템에서
직렬화 방식을 JSON으로 변경하면, 기존에 저장된 캐시를 읽지 못해 오류가 발생한다.
직렬화 방식 변경 시에는 반드시 캐시 플러시(FLUSHDB 또는 FLUSHALL)를 선행하거나,
키 네이밍 전략을 변경하여 기존 데이터와 충돌을 피해야 한다.

추가로 알아두면 좋은 것

  • @Caching: 하나의 메서드에 @CacheEvict 여러 개를 동시에 적용해야 할 때 사용하는 묶음 어노테이션
  • @CacheConfig: 클래스 레벨에 적용하여 cacheNames를 공통으로 지정, 메서드마다 반복 작성을 줄일 수 있음
  • Spring Boot Actuator + Redis 통합 시 /actuator/caches 엔드포인트로 현재 캐시 상태를 모니터링할 수 있음
  • 캐시별 TTL 차등 설정: RedisCacheManagerBuilderCustomizer 또는 withInitialCacheConfigurations()를 통해 캐시마다 다른 TTL을 부여할 수 있다
// 캐시별 TTL 차등 설정 예시
return RedisCacheManager.builder(connectionFactory)
    .cacheDefaults(defaultConfig)                                         // 기본 TTL
    .withInitialCacheConfigurations(Map.of(
        "itemCache",       defaultConfig.entryTtl(Duration.ofMinutes(10)), // 상품 캐시: 10분
        "itemAllCache",    defaultConfig.entryTtl(Duration.ofMinutes(5)),  // 전체 목록: 5분
        "itemSearchCache", defaultConfig.entryTtl(Duration.ofMinutes(3))   // 검색 결과: 3분
    ))
    .build();

참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글